├── .coveragerc ├── .github └── workflows │ ├── pypi-publish.yml │ └── python-package.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── README.md ├── docs ├── coverage.svg ├── mkdocs.yml ├── pages │ ├── base │ │ ├── commands.md │ │ ├── consoles.md │ │ ├── index.md │ │ └── models.md │ ├── classes │ │ ├── command.md │ │ ├── console.md │ │ ├── datastore.md │ │ ├── entity.md │ │ ├── index.md │ │ └── module.md │ ├── css │ │ └── extra.css │ ├── design.md │ ├── examples │ │ └── dronesploit.md │ ├── img │ │ ├── class-hierarchy.png │ │ ├── classes.png │ │ ├── command-key-completion.png │ │ ├── command-validation.png │ │ ├── command-value-completion.png │ │ ├── console-prompt.png │ │ ├── dronesploit.png │ │ ├── icon.png │ │ ├── logo.png │ │ ├── my-sploit-start.png │ │ ├── packages.png │ │ └── under-construction.png │ ├── index.md │ └── quickstart.md └── requirements.txt ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── src └── sploitkit │ ├── VERSION.txt │ ├── __info__.py │ ├── __init__.py │ ├── __main__.py │ ├── base │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── general.py │ │ ├── module.py │ │ ├── project.py │ │ ├── recording.py │ │ ├── root.py │ │ ├── session.py │ │ └── utils.py │ ├── config.conf │ └── models │ │ ├── __init__.py │ │ ├── notes.py │ │ ├── organization.py │ │ ├── systems.py │ │ └── users.py │ └── core │ ├── __init__.py │ ├── application.py │ ├── command.py │ ├── components │ ├── __init__.py │ ├── completer.py │ ├── config.py │ ├── defaults.py │ ├── files.py │ ├── jobs.py │ ├── layout.py │ ├── logger.py │ ├── recorder.py │ ├── sessions.py │ ├── store.py │ └── validator.py │ ├── console.py │ ├── entity.py │ ├── model.py │ └── module.py ├── tests ├── __utils__.py ├── test_base.py ├── test_components.py ├── test_console.py ├── test_entity.py ├── test_model.py ├── test_module.py └── test_scenarios.py └── testsploit ├── README ├── commands └── commands.py ├── main.py ├── modules └── modules.py ├── requirements.txt └── workspace └── store.db /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | cover_pylib = false 3 | source = sploitkit 4 | omit = 5 | */site-packages/* 6 | tests/* 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | class IPAddressField 12 | class MACAddressField 13 | class Trigger 14 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will deploy the Python package to PyPi.org 2 | 3 | name: deploy 4 | 5 | env: 6 | package: sploitkit 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | paths: 13 | - '**/VERSION.txt' 14 | workflow_run: 15 | workflows: ["build"] 16 | types: [completed] 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | - name: Cleanup README 27 | run: | 28 | sed -ri 's/^(##*)\s*:.*:\s*/\1 /g' README.md 29 | awk '{if (match($0,"## Supporters")) exit; print}' README.md > README 30 | mv -f README README.md 31 | - run: python3 -m pip install --upgrade build && python3 -m build 32 | - name: Upload ${{ env.package }} to PyPI 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | with: 35 | password: ${{ secrets.PYPI_API_TOKEN }} 36 | verbose: true 37 | verify_metadata: false 38 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: build 5 | 6 | env: 7 | package: sploitkit 8 | 9 | on: 10 | push: 11 | branches: [ "main" ] 12 | pull_request: 13 | branches: [ "main" ] 14 | 15 | jobs: 16 | build: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest] 22 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install pandoc 30 | run: sudo apt-get install -y pandoc 31 | - name: Install ${{ env.package }} 32 | run: | 33 | python -m pip install --upgrade pip 34 | python -m pip install pytest pytest-cov pytest-pythonpath coverage 35 | pip install -r requirements.txt 36 | pip install . 37 | - name: Test ${{ env.package }} with pytest 38 | run: | 39 | pytest --cov=$package 40 | coverage: 41 | needs: build 42 | runs-on: ubuntu-latest 43 | env: 44 | cov_badge_path: docs/coverage.svg 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: Set up Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: "3.10" 51 | - name: Install pandoc 52 | run: sudo apt-get install -y pandoc notification-daemon 53 | - name: Install ${{ env.package }} 54 | run: | 55 | python -m pip install --upgrade pip 56 | python -m pip install pytest pytest-cov pytest-pythonpath 57 | pip install -r requirements.txt 58 | pip install . 59 | - name: Make coverage badge for ${{ env.package }} 60 | run: | 61 | pip install genbadge[coverage] 62 | pytest --cov=$package --cov-report=xml 63 | genbadge coverage -i coverage.xml -o $cov_badge_path 64 | - name: Verify Changed files 65 | uses: tj-actions/verify-changed-files@v17 66 | id: changed_files 67 | with: 68 | files: ${{ env.cov_badge_path }} 69 | - name: Commit files 70 | if: steps.changed_files.outputs.files_changed == 'true' 71 | run: | 72 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 73 | git config --local user.name "github-actions[bot]" 74 | git add $cov_badge_path 75 | git commit -m "Updated coverage.svg" 76 | - name: Push changes 77 | if: steps.changed_files.outputs.files_changed == 'true' 78 | uses: ad-m/github-push-action@master 79 | with: 80 | github_token: ${{ secrets.github_token }} 81 | branch: ${{ github.ref }} 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temp files 2 | *~ 3 | *.backup 4 | .DS_Store 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | .build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | update.sh 27 | reinstall.sh 28 | version.py 29 | 30 | var/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | MANIFEST 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *,cover 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | # Project artifacts 67 | .idea 68 | .vagrant 69 | .test 70 | .pytest_cache 71 | tmp 72 | TODO 73 | script.py 74 | tool.py 75 | testsploit/workspace/* 76 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | mkdocs: 9 | configuration: docs/mkdocs.yml 10 | 11 | python: 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

SploitKit Tweet

3 |

Make a Metasploit-like console.

4 | 5 | [![PyPi](https://img.shields.io/pypi/v/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) 6 | [![Read The Docs](https://readthedocs.org/projects/python-sploitkit/badge/?version=latest)](https://python-sploitkit.readthedocs.io/en/latest/?badge=latest) 7 | [![Build Status](https://github.com/dhondta/python-sploitkit/actions/workflows/python-package.yml/badge.svg)](https://github.com/dhondta/python-sploitkit/actions/workflows/python-package.yml) 8 | [![Coverage Status](https://raw.githubusercontent.com/dhondta/python-sploitkit/main/docs/coverage.svg)](#) 9 | [![Python Versions](https://img.shields.io/pypi/pyversions/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) 10 | [![Known Vulnerabilities](https://snyk.io/test/github/dhondta/python-sploitkit/badge.svg?targetFile=requirements.txt)](https://snyk.io/test/github/dhondta/python-sploitkit?targetFile=requirements.txt) 11 | [![License](https://img.shields.io/pypi/l/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) 12 | 13 | 14 | This toolkit is aimed to easilly build framework consoles in a Metasploit-like style. It provides a comprehensive interface to define CLI commands, modules and models for its storage database. 15 | 16 | ``` 17 | pip install sploitkit 18 | ``` 19 | 20 | ## :sunglasses: Usage 21 | 22 | From this point, `main.py` has the following code: 23 | 24 | ```python 25 | #!/usr/bin/python3 26 | from sploitkit import FrameworkConsole 27 | 28 | 29 | class MySploitConsole(FrameworkConsole): 30 | #TODO: set your console attributes 31 | pass 32 | 33 | 34 | if __name__ == '__main__': 35 | MySploitConsole( 36 | "MySploit", 37 | #TODO: configure your console settings 38 | ).start() 39 | ``` 40 | 41 | And you can run it from the terminal: 42 | 43 | ![](https://github.com/dhondta/python-sploitkit/tree/main/docs/pages/img/my-sploit-start.png) 44 | 45 | ## :ballot_box_with_check: Features 46 | 47 | Sploitkit provides a base set of entities (consoles, commands, modules, models). 48 | 49 | Multiple base console levels already exist (for detailed descriptions, see [the console section](../console/index.html)): 50 | 51 | - [`FrameworkConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/console.py): the root console, started through `main.py` 52 | - [`ProjectConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/project.py): the project console, for limiting the workspace to a single project, invoked through the `select [project]` command 53 | - [`ModuleConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/module.py): the module console, started when a module is invoked through the `use [module]` command 54 | 55 | This framework provides more than 20 base commands, distributed in sets of functionalities (for detailed descriptions, see [the command section](../command/index.html)): 56 | 57 | - [*general*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/general.py): commands for every level (e.g. `help`, `show`, `set`) 58 | - [*module*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/module.py): base module-level commands (e.g. `use`, `run`, `show`) 59 | - [*project*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/project.py): base project-level commands (e.g. `select`, `load`, `archive`) 60 | - [*recording*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/recording.py): recording commands, for managing `.rc` files (`record`, `replay`) 61 | - [*root*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/root.py): base root-level commands (`help`) 62 | - [*utils*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/utils.py): utility commands (`shell`, `pydbg`, `memory`) 63 | 64 | It also holds some base models for its storage: 65 | 66 | - [*users*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/notes.py): for user-related data (`User`, `Email`, `Password`) 67 | - [*systems*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/systems.py): for system-related data (`Host`, `Port`, `Service`) 68 | - [*organization*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/organization.py): for organization-related data (`Organization`, `Unit`, `Employee`) 69 | - [*notes*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/notes.py): for linking notes to users, hosts or organizations 70 | 71 | No module is provided with the framework as it is case-specific. 72 | 73 | ## :pencil2: Customization 74 | 75 | Sploitkit defines multiple types of entities for various purposes. The following entities can be subclassed: 76 | 77 | - [`Console`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/console.py): a new console for a new level of interaction (e.g. [`ProjectConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/project.py)) ; the "`root`" level is owned by the [`FrameworkConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/console.py), [`Console`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/console.py) shall be used to create new subconsoles, to be called by commands from the root console (see an example [here](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/module.py) for the module-level commands with [`ModuleConsole(Console)` and `Use(Command)`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/module.py)) 78 | - [`Command`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/command.py): a new command associated with any or defined consoles using the `level` attribute 79 | - [`Module`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/module.py): a new module associated to a console 80 | - [`Model`, `BaseModel`, `StoreExtension`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/model.py): respectively for new models, their association tables and store additional methods (e.g. [`User(Model)`, `Email(Model)`, `UserEmail(BaseModel)`, `UsersStorage(StoreExtension)`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/users.py)) 81 | 82 | 83 | ## :clap: Supporters 84 | 85 | [![Stargazers repo roster for @dhondta/python-sploitkit](https://reporoster.com/stars/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/stargazers) 86 | 87 | [![Forkers repo roster for @dhondta/python-sploitkit](https://reporoster.com/forks/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/network/members) 88 | 89 |

Back to top

90 | -------------------------------------------------------------------------------- /docs/coverage.svg: -------------------------------------------------------------------------------- 1 | coverage: 53.73%coverage53.73% -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_author: dhondta 2 | site_name: "SploitKit - Devkit for building Metasploit-like consoles" 3 | repo_url: https://github.com/dhondta/python-sploitkit 4 | copyright: Copyright © 2019-2023 Alexandre D'Hondt 5 | docs_dir: pages 6 | nav: 7 | - Introduction: index.md 8 | - 'Getting started': quickstart.md 9 | - Design: design.md 10 | - Classes: 11 | - classes/index.md 12 | - Entity: classes/entity.md 13 | - Console: classes/console.md 14 | - Command: classes/command.md 15 | - Module: classes/module.md 16 | - Datastore: classes/datastore.md 17 | - 'Base entities': 18 | - base/index.md 19 | - Consoles: base/consoles.md 20 | - Commands: base/commands.md 21 | - Models: base/models.md 22 | - 'Real-life examples': 23 | - 'DroneSploit': examples/dronesploit.md 24 | extra: 25 | generator: false 26 | social: 27 | - icon: fontawesome/solid/paper-plane 28 | link: mailto:alexandre.dhondt@gmail.com 29 | name: Contact Alex 30 | - icon: fontawesome/brands/github 31 | link: https://github.com/dhondta 32 | name: Alex on GitHub 33 | - icon: fontawesome/brands/linkedin 34 | link: https://www.linkedin.com/in/alexandre-d-2ab2aa14/ 35 | name: Alex on LinkedIn 36 | - icon: fontawesome/brands/twitter 37 | link: https://twitter.com/alex_dhondt 38 | name: Alex on Twitter 39 | extra_css: 40 | - css/extra.css 41 | theme: 42 | name: material 43 | palette: 44 | - scheme: default 45 | toggle: 46 | icon: material/brightness-7 47 | name: Switch to dark mode 48 | - scheme: slate 49 | toggle: 50 | icon: material/brightness-4 51 | name: Switch to light mode 52 | features: 53 | - navigation.indexes 54 | - navigation.top 55 | - toc.integrate 56 | logo: img/logo.png 57 | favicon: img/icon.png 58 | use_directory_urls: false 59 | markdown_extensions: 60 | - admonition 61 | - codehilite: 62 | linenums: true 63 | - pymdownx.details 64 | - pymdownx.superfences 65 | - toc: 66 | permalink: true 67 | -------------------------------------------------------------------------------- /docs/pages/base/commands.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/base/commands.md -------------------------------------------------------------------------------- /docs/pages/base/consoles.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/base/consoles.md -------------------------------------------------------------------------------- /docs/pages/base/index.md: -------------------------------------------------------------------------------- 1 | This section presents the entities provided with Sploitkit. 2 | -------------------------------------------------------------------------------- /docs/pages/base/models.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/base/models.md -------------------------------------------------------------------------------- /docs/pages/classes/command.md: -------------------------------------------------------------------------------- 1 | *Commands* are associated to *consoles* through the `level` attribute and are evaluated in the REPL of a console using their `run(...)` method. Completion and validation can be tuned using appropriate methods like explained hereafter. 2 | 3 | Note that they are two possible formats : `COMMAND VALUE` or `COMMAND KEY VALUE`. 4 | 5 | ## Styling 6 | 7 | It is possible to define the style of *commands*, that is, how the class name is rendered when called in a *console*. Currently, four styles are supported : 8 | 9 | **Name** | **Command class name** | **Rendered command name** 10 | --- | :---: | :---: 11 | *Lowercase* | `MyCommand` | `mycommand` 12 | *Powershell* | `MyCommand` | `My-Command` 13 | *Slugified* (default) | `MyCommand` | `my-command` 14 | *Uppercase* | `MyCommand` | `MYCOMMAND` 15 | 16 | Command styling can be set using the `set_style(...)` class method. 17 | 18 | ??? example "**Example**: Setting commands style" 19 | 20 | :::python 21 | from sploitkit import Command 22 | 23 | Command.set_style("powershell") 24 | 25 |
26 | 27 | ## Definition 28 | 29 | A *command* always subclasses the `Command` generic entity class and can be dissected as follows : 30 | 31 | 1. **Docstring** : This will be used for command's description in help messages (callable through the `help()` method). Note that this docstring is parsed like for any entity (as it is a feature of the `Entity` class), meaning that metadata fields will be parsed and stored in a `_metadata` class attribute. 32 | 2. **Class attributes** : They tune the applicability and nature of the command. 33 | 3. **Instance methods** : They define the logic of the command, i.e. `run()`. 34 | 35 | Here is the list of tunable class attributes : 36 | 37 | **Attribute** | **Type** | **Default** | **Description** 38 | --- | :---: | :---: | --- 39 | `aliases` | `list`(`str`) | `[]` | the list of aliases for the command 40 | `alias_only` | `bool` | `False` | whether only the aliases defined in the related list should be considered or also the converted command class name 41 | `applies_to` | `list`(`str`) | `[]` | a list of *modules* this command applies to 42 | `except_levels` | `list`(`str`) | `[]` | a list of non-applicable levels 43 | `keys` | `list`(`str`) or `dict` | `[]` | a list of possible keys or a dictionary of keys and associated values (this implies the second format with key-value) 44 | `level` | `str` | "`general`" | command's level ; "`general`" means that it applies to all console levels 45 | `single_arg` | `bool` | `False` | handle everything after the command as a single argument 46 | `values` | `list`(`str`) | 47 | 48 | 49 | ??? example "**Example**: Making a command in Powershell style with an alias and applicable to the *module* level" 50 | 51 | :::python 52 | from sploitkit import Command 53 | 54 | Command.set_style("powershell") 55 | 56 | class GetSomething(Command): 57 | """ Get something """ 58 | aliases = ["gs"] 59 | level = "module" 60 | [...] 61 | 62 |
63 | 64 | ## Completion 65 | 66 | Completion is defined according to the command format and the related method signature is adapted accordingly. So, if a command is value-only, it *can* own a `complete_values()` method with no argument. If a command has both a key and a value, it *can* own a `complete_keys()` method taking no argument and a `complete_values(key)` method that can be tuned according to the key entered in the incomplete command. 67 | 68 | By default, the `Command` class has both `complete_keys` and `complete_values` methods implemented, relying on the signature of the `run(...)` method to determine command's format. Completion is handled according to the format : 69 | 70 | - `COMMAND VALUE` : Then only `complete_values` is used, handling the `values` class attribute as a list. 71 | - `COMMAND KEY VALUE` : This one uses 72 | 73 | - `complete_keys`, handling the `keys` class attribute as a list in priority, otherwise the `values` class attribute as a dictionary whose keys are the equivalent to the `keys` class attribute 74 | - `complete_values`, handling the `values` class attribute as a dictionary whose values for the key given in argument (if not given, all the values aggregated from all the keys) give the completion list 75 | 76 | ??? example "**Example**: Default completion for key-values (second command format)" 77 | 78 | :::python 79 | class DoSomething(Command): 80 | values = {"key1": ["1", "2", "3"], 81 | "key2": ["4", "5", "6"], 82 | "key3": ["7", "8", "9"]} 83 | 84 | def run(self, key=None, value=None): 85 | print(key, value) 86 | 87 | This command will yield a completion list of : 88 | 89 | - `["key1", "key2", "key3"]` when entering "`do-something `" (or "`do-something `" and a part of the possible key, without a trailing whitespace) and pressing the tab key twice 90 | 91 | ![](/img/command-key-completion.png "Key completion") 92 | 93 | - `["4", "5", "6"]`when entering "`do-something key2 `" and pressing the tab key twice 94 | 95 | ![](/img/command-value-completion.png "Value completion") 96 | 97 |
98 | 99 | ## Validation 100 | 101 | Validation can be especially useful as, within the CLI application, an error is dynamically displayed while typing a command, relying on command's `validate()` method. Like the completion methods, this is defined according to the signature of the `run(...)` method. 102 | 103 | By default, the `Command` class has a `validate` method that relies on both `complete_keys` and `complete_values` methods to check inputs against valid keys and values. 104 | 105 | ??? example "**Example**: Key-value validation" 106 | 107 | According to the previous example, a validation error is raised as the given value is not part of the possible values for the given key : 108 | 109 | ![](/img/command-validation.png "Validation error") 110 | 111 |
112 | -------------------------------------------------------------------------------- /docs/pages/classes/console.md: -------------------------------------------------------------------------------- 1 | A *console* is a Read-Eval-Process-Loop (REPL) environment that holds a set of enabled commands, always starting from a root console. Each child console becomes bound to its parent when started so that it can also use its configuration settings. 2 | 3 | ## Components 4 | 5 | Basically, a console holds the central logic of the CLI through multiple components : 6 | 7 | - *Files Manager* : It manages files from the *WORKSPACE* (depending on the context, that is, the root level or another one setting the workspace elsewhere, e.g. as for a project). 8 | - *Global State* : It holds the key-values to be shared amongst the console levels and modules and their associated commands. 9 | - *Datastore* : It aims to persistently save data. 10 | - *Jobs Pool* : It manages jobs to be run from the console. 11 | - *Sessions Pool* : It manages the open sessions, obtained from the execution of *modules*. 12 | 13 | In order to make a custom console, two classes exist : 14 | 15 | - The generic `Console` class : for making child console levels. 16 | - The specific `FrameworkConsole` class : to be used directly or subclassed to define the root console. 17 | 18 | ??? example "**Example**: Basic application running a `FrameworkConsole`" 19 | 20 | :::python 21 | from sploitkit import FrameworkConsole 22 | 23 | if __name__ == '__main__': 24 | FrameworkConsole("MySploit").start() 25 | 26 |
27 | 28 | ## Scope and prompt 29 | 30 | A console can be tuned in the following way using some class attributes : 31 | 32 | - `level` : the console level name, for use with *commands* 33 | - `message` : a list of tokens with their styling, as of [`prompt_toolkit`](https://python-prompt-toolkit.readthedocs.io/en/master/pages/asking_for_input.html#coloring-the-prompt-itself) 34 | - `style` : the style definition as a dictionary for the prompt tokens 35 | 36 | ??? example "**Example**: A console subclass for defining a new level" 37 | 38 | :::python 39 | from sploitkit import Console 40 | 41 | class MyConsole(Console): 42 | level = "new_level" 43 | message = [ 44 | ('class:prompt', "["), 45 | ('class:name', "console"), 46 | ('class:prompt', "]>> "), 47 | ] 48 | style = { 49 | 'prompt': "#eeeeee", 50 | 'name': "#ff0000", 51 | } 52 | 53 | ![](/img/console-prompt.png "Prompt rendered") 54 | 55 |
56 | 57 | ## Entity sources 58 | 59 | Another important attribute of the `Console` class is `sources`. It is only handled for the parent console and is defined as a dictionary with three possible keys : 60 | 61 | - `banners` (default: `None`) : for customizing the startup application banner 62 | - `entities` : a list of source folders to be parsed for importing entities 63 | - `libraries` (default: "`.`") : a list of source folders to be added to `sys.path` 64 | 65 | ??? example "**Example**: Defining sources for banners, entities and libraries" 66 | 67 | :::python 68 | from sploitkit import FrameworkConsole 69 | 70 | class MyConsole(Console): 71 | ... 72 | sources = { 73 | 'banners': "banners", 74 | 'libraries': "lib", 75 | } 76 | 77 |
78 | -------------------------------------------------------------------------------- /docs/pages/classes/datastore.md: -------------------------------------------------------------------------------- 1 | ## Datastore 2 | 3 | 4 | ### `Model` 5 | 6 | 7 | ### `StoreExtension` 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/pages/classes/entity.md: -------------------------------------------------------------------------------- 1 | In order to provide a convenient API, Sploitkit defines a central class aimed to declare everything that can be tuned and imported to make a new CLI framework. As explained previously, this central class is called `Entity`. This holds the generic logic, namely for : 2 | 3 | - registering entity classes (see hereafter: `Console`, `Command`, ...) 4 | - handling requirements, dynamically enabling/disabling entities 5 | - handling metadata (formatting them for display in the CLI) 6 | 7 | ## Requirements and applicability 8 | 9 | Requirements can be defined in order to dynamically enable/disable entities. These are managed through the `requirements` class attribute. Currently, a few requirement types exist : 10 | 11 | **Key** | **Description** 12 | --- | --- 13 | `config` | Dictionary of `Config`/`Option` values to be set (see hereafter). 14 | `file` | List of files that must exist in the current workspace. 15 | `python` | List of Python packages required to be installed in the environment. 16 | `state` | State variables to be set in the *Global State* (see section *Console*) ; can be defined in three ways :
  1. List of existing keys, e.g. `['VAR1', 'VAR2']`.
  2. Dictionary of state variables (exact match), e.g. `{'VAR1': {'key1':'myval1', 'key2':'myval2'}}`.
  3. Dictionary of state values, regardless of the key, e.g. `{'VAR1': {None:'myval1'}}`.
17 | `system` | List of system tools and/or packages to be installed ; can be defined in two ways :
  1. `[tool]`, e.g. `ifconfig` ; if the system command is missing, it will only tell that this tool is not present.
  2. `[package]/[tool]`, e.g. `net-tools/ifconfig` ; this allows to be more precise regarding what is missing.
18 | 19 | In parallel with the requirements, the applicability is checked, that is, if the entity has a reference with a value that exactly matches the expected one. 20 | 21 | ??? example "**Example**: Setting a *command* as applicable only for *modules* named '`do_something`'" 22 | 23 | Let us consider defining a *command* that only applies to any *module* whose name is "`do_something`". Then defining the `applies_to` attribute like hereafter allows to limit the scope of the *command* to only *modules* named so. 24 | 25 | :::python 26 | class DoIt(Command): 27 | applies_to = [("console", "module", "name", "do_something")] 28 | [...] 29 | def run(self): 30 | [...] 31 | 32 |
33 | 34 | ## Inheritance and aggregation 35 | 36 | Entities can be defined in subclasses as a tree structure so that the leaves share some information from their proxy subclasses. The precedence goes bottom-up, that is, from the leaves to the entity classes. This is especially the case for : 37 | 38 | - `config` attribute (applies to *consoles* and *modules*) : Configurations are agreggated (in a `ProxyConfig` instance) so that an option that is common to multiple entities can be defined only once and modified for all these entities at once during the execution. 39 | - Metadata (especially useful for *modules*) : metadata is aggregated (during entity import only) so that, if multiple modules inheriting from a proxy class have, for instance, the same author, this data can be declared only once in the proxy class and applied to *modules*. 40 | 41 | ## Metadata parsing 42 | 43 | Metadata of entities can be defined in three different ways (can be combined, listed hereafter in inverse order of precedence) : 44 | 45 | 1. Docstring : By default, Sploitkit provides a parsing function that follows the convention presented hereafter, resulting in a dictionary of metadata key-values. However, a custom parsing function can be input as an argument when instantiating a parent `Console`. 46 | 2. `meta` attribute : A dictionary of metadata key-values that will update the final metadata dictionary. 47 | 3. `metadata` attribute : Same as for `meta` (exists for a question of cross-compatibility with plugins of other frameworks). 48 | 49 | This leads to a `_metadata` class attribute holding the metadata dictionary. Note that, when `meta` and `metadata` class attributes are use to update `_metadata`, they are removed to only hold this last one. This is mostly a question of compatibility with modules of other frameworks (e.g. Recon-ng). 50 | 51 | Options can even be defined through the `meta` and/or `metadata` class attributes (but NOT directly `_metadata` as it is created/overwritten when parsing the docstring). Their format follows this convention : (*name*, *default_value*, *required*, *description*). It contains less fields than what is really supported (see the `Option` class in the next subsection) but, once again, it is mostly a question of compatibility with modules from other frameworks. 52 | 53 | The default docstring format (parsed through a dedicated function within Sploitkit's utils) consists of sections separated by double newlines. Parsing occurs as follows : 54 | 55 | 1. The first section is always the *description*. 56 | 2. Next sections are handled this way : 57 | 58 | - If the first line of the section follows the convention hereafter, it is parsed as a separated field (saved in the metadata dictionary as lowercase) up to the next field-value OR section's end. 59 | 60 | [Field]: [value] 61 | 62 | That is, the field name capitalized with no whitespace before the colon and whatever value, multiline. 63 | 64 | - If the first line of the section does not follow the convention, it is parsed as a *comment* and saved into the *comments* list of the metadata dictionary. Note that using the field name *comments* append the value to the *comments* list of the metadata dictionary. 65 | 66 | ??? example "**Example**: Writing a docstring for an entity" 67 | 68 | :::python 69 | class Example(object): 70 | \""" 71 | This is a test multi-line long 72 | description. 73 | 74 | This is a first comment. 75 | 76 | Author: John Doe 77 | (john.doe@example.com) 78 | Version: 1.0 79 | Comments: 80 | - subcomment 1 81 | - subcomment 2 82 | 83 | Something: lorem ipsum 84 | paragraph 85 | 86 | This is a second comment, 87 | a multi-line one. 88 | \""" 89 | [...] 90 | 91 | >>> parse_docstring(Example) 92 | {'author': 'John Doe (john.doe@example.com)', 93 | 'comments': ['This is a first comment.', 94 | ('subcomment 1', 'subcomment 2'), 95 | 'This is a second comment, a multi-line one.'], 96 | 'description': 'This is a test multi-line long description.', 97 | 'something': 'lorem ipsum paragraph', 98 | 'version': '1.0'} 99 | 100 |
101 | 102 | ## `Config` and `Option` 103 | 104 | A configuration object is an instance of the `Config` class subclassing the common type `dict` and refining its capabilities to handle special key-value objects called `Option`'s that also have a description and other attributes (e.g. `required`). This way, it is easier to associate more data than simply a value to a key, i.e. when it comes to providing help text about the option. 105 | 106 | A configuration is declared by providing a dictionary as the only positional argument and/or key-values as keyword-arguments. It is important to note that, if options are defined with the keyword-arguments, they won't of course have any other data defined but they will be easilly accessible for further tuning. 107 | 108 | ??? example "**Example**: Declaring a configuration (entity class attribute)" 109 | 110 | :::python 111 | from sploitkit import Config, Option, ROption 112 | 113 | config = Config({ 114 | Option(...), 115 | ROption(...), 116 | }) 117 | 118 |
119 | 120 | !!! note "`Option` and `ROption`" 121 | 122 | Two types of option exist (for a question of performance) : `Option` (the normal one) and `ROption` (aka *Resetting Option*) that triggers resetting the entity bindings (e.g. the commands applicability to the current console given the new option). So, beware that, when using the `Option` class, the modification of its value does not update bindings between entities. 123 | 124 | An example of use of the behavior of `ROption` is when a `config` requirement is used in another entity which is to be enabled/disabled according to option's value. This way, entity bindings are reset when tuning the option like when starting a console (for more details on this, see section *Console*). 125 | 126 | A configuration option object, that is, an instance of the `Option` or `ROption` class, is defined using multiple arguments : 127 | 128 | **Argument** | **Type** | **Default** | **Description** 129 | --- | :---: | :---: | --- 130 | `name` | `str` | | option's name, conventionally uppercase 131 | `description` | `str` | `None` | help text for this option 132 | `required` | `bool` | `False` | whether it shall be defined or not 133 | `choices` | `list`/`lambda` | `None` | the possible values (as a list or lazily defined through a lambda function that outputs a list), used for validation ; its value can also be `bool` (the type, not as a string !) for setting choices to false and true 134 | `set_callback` | `lambda` | `None` | a function that is triggered after setting the value 135 | `unset_callback` | `lambda` | `None` | a function that is triggered after unsetting the value 136 | `transform` | `lambda` | `None` | a function transforming the value input as for any dictionary, but for computing a new value 137 | `validate` | `lambda` | `None` | by default, a lambda function that checks for the given `choices` if defined, but can be tuned accordingly 138 | 139 | Each lambda function takes `self` as the first argument. `transform` and `validate` also takes option's value as the second argument. 140 | 141 | ??? example "Config declaration (extract from the `FrameworkConsole` class)" 142 | 143 | :::python 144 | config = Config({ 145 | ..., 146 | ROption( 147 | 'DEBUG', 148 | "debug mode", 149 | False, 150 | bool, 151 | set_callback=lambda o: o.config.console._set_logging(o.value), 152 | ): "false", 153 | ..., 154 | Option( 155 | 'WORKSPACE', 156 | "folder where results are saved", 157 | True, 158 | ): "~/Notes", 159 | }) 160 | 161 |
162 | 163 | ## Utility class methods 164 | 165 | 166 | -------------------------------------------------------------------------------- /docs/pages/classes/index.md: -------------------------------------------------------------------------------- 1 | The following figure depicts the class hierarchy implementing the aforementioned design : 2 | 3 | Sploitkit - Class hierarchy 4 | 5 | On this figure, one can see the main attributes and methods that can be used (in black) or overridden (in red). 6 | -------------------------------------------------------------------------------- /docs/pages/classes/module.md: -------------------------------------------------------------------------------- 1 | ## `Module` class 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/pages/css/extra.css: -------------------------------------------------------------------------------- 1 | /* Full width (only works for some themes, including 'material') */ 2 | @media only screen and (min-width: 76.25em) { 3 | .md-main__inner { 4 | max-width: none; 5 | } 6 | .md-sidebar--primary { 7 | left: 0; 8 | } 9 | .md-sidebar--secondary { 10 | right: 0; 11 | margin-left: 0; 12 | -webkit-transform: none; 13 | transform: none; 14 | } 15 | } 16 | 17 | /* See https://github.com/mkdocs/mkdocs/wiki/MkDocs-Recipes */ 18 | /* Add Support for Checkbox Lists */ 19 | .task-list-item { 20 | list-style-type: none; 21 | } 22 | 23 | .task-list-item input { 24 | margin: 0 4px 0.25em -20px; 25 | vertical-align: middle; 26 | } 27 | -------------------------------------------------------------------------------- /docs/pages/design.md: -------------------------------------------------------------------------------- 1 | Sploitkit's API conveniently defines the CLI framework in an Object-Oriented fashion. *Consoles* have a set of *commands* and can be associated with *modules*, which are capable of handling their context in isolation and save/restore data from a *datastore* according to user-defined *models*. Datastores can also be customized using *store extensions*. 2 | 3 | Thanks to compartmentalization in *projects*, *files*, *jobs* and *sessions*, it becomes easier to organize your work or generate reports. 4 | 5 | To sum it up, Sploitkit aims to be highly customizable while keeping the same CLI philosophy as Metasploit, while leveraging Python and the power of [`prompt_toolkit`](https://github.com/prompt-toolkit/python-prompt-toolkit) in order to enhance the user experience through command-line completion and validation. 6 | 7 | ## Main architecture 8 | 9 | This library is designed around a central class called [*entity*](classes/entity.html). An entity centralizes features such as class registry, which keeps track of relevant sub-entities like *consoles*, *commands* and *modules*. This means every entity class inherits from this main class and then defines additional features of its own. 10 | 11 | Basically, [five different "main" entity classes](classes.html) are defined : 12 | 13 | - [`Console`](classes/console.html) : for defining CLI console levels 14 | - [`Command`](classes/command.html) : for defining console commands, accessible from console levels 15 | - [`Module`](classes/module.html) : for declaring modules with specific functionalities like in Metasploit 16 | - [`Model`](classes/datastore.html) : for describing data schemas to be recorded in the datastore 17 | - [`StoreExtension`](classes/datastore.html) : for defining mixins to be used with the datastore 18 | 19 | At startup, Sploitkit loads every entity it finds in the user-defined 20 | sources, as well as a pre-defined set of generic commands (like in 21 | Metasploit or Recon-ng). This behaviour can be disabled if so desired. 22 | Instantiation begins with a `Console` and then proceeds with the loading 23 | of all the other entities. For convenience, a `FrameworkConsole` 24 | containing some some base functionalities is provided. It serves as a 25 | good starting point for newcomers to Sploitkit. 26 | 27 | !!! note "Back-referencing" 28 | 29 | Back-referencing is heavily used throughout Sploitkit: 30 | 31 | * `module.console` refers to the parent console of module 32 | * calling `self.config.console` within an `option` allows to "walk up" the chain up to the console, and to create triggers for it 33 | 34 | ## Project structure 35 | 36 | The package is structured as follows : 37 | 38 | - `base` : This contains [base entities](/base.html) to be included by default in any 39 | application. Note that if some base commands are not required, they can be disabled (see section *Classes*/`Command`). 40 | - `core` : This holds the core functionalities of Sploitkit with the class definitions for `Entity` and the main entity classes but also components for the main console. 41 | - `utils` : This contains utility modules that are not specifically part of the `base` and `core` subpackages. 42 | 43 | ![Classes](img/classes.png) 44 | 45 | ![Packages](img/packages.png) 46 | -------------------------------------------------------------------------------- /docs/pages/examples/dronesploit.md: -------------------------------------------------------------------------------- 1 | [DroneSploit](https://github.com/dhondta/dronesploit) is a console tailored to drone hacking. 2 | 3 | ### Setup 4 | 5 | ```sh 6 | $ pip3 install dronesploit 7 | [...] 8 | ``` 9 | 10 | ### Usage 11 | 12 | ```sh 13 | $ dronesploit --help 14 | usage: dronesploit [--dev] [-h] [-v] 15 | 16 | Dronesploit 17 | 18 | optional arguments: 19 | --dev development mode (default: False) 20 | 21 | extra arguments: 22 | -h, --help show this help message and exit 23 | -v, --verbose verbose mode (default: False) 24 | 25 | $ dronesploit 26 | [...] 27 | ``` 28 | 29 | ![DroneSploit](https://dhondta.github.io/dronesploit/docs/img/dronesploit.png) 30 | -------------------------------------------------------------------------------- /docs/pages/img/class-hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/class-hierarchy.png -------------------------------------------------------------------------------- /docs/pages/img/classes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/classes.png -------------------------------------------------------------------------------- /docs/pages/img/command-key-completion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/command-key-completion.png -------------------------------------------------------------------------------- /docs/pages/img/command-validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/command-validation.png -------------------------------------------------------------------------------- /docs/pages/img/command-value-completion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/command-value-completion.png -------------------------------------------------------------------------------- /docs/pages/img/console-prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/console-prompt.png -------------------------------------------------------------------------------- /docs/pages/img/dronesploit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/dronesploit.png -------------------------------------------------------------------------------- /docs/pages/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/icon.png -------------------------------------------------------------------------------- /docs/pages/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/logo.png -------------------------------------------------------------------------------- /docs/pages/img/my-sploit-start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/my-sploit-start.png -------------------------------------------------------------------------------- /docs/pages/img/packages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/packages.png -------------------------------------------------------------------------------- /docs/pages/img/under-construction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/docs/pages/img/under-construction.png -------------------------------------------------------------------------------- /docs/pages/index.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | Sploitkit is a development kit designed to quickly build CLI consoles with a style resembling that of Metasploit. It features a clear and intuitive plugin architecture that allows to build consoles with new commands or modules but also models for their internal stores. The framework is built with the DRY philosophy in mind. 4 | 5 | The idea is to make creating consoles as easy as this: 6 | 7 | ```sh 8 | $ sploitkit my-sploit 9 | $ cd my-sploit 10 | $ gedit main.py 11 | ``` 12 | 13 | ```python 14 | #!/usr/bin/python3 15 | from sploitkit import FrameworkConsole 16 | from tinyscript import * 17 | 18 | 19 | class MySploitConsole(FrameworkConsole): 20 | #TODO: set your console attributes 21 | pass 22 | 23 | 24 | if __name__ == '__main__': 25 | parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") 26 | parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") 27 | initialize() 28 | c = MySploitConsole( 29 | "MySploit", 30 | #TODO: configure your console settings 31 | dev=args.dev, 32 | ) 33 | c.rcfile(args.rcfile) if args.rcfile else c.start() 34 | ``` 35 | 36 | This will give the following (no banner, ASCII image or quote yet): 37 | 38 | ![](img/my-sploit-start.png) 39 | 40 | ----- 41 | 42 | ## Setup 43 | 44 | This library is available on [PyPi](https://pypi.python.org/pypi/sploitkit/) and can be simply installed using Pip: 45 | 46 | ```sh 47 | pip install sploitkit 48 | ``` 49 | 50 | ----- 51 | 52 | ## Rationale 53 | 54 | This library is born from the need of quickly building toolsets tailored to various scopes which are sometimes not extensively covered in some well-known frameworks (like Metasploit). 55 | 56 | It relies on the awesome Python library [`prompt_toolkit`](https://github.com/prompt-toolkit/python-prompt-toolkit) to provide an enhanced CLI environment, adding multiple graphical elements (e.g. dropdown lists for completion and a dynamic toolbar for displaying command syntax errors) greatly improving user's experience regarding some classical tools (like e.g. or also [`rpl-attacks`](https://github.com/dhondta/rpl-attacks) or [`recon-ng`](https://github.com/lanmaster53/recon-ng), which have some limits on the usability point of view because of the [`cmd` module](https://docs.python.org/3/library/cmd.html)). 57 | 58 | I personally use this library to create CLI consoles for my job or during cybersecurity engagements or programming competitions and it proved very useful and convenient. 59 | -------------------------------------------------------------------------------- /docs/pages/quickstart.md: -------------------------------------------------------------------------------- 1 | ## Creating a project 2 | 3 | Creating a project can be achieved by using the `sploitkit-new` tool like follows : 4 | 5 | ```sh 6 | $ sploitkit-new --help 7 | usage: sploitkit-new [-s] [-h] [-v] name 8 | 9 | SploitkitNew 10 | 11 | positional arguments: 12 | name project name 13 | 14 | optional arguments: 15 | -s, --show-todo show the TODO list (default: False) 16 | 17 | extra arguments: 18 | -h, --help show this help message and exit 19 | -v, --verbose verbose mode (default: False) 20 | 21 | ``` 22 | 23 | ```sh 24 | $ sploitkit-new -s my-sploit 25 | 12:34:56 [INFO] TODO list: 26 | - [README:3] Fill in the README 27 | - [main.py:MySploitConsole:6] set your console attributes 28 | - [main.py:MySploitConsole:13] configure your console settings 29 | - [commands/template.py:CommandWithOneArg:9] compute the list of possible values 30 | - [commands/template.py:CommandWithOneArg:13] compute results here 31 | - [commands/template.py:CommandWithOneArg:17] validate the input value 32 | - [commands/template.py:CommandWithTwoArgs:27] compute the list of possible keys 33 | - [commands/template.py:CommandWithTwoArgs:31] compute the list of possible values taking the key into account 34 | - [commands/template.py:CommandWithTwoArgs:35] compute results here 35 | 36 | ``` 37 | 38 | This creates a folder `my-sploit` with the following items : 39 | 40 | ```sh 41 | $ cd my-sploit/ 42 | $ ll 43 | total 28K 44 | drwxrwxr-x 5 user user 4.0K 2019-12-25 12:34 . 45 | drwxr-xr-x 102 user user 4.0K 2019-12-25 12:34 .. 46 | drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 banners 47 | drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 commands 48 | -rw-rw-r-- 1 user user 279 2019-12-25 12:34 main.py 49 | drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 modules 50 | -rw-rw-r-- 1 user user 31 2019-12-25 12:34 README 51 | -rw-rw-r-- 1 user user 0 2019-12-25 12:34 requirements.txt 52 | 53 | ``` 54 | 55 | ----- 56 | 57 | ## Setting the root console 58 | 59 | ----- 60 | 61 | ## Adding commands 62 | 63 | 64 | ----- 65 | 66 | ## Adding modules 67 | 68 | 69 | ----- 70 | 71 | ## Tuning the datastore 72 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | jinja2<3.1.0 2 | mkdocs>=1.3.0 3 | mkdocs-bootswatch 4 | mkdocs-material>=7.3.0 5 | mkdocs-rtd-dropdown 6 | pymdown-extensions 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pytest.ini_options] 6 | pythonpath = ["src"] 7 | 8 | [tool.setuptools.dynamic] 9 | version = {attr = "sploitkit.__info__.__version__"} 10 | 11 | [tool.setuptools.packages.find] 12 | where = ["src"] 13 | 14 | [tool.setuptools.package-data] 15 | "*" = ["*.txt"] 16 | 17 | [project] 18 | name = "sploitkit" 19 | authors = [ 20 | {name="Alexandre D'Hondt", email="alexandre.dhondt@gmail.com"}, 21 | ] 22 | description = "Devkit for easilly building Metasploit-like framework consoles" 23 | license = {file = "LICENSE"} 24 | keywords = ["python", "development", "programming", "cli", "framework", "console", "devkit"] 25 | requires-python = ">=3.8,<4" 26 | classifiers = [ 27 | "Development Status :: 4 - Beta", 28 | "Environment :: Console", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: Information Technology", 31 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.8", 34 | "Programming Language :: Python :: 3.9", 35 | "Programming Language :: Python :: 3.10", 36 | "Programming Language :: Python :: 3.11", 37 | "Programming Language :: Python :: 3.12", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | ] 40 | dependencies = [ 41 | "peewee>=3.17.6", 42 | "prompt_toolkit>=3.0.47", 43 | "requests", 44 | "tinyscript>=1.30.15", 45 | ] 46 | dynamic = ["version"] 47 | 48 | [project.readme] 49 | file = "README.md" 50 | content-type = "text/markdown" 51 | 52 | [project.urls] 53 | documentation = "https://python-sploitkit.readthedocs.io/en/latest/?badge=latest" 54 | homepage = "https://github.com/dhondta/python-sploitkit" 55 | issues = "https://github.com/dhondta/python-sploitkit/issues" 56 | repository = "https://github.com/dhondta/python-sploitkit" 57 | 58 | [project.scripts] 59 | sploitkit = "sploitkit.__main__:main" 60 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_paths = src 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | peewee>=3.17.6 2 | prompt_toolkit>=3.0.47 3 | requests 4 | tinyscript>=1.30.15 5 | -------------------------------------------------------------------------------- /src/sploitkit/VERSION.txt: -------------------------------------------------------------------------------- 1 | 0.6.0 2 | -------------------------------------------------------------------------------- /src/sploitkit/__info__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """Sploitkit package information. 3 | 4 | """ 5 | import os 6 | 7 | __author__ = "Alexandre D'Hondt" 8 | __email__ = "alexandre.dhondt@gmail.com" 9 | __copyright__ = "© 2019-2020 A. D'Hondt" 10 | __license__ = "agpl-3.0" 11 | 12 | with open(os.path.join(os.path.dirname(__file__), "VERSION.txt")) as f: 13 | __version__ = f.read().strip() 14 | 15 | -------------------------------------------------------------------------------- /src/sploitkit/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | from ipaddress import ip_address 3 | from peewee import * 4 | from peewee import __all__ as _peewee 5 | from tinyscript.helpers.path import Path 6 | 7 | from .core import * 8 | from .core import __all__ as _core 9 | from .core.console import print_formatted_text 10 | 11 | 12 | __all__ = _core + _peewee 13 | __all__ += ["print_formatted_text", "IPAddressField", "MACAddressField", "Path"] 14 | 15 | 16 | # -------------------------------------- Peewee extra fields -------------------------------------- 17 | class IPAddressField(BigIntegerField): 18 | """ IPv4/IPv6 address database field. """ 19 | def db_value(self, value): 20 | if isinstance(value, (str, int)): 21 | try: 22 | return int(ip_address(value)) 23 | except Exception: 24 | pass 25 | raise ValueError("Invalid IPv4 or IPv6 Address") 26 | 27 | def python_value(self, value): 28 | return ip_address(value) 29 | 30 | 31 | class MACAddressField(BigIntegerField): 32 | """ MAC address database field. """ 33 | def db_value(self, value): 34 | if isinstance(value, int) and 0 <= value <= 0xffffffffffffffff: 35 | return value 36 | elif isinstance(value, str): 37 | if re.search(r"^([0-9a-f]{2}[:-]){5}[0-9A-F]{2}$", value, re.I): 38 | return int("".join(re.split(r"[:-]", value)), 16) 39 | raise ValueError("Invalid MAC Address") 40 | 41 | def python_value(self, value): 42 | try: 43 | return ":".join(re.findall("..", "%012x" % value)) 44 | except Exception: 45 | raise ValueError("Invalid MAC Address") 46 | 47 | -------------------------------------------------------------------------------- /src/sploitkit/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | from sploitkit.__info__ import __author__, __copyright__, __email__, __license__, __version__ 4 | from tinyscript import * 5 | 6 | 7 | __name__ = "__main__" 8 | __script__ = "sploitkit" 9 | __examples__ = ["my-sploit", "my-sploit -s"] 10 | __doc__ = """ 11 | This tool allows to quickly create a new Sploitkit project. 12 | """ 13 | 14 | 15 | MAIN = """#!/usr/bin/python3 16 | from sploitkit import FrameworkConsole 17 | from tinyscript import * 18 | 19 | 20 | class MySploitConsole(FrameworkConsole): 21 | #TODO: set your console attributes 22 | pass 23 | 24 | 25 | if __name__ == '__main__': 26 | parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") 27 | parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") 28 | initialize(exit_at_interrupt=False) 29 | c = MySploitConsole( 30 | "MySploit", 31 | #TODO: configure your console settings 32 | dev=args.dev, 33 | debug=args.verbose, 34 | ) 35 | c.rcfile(args.rcfile) if args.rcfile else c.start() 36 | """ 37 | COMMANDS = """from sploitkit import * 38 | 39 | 40 | class CommandWithOneArg(Command): 41 | \""" Description here \""" 42 | level = "module" 43 | single_arg = True 44 | 45 | def complete_values(self): 46 | #TODO: compute the list of possible values 47 | return [] 48 | 49 | def run(self): 50 | #TODO: compute results here 51 | pass 52 | 53 | def validate(self, value): 54 | #TODO: validate the input value 55 | if value not in self.complete_values(): 56 | raise ValueError("invalid value") 57 | 58 | 59 | class CommandWithTwoArgs(Command): 60 | \""" Description here \""" 61 | level = "module" 62 | 63 | def complete_keys(self): 64 | #TODO: compute the list of possible keys 65 | return [] 66 | 67 | def complete_values(self, key=None): 68 | #TODO: compute the list of possible values taking the key into account 69 | return [] 70 | 71 | def run(self): 72 | #TODO: compute results here 73 | pass 74 | """ 75 | MODULES = """from sploitkit import * 76 | 77 | 78 | class MyFirstModule(Module): 79 | \""" Description here 80 | 81 | Author: your name (your email) 82 | Version: 1.0 83 | \""" 84 | def run(self): 85 | pass 86 | 87 | 88 | class MySecondModule(Module): 89 | \""" Description here 90 | 91 | Author: your name (your email) 92 | Version: 1.0 93 | \""" 94 | def run(self): 95 | pass 96 | """ 97 | 98 | 99 | PROJECT_STRUCTURE = { 100 | 'README': "# {}\n\n#TODO: Fill in the README", 101 | 'main.py': MAIN, 102 | 'requirements.txt': None, 103 | 'banners': {}, 104 | 'commands': {'commands.py': COMMANDS}, 105 | 'modules': {'modules.py': MODULES}, 106 | } 107 | 108 | 109 | def main(): 110 | parser.add_argument("name", help="project name") 111 | parser.add_argument("-s", "--show-todo", dest="todo", action="store_true", help="show the TODO list") 112 | initialize(noargs_action="wizard") 113 | p = ts.ProjectPath(args.name, PROJECT_STRUCTURE) 114 | if args.todo: 115 | for k, v in p.todo.items(): 116 | print("- [%s] %s" % (k, v)) 117 | 118 | -------------------------------------------------------------------------------- /src/sploitkit/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/src/sploitkit/base/__init__.py -------------------------------------------------------------------------------- /src/sploitkit/base/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/src/sploitkit/base/commands/__init__.py -------------------------------------------------------------------------------- /src/sploitkit/base/commands/general.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import re 3 | import shlex 4 | from prompt_toolkit.formatted_text import ANSI 5 | from tinyscript.helpers import human_readable_size, BorderlessTable, Path 6 | 7 | from sploitkit import * 8 | from sploitkit.core.module import MetaModule 9 | 10 | 11 | projects = lambda cmd: [x.filename for x in cmd.workspace.iterpubdir()] 12 | 13 | 14 | # ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ 15 | class Back(Command): 16 | """ Come back to the previous console level """ 17 | except_levels = ["root", "session"] 18 | 19 | def run(self): 20 | raise ConsoleExit 21 | 22 | 23 | class Exit(Command): 24 | """ Exit the console """ 25 | aliases = ["quit"] 26 | except_levels = ["session"] 27 | 28 | def run(self): 29 | raise ConsoleExit 30 | 31 | 32 | class Help(Command): 33 | """ Display help """ 34 | aliases = ["?"] 35 | 36 | def run(self): 37 | print_formatted_text(Command.get_help("general", self.console.level)) 38 | 39 | 40 | class Search(Command): 41 | """ Search for text in modules """ 42 | except_levels = ["session"] 43 | single_arg = True 44 | 45 | def run(self, text): 46 | keywords = shlex.split(text) 47 | data = [["Name", "Path", "Description"]] 48 | for m in Module.subclasses: 49 | for k in keywords: 50 | if m.search(k): 51 | data.append([m.name, m.path, m.description]) 52 | if len(data) == 1: 53 | self.logger.error("No match found") 54 | else: 55 | t = BorderlessTable(data, "Matching modules") 56 | print_formatted_text(t.table) 57 | n = len(data) - 2 58 | self.logger.info(f"{n} match{['', 'es'][n > 0]} found") 59 | 60 | 61 | class Show(Command): 62 | """ Show options, projects, modules or issues (if any) """ 63 | level = "root" 64 | keys = ["files", "modules", "options", "projects"] 65 | 66 | def complete_values(self, key): 67 | if key == "files": 68 | if self.config.option("TEXT_VIEWER").value is not None: 69 | return list(map(str, self.console._files.list)) 70 | return [] 71 | elif key == "issues": 72 | l = [] 73 | for cls, subcls, errors in Entity.issues(): 74 | l.extend(list(errors.keys())) 75 | return l 76 | elif key == "modules": 77 | uncat = any(isinstance(m, MetaModule) for m in self.console.modules.values()) 78 | l = [c for c, m in self.console.modules.items() if not isinstance(m, MetaModule)] 79 | return l + ["uncategorized"] if uncat else l 80 | elif key == "options": 81 | return list(self.config.keys()) 82 | elif key == "projects": 83 | return projects(self) 84 | elif key == "sessions": 85 | return [str(i) for i, _ in self.console._sessions] 86 | 87 | def run(self, key, value=None): 88 | if key == "files": 89 | if value is None: 90 | data = [["Path", "Size"]] 91 | p = Path(self.config.option("WORKSPACE").value) 92 | for f in self.console._files.list: 93 | data.append([f, human_readable_size(p.joinpath(f).size)]) 94 | print_formatted_text(BorderlessTable(data, "Files from the workspace")) 95 | elif self.config.option("TEXT_VIEWER").value: 96 | self.console._files.view(value) 97 | elif key == "issues": 98 | t = Entity.get_issues() 99 | if len(t) > 0: 100 | print_formatted_text(t) 101 | elif key == "modules": 102 | h = Module.get_help(value) 103 | if h.strip() != "": 104 | print_formatted_text(h) 105 | else: 106 | self.logger.warning("No module loaded") 107 | elif key == "options": 108 | if value is None: 109 | print_formatted_text(ANSI(str(self.config))) 110 | else: 111 | c = Config() 112 | c[self.config.option(value)] = self.config[value] 113 | print_formatted_text(ANSI(str(c))) 114 | elif key == "projects": 115 | if value is None: 116 | data = [["Name"]] 117 | for p in projects(self): 118 | data.append([p]) 119 | print_formatted_text(BorderlessTable(data, "Existing projects")) 120 | else: 121 | print_formatted_text(value) 122 | elif key == "sessions": 123 | data = [["ID", "Description"]] 124 | for i, s in self.console._sessions: 125 | data.append([str(i), getattr(s, "description", "")]) 126 | print_formatted_text(BorderlessTable(data, "Open sessions")) 127 | 128 | def set_keys(self): 129 | if Entity.has_issues(): 130 | self.keys += ["issues"] 131 | else: 132 | while "issues" in self.keys: 133 | self.keys.remove("issues") 134 | if len(self.console._sessions) > 0: 135 | self.keys += ["sessions"] 136 | else: 137 | while "sessions" in self.keys: 138 | self.keys.remove("sessions") 139 | 140 | def validate(self, key, value=None): 141 | if key not in self.keys: 142 | raise ValueError("invalid key") 143 | if value is not None: 144 | if key == "files": 145 | if self.config.option("TEXT_VIEWER").value is None: 146 | raise ValueError("cannot view file ; TEXT_VIEWER is not set") 147 | if value not in self.complete_values(key): 148 | raise ValueError("invalid file") 149 | elif key == "issues": 150 | if value not in self.complete_values(key): 151 | raise ValueError("invalid error type") 152 | elif key == "modules": 153 | if value is not None and value not in self.complete_values(key): 154 | raise ValueError("invalid module") 155 | elif key == "options": 156 | if value is not None and value not in self.complete_values(key): 157 | raise ValueError("invalid option") 158 | elif key == "projects": 159 | if value is not None and value not in self.complete_values(key): 160 | raise ValueError("invalid project name") 161 | 162 | 163 | # ---------------------------- OPTIONS-RELATED COMMANDS ------------------------ 164 | class Set(Command): 165 | """ Set an option in the current context """ 166 | except_levels = ["session"] 167 | 168 | def complete_keys(self): 169 | return self.config.keys() 170 | 171 | def complete_values(self, key): 172 | if key.upper() == "WORKSPACE": 173 | return [str(x) for x in Path(".").home().iterpubdir()] 174 | return self.config.option(key).choices or [] 175 | 176 | def run(self, key, value): 177 | self.config[key] = value 178 | 179 | def validate(self, key, value): 180 | if key not in self.config.keys(): 181 | raise ValueError("invalid option") 182 | o = self.config.option(key) 183 | if o.required and value is None: 184 | raise ValueError("a value is required") 185 | if not o.validate(value): 186 | raise ValueError("invalid value") 187 | 188 | 189 | class Unset(Command): 190 | """ Unset an option from the current context """ 191 | except_levels = ["session"] 192 | 193 | def complete_values(self): 194 | for k in self.config.keys(): 195 | if not self.config.option(k).required: 196 | yield k 197 | 198 | def run(self, key): 199 | del self.config[key] 200 | 201 | def validate(self, key): 202 | if key not in self.config.keys(): 203 | raise ValueError("invalid option") 204 | if self.config.option(key).required: 205 | raise ValueError("this option is required") 206 | 207 | 208 | class Setg(Command): 209 | """ Set a global option """ 210 | except_levels = ["session"] 211 | 212 | def complete_keys(self): 213 | return self.config.keys(True) 214 | 215 | def complete_values(self, key): 216 | return self.config.option(key).choices or [] 217 | 218 | def run(self, key, value): 219 | self.config.setglobal(key, value) 220 | 221 | def validate(self, key, value): 222 | try: 223 | o = self.config.option(key) 224 | if not o.glob: 225 | raise ValueError("cannot be set as global") 226 | if not o.validate(value): 227 | raise ValueError("invalid value") 228 | except KeyError: 229 | pass 230 | 231 | 232 | class Unsetg(Command): 233 | """ Unset a global option """ 234 | except_levels = ["session"] 235 | 236 | def complete_values(self): 237 | return self.config._g.keys() 238 | 239 | def run(self, key): 240 | self.config.unsetglobal(key) 241 | 242 | def validate(self, key): 243 | if key not in self.config._g.keys(): 244 | raise ValueError("invalid option") 245 | 246 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from prompt_toolkit.formatted_text import ANSI 3 | 4 | from sploitkit import * 5 | 6 | 7 | # ----------------------------- SUBCONSOLE DEFINITION -------------------------- 8 | class ModuleConsole(Console): 9 | """ Module subconsole definition. """ 10 | level = "module" 11 | message = [ 12 | ('class:prompt', " "), 13 | ('class:prompt', None), 14 | ('class:prompt', "("), 15 | ('class:module', None), 16 | ('class:prompt', ")"), 17 | ] 18 | style = { 19 | 'prompt': "#eeeeee", 20 | 'module': "#ff0000", 21 | } 22 | 23 | def __init__(self, parent, module): 24 | self.attach(module, True) 25 | self.logname = module.fullpath 26 | self.message[1] = ('class:prompt', self.module.category) 27 | self.message[3] = ('class:module', self.module.base) 28 | self.opt_prefix = "Module" 29 | super(ModuleConsole, self).__init__(parent) 30 | 31 | 32 | # ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ 33 | class Use(Command): 34 | """ Select a module """ 35 | except_levels = ["session"] 36 | 37 | def complete_values(self): 38 | return Module.get_list() 39 | 40 | def run(self, module): 41 | new_mod, old_mod = Module.get_modules(module), self.module 42 | # avoid starting a new subconsole for the same module 43 | if old_mod is not None and old_mod.fullpath == new_mod.fullpath: 44 | return 45 | ModuleConsole(self.console, new_mod).start() 46 | 47 | 48 | # ----------------------------- MODULE-LEVEL COMMANDS -------------------------- 49 | class ModuleCommand(Command): 50 | """ Proxy class (for setting the level attribute). """ 51 | level = "module" 52 | 53 | 54 | class Run(ModuleCommand): 55 | """ Run module """ 56 | def run(self): 57 | if self.module.check(): 58 | self.module._instance.run() 59 | 60 | 61 | class Show(ModuleCommand): 62 | """ Show module-relevant information or options """ 63 | keys = ["info", "options"] 64 | 65 | def complete_values(self, key): 66 | if key == "options": 67 | return list(self.config.keys()) 68 | elif key == "issues": 69 | l = [] 70 | for attr in ["console", "module"]: 71 | for _, __, errors in getattr(self, attr).issues(self.cname): 72 | l.extend(list(errors.keys())) 73 | return l 74 | 75 | def run(self, key, value=None): 76 | if key == "options": 77 | if value is None: 78 | print_formatted_text(ANSI(str(self.config))) 79 | else: 80 | c = Config() 81 | c[self.config.option(value), True] = self.config[value] 82 | print_formatted_text(ANSI(str(c))) 83 | elif key == "info": 84 | i = self.console.module.get_info(("fullpath|path", "description"), ("author", "email", "version"), 85 | ("comments",), ("options",), show_all=True) 86 | if len(i.strip()) != "": 87 | print_formatted_text(i) 88 | elif key == "issues": 89 | t = Entity.get_issues() 90 | if len(t) > 0: 91 | print_formatted_text(t) 92 | 93 | def set_keys(self): 94 | if self.module and self.module.has_issues(self.cname): 95 | self.keys += ["issues"] 96 | else: 97 | while "issues" in self.keys: 98 | self.keys.remove("issues") 99 | 100 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/project.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from tinyscript.helpers import confirm, Path, ProjectPath 3 | 4 | from sploitkit import * 5 | 6 | 7 | # ----------------------------- SUBCONSOLE DEFINITION -------------------------- 8 | class ProjectConsole(Console): 9 | """ Project subconsole definition. """ 10 | level = "project" 11 | message = [ 12 | ('class:prompt', "["), 13 | ('class:project', None), 14 | ('class:prompt', "]"), 15 | ] 16 | style = { 17 | 'prompt': "#eeeeee", 18 | 'project': "#0000ff", 19 | } 20 | 21 | def __init__(self, parent, name): 22 | self.logname = name 23 | self.message[1] = ('class:project', name) 24 | self.config['WORKSPACE'] = str(Path(parent.config['WORKSPACE']).joinpath(name)) 25 | super(ProjectConsole, self).__init__(parent) 26 | 27 | 28 | # ------------------------------ ROOT-LEVEL COMMANDS --------------------------- 29 | # These commands are available at the root level to reference a project (archive|create|select|...) 30 | class RootCommand(Command): 31 | """ Proxy class for setting the level attribute. """ 32 | level = "root" 33 | 34 | 35 | class ProjectRootCommand(RootCommand): 36 | """ Proxy class for defining the complete_values method. """ 37 | single_arg = True 38 | 39 | def complete_values(self): 40 | return [x.filename for x in self.workspace.iterpubdir()] 41 | 42 | 43 | class Archive(ProjectRootCommand): 44 | """ Archive a project to a ZIP file (it removes the project folder) """ 45 | def run(self, project): 46 | projpath = Path(self.workspace).joinpath(project) 47 | folder = ProjectPath(projpath) 48 | self.logger.debug(f"Archiving project '{project}'...") 49 | ask = self.console.config.option("ENCRYPT_PROJECT").value 50 | try: 51 | folder.archive(ask=ask) 52 | self.logger.success(f"'{project}' archived") 53 | except OSError as e: 54 | self.logger.error(str(e)) 55 | self.logger.failure(f"'{project}' not archived") 56 | 57 | 58 | class Delete(ProjectRootCommand): 59 | """ Delete a project """ 60 | def run(self, project): 61 | self.logger.debug(f"Deleting project '{project}'...") 62 | self.workspace.joinpath(project).remove() 63 | self.logger.success(f"'{project}' deleted") 64 | 65 | 66 | class Load(ProjectRootCommand): 67 | """ Load a project from a ZIP file (it removes the ZIP file) """ 68 | def complete_values(self): 69 | # this returns the list of *.zip in the workspace folder 70 | return [x.stem for x in self.workspace.iterfiles(".zip")] 71 | 72 | def run(self, project): 73 | self.logger.debug(f"Loading archive '{project}.zip'...") 74 | projpath = Path(self.workspace).joinpath(project) 75 | archive = ProjectPath(projpath.with_suffix(".zip")) 76 | ask = self.console.config.option("ENCRYPT_PROJECT").value 77 | try: 78 | archive.load(ask=ask) 79 | self.logger.success(f"'{project}' loaded") 80 | except Exception as e: 81 | self.logger.error("Bad password" if "error -3" in str(e) else str(e)) 82 | self.logger.failure(f"'{project}' not loaded") 83 | 84 | def validate(self, project): 85 | if project not in self.complete_values(): 86 | raise ValueError("no project archive for this name") 87 | elif project in super(Load, self).complete_values(): 88 | raise ValueError("a project with the same name already exists") 89 | 90 | 91 | class Select(ProjectRootCommand): 92 | """ Select a project (create if it does not exist) """ 93 | def complete_values(self): 94 | return Load().complete_values() + super(Select, self).complete_values() 95 | 96 | def run(self, project): 97 | p = self.workspace.joinpath(project) 98 | loader = Load() 99 | if project in loader.complete_values() and confirm("An archive with this name already exists ; " 100 | "do you want to load the archive instead ?"): 101 | loader.run(project) 102 | if not p.exists(): 103 | self.logger.debug(f"Creating project '{project}'...") 104 | p.mkdir() 105 | self.logger.success(f"'{project}' created") 106 | ProjectConsole(self.console, project).start() 107 | self.config['WORKSPACE'] = str(Path(self.config['WORKSPACE']).parent) 108 | 109 | def validate(self, project): 110 | pass 111 | 112 | 113 | # ---------------------------- PROJECT-LEVEL COMMANDS -------------------------- 114 | class Show(Command): 115 | """ Show project-relevant options """ 116 | #FIXME 117 | level = "project" 118 | values = ["options"] 119 | 120 | def run(self, value): 121 | print_formatted_text(BorderlessTable(self.console.__class__.options, "Console options")) 122 | 123 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/recording.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from tinyscript.helpers import Path 3 | 4 | from sploitkit import Command 5 | 6 | 7 | # ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ 8 | class RecordStatus(Command): 9 | """ Consult status for commands recording to a .rc file """ 10 | # Rationale: recording status should be consultable from any console level 11 | aliases = ["record"] 12 | alias_only = True 13 | except_levels = ["session"] 14 | values = ["status"] 15 | 16 | def run(self, status): 17 | self.logger.info(f"Recording is {['disabled', 'enabled'][self.recorder.enabled]}") 18 | 19 | 20 | # ------------------------------ ROOT-LEVEL COMMANDS --------------------------- 21 | class RootProjectCommand(Command): 22 | """ Proxy class (for setting the level attribute). """ 23 | level = ["root", "project"] 24 | 25 | 26 | class Record(RootProjectCommand): 27 | """ Start/stop or consult status of commands recording to a .rc file """ 28 | # Rationale: recording start/stop is only triggerable from the root level 29 | keys = ["start", "stop", "status"] 30 | 31 | def complete_values(self, key=None): 32 | if key == "start": 33 | return [x.name for x in Path(self.workspace).iterfiles(".rc")] 34 | 35 | def run(self, key, rcfile=None): 36 | if key == "start": 37 | self.recorder.start(str(Path(self.workspace).joinpath(rcfile))) 38 | elif key == "stop": 39 | self.recorder.stop() 40 | elif key == "status": 41 | self.logger.info(f"Recording is {['disabled', 'enabled'][self.recorder.enabled]}") 42 | 43 | def validate(self, key, rcfile=None): 44 | if key == "start": 45 | if rcfile is None: 46 | raise ValueError("please enter a filename") 47 | if Path(self.workspace).joinpath(rcfile).exists(): 48 | raise ValueError("a file with the same name already exists") 49 | elif key in ["stop", "status"]: 50 | if rcfile is not None: 51 | raise ValueError("this key takes no value") 52 | 53 | 54 | class Replay(RootProjectCommand): 55 | """ Execute commands from a .rc file """ 56 | def complete_values(self, key=None): 57 | return [x.name for x in Path(self.workspace).iterfiles(".rc")] 58 | 59 | def run(self, rcfile): 60 | self.logger.debug(f"Replaying commands from file '{rcfile}'...") 61 | self.console.replay(rcfile) 62 | 63 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/root.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | 4 | 5 | # ----------------------- GENERAL-PURPOSE ROOT-LEVEL COMMANDS ------------------ 6 | class Help(Command): 7 | """ Display help (commands or individual command/module) """ 8 | level = "root" 9 | keys = ["command"] 10 | 11 | def __init__(self): 12 | if len(Module.modules) > 0 and "module" not in self.keys: 13 | self.keys += ["module"] 14 | 15 | def complete_values(self, category): 16 | if category == "command": 17 | return self.console.commands.keys() 18 | elif category == "module": 19 | return sorted([x.fullpath for x in Module.subclasses]) 20 | 21 | def run(self, category=None, value=None): 22 | if category is None: 23 | print_formatted_text(Command.get_help(except_levels="module")) 24 | elif category == "command": 25 | print_formatted_text(self.console.commands[value].help(value)) 26 | elif category == "module": 27 | print_formatted_text(self.modules[value].help) 28 | 29 | def validate(self, category=None, value=None): 30 | if category is None and value is None: 31 | return 32 | super(Help, self).validate(category, value) 33 | 34 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | 4 | 5 | # ----------------------------- SUBCONSOLE DEFINITION -------------------------- 6 | class SessionConsole(Console): 7 | """ Session subconsole definition. """ 8 | level = "session" 9 | message_reset = True 10 | message = [ 11 | ('class:session', None), 12 | ('class:prompt', ">"), 13 | ] 14 | style = { 15 | 'prompt': "#eeeeee", 16 | 'session': "#00ff00", 17 | } 18 | 19 | def __init__(self, parent, session_id): 20 | session = parent._sessions[session_id] 21 | self.logname = "session-%d" % session_id 22 | self.message[0] = ('class:prompt', session.name) 23 | super(SessionConsole, self).__init__(parent, fail=False) 24 | self.config.prefix = "Module" 25 | 26 | 27 | # ---------------------------- SESSION-RELATED COMMANDS ------------------------ 28 | class Background(Command): 29 | """ Put the current session to the background """ 30 | level = "session" 31 | 32 | def run(self): 33 | # do something with the session 34 | raise ConsoleExit 35 | 36 | 37 | class Session(Command): 38 | """ Resume an open session """ 39 | except_levels = ["session"] 40 | #requirements = {'internal': lambda s: len(s.console._sessions) > 0} 41 | 42 | def complete_values(self): 43 | return list(range(len(self.console._sessions))) 44 | 45 | def run(self, session_id): 46 | SessionConsole(self.console, session_id).start() 47 | 48 | -------------------------------------------------------------------------------- /src/sploitkit/base/commands/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import os 3 | import stat 4 | import yaml 5 | from collections.abc import Iterable 6 | from gc import collect, get_objects, get_referrers 7 | from subprocess import call 8 | from sys import getrefcount 9 | from tinyscript.helpers import human_readable_size, parse_docstring, pprint, BorderlessTable, Capture, Path 10 | 11 | from sploitkit import * 12 | from sploitkit.core.components import BACK_REFERENCES 13 | from sploitkit.core.entity import load_entities 14 | 15 | 16 | # ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ 17 | class Edit(Command): 18 | """ Edit a text file """ 19 | except_levels = ["session"] 20 | requirements = {'system': ["vim"]} 21 | single_arg = True 22 | 23 | def check_requirements(self): 24 | return self.config.option("TEXT_EDITOR").value is not None 25 | 26 | def complete_values(self): 27 | p = Path(self.config.option("WORKSPACE").value) 28 | f = p.iterfiles(relative=True) 29 | return list(map(lambda x: str(x), f)) 30 | 31 | def run(self, filename): 32 | f = Path(self.config.option("WORKSPACE").value).joinpath(filename) 33 | self.console._files.edit(str(f)) 34 | 35 | def validate(self, filename): 36 | return 37 | 38 | 39 | class History(Command): 40 | """ Inspect commands history """ 41 | except_levels = ["session"] 42 | requirements = {'system': ["less"]} 43 | 44 | def run(self): 45 | h = Path(self.config.option("WORKSPACE").value).joinpath("history") 46 | self.console._files.page(str(h)) 47 | 48 | 49 | class Shell(Command): 50 | """ Execute a shell command """ 51 | except_levels = ["session"] 52 | single_arg = True 53 | 54 | def complete_values(self): 55 | l = [] 56 | e = stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH 57 | for p in os.environ['PATH'].split(":"): 58 | if not os.path.isdir(p): 59 | continue 60 | for f in os.listdir(p): 61 | fp = os.path.join(p, f) 62 | if os.path.isfile(fp): 63 | st = os.stat(fp) 64 | if st.st_mode & e and f not in l: 65 | l.append(f) 66 | return l 67 | 68 | def run(self, cmd=None): 69 | if cmd is None: 70 | from pty import spawn 71 | spawn("/bin/bash") 72 | else: 73 | call(cmd, shell=True) 74 | print_formatted_text("") 75 | 76 | def validate(self, cmd): 77 | _ = cmd.split() 78 | if len(_) <= 1 and _[0] not in self.complete_values(): 79 | raise ValueError("bad shell command") 80 | 81 | 82 | class Stats(Command): 83 | """ Display console's statistics """ 84 | level = "root" 85 | 86 | def run(self): 87 | d = [["Item", "Path", "Size"]] 88 | p = self.console.app_folder 89 | d.append(["APP_FOLDER", str(p), human_readable_size(p.size)]) 90 | p = self.workspace 91 | d.append(["WORKSPACE", str(p), human_readable_size(p.size)]) 92 | t = BorderlessTable(d, "Statistics") 93 | print_formatted_text(t.table) 94 | 95 | 96 | # ------------------------------- DEBUGGING COMMANDS --------------------------- 97 | class DebugCommand(Command): 98 | """ Proxy class for development commands """ 99 | except_levels = ["session"] 100 | requirements = {'config': {'DEBUG': True}} 101 | 102 | 103 | class Logs(DebugCommand): 104 | """ Inspect console logs """ 105 | requirements = {'system': ["less"]} 106 | 107 | def run(self): 108 | self.console._files.page(self.logger.__logfile__) 109 | 110 | 111 | class Pydbg(DebugCommand): 112 | """ Start a Python debugger session """ 113 | requirements = {'python': ["pdb"]} 114 | 115 | def run(self): 116 | import pdb 117 | pdb.set_trace() 118 | 119 | 120 | class State(DebugCommand): 121 | """ Display console's shared state """ 122 | def run(self): 123 | for k, v in self.console.state.items(): 124 | print_formatted_text(f"\n{k}:") 125 | v = v or "" 126 | if len(v) == 0: 127 | continue 128 | if isinstance(v, Iterable): 129 | if isinstance(v, dict): 130 | v = dict(**v) 131 | for l in yaml.dump(v).split("\n"): 132 | if len(l.strip()) == 0: 133 | continue 134 | print_formatted_text(" " + l) 135 | else: 136 | print_formatted_text(v) 137 | print_formatted_text("") 138 | 139 | 140 | # ------------------------------ DEVELOPMENT COMMANDS -------------------------- 141 | class DevCommand(DebugCommand): 142 | """ Proxy class for development commands """ 143 | def condition(self): 144 | return getattr(Console, "_dev_mode", False) 145 | 146 | 147 | class Collect(DevCommand): 148 | """ Garbage-collect """ 149 | def run(self): 150 | collect() 151 | 152 | 153 | class Dict(DevCommand): 154 | """ Show console's dictionary of attributes """ 155 | def run(self): 156 | pprint(self.console.__dict__) 157 | 158 | 159 | class Memory(DevCommand): 160 | """ Inspect memory consumption """ 161 | keys = ["graph", "growth", "info", "leaking", "objects", "refs"] 162 | requirements = { 163 | 'python': ["objgraph", "psutil", "xdot"], 164 | 'system': ["xdot"], 165 | } 166 | 167 | def complete_values(self, key=None): 168 | if key in ["graph", "refs"]: 169 | return [str(o) for o in get_objects() if isinstance(o, Console)] 170 | 171 | def run(self, key, value=None): 172 | if value is not None: 173 | obj = list(filter(lambda o: str(o) == value, get_objects()))[0] 174 | if key == "graph": 175 | from objgraph import show_refs 176 | if value is None: 177 | p = self.console.parent 178 | show_refs(self.console if p is None else p, refcounts=True, max_depth=3) 179 | else: 180 | show_refs(obj, refcounts=True, max_depth=3) 181 | elif key == "growth": 182 | from objgraph import get_leaking_objects, show_most_common_types 183 | show_most_common_types(objects=get_leaking_objects()) 184 | elif key == "info": 185 | from psutil import Process 186 | p = Process(os.getpid()) 187 | print_formatted_text(p.memory_info()) 188 | elif key == "leaking": 189 | from objgraph import get_leaking_objects 190 | with Capture() as (out, err): 191 | pprint(get_leaking_objects()) 192 | print_formatted_text(out) 193 | elif key == "objects": 194 | data = [["Object", "#References"]] 195 | for o in get_objects(): 196 | if isinstance(o, (Console, Module)): 197 | data.append([str(o), str(getrefcount(o))]) 198 | t = BorderlessTable(data, "Consoles/Modules") 199 | print_formatted_text(t.table) 200 | elif key == "refs": 201 | if value is not None: 202 | print_formatted_text(getrefcount(obj), ":") 203 | pprint(get_referrers(obj)) 204 | 205 | def validate(self, key, value=None): 206 | if key in ["graph", "refs"]: 207 | if value and value not in self.complete_values("graph"): 208 | raise ValueError("bad object") 209 | elif value: 210 | raise ValueError("this key takes no value") 211 | 212 | 213 | class Reload(Command): 214 | """ Inspect memory consumption """ 215 | level = "root" 216 | values = ["commands", "modules", "models"] 217 | 218 | def condition(self): 219 | return getattr(Console, "_dev_mode", False) 220 | 221 | def run(self, value): 222 | load_entities([globals()[value[:-1].capitalize()]], 223 | *([self.console._root] + self.console._sources("entities")), **self.console._load_kwargs) 224 | 225 | -------------------------------------------------------------------------------- /src/sploitkit/base/config.conf: -------------------------------------------------------------------------------- 1 | [main] 2 | projects_folder = ~/Projects 3 | -------------------------------------------------------------------------------- /src/sploitkit/base/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/src/sploitkit/base/models/__init__.py -------------------------------------------------------------------------------- /src/sploitkit/base/models/notes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | from sploitkit.base.models.organization import Organization 4 | from sploitkit.base.models.systems import Host 5 | from sploitkit.base.models.users import User 6 | 7 | 8 | class Note(Model): 9 | content = TextField() 10 | 11 | 12 | class OrganizationNote(BaseModel): 13 | organization = ForeignKeyField(Organization) 14 | note = ForeignKeyField(Note) 15 | 16 | class Meta: 17 | primary_key = CompositeKey("organization", "note") 18 | 19 | 20 | class NoteHost(BaseModel): 21 | host = ForeignKeyField(Host) 22 | note = ForeignKeyField(Note) 23 | 24 | class Meta: 25 | primary_key = CompositeKey("host", "note") 26 | 27 | 28 | class NoteUser(BaseModel): 29 | user = ForeignKeyField(User) 30 | note = ForeignKeyField(Note) 31 | 32 | class Meta: 33 | primary_key = CompositeKey("user", "note") 34 | 35 | -------------------------------------------------------------------------------- /src/sploitkit/base/models/organization.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | from sploitkit.base.models.users import Email, User 4 | 5 | 6 | class Organization(Model): 7 | name = CharField(primary_key=True) 8 | 9 | 10 | class Unit(Model): 11 | name = CharField(primary_key=True) 12 | organization = ForeignKeyField(Organization, backref="units") 13 | 14 | 15 | class Employee(Model): 16 | firstname = CharField() 17 | lastname = CharField() 18 | role = CharField() 19 | title = CharField() 20 | 21 | class Meta: 22 | indexes = ((("firstname", "lastname", "role"), True),) 23 | 24 | @property 25 | def fullname(self): 26 | return f"{self.firstname} {self.lastname} ({self.role})" 27 | 28 | 29 | class EmployeeUnit(BaseModel): 30 | employee = ForeignKeyField(Employee, backref="units") 31 | unit = ForeignKeyField(Unit, backref="employees") 32 | 33 | class Meta: 34 | primary_key = CompositeKey("employee", "unit") 35 | 36 | 37 | class EmployeeEmail(BaseModel): 38 | employee = ForeignKeyField(Employee, backref="emails") 39 | email = ForeignKeyField(Email, backref="employees") 40 | 41 | class Meta: 42 | primary_key = CompositeKey("employee", "email") 43 | 44 | 45 | class EmployeeUser(BaseModel): 46 | employee = ForeignKeyField(Employee, backref="users") 47 | user = ForeignKeyField(User, backref="employees") 48 | 49 | class Meta: 50 | primary_key = CompositeKey("employee", "user") 51 | 52 | -------------------------------------------------------------------------------- /src/sploitkit/base/models/systems.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | 4 | 5 | class Host(Model): 6 | hostname = CharField(max_length=256) 7 | ip = IPAddressField() 8 | mac = MACAddressField() 9 | os = CharField() 10 | location = CharField() 11 | 12 | class Meta: 13 | indexes = ((("hostname", "ip", "mac"), True),) 14 | 15 | 16 | class Port(Model): 17 | number = IntegerField(primary_key=True) 18 | status = BooleanField() 19 | 20 | 21 | class Service(Model): 22 | name = CharField(primary_key=True) 23 | 24 | 25 | class HostPort(BaseModel): 26 | host = ForeignKeyField(Host, backref="ports") 27 | port = ForeignKeyField(Port, backref="hosts") 28 | 29 | class Meta: 30 | primary_key = CompositeKey("host", "port") 31 | 32 | 33 | class ServicePort(BaseModel): 34 | service = ForeignKeyField(Service, backref="ports") 35 | port = ForeignKeyField(Port, backref="services") 36 | 37 | class Meta: 38 | primary_key = CompositeKey("service", "port") 39 | 40 | -------------------------------------------------------------------------------- /src/sploitkit/base/models/users.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from sploitkit import * 3 | 4 | 5 | class User(Model): 6 | username = CharField(primary_key=True) 7 | 8 | 9 | class Email(Model): 10 | address = CharField(primary_key=True, max_length=320) 11 | 12 | 13 | class Password(Model): 14 | hash = CharField(primary_key=True) 15 | plain = CharField() 16 | 17 | 18 | class UserEmail(BaseModel): 19 | user = ForeignKeyField(User, backref="emails") 20 | email = ForeignKeyField(Email, backref="users") 21 | 22 | class Meta: 23 | primary_key = CompositeKey("user", "email") 24 | 25 | 26 | class UserPassword(BaseModel): 27 | user = ForeignKeyField(User, backref="passwords") 28 | password = ForeignKeyField(Password, backref="users") 29 | 30 | class Meta: 31 | primary_key = CompositeKey("user", "password") 32 | 33 | 34 | #TODO: to be tested 35 | #class UsersStorage(StoreExtension): 36 | # def set_user(self, username): 37 | # User.get_or_create(username=username).execute() 38 | 39 | 40 | #TODO: to be tested 41 | #class PasswordsStorage(StoreExtension): 42 | # def set_password(self, password): 43 | # Password.insert(password=password).execute() 44 | 45 | -------------------------------------------------------------------------------- /src/sploitkit/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .console import * 2 | from .console import __all__ 3 | 4 | -------------------------------------------------------------------------------- /src/sploitkit/core/application.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from prompt_toolkit.application import Application 3 | from prompt_toolkit.layout.containers import HSplit, Window 4 | from prompt_toolkit.layout.layout import Layout 5 | 6 | 7 | __all__ = ["FrameworkApp"] 8 | 9 | 10 | #TODO: find a way to embed the Console instance (started with .start()) into FrameworkApp 11 | class FrameworkApp(Application): 12 | def __init__(self, *args, **kwargs): 13 | console = kwargs.get('console') 14 | if console is None: 15 | raise Exception("No root console passed to the application") 16 | #console.__class__ = type("ConsoleTextArea", (TextArea, console.__class__), {}) 17 | #console.scrollbar = True 18 | root_container = HSplit([ 19 | console, 20 | ]) 21 | kwargs['layout'] = Layout(root_container, focused_element=console) 22 | super(FrameworkApp, self).__init__(*args, **kwargs) 23 | 24 | -------------------------------------------------------------------------------- /src/sploitkit/core/command.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import re 3 | from inspect import getfullargspec 4 | from tinyscript.helpers import failsafe, BorderlessTable, Path, PythonPath 5 | 6 | from .components.config import Config 7 | from .components.logger import get_logger 8 | from .entity import Entity, MetaEntity 9 | 10 | 11 | __all__ = ["Command"] 12 | 13 | 14 | COMMAND_STYLES = [ 15 | "lowercase", # ClassName => classname 16 | "none", # ClassName => ClassName 17 | "powershell", # ClassName => Class-Name 18 | "slugified", # ClassName => class-name 19 | "uppercase" # ClassName => CLASSNAME 20 | ] 21 | """ 22 | Usage: 23 | >>> from sploitkit import Command 24 | >>> Command.set_style("powershell") 25 | """ 26 | FUNCTIONALITIES = [ 27 | "general", # commands for every level 28 | "utils", # utility commands (for every level) 29 | "recording", # recording commands (for every level) 30 | "root", # base root-level commands 31 | "project", # base project-level commands 32 | "module", # base module-level commands 33 | "session", # base session-level commands 34 | ] 35 | 36 | 37 | logger = get_logger("core.command") 38 | 39 | 40 | class MetaCommand(MetaEntity): 41 | """ Metaclass of a Command. """ 42 | _inherit_metadata = True 43 | style = "slugified" 44 | 45 | def __init__(self, *args): 46 | argspec = getfullargspec(self.run) 47 | s, args, defs = "{}", argspec.args[1:], argspec.defaults 48 | for a in args[:len(args)-len(defs or [])]: 49 | s += " " + a 50 | if len(defs or []) > 0: 51 | s += " [" 52 | i = [] 53 | for a, d in zip(args[len(args)-len(defs):], defs): 54 | i.append(f"{a}={d}" if d is not None else a) 55 | s += " ".join(i) + "]" 56 | self.signature = s 57 | self.args, self.defaults = args, defs 58 | 59 | def help(self, alias=None): 60 | """ Help message for the command. """ 61 | return self.get_info(("name", "description"), "comments") 62 | 63 | @property 64 | def config(self): 65 | """ Shortcut to bound console's config instance. """ 66 | try: 67 | return self.console.config 68 | except AttributeError: 69 | return Config() 70 | 71 | @property 72 | def name(self): 73 | """ Command name, according to the defined style. """ 74 | n = self.__name__ 75 | if self.style == "lowercase": 76 | n = n.lower() 77 | elif self.style in ["powershell", "slugified"]: 78 | n = re.sub(r'(.)([A-Z][a-z]+)', r'\1-\2', n) 79 | n = re.sub(r'([a-z0-9])([A-Z])', r'\1-\2', n) 80 | n = n.lower() if self.style == "slugified" else n 81 | elif self.style == "uppercase": 82 | n = n.upper() 83 | return n 84 | 85 | 86 | class Command(Entity, metaclass=MetaCommand): 87 | """ Main class handling console commands. """ 88 | # convention: mangled attributes should not be customized when subclassing Command... 89 | _functionalities = FUNCTIONALITIES 90 | _levels = [] 91 | # ... by opposition to public class attributes that can be tuned 92 | aliases = [] 93 | alias_only = False 94 | commands = {} 95 | level = "general" 96 | except_levels = [] 97 | single_arg = False 98 | 99 | @property 100 | def _nargs(self): 101 | """ Get run's signature info (n = number of args, m = number of args with no default). """ 102 | argspec = getfullargspec(self.run) 103 | n = len(argspec.args) - 1 # substract 1 for self 104 | return n, n - len(argspec.defaults or ()) 105 | 106 | @property 107 | def app_folder(self): 108 | """ Shortcut to the current application folder. """ 109 | return self.console.app_folder 110 | 111 | @property 112 | def config(self): 113 | """ Shortcut to bound console's config instance. """ 114 | return self.module.config if hasattr(self, "module") and self.module is not None else \ 115 | self.__class__.console.__class__.config 116 | 117 | @property 118 | def files(self): 119 | """ Shortcut to bound console's file manager instance. """ 120 | return self.console.__class__._files 121 | 122 | @property 123 | def logger(self): 124 | """ Shortcut to bound console's logger instance. """ 125 | return self.console.logger 126 | 127 | @property 128 | @failsafe 129 | def module(self): 130 | """ Shortcut to bound console's module class. """ 131 | return self.console.module 132 | 133 | @property 134 | def modules(self): 135 | """ Shortcut to list of registered modules. """ 136 | return self.console.modules 137 | 138 | @property 139 | def recorder(self): 140 | """ Shortcut to global command recorder. """ 141 | return self.console.__class__._recorder 142 | 143 | @property 144 | def workspace(self): 145 | """ Shortcut to the current workspace. """ 146 | return self.console.workspace 147 | 148 | @classmethod 149 | def check_applicability(cls): 150 | """ Check for Command's applicability. """ 151 | a = getattr(cls, "applies_to", []) 152 | return len(a) == 0 or not hasattr(cls, "console") or cls.console.module.fullpath in a 153 | 154 | @classmethod 155 | def get_help(cls, *levels, **kwargs): 156 | """ Display commands' help(s), using its metaclass' properties. """ 157 | if len(levels) == 0: 158 | levels = Command._levels 159 | if len(levels) == 2 and "general" in levels: 160 | # process a new dictionary of commands, handling levels in order 161 | _ = {} 162 | for l in levels: 163 | for n, c in cls.commands.get(l, {}).items(): 164 | if c.level != "general" or all(l not in levels for l in c.except_levels): 165 | _[n] = c 166 | # then rebuild the dictionary by levels from this dictionary 167 | levels = {"general": {}, "specific": {}} 168 | for n, c in _.items(): 169 | levels[["specific", "general"][c.level == "general"]][n] = c 170 | else: 171 | _, levels = levels, {} 172 | for l in _: 173 | levels[l] = cls.commands[l] 174 | # now make the help with tables of command name-descriptions by level 175 | s, i = "", 0 176 | for l, cmds in sorted(levels.items(), key=lambda x: x[0]): 177 | if len(cmds) == 0 or l in kwargs.get('except_levels', []): 178 | continue 179 | d = [["Command", "Description"]] 180 | for n, c in sorted(cmds.items(), key=lambda x: x[0]): 181 | if not hasattr(c, "console") or not c.check(): 182 | continue 183 | d.append([n, getattr(c, "description", "")]) 184 | if len(d) > 1: 185 | t = BorderlessTable(d, f"{l.capitalize()} commands") 186 | s += t.table + "\n" 187 | i += 1 188 | return "\n" + s.strip() + "\n" if i > 0 else "" 189 | 190 | @classmethod 191 | def register_command(cls, subcls): 192 | """ Register the command and its aliases in a dictionary according to its level. """ 193 | l = subcls.level 194 | levels = [l] if not isinstance(l, (list, tuple)) else l 195 | for l in levels: 196 | Command.commands.setdefault(l, {}) 197 | if l not in Command._levels: 198 | Command._levels.append(l) 199 | if not subcls.alias_only: 200 | Command.commands[l][subcls.name] = subcls 201 | for alias in subcls.aliases: 202 | Command.commands[l][alias] = subcls 203 | logger.detail(f"Registered command alias '{alias}'") 204 | 205 | @classmethod 206 | def set_style(cls, style): 207 | """ Set the style of command name. """ 208 | if style not in COMMAND_STYLES: 209 | raise ValueError(f"Command style must be one of the followings: [{'|'.join(COMMAND_STYLES)}]") 210 | MetaCommand.style = style 211 | 212 | @classmethod 213 | def unregister_command(cls, subcls): 214 | """ Unregister a command class from the subclasses and the commands dictionary. """ 215 | _ = subcls.level 216 | levels = [_] if not isinstance(_, (list, tuple)) else _ 217 | n = subcls.name 218 | # remove every reference in commands dictionary 219 | for l in levels: 220 | for n in [n] + subcls.aliases: 221 | del Command.commands[l][n] 222 | # remove the subclass instance from the subclasses registry 223 | try: 224 | Command.subclasses.remove(subcls) 225 | except ValueError: 226 | pass 227 | # remove the subclass from the global namespace (if not Command itself) 228 | if subcls is not Command: 229 | try: 230 | del globals()[subcls.__name__] 231 | except KeyError: 232 | pass # subcls may be a proxy Command-inherited class 233 | # if the level of commands is become empty, remove it 234 | for l in levels: 235 | if len(Command.commands[l]) == 0: 236 | del Command.commands[l] 237 | logger.detail(f"Unregistered command '{l}/{n}'") 238 | 239 | @classmethod 240 | def unregister_commands(cls, *identifiers): 241 | """ Unregister items from Command based on their 'identifiers' (functionality or level/name). """ 242 | for i in identifiers: 243 | _ = i.split("/", 1) 244 | try: 245 | l, n = _ # level, name 246 | except ValueError: 247 | f, n = _[0], None # functionality 248 | # apply deletions 249 | if n is None: 250 | if f not in cls._functionalities: 251 | raise ValueError(f"Unknown functionality {f}") 252 | p = Path(__file__).parent.joinpath("../base/commands/" + f + ".py").resolve() 253 | for c in PythonPath(str(p)).get_classes(Command): 254 | Command.unregister_command(c) 255 | else: 256 | try: 257 | c = Command.commands[l][n] 258 | Command.unregister_command(c) 259 | except KeyError: 260 | pass 261 | 262 | def _complete_keys(self, *args, **kwargs): 263 | """ Key completion executed method. """ 264 | self.set_keys(*args, **kwargs) 265 | return self.complete_keys(*args, **kwargs) 266 | 267 | def _complete_values(self, *args, **kwargs): 268 | """ Value completion executed method. """ 269 | self.set_values(*args, **kwargs) 270 | return self.complete_values(*args, **kwargs) 271 | 272 | def _validate(self, *args): 273 | """ Value completion executed method. """ 274 | self.set_keys() 275 | self.set_values(*args[:1]) 276 | self.validate(*args) 277 | 278 | def complete_keys(self): 279 | """ Default key completion method (will be triggered if the number of run arguments is 2). """ 280 | return getattr(self, "keys", []) or list(getattr(self, "values", {}).keys()) 281 | 282 | def complete_values(self, key=None): 283 | """ Default value completion method. """ 284 | if self._nargs[0] == 1: 285 | if key is not None: 286 | raise TypeError("complete_values() takes 1 positional argument but 2 were given") 287 | return getattr(self, "values", []) 288 | if self._nargs[0] == 2: 289 | return getattr(self, "values", {}).get(key) 290 | return [] 291 | 292 | def set_keys(self): 293 | """ Default key setting method. """ 294 | pass 295 | 296 | def set_values(self, key=None): 297 | """ Default value setting method. """ 298 | pass 299 | 300 | def validate(self, *args): 301 | """ Default validation method. """ 302 | # check for the signature and, if relevant, validating keys and values 303 | n_in = len(args) 304 | n, m = self._nargs 305 | if n_in < m or n_in > n: 306 | pargs = "from %d to %d" % (m, n) if n != m else "%d" % n 307 | raise TypeError("validate() takes %s positional argument%s but %d were given" % \ 308 | (pargs, ["", "s"][n > 0], n_in)) 309 | if n == 1: # command format: COMMAND VALUE 310 | l = self.complete_values() or [] 311 | if n_in == 1 and len(l) > 0 and args[0] not in l: 312 | raise ValueError("invalid value") 313 | elif n == 2: # command format: COMMAND KEY VALUE 314 | l = self.complete_keys() or [] 315 | if n_in > 0 and len(l) > 0 and args[0] not in l: 316 | raise ValueError("invalid key") 317 | l = self.complete_values(args[0]) or [] 318 | if n_in == 2 and len(l) > 0 and args[1] not in l: 319 | raise ValueError("invalid value") 320 | 321 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/__init__.py: -------------------------------------------------------------------------------- 1 | from .completer import * 2 | from .config import * 3 | from .defaults import * 4 | from .files import * 5 | from .jobs import * 6 | from .layout import * 7 | from .logger import * 8 | from .recorder import * 9 | from .sessions import * 10 | from .store import * 11 | from .validator import * 12 | 13 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/completer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from prompt_toolkit.completion import Completer, Completion 3 | 4 | 5 | __all__ = ["CommandCompleter"] 6 | 7 | 8 | def _filter_sort(lst, prefix=None, sort=False): 9 | if sort: 10 | lst = sorted(map(str, set(lst or [])), key=lambda s: str(s).casefold()) 11 | for x in lst or []: 12 | if prefix is None or x.startswith(prefix): 13 | yield x 14 | 15 | 16 | class CommandCompleter(Completer): 17 | """ Completer for console's commands and arguments. """ 18 | def get_completions(self, document, complete_event): 19 | # this completion method handles the following formats: 20 | # 1) COMMAND VALUE ; e.g. create my-project 21 | # 2) COMMAND KEY VALUE ; e.g. set LHOST 192.168.1.1 22 | # first, tokenize document.text and initialize some shorcut variables 23 | d = document.text 24 | tokens = self.console._get_tokens(d) 25 | l = len(tokens) 26 | ts = len(d) - len(d.rstrip(" ")) # trailing spaces 27 | try: 28 | cmd, t1, t2 = tokens + [None] * (3 - l) 29 | except: # occurs when l > 3 ; no need to complete anything as it corresponds to an invalid command 30 | return 31 | bc = len(document.text_before_cursor) 32 | it = len(d) - bc > 0 33 | o1 = len(cmd) + 1 - bc if cmd else 0 34 | o2 = len(cmd) + len(t1 or "") + 2 - bc if cmd and t2 else 0 35 | cmds = {k: v for k, v in self.console.commands.items()} 36 | c = cmds[cmd]._instance if cmd in cmds else None 37 | nargs = len(c.args) if c is not None else 0 38 | # then handle tokens ; 39 | # when no token is provided, just yield the list of available commands 40 | if l == 0: 41 | for x in _filter_sort(cmds.keys(), sort=True): 42 | yield Completion(x, start_position=0) 43 | # when one token is provided, handle format: 44 | # [PARTIAL_]COMMAND ... 45 | elif l == 1: 46 | # when a partial token is provided, yield the list of valid commands 47 | if ts == 0 and c not in cmds: 48 | for x in _filter_sort(cmds, cmd, True): 49 | yield Completion(x, start_position=-bc) 50 | # when a valid command is provided, yield the list of valid keys or values, depending on the type of command 51 | elif ts > 0 and c is not None: 52 | if nargs == 1: # COMMAND VALUE 53 | for x in _filter_sort(c._complete_values(), sort=True): 54 | yield Completion(x, start_position=0) 55 | # e.g. set ---> ["WORKSPACE", ...] 56 | elif nargs == 2: # COMMAND KEY VALUE 57 | for x in _filter_sort(c._complete_keys(), sort=True): 58 | yield Completion(x, start_position=0) 59 | # when two tokens are provided, handle format: 60 | # COMMAND [PARTIAL_](KEY ...|VALUE) 61 | elif l == 2 and c is not None: 62 | # when a partial value token is given, yield the list of valid ones 63 | # e.g. select my-pro ---> ["my-project", ...] 64 | if nargs == 1 and ts == 0: 65 | for x in _filter_sort(c._complete_values(), t1, True): 66 | yield Completion(x, start_position=o1) 67 | # when a partial key token is given, yield the list of valid ones 68 | # e.g. set W ---> ["WORKSPACE"] 69 | elif nargs == 2 and ts == 0: 70 | for x in _filter_sort(c._complete_keys(), t1, True): 71 | yield Completion(x, start_position=o1) 72 | # when a valid key token is given, yield the list of values 73 | # e.g. set WORKSPACE ---> ["/home/user/...", "..."] 74 | elif nargs == 2 and ts > 0 and t1 in c._complete_keys(): 75 | for x in _filter_sort(c._complete_values(t1), sort=True): 76 | yield Completion(x, start_position=0) 77 | # when three tokens are provided, handle format: 78 | # COMMAND KEY [PARTIAL_]VALUE 79 | elif l == 3 and c is not None and t1 in c._complete_keys(): 80 | if nargs == 2 and ts == 0: 81 | for x in _filter_sort(c._complete_values(t1), t2, True): 82 | yield Completion(x, start_position=o2) 83 | # handle no other format 84 | 85 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from .logger import * 3 | 4 | 5 | __all__ = ["Config", "Option", "ProxyConfig", "ROption"] 6 | 7 | 8 | logger = get_logger("core.components.config") 9 | 10 | 11 | class Config(dict): 12 | """ Enhanced dictionary for handling Option instances as its keys. """ 13 | def __init__(self, *args, **kwargs): 14 | self._d = {} 15 | if not hasattr(Config, "_g"): 16 | Config._g = {} 17 | # this will set options for this config, that is, creating NEW Option instances based on the given ones 18 | self.update(*args, **kwargs) 19 | 20 | def __add__(self, config): 21 | """ Method for appending another config. """ 22 | return ProxyConfig() + self + config 23 | 24 | def __delitem__(self, key): 25 | """ Custom method for deleting an item, for triggering an unset callback from an Option. """ 26 | try: 27 | l = self.console.logger 28 | except: 29 | l = null_logger 30 | key = self._getkey(key) 31 | self[key] = getattr(self, "default", None) 32 | self.__run_callback(key, "unset") 33 | if key._reset: 34 | try: 35 | self.console.reset() 36 | except AttributeError as err: 37 | pass 38 | l.debug(f"{key.name} => null") 39 | 40 | def __getitem__(self, key): 41 | """ Custom method for getting an item, returning the original value from the current Config instance or, if the 42 | key does not exist and this instance has a parent, try to get it from the parent. """ 43 | try: # search first in the private dictionary 44 | return self._getitem(key) 45 | except KeyError: 46 | pass 47 | try: # then search in the parent ProxyConfig 48 | return self.parent[key] 49 | except (AttributeError, KeyError): 50 | pass 51 | try: # finally search in the config of the parent console 52 | return self.console.parent.config[key] 53 | except (AttributeError, KeyError): 54 | pass 55 | raise KeyError(key) 56 | 57 | def __setitem__(self, key, value): 58 | """ Custom method for setting an item, keeping the original value in a private dictionary. """ 59 | try: 60 | l = self.console.logger 61 | except AttributeError: 62 | l = null_logger 63 | if isinstance(key, (list, tuple)) and len(key) == 2 and isinstance(key[0], str) and isinstance(key[1], bool): 64 | key, force = key 65 | else: 66 | force = False 67 | key = self._setkey(key, value) 68 | if not force and key.old_value == key.value: 69 | try: 70 | l.debug(f"{key.name} unchanged") 71 | except AttributeError: 72 | pass 73 | return # stop here if the final value is unchanged 74 | # when the value is validated and assigned, run the callback function 75 | self.__run_callback(key, "set") 76 | if key._reset: 77 | try: 78 | self.console.reset() 79 | except AttributeError as err: 80 | pass 81 | l.success(f"{key.name} => {value if force else key.value}") 82 | 83 | def __str__(self): 84 | """ Custom string method. """ 85 | data = [["Name", "Value", "Required", "Description"]] 86 | l = len(list(self.items(False))) 87 | for n, d, v, r in sorted(self.items(False), key=lambda x: x[0]): 88 | if v is None and l > 1: 89 | continue 90 | r = ["N", "Y"][r] 91 | if v == "": 92 | from tinyscript.helpers import colored 93 | n, v, r = map(lambda s: colored(s, "red", attrs=['bold']), [n, v, r]) 94 | data.append([n, v, r, d]) 95 | if len(data) > 1: 96 | from tinyscript.helpers import BorderlessTable 97 | try: 98 | prefix = self.console.opt_prefix 99 | except AttributeError: 100 | prefix = None 101 | return BorderlessTable(data).table if prefix is None else \ 102 | BorderlessTable(data, f"{prefix} options").table 103 | return "" 104 | 105 | def __run_callback(self, key, name): 106 | """ Method for executing a callback and updating the current value with its return value if any. """ 107 | logger.detail(f"{key} {name} callback triggered") 108 | retval = None 109 | if hasattr(self, "_last_error"): 110 | del self._last_error 111 | try: 112 | retval = getattr(key, f"{name}_callback")() 113 | except Exception as e: 114 | self._last_error = e 115 | if True:#not isinstance(e, AttributeError): 116 | raise 117 | if retval is not None: 118 | key.old_value = key.value 119 | if not key.validate(retval): 120 | raise ValueError(f"Invalid value '{retval}'") 121 | self._d[key.name] = (key, retval) 122 | 123 | def _getitem(self, key): 124 | """ Custom method for getting an item, returning the original value from the current Config instance. """ 125 | return self._d[key.name if isinstance(key, Option) else key][1] 126 | 127 | def _getkey(self, key): 128 | """ Proxy method for ensuring that the key is an Option instance. """ 129 | if not isinstance(key, Option): 130 | try: 131 | key = self.option(key) 132 | except KeyError: 133 | if not isinstance(key, tuple): 134 | key = (key,) 135 | key = Option(*key) 136 | return key 137 | 138 | def _getoption(self, key): 139 | """ Return Option instance from key. """ 140 | return self._d[key.name if isinstance(key, Option) else key][0] 141 | 142 | def _setkey(self, key, value): 143 | """ Proxy method for setting a key-value as a validated Option instance. """ 144 | key = tmp = self._getkey(key) 145 | # get an existing instance or the new one 146 | key = key.bind(self if not hasattr(key, "config") else key.config) 147 | if tmp is not key: 148 | del tmp # if an instance already existed, remove the new one 149 | key.config._setkey(key, value) 150 | return key 151 | # keep track of the previous value 152 | try: 153 | key.old_value = key.value 154 | except (KeyError, ValueError): 155 | key.old_value = None 156 | # then assign the new one if it is valid 157 | self._d[key.name] = (key, value) 158 | if value is not None and not key.validate(value): 159 | raise ValueError(f"Invalid value '{value}' for key '{key.name}'") 160 | super(Config, self).__setitem__(key, value) 161 | return key 162 | 163 | def copy(self, config, key): 164 | """ Copy an option based on its key from another Config instance. """ 165 | self[config.option(key)] = config[key] 166 | 167 | def items(self, fail=True): 168 | """ Return (key, descr, value, required) instead of (key, value). """ 169 | for o in sorted(self, key=lambda x: x.name): 170 | try: 171 | n = str(o.name) 172 | v = o.value 173 | except ValueError as e: 174 | if fail: 175 | raise e 176 | v = "" 177 | yield n, o.description or "", v, o.required 178 | 179 | def keys(self, glob=False): 180 | """ Return string keys (like original dict). """ 181 | from itertools import chain 182 | l = [k for k in self._d.keys()] 183 | if glob: 184 | for k in chain(self._d.keys(), Config._g.keys()): 185 | try: 186 | getattr(l, ["remove", "append"][self.option(k).glob])(k) 187 | except KeyError: 188 | pass 189 | for k in sorted(l): 190 | yield k 191 | 192 | def option(self, key): 193 | """ Return Option instance from key, also searching for this in parent configs. """ 194 | try: # search first in the private dictionary 195 | return self._getoption(key) 196 | except KeyError: 197 | pass 198 | try: # then search in the parent ProxyConfig 199 | return self.parent.option(key) 200 | except (AttributeError, KeyError): 201 | pass 202 | try: # finally search in the config of the parent console 203 | return self.console.parent.config.option(key) 204 | except (AttributeError, KeyError): 205 | pass 206 | raise KeyError(key) 207 | 208 | def options(self): 209 | """ Return Option instances instead of keys. """ 210 | for k in sorted(self._d.keys()): 211 | yield self._d[k][0] 212 | 213 | def setdefault(self, key, value=None): 214 | """ Custom method for forcing the use of the modified __setitem__. """ 215 | if key not in self: 216 | self._setkey(key, value) # this avoids triggering callbacks (on the contrary of self[key]) ! 217 | return self[key] 218 | 219 | def setglobal(self, key, value): 220 | """ Set a global key-value. """ 221 | Config._g[key] = value 222 | 223 | def unsetglobal(self, key): 224 | """ Unset a global key. """ 225 | del Config._g[key] 226 | 227 | def update(self, *args, **kwargs): 228 | """ Custom method for handling update of another Config and forcing the use of the modified __setitem__. """ 229 | if len(args) > 0: 230 | if len(args) > 1: 231 | raise TypeError("update expected at most 1 argument, got %d" % len(args)) 232 | d = args[0] 233 | for k in (d.options() if isinstance(d, Config) else d.keys() if isinstance(d, dict) else []): 234 | k = self._setkey(k, d[k]) # this avoids triggering callbacks (on the contrary of self[key]) ! 235 | k.default = d[k] 236 | # important note: this way, this will cause Option instances to be bound to THIS Config instance, with their 237 | # default attribute values (description, required, ...) 238 | for k, v in kwargs.items(): 239 | k, self._setkey(k, v) # this avoids triggering callbacks (on the contrary of self[key]) ! 240 | k.default = v 241 | 242 | @property 243 | def bound(self): 244 | return hasattr(self, "_console") or (hasattr(self, "module") and hasattr(self.module, "console")) 245 | 246 | @property 247 | def console(self): 248 | # check first that the console is back-referenced on an attached module instance 249 | if hasattr(self, "module") and hasattr(self.module, "console"): 250 | return self.module.console 251 | # then check for a direct reference 252 | if self.bound: 253 | c = self._console 254 | return c() if isinstance(c, type(lambda:0)) else c 255 | # finally try to get it from the parent ProxyConfig 256 | if hasattr(self, "parent"): 257 | # reference the callee to let ProxyConfig.__getattribute__ avoid trying to get the console attribute from 258 | # the current config object, ending in an infinite loop 259 | self.parent._caller = self 260 | try: 261 | return self.parent.console 262 | except AttributeError: 263 | pass 264 | raise AttributeError("'Config' object has no attribute 'console'") 265 | 266 | @console.setter 267 | def console(self, value): 268 | self._console = value 269 | 270 | 271 | class Option(object): 272 | """ Class for handling an option with its parameters while using it as key for a Config dictionary. """ 273 | _instances = {} 274 | _reset = False 275 | old_value = None 276 | 277 | def __init__(self, name, description=None, required=False, choices=None, suggestions=None, set_callback=None, 278 | unset_callback=None, transform=None, validate=None, glob=True): 279 | if choices is not None and suggestions is not None: 280 | raise ValueError("choices and suggestions cannot be set at the same time") 281 | self.name = name 282 | self.description = description 283 | self.required = required 284 | self.glob = glob 285 | if choices is bool or suggestions is bool: 286 | choices = ["true", "false"] 287 | self._choices = choices if choices is not None else suggestions 288 | self.__set_func(transform, "transform") 289 | if validate is None and choices is not None: 290 | validate = lambda s, v: str(v).lower() in [str(c).lower() for c in s.choices] 291 | self.__set_func(validate, "validate") 292 | self.__set_func(set_callback, "set_callback", lambda *a, **kw: None) 293 | self.__set_func(unset_callback, "unset_callback", lambda *a, **kw: None) 294 | 295 | def __repr__(self): 296 | """ Custom representation method. """ 297 | return str(self) 298 | 299 | def __str__(self): 300 | """ Custom string method. """ 301 | return f"<{self.name}[{'NY'[self.required]}]>" 302 | 303 | def __set_func(self, func, name, default_func=None): 304 | """ Set a function, e.g. for manipulating option's value. """ 305 | if func is None: 306 | func = default_func or (lambda *a, **kw: a[-1] if len(a) > 0 else None) 307 | if isinstance(func, type(lambda:0)): 308 | setattr(self, name, func.__get__(self, self.__class__)) 309 | else: 310 | raise Exception(f"Bad {name} lambda") 311 | 312 | def bind(self, parent): 313 | """ Register this instance as a key of the given Config or retrieve the already existing one. """ 314 | o, i = Option._instances, id(parent) 315 | o.setdefault(i, {}) 316 | if o[i].get(self.name) is None: 317 | self.config = parent 318 | o[i][self.name] = self 319 | else: 320 | o[i][self.name].config = parent 321 | return o[i][self.name] 322 | 323 | @property 324 | def choices(self): 325 | """ Pre- or lazy-computed list of choices. """ 326 | from tinyscript.helpers import is_function 327 | c = self._choices 328 | if not is_function(c): 329 | return c 330 | try: 331 | return c() 332 | except TypeError: 333 | return c(self) 334 | 335 | @property 336 | def console(self): 337 | """ Shortcut to parent config's console attribute. """ 338 | return self.config.console 339 | 340 | @property 341 | def input(self): 342 | """ Original input value. """ 343 | if hasattr(self, "config"): 344 | return self.config[self] 345 | else: 346 | raise Exception(f"Unbound option {self.name}") 347 | 348 | @property 349 | def module(self): 350 | """ Shortcut to parent config's console bound module attribute. """ 351 | return self.console.module 352 | 353 | @property 354 | def root(self): 355 | """ Shortcut to parent config's root console attribute. """ 356 | return self.console.root or self.console 357 | 358 | @property 359 | def state(self): 360 | """ Shortcut to parent console's state attribute. """ 361 | return self.console.state 362 | 363 | @property 364 | def value(self): 365 | """ Normalized value attribute. """ 366 | value = self.input 367 | if value == getattr(self, "default", None): 368 | value = Config._g.get(self.name, value) 369 | if self.required and value is None: 370 | raise ValueError(f"{self.name} must be defined") 371 | # try to expand format variables using console's attributes 372 | from re import findall 373 | try: 374 | kw = {} 375 | for n in findall(r'\{([a-z]+)\}', str(value)): 376 | kw[n] = self.config.console.__dict__.get(n, "") 377 | try: 378 | value = value.format(**kw) 379 | except: 380 | pass 381 | except AttributeError as e: # occurs when console is not linked to config (i.e. at startup) 382 | pass 383 | # expand and resolve paths 384 | if self.name.endswith("FOLDER") or self.name.endswith("WORKSPACE"): 385 | from tinyscript.helpers import Path 386 | # this will ensure that every path is expanded 387 | value = str(Path(value, expand=True)) 388 | # convert common formats to their basic types 389 | try: 390 | if value.isdigit(): 391 | value = int(value) 392 | if value.lower() in ["false", "true"]: 393 | value = value.lower() == "true" 394 | except AttributeError: # occurs e.g. if value is already a bool 395 | pass 396 | # then try to transform using the user-defined function 397 | if isinstance(self.transform, type(lambda:0)) and self.transform.__name__ == (lambda:0).__name__: 398 | value = self.transform(value) 399 | return value 400 | 401 | 402 | class ProxyConfig(object): 403 | """ Proxy class for mixing multiple Config instances, keeping original references to Option instances (as they are 404 | managed based on Config's instance identifier). """ 405 | def __init__(self, *args): 406 | self.__configs = [] 407 | for config in args: 408 | self.append(config) 409 | 410 | def __add__(self, config): 411 | """ Method for appending another config. """ 412 | self.append(config) 413 | return self 414 | 415 | def __delitem__(self, key): 416 | """ Del method removing the giving key in every bound config instance. """ 417 | for c in self.configs: 418 | del c[key] 419 | 420 | def __getattribute__(self, name): 421 | """ Custom getattribute method for aggregating Config instances for some specific methods and attributes. """ 422 | # try to get it from this class first 423 | try: 424 | return super(ProxyConfig, self).__getattribute__(name) 425 | except AttributeError: 426 | pass 427 | # for these methods, create an aggregated config and get its attribute 428 | # from this new instance 429 | if name in ["items", "keys", "options"]: 430 | try: 431 | c = Config() 432 | for config in self.__configs: 433 | c.update(config) 434 | except IndexError: 435 | c = Config() 436 | return c.__getattribute__(name) 437 | # for this attribute, only try to get this of the first config 438 | if name == "console": 439 | c = self.__configs[0] 440 | if c is not getattr(self, "_caller", None): 441 | if c.bound: 442 | return c.console 443 | # for any other, get the first one found from the list of configs 444 | else: 445 | for c in self.__configs: 446 | if name != "_caller" and c is getattr(self, "_caller", None): 447 | continue 448 | try: 449 | return c.__getattribute__(name) 450 | except AttributeError: 451 | continue 452 | raise AttributeError(f"'ProxyConfig' object has no attribute '{name}'") 453 | 454 | def __getitem__(self, key): 455 | """ Get method for returning the first occurrence of a key among the list of Config instances. """ 456 | # search for the first config that has this key and return the value 457 | for c in self.configs: 458 | try: 459 | return c._getitem(key) 460 | except KeyError: 461 | pass 462 | # if not found, raise KeyError 463 | raise KeyError(key) 464 | 465 | def __setattr__(self, name, value): 466 | """ Custom setattr method for handling the backref to a console. """ 467 | if name == "console": 468 | if len(self.configs) > 0: 469 | self.configs[0].console = value 470 | else: 471 | super(ProxyConfig, self).__setattr__(name, value) 472 | 473 | def __setitem__(self, key, value): 474 | """ Set method setting a key-value pair in the right Config among the list of Config instances. First, it tries 475 | to get the option corresponding to the given key and if it exists, it sets the value. Otherwise, it sets a 476 | new key in the first Config among the list """ 477 | try: 478 | c = self.option(key).config 479 | except KeyError: 480 | c = self.configs[0] if len(self.configs) > 0 else Config() 481 | return c.__setitem__(key, value) 482 | 483 | def __str__(self): 484 | """ String method for aggregating the list of Config instances. """ 485 | c = Config() 486 | for config in self.configs: 487 | if not hasattr(c, "console"): 488 | try: 489 | c.console = config.console 490 | except AttributeError: 491 | pass 492 | c.update(config) 493 | return str(c) 494 | 495 | def append(self, config): 496 | """ Method for apending a config to the list (if it does not exist). """ 497 | for c in ([config] if isinstance(config, Config) else config.configs): 498 | if c not in self.configs: 499 | self.configs.append(c) 500 | c.parent = self 501 | 502 | def get(self, key, default=None): 503 | """ Adapted get method (wrt Config). """ 504 | try: 505 | return self[key] 506 | except KeyError: 507 | return default 508 | 509 | def option(self, key): 510 | """ Adapted option method (wrt Config). """ 511 | # search for the first config that has this key and return its Option 512 | for c in self.configs: 513 | try: 514 | self[key] 515 | return c._getoption(key) 516 | except KeyError: 517 | pass 518 | # if not found, raise KeyError 519 | raise KeyError(key) 520 | 521 | @property 522 | def configs(self): 523 | return self.__configs 524 | 525 | 526 | class ROption(Option): 527 | """ Class for handling a reset option (that is, an option that triggers a console reset after change) with its 528 | parameters while using it as key for a Config dictionary. """ 529 | _reset = True 530 | 531 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/defaults.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | 3 | ROOT_LEVEL = "root" # console root level's name 4 | 5 | 6 | # list of folders from which related items are to be loaded 7 | SOURCES = { 8 | 'banners': None, 9 | 'entities': ["commands", "models", "modules"], 10 | 'libraries': ".", 11 | } 12 | 13 | 14 | # dictionary of back-references to be made on entities 15 | BACK_REFERENCES = { 16 | 'console': [("config", "console")], 17 | } 18 | 19 | 20 | # prompt message format 21 | PROMPT_FORMAT = [ 22 | ('class:prompt', " > "), 23 | ] 24 | 25 | 26 | # prompt message style 27 | PROMPT_STYLE = { 28 | '': "#30b06f", # text after the prompt 29 | 'prompt': "#eeeeee", # prompt message 30 | 'appname': "#eeeeee underline", # application name 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import re 3 | import requests 4 | from ftplib import FTP, FTP_TLS 5 | from shutil import which 6 | from subprocess import call, PIPE 7 | from tinyscript.helpers import Path 8 | 9 | 10 | __all__ = ["FilesManager"] 11 | 12 | 13 | class FilesManager(dict): 14 | """ Files dictionary for registering files, if necessary downloading them using multiple supported schemes. """ 15 | root_dir = "." 16 | 17 | def _file(self, locator, *args, **kwargs): 18 | """ Simple local file copier. """ 19 | self[locator] = open(locator.split("://", 1)[1], 'rb') 20 | 21 | def _ftp(self, locator, *args, **kwargs): 22 | """ Simple FTP downloader. """ 23 | scheme = locator.split("://", 1)[0] 24 | client = [FTP, FTP_TLS][scheme == "ftps"]() 25 | client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) 26 | if scheme == "ftps": 27 | client.auth() 28 | usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") 29 | if usr != "" and pswd != "": 30 | client.login(usr, passwd) 31 | #client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) 32 | #FIXME 33 | _ftps = _ftp 34 | 35 | def _http(self, url, *args, **kwargs): 36 | """ Simple HTTP downloader. """ 37 | self[url] = requests.get(url, *args, **kwargs).content 38 | _https = _http 39 | 40 | def edit(self, filename): 41 | """ Edit a file using the configured text editor. """ 42 | #FIXME: edit by calling the locator and manage its local file (e.g. for a URL, point to a temp folder) 43 | ted = self.console.config['TEXT_EDITOR'] 44 | if which(ted) is None: 45 | raise ValueError(f"'{ted}' does not exist or is not installed") 46 | p = Path(self.console.config['WORKSPACE']).joinpath(filename) 47 | if not p.exists(): 48 | p.touch() 49 | call([ted, str(p)], stderr=PIPE) 50 | 51 | def get(self, locator, *args, **kwargs): 52 | """ Get a resource. """ 53 | if locator in self.keys() and not kwargs.pop("force", False): 54 | return self[locator] 55 | scheme, path = locator.split("://") 56 | if scheme in ["http", "https"]: 57 | r = requests.get(locator, *args, **kwargs) 58 | self[locator] = r.content 59 | if r.status_code == 403: 60 | raise ValueError("Forbidden") 61 | elif scheme in ["ftp", "ftps"]: 62 | client = [FTP, FTP_TLS][schem == "ftps"]() 63 | client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) 64 | if scheme == "ftps": 65 | client.auth() 66 | usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") 67 | if usr != "" and pswd != "": 68 | client.login(usr, passwd) 69 | client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) 70 | #FIXME 71 | elif scheme == "file": 72 | with open(path, 'rb') as f: 73 | self[locator] = f.read() 74 | else: 75 | raise ValueError(f"Unsupported scheme '{scheme}'") 76 | 77 | def page(self, *filenames): 78 | """ Page a list of files using Less. """ 79 | tvw = self.console.config['TEXT_VIEWER'] 80 | if which(tvw) is None: 81 | raise ValueError(f"'{tvw}' does not exist or is not installed") 82 | filenames = list(map(str, filenames)) 83 | for f in filenames: 84 | if not Path(str(f)).is_file(): 85 | raise OSError("File does not exist") 86 | call([tvw] + filenames, stderr=PIPE) 87 | 88 | def page_text(self, text): 89 | """ Page a text using Less. """ 90 | tmp = self.tempdir.tempfile() 91 | tmp.write_text(text) 92 | self.page(str(tmp)) 93 | 94 | def save(self, key, dst): 95 | """ Save a resource. """ 96 | with open(dst, 'wb') as f: 97 | f.write(self[key]) 98 | 99 | def view(self, key): 100 | """ View a file using the configured text viewer. """ 101 | from tinyscript.helpers import txt_terminal_render 102 | try: 103 | self.page_text(self[key]) 104 | except KeyError: 105 | pass 106 | p = Path(self.console.config['WORKSPACE'], expand=True).joinpath(key) 107 | if p.suffix == ".md": 108 | self.page_text(txt_terminal_render(p.text, format="md").strip()) 109 | else: 110 | # if the given key is not in the dictionary of files (APP_FOLDER/files/), it can still be in the workspace 111 | self.page(p) 112 | 113 | @property 114 | def list(self): 115 | """ Get the list of files from the workspace. """ 116 | p = Path(self.console.config['WORKSPACE']).expanduser() 117 | for f in p.walk(filter_func=lambda p: p.is_file(), relative=True): 118 | if all(not re.match(x, f.filename) for x in ["(data|key|store)\.db.*", "history"]): 119 | yield f 120 | 121 | @property 122 | def tempdir(self): 123 | """ Get the temporary directory. """ 124 | from tinyscript.helpers import TempPath 125 | if not hasattr(self, "_tempdir"): 126 | self._tempdir = TempPath(prefix=f"{self.console.appname}-", length=16) 127 | return self._tempdir 128 | 129 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/jobs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import shlex 3 | import subprocess 4 | from tinyscript.helpers.text import ansi_seq_strip 5 | 6 | 7 | __all__ = ["JobsPool"] 8 | 9 | 10 | communicate = lambda p, **i: tuple(map(lambda x: x.decode().strip(), p.communicate(**i))) 11 | 12 | 13 | class Job(subprocess.Popen): 14 | """ Subprocess-based job class, bound to its parent pool. """ 15 | def __init__(self, cmd, **kwargs): 16 | self.parent = kwargs.pop('parent') 17 | debug = not kwargs.pop('no_debug', False) 18 | if debug: 19 | self.parent.logger.debug(" ".join(cmd) if isinstance(cmd, (tuple, list)) else cmd) 20 | cmd = shlex.split(cmd) if isinstance(cmd, str) and not kwargs.get('shell', False) else cmd 21 | super(Job, self).__init__(cmd, stdout=subprocess.PIPE, **kwargs) 22 | self._debug = debug 23 | 24 | def close(self, wait=True): 25 | for s in ["stdin", "stdout", "stderr"]: 26 | getattr(getattr(self, s, object()), "close", lambda: None)() 27 | if wait: 28 | return self.wait() 29 | 30 | 31 | class JobsPool(object): 32 | """ Subprocess-based pool for managing open jobs. """ 33 | def __init__(self, max_jobs=None): 34 | self.__jobs = {None: []} 35 | self.max = max_jobs 36 | 37 | def __iter__(self): 38 | for j in self.__jobs.items(): 39 | yield j 40 | 41 | def background(self, cmd, **kwargs): 42 | subpool = kwargs.pop('subpool') 43 | self.__jobs.setdefault(subpool, []) 44 | self.__jobs[subpool].append(Job(cmd, parent=self, **kwargs)) 45 | 46 | def call(self, cmd, **kwargs): 47 | kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE 48 | return subprocess.call(shlex.split(cmd), **kwargs) 49 | 50 | def free(self, subpool=None): 51 | for p in self.__jobs[subpool]: 52 | if p.poll(): 53 | p.close(False) 54 | self.__jobs[subpool].remove(p) 55 | 56 | def run(self, cmd, stdin=None, show=False, timeout=None, ansi_strip=True, **kwargs): 57 | kwargs['stderr'] = subprocess.PIPE 58 | kwargs['stdin'] = (None if stdin is None else subprocess.PIPE) 59 | p = Job(cmd, parent=self, **kwargs) 60 | com_kw = {} 61 | if stdin is not None: 62 | com_kw['input'] = stdin.encode() 63 | if timeout is not None: 64 | com_kw['timeout'] = timeout 65 | out, err = "", "" 66 | try: 67 | out, err = tuple(map(lambda x: x.decode().strip(), p.communicate(**com_kw))) 68 | except (KeyboardInterrupt, subprocess.TimeoutExpired): 69 | out = [] 70 | for line in iter(p.stdout.readline, ""): 71 | out.append(line) 72 | out = "\n".join(out) 73 | err = [] 74 | for line in iter(p.stderr.readline, ""): 75 | err.append(line) 76 | err = "\n".join(err) 77 | if out != "" and p._debug: 78 | getattr(self.logger, ["debug", "info"][show])(out) 79 | if err != "" and p._debug: 80 | getattr(self.logger, ["debug", "error"][show])(err) 81 | if ansi_strip: 82 | out = ansi_seq_strip(out) 83 | return out, err 84 | 85 | def run_iter(self, cmd, timeout=None, ansi_strip=True, **kwargs): 86 | from time import time 87 | kwargs['stderr'] = subprocess.STDOUT 88 | kwargs['universal_newlines'] = True 89 | p = Job(cmd, parent=self, **kwargs) 90 | s = time() 91 | #FIXME: cleanup this part 92 | def readline(): 93 | while True: 94 | try: 95 | l = p.stdout.readline() 96 | if l == "": 97 | break 98 | except UnicodeDecodeError: 99 | continue 100 | yield l 101 | try: 102 | for line in readline(): 103 | if len(line) > 0: 104 | if p._debug: 105 | self.logger.debug(line) 106 | if ansi_strip: 107 | line = ansi_seq_strip(line) 108 | yield line 109 | if timeout is not None and time() - s > timeout: 110 | break 111 | finally: 112 | p.kill() 113 | p.close() 114 | 115 | def terminate(self, subpool=None): 116 | for p in self.__jobs.get(subpool, []): 117 | p.terminate() 118 | p.close() 119 | self.__jobs[subpool].remove(p) 120 | 121 | @property 122 | def logger(self): 123 | if hasattr(self, "console"): 124 | return self.console.logger 125 | from sploitkit.core.components.logger import null_logger 126 | return null_logger 127 | 128 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/layout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from prompt_toolkit.widgets import FormattedTextToolbar, TextArea 3 | #TODO: do not forget to remove unuseful imports 4 | 5 | 6 | __all__ = ["CustomLayout"] 7 | 8 | 9 | #TODO: determine if this module is still useful ; remove it if necessary 10 | 11 | class AppToolbar(FormattedTextToolbar): 12 | pass 13 | 14 | 15 | class CustomLayout(object): 16 | def __init__(self, console): 17 | self.layout = console._session.app.layout 18 | #self.layout.container.children = self.layout.container.children[:-1] 19 | #print(self.layout.container.children) 20 | 21 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | from tinyscript import colored, logging 3 | 4 | 5 | __all__ = ["get_logger", "null_logger", "set_logging_level"] 6 | 7 | 8 | DATETIME_FORMAT = "%m/%d/%y %H:%M:%S" 9 | LOGFILE_FORMAT = "%(asctime)s [%(process)5d] %(levelname)8s %(name)s - %(message)s" 10 | LOG_FORMAT = "%(levelsymbol)s %(message)s" 11 | LOG_FORMAT_DBG = "%(asctime)s %(name)32s %(levelname)8s %(message)s" 12 | LOG_LEVEL_SYMBOLS = { 13 | logging.DETAIL: colored("[#]", "white"), # this is aimed to provide even more info in dev mode 14 | logging.DEBUG: colored("[#]", "white"), # this is aimed to be used in normal mode 15 | logging.INFO: colored("[*]", "blue"), 16 | logging.WARNING: colored("[!]", "yellow"), 17 | logging.SUCCESS: colored("[+]", "green", attrs=['bold']), 18 | logging.ERROR: colored("[-]", "red", attrs=['bold']), 19 | logging.CRITICAL: colored("[X]", "red", attrs=['bold']), 20 | None: colored("[?]", "grey"), 21 | } 22 | 23 | 24 | # this avoids throwing e.g. FutureWarning or DeprecationWarning messages 25 | logging.captureWarnings(True) 26 | logger = logging.getLogger('py.warnings') 27 | logger.setLevel(logging.CRITICAL) 28 | 29 | 30 | # silent sh module's logging 31 | logger = logging.getLogger('sh.command') 32 | logger.setLevel(level=logging.WARNING) 33 | logger = logging.getLogger('sh.streamreader') 34 | logger.setLevel(level=logging.WARNING) 35 | logger = logging.getLogger('sh.stream_bufferer') 36 | logger.setLevel(level=logging.WARNING) 37 | 38 | 39 | # make aliases from logging functions 40 | null_logger = logging.nullLogger 41 | set_logging_level = logging.setLoggingLevel 42 | 43 | 44 | # add a custom message handler for tuning the format with 'levelsymbol' 45 | class ConsoleHandler(logging.StreamHandler): 46 | def emit(self, record): 47 | record.levelsymbol = LOG_LEVEL_SYMBOLS.get(record.levelno, "") 48 | super(ConsoleHandler, self).emit(record) 49 | 50 | 51 | # logging configuration 52 | def get_logger(name, logfile=None, level="INFO", dev=False, enabled=True): 53 | """ Logger initialization function. """ 54 | def _setup_logfile(l): 55 | from logging.handlers import RotatingFileHandler 56 | if logfile is not None and not any(isinstance(h, RotatingFileHandler) for h in l.handlers): 57 | l.__logfile__ = logfile 58 | # setup a FileHandler for logging to a file (at level DEBUG) 59 | fh = RotatingFileHandler(logfile) 60 | fh.setFormatter(logging.Formatter(LOGFILE_FORMAT, datefmt=DATETIME_FORMAT)) 61 | fh.setLevel(level) 62 | l.addHandler(fh) 63 | else: 64 | l.__logfile__ = None 65 | 66 | logger = logging.getLogger(name) 67 | logger.propagate = False 68 | level = getattr(logging, level) if not isinstance(level, int) else level 69 | # distinguish dev and framework-bound logger formats 70 | if dev: 71 | if enabled: 72 | # in dev mode, get a logger as of the native library 73 | logging.configLogger(logger, level, relative=True, fmt=LOG_FORMAT_DBG) 74 | _setup_logfile(logger) 75 | else: 76 | logger.setLevel(1000) 77 | else: 78 | # now use the dedicated class for the logger to be returned 79 | logger.setLevel(level) 80 | if len(logger.handlers) == 0: 81 | # setup a StreamHandler for the console (at level INFO) 82 | ch = ConsoleHandler() 83 | ch.setFormatter(logging.Formatter(fmt=LOG_FORMAT)) 84 | ch.setLevel(level) 85 | logger.addHandler(ch) 86 | _setup_logfile(logger) 87 | else: 88 | for h in logger.handlers: 89 | h.setLevel(level) 90 | return logger 91 | 92 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/recorder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | __all__ = ["Recorder"] 3 | 4 | 5 | class Recorder(object): 6 | """ Simple text recording class. """ 7 | _filter = ["record"] 8 | root_dir = "." 9 | 10 | def __init__(self): 11 | self.stop() 12 | 13 | @property 14 | def enabled(self): 15 | return self.__file is not None 16 | 17 | def save(self, text): 18 | """ Save the given text to the record file. """ 19 | if self.enabled and text.split()[0] not in self._filter: 20 | self.__file.append_line(text) 21 | 22 | def start(self, filename, overwrite=False): 23 | """ Start the recorder, creating the record file. """ 24 | from tinyscript.helpers import Path 25 | self.__file = f = Path(filename) 26 | if f.suffix != ".rc": 27 | self.__file = f = Path(self.root_dir).joinpath(filename + ".rc") 28 | if not overwrite and f.exists(): 29 | raise OSError("File already exists") 30 | f.reset() 31 | 32 | def stop(self): 33 | """ Stop the recorder by removing the record file reference. """ 34 | self.__file = None 35 | 36 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/sessions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import os 3 | 4 | 5 | __all__ = ["SessionsManager"] 6 | 7 | 8 | class Session(object): 9 | """ Class representing a session object based on a shell command """ 10 | def __init__(self, n, cmd, **kwargs): 11 | from shlex import split 12 | from tinyscript.helpers import Path 13 | self.id = n 14 | self.parent = kwargs.pop('parent') 15 | if isinstance(cmd, str): 16 | cmd = split(cmd) 17 | self._path = Path(self.parent.console._files.tempdir, "session", str(n), create=True) 18 | for i, s in enumerate(["stdin", "stdout", "stderr"]): 19 | fifo = str(self._path.joinpath(str(i))) 20 | self._named_pipes.append(fifo) 21 | os.mkfifo(fifo, 0o777) 22 | setattr(self, "_" + s, os.open(fifo ,os.O_WRONLY)) 23 | 24 | def close(self): 25 | from shutil import rmtree 26 | for s in ["stdin", "stdout", "stderr"]: 27 | getattr(self, "_" + s).close() 28 | rmtree(str(self._path)) 29 | self._process.wait() 30 | del self.parent[self.id] 31 | 32 | def start(self, **kwargs): 33 | from subprocess import Popen 34 | kwargs['close_fds'] = True 35 | kwargs['preexec_fn'] = os.setsid # NB: see subprocess' doc ; preexec_fn is not thread-safe 36 | self._process = Popen(cmd, stdout=self._stdout, stderr=self._stderr, stdin=self._stdin, **kwargs) 37 | 38 | 39 | class SessionsManager(object): 40 | """ Class for managing session objects. """ 41 | def __init__(self, max_sessions=None): 42 | self.__sessions = [] 43 | self.max = max_sessions 44 | 45 | def __delitem__(self, session_id): 46 | self.__sessions[sessin_id] = None 47 | while self.__sessions[-1] is None: 48 | self.__sessions.pop() 49 | 50 | def __getitem__(self, session_id): 51 | return self.__sessions[int(session_id)] 52 | 53 | def __iter__(self): 54 | for i, s in enumerate(self.__sessions): 55 | if s is not None: 56 | yield i, s 57 | 58 | def __len__(self): 59 | n = 0 60 | for s in self: 61 | n += 1 62 | return n 63 | 64 | def new(self, session): 65 | for i, s in enumerate(self.__session): 66 | if s is None: 67 | self.__session[i] = session 68 | return session 69 | self.__session.append(session) 70 | return session 71 | 72 | def process(self, cmd, **kwargs): 73 | return self.new(Session(self, i+1, cmd, **kwargs)) 74 | 75 | def shell(self, shell_cls, *args, **kwargs): 76 | return self.new(shell_cls(*args, **kwargs)) 77 | 78 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/store.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from peewee import SqliteDatabase 3 | 4 | 5 | __all__ = ["StoragePool"] 6 | 7 | 8 | class StoragePool(object): 9 | """ Storage pool class. """ 10 | __pool = [] 11 | 12 | def __init__(self, ext_class=None): 13 | self._entity_class = getattr(ext_class(), "base_class", None) 14 | self._ext_class = ext_class 15 | 16 | def close(self, remove=False): 17 | """ Close every database in the pool. """ 18 | for db in self.__pool[::-1]: 19 | self.remove(db) if remove else db.close() 20 | 21 | def free(self): 22 | """ Close and remove every database in the pool. """ 23 | self.close(True) 24 | 25 | def get(self, path, *args, **kwargs): 26 | """ Get a database from the pool ; if the DB does not exist yet, create and register it. """ 27 | path = str(path) # ensure the input is str, e.g. not a Path instance 28 | try: 29 | db = [_ for _ in self.__pool if _.path == path][0] 30 | except IndexError: 31 | classes = tuple([Store] + self.extensions) 32 | cls = type("ExtendedStore", classes, {}) 33 | db = cls(path, *args, **kwargs) 34 | db._pool = self 35 | # as the store extension class should subclass Entity, in 'classes', store extension subclasses will be 36 | # present, therefore making ExtendedStore registered in its list of subclasses ; this line prevents from 37 | # having multiple combined classes having the same Store base class 38 | if self._ext_class is not None and hasattr(self._ext_class, "unregister_subclass"): 39 | self._ext_class.unregister_subclass(cls) 40 | self.__pool.append(db) 41 | for m in self.models: 42 | m.bind(db) 43 | db.create_tables(self.models, safe=True) 44 | db.close() # commit and save the created tables 45 | db.connect() 46 | return db 47 | 48 | def remove(self, db): 49 | """ Remove a database from the pool. """ 50 | db.close() 51 | delattr(db, "_pool") 52 | self.__pool.remove(db) 53 | del db 54 | 55 | @property 56 | def extensions(self): 57 | """ Get the list of store extension subclasses. """ 58 | try: 59 | return self._ext_class.subclasses 60 | except AttributeError: 61 | return [] 62 | 63 | 64 | class Store(SqliteDatabase): 65 | """ Storage database class. """ 66 | def __init__(self, path, *args, **kwargs): 67 | self.path = str(path) # ensure the input is str, e.g. not Path 68 | self._last_snapshot = 0 69 | kwargs.setdefault('pragmas', {}) 70 | # enable automatic VACUUM (to regularly defragment the DB) 71 | kwargs['pragmas'].setdefault('auto_vacuum', 1) 72 | # set page cache size (in KiB) 73 | kwargs['pragmas'].setdefault('cache_size', -64000) 74 | # allow readers and writers to co-exist 75 | kwargs['pragmas'].setdefault('journal_mode', "wal") 76 | # enforce foreign-key constraints 77 | kwargs['pragmas'].setdefault('foreign_keys', 1) 78 | # enforce CHECK constraints 79 | kwargs['pragmas'].setdefault('ignore_check_constraints', 0) 80 | # let OS handle fsync 81 | kwargs['pragmas'].setdefault('synchronous', 0) 82 | # force every transaction in exclusive mode 83 | kwargs['pragmas'].setdefault('locking_mode', 1) 84 | super(Store, self).__init__(path, *args, **kwargs) 85 | 86 | def __getattr__(self, name): 87 | """ Override getattr to handle add_* store methods. """ 88 | from re import match 89 | if name == "basemodels": 90 | BaseModel = self._pool._entity_class._subclasses["basemodel"] 91 | return self._pool._entity_class._subclasses[BaseModel] 92 | elif name == "models": 93 | Model = self._pool._entity_class._subclasses["model"] 94 | return self._pool._entity_class._subclasses[Model] 95 | elif name == "volatile": 96 | return self.path == ":memory:" 97 | elif match(r"^[gs]et_[a-z]+", name) and name != "model": 98 | model = "".join(w.capitalize() for w in name.split("_")[1:]) 99 | cls = self.get_model(model) 100 | if cls is not None: 101 | if name.startswith("get"): 102 | return cls.get 103 | elif hasattr(cls, "set"): 104 | return cls.set 105 | raise AttributeError("Store object has no attribute %r" % name) 106 | 107 | def get_model(self, name, base=False): 108 | """ Get a model class from its name. """ 109 | return self._pool._entity_class.get_subclass("model", name) or \ 110 | self._pool._entity_class.get_subclass("basemodel", name) 111 | 112 | def snapshot(self, save=True): 113 | """ Snapshot the store in order to be able to get back to this state afterwards if the results are corrupted by 114 | a module OR provide the reference number of the snapshot to get back to, and remove every other snapshot 115 | after this number. """ 116 | if not save and self._last_snapshot == 0: 117 | return 118 | self.close() 119 | if save: 120 | self._last_snapshot += 1 121 | s = f"{self.path}.snapshot{self._last_snapshot}" 122 | from shutil import copy 123 | copy(self.path, s) if save else copy(s, self.path) 124 | if not save: 125 | from os import remove 126 | remove("{}.snapshot{}".format(self.path, self._last_snapshot)) 127 | self._last_snapshot -= 1 128 | self.connect() 129 | 130 | -------------------------------------------------------------------------------- /src/sploitkit/core/components/validator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from prompt_toolkit.validation import Validator, ValidationError 3 | 4 | 5 | __all__ = ["CommandValidator"] 6 | 7 | 8 | class CommandValidator(Validator): 9 | """ Completer for console's commands and arguments. """ 10 | def __init__(self, fail=True): 11 | self._fail = fail 12 | super(CommandValidator, self).__init__() 13 | 14 | def validate(self, document): 15 | # first, tokenize document.text 16 | tokens = self.console._get_tokens(document.text.strip()) 17 | l = len(tokens) 18 | # then handle tokens 19 | commands = self.console.commands 20 | # when no token provided, do nothing 21 | if l == 0: 22 | return 23 | # when a command is being typed, mention if it is existing 24 | cmd = tokens[0] 25 | if l == 1 and cmd not in commands.keys() and self._fail: 26 | raise ValidationError(message="Unknown command") 27 | # when a valid first token is provided, handle command's validation, if any available 28 | elif l >= 1 and cmd in commands.keys(): 29 | c = commands[cmd]._instance 30 | try: 31 | c._validate(*tokens[1:]) 32 | except Exception as e: 33 | m = f"Command syntax: {c.signature.format(cmd)}" 34 | e = str(e) 35 | if not e.startswith("validate() "): 36 | m = m.format([" (" + e + ")", ""][len(e) == 0]) 37 | else: 38 | m = m.format("") 39 | raise ValidationError(message=m) 40 | # otherwise, the command is considered bad 41 | elif self._fail: 42 | raise ValidationError(message="Bad command") 43 | 44 | -------------------------------------------------------------------------------- /src/sploitkit/core/console.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import gc 3 | import io 4 | import os 5 | import shlex 6 | import sys 7 | from asciistuff import get_banner, get_quote 8 | from bdb import BdbQuit 9 | from datetime import datetime 10 | from inspect import getfile, isfunction 11 | from itertools import chain 12 | from prompt_toolkit import print_formatted_text as print_ft, PromptSession 13 | from prompt_toolkit.application.current import get_app_session 14 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 15 | from prompt_toolkit.formatted_text import ANSI, FormattedText 16 | from prompt_toolkit.history import FileHistory 17 | from prompt_toolkit.output import DummyOutput 18 | from prompt_toolkit.styles import Style 19 | from random import choice 20 | from shutil import which 21 | from tinyscript.helpers import filter_bin, get_terminal_size, parse_docstring, Capture, Path 22 | 23 | from .command import * 24 | from .components import * 25 | from .entity import * 26 | from .model import * 27 | from .module import * 28 | 29 | 30 | __all__ = [ 31 | "Entity", 32 | # subclassable main entities 33 | "BaseModel", "Command", "Console", "Model", "Module", "StoreExtension", 34 | # console-related classes 35 | "Config", "ConsoleExit", "ConsoleDuplicate", "FrameworkConsole", "Option", 36 | ] 37 | 38 | EDITORS = ["atom", "emacs", "gedit", "mousepad", "nano", "notepad", "notepad++", "vi", "vim"] 39 | VIEWERS = ["bat", "less"] 40 | try: 41 | DEFAULT_EDITOR = filter_bin(*EDITORS)[-1] 42 | except IndexError: 43 | DEFAULT_EDITOR = None 44 | try: 45 | DEFAULT_VIEWER = filter_bin(*VIEWERS)[0] 46 | except IndexError: 47 | DEFAULT_VIEWER = None 48 | 49 | _output = get_app_session().output 50 | dcount = lambda d, n=0: sum([dcount(v, n) if isinstance(v, dict) else n + 1 for v in d.values()]) 51 | logger = get_logger("core.console") 52 | 53 | 54 | def print_formatted_text(*args, **kwargs): 55 | """ Proxy function that uses the global (capturable) _output. """ 56 | kwargs['output'] = kwargs.get('output', _output) 57 | return print_ft(*args, **kwargs) 58 | 59 | 60 | class _CaptureOutput(DummyOutput): 61 | def __init__(self): 62 | self.__file = io.StringIO() 63 | 64 | def __str__(self): 65 | return self.__file.getvalue().strip() 66 | 67 | def write(self, data): 68 | self.__file.write(data) 69 | 70 | 71 | class MetaConsole(MetaEntity): 72 | """ Metaclass of a Console. """ 73 | _has_config = True 74 | 75 | 76 | class Console(Entity, metaclass=MetaConsole): 77 | """ Base console class. """ 78 | # convention: mangled attributes should not be customized when subclassing Console... 79 | _files = FilesManager() 80 | _jobs = JobsPool() 81 | _recorder = Recorder() 82 | _sessions = SessionsManager() 83 | _state = {} # state shared between all the consoles 84 | _storage = StoragePool(StoreExtension) 85 | # ... by opposition to public class attributes that can be tuned 86 | appname = "" 87 | config = Config() 88 | exclude = [] 89 | level = ROOT_LEVEL 90 | message = PROMPT_FORMAT 91 | motd = """ 92 | 93 | """ 94 | parent = None 95 | sources = SOURCES 96 | style = PROMPT_STYLE 97 | 98 | def __init__(self, parent=None, **kwargs): 99 | fail = kwargs.pop("fail", True) 100 | super(Console, self).__init__() 101 | # determine the relevant parent 102 | self.parent = parent 103 | if self.parent is not None and self.parent.level == self.level: 104 | while parent is not None and parent.level == self.level: 105 | parent = parent.parent # go up of one console level 106 | # raise an exception in the context of command's .run() execution, to be propagated to console's .run() 107 | # execution, setting the directly higher level console in argument 108 | raise ConsoleDuplicate(self, parent) 109 | # back-reference the console 110 | self.config.console = self 111 | # configure the console regarding its parenthood 112 | if self.parent is None: 113 | if Console.parent is not None: 114 | raise Exception("Only one parent console can be used") 115 | Console.parent = self 116 | Console.parent._start_time = datetime.now() 117 | Console.appdispname = Console.appname 118 | Console.appname = Console.appname.lower() 119 | self._root = Path(getfile(self.__class__)).resolve() 120 | self.__init(**kwargs) 121 | else: 122 | self.parent.child = self 123 | # reset commands and other bound stuffs 124 | self.reset() 125 | # setup the session with the custom completer and validator 126 | completer, validator = CommandCompleter(), CommandValidator(fail) 127 | completer.console = validator.console = self 128 | message, style = self.prompt 129 | self._session = PromptSession( 130 | message, 131 | completer=completer, 132 | history=FileHistory(Path(self.config.option("WORKSPACE").value).joinpath("history")), 133 | validator=validator, 134 | style=Style.from_dict(style), 135 | ) 136 | CustomLayout(self) 137 | 138 | def __init(self, **kwargs): 139 | """ Initialize the parent console with commands and modules. """ 140 | # setup banners 141 | try: 142 | bsrc = str(choice(self._sources("banners"))) 143 | print_formatted_text("") 144 | # display a random banner from the banners folder 145 | get_banner_func = kwargs.get('get_banner_func', get_banner) 146 | banner_colors = kwargs.get('banner_section_styles', {}) 147 | text = get_banner_func(self.appdispname, bsrc, styles=banner_colors) 148 | if text: 149 | print_formatted_text(ANSI(text)) 150 | # display a random quote from quotes.csv (in the banners folder) 151 | get_quote_func = kwargs.get('get_quote_func', get_quote) 152 | try: 153 | text = get_quote_func(os.path.join(bsrc, "quotes.csv")) 154 | if text: 155 | print_formatted_text(ANSI(text)) 156 | except ValueError: 157 | pass 158 | except IndexError: 159 | pass 160 | # setup libraries 161 | for lib in self._sources("libraries"): 162 | sys.path.insert(0, str(lib)) 163 | # setup entities 164 | self._load_kwargs = {'include_base': kwargs.get("include_base", True), 165 | 'select': kwargs.get("select", {'command': Command._functionalities}), 166 | 'exclude': kwargs.get("exclude", {}), 167 | 'backref': kwargs.get("backref", BACK_REFERENCES), 168 | 'docstr_parser': kwargs.get("docstr_parser", parse_docstring), 169 | 'remove_cache': True} 170 | load_entities([BaseModel, Command, Console, Model, Module, StoreExtension], 171 | *([self._root] + self._sources("entities")), **self._load_kwargs) 172 | Console._storage.models = Model.subclasses + BaseModel.subclasses 173 | # display module stats 174 | print_formatted_text(FormattedText([("#00ff00", Module.get_summary())])) 175 | # setup the prompt message 176 | self.message.insert(0, ('class:appname', self.appname)) 177 | # display warnings 178 | self.reset() 179 | if Entity.has_issues(): 180 | self.logger.warning("There are some issues ; use 'show issues' to see more details") 181 | # console's components back-referencing 182 | for attr in ["_files", "_jobs", "_sessions"]: 183 | setattr(getattr(Console, attr), "console", self) 184 | 185 | def _close(self): 186 | """ Gracefully close the console. """ 187 | self.logger.debug(f"Exiting {self.__class__.__name__}[{id(self)}]") 188 | if hasattr(self, "close") and isfunction(self.close): 189 | self.close() 190 | # cleanup references for this console 191 | self.detach() 192 | # important note: do not confuse '_session' (refers to prompt session) with sessions (sessions manager) 193 | if hasattr(self, "_session"): 194 | delattr(self._session.completer, "console") 195 | delattr(self._session.validator, "console") 196 | # remove the singleton instance of the current console 197 | c = self.__class__ 198 | if hasattr(c, "_instance"): 199 | del c._instance 200 | if self.parent is not None: 201 | del self.parent.child 202 | # rebind entities to the parent console 203 | self.parent.reset() 204 | # remove all finished jobs from the pool 205 | self._jobs.free() 206 | else: 207 | # gracefully close every DB in the pool 208 | self._storage.free() 209 | # terminate all running jobs 210 | self._jobs.terminate() 211 | 212 | def _get_tokens(self, text, suffix=("", "\"", "'")): 213 | """ Recursive token split function also handling ' and " (that is, when 'text' is a partial input with a string 214 | not closed by a quote). """ 215 | text = text.lstrip() 216 | try: 217 | tokens = shlex.split(text + suffix[0]) 218 | except ValueError: 219 | return self._get_tokens(text, suffix[1:]) 220 | except IndexError: 221 | return [] 222 | if len(tokens) > 0: 223 | cmd = tokens[0] 224 | if len(tokens) > 2 and getattr(self.commands.get(cmd), "single_arg", False): 225 | tokens = [cmd, " ".join(tokens[1:])] 226 | elif len(tokens) > 3: 227 | tokens = [cmd, tokens[1], " ".join(tokens[2:])] 228 | return tokens 229 | 230 | def _reset_logname(self): 231 | """ Reset logger's name according to console's attributes. """ 232 | try: 233 | self.logger.name = f"{self.level}:{self.logname}" 234 | except AttributeError: 235 | self.logger.name = self.__class__.name 236 | 237 | def _run_if_defined(self, func): 238 | """ Run the given function if it is defined at the module level. """ 239 | if hasattr(self, "module") and hasattr(self.module, func) and \ 240 | not (getattr(self.module._instance, func)() is None): 241 | self.logger.debug(f"{func} failed") 242 | return False 243 | return True 244 | 245 | def _sources(self, items): 246 | """ Return the list of sources for the related items [banners|entities|libraries], first trying subclass' one 247 | then Console class' one. Also, resolve paths relative to the path where the parent Console is found. """ 248 | src = self.sources.get(items, Console.sources[items]) 249 | if isinstance(src, (str, Path)): 250 | src = [src] 251 | return [Path(self._root.dirname.joinpath(s).expanduser().resolve()) for s in (src or [])] 252 | 253 | def attach(self, eccls, directref=False, backref=True): 254 | """ Attach an entity child to the calling entity's instance. """ 255 | # handle direct reference from self to eccls 256 | if directref: 257 | # attach new class 258 | setattr(self, eccls.entity, eccls) 259 | # handle back reference from eccls to self 260 | if backref: 261 | setattr(eccls, "console", self) 262 | # create a singleton instance of the entity 263 | eccls._instance = getattr(eccls, "_instance", None) or eccls() 264 | 265 | def detach(self, eccls=None): 266 | """ Detach an entity child class from the console and remove its back-reference. """ 267 | # if no argument, detach every class registered in self._attached 268 | if eccls is None: 269 | for subcls in Entity._subclasses: 270 | self.detach(subcls) 271 | elif eccls in ["command", "module"]: 272 | for ec in [Command, Module][eccls == "module"].subclasses: 273 | if ec.entity == eccls: 274 | self.detach(ec) 275 | else: 276 | if hasattr(eccls, "entity") and hasattr(self, eccls.entity): 277 | delattr(self, eccls.entity) 278 | # remove the singleton instance of the entity previously opened 279 | if hasattr(eccls, "_instance"): 280 | del eccls._instance 281 | 282 | def execute(self, cmd, abort=False): 283 | """ Alias for run. """ 284 | return self.run(cmd, abort) 285 | 286 | def play(self, *commands, capture=False): 287 | """ Execute a list of commands. """ 288 | global _output 289 | if capture: 290 | r = [] 291 | error = False 292 | try: 293 | w, _ = get_terminal_size() 294 | except TypeError: 295 | w = 80 296 | for c in commands: 297 | if capture: 298 | if error: 299 | r.append((c, None, None)) 300 | continue 301 | __tmp = _output 302 | _output = _CaptureOutput() 303 | error = not self.run(c, True) 304 | r.append((c, str(_output))) 305 | _output = __tmp 306 | else: 307 | print_formatted_text("\n" + (" " + c + " ").center(w, "+") + "\n") 308 | if not self.run(c, True): 309 | break 310 | if capture: 311 | if r[-1][0] == "exit": 312 | r.pop(-1) 313 | return r 314 | 315 | def rcfile(self, rcfile, capture=False): 316 | """ Execute commands from a .rc file. """ 317 | with open(rcfile) as f: 318 | commands = [c.strip() for c in f] 319 | return self.play(*commands, capture) 320 | 321 | def reset(self): 322 | """ Setup commands for the current level, reset bindings between commands and the current console then update 323 | store's object. """ 324 | self.detach("command") 325 | # setup level's commands, starting from general-purpose commands 326 | self.commands = {} 327 | # add commands 328 | for n, c in chain(Command.commands.get("general", {}).items(), Command.commands.get(self.level, {}).items()): 329 | self.attach(c) 330 | if self.level not in getattr(c, "except_levels", []) and c.check(): 331 | self.commands[n] = c 332 | else: 333 | self.detach(c) 334 | root = self.config.option('WORKSPACE').value 335 | # get the relevant store and bind it to loaded models 336 | Console.store = Console._storage.get(Path(root).joinpath("store.db")) 337 | # update command recorder's root directory 338 | self._recorder.root_dir = root 339 | 340 | def run(self, cmd, abort=False): 341 | """ Run a framework console command. """ 342 | # assign tokens (or abort if tokens' split gives []) 343 | tokens = self._get_tokens(cmd) 344 | try: 345 | name, args = tokens[0], tokens[1:] 346 | except IndexError: 347 | if abort: 348 | raise 349 | return True 350 | # get the command singleton instance (or abort if name not in self.commands) ; if command arguments should not 351 | # be split, adapt args 352 | try: 353 | obj = self.commands[name]._instance 354 | except KeyError: 355 | if abort: 356 | raise 357 | return True 358 | # now handle the command (and its validation if existing) 359 | try: 360 | if hasattr(obj, "validate"): 361 | obj.validate(*args) 362 | if name != "run" or self._run_if_defined("prerun"): 363 | obj.run(*args) 364 | if name == "run": 365 | self._run_if_defined("postrun") 366 | return True 367 | except BdbQuit: # when using pdb.set_trace() 368 | return True 369 | except ConsoleDuplicate as e: 370 | # pass the higher console instance attached to the exception raised from within a command's .run() execution 371 | # to console's .start(), keeping the current command to be reexecuted 372 | raise ConsoleDuplicate(e.current, e.higher, cmd if e.cmd is None else e.cmd) 373 | except ConsoleExit: 374 | return False 375 | except ValueError as e: 376 | if str(e).startswith("invalid width ") and str(e).endswith(" (must be > 0)"): 377 | self.logger.warning("Cannot display ; terminal width too low") 378 | else: 379 | (self.logger.exception if self.config.option('DEBUG').value else self.logger.failure)(e) 380 | return abort is False 381 | except Exception as e: 382 | self.logger.exception(e) 383 | return abort is False 384 | finally: 385 | gc.collect() 386 | 387 | def start(self): 388 | """ Start looping with console's session prompt. """ 389 | reexec = None 390 | self._reset_logname() 391 | self.logger.debug(f"Starting {self.__class__.__name__}[{id(self)}]") 392 | # execute attached module's pre-load function if relevant 393 | self._run_if_defined("preload") 394 | # now start the console loop 395 | while True: 396 | self._reset_logname() 397 | try: 398 | c = reexec if reexec is not None else self._session.prompt( 399 | auto_suggest=AutoSuggestFromHistory(), 400 | #bottom_toolbar="This is\na multiline toolbar", # note: this disables terminal scrolling 401 | #mouse_support=True, 402 | ) 403 | reexec = None 404 | Console._recorder.save(c) 405 | if not self.run(c): 406 | break # console run aborted 407 | except ConsoleDuplicate as e: 408 | # stop raising duplicate when reaching a console with a different level, then reset associated commands 409 | # not to rerun the erroneous one from the context of the just-exited console 410 | if self == e.higher: 411 | reexec = e.cmd 412 | self.reset() 413 | continue 414 | self._close() 415 | # reraise up to the higher (level) console 416 | raise e 417 | except EOFError: 418 | Console._recorder.save("exit") 419 | break 420 | except (KeyboardInterrupt, ValueError): 421 | continue 422 | # execute attached module's post-load function if relevant 423 | self._run_if_defined("postload") 424 | # gracefully close and chain this console instance 425 | self._close() 426 | return self 427 | 428 | @property 429 | def logger(self): 430 | try: 431 | return Console._logger 432 | except: 433 | return null_logger 434 | 435 | @property 436 | def modules(self): 437 | return Module.modules 438 | 439 | @property 440 | def prompt(self): 441 | if self.parent is None: 442 | return self.message, self.style 443 | # setup the prompt message by adding child's message tokens at the end of parent's one (parent's last token is 444 | # then re-appended) if it shall not be reset, otherwise reset it then set child's tokens 445 | if getattr(self, "message_reset", False): 446 | return self.message, self.style 447 | pmessage, pstyle = self.parent.prompt 448 | message = pmessage.copy() # copy parent message tokens 449 | t = message.pop() 450 | message.extend(self.message) 451 | message.append(t) 452 | # setup the style, using this of the parent 453 | style = pstyle.copy() # copy parent style dict 454 | style.update(self.style) 455 | return message, style 456 | 457 | @property 458 | def root(self): 459 | return Console.parent 460 | 461 | @property 462 | def sessions(self): 463 | return list(self._sessions) 464 | 465 | @property 466 | def state(self): 467 | """ Getter for the shared state. """ 468 | return Console._state 469 | 470 | @property 471 | def uptime(self): 472 | """ Get application's uptime. """ 473 | t = datetime.now() - Console.parent._start_time 474 | s = t.total_seconds() 475 | h, _ = divmod(s, 3600) 476 | m, s = divmod(_, 60) 477 | return f"{h:02}:{m:02}:{s:02}" 478 | 479 | 480 | class ConsoleDuplicate(Exception): 481 | """ Dedicated exception class for exiting a duplicate (sub)console. """ 482 | def __init__(self, current, higher, cmd=None): 483 | self.cmd, self.current, self.higher = cmd, current, higher 484 | super(ConsoleDuplicate, self).__init__("Another console of the same level is already running") 485 | 486 | 487 | class ConsoleExit(SystemExit): 488 | """ Dedicated exception class for exiting a (sub)console. """ 489 | pass 490 | 491 | 492 | class FrameworkConsole(Console): 493 | """ Framework console subclass for defining specific config options. """ 494 | _entity_class = Console 495 | aliases = [] 496 | config = Config({ 497 | Option( 498 | 'APP_FOLDER', 499 | "folder where application assets (i.e. logs) are saved", 500 | True, 501 | #set_callback=lambda o: o.root._set_app_folder(debug=o.config.option('DEBUG').value), 502 | glob=False, 503 | ): "~/.{appname}", 504 | ROption( 505 | 'DEBUG', 506 | "debug mode", 507 | True, 508 | bool, 509 | set_callback=lambda o: o.root._set_logging(o.value), 510 | glob=False, 511 | ): "false", 512 | ROption( 513 | 'TEXT_EDITOR', 514 | "text file editor to be used", 515 | False, 516 | choices=lambda: filter_bin(*EDITORS), 517 | validate=lambda s, v: which(v) is not None, 518 | glob=False, 519 | ): DEFAULT_EDITOR, 520 | ROption( 521 | 'TEXT_VIEWER', 522 | "text file viewer (pager) to be used", 523 | False, 524 | choices=lambda: filter_bin(*VIEWERS), 525 | validate=lambda s, v: which(v) is not None, 526 | glob=False, 527 | ): DEFAULT_VIEWER, 528 | Option( 529 | 'ENCRYPT_PROJECT', 530 | "ask for a password to encrypt a project when archiving", 531 | True, 532 | bool, 533 | glob=False, 534 | ): "true", 535 | Option( 536 | 'WORKSPACE', 537 | "folder where results are saved", 538 | True, 539 | set_callback=lambda o: o.root._set_workspace(), 540 | glob=False, 541 | ): "~/Notes", 542 | }) 543 | 544 | def __init__(self, appname=None, *args, **kwargs): 545 | Console._dev_mode = kwargs.pop("dev", False) 546 | Console.appname = appname or getattr(self, "appname", Console.appname) 547 | self.opt_prefix = "Console" 548 | o, v = self.config.option('APP_FOLDER'), str(self.config['APP_FOLDER']) 549 | self.config[o] = Path(v.format(appname=self.appname.lower())) 550 | o.old_value = None 551 | self.config['DEBUG'] = kwargs.get('debug', False) 552 | self._set_app_folder(silent=True, **kwargs) 553 | self._set_workspace() 554 | super(FrameworkConsole, self).__init__(*args, **kwargs) 555 | 556 | def __set_folder(self, option, subpath=""): 557 | """ Set a new folder, moving an old to the new one if necessary. """ 558 | o = self.config.option(option) 559 | old, new = o.old_value, o.value 560 | if old == new: 561 | return 562 | try: 563 | if old is not None: 564 | os.rename(old, new) 565 | except Exception as e: 566 | pass 567 | Path(new).joinpath(subpath).mkdir(parents=True, exist_ok=True) 568 | return new 569 | 570 | def _set_app_folder(self, **kwargs): 571 | """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ 572 | self._files.root_dir = self.__set_folder("APP_FOLDER", "files") 573 | self._set_logging(**kwargs) # this is necessary as the log file is located in APP_FOLDER 574 | 575 | def _set_logging(self, debug=False, to_file=True, **kwargs): 576 | """ Set a new logger with the input logging level. """ 577 | l, p1, p2, dev = "INFO", None, None, Console._dev_mode 578 | if debug: 579 | l = "DETAIL" if Console._dev_mode else "DEBUG" 580 | if to_file: 581 | # attach a logger to the console 582 | lpath = self.app_folder.joinpath("logs") 583 | lpath.mkdir(parents=True, exist_ok=True) 584 | p1 = str(lpath.joinpath("main.log")) 585 | if dev: 586 | p2 = str(lpath.joinpath("debug.log")) 587 | if l == "INFO" and not kwargs.get('silent', False): 588 | self.logger.debug("Set logging to INFO") 589 | Console._logger = get_logger(self.appname.lower(), p1, l) 590 | # setup framework's logger with its own get_logger function (configuring other handlers than the default one) 591 | set_logging_level(l, self.appname.lower(), config_func=lambda lgr, lvl: get_logger(lgr.name, p1, lvl)) 592 | # setup internal (dev) loggers with the default logging.configLogger (enhancement to logging from Tinyscript) 593 | set_logging_level(l, "core", config_func=lambda lgr, lvl: get_logger(lgr.name, p2, lvl, True, dev)) 594 | if l != "INFO" and not kwargs.get('silent', False): 595 | self.logger.debug(f"Set logging to {l}") 596 | 597 | def _set_workspace(self): 598 | """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ 599 | self.__set_folder("WORKSPACE") 600 | 601 | @property 602 | def app_folder(self): 603 | """ Shortcut to the current application folder. """ 604 | return Path(self.config.option('APP_FOLDER').value) 605 | 606 | @property 607 | def workspace(self): 608 | """ Shortcut to the current workspace. """ 609 | return Path(self.config.option("WORKSPACE").value) 610 | 611 | -------------------------------------------------------------------------------- /src/sploitkit/core/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import datetime 3 | from peewee import * 4 | from peewee import Model as PeeweeModel, ModelBase 5 | 6 | from .components.logger import get_logger 7 | from .entity import Entity, MetaEntityBase 8 | 9 | 10 | __all__ = ["BaseModel", "Model", "StoreExtension"] 11 | 12 | 13 | logger = get_logger("core.model") 14 | 15 | 16 | class MetaModel(ModelBase, MetaEntityBase): 17 | """ Metaclass of a Model. """ 18 | triggers = [] 19 | 20 | def __new__(meta, name, bases, clsdict): 21 | subcls = ModelBase.__new__(meta, name, bases, clsdict) 22 | if subcls.__name__ != "Model": 23 | pass 24 | # add triggers here 25 | #try: 26 | # trigger = f"{subcls._meta.table_name}_updated" 27 | # subcls.add_trigger(trigger, "AFTER", "UPDATE", "UPDATE", "SET updated=CURRENT_TIMESTAMP") 28 | #except AttributeError: 29 | # pass 30 | return subcls 31 | 32 | def __repr__(self): 33 | return "<%s: %s>" % (self.entity.capitalize(), self.__name__) 34 | 35 | 36 | class BaseModel(PeeweeModel, Entity, metaclass=MetaModel): 37 | """ Main class handling console store's base models (that is, without pre-attached fields). """ 38 | pass 39 | 40 | 41 | class Model(BaseModel): 42 | """ Main class handling console store's models. """ 43 | source = CharField(null=True) 44 | created = DateTimeField(default=datetime.datetime.now, null=False) 45 | updated = DateTimeField(default=datetime.datetime.now, null=False) 46 | 47 | @classmethod 48 | def add_trigger(cls, trig, when, top, op, sql, safe=True): 49 | """ Add a trigger to model's list of triggers. """ 50 | cls.triggers.append(Trigger(cls, trig, when, top, op, sql, safe)) 51 | 52 | @classmethod 53 | def create_table(cls, **options): 54 | """ Create this table in the bound database.""" 55 | super(Model, cls).create_table(**options) 56 | for trigger in cls.triggers: 57 | try: 58 | cls._meta.database.execute_sql(str(trigger)) 59 | except: 60 | pass 61 | 62 | @classmethod 63 | def set(cls, **items): 64 | """ Insert or update a record. """ 65 | items["updated"] = datetime.datetime.now() 66 | return super(Model, cls).get_or_create(**items) 67 | 68 | 69 | class StoreExtension(Entity, metaclass=MetaEntityBase): 70 | """ Dummy class handling store extensions for the Store class. """ 71 | pass 72 | 73 | 74 | # source: 75 | # https://stackoverflow.com/questions/34142550/sqlite-triggers-datetime-defaults-in-sql-ddl-using-peewee-in-python 76 | class Trigger(object): 77 | """Trigger template wrapper for use with peewee ORM.""" 78 | _template = """ 79 | {create} {name} {when} {trigger_op} 80 | ON {tablename} 81 | BEGIN 82 | {op} {tablename} {sql} WHERE {pk}={old_new}.{pk}; 83 | END; 84 | """ 85 | 86 | def __init__(self, table, name, when, trigger_op, op, sql, safe=True): 87 | self.create = "CREATE TRIGGER" + (" IF NOT EXISTS" if safe else "") 88 | self.tablename = table._meta.name 89 | self.pk = table._meta.primary_key.name 90 | self.name = name 91 | self.when = when 92 | self.trigger_op = trigger_op 93 | self.op = op 94 | self.sql = sql 95 | self.old_new = "new" if trigger_op.lower() == "insert" else "old" 96 | 97 | def __str__(self): 98 | return self._template.format(**self.__dict__) 99 | 100 | -------------------------------------------------------------------------------- /src/sploitkit/core/module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from inspect import getfile 3 | from tinyscript.helpers import flatten_dict, BorderlessTable, Path, PathBasedDict 4 | 5 | from .components.logger import get_logger 6 | from .entity import Entity, MetaEntity 7 | 8 | 9 | __all__ = ["Module"] 10 | 11 | 12 | logger = get_logger("core.module") 13 | 14 | 15 | class MetaModule(MetaEntity): 16 | """ Metaclass of a Module. """ 17 | _has_config = True 18 | _inherit_metadata = True 19 | 20 | def __new__(meta, name, bases, clsdict): 21 | subcls = type.__new__(meta, name, bases, clsdict) 22 | # compute module's path from its root folder if no path attribute defined on its class 23 | if getattr(subcls, "path", None) is None: 24 | p = Path(getfile(subcls)).parent 25 | # collect the source temporary attribute 26 | s = getattr(subcls, "_source", ".") 27 | try: 28 | scp = p.relative_to(Path(s)) 29 | if len(scp.parts) > 0 and scp.parts[-1] == "__pycache__": 30 | scp = scp.parent 31 | subcls.path = str(scp) 32 | except ValueError: 33 | subcls.path = None 34 | # then pass the subclass with its freshly computed path attribute to the original __new__ method, for 35 | # registration in subclasses and in the list of modules 36 | super(MetaModule, meta).__new__(meta, name, bases, clsdict, subcls) 37 | return subcls 38 | 39 | @property 40 | def base(self): 41 | """ Module's category. """ 42 | return str(Path(self.fullpath).child) if self.category != "" else self.name 43 | 44 | @property 45 | def category(self): 46 | """ Module's category. """ 47 | try: 48 | return str(Path(self.path).parts[0]) 49 | except IndexError: 50 | return "" 51 | 52 | @property 53 | def fullpath(self): 54 | """ Full path of the module, that is, its path joined with its name. """ 55 | return str(Path(self.path).joinpath(self.name)) 56 | 57 | @property 58 | def help(self): 59 | """ Help message for the module. """ 60 | return self.get_info(("name", "description"), "comments") 61 | 62 | @property 63 | def subpath(self): 64 | """ First child path of the module. """ 65 | return str(Path(self.path).child) 66 | 67 | def search(self, text): 68 | """ Search for text in module's attributes. """ 69 | t = text.lower() 70 | return any(t in "".join(v).lower() for v in self._metadata.values()) or t in self.fullpath 71 | 72 | 73 | class Module(Entity, metaclass=MetaModule): 74 | """ Main class handling console modules. """ 75 | modules = PathBasedDict() 76 | 77 | @property 78 | def files(self): 79 | """ Shortcut to bound console's file manager instance. """ 80 | return self.console.__class__._files 81 | 82 | @property 83 | def logger(self): 84 | """ Shortcut to bound console's logger instance. """ 85 | return self.console.logger 86 | 87 | @property 88 | def store(self): 89 | """ Shortcut to bound console's store instance. """ 90 | return self.console.store 91 | 92 | @property 93 | def workspace(self): 94 | """ Shortcut to the current workspace. """ 95 | return self.console.workspace 96 | 97 | @classmethod 98 | def get_count(cls, path=None, **attrs): 99 | """ Count the number of modules under the given path and matching attributes. """ 100 | return cls.modules.count(path, **attrs) 101 | 102 | @classmethod 103 | def get_help(cls, category=None): 104 | """ Display command's help, using its metaclass' properties. """ 105 | uncat = {} 106 | for c, v in cls.modules.items(): 107 | if not isinstance(v, dict): 108 | uncat[c] = v 109 | if category is None: 110 | categories = list(set(cls.modules.keys()) - set(uncat.keys())) 111 | if len(uncat) > 0: 112 | categories += ["uncategorized"] 113 | else: 114 | categories = [category] 115 | s, i = "", 0 116 | for c in categories: 117 | d = [["Name", "Path", "Enabled", "Description"]] 118 | for n, m in sorted((flatten_dict(cls.modules.get(c, {})) if c != "uncategorized" else uncat).items(), 119 | key=lambda x: x[1].name): 120 | e = ["N", "Y"][m.enabled] 121 | d.append([m.name, m.subpath, e, m.description]) 122 | t = BorderlessTable(d, f"{c.capitalize()} modules") 123 | s += t.table + "\n\n" 124 | i += 1 125 | return "\n" + s.strip() + "\n" if i > 0 else "" 126 | 127 | @classmethod 128 | def get_list(cls): 129 | """ Get the list of modules' fullpath. """ 130 | return sorted([m.fullpath for m in Module.subclasses if m.check()]) 131 | 132 | @classmethod 133 | def get_modules(cls, path=None): 134 | """ Get the subdictionary of modules matching the given path. """ 135 | return cls.modules[path or ""] 136 | 137 | @classmethod 138 | def get_summary(cls): 139 | """ Get the summary of module counts per category. """ 140 | # display module stats 141 | m = [] 142 | uncat = [] 143 | for category in cls.modules.keys(): 144 | if isinstance(cls.modules[category], MetaModule): 145 | uncat.append(cls.modules[category]) 146 | continue 147 | l = "%d %s" % (Module.get_count(category), category) 148 | disabled = Module.get_count(category, enabled=False) 149 | if disabled > 0: 150 | l += " (%d disabled)" % disabled 151 | m.append(l) 152 | if len(uncat) > 0: 153 | l = "%d uncategorized" % len(uncat) 154 | disabled = len([u for u in uncat if not u.enabled]) 155 | if disabled > 0: 156 | l += " (%d disabled)" % disabled 157 | m.append(l) 158 | if len(m) > 0: 159 | mlen = max(map(len, m)) 160 | s = "\n" 161 | for line in m: 162 | s += f"\t-=[ {line: <{mlen}} ]=-\n" 163 | return s 164 | return "" 165 | 166 | @classmethod 167 | def register_module(cls, subcls): 168 | """ Register a Module subclass to the dictionary of modules. """ 169 | if subcls.path is None: 170 | return # do not consider orphan modules 171 | cls.modules[subcls.path, subcls.name] = subcls 172 | 173 | @classmethod 174 | def unregister_module(cls, subcls): 175 | """ Unregister a Module subclass from the dictionary of modules. """ 176 | p, n = subcls.path, subcls.name 177 | try: 178 | del cls.modules[n if p == "." else (p, n)] 179 | except KeyError: 180 | pass 181 | for M in Module.subclasses: 182 | if p == M.path and n == M.name: 183 | Module.subclasses.remove(M) 184 | break 185 | logger.detail(f"Unregistered module '{p}/{n}'") 186 | 187 | @classmethod 188 | def unregister_modules(cls, *subcls): 189 | """ Unregister Module subclasses from the dictionary of modules. """ 190 | for sc in subcls: 191 | cls.unregister_module(sc) 192 | 193 | def _feedback(self, success, failmsg): 194 | """ Dummy feedback method using a fail-message formatted with the "not" keyword (to be replaced by a null string 195 | in case of success). """ 196 | if success is None: 197 | return 198 | elif success: 199 | self.logger.success(failmsg.replace("not ", "")) 200 | else: 201 | self.logger.failure(failmsg) 202 | 203 | -------------------------------------------------------------------------------- /tests/__utils__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Test utility functions. 4 | 5 | """ 6 | import os 7 | import pytest 8 | import re 9 | import sys 10 | from peewee import DoesNotExist 11 | from subprocess import Popen, PIPE 12 | from tinyscript.helpers import ClassRegistry, Path 13 | from unittest import TestCase 14 | from unittest.mock import patch 15 | 16 | from sploitkit import * 17 | from sploitkit.__info__ import * 18 | from sploitkit.core.entity import load_entities, Entity 19 | 20 | from testsploit.main import MySploitConsole 21 | 22 | 23 | __all__ = ["CONSOLE", "execute", "patch", "rcfile", "reset_entities", "BaseModel", "Command", "Console", "DoesNotExist", 24 | "Entity", "Model", "Module", "StoreExtension", "TestCase"] 25 | 26 | 27 | try: 28 | CONSOLE = MySploitConsole() 29 | CONSOLE.config['APP_FOLDER'] = "testsploit/workspace" 30 | CONSOLE.config['WORKSPACE'] = "testsploit/workspace" 31 | except: 32 | CONSOLE = MySploitConsole.parent 33 | FILE = ".commands.rc" 34 | 35 | 36 | def execute(*commands): 37 | """ Execute commands. """ 38 | c = list(commands) + ["exit"] 39 | p = os.path.join("testsploit", FILE) 40 | with open(p, 'w') as f: 41 | f.write("\n".join(c)) 42 | r = rcfile(FILE) 43 | os.remove(p) 44 | return r 45 | 46 | 47 | def rcfile(rcfile, debug=False): 48 | """ Execute commands using a rcfile. """ 49 | p = os.path.join("testsploit", rcfile) 50 | if not os.path.isfile(p): 51 | raise ValueError("Bad rc file") 52 | cmd = "cd testsploit && python3 main.py --rcfile %s" % rcfile 53 | if debug: 54 | cmd += " -v" 55 | out, err = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True).communicate() 56 | out = re.split(r"\+{10,}\s.*?\s\+{10,}", out.decode())[1:] 57 | err = "\n".join(l for l in err.decode().splitlines() if not l.startswith("Warning: ") and \ 58 | all(x not in l for x in ["DeprecationWarning: ", "import pkg_resources", "There are some issues"])).strip() 59 | c = [] 60 | with open(p) as f: 61 | for l in f: 62 | l = l.strip() 63 | try: 64 | c.append((l, re.sub(r"\x1b\[\??\d{1,3}[hm]", "", out.pop(0)).strip())) 65 | except IndexError: 66 | c.append((l, None)) 67 | if c[-1][0] == "exit": 68 | c.pop(-1) 69 | return c, err 70 | 71 | 72 | def reset_entities(*entities): 73 | entities = list(entities) or [BaseModel, Command, Console, Model, Module, StoreExtension] 74 | Entity._subclasses = ClassRegistry() 75 | load_entities(entities) 76 | 77 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Core entity assets' tests. 4 | 5 | """ 6 | from __utils__ import * 7 | 8 | 9 | class TestBase(TestCase): 10 | def test_general_commands(self): 11 | self.assertRaises(SystemExit, Entity.get_subclass(Command, "Back")().run) 12 | self.assertRaises(SystemExit, Entity.get_subclass(Command, "Exit")().run) 13 | for Help in Entity.get_subclass(Command, "Help"): 14 | if Help.level == "general": 15 | self.assertIsNone(Help().run()) 16 | search = Entity.get_subclass(Command, "Search")() 17 | self.assertIsNone(search.run("does_not_exist")) 18 | self.assertIsNone(search.run("first")) 19 | for Show in Entity.get_subclass(Command, "Show"): 20 | if Show.level == "root": 21 | Show.keys += ["issues"] 22 | Show().set_keys() 23 | for k in Show.keys + ["issues"]: 24 | self.assertIsNotNone(Show().complete_values(k)) 25 | self.assertIsNone(Show().run(k)) 26 | if k == "options": 27 | self.assertIsNone(Show().run(k, "DEBUG")) 28 | break 29 | Set = Entity.get_subclass(Command, "Set") 30 | keys = list(Set().complete_keys()) 31 | self.assertTrue(len(keys) > 0) 32 | self.assertIsNotNone(Set().complete_values(keys[0])) 33 | self.assertIsNotNone(Set().complete_values("WORKSPACE")) 34 | self.assertRaises(ValueError, Set().validate, "BAD", "whatever") 35 | self.assertRaises(ValueError, Set().validate, "WORKSPACE", None) 36 | self.assertRaises(ValueError, Set().validate, "DEBUG", "whatever") 37 | self.assertIsNone(Set().run("DEBUG", "false")) 38 | Unset = Entity.get_subclass(Command, "Unset") 39 | self.assertTrue(len(list(Unset().complete_values())) > 0) 40 | self.assertRaises(ValueError, Unset().validate, "BAD") 41 | self.assertRaises(ValueError, Unset().validate, "WORKSPACE") 42 | self.assertRaises(ValueError, Unset().run, "DEBUG") 43 | self.assertIsNone(Set().run("DEBUG", "false")) 44 | 45 | def test_root_commands(self): 46 | for Help in Entity.get_subclass(Command, "Help"): 47 | if Help.level == "root": 48 | self.assertIsNone(Help().validate()) 49 | self.assertRaises(ValueError, Help().validate, "BAD") 50 | self.assertIsNone(Help().run()) 51 | for k in Help().keys: 52 | for v in Help().complete_values(k): 53 | self.assertIsNone(Help().run(k, v)) 54 | break 55 | 56 | -------------------------------------------------------------------------------- /tests/test_components.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Core entity assets' tests. 4 | 5 | """ 6 | from sploitkit.core.components import * 7 | 8 | from __utils__ import * 9 | 10 | 11 | class TestComponents(TestCase): 12 | def test_store(self): 13 | s = CONSOLE._storage 14 | self.assertTrue(isinstance(s.extensions, list)) 15 | s = CONSOLE.store 16 | self.assertTrue(isinstance(s.basemodels, list)) 17 | self.assertTrue(isinstance(s.models, list)) 18 | self.assertFalse(s.volatile) 19 | try: 20 | s.get_user(username="test") 21 | except DoesNotExist: 22 | s.set_user(username="test") 23 | self.assertEqual(s._last_snapshot, 0) 24 | self.assertIsNone(s.snapshot()) 25 | self.assertEqual(s._last_snapshot, 1) 26 | self.assertIsNone(s.snapshot(False)) 27 | self.assertEqual(s._last_snapshot, 0) 28 | self.assertIsNone(s.snapshot(False)) 29 | 30 | -------------------------------------------------------------------------------- /tests/test_console.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Core entity assets' tests. 4 | 5 | """ 6 | from sploitkit.core.components import * 7 | 8 | from __utils__ import * 9 | 10 | 11 | class TestConsole(TestCase): 12 | def test_console(self): 13 | self.assertIsNotNone(CONSOLE._get_tokens("help")) 14 | self.assertIsNone(CONSOLE.play("help")) 15 | r = CONSOLE.play("help", "show modules", capture=True) 16 | # check the presence of some commands from the base 17 | for cmd in ["?", "exit", "quit", "unset", "use", "record", "replay", "help", "show", "select"]: 18 | self.assertIn(" " + cmd + " ", r[0][1]) 19 | # check that some particular commands are missing 20 | for cmd in ["pydbg", "memory", "dict"]: 21 | self.assertNotIn(" " + cmd + " ", r[0][1]) 22 | 23 | -------------------------------------------------------------------------------- /tests/test_entity.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Core entity assets' tests. 4 | 5 | """ 6 | from sploitkit.core.entity import load_entities, set_metadata 7 | from tinyscript.helpers import parse_docstring 8 | 9 | from __utils__ import * 10 | 11 | 12 | class TestEntity(TestCase): 13 | def test_load_entities(self): 14 | self.assertIn(Command, list(Entity._subclasses.keys())) 15 | self.assertIn(Model, list(Entity._subclasses.keys())) 16 | self.assertIn(Module, list(Entity._subclasses.keys())) 17 | self.assertTrue(len(Command.subclasses) > 0) 18 | self.assertTrue(len(Model.subclasses) > 0) 19 | l = len(Module.subclasses) 20 | self.assertTrue(l > 0) 21 | M = Module.subclasses[0] 22 | del Entity._subclasses[Module] 23 | load_entities([Module], CONSOLE._root.dirname.joinpath("modules"), exclude={'module': [M]}) 24 | self.assertTrue(len(Module.subclasses) > 0) 25 | self.assertRaises(ValueError, Entity._subclasses.__getitem__, (Module, M.__name__)) 26 | del Entity._subclasses[Module] 27 | load_entities([Module], CONSOLE._root.dirname.joinpath("modules")) 28 | self.assertTrue(len(Module.subclasses) > 0) 29 | self.assertIn(Entity._subclasses[Module, M.__name__], Module.subclasses) 30 | load_entities([Console], CONSOLE._root, backref={'command': ["console"]}) 31 | 32 | def test_set_metadata(self): 33 | # check that every subclass has its own description, and not the one of its entity class 34 | for cls in Entity._subclasses.keys(): 35 | for subcls in cls.subclasses: 36 | self.assertNotEqual(subcls.__doc__, cls.__doc__) 37 | # now, alter a Command subclass to test for set_metadata 38 | C = Command.subclasses[0] 39 | C.meta = {'options': ["BAD_OPTION"]} 40 | self.assertRaises(ValueError, set_metadata, C, parse_docstring) 41 | C.meta = {'options': [("test", "default", False, "description")]} 42 | set_metadata(C, parse_docstring) 43 | M = Module.subclasses[0] 44 | B = M.__base__ 45 | B.meta = {'test': "test"} 46 | M._inherit_metadata = True 47 | set_metadata(M, parse_docstring) 48 | 49 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Model entity tests. 4 | 5 | """ 6 | from __utils__ import * 7 | 8 | 9 | class TestModule(TestCase): 10 | def test_model(self): 11 | self.assertTrue(len(Model.subclasses) > 0) 12 | self.assertTrue(len(StoreExtension.subclasses) == 0) # sploitkit's base has no StoreExtension at this moment 13 | self.assertIsNotNone(repr(Model.subclasses[0])) 14 | 15 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Module entity tests. 4 | 5 | """ 6 | from __utils__ import * 7 | 8 | 9 | class TestModule(TestCase): 10 | def test_module_attributes(self): 11 | for m in Module.subclasses: 12 | m.console = CONSOLE # use the main console ; should normally be a ModuleConsole 13 | self.assertIsNotNone(m.base) 14 | for a in ["files", "logger", "store", "workspace"]: 15 | self.assertIsNotNone(getattr(m(), a)) 16 | 17 | def test_module_help(self): 18 | for c in [None, "uncategorized", "does_not_exist"]: 19 | self.assertIsNotNone(Module.get_help(c)) 20 | M = Module.subclasses[0] 21 | self.assertIsNone(M()._feedback(None, "")) 22 | self.assertIsNone(M()._feedback(True, "test")) 23 | self.assertIsNone(M()._feedback(False, "test")) 24 | 25 | def test_module_registry(self): 26 | self.assertIsNotNone(Module.get_list()) 27 | self.assertIsNotNone(Module.get_modules()) 28 | class FakeModule(Module): 29 | path = "fake_module" 30 | name = "fake_module" 31 | self.assertIsNone(Module.unregister_module(FakeModule)) 32 | class OrphanModule(Module): 33 | path = None 34 | name = "orphan_module" 35 | self.assertIsNone(Module.register_module(OrphanModule)) 36 | self.assertNotIn(OrphanModule, Module.subclasses) 37 | 38 | -------------------------------------------------------------------------------- /tests/test_scenarios.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """Scenario-based tests of a Sploitkit application. 4 | 5 | """ 6 | from __utils__ import * 7 | 8 | 9 | class TestScenarios(TestCase): 10 | def test_bad_command(self): 11 | # single bad command 12 | out, err = execute("bad") 13 | self.assertNotEqual(err, "") 14 | self.assertEqual(out[0][1], "") 15 | # successful command before the failing one 16 | out, err = execute("help", "bad") 17 | self.assertNotEqual(err, "") 18 | self.assertNotEqual(out[0][1], "") 19 | self.assertEqual(out[1][1], "") 20 | # failing command before the successful one 21 | out, err = execute("bad", "help") 22 | self.assertNotEqual(err, "") 23 | self.assertEqual(out[0][1], "") 24 | self.assertIsNone(out[1][1]) 25 | 26 | def test_help(self): 27 | out, err = execute("help") 28 | self.assertEqual(err, "") 29 | self.assertNotEqual(out, "") 30 | out, err = execute("?") 31 | self.assertEqual(err, "") 32 | self.assertNotEqual(out, "") 33 | 34 | def test_set_debug(self): 35 | out, err = execute("set DEBUG true", "help") 36 | 37 | def test_show_modules(self): 38 | out, err = execute("show modules") 39 | self.assertEqual(err, "") 40 | self.assertIn("modules", out[0][1]) 41 | self.assertIn("my_first_module", out[0][1]) 42 | 43 | def test_show_options(self): 44 | out, err = execute("show options") 45 | self.assertEqual(err, "") 46 | self.assertIn("Console options", out[0][1]) 47 | self.assertIn("APP_FOLDER", out[0][1]) 48 | self.assertIn("DEBUG", out[0][1]) 49 | self.assertIn("WORKSPACE", out[0][1]) 50 | 51 | def test_show_projects(self): 52 | out, err = execute("show projects") 53 | self.assertEqual(err, "") 54 | self.assertIn("Existing projects", out[0][1]) 55 | 56 | -------------------------------------------------------------------------------- /testsploit/README: -------------------------------------------------------------------------------- 1 | # {} 2 | 3 | #TODO: Fill in the README -------------------------------------------------------------------------------- /testsploit/commands/commands.py: -------------------------------------------------------------------------------- 1 | from sploitkit import * 2 | 3 | 4 | class CommandWithOneArg(Command): 5 | """ Description here """ 6 | level = "module" 7 | single_arg = True 8 | 9 | def complete_values(self): 10 | #TODO: compute the list of possible values 11 | return [] 12 | 13 | def run(self): 14 | #TODO: compute results here 15 | pass 16 | 17 | def validate(self, value): 18 | #TODO: validate the input value 19 | if value not in self.complete_values(): 20 | raise ValueError("invalid value") 21 | 22 | 23 | class CommandWithTwoArgs(Command): 24 | """ Description here """ 25 | level = "module" 26 | 27 | def complete_keys(self): 28 | #TODO: compute the list of possible keys 29 | return [] 30 | 31 | def complete_values(self, key=None): 32 | #TODO: compute the list of possible values taking the key into account 33 | return [] 34 | 35 | def run(self): 36 | #TODO: compute results here 37 | pass 38 | -------------------------------------------------------------------------------- /testsploit/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import site 3 | site.addsitedir("../src") 4 | 5 | from sploitkit import FrameworkConsole 6 | from tinyscript import * 7 | 8 | 9 | class MySploitConsole(FrameworkConsole): 10 | #TODO: set your console attributes 11 | pass 12 | 13 | 14 | if __name__ == '__main__': 15 | parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") 16 | parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") 17 | initialize() 18 | c = MySploitConsole( 19 | "MySploit", 20 | #TODO: configure your console settings 21 | dev=args.dev, 22 | ) 23 | c.rcfile(args.rcfile) if args.rcfile else c.start() 24 | -------------------------------------------------------------------------------- /testsploit/modules/modules.py: -------------------------------------------------------------------------------- 1 | from sploitkit import * 2 | 3 | 4 | class MyFirstModule(Module): 5 | """ Description here 6 | 7 | Author: your name (your email) 8 | Version: 1.0 9 | """ 10 | def run(self): 11 | pass 12 | 13 | 14 | class MySecondModule(Module): 15 | """ Description here 16 | 17 | Author: your name (your email) 18 | Version: 1.0 19 | """ 20 | def run(self): 21 | pass 22 | -------------------------------------------------------------------------------- /testsploit/requirements.txt: -------------------------------------------------------------------------------- 1 | sploitkit>=0.5.8 2 | -------------------------------------------------------------------------------- /testsploit/workspace/store.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dhondta/python-sploitkit/689597358ca12fe4076a8d9faa86b345304955d1/testsploit/workspace/store.db --------------------------------------------------------------------------------