diff --git a/.coveragerc b/.coveragerc index f2c920e..35c708a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,14 +1,13 @@ -[run] -cover_pylib = false -source = sploitkit -omit = - /home/travis/virtualenv/* - */site-packages/* - tests/* - -[report] -exclude_lines = - pragma: no cover - class IPAddressField - class MACAddressField - class Trigger +[run] +cover_pylib = false +source = sploitkit +omit = + */site-packages/* + tests/* + +[report] +exclude_lines = + pragma: no cover + class IPAddressField + class MACAddressField + class Trigger diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..c5c8b7e --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,37 @@ +# This workflow will deploy the Python package to PyPi.org + +name: deploy + +env: + package: sploitkit + +on: + push: + branches: + - main + paths: + - '**/VERSION.txt' + workflow_run: + workflows: ["build"] + types: [completed] + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Cleanup README + run: | + sed -ri 's/^(##*)\s*:.*:\s*/\1 /g' README.md + awk '{if (match($0,"## Supporters")) exit; print}' README.md > README + mv -f README README.md + - run: python3 -m pip install --upgrade build && python3 -m build + - name: Upload ${{ env.package }} to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + verbose: true + verify_metadata: false diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..7daa6fd --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,81 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: build + +env: + package: sploitkit + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: ["3.8", "3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install pandoc + run: sudo apt-get install -y pandoc + - name: Install ${{ env.package }} + run: | + python -m pip install --upgrade pip + python -m pip install pytest pytest-cov pytest-pythonpath coverage + pip install -r requirements.txt + pip install . + - name: Test ${{ env.package }} with pytest + run: | + pytest --cov=$package + coverage: + needs: build + runs-on: ubuntu-latest + env: + cov_badge_path: docs/coverage.svg + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Install pandoc + run: sudo apt-get install -y pandoc notification-daemon + - name: Install ${{ env.package }} + run: | + python -m pip install --upgrade pip + python -m pip install pytest pytest-cov pytest-pythonpath + pip install -r requirements.txt + pip install . + - name: Make coverage badge for ${{ env.package }} + run: | + pip install genbadge[coverage] + pytest --cov=$package --cov-report=xml + genbadge coverage -i coverage.xml -o $cov_badge_path + - name: Verify Changed files + uses: tj-actions/verify-changed-files@v12 + id: changed_files + with: + files: ${{ env.cov_badge_path }} + - name: Commit files + if: steps.changed_files.outputs.files_changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add $cov_badge_path + git commit -m "Updated coverage.svg" + - name: Push changes + if: steps.changed_files.outputs.files_changed == 'true' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.github_token }} + branch: ${{ github.ref }} diff --git a/.gitignore b/.gitignore index b82dc2f..c1c4059 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ target/ .idea .vagrant .test -testsploit .pytest_cache tmp TODO diff --git a/.readthedocs.yml b/.readthedocs.yml index b00eb0d..3c34711 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,8 +1,8 @@ -version: 2 -mkdocs: - configuration: mkdocs.yml -formats: all -python: - version: 3.6 - install: - - requirements: docs/requirements.txt +version: 2 + +mkdocs: + configuration: docs/mkdocs.yml + +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ad1a9cf..0000000 --- a/.travis.yml +++ /dev/null @@ -1,22 +0,0 @@ -language: python -sudo: required -env: - global: - - PIP_ARGS="--upgrade --use-feature=2020-resolver" - - COLUMNS=120 -python: - - "3.6" - - "3.7" - - "3.8" -cache: pip -install: - - python -m pip install --upgrade pip - - pip install $PIP_ARGS nose - - pip install $PIP_ARGS coveralls - - pip install $PIP_ARGS -r requirements.txt - - pip install $PIP_ARGS . -script: - - stty cols 120 - - sploitkit testsploit - - nosetests -v --with-coverage --cover-tests --cover-package sploitkit tests -after_success: coveralls diff --git a/README.md b/README.md index a6477b1..b5271a9 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,89 @@ -

-

SploitKit Tweet

-

Make a Metasploit-like console.

- -[![PyPi](https://img.shields.io/pypi/v/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) -[![Read The Docs](https://readthedocs.org/projects/python-sploitkit/badge/?version=latest)](https://python-sploitkit.readthedocs.io/en/latest/?badge=latest) -[![Build Status](https://travis-ci.com/dhondta/python-sploitkit.svg?branch=master)](https://travis-ci.com/dhondta/python-sploitkit) -[![Coverage Status](https://coveralls.io/repos/github/dhondta/python-sploitkit/badge.svg?branch=master)](https://coveralls.io/github/dhondta/python-sploitkit?branch=master) -[![Python Versions](https://img.shields.io/pypi/pyversions/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) -[![Requirements Status](https://requires.io/github/dhondta/python-sploitkit/requirements.svg?branch=master)](https://requires.io/github/dhondta/python-sploitkit/requirements/?branch=master) -[![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) -[![License](https://img.shields.io/pypi/l/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) -[![Donate](https://img.shields.io/badge/donate-paypal-orange.svg)](https://www.paypal.me/dhondta) - - -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. - -``` -pip install sploitkit -``` - -## :sunglasses: Usage - -From this point, `main.py` has the following code: - -```python -#!/usr/bin/python3 -from sploitkit import FrameworkConsole - - -class MySploitConsole(FrameworkConsole): - #TODO: set your console attributes - pass - - -if __name__ == '__main__': - MySploitConsole( - "MySploit", - #TODO: configure your console settings - ).start() -``` - -And you can run it from the terminal: - -![](https://github.com/dhondta/python-sploitkit/tree/master/docs/img/my-sploit-start.png) - -## :ballot_box_with_check: Features - -Sploitkit provides a base set of entities (consoles, commands, modules, models). - -Multiple base console levels already exist (for detailed descriptions, see [the console section](../console/index.html)): - -- [`FrameworkConsole`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/core/console.py): the root console, started through `main.py` -- [`ProjectConsole`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/project.py): the project console, for limiting the workspace to a single project, invoked through the `select [project]` command -- [`ModuleConsole`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/module.py): the module console, started when a module is invoked through the `use [module]` command - -This framework provides more than 20 base commands, distributed in sets of functionalities (for detailed descriptions, see [the command section](../command/index.html)): - -- [*general*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/general.py): commands for every level (e.g. `help`, `show`, `set`) -- [*module*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/module.py): base module-level commands (e.g. `use`, `run`, `show`) -- [*project*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/project.py): base project-level commands (e.g. `select`, `load`, `archive`) -- [*recording*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/recording.py): recording commands, for managing `.rc` files (`record`, `replay`) -- [*root*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/root.py): base root-level commands (`help`) -- [*utils*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/utils.py): utility commands (`shell`, `pydbg`, `memory`) - -It also holds some base models for its storage: - -- [*users*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/models/notes.py): for user-related data (`User`, `Email`, `Password`) -- [*systems*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/models/systems.py): for system-related data (`Host`, `Port`, `Service`) -- [*organization*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/models/organization.py): for organization-related data (`Organization`, `Unit`, `Employee`) -- [*notes*](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/models/notes.py): for linking notes to users, hosts or organizations - -No module is provided with the framework as it is case-specific. - -## :pencil2: Customization - -Sploitkit defines multiple types of entities for various purposes. The following entities can be subclassed: - -- [`Console`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/core/console.py): a new console for a new level of interaction (e.g. [`ProjectConsole`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/project.py)) ; the "`root`" level is owned by the [`FrameworkConsole`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/core/console.py), [`Console`](https://github.com/dhondta/python-sploitkit/blob/master/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/master/sploitkit/base/commands/module.py) for the module-level commands with [`ModuleConsole(Console)` and `Use(Command)`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/base/commands/module.py)) -- [`Command`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/core/command.py): a new command associated with any or defined consoles using the `level` attribute -- [`Module`](https://github.com/dhondta/python-sploitkit/blob/master/sploitkit/core/module.py): a new module associated to a console -- [`Model`, `BaseModel`, `StoreExtension`](https://github.com/dhondta/python-sploitkit/blob/master/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/master/sploitkit/base/models/users.py)) - - -## :clap: Supporters - -[![Stargazers repo roster for @dhondta/python-sploitkit](https://reporoster.com/stars/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/stargazers) - -[![Forkers repo roster for @dhondta/python-sploitkit](https://reporoster.com/forks/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/network/members) - -

Back to top

+

+

SploitKit Tweet

+

Make a Metasploit-like console.

+ +[![PyPi](https://img.shields.io/pypi/v/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) +[![Read The Docs](https://readthedocs.org/projects/python-sploitkit/badge/?version=latest)](https://python-sploitkit.readthedocs.io/en/latest/?badge=latest) +[![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) +[![Coverage Status](https://raw.githubusercontent.com/dhondta/python-sploitkit/main/docs/coverage.svg)](#) +[![Python Versions](https://img.shields.io/pypi/pyversions/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) +[![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) +[![License](https://img.shields.io/pypi/l/sploitkit.svg)](https://pypi.python.org/pypi/sploitkit/) + + +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. + +``` +pip install sploitkit +``` + +## :sunglasses: Usage + +From this point, `main.py` has the following code: + +```python +#!/usr/bin/python3 +from sploitkit import FrameworkConsole + + +class MySploitConsole(FrameworkConsole): + #TODO: set your console attributes + pass + + +if __name__ == '__main__': + MySploitConsole( + "MySploit", + #TODO: configure your console settings + ).start() +``` + +And you can run it from the terminal: + +![](https://github.com/dhondta/python-sploitkit/tree/main/docs/pages/img/my-sploit-start.png) + +## :ballot_box_with_check: Features + +Sploitkit provides a base set of entities (consoles, commands, modules, models). + +Multiple base console levels already exist (for detailed descriptions, see [the console section](../console/index.html)): + +- [`FrameworkConsole`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/console.py): the root console, started through `main.py` +- [`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 +- [`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 + +This framework provides more than 20 base commands, distributed in sets of functionalities (for detailed descriptions, see [the command section](../command/index.html)): + +- [*general*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/general.py): commands for every level (e.g. `help`, `show`, `set`) +- [*module*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/module.py): base module-level commands (e.g. `use`, `run`, `show`) +- [*project*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/project.py): base project-level commands (e.g. `select`, `load`, `archive`) +- [*recording*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/recording.py): recording commands, for managing `.rc` files (`record`, `replay`) +- [*root*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/root.py): base root-level commands (`help`) +- [*utils*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/commands/utils.py): utility commands (`shell`, `pydbg`, `memory`) + +It also holds some base models for its storage: + +- [*users*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/notes.py): for user-related data (`User`, `Email`, `Password`) +- [*systems*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/systems.py): for system-related data (`Host`, `Port`, `Service`) +- [*organization*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/organization.py): for organization-related data (`Organization`, `Unit`, `Employee`) +- [*notes*](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/base/models/notes.py): for linking notes to users, hosts or organizations + +No module is provided with the framework as it is case-specific. + +## :pencil2: Customization + +Sploitkit defines multiple types of entities for various purposes. The following entities can be subclassed: + +- [`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)) +- [`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 +- [`Module`](https://github.com/dhondta/python-sploitkit/blob/main/sploitkit/core/module.py): a new module associated to a console +- [`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)) + + +## :clap: Supporters + +[![Stargazers repo roster for @dhondta/python-sploitkit](https://reporoster.com/stars/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/stargazers) + +[![Forkers repo roster for @dhondta/python-sploitkit](https://reporoster.com/forks/dark/dhondta/python-sploitkit)](https://github.com/dhondta/python-sploitkit/network/members) + +

Back to top

diff --git a/docs/coverage.svg b/docs/coverage.svg new file mode 100644 index 0000000..78f9f98 --- /dev/null +++ b/docs/coverage.svg @@ -0,0 +1 @@ +coverage: 99.03%coverage99.03% \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..44488f8 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,59 @@ +site_author: dhondta +site_name: "SploitKit - Devkit for building Metasploit-like consoles" +repo_url: https://github.com/dhondta/python-sploitkit +copyright: Copyright © 2019-2023 Alexandre D'Hondt +docs_dir: pages +nav: + - Introduction: index.md + - 'Getting started': quickstart.md + - Design: design.md + - Classes: classes.md + - Classes: + - Entity: classes/entity.md + - Console: classes/console.md + - Command: classes/command.md + - Module: classes/module.md + - Datastore: classes/datastore.md + - 'Base entities': base.md + - 'Base entities': + - Consoles: base/consoles.md + - Commands: base/commands.md + - Models: base/models.md + - 'Real-life examples': + - 'DroneSploit': examples/dronesploit.md +extra: + generator: false + social: + - icon: fontawesome/solid/paper-plane + link: mailto:alexandre.dhondt@gmail.com + name: Contact Alex + - icon: fontawesome/brands/github + link: https://github.com/dhondta + name: Alex on GitHub + - icon: fontawesome/brands/linkedin + link: https://www.linkedin.com/in/alexandre-d-2ab2aa14/ + name: Alex on LinkedIn + - icon: fontawesome/brands/twitter + link: https://twitter.com/alex_dhondt + name: Alex on Twitter +theme: + name: material + palette: + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + logo: img/logo.png + favicon: img/icon.png +use_directory_urls: false +markdown_extensions: + - admonition + - codehilite: + linenums: true + - pymdownx.details + - toc: + permalink: True diff --git a/docs/base.md b/docs/pages/base.md similarity index 96% rename from docs/base.md rename to docs/pages/base.md index 059f061..470800b 100644 --- a/docs/base.md +++ b/docs/pages/base.md @@ -1,3 +1,3 @@ -The following table shows the list of native entities provided with Sploitkit : - - +The following table shows the list of native entities provided with Sploitkit : + + diff --git a/docs/base/commands.md b/docs/pages/base/commands.md similarity index 100% rename from docs/base/commands.md rename to docs/pages/base/commands.md diff --git a/docs/base/consoles.md b/docs/pages/base/consoles.md similarity index 100% rename from docs/base/consoles.md rename to docs/pages/base/consoles.md diff --git a/docs/base/models.md b/docs/pages/base/models.md similarity index 100% rename from docs/base/models.md rename to docs/pages/base/models.md diff --git a/docs/classes.md b/docs/pages/classes.md similarity index 98% rename from docs/classes.md rename to docs/pages/classes.md index d9117dc..90a6f28 100644 --- a/docs/classes.md +++ b/docs/pages/classes.md @@ -1,5 +1,5 @@ -The following figure depicts the class hierarchy implementing the aforementioned design : - -![Sploitkit - Class hierarchy](img/class-hierarchy.png) - -On this figure, one can see the main attributes and methods that can be used (in black) or overridden (in red). +The following figure depicts the class hierarchy implementing the aforementioned design : + +![Sploitkit - Class hierarchy](img/class-hierarchy.png) + +On this figure, one can see the main attributes and methods that can be used (in black) or overridden (in red). diff --git a/docs/classes/command.md b/docs/pages/classes/command.md similarity index 98% rename from docs/classes/command.md rename to docs/pages/classes/command.md index 24ae3ab..1be5da1 100644 --- a/docs/classes/command.md +++ b/docs/pages/classes/command.md @@ -1,111 +1,111 @@ -*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. - -Note that they are two possible formats : `COMMAND VALUE` or `COMMAND KEY VALUE`. - -## Styling - -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 : - -**Name** | **Command class name** | **Rendered command name** ---- | :---: | :---: -*Lowercase* | `MyCommand` | `mycommand` -*Powershell* | `MyCommand` | `My-Command` -*Slugified* (default) | `MyCommand` | `my-command` -*Uppercase* | `MyCommand` | `MYCOMMAND` - -Command styling can be set using the `set_style(...)` class method. - -??? example "**Example**: Setting commands style" - - :::python - from sploitkit import Command - - Command.set_style("powershell") - -
- -## Definition - -A *command* always subclasses the `Command` generic entity class and can be dissected as follows : - -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. -2. **Class attributes** : They tune the applicability and nature of the command. -3. **Instance methods** : They define the logic of the command, i.e. `run()`. - -Here is the list of tunable class attributes : - -**Attribute** | **Type** | **Default** | **Description** ---- | :---: | :---: | --- -`aliases` | `list`(`str`) | `[]` | the list of aliases for the command -`alias_only` | `bool` | `False` | whether only the aliases defined in the related list should be considered or also the converted command class name -`applies_to` | `list`(`str`) | `[]` | a list of *modules* this command applies to -`except_levels` | `list`(`str`) | `[]` | a list of non-applicable levels -`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) -`level` | `str` | "`general`" | command's level ; "`general`" means that it applies to all console levels -`single_arg` | `bool` | `False` | handle everything after the command as a single argument -`values` | `list`(`str`) | - - -??? example "**Example**: Making a command in Powershell style with an alias and applicable to the *module* level" - - :::python - from sploitkit import Command - - Command.set_style("powershell") - - class GetSomething(Command): - """ Get something """ - aliases = ["gs"] - level = "module" - [...] - -
- -## Completion - -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. - -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 : - -- `COMMAND VALUE` : Then only `complete_values` is used, handling the `values` class attribute as a list. -- `COMMAND KEY VALUE` : This one uses - - - `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 - - `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 - -??? example "**Example**: Default completion for key-values (second command format)" - - :::python - class DoSomething(Command): - values = {"key1": ["1", "2", "3"], - "key2": ["4", "5", "6"], - "key3": ["7", "8", "9"]} - - def run(self, key=None, value=None): - print(key, value) - - This command will yield a completion list of : - - - `["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 - - ![](/img/command-key-completion.png "Key completion") - - - `["4", "5", "6"]`when entering "`do-something key2 `" and pressing the tab key twice - - ![](/img/command-value-completion.png "Value completion") - -
- -## Validation - -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. - -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. - -??? example "**Example**: Key-value validation" - - 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 : - - ![](/img/command-validation.png "Validation error") - -
+*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. + +Note that they are two possible formats : `COMMAND VALUE` or `COMMAND KEY VALUE`. + +## Styling + +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 : + +**Name** | **Command class name** | **Rendered command name** +--- | :---: | :---: +*Lowercase* | `MyCommand` | `mycommand` +*Powershell* | `MyCommand` | `My-Command` +*Slugified* (default) | `MyCommand` | `my-command` +*Uppercase* | `MyCommand` | `MYCOMMAND` + +Command styling can be set using the `set_style(...)` class method. + +??? example "**Example**: Setting commands style" + + :::python + from sploitkit import Command + + Command.set_style("powershell") + +
+ +## Definition + +A *command* always subclasses the `Command` generic entity class and can be dissected as follows : + +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. +2. **Class attributes** : They tune the applicability and nature of the command. +3. **Instance methods** : They define the logic of the command, i.e. `run()`. + +Here is the list of tunable class attributes : + +**Attribute** | **Type** | **Default** | **Description** +--- | :---: | :---: | --- +`aliases` | `list`(`str`) | `[]` | the list of aliases for the command +`alias_only` | `bool` | `False` | whether only the aliases defined in the related list should be considered or also the converted command class name +`applies_to` | `list`(`str`) | `[]` | a list of *modules* this command applies to +`except_levels` | `list`(`str`) | `[]` | a list of non-applicable levels +`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) +`level` | `str` | "`general`" | command's level ; "`general`" means that it applies to all console levels +`single_arg` | `bool` | `False` | handle everything after the command as a single argument +`values` | `list`(`str`) | + + +??? example "**Example**: Making a command in Powershell style with an alias and applicable to the *module* level" + + :::python + from sploitkit import Command + + Command.set_style("powershell") + + class GetSomething(Command): + """ Get something """ + aliases = ["gs"] + level = "module" + [...] + +
+ +## Completion + +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. + +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 : + +- `COMMAND VALUE` : Then only `complete_values` is used, handling the `values` class attribute as a list. +- `COMMAND KEY VALUE` : This one uses + + - `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 + - `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 + +??? example "**Example**: Default completion for key-values (second command format)" + + :::python + class DoSomething(Command): + values = {"key1": ["1", "2", "3"], + "key2": ["4", "5", "6"], + "key3": ["7", "8", "9"]} + + def run(self, key=None, value=None): + print(key, value) + + This command will yield a completion list of : + + - `["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 + + ![](/img/command-key-completion.png "Key completion") + + - `["4", "5", "6"]`when entering "`do-something key2 `" and pressing the tab key twice + + ![](/img/command-value-completion.png "Value completion") + +
+ +## Validation + +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. + +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. + +??? example "**Example**: Key-value validation" + + 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 : + + ![](/img/command-validation.png "Validation error") + +
diff --git a/docs/classes/console.md b/docs/pages/classes/console.md similarity index 97% rename from docs/classes/console.md rename to docs/pages/classes/console.md index e3ff2eb..449f1c6 100644 --- a/docs/classes/console.md +++ b/docs/pages/classes/console.md @@ -1,77 +1,77 @@ -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. - -## Components - -Basically, a console holds the central logic of the CLI through multiple components : - -- *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). -- *Global State* : It holds the key-values to be shared amongst the console levels and modules and their associated commands. -- *Datastore* : It aims to persistently save data. -- *Jobs Pool* : It manages jobs to be run from the console. -- *Sessions Pool* : It manages the open sessions, obtained from the execution of *modules*. - -In order to make a custom console, two classes exist : - -- The generic `Console` class : for making child console levels. -- The specific `FrameworkConsole` class : to be used directly or subclassed to define the root console. - -??? example "**Example**: Basic application running a `FrameworkConsole`" - - :::python - from sploitkit import FrameworkConsole - - if __name__ == '__main__': - FrameworkConsole("MySploit").start() - -
- -## Scope and prompt - -A console can be tuned in the following way using some class attributes : - -- `level` : the console level name, for use with *commands* -- `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) -- `style` : the style definition as a dictionary for the prompt tokens - -??? example "**Example**: A console subclass for defining a new level" - - :::python - from sploitkit import Console - - class MyConsole(Console): - level = "new_level" - message = [ - ('class:prompt', "["), - ('class:name', "console"), - ('class:prompt', "]>> "), - ] - style = { - 'prompt': "#eeeeee", - 'name': "#ff0000", - } - - ![](/img/console-prompt.png "Prompt rendered") - -
- -## Entity sources - -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 : - -- `banners` (default: `None`) : for customizing the startup application banner -- `entities` : a list of source folders to be parsed for importing entities -- `libraries` (default: "`.`") : a list of source folders to be added to `sys.path` - -??? example "**Example**: Defining sources for banners, entities and libraries" - - :::python - from sploitkit import FrameworkConsole - - class MyConsole(Console): - ... - sources = { - 'banners': "banners", - 'libraries': "lib", - } - -
+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. + +## Components + +Basically, a console holds the central logic of the CLI through multiple components : + +- *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). +- *Global State* : It holds the key-values to be shared amongst the console levels and modules and their associated commands. +- *Datastore* : It aims to persistently save data. +- *Jobs Pool* : It manages jobs to be run from the console. +- *Sessions Pool* : It manages the open sessions, obtained from the execution of *modules*. + +In order to make a custom console, two classes exist : + +- The generic `Console` class : for making child console levels. +- The specific `FrameworkConsole` class : to be used directly or subclassed to define the root console. + +??? example "**Example**: Basic application running a `FrameworkConsole`" + + :::python + from sploitkit import FrameworkConsole + + if __name__ == '__main__': + FrameworkConsole("MySploit").start() + +
+ +## Scope and prompt + +A console can be tuned in the following way using some class attributes : + +- `level` : the console level name, for use with *commands* +- `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) +- `style` : the style definition as a dictionary for the prompt tokens + +??? example "**Example**: A console subclass for defining a new level" + + :::python + from sploitkit import Console + + class MyConsole(Console): + level = "new_level" + message = [ + ('class:prompt', "["), + ('class:name', "console"), + ('class:prompt', "]>> "), + ] + style = { + 'prompt': "#eeeeee", + 'name': "#ff0000", + } + + ![](/img/console-prompt.png "Prompt rendered") + +
+ +## Entity sources + +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 : + +- `banners` (default: `None`) : for customizing the startup application banner +- `entities` : a list of source folders to be parsed for importing entities +- `libraries` (default: "`.`") : a list of source folders to be added to `sys.path` + +??? example "**Example**: Defining sources for banners, entities and libraries" + + :::python + from sploitkit import FrameworkConsole + + class MyConsole(Console): + ... + sources = { + 'banners': "banners", + 'libraries': "lib", + } + +
diff --git a/docs/classes/datastore.md b/docs/pages/classes/datastore.md similarity index 85% rename from docs/classes/datastore.md rename to docs/pages/classes/datastore.md index 37b34b1..59e55f4 100644 --- a/docs/classes/datastore.md +++ b/docs/pages/classes/datastore.md @@ -1,9 +1,9 @@ -## Datastore - - -### `Model` - - -### `StoreExtension` - - +## Datastore + + +### `Model` + + +### `StoreExtension` + + diff --git a/docs/classes/entity.md b/docs/pages/classes/entity.md similarity index 98% rename from docs/classes/entity.md rename to docs/pages/classes/entity.md index 6cbab7b..c4fa3e7 100644 --- a/docs/classes/entity.md +++ b/docs/pages/classes/entity.md @@ -1,165 +1,165 @@ -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 : - -- registering entity classes (see hereafter: `Console`, `Command`, ...) -- handling requirements, dynamically enabling/disabling entities -- handling metadata (formatting them for display in the CLI) - -## Requirements and applicability - -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 : - -**Key** | **Description** ---- | --- -`config` | Dictionary of `Config`/`Option` values to be set (see hereafter). -`file` | List of files that must exist in the current workspace. -`python` | List of Python packages required to be installed in the environment. -`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'}}`.
-`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.
- -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. - -??? example "**Example**: Setting a *command* as applicable only for *modules* named '`do_something`'" - - 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. - - :::python - class DoIt(Command): - applies_to = [("console", "module", "name", "do_something")] - [...] - def run(self): - [...] - -
- -## Inheritance and aggregation - -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 : - -- `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. -- 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*. - -## Metadata parsing - -Metadata of entities can be defined in three different ways (can be combined, listed hereafter in inverse order of precedence) : - -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`. -2. `meta` attribute : A dictionary of metadata key-values that will update the final metadata dictionary. -3. `metadata` attribute : Same as for `meta` (exists for a question of cross-compatibility with plugins of other frameworks). - -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). - -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. - -The default docstring format (parsed through a dedicated function within Sploitkit's utils) consists of sections separated by double newlines. Parsing occurs as follows : - -1. The first section is always the *description*. -2. Next sections are handled this way : - - - 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. - - [Field]: [value] - - That is, the field name capitalized with no whitespace before the colon and whatever value, multiline. - - - 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. - -??? example "**Example**: Writing a docstring for an entity" - - :::python - class Example(object): - \""" - This is a test multi-line long - description. - - This is a first comment. - - Author: John Doe - (john.doe@example.com) - Version: 1.0 - Comments: - - subcomment 1 - - subcomment 2 - - Something: lorem ipsum - paragraph - - This is a second comment, - a multi-line one. - \""" - [...] - - >>> parse_docstring(Example) - {'author': 'John Doe (john.doe@example.com)', - 'comments': ['This is a first comment.', - ('subcomment 1', 'subcomment 2'), - 'This is a second comment, a multi-line one.'], - 'description': 'This is a test multi-line long description.', - 'something': 'lorem ipsum paragraph', - 'version': '1.0'} - -
- -## `Config` and `Option` - -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. - -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. - -??? example "**Example**: Declaring a configuration (entity class attribute)" - - :::python - from sploitkit import Config, Option, ROption - - config = Config({ - Option(...), - ROption(...), - }) - -
- -!!! note "`Option` and `ROption`" - - 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. - - 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*). - -A configuration option object, that is, an instance of the `Option` or `ROption` class, is defined using multiple arguments : - -**Argument** | **Type** | **Default** | **Description** ---- | :---: | :---: | --- -`name` | `str` | | option's name, conventionally uppercase -`description` | `str` | `None` | help text for this option -`required` | `bool` | `False` | whether it shall be defined or not -`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 -`set_callback` | `lambda` | `None` | a function that is triggered after setting the value -`unset_callback` | `lambda` | `None` | a function that is triggered after unsetting the value -`transform` | `lambda` | `None` | a function transforming the value input as for any dictionary, but for computing a new value -`validate` | `lambda` | `None` | by default, a lambda function that checks for the given `choices` if defined, but can be tuned accordingly - -Each lambda function takes `self` as the first argument. `transform` and `validate` also takes option's value as the second argument. - -??? example "Config declaration (extract from the `FrameworkConsole` class)" - - :::python - config = Config({ - ..., - ROption( - 'DEBUG', - "debug mode", - False, - bool, - set_callback=lambda o: o.config.console._set_logging(o.value), - ): "false", - ..., - Option( - 'WORKSPACE', - "folder where results are saved", - True, - ): "~/Notes", - }) - -
- -## Utility class methods - - +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 : + +- registering entity classes (see hereafter: `Console`, `Command`, ...) +- handling requirements, dynamically enabling/disabling entities +- handling metadata (formatting them for display in the CLI) + +## Requirements and applicability + +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 : + +**Key** | **Description** +--- | --- +`config` | Dictionary of `Config`/`Option` values to be set (see hereafter). +`file` | List of files that must exist in the current workspace. +`python` | List of Python packages required to be installed in the environment. +`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'}}`.
+`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.
+ +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. + +??? example "**Example**: Setting a *command* as applicable only for *modules* named '`do_something`'" + + 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. + + :::python + class DoIt(Command): + applies_to = [("console", "module", "name", "do_something")] + [...] + def run(self): + [...] + +
+ +## Inheritance and aggregation + +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 : + +- `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. +- 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*. + +## Metadata parsing + +Metadata of entities can be defined in three different ways (can be combined, listed hereafter in inverse order of precedence) : + +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`. +2. `meta` attribute : A dictionary of metadata key-values that will update the final metadata dictionary. +3. `metadata` attribute : Same as for `meta` (exists for a question of cross-compatibility with plugins of other frameworks). + +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). + +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. + +The default docstring format (parsed through a dedicated function within Sploitkit's utils) consists of sections separated by double newlines. Parsing occurs as follows : + +1. The first section is always the *description*. +2. Next sections are handled this way : + + - 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. + + [Field]: [value] + + That is, the field name capitalized with no whitespace before the colon and whatever value, multiline. + + - 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. + +??? example "**Example**: Writing a docstring for an entity" + + :::python + class Example(object): + \""" + This is a test multi-line long + description. + + This is a first comment. + + Author: John Doe + (john.doe@example.com) + Version: 1.0 + Comments: + - subcomment 1 + - subcomment 2 + + Something: lorem ipsum + paragraph + + This is a second comment, + a multi-line one. + \""" + [...] + + >>> parse_docstring(Example) + {'author': 'John Doe (john.doe@example.com)', + 'comments': ['This is a first comment.', + ('subcomment 1', 'subcomment 2'), + 'This is a second comment, a multi-line one.'], + 'description': 'This is a test multi-line long description.', + 'something': 'lorem ipsum paragraph', + 'version': '1.0'} + +
+ +## `Config` and `Option` + +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. + +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. + +??? example "**Example**: Declaring a configuration (entity class attribute)" + + :::python + from sploitkit import Config, Option, ROption + + config = Config({ + Option(...), + ROption(...), + }) + +
+ +!!! note "`Option` and `ROption`" + + 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. + + 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*). + +A configuration option object, that is, an instance of the `Option` or `ROption` class, is defined using multiple arguments : + +**Argument** | **Type** | **Default** | **Description** +--- | :---: | :---: | --- +`name` | `str` | | option's name, conventionally uppercase +`description` | `str` | `None` | help text for this option +`required` | `bool` | `False` | whether it shall be defined or not +`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 +`set_callback` | `lambda` | `None` | a function that is triggered after setting the value +`unset_callback` | `lambda` | `None` | a function that is triggered after unsetting the value +`transform` | `lambda` | `None` | a function transforming the value input as for any dictionary, but for computing a new value +`validate` | `lambda` | `None` | by default, a lambda function that checks for the given `choices` if defined, but can be tuned accordingly + +Each lambda function takes `self` as the first argument. `transform` and `validate` also takes option's value as the second argument. + +??? example "Config declaration (extract from the `FrameworkConsole` class)" + + :::python + config = Config({ + ..., + ROption( + 'DEBUG', + "debug mode", + False, + bool, + set_callback=lambda o: o.config.console._set_logging(o.value), + ): "false", + ..., + Option( + 'WORKSPACE', + "folder where results are saved", + True, + ): "~/Notes", + }) + +
+ +## Utility class methods + + diff --git a/docs/classes/module.md b/docs/pages/classes/module.md similarity index 86% rename from docs/classes/module.md rename to docs/pages/classes/module.md index 7c038d5..c53cfb3 100644 --- a/docs/classes/module.md +++ b/docs/pages/classes/module.md @@ -1,3 +1,3 @@ -## `Module` class - - +## `Module` class + + diff --git a/docs/design.md b/docs/pages/design.md similarity index 98% rename from docs/design.md rename to docs/pages/design.md index 1f9809d..137f419 100644 --- a/docs/design.md +++ b/docs/pages/design.md @@ -1,45 +1,45 @@ -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*. - -Thanks to compartmentalization in *projects*, *files*, *jobs* and *sessions*, it becomes easier to organize your work or generate reports. - -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. - -## Main architecture - -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. - -Basically, [five different "main" entity classes](classes.html) are defined : - -- [`Console`](classes/console.html) : for defining CLI console levels -- [`Command`](classes/command.html) : for defining console commands, accessible from console levels -- [`Module`](classes/module.html) : for declaring modules with specific functionalities like in Metasploit -- [`Model`](classes/datastore.html) : for describing data schemas to be recorded in the datastore -- [`StoreExtension`](classes/datastore.html) : for defining mixins to be used with the datastore - -At startup, Sploitkit loads every entity it finds in the user-defined -sources, as well as a pre-defined set of generic commands (like in -Metasploit or Recon-ng). This behaviour can be disabled if so desired. -Instantiation begins with a `Console` and then proceeds with the loading -of all the other entities. For convenience, a `FrameworkConsole` -containing some some base functionalities is provided. It serves as a -good starting point for newcomers to Sploitkit. - -!!! note "Back-referencing" - - Back-referencing is heavily used throughout Sploitkit: - - * `module.console` refers to the parent console of module - * calling `self.config.console` within an `option` allows to "walk up" the chain up to the console, and to create triggers for it - -## Project structure - -The package is structured as follows : - -- `base` : This contains [base entities](/base.html) to be included by default in any -application. Note that if some base commands are not required, they can be disabled (see section *Classes*/`Command`). -- `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. -- `utils` : This contains utility modules that are not specifically part of the `base` and `core` subpackages. - -![Classes](img/classes.png) - -![Packages](img/packages.png) +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*. + +Thanks to compartmentalization in *projects*, *files*, *jobs* and *sessions*, it becomes easier to organize your work or generate reports. + +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. + +## Main architecture + +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. + +Basically, [five different "main" entity classes](classes.html) are defined : + +- [`Console`](classes/console.html) : for defining CLI console levels +- [`Command`](classes/command.html) : for defining console commands, accessible from console levels +- [`Module`](classes/module.html) : for declaring modules with specific functionalities like in Metasploit +- [`Model`](classes/datastore.html) : for describing data schemas to be recorded in the datastore +- [`StoreExtension`](classes/datastore.html) : for defining mixins to be used with the datastore + +At startup, Sploitkit loads every entity it finds in the user-defined +sources, as well as a pre-defined set of generic commands (like in +Metasploit or Recon-ng). This behaviour can be disabled if so desired. +Instantiation begins with a `Console` and then proceeds with the loading +of all the other entities. For convenience, a `FrameworkConsole` +containing some some base functionalities is provided. It serves as a +good starting point for newcomers to Sploitkit. + +!!! note "Back-referencing" + + Back-referencing is heavily used throughout Sploitkit: + + * `module.console` refers to the parent console of module + * calling `self.config.console` within an `option` allows to "walk up" the chain up to the console, and to create triggers for it + +## Project structure + +The package is structured as follows : + +- `base` : This contains [base entities](/base.html) to be included by default in any +application. Note that if some base commands are not required, they can be disabled (see section *Classes*/`Command`). +- `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. +- `utils` : This contains utility modules that are not specifically part of the `base` and `core` subpackages. + +![Classes](img/classes.png) + +![Packages](img/packages.png) diff --git a/docs/examples/dronesploit.md b/docs/pages/examples/dronesploit.md similarity index 94% rename from docs/examples/dronesploit.md rename to docs/pages/examples/dronesploit.md index a5abd85..c82497b 100644 --- a/docs/examples/dronesploit.md +++ b/docs/pages/examples/dronesploit.md @@ -1,29 +1,29 @@ -[DroneSploit](https://github.com/dhondta/dronesploit) is a console tailored to drone hacking. - -### Setup - -```sh -$ pip3 install dronesploit -[...] -``` - -### Usage - -```sh -$ dronesploit --help -usage: dronesploit [--dev] [-h] [-v] - -Dronesploit - -optional arguments: - --dev development mode (default: False) - -extra arguments: - -h, --help show this help message and exit - -v, --verbose verbose mode (default: False) - -$ dronesploit -[...] -``` - -![DroneSploit](https://dhondta.github.io/dronesploit/docs/img/dronesploit.png) +[DroneSploit](https://github.com/dhondta/dronesploit) is a console tailored to drone hacking. + +### Setup + +```sh +$ pip3 install dronesploit +[...] +``` + +### Usage + +```sh +$ dronesploit --help +usage: dronesploit [--dev] [-h] [-v] + +Dronesploit + +optional arguments: + --dev development mode (default: False) + +extra arguments: + -h, --help show this help message and exit + -v, --verbose verbose mode (default: False) + +$ dronesploit +[...] +``` + +![DroneSploit](https://dhondta.github.io/dronesploit/docs/img/dronesploit.png) diff --git a/docs/img/class-hierarchy.png b/docs/pages/img/class-hierarchy.png similarity index 100% rename from docs/img/class-hierarchy.png rename to docs/pages/img/class-hierarchy.png diff --git a/docs/img/classes.png b/docs/pages/img/classes.png similarity index 100% rename from docs/img/classes.png rename to docs/pages/img/classes.png diff --git a/docs/img/command-key-completion.png b/docs/pages/img/command-key-completion.png similarity index 100% rename from docs/img/command-key-completion.png rename to docs/pages/img/command-key-completion.png diff --git a/docs/img/command-validation.png b/docs/pages/img/command-validation.png similarity index 100% rename from docs/img/command-validation.png rename to docs/pages/img/command-validation.png diff --git a/docs/img/command-value-completion.png b/docs/pages/img/command-value-completion.png similarity index 100% rename from docs/img/command-value-completion.png rename to docs/pages/img/command-value-completion.png diff --git a/docs/img/console-prompt.png b/docs/pages/img/console-prompt.png similarity index 100% rename from docs/img/console-prompt.png rename to docs/pages/img/console-prompt.png diff --git a/docs/img/dronesploit.png b/docs/pages/img/dronesploit.png similarity index 100% rename from docs/img/dronesploit.png rename to docs/pages/img/dronesploit.png diff --git a/docs/pages/img/icon.png b/docs/pages/img/icon.png new file mode 100644 index 0000000..b72885d Binary files /dev/null and b/docs/pages/img/icon.png differ diff --git a/docs/logo.png b/docs/pages/img/logo.png similarity index 100% rename from docs/logo.png rename to docs/pages/img/logo.png diff --git a/docs/img/my-sploit-start.png b/docs/pages/img/my-sploit-start.png similarity index 100% rename from docs/img/my-sploit-start.png rename to docs/pages/img/my-sploit-start.png diff --git a/docs/img/packages.png b/docs/pages/img/packages.png similarity index 100% rename from docs/img/packages.png rename to docs/pages/img/packages.png diff --git a/docs/img/under-construction.png b/docs/pages/img/under-construction.png similarity index 100% rename from docs/img/under-construction.png rename to docs/pages/img/under-construction.png diff --git a/docs/index.md b/docs/pages/index.md similarity index 97% rename from docs/index.md rename to docs/pages/index.md index 5ba354e..f8af34b 100644 --- a/docs/index.md +++ b/docs/pages/index.md @@ -1,58 +1,58 @@ -## Introduction - -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. - -The idea is to make creating consoles as easy as this: - -```sh -$ sploitkit my-sploit -$ cd my-sploit -$ gedit main.py -``` - -```python -#!/usr/bin/python3 -from sploitkit import FrameworkConsole -from tinyscript import * - - -class MySploitConsole(FrameworkConsole): - #TODO: set your console attributes - pass - - -if __name__ == '__main__': - parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") - parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") - initialize() - c = MySploitConsole( - "MySploit", - #TODO: configure your console settings - dev=args.dev, - ) - c.rcfile(args.rcfile) if args.rcfile else c.start() -``` - -This will give the following (no banner, ASCII image or quote yet): - -![](img/my-sploit-start.png) - ------ - -## Setup - -This library is available on [PyPi](https://pypi.python.org/pypi/sploitkit/) and can be simply installed using Pip: - -```sh -pip install sploitkit -``` - ------ - -## Rationale - -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). - -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)). - -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. +## Introduction + +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. + +The idea is to make creating consoles as easy as this: + +```sh +$ sploitkit my-sploit +$ cd my-sploit +$ gedit main.py +``` + +```python +#!/usr/bin/python3 +from sploitkit import FrameworkConsole +from tinyscript import * + + +class MySploitConsole(FrameworkConsole): + #TODO: set your console attributes + pass + + +if __name__ == '__main__': + parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") + parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") + initialize() + c = MySploitConsole( + "MySploit", + #TODO: configure your console settings + dev=args.dev, + ) + c.rcfile(args.rcfile) if args.rcfile else c.start() +``` + +This will give the following (no banner, ASCII image or quote yet): + +![](img/my-sploit-start.png) + +----- + +## Setup + +This library is available on [PyPi](https://pypi.python.org/pypi/sploitkit/) and can be simply installed using Pip: + +```sh +pip install sploitkit +``` + +----- + +## Rationale + +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). + +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)). + +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. diff --git a/docs/js/collapsible-navbar.js b/docs/pages/js/collapsible-navbar.js similarity index 97% rename from docs/js/collapsible-navbar.js rename to docs/pages/js/collapsible-navbar.js index b1e1593..335d4bb 100644 --- a/docs/js/collapsible-navbar.js +++ b/docs/pages/js/collapsible-navbar.js @@ -1,54 +1,54 @@ -String.prototype.format = function() { - a = this; - for (k in arguments) { - a = a.replace("{" + k + "}", arguments[k]) - } - return a -} - -$(document).ready(function () { - $('li.toctree-l1').each(function () { - var parent = $(this); - var span = parent.find('span:first'); - var sibling = null; - var remove = true; - $('li.toctree-l1').each(function() { - var a = $(this).find('a:first'); - if (a.text() != '' && a.text() == span.text()) { - parent.prepend(a); - span.remove(); - span = a; - if ($(this).hasClass('current')) parent.addClass('current'); - sibling = $(this); - return false - } - }); - if (sibling === null && parent.find('ul.subnav:not(li.toctree-l2)').children('li').length) { - sibling = parent; - remove = false; - } - if (sibling !== null) { - var ul = parent.find('ul.subnav:not(li.toctree-l2)'); - var new_a = ''; - if (!ul.children('li.current').length && !parent.hasClass('current')) { - ul.hide(); - $(new_a.format("left")).insertBefore(span); - } else { - $(new_a.format("down")).insertBefore(span); - } - if (remove) sibling.remove(); - } - }); - $('a.collapse-navbar').click(function () { - var parent = $(this).closest('li.toctree-l1'); - var subnav = parent.find('ul.subnav:not(li.toctree-l2)'); - if ($(this).hasClass('fa-caret-left')) { - subnav.show(); - $(this).removeClass('fa-caret-left'); - $(this).addClass('fa-caret-down'); - } else { - subnav.hide(); - $(this).addClass('fa-caret-left'); - $(this).removeClass('fa-caret-down'); - } -});}); +String.prototype.format = function() { + a = this; + for (k in arguments) { + a = a.replace("{" + k + "}", arguments[k]) + } + return a +} + +$(document).ready(function () { + $('li.toctree-l1').each(function () { + var parent = $(this); + var span = parent.find('span:first'); + var sibling = null; + var remove = true; + $('li.toctree-l1').each(function() { + var a = $(this).find('a:first'); + if (a.text() != '' && a.text() == span.text()) { + parent.prepend(a); + span.remove(); + span = a; + if ($(this).hasClass('current')) parent.addClass('current'); + sibling = $(this); + return false + } + }); + if (sibling === null && parent.find('ul.subnav:not(li.toctree-l2)').children('li').length) { + sibling = parent; + remove = false; + } + if (sibling !== null) { + var ul = parent.find('ul.subnav:not(li.toctree-l2)'); + var new_a = ''; + if (!ul.children('li.current').length && !parent.hasClass('current')) { + ul.hide(); + $(new_a.format("left")).insertBefore(span); + } else { + $(new_a.format("down")).insertBefore(span); + } + if (remove) sibling.remove(); + } + }); + $('a.collapse-navbar').click(function () { + var parent = $(this).closest('li.toctree-l1'); + var subnav = parent.find('ul.subnav:not(li.toctree-l2)'); + if ($(this).hasClass('fa-caret-left')) { + subnav.show(); + $(this).removeClass('fa-caret-left'); + $(this).addClass('fa-caret-down'); + } else { + subnav.hide(); + $(this).addClass('fa-caret-left'); + $(this).removeClass('fa-caret-down'); + } +});}); diff --git a/docs/quickstart.md b/docs/pages/quickstart.md similarity index 96% rename from docs/quickstart.md rename to docs/pages/quickstart.md index fcff6e5..ee6075c 100644 --- a/docs/quickstart.md +++ b/docs/pages/quickstart.md @@ -1,71 +1,71 @@ -## Creating a project - -Creating a project can be achieved by using the `sploitkit-new` tool like follows : - -```sh -$ sploitkit-new --help -usage: sploitkit-new [-s] [-h] [-v] name - -SploitkitNew - -positional arguments: - name project name - -optional arguments: - -s, --show-todo show the TODO list (default: False) - -extra arguments: - -h, --help show this help message and exit - -v, --verbose verbose mode (default: False) - -``` - -```sh -$ sploitkit-new -s my-sploit -12:34:56 [INFO] TODO list: -- [README:3] Fill in the README -- [main.py:MySploitConsole:6] set your console attributes -- [main.py:MySploitConsole:13] configure your console settings -- [commands/template.py:CommandWithOneArg:9] compute the list of possible values -- [commands/template.py:CommandWithOneArg:13] compute results here -- [commands/template.py:CommandWithOneArg:17] validate the input value -- [commands/template.py:CommandWithTwoArgs:27] compute the list of possible keys -- [commands/template.py:CommandWithTwoArgs:31] compute the list of possible values taking the key into account -- [commands/template.py:CommandWithTwoArgs:35] compute results here - -``` - -This creates a folder `my-sploit` with the following items : - -```sh -$ cd my-sploit/ -$ ll -total 28K -drwxrwxr-x 5 user user 4.0K 2019-12-25 12:34 . -drwxr-xr-x 102 user user 4.0K 2019-12-25 12:34 .. -drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 banners -drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 commands --rw-rw-r-- 1 user user 279 2019-12-25 12:34 main.py -drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 modules --rw-rw-r-- 1 user user 31 2019-12-25 12:34 README --rw-rw-r-- 1 user user 0 2019-12-25 12:34 requirements.txt - -``` - ------ - -## Setting the root console - ------ - -## Adding commands - - ------ - -## Adding modules - - ------ - -## Tuning the datastore +## Creating a project + +Creating a project can be achieved by using the `sploitkit-new` tool like follows : + +```sh +$ sploitkit-new --help +usage: sploitkit-new [-s] [-h] [-v] name + +SploitkitNew + +positional arguments: + name project name + +optional arguments: + -s, --show-todo show the TODO list (default: False) + +extra arguments: + -h, --help show this help message and exit + -v, --verbose verbose mode (default: False) + +``` + +```sh +$ sploitkit-new -s my-sploit +12:34:56 [INFO] TODO list: +- [README:3] Fill in the README +- [main.py:MySploitConsole:6] set your console attributes +- [main.py:MySploitConsole:13] configure your console settings +- [commands/template.py:CommandWithOneArg:9] compute the list of possible values +- [commands/template.py:CommandWithOneArg:13] compute results here +- [commands/template.py:CommandWithOneArg:17] validate the input value +- [commands/template.py:CommandWithTwoArgs:27] compute the list of possible keys +- [commands/template.py:CommandWithTwoArgs:31] compute the list of possible values taking the key into account +- [commands/template.py:CommandWithTwoArgs:35] compute results here + +``` + +This creates a folder `my-sploit` with the following items : + +```sh +$ cd my-sploit/ +$ ll +total 28K +drwxrwxr-x 5 user user 4.0K 2019-12-25 12:34 . +drwxr-xr-x 102 user user 4.0K 2019-12-25 12:34 .. +drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 banners +drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 commands +-rw-rw-r-- 1 user user 279 2019-12-25 12:34 main.py +drwxrwxr-x 2 user user 4.0K 2019-12-25 12:34 modules +-rw-rw-r-- 1 user user 31 2019-12-25 12:34 README +-rw-rw-r-- 1 user user 0 2019-12-25 12:34 requirements.txt + +``` + +----- + +## Setting the root console + +----- + +## Adding commands + + +----- + +## Adding modules + + +----- + +## Tuning the datastore diff --git a/docs/requirements.txt b/docs/requirements.txt index ae84573..0ca7aee 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,6 @@ -pymdown-extensions +jinja2<3.1.0 +mkdocs>=1.3.0 +mkdocs-bootswatch +mkdocs-material +mkdocs-rtd-dropdown +pymdown-extensions diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 18e77b3..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,35 +0,0 @@ -site_name: "SploitKit - Devkit for building Metasploit-like consoles" -repo_url: https://github.com/dhondta/python-sploitkit -site_author: dhondta -docs_dir: docs -pages: - - Introduction: index.md - - 'Getting started': quickstart.md - - Design: design.md - - Classes: classes.md - - Classes: - - Entity: classes/entity.md - - Console: classes/console.md - - Command: classes/command.md - - Module: classes/module.md - - Datastore: classes/datastore.md - - 'Base entities': base.md - - 'Base entities': - - Consoles: base/consoles.md - - Commands: base/commands.md - - Models: base/models.md - - 'Real-life examples': - - 'DroneSploit': examples/dronesploit.md -extra: - mailto: alexandre.dhondt@gmail.com -theme: readthedocs -extra_javascript: - - js/collapsible-navbar.js -use_directory_urls: false -markdown_extensions: - - admonition - - codehilite: - linenums: true - - pymdownx.details - - toc: - permalink: True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4092c6e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +pythonpath = ["src"] + +[tool.setuptools.dynamic] +version = {attr = "sploitkit.__info__.__version__"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"*" = ["*.txt"] + +[project] +name = "sploitkit" +authors = [ + {name="Alexandre D'Hondt", email="alexandre.dhondt@gmail.com"}, +] +description = "Devkit for easilly building Metasploit-like framework consoles" +license = {file = "LICENSE"} +keywords = ["python", "development", "programming", "cli", "framework", "console", "devkit"] +requires-python = ">=3.8,<4" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "peewee", + "prompt_toolkit>=3.0.32", + "requests", + "tinyscript>=1.27.1", +] +dynamic = ["version"] + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[project.urls] +documentation = "https://python-sploitkit.readthedocs.io/en/latest/?badge=latest" +homepage = "https://github.com/dhondta/python-sploitkit" +issues = "https://github.com/dhondta/python-sploitkit/issues" +repository = "https://github.com/dhondta/python-sploitkit" + +[project.scripts] +sploitkit = "sploitkit.__main__:main" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..ab4c198 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +python_paths = src diff --git a/requirements.txt b/requirements.txt index 50e8aaa..6d9c9af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -peewee -prompt_toolkit -requests -tinyscript>=1.24.14 +peewee +prompt_toolkit>=3.0.32 +requests +tinyscript>=1.26.13 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bfb1ddb..0000000 --- a/setup.cfg +++ /dev/null @@ -1,50 +0,0 @@ -[metadata] -name = sploitkit -version = file: sploitkit/VERSION.txt -author = Alexandre D'Hondt -author-email = alexandre.dhondt@gmail.com -home-page = https://github.com/dhondta/python-sploitkit -description = Toolkit for easilly building Metasploit-like framework consoles -long_description = file: README.md -long_description_content_type = text/markdown -keywords = - python - development - programming - cli - framework - console - devkit -license = AGPLv3 -license-file = LICENSE -classifier = - Development Status :: 4 - Beta - Environment :: Console - Intended Audience :: Developers - Intended Audience :: Information Technology - License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+) - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Software Development :: Libraries :: Python Modules - -[options] -packages = find: -include_package_data = False -install_requires = - peewee - prompt_toolkit>=3.0.32 - requests - tinyscript>=1.26.13 -setup-requires = setuptools -python-requires = >=3.6,<4 - -[options.package_data] -* = *.txt - -[options.entry_points] -console_scripts = - sploitkit = sploitkit.__main__:main diff --git a/setup.py b/setup.py deleted file mode 100644 index c823345..0000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup - -setup() diff --git a/sploitkit/VERSION.txt b/sploitkit/VERSION.txt deleted file mode 100644 index d3532a1..0000000 --- a/sploitkit/VERSION.txt +++ /dev/null @@ -1 +0,0 @@ -0.5.7 diff --git a/src/sploitkit/VERSION.txt b/src/sploitkit/VERSION.txt new file mode 100644 index 0000000..475edd2 --- /dev/null +++ b/src/sploitkit/VERSION.txt @@ -0,0 +1 @@ +0.5.8 diff --git a/sploitkit/__info__.py b/src/sploitkit/__info__.py similarity index 95% rename from sploitkit/__info__.py rename to src/sploitkit/__info__.py index 87c9b6b..754c5d3 100644 --- a/sploitkit/__info__.py +++ b/src/sploitkit/__info__.py @@ -1,14 +1,14 @@ -# -*- coding: UTF-8 -*- -"""Sploitkit package information. - -""" -import os - -__author__ = "Alexandre D'Hondt" -__email__ = "alexandre.dhondt@gmail.com" -__copyright__ = "© 2019-2020 A. D'Hondt" -__license__ = "agpl-3.0" - -with open(os.path.join(os.path.dirname(__file__), "VERSION.txt")) as f: - __version__ = f.read().strip() - +# -*- coding: UTF-8 -*- +"""Sploitkit package information. + +""" +import os + +__author__ = "Alexandre D'Hondt" +__email__ = "alexandre.dhondt@gmail.com" +__copyright__ = "© 2019-2020 A. D'Hondt" +__license__ = "agpl-3.0" + +with open(os.path.join(os.path.dirname(__file__), "VERSION.txt")) as f: + __version__ = f.read().strip() + diff --git a/sploitkit/__init__.py b/src/sploitkit/__init__.py similarity index 93% rename from sploitkit/__init__.py rename to src/sploitkit/__init__.py index 1141503..daace08 100644 --- a/sploitkit/__init__.py +++ b/src/sploitkit/__init__.py @@ -1,50 +1,46 @@ -import os -import re -from ipaddress import ip_address -from peewee import * -from peewee import __all__ as _peewee -from shutil import which -from subprocess import call -from tempfile import TemporaryFile -from tinyscript.helpers.path import Path - -from .core import * -from .core import __all__ as _core -from .core.console import print_formatted_text - - -__all__ = _core + _peewee -__all__ += ["print_formatted_text", "IPAddressField", "MACAddressField", "Path"] - - -# -------------------------------------- Peewee extra fields -------------------------------------- -class IPAddressField(BigIntegerField): - """ IPv4/IPv6 address database field. """ - def db_value(self, value): - if isinstance(value, (str, int)): - try: - return int(ip_address(value)) - except Exception: - pass - raise ValueError("Invalid IPv4 or IPv6 Address") - - def python_value(self, value): - return ip_address(value) - - -class MACAddressField(BigIntegerField): - """ MAC address database field. """ - def db_value(self, value): - if isinstance(value, int) and 0 <= value <= 0xffffffffffffffff: - return value - elif isinstance(value, str): - if re.search(r"^([0-9a-f]{2}[:-]){5}[0-9A-F]{2}$", value, re.I): - return int("".join(re.split(r"[:-]", value)), 16) - raise ValueError("Invalid MAC Address") - - def python_value(self, value): - try: - return ":".join(re.findall("..", "%012x" % value)) - except Exception: - raise ValueError("Invalid MAC Address") - +import re +from ipaddress import ip_address +from peewee import * +from peewee import __all__ as _peewee +from tinyscript.helpers.path import Path + +from .core import * +from .core import __all__ as _core +from .core.console import print_formatted_text + + +__all__ = _core + _peewee +__all__ += ["print_formatted_text", "IPAddressField", "MACAddressField", "Path"] + + +# -------------------------------------- Peewee extra fields -------------------------------------- +class IPAddressField(BigIntegerField): + """ IPv4/IPv6 address database field. """ + def db_value(self, value): + if isinstance(value, (str, int)): + try: + return int(ip_address(value)) + except Exception: + pass + raise ValueError("Invalid IPv4 or IPv6 Address") + + def python_value(self, value): + return ip_address(value) + + +class MACAddressField(BigIntegerField): + """ MAC address database field. """ + def db_value(self, value): + if isinstance(value, int) and 0 <= value <= 0xffffffffffffffff: + return value + elif isinstance(value, str): + if re.search(r"^([0-9a-f]{2}[:-]){5}[0-9A-F]{2}$", value, re.I): + return int("".join(re.split(r"[:-]", value)), 16) + raise ValueError("Invalid MAC Address") + + def python_value(self, value): + try: + return ":".join(re.findall("..", "%012x" % value)) + except Exception: + raise ValueError("Invalid MAC Address") + diff --git a/sploitkit/__main__.py b/src/sploitkit/__main__.py similarity index 96% rename from sploitkit/__main__.py rename to src/sploitkit/__main__.py index c9ab36a..6382a32 100755 --- a/sploitkit/__main__.py +++ b/src/sploitkit/__main__.py @@ -1,117 +1,117 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -from sploitkit.__info__ import __author__, __copyright__, __email__, __license__, __version__ -from tinyscript import * - - -__name__ = "__main__" -__script__ = "sploitkit" -__examples__ = ["my-sploit", "my-sploit -s"] -__doc__ = """ -This tool allows to quickly create a new Sploitkit project. -""" - - -MAIN = """#!/usr/bin/python3 -from sploitkit import FrameworkConsole -from tinyscript import * - - -class MySploitConsole(FrameworkConsole): - #TODO: set your console attributes - pass - - -if __name__ == '__main__': - parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") - parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") - initialize(exit_at_interrupt=False) - c = MySploitConsole( - "MySploit", - #TODO: configure your console settings - dev=args.dev, - debug=args.verbose, - ) - c.rcfile(args.rcfile) if args.rcfile else c.start() -""" -COMMANDS = """from sploitkit import * - - -class CommandWithOneArg(Command): - \""" Description here \""" - level = "module" - single_arg = True - - def complete_values(self): - #TODO: compute the list of possible values - return [] - - def run(self): - #TODO: compute results here - pass - - def validate(self, value): - #TODO: validate the input value - if value not in self.complete_values(): - raise ValueError("invalid value") - - -class CommandWithTwoArgs(Command): - \""" Description here \""" - level = "module" - - def complete_keys(self): - #TODO: compute the list of possible keys - return [] - - def complete_values(self, key=None): - #TODO: compute the list of possible values taking the key into account - return [] - - def run(self): - #TODO: compute results here - pass -""" -MODULES = """from sploitkit import * - - -class MyFirstModule(Module): - \""" Description here - - Author: your name (your email) - Version: 1.0 - \""" - def run(self): - pass - - -class MySecondModule(Module): - \""" Description here - - Author: your name (your email) - Version: 1.0 - \""" - def run(self): - pass -""" - - -PROJECT_STRUCTURE = { - 'README': "# {}\n\n#TODO: Fill in the README", - 'main.py': MAIN, - 'requirements.txt': None, - 'banners': {}, - 'commands': {'commands.py': COMMANDS}, - 'modules': {'modules.py': MODULES}, -} - - -def main(): - parser.add_argument("name", help="project name") - parser.add_argument("-s", "--show-todo", dest="todo", action="store_true", help="show the TODO list") - initialize(noargs_action="wizard") - p = ts.ProjectPath(args.name, PROJECT_STRUCTURE) - if args.todo: - for k, v in p.todo.items(): - print("- [%s] %s" % (k, v)) - +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +from sploitkit.__info__ import __author__, __copyright__, __email__, __license__, __version__ +from tinyscript import * + + +__name__ = "__main__" +__script__ = "sploitkit" +__examples__ = ["my-sploit", "my-sploit -s"] +__doc__ = """ +This tool allows to quickly create a new Sploitkit project. +""" + + +MAIN = """#!/usr/bin/python3 +from sploitkit import FrameworkConsole +from tinyscript import * + + +class MySploitConsole(FrameworkConsole): + #TODO: set your console attributes + pass + + +if __name__ == '__main__': + parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") + parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") + initialize(exit_at_interrupt=False) + c = MySploitConsole( + "MySploit", + #TODO: configure your console settings + dev=args.dev, + debug=args.verbose, + ) + c.rcfile(args.rcfile) if args.rcfile else c.start() +""" +COMMANDS = """from sploitkit import * + + +class CommandWithOneArg(Command): + \""" Description here \""" + level = "module" + single_arg = True + + def complete_values(self): + #TODO: compute the list of possible values + return [] + + def run(self): + #TODO: compute results here + pass + + def validate(self, value): + #TODO: validate the input value + if value not in self.complete_values(): + raise ValueError("invalid value") + + +class CommandWithTwoArgs(Command): + \""" Description here \""" + level = "module" + + def complete_keys(self): + #TODO: compute the list of possible keys + return [] + + def complete_values(self, key=None): + #TODO: compute the list of possible values taking the key into account + return [] + + def run(self): + #TODO: compute results here + pass +""" +MODULES = """from sploitkit import * + + +class MyFirstModule(Module): + \""" Description here + + Author: your name (your email) + Version: 1.0 + \""" + def run(self): + pass + + +class MySecondModule(Module): + \""" Description here + + Author: your name (your email) + Version: 1.0 + \""" + def run(self): + pass +""" + + +PROJECT_STRUCTURE = { + 'README': "# {}\n\n#TODO: Fill in the README", + 'main.py': MAIN, + 'requirements.txt': None, + 'banners': {}, + 'commands': {'commands.py': COMMANDS}, + 'modules': {'modules.py': MODULES}, +} + + +def main(): + parser.add_argument("name", help="project name") + parser.add_argument("-s", "--show-todo", dest="todo", action="store_true", help="show the TODO list") + initialize(noargs_action="wizard") + p = ts.ProjectPath(args.name, PROJECT_STRUCTURE) + if args.todo: + for k, v in p.todo.items(): + print("- [%s] %s" % (k, v)) + diff --git a/sploitkit/base/__init__.py b/src/sploitkit/base/__init__.py similarity index 100% rename from sploitkit/base/__init__.py rename to src/sploitkit/base/__init__.py diff --git a/sploitkit/base/commands/__init__.py b/src/sploitkit/base/commands/__init__.py similarity index 100% rename from sploitkit/base/commands/__init__.py rename to src/sploitkit/base/commands/__init__.py diff --git a/sploitkit/base/commands/general.py b/src/sploitkit/base/commands/general.py similarity index 97% rename from sploitkit/base/commands/general.py rename to src/sploitkit/base/commands/general.py index 2f53a1d..61866c0 100644 --- a/sploitkit/base/commands/general.py +++ b/src/sploitkit/base/commands/general.py @@ -1,245 +1,245 @@ -# -*- coding: UTF-8 -*- -import re -import shlex -from prompt_toolkit.formatted_text import ANSI -from tinyscript.helpers import human_readable_size, BorderlessTable, Path - -from sploitkit import * -from sploitkit.core.module import MetaModule - - -projects = lambda cmd: [x.filename for x in cmd.workspace.iterpubdir()] - - -# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ -class Back(Command): - """ Come back to the previous console level """ - except_levels = ["root", "session"] - - def run(self): - raise ConsoleExit - - -class Exit(Command): - """ Exit the console """ - aliases = ["quit"] - except_levels = ["session"] - - def run(self): - raise ConsoleExit - - -class Help(Command): - """ Display help """ - aliases = ["?"] - - def run(self): - print_formatted_text(Command.get_help("general", self.console.level)) - - -class Search(Command): - """ Search for text in modules """ - except_levels = ["session"] - single_arg = True - - def run(self, text): - keywords = shlex.split(text) - data = [["Name", "Path", "Description"]] - for m in Module.subclasses: - for k in keywords: - if m.search(k): - data.append([m.name, m.path, m.description]) - if len(data) == 1: - self.logger.error("No match found") - else: - t = BorderlessTable(data, "Matching modules") - print_formatted_text(t.table) - n = len(data) - 2 - self.logger.info("{} match{} found".format(n, ["", "es"][n > 0])) - - -class Show(Command): - """ Show options, projects, modules or issues (if any) """ - level = "root" - keys = ["files", "modules", "options", "projects"] - - def complete_values(self, key): - if key == "files": - if self.config.option("TEXT_VIEWER").value is not None: - return list(map(str, self.console._files.list)) - return [] - elif key == "issues": - l = [] - for cls, subcls, errors in Entity.issues(): - l.extend(list(errors.keys())) - return l - elif key == "modules": - uncat = any(isinstance(m, MetaModule) for m in self.console.modules.values()) - l = [c for c, m in self.console.modules.items() if not isinstance(m, MetaModule)] - return l + ["uncategorized"] if uncat else l - elif key == "options": - return list(self.config.keys()) - elif key == "projects": - return projects(self) - elif key == "sessions": - return [str(i) for i, _ in self.console._sessions] - - def run(self, key, value=None): - if key == "files": - if value is None: - data = [["Path", "Size"]] - p = Path(self.config.option("WORKSPACE").value) - for f in self.console._files.list: - data.append([f, human_readable_size(p.joinpath(f).size)]) - print_formatted_text(BorderlessTable(data, "Files from the workspace")) - elif self.config.option("TEXT_VIEWER").value: - self.console._files.view(value) - elif key == "issues": - t = Entity.get_issues() - if len(t) > 0: - print_formatted_text(t) - elif key == "modules": - h = Module.get_help(value) - if h.strip() != "": - print_formatted_text(h) - else: - self.logger.warning("No module loaded") - elif key == "options": - if value is None: - print_formatted_text(ANSI(str(self.config))) - else: - c = Config() - c[self.config.option(value)] = self.config[value] - print_formatted_text(ANSI(str(c))) - elif key == "projects": - if value is None: - data = [["Name"]] - for p in projects(self): - data.append([p]) - print_formatted_text(BorderlessTable(data, "Existing projects")) - else: - print_formatted_text(value) - elif key == "sessions": - data = [["ID", "Description"]] - for i, s in self.console._sessions: - data.append([str(i), getattr(s, "description", "")]) - print_formatted_text(BorderlessTable(data, "Open sessions")) - - def set_keys(self): - if Entity.has_issues(): - self.keys += ["issues"] - else: - while "issues" in self.keys: - self.keys.remove("issues") - if len(self.console._sessions) > 0: - self.keys += ["sessions"] - else: - while "sessions" in self.keys: - self.keys.remove("sessions") - - def validate(self, key, value=None): - if key not in self.keys: - raise ValueError("invalid key") - if value is not None: - if key == "files": - if self.config.option("TEXT_VIEWER").value is None: - raise ValueError("cannot view file ; TEXT_VIEWER is not set") - if value not in self.complete_values(key): - raise ValueError("invalid file") - elif key == "issues": - if value not in self.complete_values(key): - raise ValueError("invalid error type") - elif key == "modules": - if value is not None and value not in self.complete_values(key): - raise ValueError("invalid module") - elif key == "options": - if value is not None and value not in self.complete_values(key): - raise ValueError("invalid option") - elif key == "projects": - if value is not None and value not in self.complete_values(key): - raise ValueError("invalid project name") - - -# ---------------------------- OPTIONS-RELATED COMMANDS ------------------------ -class Set(Command): - """ Set an option in the current context """ - except_levels = ["session"] - - def complete_keys(self): - return self.config.keys() - - def complete_values(self, key): - if key.upper() == "WORKSPACE": - return [str(x) for x in Path(".").home().iterpubdir()] - return self.config.option(key).choices or [] - - def run(self, key, value): - self.config[key] = value - - def validate(self, key, value): - if key not in self.config.keys(): - raise ValueError("invalid option") - o = self.config.option(key) - if o.required and value is None: - raise ValueError("a value is required") - if not o.validate(value): - raise ValueError("invalid value") - - -class Unset(Command): - """ Unset an option from the current context """ - except_levels = ["session"] - - def complete_values(self): - for k in self.config.keys(): - if not self.config.option(k).required: - yield k - - def run(self, key): - del self.config[key] - - def validate(self, key): - if key not in self.config.keys(): - raise ValueError("invalid option") - if self.config.option(key).required: - raise ValueError("this option is required") - - -class Setg(Command): - """ Set a global option """ - except_levels = ["session"] - - def complete_keys(self): - return self.config.keys(True) - - def complete_values(self, key): - return self.config.option(key).choices or [] - - def run(self, key, value): - self.config.setglobal(key, value) - - def validate(self, key, value): - try: - o = self.config.option(key) - if not o.glob: - raise ValueError("cannot be set as global") - if not o.validate(value): - raise ValueError("invalid value") - except KeyError: - pass - - -class Unsetg(Command): - """ Unset a global option """ - except_levels = ["session"] - - def complete_values(self): - return self.config._g.keys() - - def run(self, key): - self.config.unsetglobal(key) - - def validate(self, key): - if key not in self.config._g.keys(): - raise ValueError("invalid option") - +# -*- coding: UTF-8 -*- +import re +import shlex +from prompt_toolkit.formatted_text import ANSI +from tinyscript.helpers import human_readable_size, BorderlessTable, Path + +from sploitkit import * +from sploitkit.core.module import MetaModule + + +projects = lambda cmd: [x.filename for x in cmd.workspace.iterpubdir()] + + +# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ +class Back(Command): + """ Come back to the previous console level """ + except_levels = ["root", "session"] + + def run(self): + raise ConsoleExit + + +class Exit(Command): + """ Exit the console """ + aliases = ["quit"] + except_levels = ["session"] + + def run(self): + raise ConsoleExit + + +class Help(Command): + """ Display help """ + aliases = ["?"] + + def run(self): + print_formatted_text(Command.get_help("general", self.console.level)) + + +class Search(Command): + """ Search for text in modules """ + except_levels = ["session"] + single_arg = True + + def run(self, text): + keywords = shlex.split(text) + data = [["Name", "Path", "Description"]] + for m in Module.subclasses: + for k in keywords: + if m.search(k): + data.append([m.name, m.path, m.description]) + if len(data) == 1: + self.logger.error("No match found") + else: + t = BorderlessTable(data, "Matching modules") + print_formatted_text(t.table) + n = len(data) - 2 + self.logger.info("{} match{} found".format(n, ["", "es"][n > 0])) + + +class Show(Command): + """ Show options, projects, modules or issues (if any) """ + level = "root" + keys = ["files", "modules", "options", "projects"] + + def complete_values(self, key): + if key == "files": + if self.config.option("TEXT_VIEWER").value is not None: + return list(map(str, self.console._files.list)) + return [] + elif key == "issues": + l = [] + for cls, subcls, errors in Entity.issues(): + l.extend(list(errors.keys())) + return l + elif key == "modules": + uncat = any(isinstance(m, MetaModule) for m in self.console.modules.values()) + l = [c for c, m in self.console.modules.items() if not isinstance(m, MetaModule)] + return l + ["uncategorized"] if uncat else l + elif key == "options": + return list(self.config.keys()) + elif key == "projects": + return projects(self) + elif key == "sessions": + return [str(i) for i, _ in self.console._sessions] + + def run(self, key, value=None): + if key == "files": + if value is None: + data = [["Path", "Size"]] + p = Path(self.config.option("WORKSPACE").value) + for f in self.console._files.list: + data.append([f, human_readable_size(p.joinpath(f).size)]) + print_formatted_text(BorderlessTable(data, "Files from the workspace")) + elif self.config.option("TEXT_VIEWER").value: + self.console._files.view(value) + elif key == "issues": + t = Entity.get_issues() + if len(t) > 0: + print_formatted_text(t) + elif key == "modules": + h = Module.get_help(value) + if h.strip() != "": + print_formatted_text(h) + else: + self.logger.warning("No module loaded") + elif key == "options": + if value is None: + print_formatted_text(ANSI(str(self.config))) + else: + c = Config() + c[self.config.option(value)] = self.config[value] + print_formatted_text(ANSI(str(c))) + elif key == "projects": + if value is None: + data = [["Name"]] + for p in projects(self): + data.append([p]) + print_formatted_text(BorderlessTable(data, "Existing projects")) + else: + print_formatted_text(value) + elif key == "sessions": + data = [["ID", "Description"]] + for i, s in self.console._sessions: + data.append([str(i), getattr(s, "description", "")]) + print_formatted_text(BorderlessTable(data, "Open sessions")) + + def set_keys(self): + if Entity.has_issues(): + self.keys += ["issues"] + else: + while "issues" in self.keys: + self.keys.remove("issues") + if len(self.console._sessions) > 0: + self.keys += ["sessions"] + else: + while "sessions" in self.keys: + self.keys.remove("sessions") + + def validate(self, key, value=None): + if key not in self.keys: + raise ValueError("invalid key") + if value is not None: + if key == "files": + if self.config.option("TEXT_VIEWER").value is None: + raise ValueError("cannot view file ; TEXT_VIEWER is not set") + if value not in self.complete_values(key): + raise ValueError("invalid file") + elif key == "issues": + if value not in self.complete_values(key): + raise ValueError("invalid error type") + elif key == "modules": + if value is not None and value not in self.complete_values(key): + raise ValueError("invalid module") + elif key == "options": + if value is not None and value not in self.complete_values(key): + raise ValueError("invalid option") + elif key == "projects": + if value is not None and value not in self.complete_values(key): + raise ValueError("invalid project name") + + +# ---------------------------- OPTIONS-RELATED COMMANDS ------------------------ +class Set(Command): + """ Set an option in the current context """ + except_levels = ["session"] + + def complete_keys(self): + return self.config.keys() + + def complete_values(self, key): + if key.upper() == "WORKSPACE": + return [str(x) for x in Path(".").home().iterpubdir()] + return self.config.option(key).choices or [] + + def run(self, key, value): + self.config[key] = value + + def validate(self, key, value): + if key not in self.config.keys(): + raise ValueError("invalid option") + o = self.config.option(key) + if o.required and value is None: + raise ValueError("a value is required") + if not o.validate(value): + raise ValueError("invalid value") + + +class Unset(Command): + """ Unset an option from the current context """ + except_levels = ["session"] + + def complete_values(self): + for k in self.config.keys(): + if not self.config.option(k).required: + yield k + + def run(self, key): + del self.config[key] + + def validate(self, key): + if key not in self.config.keys(): + raise ValueError("invalid option") + if self.config.option(key).required: + raise ValueError("this option is required") + + +class Setg(Command): + """ Set a global option """ + except_levels = ["session"] + + def complete_keys(self): + return self.config.keys(True) + + def complete_values(self, key): + return self.config.option(key).choices or [] + + def run(self, key, value): + self.config.setglobal(key, value) + + def validate(self, key, value): + try: + o = self.config.option(key) + if not o.glob: + raise ValueError("cannot be set as global") + if not o.validate(value): + raise ValueError("invalid value") + except KeyError: + pass + + +class Unsetg(Command): + """ Unset a global option """ + except_levels = ["session"] + + def complete_values(self): + return self.config._g.keys() + + def run(self, key): + self.config.unsetglobal(key) + + def validate(self, key): + if key not in self.config._g.keys(): + raise ValueError("invalid option") + diff --git a/sploitkit/base/commands/module.py b/src/sploitkit/base/commands/module.py similarity index 97% rename from sploitkit/base/commands/module.py rename to src/sploitkit/base/commands/module.py index f0311c9..2f0e1ca 100644 --- a/sploitkit/base/commands/module.py +++ b/src/sploitkit/base/commands/module.py @@ -1,99 +1,99 @@ -# -*- coding: UTF-8 -*- -from prompt_toolkit.formatted_text import ANSI - -from sploitkit import * - - -# ----------------------------- SUBCONSOLE DEFINITION -------------------------- -class ModuleConsole(Console): - """ Module subconsole definition. """ - level = "module" - message = [ - ('class:prompt', " "), - ('class:prompt', None), - ('class:prompt', "("), - ('class:module', None), - ('class:prompt', ")"), - ] - style = { - 'prompt': "#eeeeee", - 'module': "#ff0000", - } - - def __init__(self, parent, module): - self.attach(module, True) - self.logname = module.fullpath - self.message[1] = ('class:prompt', self.module.category) - self.message[3] = ('class:module', self.module.base) - self.opt_prefix = "Module" - super(ModuleConsole, self).__init__(parent) - - -# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ -class Use(Command): - """ Select a module """ - except_levels = ["session"] - - def complete_values(self): - return Module.get_list() - - def run(self, module): - new_mod, old_mod = Module.get_modules(module), self.module - # avoid starting a new subconsole for the same module - if old_mod is not None and old_mod.fullpath == new_mod.fullpath: - return - ModuleConsole(self.console, new_mod).start() - - -# ----------------------------- MODULE-LEVEL COMMANDS -------------------------- -class ModuleCommand(Command): - """ Proxy class (for setting the level attribute). """ - level = "module" - - -class Run(ModuleCommand): - """ Run module """ - def run(self): - if self.module.check(): - self.module._instance.run() - - -class Show(ModuleCommand): - """ Show module-relevant information or options """ - keys = ["info", "options"] - - def complete_values(self, key): - if key == "options": - return list(self.config.keys()) - elif key == "issues": - l = [] - for attr in ["console", "module"]: - for _, __, errors in getattr(self, attr).issues(self.cname): - l.extend(list(errors.keys())) - return l - - def run(self, key, value=None): - if key == "options": - if value is None: - print_formatted_text(ANSI(str(self.config))) - else: - c = Config() - c[self.config.option(value), True] = self.config[value] - print_formatted_text(ANSI(str(c))) - elif key == "info": - i = self.console.module.get_info(("fullpath|path", "description"), ("author", "email", "version"), - ("comments",), ("options",), show_all=True) - if len(i.strip()) != "": - print_formatted_text(i) - elif key == "issues": - t = Entity.get_issues() - if len(t) > 0: - print_formatted_text(t) - - def set_keys(self): - if self.module and self.module.has_issues(self.cname): - self.keys += ["issues"] - else: - while "issues" in self.keys: - self.keys.remove("issues") - +# -*- coding: UTF-8 -*- +from prompt_toolkit.formatted_text import ANSI + +from sploitkit import * + + +# ----------------------------- SUBCONSOLE DEFINITION -------------------------- +class ModuleConsole(Console): + """ Module subconsole definition. """ + level = "module" + message = [ + ('class:prompt', " "), + ('class:prompt', None), + ('class:prompt', "("), + ('class:module', None), + ('class:prompt', ")"), + ] + style = { + 'prompt': "#eeeeee", + 'module': "#ff0000", + } + + def __init__(self, parent, module): + self.attach(module, True) + self.logname = module.fullpath + self.message[1] = ('class:prompt', self.module.category) + self.message[3] = ('class:module', self.module.base) + self.opt_prefix = "Module" + super(ModuleConsole, self).__init__(parent) + + +# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ +class Use(Command): + """ Select a module """ + except_levels = ["session"] + + def complete_values(self): + return Module.get_list() + + def run(self, module): + new_mod, old_mod = Module.get_modules(module), self.module + # avoid starting a new subconsole for the same module + if old_mod is not None and old_mod.fullpath == new_mod.fullpath: + return + ModuleConsole(self.console, new_mod).start() + + +# ----------------------------- MODULE-LEVEL COMMANDS -------------------------- +class ModuleCommand(Command): + """ Proxy class (for setting the level attribute). """ + level = "module" + + +class Run(ModuleCommand): + """ Run module """ + def run(self): + if self.module.check(): + self.module._instance.run() + + +class Show(ModuleCommand): + """ Show module-relevant information or options """ + keys = ["info", "options"] + + def complete_values(self, key): + if key == "options": + return list(self.config.keys()) + elif key == "issues": + l = [] + for attr in ["console", "module"]: + for _, __, errors in getattr(self, attr).issues(self.cname): + l.extend(list(errors.keys())) + return l + + def run(self, key, value=None): + if key == "options": + if value is None: + print_formatted_text(ANSI(str(self.config))) + else: + c = Config() + c[self.config.option(value), True] = self.config[value] + print_formatted_text(ANSI(str(c))) + elif key == "info": + i = self.console.module.get_info(("fullpath|path", "description"), ("author", "email", "version"), + ("comments",), ("options",), show_all=True) + if len(i.strip()) != "": + print_formatted_text(i) + elif key == "issues": + t = Entity.get_issues() + if len(t) > 0: + print_formatted_text(t) + + def set_keys(self): + if self.module and self.module.has_issues(self.cname): + self.keys += ["issues"] + else: + while "issues" in self.keys: + self.keys.remove("issues") + diff --git a/sploitkit/base/commands/project.py b/src/sploitkit/base/commands/project.py similarity index 97% rename from sploitkit/base/commands/project.py rename to src/sploitkit/base/commands/project.py index 00a8990..eed0012 100644 --- a/sploitkit/base/commands/project.py +++ b/src/sploitkit/base/commands/project.py @@ -1,122 +1,122 @@ -# -*- coding: UTF-8 -*- -from tinyscript.helpers import confirm, Path, ProjectPath - -from sploitkit import * - - -# ----------------------------- SUBCONSOLE DEFINITION -------------------------- -class ProjectConsole(Console): - """ Project subconsole definition. """ - level = "project" - message = [ - ('class:prompt', "["), - ('class:project', None), - ('class:prompt', "]"), - ] - style = { - 'prompt': "#eeeeee", - 'project': "#0000ff", - } - - def __init__(self, parent, name): - self.logname = name - self.message[1] = ('class:project', name) - self.config['WORKSPACE'] = str(Path(parent.config['WORKSPACE']).joinpath(name)) - super(ProjectConsole, self).__init__(parent) - - -# ------------------------------ ROOT-LEVEL COMMANDS --------------------------- -# These commands are available at the root level to reference a project (archive|create|select|...) -class RootCommand(Command): - """ Proxy class for setting the level attribute. """ - level = "root" - - -class ProjectRootCommand(RootCommand): - """ Proxy class for defining the complete_values method. """ - single_arg = True - - def complete_values(self): - return [x.filename for x in self.workspace.iterpubdir()] - - -class Archive(ProjectRootCommand): - """ Archive a project to a ZIP file (it removes the project folder) """ - def run(self, project): - projpath = Path(self.workspace).joinpath(project) - folder = ProjectPath(projpath) - self.logger.debug("Archiving project '{}'...".format(project)) - ask = self.console.config.option("ENCRYPT_PROJECT").value - try: - folder.archive(ask=ask) - self.logger.success("'{}' archived".format(project)) - except OSError as e: - logger.error(str(e)) - self.logger.failure("'{}' not archived".format(project)) - - -class Delete(ProjectRootCommand): - """ Delete a project """ - def run(self, project): - self.logger.debug("Deleting project '{}'...".format(project)) - self.workspace.joinpath(project).remove() - self.logger.success("'{}' deleted".format(project)) - - -class Load(ProjectRootCommand): - """ Load a project from a ZIP file (it removes the ZIP file) """ - def complete_values(self): - # this returns the list of *.zip in the workspace folder - return [x.stem for x in self.workspace.iterfiles(".zip")] - - def run(self, project): - self.logger.debug("Loading archive '{}'...".format(project + ".zip")) - projpath = Path(self.workspace).joinpath(project) - archive = ProjectPath(projpath.with_suffix(".zip")) - ask = self.console.config.option("ENCRYPT_PROJECT").value - try: - archive.load(ask=ask) - self.logger.success("'{}' loaded".format(project)) - except Exception as e: - logger.error("Bad password" if "error -3" in str(e) else str(e)) - self.logger.failure("'{}' not loaded".format(project)) - - def validate(self, project): - if project not in self.complete_values(): - raise ValueError("no project archive for this name") - elif project in super(Load, self).complete_values(): - raise ValueError("a project with the same name already exists") - - -class Select(ProjectRootCommand): - """ Select a project (create if it does not exist) """ - def complete_values(self): - return Load().complete_values() + super(Select, self).complete_values() - - def run(self, project): - p = self.workspace.joinpath(project) - loader = Load() - if project in loader.complete_values() and confirm("An archive with this name already exists ; " - "do you want to load the archive instead ?"): - loader.run(project) - if not p.exists(): - self.logger.debug("Creating project '{}'...".format(project)) - p.mkdir() - self.logger.success("'{}' created".format(project)) - ProjectConsole(self.console, project).start() - self.config['WORKSPACE'] = str(Path(self.config['WORKSPACE']).parent) - - def validate(self, project): - pass - - -# ---------------------------- PROJECT-LEVEL COMMANDS -------------------------- -class Show(Command): - """ Show project-relevant options """ - #FIXME - level = "project" - values = ["options"] - - def run(self, value): - print_formatted_text(BorderlessTable(self.console.__class__.options, "Console options")) - +# -*- coding: UTF-8 -*- +from tinyscript.helpers import confirm, Path, ProjectPath + +from sploitkit import * + + +# ----------------------------- SUBCONSOLE DEFINITION -------------------------- +class ProjectConsole(Console): + """ Project subconsole definition. """ + level = "project" + message = [ + ('class:prompt', "["), + ('class:project', None), + ('class:prompt', "]"), + ] + style = { + 'prompt': "#eeeeee", + 'project': "#0000ff", + } + + def __init__(self, parent, name): + self.logname = name + self.message[1] = ('class:project', name) + self.config['WORKSPACE'] = str(Path(parent.config['WORKSPACE']).joinpath(name)) + super(ProjectConsole, self).__init__(parent) + + +# ------------------------------ ROOT-LEVEL COMMANDS --------------------------- +# These commands are available at the root level to reference a project (archive|create|select|...) +class RootCommand(Command): + """ Proxy class for setting the level attribute. """ + level = "root" + + +class ProjectRootCommand(RootCommand): + """ Proxy class for defining the complete_values method. """ + single_arg = True + + def complete_values(self): + return [x.filename for x in self.workspace.iterpubdir()] + + +class Archive(ProjectRootCommand): + """ Archive a project to a ZIP file (it removes the project folder) """ + def run(self, project): + projpath = Path(self.workspace).joinpath(project) + folder = ProjectPath(projpath) + self.logger.debug("Archiving project '{}'...".format(project)) + ask = self.console.config.option("ENCRYPT_PROJECT").value + try: + folder.archive(ask=ask) + self.logger.success("'{}' archived".format(project)) + except OSError as e: + logger.error(str(e)) + self.logger.failure("'{}' not archived".format(project)) + + +class Delete(ProjectRootCommand): + """ Delete a project """ + def run(self, project): + self.logger.debug("Deleting project '{}'...".format(project)) + self.workspace.joinpath(project).remove() + self.logger.success("'{}' deleted".format(project)) + + +class Load(ProjectRootCommand): + """ Load a project from a ZIP file (it removes the ZIP file) """ + def complete_values(self): + # this returns the list of *.zip in the workspace folder + return [x.stem for x in self.workspace.iterfiles(".zip")] + + def run(self, project): + self.logger.debug("Loading archive '{}'...".format(project + ".zip")) + projpath = Path(self.workspace).joinpath(project) + archive = ProjectPath(projpath.with_suffix(".zip")) + ask = self.console.config.option("ENCRYPT_PROJECT").value + try: + archive.load(ask=ask) + self.logger.success("'{}' loaded".format(project)) + except Exception as e: + logger.error("Bad password" if "error -3" in str(e) else str(e)) + self.logger.failure("'{}' not loaded".format(project)) + + def validate(self, project): + if project not in self.complete_values(): + raise ValueError("no project archive for this name") + elif project in super(Load, self).complete_values(): + raise ValueError("a project with the same name already exists") + + +class Select(ProjectRootCommand): + """ Select a project (create if it does not exist) """ + def complete_values(self): + return Load().complete_values() + super(Select, self).complete_values() + + def run(self, project): + p = self.workspace.joinpath(project) + loader = Load() + if project in loader.complete_values() and confirm("An archive with this name already exists ; " + "do you want to load the archive instead ?"): + loader.run(project) + if not p.exists(): + self.logger.debug("Creating project '{}'...".format(project)) + p.mkdir() + self.logger.success("'{}' created".format(project)) + ProjectConsole(self.console, project).start() + self.config['WORKSPACE'] = str(Path(self.config['WORKSPACE']).parent) + + def validate(self, project): + pass + + +# ---------------------------- PROJECT-LEVEL COMMANDS -------------------------- +class Show(Command): + """ Show project-relevant options """ + #FIXME + level = "project" + values = ["options"] + + def run(self, value): + print_formatted_text(BorderlessTable(self.console.__class__.options, "Console options")) + diff --git a/sploitkit/base/commands/recording.py b/src/sploitkit/base/commands/recording.py similarity index 97% rename from sploitkit/base/commands/recording.py rename to src/sploitkit/base/commands/recording.py index 01b63f1..d55b7df 100644 --- a/sploitkit/base/commands/recording.py +++ b/src/sploitkit/base/commands/recording.py @@ -1,62 +1,62 @@ -# -*- coding: UTF-8 -*- -from tinyscript.helpers import Path - -from sploitkit import Command - - -# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ -class RecordStatus(Command): - """ Consult status for commands recording to a .rc file """ - # Rationale: recording status should be consultable from any console level - aliases = ["record"] - alias_only = True - except_levels = ["session"] - values = ["status"] - - def run(self, status): - self.logger.info("Recording is {}".format(["disabled", "enabled"][self.recorder.enabled])) - - -# ------------------------------ ROOT-LEVEL COMMANDS --------------------------- -class RootProjectCommand(Command): - """ Proxy class (for setting the level attribute). """ - level = ["root", "project"] - - -class Record(RootProjectCommand): - """ Start/stop or consult status of commands recording to a .rc file """ - # Rationale: recording start/stop is only triggerable from the root level - keys = ["start", "stop", "status"] - - def complete_values(self, key=None): - if key == "start": - return [x.name for x in Path(self.workspace).iterfiles(".rc")] - - def run(self, key, rcfile=None): - if key == "start": - self.recorder.start(str(Path(self.workspace).joinpath(rcfile))) - elif key == "stop": - self.recorder.stop() - elif key == "status": - self.logger.info("Recording is {}".format(["disabled", "enabled"][self.recorder.enabled])) - - def validate(self, key, rcfile=None): - if key == "start": - if rcfile is None: - raise ValueError("please enter a filename") - if Path(self.workspace).joinpath(rcfile).exists(): - raise ValueError("a file with the same name already exists") - elif key in ["stop", "status"]: - if rcfile is not None: - raise ValueError("this key takes no value") - - -class Replay(RootProjectCommand): - """ Execute commands from a .rc file """ - def complete_values(self, key=None): - return [x.name for x in Path(self.workspace).iterfiles(".rc")] - - def run(self, rcfile): - self.logger.debug("Replaying commands from file '{}'...".format(rcfile)) - self.console.replay(rcfile) - +# -*- coding: UTF-8 -*- +from tinyscript.helpers import Path + +from sploitkit import Command + + +# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ +class RecordStatus(Command): + """ Consult status for commands recording to a .rc file """ + # Rationale: recording status should be consultable from any console level + aliases = ["record"] + alias_only = True + except_levels = ["session"] + values = ["status"] + + def run(self, status): + self.logger.info("Recording is {}".format(["disabled", "enabled"][self.recorder.enabled])) + + +# ------------------------------ ROOT-LEVEL COMMANDS --------------------------- +class RootProjectCommand(Command): + """ Proxy class (for setting the level attribute). """ + level = ["root", "project"] + + +class Record(RootProjectCommand): + """ Start/stop or consult status of commands recording to a .rc file """ + # Rationale: recording start/stop is only triggerable from the root level + keys = ["start", "stop", "status"] + + def complete_values(self, key=None): + if key == "start": + return [x.name for x in Path(self.workspace).iterfiles(".rc")] + + def run(self, key, rcfile=None): + if key == "start": + self.recorder.start(str(Path(self.workspace).joinpath(rcfile))) + elif key == "stop": + self.recorder.stop() + elif key == "status": + self.logger.info("Recording is {}".format(["disabled", "enabled"][self.recorder.enabled])) + + def validate(self, key, rcfile=None): + if key == "start": + if rcfile is None: + raise ValueError("please enter a filename") + if Path(self.workspace).joinpath(rcfile).exists(): + raise ValueError("a file with the same name already exists") + elif key in ["stop", "status"]: + if rcfile is not None: + raise ValueError("this key takes no value") + + +class Replay(RootProjectCommand): + """ Execute commands from a .rc file """ + def complete_values(self, key=None): + return [x.name for x in Path(self.workspace).iterfiles(".rc")] + + def run(self, rcfile): + self.logger.debug("Replaying commands from file '{}'...".format(rcfile)) + self.console.replay(rcfile) + diff --git a/sploitkit/base/commands/root.py b/src/sploitkit/base/commands/root.py similarity index 97% rename from sploitkit/base/commands/root.py rename to src/sploitkit/base/commands/root.py index 176e851..25af37b 100644 --- a/sploitkit/base/commands/root.py +++ b/src/sploitkit/base/commands/root.py @@ -1,33 +1,33 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * - - -# ----------------------- GENERAL-PURPOSE ROOT-LEVEL COMMANDS ------------------ -class Help(Command): - """ Display help (commands or individual command/module) """ - level = "root" - keys = ["command"] - - def __init__(self): - if len(Module.modules) > 0 and "module" not in self.keys: - self.keys += ["module"] - - def complete_values(self, category): - if category == "command": - return self.console.commands.keys() - elif category == "module": - return sorted([x.fullpath for x in Module.subclasses]) - - def run(self, category=None, value=None): - if category is None: - print_formatted_text(Command.get_help(except_levels="module")) - elif category == "command": - print_formatted_text(self.console.commands[value].help(value)) - elif category == "module": - print_formatted_text(self.modules[value].help) - - def validate(self, category=None, value=None): - if category is None and value is None: - return - super(Help, self).validate(category, value) - +# -*- coding: UTF-8 -*- +from sploitkit import * + + +# ----------------------- GENERAL-PURPOSE ROOT-LEVEL COMMANDS ------------------ +class Help(Command): + """ Display help (commands or individual command/module) """ + level = "root" + keys = ["command"] + + def __init__(self): + if len(Module.modules) > 0 and "module" not in self.keys: + self.keys += ["module"] + + def complete_values(self, category): + if category == "command": + return self.console.commands.keys() + elif category == "module": + return sorted([x.fullpath for x in Module.subclasses]) + + def run(self, category=None, value=None): + if category is None: + print_formatted_text(Command.get_help(except_levels="module")) + elif category == "command": + print_formatted_text(self.console.commands[value].help(value)) + elif category == "module": + print_formatted_text(self.modules[value].help) + + def validate(self, category=None, value=None): + if category is None and value is None: + return + super(Help, self).validate(category, value) + diff --git a/sploitkit/base/commands/session.py b/src/sploitkit/base/commands/session.py similarity index 96% rename from sploitkit/base/commands/session.py rename to src/sploitkit/base/commands/session.py index 61d521e..b4eb509 100644 --- a/sploitkit/base/commands/session.py +++ b/src/sploitkit/base/commands/session.py @@ -1,47 +1,47 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * - - -# ----------------------------- SUBCONSOLE DEFINITION -------------------------- -class SessionConsole(Console): - """ Session subconsole definition. """ - level = "session" - message_reset = True - message = [ - ('class:session', None), - ('class:prompt', ">"), - ] - style = { - 'prompt': "#eeeeee", - 'session': "#00ff00", - } - - def __init__(self, parent, session_id): - session = parent._sessions[session_id] - self.logname = "session-%d" % session_id - self.message[0] = ('class:prompt', session.name) - super(SessionConsole, self).__init__(parent, fail=False) - self.config.prefix = "Module" - - -# ---------------------------- SESSION-RELATED COMMANDS ------------------------ -class Background(Command): - """ Put the current session to the background """ - level = "session" - - def run(self): - # do something with the session - raise ConsoleExit - - -class Session(Command): - """ Resume an open session """ - except_levels = ["session"] - #requirements = {'internal': lambda s: len(s.console._sessions) > 0} - - def complete_values(self): - return list(range(len(self.console._sessions))) - - def run(self, session_id): - SessionConsole(self.console, session_id).start() - +# -*- coding: UTF-8 -*- +from sploitkit import * + + +# ----------------------------- SUBCONSOLE DEFINITION -------------------------- +class SessionConsole(Console): + """ Session subconsole definition. """ + level = "session" + message_reset = True + message = [ + ('class:session', None), + ('class:prompt', ">"), + ] + style = { + 'prompt': "#eeeeee", + 'session': "#00ff00", + } + + def __init__(self, parent, session_id): + session = parent._sessions[session_id] + self.logname = "session-%d" % session_id + self.message[0] = ('class:prompt', session.name) + super(SessionConsole, self).__init__(parent, fail=False) + self.config.prefix = "Module" + + +# ---------------------------- SESSION-RELATED COMMANDS ------------------------ +class Background(Command): + """ Put the current session to the background """ + level = "session" + + def run(self): + # do something with the session + raise ConsoleExit + + +class Session(Command): + """ Resume an open session """ + except_levels = ["session"] + #requirements = {'internal': lambda s: len(s.console._sessions) > 0} + + def complete_values(self): + return list(range(len(self.console._sessions))) + + def run(self, session_id): + SessionConsole(self.console, session_id).start() + diff --git a/sploitkit/base/commands/utils.py b/src/sploitkit/base/commands/utils.py similarity index 96% rename from sploitkit/base/commands/utils.py rename to src/sploitkit/base/commands/utils.py index 0c045c7..a345508 100644 --- a/sploitkit/base/commands/utils.py +++ b/src/sploitkit/base/commands/utils.py @@ -1,224 +1,224 @@ -# -*- coding: UTF-8 -*- -import os -import stat -import yaml -from collections.abc import Iterable -from gc import collect, get_objects, get_referrers -from subprocess import call -from sys import getrefcount -from tinyscript.helpers import human_readable_size, parse_docstring, pprint, BorderlessTable, Capture, Path - -from sploitkit import * -from sploitkit.core.components import BACK_REFERENCES -from sploitkit.core.entity import load_entities - - -# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ -class Edit(Command): - """ Edit a text file """ - except_levels = ["session"] - requirements = {'system': ["vim"]} - single_arg = True - - def check_requirements(self): - return self.config.option("TEXT_EDITOR").value is not None - - def complete_values(self): - p = Path(self.config.option("WORKSPACE").value) - f = p.iterfiles(relative=True) - return list(map(lambda x: str(x), f)) - - def run(self, filename): - f = Path(self.config.option("WORKSPACE").value).joinpath(filename) - self.console._files.edit(str(f)) - - def validate(self, filename): - return - - -class History(Command): - """ Inspect commands history """ - except_levels = ["session"] - requirements = {'system': ["less"]} - - def run(self): - h = Path(self.config.option("WORKSPACE").value).joinpath("history") - self.console._files.page(str(h)) - - -class Shell(Command): - """ Execute a shell command """ - except_levels = ["session"] - single_arg = True - - def complete_values(self): - l = [] - e = stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH - for p in os.environ['PATH'].split(":"): - if not os.path.isdir(p): - continue - for f in os.listdir(p): - fp = os.path.join(p, f) - if os.path.isfile(fp): - st = os.stat(fp) - if st.st_mode & e and f not in l: - l.append(f) - return l - - def run(self, cmd=None): - if cmd is None: - from pty import spawn - spawn("/bin/bash") - else: - call(cmd, shell=True) - print_formatted_text("") - - def validate(self, cmd): - _ = cmd.split() - if len(_) <= 1 and _[0] not in self.complete_values(): - raise ValueError("bad shell command") - - -class Stats(Command): - """ Display console's statistics """ - level = "root" - - def run(self): - d = [["Item", "Path", "Size"]] - p = self.console.app_folder - d.append(["APP_FOLDER", str(p), human_readable_size(p.size)]) - p = self.workspace - d.append(["WORKSPACE", str(p), human_readable_size(p.size)]) - t = BorderlessTable(d, "Statistics") - print_formatted_text(t.table) - - -# ------------------------------- DEBUGGING COMMANDS --------------------------- -class DebugCommand(Command): - """ Proxy class for development commands """ - except_levels = ["session"] - requirements = {'config': {'DEBUG': True}} - - -class Logs(DebugCommand): - """ Inspect console logs """ - requirements = {'system': ["less"]} - - def run(self): - self.console._files.page(self.logger.__logfile__) - - -class Pydbg(DebugCommand): - """ Start a Python debugger session """ - requirements = {'python': ["pdb"]} - - def run(self): - import pdb - pdb.set_trace() - - -class State(DebugCommand): - """ Display console's shared state """ - def run(self): - for k, v in self.console.state.items(): - print_formatted_text("\n{}:".format(k)) - v = v or "" - if len(v) == 0: - continue - if isinstance(v, Iterable): - if isinstance(v, dict): - v = dict(**v) - for l in yaml.dump(v).split("\n"): - if len(l.strip()) == 0: - continue - print_formatted_text(" " + l) - else: - print_formatted_text(v) - print_formatted_text("") - - -# ------------------------------ DEVELOPMENT COMMANDS -------------------------- -class DevCommand(DebugCommand): - """ Proxy class for development commands """ - def condition(self): - return getattr(Console, "_dev_mode", False) - - -class Collect(DevCommand): - """ Garbage-collect """ - def run(self): - collect() - - -class Dict(DevCommand): - """ Show console's dictionary of attributes """ - def run(self): - pprint(self.console.__dict__) - - -class Memory(DevCommand): - """ Inspect memory consumption """ - keys = ["graph", "growth", "info", "leaking", "objects", "refs"] - requirements = { - 'python': ["objgraph", "psutil", "xdot"], - 'system': ["xdot"], - } - - def complete_values(self, key=None): - if key in ["graph", "refs"]: - return [str(o) for o in get_objects() if isinstance(o, Console)] - - def run(self, key, value=None): - if value is not None: - obj = list(filter(lambda o: str(o) == value, get_objects()))[0] - if key == "graph": - from objgraph import show_refs - if value is None: - p = self.console.parent - show_refs(self.console if p is None else p, refcounts=True, max_depth=3) - else: - show_refs(obj, refcounts=True, max_depth=3) - elif key == "growth": - from objgraph import get_leaking_objects, show_most_common_types - show_most_common_types(objects=get_leaking_objects()) - elif key == "info": - from psutil import Process - p = Process(os.getpid()) - print_formatted_text(p.memory_info()) - elif key == "leaking": - from objgraph import get_leaking_objects - with Capture() as (out, err): - pprint(get_leaking_objects()) - print_formatted_text(out) - elif key == "objects": - data = [["Object", "#References"]] - for o in get_objects(): - if isinstance(o, (Console, Module)): - data.append([str(o), str(getrefcount(o))]) - t = BorderlessTable(data, "Consoles/Modules") - print_formatted_text(t.table) - elif key == "refs": - if value is not None: - print_formatted_text(getrefcount(obj), ":") - pprint(get_referrers(obj)) - - def validate(self, key, value=None): - if key in ["graph", "refs"]: - if value and value not in self.complete_values("graph"): - raise ValueError("bad object") - elif value: - raise ValueError("this key takes no value") - - -class Reload(Command): - """ Inspect memory consumption """ - level = "root" - values = ["commands", "modules", "models"] - - def condition(self): - return getattr(Console, "_dev_mode", False) - - def run(self, value): - load_entities([globals()[value[:-1].capitalize()]], - *([self.console._root] + self.console._sources("entities")), **self.console._load_kwargs) - +# -*- coding: UTF-8 -*- +import os +import stat +import yaml +from collections.abc import Iterable +from gc import collect, get_objects, get_referrers +from subprocess import call +from sys import getrefcount +from tinyscript.helpers import human_readable_size, parse_docstring, pprint, BorderlessTable, Capture, Path + +from sploitkit import * +from sploitkit.core.components import BACK_REFERENCES +from sploitkit.core.entity import load_entities + + +# ---------------------------- GENERAL-PURPOSE COMMANDS ------------------------ +class Edit(Command): + """ Edit a text file """ + except_levels = ["session"] + requirements = {'system': ["vim"]} + single_arg = True + + def check_requirements(self): + return self.config.option("TEXT_EDITOR").value is not None + + def complete_values(self): + p = Path(self.config.option("WORKSPACE").value) + f = p.iterfiles(relative=True) + return list(map(lambda x: str(x), f)) + + def run(self, filename): + f = Path(self.config.option("WORKSPACE").value).joinpath(filename) + self.console._files.edit(str(f)) + + def validate(self, filename): + return + + +class History(Command): + """ Inspect commands history """ + except_levels = ["session"] + requirements = {'system': ["less"]} + + def run(self): + h = Path(self.config.option("WORKSPACE").value).joinpath("history") + self.console._files.page(str(h)) + + +class Shell(Command): + """ Execute a shell command """ + except_levels = ["session"] + single_arg = True + + def complete_values(self): + l = [] + e = stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH + for p in os.environ['PATH'].split(":"): + if not os.path.isdir(p): + continue + for f in os.listdir(p): + fp = os.path.join(p, f) + if os.path.isfile(fp): + st = os.stat(fp) + if st.st_mode & e and f not in l: + l.append(f) + return l + + def run(self, cmd=None): + if cmd is None: + from pty import spawn + spawn("/bin/bash") + else: + call(cmd, shell=True) + print_formatted_text("") + + def validate(self, cmd): + _ = cmd.split() + if len(_) <= 1 and _[0] not in self.complete_values(): + raise ValueError("bad shell command") + + +class Stats(Command): + """ Display console's statistics """ + level = "root" + + def run(self): + d = [["Item", "Path", "Size"]] + p = self.console.app_folder + d.append(["APP_FOLDER", str(p), human_readable_size(p.size)]) + p = self.workspace + d.append(["WORKSPACE", str(p), human_readable_size(p.size)]) + t = BorderlessTable(d, "Statistics") + print_formatted_text(t.table) + + +# ------------------------------- DEBUGGING COMMANDS --------------------------- +class DebugCommand(Command): + """ Proxy class for development commands """ + except_levels = ["session"] + requirements = {'config': {'DEBUG': True}} + + +class Logs(DebugCommand): + """ Inspect console logs """ + requirements = {'system': ["less"]} + + def run(self): + self.console._files.page(self.logger.__logfile__) + + +class Pydbg(DebugCommand): + """ Start a Python debugger session """ + requirements = {'python': ["pdb"]} + + def run(self): + import pdb + pdb.set_trace() + + +class State(DebugCommand): + """ Display console's shared state """ + def run(self): + for k, v in self.console.state.items(): + print_formatted_text("\n{}:".format(k)) + v = v or "" + if len(v) == 0: + continue + if isinstance(v, Iterable): + if isinstance(v, dict): + v = dict(**v) + for l in yaml.dump(v).split("\n"): + if len(l.strip()) == 0: + continue + print_formatted_text(" " + l) + else: + print_formatted_text(v) + print_formatted_text("") + + +# ------------------------------ DEVELOPMENT COMMANDS -------------------------- +class DevCommand(DebugCommand): + """ Proxy class for development commands """ + def condition(self): + return getattr(Console, "_dev_mode", False) + + +class Collect(DevCommand): + """ Garbage-collect """ + def run(self): + collect() + + +class Dict(DevCommand): + """ Show console's dictionary of attributes """ + def run(self): + pprint(self.console.__dict__) + + +class Memory(DevCommand): + """ Inspect memory consumption """ + keys = ["graph", "growth", "info", "leaking", "objects", "refs"] + requirements = { + 'python': ["objgraph", "psutil", "xdot"], + 'system': ["xdot"], + } + + def complete_values(self, key=None): + if key in ["graph", "refs"]: + return [str(o) for o in get_objects() if isinstance(o, Console)] + + def run(self, key, value=None): + if value is not None: + obj = list(filter(lambda o: str(o) == value, get_objects()))[0] + if key == "graph": + from objgraph import show_refs + if value is None: + p = self.console.parent + show_refs(self.console if p is None else p, refcounts=True, max_depth=3) + else: + show_refs(obj, refcounts=True, max_depth=3) + elif key == "growth": + from objgraph import get_leaking_objects, show_most_common_types + show_most_common_types(objects=get_leaking_objects()) + elif key == "info": + from psutil import Process + p = Process(os.getpid()) + print_formatted_text(p.memory_info()) + elif key == "leaking": + from objgraph import get_leaking_objects + with Capture() as (out, err): + pprint(get_leaking_objects()) + print_formatted_text(out) + elif key == "objects": + data = [["Object", "#References"]] + for o in get_objects(): + if isinstance(o, (Console, Module)): + data.append([str(o), str(getrefcount(o))]) + t = BorderlessTable(data, "Consoles/Modules") + print_formatted_text(t.table) + elif key == "refs": + if value is not None: + print_formatted_text(getrefcount(obj), ":") + pprint(get_referrers(obj)) + + def validate(self, key, value=None): + if key in ["graph", "refs"]: + if value and value not in self.complete_values("graph"): + raise ValueError("bad object") + elif value: + raise ValueError("this key takes no value") + + +class Reload(Command): + """ Inspect memory consumption """ + level = "root" + values = ["commands", "modules", "models"] + + def condition(self): + return getattr(Console, "_dev_mode", False) + + def run(self, value): + load_entities([globals()[value[:-1].capitalize()]], + *([self.console._root] + self.console._sources("entities")), **self.console._load_kwargs) + diff --git a/sploitkit/base/config.conf b/src/sploitkit/base/config.conf similarity index 94% rename from sploitkit/base/config.conf rename to src/sploitkit/base/config.conf index d65c9ed..fd91e88 100644 --- a/sploitkit/base/config.conf +++ b/src/sploitkit/base/config.conf @@ -1,2 +1,2 @@ -[main] -projects_folder = ~/Projects +[main] +projects_folder = ~/Projects diff --git a/sploitkit/base/models/__init__.py b/src/sploitkit/base/models/__init__.py similarity index 100% rename from sploitkit/base/models/__init__.py rename to src/sploitkit/base/models/__init__.py diff --git a/sploitkit/base/models/notes.py b/src/sploitkit/base/models/notes.py similarity index 95% rename from sploitkit/base/models/notes.py rename to src/sploitkit/base/models/notes.py index 6602c98..dbabc26 100644 --- a/sploitkit/base/models/notes.py +++ b/src/sploitkit/base/models/notes.py @@ -1,34 +1,34 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * -from sploitkit.base.models.organization import Organization -from sploitkit.base.models.systems import Host -from sploitkit.base.models.users import User - - -class Note(Model): - content = TextField() - - -class OrganizationNote(BaseModel): - organization = ForeignKeyField(Organization) - note = ForeignKeyField(Note) - - class Meta: - primary_key = CompositeKey("organization", "note") - - -class NoteHost(BaseModel): - host = ForeignKeyField(Host) - note = ForeignKeyField(Note) - - class Meta: - primary_key = CompositeKey("host", "note") - - -class NoteUser(BaseModel): - user = ForeignKeyField(User) - note = ForeignKeyField(Note) - - class Meta: - primary_key = CompositeKey("user", "note") - +# -*- coding: UTF-8 -*- +from sploitkit import * +from sploitkit.base.models.organization import Organization +from sploitkit.base.models.systems import Host +from sploitkit.base.models.users import User + + +class Note(Model): + content = TextField() + + +class OrganizationNote(BaseModel): + organization = ForeignKeyField(Organization) + note = ForeignKeyField(Note) + + class Meta: + primary_key = CompositeKey("organization", "note") + + +class NoteHost(BaseModel): + host = ForeignKeyField(Host) + note = ForeignKeyField(Note) + + class Meta: + primary_key = CompositeKey("host", "note") + + +class NoteUser(BaseModel): + user = ForeignKeyField(User) + note = ForeignKeyField(Note) + + class Meta: + primary_key = CompositeKey("user", "note") + diff --git a/sploitkit/base/models/organization.py b/src/sploitkit/base/models/organization.py similarity index 96% rename from sploitkit/base/models/organization.py rename to src/sploitkit/base/models/organization.py index ac1a19a..aecbcde 100644 --- a/sploitkit/base/models/organization.py +++ b/src/sploitkit/base/models/organization.py @@ -1,51 +1,51 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * -from sploitkit.base.models.users import Email, User - - -class Organization(Model): - name = CharField(primary_key=True) - - -class Unit(Model): - name = CharField(primary_key=True) - organization = ForeignKeyField(Organization, backref="units") - - -class Employee(Model): - firstname = CharField() - lastname = CharField() - role = CharField() - title = CharField() - - class Meta: - indexes = ((("firstname", "lastname", "role"), True),) - - @property - def fullname(self): - return "{} {} ({})".format(self.firstname, self.lastname, self.role) - - -class EmployeeUnit(BaseModel): - employee = ForeignKeyField(Employee, backref="units") - unit = ForeignKeyField(Unit, backref="employees") - - class Meta: - primary_key = CompositeKey("employee", "unit") - - -class EmployeeEmail(BaseModel): - employee = ForeignKeyField(Employee, backref="emails") - email = ForeignKeyField(Email, backref="employees") - - class Meta: - primary_key = CompositeKey("employee", "email") - - -class EmployeeUser(BaseModel): - employee = ForeignKeyField(Employee, backref="users") - user = ForeignKeyField(User, backref="employees") - - class Meta: - primary_key = CompositeKey("employee", "user") - +# -*- coding: UTF-8 -*- +from sploitkit import * +from sploitkit.base.models.users import Email, User + + +class Organization(Model): + name = CharField(primary_key=True) + + +class Unit(Model): + name = CharField(primary_key=True) + organization = ForeignKeyField(Organization, backref="units") + + +class Employee(Model): + firstname = CharField() + lastname = CharField() + role = CharField() + title = CharField() + + class Meta: + indexes = ((("firstname", "lastname", "role"), True),) + + @property + def fullname(self): + return "{} {} ({})".format(self.firstname, self.lastname, self.role) + + +class EmployeeUnit(BaseModel): + employee = ForeignKeyField(Employee, backref="units") + unit = ForeignKeyField(Unit, backref="employees") + + class Meta: + primary_key = CompositeKey("employee", "unit") + + +class EmployeeEmail(BaseModel): + employee = ForeignKeyField(Employee, backref="emails") + email = ForeignKeyField(Email, backref="employees") + + class Meta: + primary_key = CompositeKey("employee", "email") + + +class EmployeeUser(BaseModel): + employee = ForeignKeyField(Employee, backref="users") + user = ForeignKeyField(User, backref="employees") + + class Meta: + primary_key = CompositeKey("employee", "user") + diff --git a/sploitkit/base/models/systems.py b/src/sploitkit/base/models/systems.py similarity index 95% rename from sploitkit/base/models/systems.py rename to src/sploitkit/base/models/systems.py index bec21b0..be2b8f5 100644 --- a/sploitkit/base/models/systems.py +++ b/src/sploitkit/base/models/systems.py @@ -1,39 +1,39 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * - - -class Host(Model): - hostname = CharField(max_length=256) - ip = IPAddressField() - mac = MACAddressField() - os = CharField() - location = CharField() - - class Meta: - indexes = ((("hostname", "ip", "mac"), True),) - - -class Port(Model): - number = IntegerField(primary_key=True) - status = BooleanField() - - -class Service(Model): - name = CharField(primary_key=True) - - -class HostPort(BaseModel): - host = ForeignKeyField(Host, backref="ports") - port = ForeignKeyField(Port, backref="hosts") - - class Meta: - primary_key = CompositeKey("host", "port") - - -class ServicePort(BaseModel): - service = ForeignKeyField(Service, backref="ports") - port = ForeignKeyField(Port, backref="services") - - class Meta: - primary_key = CompositeKey("service", "port") - +# -*- coding: UTF-8 -*- +from sploitkit import * + + +class Host(Model): + hostname = CharField(max_length=256) + ip = IPAddressField() + mac = MACAddressField() + os = CharField() + location = CharField() + + class Meta: + indexes = ((("hostname", "ip", "mac"), True),) + + +class Port(Model): + number = IntegerField(primary_key=True) + status = BooleanField() + + +class Service(Model): + name = CharField(primary_key=True) + + +class HostPort(BaseModel): + host = ForeignKeyField(Host, backref="ports") + port = ForeignKeyField(Port, backref="hosts") + + class Meta: + primary_key = CompositeKey("host", "port") + + +class ServicePort(BaseModel): + service = ForeignKeyField(Service, backref="ports") + port = ForeignKeyField(Port, backref="services") + + class Meta: + primary_key = CompositeKey("service", "port") + diff --git a/sploitkit/base/models/users.py b/src/sploitkit/base/models/users.py similarity index 95% rename from sploitkit/base/models/users.py rename to src/sploitkit/base/models/users.py index 60df147..e6d7a77 100644 --- a/sploitkit/base/models/users.py +++ b/src/sploitkit/base/models/users.py @@ -1,44 +1,44 @@ -# -*- coding: UTF-8 -*- -from sploitkit import * - - -class User(Model): - username = CharField(primary_key=True) - - -class Email(Model): - address = CharField(primary_key=True, max_length=320) - - -class Password(Model): - hash = CharField(primary_key=True) - plain = CharField() - - -class UserEmail(BaseModel): - user = ForeignKeyField(User, backref="emails") - email = ForeignKeyField(Email, backref="users") - - class Meta: - primary_key = CompositeKey("user", "email") - - -class UserPassword(BaseModel): - user = ForeignKeyField(User, backref="passwords") - password = ForeignKeyField(Password, backref="users") - - class Meta: - primary_key = CompositeKey("user", "password") - - -#TODO: to be tested -#class UsersStorage(StoreExtension): -# def set_user(self, username): -# User.get_or_create(username=username).execute() - - -#TODO: to be tested -#class PasswordsStorage(StoreExtension): -# def set_password(self, password): -# Password.insert(password=password).execute() - +# -*- coding: UTF-8 -*- +from sploitkit import * + + +class User(Model): + username = CharField(primary_key=True) + + +class Email(Model): + address = CharField(primary_key=True, max_length=320) + + +class Password(Model): + hash = CharField(primary_key=True) + plain = CharField() + + +class UserEmail(BaseModel): + user = ForeignKeyField(User, backref="emails") + email = ForeignKeyField(Email, backref="users") + + class Meta: + primary_key = CompositeKey("user", "email") + + +class UserPassword(BaseModel): + user = ForeignKeyField(User, backref="passwords") + password = ForeignKeyField(Password, backref="users") + + class Meta: + primary_key = CompositeKey("user", "password") + + +#TODO: to be tested +#class UsersStorage(StoreExtension): +# def set_user(self, username): +# User.get_or_create(username=username).execute() + + +#TODO: to be tested +#class PasswordsStorage(StoreExtension): +# def set_password(self, password): +# Password.insert(password=password).execute() + diff --git a/sploitkit/core/__init__.py b/src/sploitkit/core/__init__.py similarity index 94% rename from sploitkit/core/__init__.py rename to src/sploitkit/core/__init__.py index bcfee81..e69eaa3 100755 --- a/sploitkit/core/__init__.py +++ b/src/sploitkit/core/__init__.py @@ -1,3 +1,3 @@ -from .console import * -from .console import __all__ - +from .console import * +from .console import __all__ + diff --git a/sploitkit/core/application.py b/src/sploitkit/core/application.py similarity index 69% rename from sploitkit/core/application.py rename to src/sploitkit/core/application.py index 4b063b3..4aac91c 100755 --- a/sploitkit/core/application.py +++ b/src/sploitkit/core/application.py @@ -1,30 +1,23 @@ -# -*- coding: UTF-8 -*- -from prompt_toolkit.application import Application -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.layout.containers import HSplit, Window -from prompt_toolkit.layout.controls import FormattedTextControl -from prompt_toolkit.layout.dimension import LayoutDimension as D -from prompt_toolkit.layout.layout import Layout -from prompt_toolkit.lexers import PygmentsLexer -from prompt_toolkit.styles import Style -from prompt_toolkit.widgets import SearchToolbar, TextArea -#TODO: do not forget to remove unuseful imports - - -__all__ = ["FrameworkApp"] - - -#TODO: find a way to embed the Console instance (started with .start()) into FrameworkApp -class FrameworkApp(Application): - def __init__(self, *args, **kwargs): - console = kwargs.get('console') - if console is None: - raise Exception("No root console passed to the application") - #console.__class__ = type("ConsoleTextArea", (TextArea, console.__class__), {}) - #console.scrollbar = True - root_container = HSplit([ - console, - ]) - kwargs['layout'] = Layout(root_container, focused_element=console) - super(FrameworkApp, self).__init__(*args, **kwargs) - +# -*- coding: UTF-8 -*- +from prompt_toolkit.application import Application +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.layout import Layout + + +__all__ = ["FrameworkApp"] + + +#TODO: find a way to embed the Console instance (started with .start()) into FrameworkApp +class FrameworkApp(Application): + def __init__(self, *args, **kwargs): + console = kwargs.get('console') + if console is None: + raise Exception("No root console passed to the application") + #console.__class__ = type("ConsoleTextArea", (TextArea, console.__class__), {}) + #console.scrollbar = True + root_container = HSplit([ + console, + ]) + kwargs['layout'] = Layout(root_container, focused_element=console) + super(FrameworkApp, self).__init__(*args, **kwargs) + diff --git a/sploitkit/core/command.py b/src/sploitkit/core/command.py similarity index 97% rename from sploitkit/core/command.py rename to src/sploitkit/core/command.py index c364949..29e934c 100755 --- a/sploitkit/core/command.py +++ b/src/sploitkit/core/command.py @@ -1,322 +1,320 @@ -# -*- coding: UTF-8 -*- -import gc -import re -from inspect import getfullargspec -from tinyscript import logging -from tinyscript.helpers import failsafe, BorderlessTable, Path, PythonPath - -from .components.config import Config -from .components.logger import get_logger -from .entity import Entity, MetaEntity - - -__all__ = ["Command"] - - -COMMAND_STYLES = [ - "lowercase", # ClassName => classname - "none", # ClassName => ClassName - "powershell", # ClassName => Class-Name - "slugified", # ClassName => class-name - "uppercase" # ClassName => CLASSNAME -] -""" -Usage: - >>> from sploitkit import Command - >>> Command.set_style("powershell") -""" -FUNCTIONALITIES = [ - "general", # commands for every level - "utils", # utility commands (for every level) - "recording", # recording commands (for every level) - "root", # base root-level commands - "project", # base project-level commands - "module", # base module-level commands - "session", # base session-level commands -] - - -logger = get_logger("core.command") - - -class MetaCommand(MetaEntity): - """ Metaclass of a Command. """ - _inherit_metadata = True - style = "slugified" - - def __init__(self, *args): - argspec = getfullargspec(self.run) - s, args, defs = "{}", argspec.args[1:], argspec.defaults - for a in args[:len(args)-len(defs or [])]: - s += " " + a - if len(defs or []) > 0: - s += " [" - i = [] - for a, d in zip(args[len(args)-len(defs):], defs): - i.append("{}={}".format(a, d) if d is not None else a) - s += " ".join(i) + "]" - self.signature = s - self.args, self.defaults = args, defs - - def help(self, alias=None): - """ Help message for the command. """ - return self.get_info(("name", "description"), "comments") - - @property - def config(self): - """ Shortcut to bound console's config instance. """ - try: - return self.console.config - except AttributeError: - return Config() - - @property - def name(self): - """ Command name, according to the defined style. """ - n = self.__name__ - if self.style == "lowercase": - n = n.lower() - elif self.style in ["powershell", "slugified"]: - n = re.sub(r'(.)([A-Z][a-z]+)', r'\1-\2', n) - n = re.sub(r'([a-z0-9])([A-Z])', r'\1-\2', n) - n = n.lower() if self.style == "slugified" else n - elif self.style == "uppercase": - n = n.upper() - return n - - -class Command(Entity, metaclass=MetaCommand): - """ Main class handling console commands. """ - # convention: mangled attributes should not be customized when subclassing Command... - _functionalities = FUNCTIONALITIES - _levels = [] - # ... by opposition to public class attributes that can be tuned - aliases = [] - alias_only = False - commands = {} - level = "general" - except_levels = [] - single_arg = False - - @property - def _nargs(self): - """ Get run's signature info (n = number of args, m = number of args with no default). """ - argspec = getfullargspec(self.run) - n = len(argspec.args) - 1 # substract 1 for self - return n, n - len(argspec.defaults or ()) - - @property - def app_folder(self): - """ Shortcut to the current application folder. """ - return self.console.app_folder - - @property - def config(self): - """ Shortcut to bound console's config instance. """ - return self.module.config if hasattr(self, "module") and self.module is not None else \ - self.__class__.console.__class__.config - - @property - def files(self): - """ Shortcut to bound console's file manager instance. """ - return self.console.__class__._files - - @property - def logger(self): - """ Shortcut to bound console's logger instance. """ - return self.console.logger - - @property - @failsafe - def module(self): - """ Shortcut to bound console's module class. """ - return self.console.module - - @property - def modules(self): - """ Shortcut to list of registered modules. """ - return self.console.modules - - @property - def recorder(self): - """ Shortcut to global command recorder. """ - return self.console.__class__._recorder - - @property - def workspace(self): - """ Shortcut to the current workspace. """ - return self.console.workspace - - @classmethod - def check_applicability(cls): - """ Check for Command's applicability. """ - a = getattr(cls, "applies_to", []) - return len(a) == 0 or not hasattr(cls, "console") or cls.console.module.fullpath in a - - @classmethod - def get_help(cls, *levels, **kwargs): - """ Display commands' help(s), using its metaclass' properties. """ - if len(levels) == 0: - levels = Command._levels - if len(levels) == 2 and "general" in levels: - # process a new dictionary of commands, handling levels in order - _ = {} - for l in levels: - for n, c in cls.commands.get(l, {}).items(): - if c.level != "general" or all(l not in levels for l in c.except_levels): - _[n] = c - # then rebuild the dictionary by levels from this dictionary - levels = {"general": {}, "specific": {}} - for n, c in _.items(): - levels[["specific", "general"][c.level == "general"]][n] = c - else: - _, levels = levels, {} - for l in _: - levels[l] = cls.commands[l] - # now make the help with tables of command name-descriptions by level - s, i = "", 0 - for l, cmds in sorted(levels.items(), key=lambda x: x[0]): - if len(cmds) == 0 or l in kwargs.get('except_levels', []): - continue - d = [["Command", "Description"]] - for n, c in sorted(cmds.items(), key=lambda x: x[0]): - if not hasattr(c, "console") or not c.check(): - continue - d.append([n, getattr(c, "description", "")]) - if len(d) > 1: - t = BorderlessTable(d, "{} commands".format(l.capitalize())) - s += t.table + "\n" - i += 1 - return "\n" + s.strip() + "\n" if i > 0 else "" - - @classmethod - def register_command(cls, subcls): - """ Register the command and its aliases in a dictionary according to its level. """ - l = subcls.level - levels = [l] if not isinstance(l, (list, tuple)) else l - for l in levels: - Command.commands.setdefault(l, {}) - if l not in Command._levels: - Command._levels.append(l) - if not subcls.alias_only: - Command.commands[l][subcls.name] = subcls - for alias in subcls.aliases: - Command.commands[l][alias] = subcls - logger.detail("Registered command alias '{}'".format(alias)) - - @classmethod - def set_style(cls, style): - """ Set the style of command name. """ - if style not in COMMAND_STYLES: - raise ValueError("Command style must be one of the followings: [{}]".format("|".join(COMMAND_STYLES))) - MetaCommand.style = style - - @classmethod - def unregister_command(cls, subcls): - """ Unregister a command class from the subclasses and the commands dictionary. """ - _ = subcls.level - levels = [_] if not isinstance(_, (list, tuple)) else _ - n = subcls.name - # remove every reference in commands dictionary - for l in levels: - for n in [n] + subcls.aliases: - del Command.commands[l][n] - # remove the subclass instance from the subclasses registry - try: - Command.subclasses.remove(subcls) - except ValueError: - pass - # remove the subclass from the global namespace (if not Command itself) - if subcls is not Command: - try: - del globals()[subcls.__name__] - except KeyError: - pass # subcls may be a proxy Command-inherited class - # if the level of commands is become empty, remove it - for l in levels: - if len(Command.commands[l]) == 0: - del Command.commands[l] - logger.detail("Unregistered command '{}/{}'".format(l, n)) - - @classmethod - def unregister_commands(cls, *identifiers): - """ Unregister items from Command based on their 'identifiers' (functionality or level/name). """ - for i in identifiers: - _ = i.split("/", 1) - try: - l, n = _ # level, name - except ValueError: - f, n = _[0], None # functionality - # apply deletions - if n is None: - if f not in cls._functionalities: - raise ValueError("Unknown functionality {}".format(f)) - p = Path(__file__).parent.joinpath("../base/commands/" + f + ".py").resolve() - for c in PythonPath(str(p)).get_classes(Command): - Command.unregister_command(c) - else: - try: - c = Command.commands[l][n] - Command.unregister_command(c) - except KeyError: - pass - - def _complete_keys(self, *args, **kwargs): - """ Key completion executed method. """ - self.set_keys(*args, **kwargs) - return self.complete_keys(*args, **kwargs) - - def _complete_values(self, *args, **kwargs): - """ Value completion executed method. """ - self.set_values(*args, **kwargs) - return self.complete_values(*args, **kwargs) - - def _validate(self, *args): - """ Value completion executed method. """ - self.set_keys() - self.set_values(*args[:1]) - self.validate(*args) - - def complete_keys(self): - """ Default key completion method (will be triggered if the number of run arguments is 2). """ - return getattr(self, "keys", []) or list(getattr(self, "values", {}).keys()) - - def complete_values(self, key=None): - """ Default value completion method. """ - if self._nargs[0] == 1: - if key is not None: - raise TypeError("complete_values() takes 1 positional argument but 2 were given") - return getattr(self, "values", []) - if self._nargs[0] == 2: - return getattr(self, "values", {}).get(key) - return [] - - def set_keys(self): - """ Default key setting method. """ - pass - - def set_values(self, key=None): - """ Default value setting method. """ - pass - - def validate(self, *args): - """ Default validation method. """ - # check for the signature and, if relevant, validating keys and values - n_in = len(args) - n, m = self._nargs - if n_in < m or n_in > n: - pargs = "from %d to %d" % (m, n) if n != m else "%d" % n - raise TypeError("validate() takes %s positional argument%s but %d were given" % \ - (pargs, ["", "s"][n > 0], n_in)) - if n == 1: # command format: COMMAND VALUE - l = self.complete_values() or [] - if n_in == 1 and len(l) > 0 and args[0] not in l: - raise ValueError("invalid value") - elif n == 2: # command format: COMMAND KEY VALUE - l = self.complete_keys() or [] - if n_in > 0 and len(l) > 0 and args[0] not in l: - raise ValueError("invalid key") - l = self.complete_values(args[0]) or [] - if n_in == 2 and len(l) > 0 and args[1] not in l: - raise ValueError("invalid value") - +# -*- coding: UTF-8 -*- +import re +from inspect import getfullargspec +from tinyscript.helpers import failsafe, BorderlessTable, Path, PythonPath + +from .components.config import Config +from .components.logger import get_logger +from .entity import Entity, MetaEntity + + +__all__ = ["Command"] + + +COMMAND_STYLES = [ + "lowercase", # ClassName => classname + "none", # ClassName => ClassName + "powershell", # ClassName => Class-Name + "slugified", # ClassName => class-name + "uppercase" # ClassName => CLASSNAME +] +""" +Usage: + >>> from sploitkit import Command + >>> Command.set_style("powershell") +""" +FUNCTIONALITIES = [ + "general", # commands for every level + "utils", # utility commands (for every level) + "recording", # recording commands (for every level) + "root", # base root-level commands + "project", # base project-level commands + "module", # base module-level commands + "session", # base session-level commands +] + + +logger = get_logger("core.command") + + +class MetaCommand(MetaEntity): + """ Metaclass of a Command. """ + _inherit_metadata = True + style = "slugified" + + def __init__(self, *args): + argspec = getfullargspec(self.run) + s, args, defs = "{}", argspec.args[1:], argspec.defaults + for a in args[:len(args)-len(defs or [])]: + s += " " + a + if len(defs or []) > 0: + s += " [" + i = [] + for a, d in zip(args[len(args)-len(defs):], defs): + i.append("{}={}".format(a, d) if d is not None else a) + s += " ".join(i) + "]" + self.signature = s + self.args, self.defaults = args, defs + + def help(self, alias=None): + """ Help message for the command. """ + return self.get_info(("name", "description"), "comments") + + @property + def config(self): + """ Shortcut to bound console's config instance. """ + try: + return self.console.config + except AttributeError: + return Config() + + @property + def name(self): + """ Command name, according to the defined style. """ + n = self.__name__ + if self.style == "lowercase": + n = n.lower() + elif self.style in ["powershell", "slugified"]: + n = re.sub(r'(.)([A-Z][a-z]+)', r'\1-\2', n) + n = re.sub(r'([a-z0-9])([A-Z])', r'\1-\2', n) + n = n.lower() if self.style == "slugified" else n + elif self.style == "uppercase": + n = n.upper() + return n + + +class Command(Entity, metaclass=MetaCommand): + """ Main class handling console commands. """ + # convention: mangled attributes should not be customized when subclassing Command... + _functionalities = FUNCTIONALITIES + _levels = [] + # ... by opposition to public class attributes that can be tuned + aliases = [] + alias_only = False + commands = {} + level = "general" + except_levels = [] + single_arg = False + + @property + def _nargs(self): + """ Get run's signature info (n = number of args, m = number of args with no default). """ + argspec = getfullargspec(self.run) + n = len(argspec.args) - 1 # substract 1 for self + return n, n - len(argspec.defaults or ()) + + @property + def app_folder(self): + """ Shortcut to the current application folder. """ + return self.console.app_folder + + @property + def config(self): + """ Shortcut to bound console's config instance. """ + return self.module.config if hasattr(self, "module") and self.module is not None else \ + self.__class__.console.__class__.config + + @property + def files(self): + """ Shortcut to bound console's file manager instance. """ + return self.console.__class__._files + + @property + def logger(self): + """ Shortcut to bound console's logger instance. """ + return self.console.logger + + @property + @failsafe + def module(self): + """ Shortcut to bound console's module class. """ + return self.console.module + + @property + def modules(self): + """ Shortcut to list of registered modules. """ + return self.console.modules + + @property + def recorder(self): + """ Shortcut to global command recorder. """ + return self.console.__class__._recorder + + @property + def workspace(self): + """ Shortcut to the current workspace. """ + return self.console.workspace + + @classmethod + def check_applicability(cls): + """ Check for Command's applicability. """ + a = getattr(cls, "applies_to", []) + return len(a) == 0 or not hasattr(cls, "console") or cls.console.module.fullpath in a + + @classmethod + def get_help(cls, *levels, **kwargs): + """ Display commands' help(s), using its metaclass' properties. """ + if len(levels) == 0: + levels = Command._levels + if len(levels) == 2 and "general" in levels: + # process a new dictionary of commands, handling levels in order + _ = {} + for l in levels: + for n, c in cls.commands.get(l, {}).items(): + if c.level != "general" or all(l not in levels for l in c.except_levels): + _[n] = c + # then rebuild the dictionary by levels from this dictionary + levels = {"general": {}, "specific": {}} + for n, c in _.items(): + levels[["specific", "general"][c.level == "general"]][n] = c + else: + _, levels = levels, {} + for l in _: + levels[l] = cls.commands[l] + # now make the help with tables of command name-descriptions by level + s, i = "", 0 + for l, cmds in sorted(levels.items(), key=lambda x: x[0]): + if len(cmds) == 0 or l in kwargs.get('except_levels', []): + continue + d = [["Command", "Description"]] + for n, c in sorted(cmds.items(), key=lambda x: x[0]): + if not hasattr(c, "console") or not c.check(): + continue + d.append([n, getattr(c, "description", "")]) + if len(d) > 1: + t = BorderlessTable(d, "{} commands".format(l.capitalize())) + s += t.table + "\n" + i += 1 + return "\n" + s.strip() + "\n" if i > 0 else "" + + @classmethod + def register_command(cls, subcls): + """ Register the command and its aliases in a dictionary according to its level. """ + l = subcls.level + levels = [l] if not isinstance(l, (list, tuple)) else l + for l in levels: + Command.commands.setdefault(l, {}) + if l not in Command._levels: + Command._levels.append(l) + if not subcls.alias_only: + Command.commands[l][subcls.name] = subcls + for alias in subcls.aliases: + Command.commands[l][alias] = subcls + logger.detail("Registered command alias '{}'".format(alias)) + + @classmethod + def set_style(cls, style): + """ Set the style of command name. """ + if style not in COMMAND_STYLES: + raise ValueError("Command style must be one of the followings: [{}]".format("|".join(COMMAND_STYLES))) + MetaCommand.style = style + + @classmethod + def unregister_command(cls, subcls): + """ Unregister a command class from the subclasses and the commands dictionary. """ + _ = subcls.level + levels = [_] if not isinstance(_, (list, tuple)) else _ + n = subcls.name + # remove every reference in commands dictionary + for l in levels: + for n in [n] + subcls.aliases: + del Command.commands[l][n] + # remove the subclass instance from the subclasses registry + try: + Command.subclasses.remove(subcls) + except ValueError: + pass + # remove the subclass from the global namespace (if not Command itself) + if subcls is not Command: + try: + del globals()[subcls.__name__] + except KeyError: + pass # subcls may be a proxy Command-inherited class + # if the level of commands is become empty, remove it + for l in levels: + if len(Command.commands[l]) == 0: + del Command.commands[l] + logger.detail("Unregistered command '{}/{}'".format(l, n)) + + @classmethod + def unregister_commands(cls, *identifiers): + """ Unregister items from Command based on their 'identifiers' (functionality or level/name). """ + for i in identifiers: + _ = i.split("/", 1) + try: + l, n = _ # level, name + except ValueError: + f, n = _[0], None # functionality + # apply deletions + if n is None: + if f not in cls._functionalities: + raise ValueError("Unknown functionality {}".format(f)) + p = Path(__file__).parent.joinpath("../base/commands/" + f + ".py").resolve() + for c in PythonPath(str(p)).get_classes(Command): + Command.unregister_command(c) + else: + try: + c = Command.commands[l][n] + Command.unregister_command(c) + except KeyError: + pass + + def _complete_keys(self, *args, **kwargs): + """ Key completion executed method. """ + self.set_keys(*args, **kwargs) + return self.complete_keys(*args, **kwargs) + + def _complete_values(self, *args, **kwargs): + """ Value completion executed method. """ + self.set_values(*args, **kwargs) + return self.complete_values(*args, **kwargs) + + def _validate(self, *args): + """ Value completion executed method. """ + self.set_keys() + self.set_values(*args[:1]) + self.validate(*args) + + def complete_keys(self): + """ Default key completion method (will be triggered if the number of run arguments is 2). """ + return getattr(self, "keys", []) or list(getattr(self, "values", {}).keys()) + + def complete_values(self, key=None): + """ Default value completion method. """ + if self._nargs[0] == 1: + if key is not None: + raise TypeError("complete_values() takes 1 positional argument but 2 were given") + return getattr(self, "values", []) + if self._nargs[0] == 2: + return getattr(self, "values", {}).get(key) + return [] + + def set_keys(self): + """ Default key setting method. """ + pass + + def set_values(self, key=None): + """ Default value setting method. """ + pass + + def validate(self, *args): + """ Default validation method. """ + # check for the signature and, if relevant, validating keys and values + n_in = len(args) + n, m = self._nargs + if n_in < m or n_in > n: + pargs = "from %d to %d" % (m, n) if n != m else "%d" % n + raise TypeError("validate() takes %s positional argument%s but %d were given" % \ + (pargs, ["", "s"][n > 0], n_in)) + if n == 1: # command format: COMMAND VALUE + l = self.complete_values() or [] + if n_in == 1 and len(l) > 0 and args[0] not in l: + raise ValueError("invalid value") + elif n == 2: # command format: COMMAND KEY VALUE + l = self.complete_keys() or [] + if n_in > 0 and len(l) > 0 and args[0] not in l: + raise ValueError("invalid key") + l = self.complete_values(args[0]) or [] + if n_in == 2 and len(l) > 0 and args[1] not in l: + raise ValueError("invalid value") + diff --git a/sploitkit/core/components/__init__.py b/src/sploitkit/core/components/__init__.py similarity index 95% rename from sploitkit/core/components/__init__.py rename to src/sploitkit/core/components/__init__.py index 07f6c73..454eca2 100755 --- a/sploitkit/core/components/__init__.py +++ b/src/sploitkit/core/components/__init__.py @@ -1,12 +1,12 @@ -from .completer import * -from .config import * -from .defaults import * -from .files import * -from .jobs import * -from .layout import * -from .logger import * -from .recorder import * -from .sessions import * -from .store import * -from .validator import * - +from .completer import * +from .config import * +from .defaults import * +from .files import * +from .jobs import * +from .layout import * +from .logger import * +from .recorder import * +from .sessions import * +from .store import * +from .validator import * + diff --git a/sploitkit/core/components/completer.py b/src/sploitkit/core/components/completer.py similarity index 97% rename from sploitkit/core/components/completer.py rename to src/sploitkit/core/components/completer.py index acf737b..4694d8c 100755 --- a/sploitkit/core/components/completer.py +++ b/src/sploitkit/core/components/completer.py @@ -1,85 +1,84 @@ -# -*- coding: UTF-8 -*- -import shlex -from prompt_toolkit.completion import Completer, Completion - - -__all__ = ["CommandCompleter"] - - -def _filter_sort(lst, prefix=None, sort=False): - if sort: - lst = sorted(map(str, set(lst or [])), key=lambda s: str(s).casefold()) - for x in lst or []: - if prefix is None or x.startswith(prefix): - yield x - - -class CommandCompleter(Completer): - """ Completer for console's commands and arguments. """ - def get_completions(self, document, complete_event): - # this completion method handles the following formats: - # 1) COMMAND VALUE ; e.g. create my-project - # 2) COMMAND KEY VALUE ; e.g. set LHOST 192.168.1.1 - # first, tokenize document.text and initialize some shorcut variables - d = document.text - tokens = self.console._get_tokens(d) - l = len(tokens) - ts = len(d) - len(d.rstrip(" ")) # trailing spaces - try: - cmd, t1, t2 = tokens + [None] * (3 - l) - except: # occurs when l > 3 ; no need to complete anything as it corresponds to an invalid command - return - bc = len(document.text_before_cursor) - it = len(d) - bc > 0 - o1 = len(cmd) + 1 - bc if cmd else 0 - o2 = len(cmd) + len(t1 or "") + 2 - bc if cmd and t2 else 0 - cmds = {k: v for k, v in self.console.commands.items()} - c = cmds[cmd]._instance if cmd in cmds else None - nargs = len(c.args) if c is not None else 0 - # then handle tokens ; - # when no token is provided, just yield the list of available commands - if l == 0: - for x in _filter_sort(cmds.keys(), sort=True): - yield Completion(x, start_position=0) - # when one token is provided, handle format: - # [PARTIAL_]COMMAND ... - elif l == 1: - # when a partial token is provided, yield the list of valid commands - if ts == 0 and c not in cmds: - for x in _filter_sort(cmds, cmd, True): - yield Completion(x, start_position=-bc) - # when a valid command is provided, yield the list of valid keys or values, depending on the type of command - elif ts > 0 and c is not None: - if nargs == 1: # COMMAND VALUE - for x in _filter_sort(c._complete_values(), sort=True): - yield Completion(x, start_position=0) - # e.g. set ---> ["WORKSPACE", ...] - elif nargs == 2: # COMMAND KEY VALUE - for x in _filter_sort(c._complete_keys(), sort=True): - yield Completion(x, start_position=0) - # when two tokens are provided, handle format: - # COMMAND [PARTIAL_](KEY ...|VALUE) - elif l == 2 and c is not None: - # when a partial value token is given, yield the list of valid ones - # e.g. select my-pro ---> ["my-project", ...] - if nargs == 1 and ts == 0: - for x in _filter_sort(c._complete_values(), t1, True): - yield Completion(x, start_position=o1) - # when a partial key token is given, yield the list of valid ones - # e.g. set W ---> ["WORKSPACE"] - elif nargs == 2 and ts == 0: - for x in _filter_sort(c._complete_keys(), t1, True): - yield Completion(x, start_position=o1) - # when a valid key token is given, yield the list of values - # e.g. set WORKSPACE ---> ["/home/user/...", "..."] - elif nargs == 2 and ts > 0 and t1 in c._complete_keys(): - for x in _filter_sort(c._complete_values(t1), sort=True): - yield Completion(x, start_position=0) - # when three tokens are provided, handle format: - # COMMAND KEY [PARTIAL_]VALUE - elif l == 3 and c is not None and t1 in c._complete_keys(): - if nargs == 2 and ts == 0: - for x in _filter_sort(c._complete_values(t1), t2, True): - yield Completion(x, start_position=o2) - # handle no other format - +# -*- coding: UTF-8 -*- +from prompt_toolkit.completion import Completer, Completion + + +__all__ = ["CommandCompleter"] + + +def _filter_sort(lst, prefix=None, sort=False): + if sort: + lst = sorted(map(str, set(lst or [])), key=lambda s: str(s).casefold()) + for x in lst or []: + if prefix is None or x.startswith(prefix): + yield x + + +class CommandCompleter(Completer): + """ Completer for console's commands and arguments. """ + def get_completions(self, document, complete_event): + # this completion method handles the following formats: + # 1) COMMAND VALUE ; e.g. create my-project + # 2) COMMAND KEY VALUE ; e.g. set LHOST 192.168.1.1 + # first, tokenize document.text and initialize some shorcut variables + d = document.text + tokens = self.console._get_tokens(d) + l = len(tokens) + ts = len(d) - len(d.rstrip(" ")) # trailing spaces + try: + cmd, t1, t2 = tokens + [None] * (3 - l) + except: # occurs when l > 3 ; no need to complete anything as it corresponds to an invalid command + return + bc = len(document.text_before_cursor) + it = len(d) - bc > 0 + o1 = len(cmd) + 1 - bc if cmd else 0 + o2 = len(cmd) + len(t1 or "") + 2 - bc if cmd and t2 else 0 + cmds = {k: v for k, v in self.console.commands.items()} + c = cmds[cmd]._instance if cmd in cmds else None + nargs = len(c.args) if c is not None else 0 + # then handle tokens ; + # when no token is provided, just yield the list of available commands + if l == 0: + for x in _filter_sort(cmds.keys(), sort=True): + yield Completion(x, start_position=0) + # when one token is provided, handle format: + # [PARTIAL_]COMMAND ... + elif l == 1: + # when a partial token is provided, yield the list of valid commands + if ts == 0 and c not in cmds: + for x in _filter_sort(cmds, cmd, True): + yield Completion(x, start_position=-bc) + # when a valid command is provided, yield the list of valid keys or values, depending on the type of command + elif ts > 0 and c is not None: + if nargs == 1: # COMMAND VALUE + for x in _filter_sort(c._complete_values(), sort=True): + yield Completion(x, start_position=0) + # e.g. set ---> ["WORKSPACE", ...] + elif nargs == 2: # COMMAND KEY VALUE + for x in _filter_sort(c._complete_keys(), sort=True): + yield Completion(x, start_position=0) + # when two tokens are provided, handle format: + # COMMAND [PARTIAL_](KEY ...|VALUE) + elif l == 2 and c is not None: + # when a partial value token is given, yield the list of valid ones + # e.g. select my-pro ---> ["my-project", ...] + if nargs == 1 and ts == 0: + for x in _filter_sort(c._complete_values(), t1, True): + yield Completion(x, start_position=o1) + # when a partial key token is given, yield the list of valid ones + # e.g. set W ---> ["WORKSPACE"] + elif nargs == 2 and ts == 0: + for x in _filter_sort(c._complete_keys(), t1, True): + yield Completion(x, start_position=o1) + # when a valid key token is given, yield the list of values + # e.g. set WORKSPACE ---> ["/home/user/...", "..."] + elif nargs == 2 and ts > 0 and t1 in c._complete_keys(): + for x in _filter_sort(c._complete_values(t1), sort=True): + yield Completion(x, start_position=0) + # when three tokens are provided, handle format: + # COMMAND KEY [PARTIAL_]VALUE + elif l == 3 and c is not None and t1 in c._complete_keys(): + if nargs == 2 and ts == 0: + for x in _filter_sort(c._complete_values(t1), t2, True): + yield Completion(x, start_position=o2) + # handle no other format + diff --git a/sploitkit/core/components/config.py b/src/sploitkit/core/components/config.py similarity index 97% rename from sploitkit/core/components/config.py rename to src/sploitkit/core/components/config.py index e0b8377..37c97d1 100755 --- a/sploitkit/core/components/config.py +++ b/src/sploitkit/core/components/config.py @@ -1,528 +1,528 @@ -# -*- coding: UTF-8 -*- -import re -from itertools import chain -from termcolor import colored -from tinyscript.helpers import is_function, BorderlessTable, Path - -from .logger import * - - -__all__ = ["Config", "Option", "ProxyConfig", "ROption"] - - -logger = get_logger("core.components.config") - - -class Config(dict): - """ Enhanced dictionary for handling Option instances as its keys. """ - def __init__(self, *args, **kwargs): - self._d = {} - if not hasattr(Config, "_g"): - Config._g = {} - # this will set options for this config, that is, creating NEW Option instances based on the given ones - self.update(*args, **kwargs) - - def __add__(self, config): - """ Method for appending another config. """ - return ProxyConfig() + self + config - - def __delitem__(self, key): - """ Custom method for deleting an item, for triggering an unset callback from an Option. """ - try: - l = self.console.logger - except: - l = null_logger - key = self._getkey(key) - self[key] = getattr(self, "default", None) - self.__run_callback(key, "unset") - if key._reset: - try: - self.console.reset() - except AttributeError as err: - pass - l.debug("{} => null".format(key.name)) - - def __getitem__(self, key): - """ Custom method for getting an item, returning the original value from the current Config instance or, if the - key does not exist and this instance has a parent, try to get it from the parent. """ - try: # search first in the private dictionary - return self._getitem(key) - except KeyError: - pass - try: # then search in the parent ProxyConfig - return self.parent[key] - except (AttributeError, KeyError): - pass - try: # finally search in the config of the parent console - return self.console.parent.config[key] - except (AttributeError, KeyError): - pass - raise KeyError(key) - - def __setitem__(self, key, value): - """ Custom method for setting an item, keeping the original value in a private dictionary. """ - try: - l = self.console.logger - except AttributeError: - l = null_logger - if isinstance(key, (list, tuple)) and len(key) == 2 and isinstance(key[0], str) and isinstance(key[1], bool): - key, force = key - else: - force = False - key = self._setkey(key, value) - if not force and key.old_value == key.value: - try: - l.debug("{} unchanged".format(key.name)) - except AttributeError: - pass - return # stop here if the final value is unchanged - # when the value is validated and assigned, run the callback function - self.__run_callback(key, "set") - if key._reset: - try: - self.console.reset() - except AttributeError as err: - pass - l.success("{} => {}".format(key.name, value if force else key.value)) - - def __str__(self): - """ Custom string method. """ - data = [["Name", "Value", "Required", "Description"]] - l = len(list(self.items(False))) - for n, d, v, r in sorted(self.items(False), key=lambda x: x[0]): - if v is None and l > 1: - continue - r = ["N", "Y"][r] - if v == "": - n, v, r = map(lambda s: colored(s, "red", attrs=['bold']), [n, v, r]) - data.append([n, v, r, d]) - if len(data) > 1: - try: - prefix = self.console.opt_prefix - except AttributeError: - prefix = None - return BorderlessTable(data).table if prefix is None else \ - BorderlessTable(data, "%s options" % prefix).table - return "" - - def __run_callback(self, key, name): - """ Method for executing a callback and updating the current value with its return value if any. """ - logger.detail("{} {} callback triggered".format(key, name)) - retval = None - if hasattr(self, "_last_error"): - del self._last_error - try: - retval = getattr(key, "{}_callback".format(name))() - except Exception as e: - self._last_error = e - if True:#not isinstance(e, AttributeError): - raise - if retval is not None: - key.old_value = key.value - if not key.validate(retval): - raise ValueError("Invalid value '{}'".format(retval)) - self._d[key.name] = (key, retval) - - def _getitem(self, key): - """ Custom method for getting an item, returning the original value from the current Config instance. """ - return self._d[key.name if isinstance(key, Option) else key][1] - - def _getkey(self, key): - """ Proxy method for ensuring that the key is an Option instance. """ - if not isinstance(key, Option): - try: - key = self.option(key) - except KeyError: - if not isinstance(key, tuple): - key = (key,) - key = Option(*key) - return key - - def _getoption(self, key): - """ Return Option instance from key. """ - return self._d[key.name if isinstance(key, Option) else key][0] - - def _setkey(self, key, value): - """ Proxy method for setting a key-value as a validated Option instance. """ - key = tmp = self._getkey(key) - # get an existing instance or the new one - key = key.bind(self if not hasattr(key, "config") else key.config) - if tmp is not key: - del tmp # if an instance already existed, remove the new one - key.config._setkey(key, value) - return key - # keep track of the previous value - try: - key.old_value = key.value - except (KeyError, ValueError): - key.old_value = None - # then assign the new one if it is valid - self._d[key.name] = (key, value) - if value is not None and not key.validate(value): - raise ValueError("Invalid value '{}' for key '{}'".format(value, key.name)) - super(Config, self).__setitem__(key, value) - return key - - def copy(self, config, key): - """ Copy an option based on its key from another Config instance. """ - self[config.option(key)] = config[key] - - def items(self, fail=True): - """ Return (key, descr, value, required) instead of (key, value). """ - for o in sorted(self, key=lambda x: x.name): - try: - n = str(o.name) - v = o.value - except ValueError as e: - if fail: - raise e - v = "" - yield n, o.description or "", v, o.required - - def keys(self, glob=False): - """ Return string keys (like original dict). """ - l = [k for k in self._d.keys()] - if glob: - for k in chain(self._d.keys(), Config._g.keys()): - try: - getattr(l, ["remove", "append"][self.option(k).glob])(k) - except KeyError: - pass - for k in sorted(l): - yield k - - def option(self, key): - """ Return Option instance from key, also searching for this in parent configs. """ - try: # search first in the private dictionary - return self._getoption(key) - except KeyError: - pass - try: # then search in the parent ProxyConfig - return self.parent.option(key) - except (AttributeError, KeyError): - pass - try: # finally search in the config of the parent console - return self.console.parent.config.option(key) - except (AttributeError, KeyError): - pass - raise KeyError(key) - - def options(self): - """ Return Option instances instead of keys. """ - for k in sorted(self._d.keys()): - yield self._d[k][0] - - def setdefault(self, key, value=None): - """ Custom method for forcing the use of the modified __setitem__. """ - if key not in self: - self._setkey(key, value) # this avoids triggering callbacks (on the contrary of self[key]) ! - return self[key] - - def setglobal(self, key, value): - """ Set a global key-value. """ - Config._g[key] = value - - def unsetglobal(self, key): - """ Unset a global key. """ - del Config._g[key] - - def update(self, *args, **kwargs): - """ Custom method for handling update of another Config and forcing the use of the modified __setitem__. """ - if len(args) > 0: - if len(args) > 1: - raise TypeError("update expected at most 1 argument, got %d" % len(args)) - d = args[0] - for k in (d.options() if isinstance(d, Config) else d.keys() if isinstance(d, dict) else []): - k = self._setkey(k, d[k]) # this avoids triggering callbacks (on the contrary of self[key]) ! - k.default = d[k] - # important note: this way, this will cause Option instances to be bound to THIS Config instance, with their - # default attribute values (description, required, ...) - for k, v in kwargs.items(): - k, self._setkey(k, v) # this avoids triggering callbacks (on the contrary of self[key]) ! - k.default = v - - @property - def bound(self): - return hasattr(self, "_console") or (hasattr(self, "module") and hasattr(self.module, "console")) - - @property - def console(self): - # check first that the console is back-referenced on an attached module instance - if hasattr(self, "module") and hasattr(self.module, "console"): - return self.module.console - # then check for a direct reference - if self.bound: - c = self._console - return c() if isinstance(c, type(lambda:0)) else c - # finally try to get it from the parent ProxyConfig - if hasattr(self, "parent"): - # reference the callee to let ProxyConfig.__getattribute__ avoid trying to get the console attribute from - # the current config object, ending in an infinite loop - self.parent._caller = self - try: - return self.parent.console - except AttributeError: - pass - raise AttributeError("'Config' object has no attribute 'console'") - - @console.setter - def console(self, value): - self._console = value - - -class Option(object): - """ Class for handling an option with its parameters while using it as key for a Config dictionary. """ - _instances = {} - _reset = False - old_value = None - - def __init__(self, name, description=None, required=False, choices=None, suggestions=None, set_callback=None, - unset_callback=None, transform=None, validate=None, glob=True): - if choices is not None and suggestions is not None: - raise ValueError("choices and suggestions cannot be set at the same time") - self.name = name - self.description = description - self.required = required - self.glob = glob - if choices is bool or suggestions is bool: - choices = ["true", "false"] - self._choices = choices if choices is not None else suggestions - self.__set_func(transform, "transform") - if validate is None and choices is not None: - validate = lambda s, v: str(v).lower() in [str(c).lower() for c in s.choices] - self.__set_func(validate, "validate") - self.__set_func(set_callback, "set_callback", lambda *a, **kw: None) - self.__set_func(unset_callback, "unset_callback", lambda *a, **kw: None) - - def __repr__(self): - """ Custom representation method. """ - return str(self) - - def __str__(self): - """ Custom string method. """ - return "<{}[{}]>".format(self.name, ["N", "Y"][self.required]) - - def __set_func(self, func, name, default_func=None): - """ Set a function, e.g. for manipulating option's value. """ - if func is None: - func = default_func or (lambda *a, **kw: a[-1] if len(a) > 0 else None) - if isinstance(func, type(lambda:0)): - setattr(self, name, func.__get__(self, self.__class__)) - else: - raise Exception("Bad {} lambda".format(name)) - - def bind(self, parent): - """ Register this instance as a key of the given Config or retrieve the already existing one. """ - o, i = Option._instances, id(parent) - o.setdefault(i, {}) - if o[i].get(self.name) is None: - self.config = parent - o[i][self.name] = self - else: - o[i][self.name].config = parent - return o[i][self.name] - - @property - def choices(self): - """ Pre- or lazy-computed list of choices. """ - c = self._choices - if not is_function(c): - return c - try: - return c() - except TypeError: - return c(self) - - @property - def console(self): - """ Shortcut to parent config's console attribute. """ - return self.config.console - - @property - def input(self): - """ Original input value. """ - if hasattr(self, "config"): - return self.config[self] - else: - raise Exception("Unbound option {}".format(self.name)) - - @property - def module(self): - """ Shortcut to parent config's console bound module attribute. """ - return self.console.module - - @property - def root(self): - """ Shortcut to parent config's root console attribute. """ - return self.console.root or self.console - - @property - def state(self): - """ Shortcut to parent console's state attribute. """ - return self.console.state - - @property - def value(self): - """ Normalized value attribute. """ - value = self.input - if value == getattr(self, "default", None): - value = Config._g.get(self.name, value) - if self.required and value is None: - raise ValueError("{} must be defined".format(self.name)) - try: # try to expand format variables using console's attributes - kw = {} - for n in re.findall(r'\{([a-z]+)\}', str(value)): - kw[n] = self.config.console.__dict__.get(n, "") - try: - value = value.format(**kw) - except: - pass - except AttributeError as e: # occurs when console is not linked to config (i.e. at startup) - pass - # expand and resolve paths - if self.name.endswith("FOLDER") or self.name.endswith("WORKSPACE"): - # this will ensure that every path is expanded - value = str(Path(value, expand=True)) - # convert common formats to their basic types - try: - if value.isdigit(): - value = int(value) - if value.lower() in ["false", "true"]: - value = value.lower() == "true" - except AttributeError: # occurs e.g. if value is already a bool - pass - # then try to transform using the user-defined function - if isinstance(self.transform, type(lambda:0)) and self.transform.__name__ == (lambda:0).__name__: - value = self.transform(value) - return value - - -class ProxyConfig(object): - """ Proxy class for mixing multiple Config instances, keeping original references to Option instances (as they are - managed based on Config's instance identifier). """ - def __init__(self, *args): - self.__configs = [] - for config in args: - self.append(config) - - def __add__(self, config): - """ Method for appending another config. """ - self.append(config) - return self - - def __delitem__(self, key): - """ Del method removing the giving key in every bound config instance. """ - for c in self.configs: - del c[key] - - def __getattribute__(self, name): - """ Custom getattribute method for aggregating Config instances for some specific methods and attributes. """ - # try to get it from this class first - try: - return super(ProxyConfig, self).__getattribute__(name) - except AttributeError: - pass - # for these methods, create an aggregated config and get its attribute - # from this new instance - if name in ["items", "keys", "options"]: - try: - c = Config() - for config in self.__configs: - c.update(config) - except IndexError: - c = Config() - return c.__getattribute__(name) - # for this attribute, only try to get this of the first config - if name == "console": - c = self.__configs[0] - if c is not getattr(self, "_caller", None): - if c.bound: - return c.console - # for any other, get the first one found from the list of configs - else: - for c in self.__configs: - if name != "_caller" and c is getattr(self, "_caller", None): - continue - try: - return c.__getattribute__(name) - except AttributeError: - continue - raise AttributeError("'ProxyConfig' object has no attribute '{}'".format(name)) - - def __getitem__(self, key): - """ Get method for returning the first occurrence of a key among the list of Config instances. """ - # search for the first config that has this key and return the value - for c in self.configs: - try: - return c._getitem(key) - except KeyError: - pass - # if not found, raise KeyError - raise KeyError(key) - - def __setattr__(self, name, value): - """ Custom setattr method for handling the backref to a console. """ - if name == "console": - if len(self.configs) > 0: - self.configs[0].console = value - else: - super(ProxyConfig, self).__setattr__(name, value) - - def __setitem__(self, key, value): - """ Set method setting a key-value pair in the right Config among the list of Config instances. First, it tries - to get the option corresponding to the given key and if it exists, it sets the value. Otherwise, it sets a - new key in the first Config among the list """ - try: - c = self.option(key).config - except KeyError: - c = self.configs[0] if len(self.configs) > 0 else Config() - return c.__setitem__(key, value) - - def __str__(self): - """ String method for aggregating the list of Config instances. """ - c = Config() - for config in self.configs: - if not hasattr(c, "console"): - try: - c.console = config.console - except AttributeError: - pass - c.update(config) - return str(c) - - def append(self, config): - """ Method for apending a config to the list (if it does not exist). """ - for c in ([config] if isinstance(config, Config) else config.configs): - if c not in self.configs: - self.configs.append(c) - c.parent = self - - def get(self, key, default=None): - """ Adapted get method (wrt Config). """ - try: - return self[key] - except KeyError: - return default - - def option(self, key): - """ Adapted option method (wrt Config). """ - # search for the first config that has this key and return its Option - for c in self.configs: - try: - self[key] - return c._getoption(key) - except KeyError: - pass - # if not found, raise KeyError - raise KeyError(key) - - @property - def configs(self): - return self.__configs - - -class ROption(Option): - """ Class for handling a reset option (that is, an option that triggers a console reset after change) with its - parameters while using it as key for a Config dictionary. """ - _reset = True - +# -*- coding: UTF-8 -*- +import re +from itertools import chain +from termcolor import colored +from tinyscript.helpers import is_function, BorderlessTable, Path + +from .logger import * + + +__all__ = ["Config", "Option", "ProxyConfig", "ROption"] + + +logger = get_logger("core.components.config") + + +class Config(dict): + """ Enhanced dictionary for handling Option instances as its keys. """ + def __init__(self, *args, **kwargs): + self._d = {} + if not hasattr(Config, "_g"): + Config._g = {} + # this will set options for this config, that is, creating NEW Option instances based on the given ones + self.update(*args, **kwargs) + + def __add__(self, config): + """ Method for appending another config. """ + return ProxyConfig() + self + config + + def __delitem__(self, key): + """ Custom method for deleting an item, for triggering an unset callback from an Option. """ + try: + l = self.console.logger + except: + l = null_logger + key = self._getkey(key) + self[key] = getattr(self, "default", None) + self.__run_callback(key, "unset") + if key._reset: + try: + self.console.reset() + except AttributeError as err: + pass + l.debug("{} => null".format(key.name)) + + def __getitem__(self, key): + """ Custom method for getting an item, returning the original value from the current Config instance or, if the + key does not exist and this instance has a parent, try to get it from the parent. """ + try: # search first in the private dictionary + return self._getitem(key) + except KeyError: + pass + try: # then search in the parent ProxyConfig + return self.parent[key] + except (AttributeError, KeyError): + pass + try: # finally search in the config of the parent console + return self.console.parent.config[key] + except (AttributeError, KeyError): + pass + raise KeyError(key) + + def __setitem__(self, key, value): + """ Custom method for setting an item, keeping the original value in a private dictionary. """ + try: + l = self.console.logger + except AttributeError: + l = null_logger + if isinstance(key, (list, tuple)) and len(key) == 2 and isinstance(key[0], str) and isinstance(key[1], bool): + key, force = key + else: + force = False + key = self._setkey(key, value) + if not force and key.old_value == key.value: + try: + l.debug("{} unchanged".format(key.name)) + except AttributeError: + pass + return # stop here if the final value is unchanged + # when the value is validated and assigned, run the callback function + self.__run_callback(key, "set") + if key._reset: + try: + self.console.reset() + except AttributeError as err: + pass + l.success("{} => {}".format(key.name, value if force else key.value)) + + def __str__(self): + """ Custom string method. """ + data = [["Name", "Value", "Required", "Description"]] + l = len(list(self.items(False))) + for n, d, v, r in sorted(self.items(False), key=lambda x: x[0]): + if v is None and l > 1: + continue + r = ["N", "Y"][r] + if v == "": + n, v, r = map(lambda s: colored(s, "red", attrs=['bold']), [n, v, r]) + data.append([n, v, r, d]) + if len(data) > 1: + try: + prefix = self.console.opt_prefix + except AttributeError: + prefix = None + return BorderlessTable(data).table if prefix is None else \ + BorderlessTable(data, "%s options" % prefix).table + return "" + + def __run_callback(self, key, name): + """ Method for executing a callback and updating the current value with its return value if any. """ + logger.detail("{} {} callback triggered".format(key, name)) + retval = None + if hasattr(self, "_last_error"): + del self._last_error + try: + retval = getattr(key, "{}_callback".format(name))() + except Exception as e: + self._last_error = e + if True:#not isinstance(e, AttributeError): + raise + if retval is not None: + key.old_value = key.value + if not key.validate(retval): + raise ValueError("Invalid value '{}'".format(retval)) + self._d[key.name] = (key, retval) + + def _getitem(self, key): + """ Custom method for getting an item, returning the original value from the current Config instance. """ + return self._d[key.name if isinstance(key, Option) else key][1] + + def _getkey(self, key): + """ Proxy method for ensuring that the key is an Option instance. """ + if not isinstance(key, Option): + try: + key = self.option(key) + except KeyError: + if not isinstance(key, tuple): + key = (key,) + key = Option(*key) + return key + + def _getoption(self, key): + """ Return Option instance from key. """ + return self._d[key.name if isinstance(key, Option) else key][0] + + def _setkey(self, key, value): + """ Proxy method for setting a key-value as a validated Option instance. """ + key = tmp = self._getkey(key) + # get an existing instance or the new one + key = key.bind(self if not hasattr(key, "config") else key.config) + if tmp is not key: + del tmp # if an instance already existed, remove the new one + key.config._setkey(key, value) + return key + # keep track of the previous value + try: + key.old_value = key.value + except (KeyError, ValueError): + key.old_value = None + # then assign the new one if it is valid + self._d[key.name] = (key, value) + if value is not None and not key.validate(value): + raise ValueError("Invalid value '{}' for key '{}'".format(value, key.name)) + super(Config, self).__setitem__(key, value) + return key + + def copy(self, config, key): + """ Copy an option based on its key from another Config instance. """ + self[config.option(key)] = config[key] + + def items(self, fail=True): + """ Return (key, descr, value, required) instead of (key, value). """ + for o in sorted(self, key=lambda x: x.name): + try: + n = str(o.name) + v = o.value + except ValueError as e: + if fail: + raise e + v = "" + yield n, o.description or "", v, o.required + + def keys(self, glob=False): + """ Return string keys (like original dict). """ + l = [k for k in self._d.keys()] + if glob: + for k in chain(self._d.keys(), Config._g.keys()): + try: + getattr(l, ["remove", "append"][self.option(k).glob])(k) + except KeyError: + pass + for k in sorted(l): + yield k + + def option(self, key): + """ Return Option instance from key, also searching for this in parent configs. """ + try: # search first in the private dictionary + return self._getoption(key) + except KeyError: + pass + try: # then search in the parent ProxyConfig + return self.parent.option(key) + except (AttributeError, KeyError): + pass + try: # finally search in the config of the parent console + return self.console.parent.config.option(key) + except (AttributeError, KeyError): + pass + raise KeyError(key) + + def options(self): + """ Return Option instances instead of keys. """ + for k in sorted(self._d.keys()): + yield self._d[k][0] + + def setdefault(self, key, value=None): + """ Custom method for forcing the use of the modified __setitem__. """ + if key not in self: + self._setkey(key, value) # this avoids triggering callbacks (on the contrary of self[key]) ! + return self[key] + + def setglobal(self, key, value): + """ Set a global key-value. """ + Config._g[key] = value + + def unsetglobal(self, key): + """ Unset a global key. """ + del Config._g[key] + + def update(self, *args, **kwargs): + """ Custom method for handling update of another Config and forcing the use of the modified __setitem__. """ + if len(args) > 0: + if len(args) > 1: + raise TypeError("update expected at most 1 argument, got %d" % len(args)) + d = args[0] + for k in (d.options() if isinstance(d, Config) else d.keys() if isinstance(d, dict) else []): + k = self._setkey(k, d[k]) # this avoids triggering callbacks (on the contrary of self[key]) ! + k.default = d[k] + # important note: this way, this will cause Option instances to be bound to THIS Config instance, with their + # default attribute values (description, required, ...) + for k, v in kwargs.items(): + k, self._setkey(k, v) # this avoids triggering callbacks (on the contrary of self[key]) ! + k.default = v + + @property + def bound(self): + return hasattr(self, "_console") or (hasattr(self, "module") and hasattr(self.module, "console")) + + @property + def console(self): + # check first that the console is back-referenced on an attached module instance + if hasattr(self, "module") and hasattr(self.module, "console"): + return self.module.console + # then check for a direct reference + if self.bound: + c = self._console + return c() if isinstance(c, type(lambda:0)) else c + # finally try to get it from the parent ProxyConfig + if hasattr(self, "parent"): + # reference the callee to let ProxyConfig.__getattribute__ avoid trying to get the console attribute from + # the current config object, ending in an infinite loop + self.parent._caller = self + try: + return self.parent.console + except AttributeError: + pass + raise AttributeError("'Config' object has no attribute 'console'") + + @console.setter + def console(self, value): + self._console = value + + +class Option(object): + """ Class for handling an option with its parameters while using it as key for a Config dictionary. """ + _instances = {} + _reset = False + old_value = None + + def __init__(self, name, description=None, required=False, choices=None, suggestions=None, set_callback=None, + unset_callback=None, transform=None, validate=None, glob=True): + if choices is not None and suggestions is not None: + raise ValueError("choices and suggestions cannot be set at the same time") + self.name = name + self.description = description + self.required = required + self.glob = glob + if choices is bool or suggestions is bool: + choices = ["true", "false"] + self._choices = choices if choices is not None else suggestions + self.__set_func(transform, "transform") + if validate is None and choices is not None: + validate = lambda s, v: str(v).lower() in [str(c).lower() for c in s.choices] + self.__set_func(validate, "validate") + self.__set_func(set_callback, "set_callback", lambda *a, **kw: None) + self.__set_func(unset_callback, "unset_callback", lambda *a, **kw: None) + + def __repr__(self): + """ Custom representation method. """ + return str(self) + + def __str__(self): + """ Custom string method. """ + return "<{}[{}]>".format(self.name, ["N", "Y"][self.required]) + + def __set_func(self, func, name, default_func=None): + """ Set a function, e.g. for manipulating option's value. """ + if func is None: + func = default_func or (lambda *a, **kw: a[-1] if len(a) > 0 else None) + if isinstance(func, type(lambda:0)): + setattr(self, name, func.__get__(self, self.__class__)) + else: + raise Exception("Bad {} lambda".format(name)) + + def bind(self, parent): + """ Register this instance as a key of the given Config or retrieve the already existing one. """ + o, i = Option._instances, id(parent) + o.setdefault(i, {}) + if o[i].get(self.name) is None: + self.config = parent + o[i][self.name] = self + else: + o[i][self.name].config = parent + return o[i][self.name] + + @property + def choices(self): + """ Pre- or lazy-computed list of choices. """ + c = self._choices + if not is_function(c): + return c + try: + return c() + except TypeError: + return c(self) + + @property + def console(self): + """ Shortcut to parent config's console attribute. """ + return self.config.console + + @property + def input(self): + """ Original input value. """ + if hasattr(self, "config"): + return self.config[self] + else: + raise Exception("Unbound option {}".format(self.name)) + + @property + def module(self): + """ Shortcut to parent config's console bound module attribute. """ + return self.console.module + + @property + def root(self): + """ Shortcut to parent config's root console attribute. """ + return self.console.root or self.console + + @property + def state(self): + """ Shortcut to parent console's state attribute. """ + return self.console.state + + @property + def value(self): + """ Normalized value attribute. """ + value = self.input + if value == getattr(self, "default", None): + value = Config._g.get(self.name, value) + if self.required and value is None: + raise ValueError("{} must be defined".format(self.name)) + try: # try to expand format variables using console's attributes + kw = {} + for n in re.findall(r'\{([a-z]+)\}', str(value)): + kw[n] = self.config.console.__dict__.get(n, "") + try: + value = value.format(**kw) + except: + pass + except AttributeError as e: # occurs when console is not linked to config (i.e. at startup) + pass + # expand and resolve paths + if self.name.endswith("FOLDER") or self.name.endswith("WORKSPACE"): + # this will ensure that every path is expanded + value = str(Path(value, expand=True)) + # convert common formats to their basic types + try: + if value.isdigit(): + value = int(value) + if value.lower() in ["false", "true"]: + value = value.lower() == "true" + except AttributeError: # occurs e.g. if value is already a bool + pass + # then try to transform using the user-defined function + if isinstance(self.transform, type(lambda:0)) and self.transform.__name__ == (lambda:0).__name__: + value = self.transform(value) + return value + + +class ProxyConfig(object): + """ Proxy class for mixing multiple Config instances, keeping original references to Option instances (as they are + managed based on Config's instance identifier). """ + def __init__(self, *args): + self.__configs = [] + for config in args: + self.append(config) + + def __add__(self, config): + """ Method for appending another config. """ + self.append(config) + return self + + def __delitem__(self, key): + """ Del method removing the giving key in every bound config instance. """ + for c in self.configs: + del c[key] + + def __getattribute__(self, name): + """ Custom getattribute method for aggregating Config instances for some specific methods and attributes. """ + # try to get it from this class first + try: + return super(ProxyConfig, self).__getattribute__(name) + except AttributeError: + pass + # for these methods, create an aggregated config and get its attribute + # from this new instance + if name in ["items", "keys", "options"]: + try: + c = Config() + for config in self.__configs: + c.update(config) + except IndexError: + c = Config() + return c.__getattribute__(name) + # for this attribute, only try to get this of the first config + if name == "console": + c = self.__configs[0] + if c is not getattr(self, "_caller", None): + if c.bound: + return c.console + # for any other, get the first one found from the list of configs + else: + for c in self.__configs: + if name != "_caller" and c is getattr(self, "_caller", None): + continue + try: + return c.__getattribute__(name) + except AttributeError: + continue + raise AttributeError("'ProxyConfig' object has no attribute '{}'".format(name)) + + def __getitem__(self, key): + """ Get method for returning the first occurrence of a key among the list of Config instances. """ + # search for the first config that has this key and return the value + for c in self.configs: + try: + return c._getitem(key) + except KeyError: + pass + # if not found, raise KeyError + raise KeyError(key) + + def __setattr__(self, name, value): + """ Custom setattr method for handling the backref to a console. """ + if name == "console": + if len(self.configs) > 0: + self.configs[0].console = value + else: + super(ProxyConfig, self).__setattr__(name, value) + + def __setitem__(self, key, value): + """ Set method setting a key-value pair in the right Config among the list of Config instances. First, it tries + to get the option corresponding to the given key and if it exists, it sets the value. Otherwise, it sets a + new key in the first Config among the list """ + try: + c = self.option(key).config + except KeyError: + c = self.configs[0] if len(self.configs) > 0 else Config() + return c.__setitem__(key, value) + + def __str__(self): + """ String method for aggregating the list of Config instances. """ + c = Config() + for config in self.configs: + if not hasattr(c, "console"): + try: + c.console = config.console + except AttributeError: + pass + c.update(config) + return str(c) + + def append(self, config): + """ Method for apending a config to the list (if it does not exist). """ + for c in ([config] if isinstance(config, Config) else config.configs): + if c not in self.configs: + self.configs.append(c) + c.parent = self + + def get(self, key, default=None): + """ Adapted get method (wrt Config). """ + try: + return self[key] + except KeyError: + return default + + def option(self, key): + """ Adapted option method (wrt Config). """ + # search for the first config that has this key and return its Option + for c in self.configs: + try: + self[key] + return c._getoption(key) + except KeyError: + pass + # if not found, raise KeyError + raise KeyError(key) + + @property + def configs(self): + return self.__configs + + +class ROption(Option): + """ Class for handling a reset option (that is, an option that triggers a console reset after change) with its + parameters while using it as key for a Config dictionary. """ + _reset = True + diff --git a/sploitkit/core/components/defaults.py b/src/sploitkit/core/components/defaults.py similarity index 95% rename from sploitkit/core/components/defaults.py rename to src/sploitkit/core/components/defaults.py index 85db6ee..6ab6627 100755 --- a/sploitkit/core/components/defaults.py +++ b/src/sploitkit/core/components/defaults.py @@ -1,32 +1,32 @@ -# -*- coding: UTF-8 -*- - -ROOT_LEVEL = "root" # console root level's name - - -# list of folders from which related items are to be loaded -SOURCES = { - 'banners': None, - 'entities': ["commands", "models", "modules"], - 'libraries': ".", -} - - -# dictionary of back-references to be made on entities -BACK_REFERENCES = { - 'console': [("config", "console")], -} - - -# prompt message format -PROMPT_FORMAT = [ - ('class:prompt', " > "), -] - - -# prompt message style -PROMPT_STYLE = { - '': "#30b06f", # text after the prompt - 'prompt': "#eeeeee", # prompt message - 'appname': "#eeeeee underline", # application name -} - +# -*- coding: UTF-8 -*- + +ROOT_LEVEL = "root" # console root level's name + + +# list of folders from which related items are to be loaded +SOURCES = { + 'banners': None, + 'entities': ["commands", "models", "modules"], + 'libraries': ".", +} + + +# dictionary of back-references to be made on entities +BACK_REFERENCES = { + 'console': [("config", "console")], +} + + +# prompt message format +PROMPT_FORMAT = [ + ('class:prompt', " > "), +] + + +# prompt message style +PROMPT_STYLE = { + '': "#30b06f", # text after the prompt + 'prompt': "#eeeeee", # prompt message + 'appname': "#eeeeee underline", # application name +} + diff --git a/sploitkit/core/components/files.py b/src/sploitkit/core/components/files.py similarity index 97% rename from sploitkit/core/components/files.py rename to src/sploitkit/core/components/files.py index c26a0fc..96bca9b 100755 --- a/sploitkit/core/components/files.py +++ b/src/sploitkit/core/components/files.py @@ -1,126 +1,126 @@ -# -*- coding: UTF-8 -*- -import re -import requests -from ftplib import FTP, FTP_TLS -from shutil import which -from subprocess import call, PIPE -from tinyscript.helpers import b, ensure_str, txt_terminal_render, Path, TempPath - - -__all__ = ["FilesManager"] - - -class FilesManager(dict): - """ Files dictionary for registering files, if necessary downloading them using multiple supported schemes. """ - root_dir = "." - - def _file(self, locator, *args, **kwargs): - """ Simple local file copier. """ - self[locator] = open(locator.split("://", 1)[1], 'rb') - - def _ftp(self, locator, *args, **kwargs): - """ Simple FTP downloader. """ - scheme = locator.split("://", 1)[0] - client = [FTP, FTP_TLS][scheme == "ftps"]() - client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) - if scheme == "ftps": - client.auth() - usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") - if usr != "" and pswd != "": - client.login(usr, passwd) - #client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) - #FIXME - _ftps = _ftp - - def _http(self, url, *args, **kwargs): - """ Simple HTTP downloader. """ - self[url] = requests.get(url, *args, **kwargs).content - _https = _http - - def edit(self, filename): - """ Edit a file using the configured text editor. """ - #FIXME: edit by calling the locator and manage its local file (e.g. for a URL, point to a temp folder) - ted = self.console.config['TEXT_EDITOR'] - if which(ted) is None: - raise ValueError("'%s' does not exist or is not installed" % ted) - p = Path(self.console.config['WORKSPACE']).joinpath(filename) - if not p.exists(): - p.touch() - call([ted, str(p)], stderr=PIPE) - - def get(self, locator, *args, **kwargs): - """ Get a resource. """ - if locator in self.keys() and not kwargs.pop("force", False): - return self[locator] - scheme, path = locator.split("://") - if scheme in ["http", "https"]: - r = requests.get(locator, *args, **kwargs) - self[locator] = r.content - if r.status_code == 403: - raise ValueError("Forbidden") - elif scheme in ["ftp", "ftps"]: - client = [FTP, FTP_TLS][schem == "ftps"]() - client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) - if scheme == "ftps": - client.auth() - usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") - if usr != "" and pswd != "": - client.login(usr, passwd) - client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) - #FIXME - elif scheme == "file": - with open(path, 'rb') as f: - self[locator] = f.read() - else: - raise ValueError("Unsupported scheme '{}'".format(scheme)) - - def page(self, *filenames): - """ Page a list of files using Less. """ - tvw = self.console.config['TEXT_VIEWER'] - if which(tvw) is None: - raise ValueError("'%s' does not exist or is not installed" % tvw) - filenames = list(map(str, filenames)) - for f in filenames: - if not Path(str(f)).is_file(): - raise OSError("File does not exist") - call([tvw] + filenames, stderr=PIPE) - - def page_text(self, text): - """ Page a text using Less. """ - tmp = self.tempdir.tempfile() - tmp.write_text(text) - self.page(str(tmp)) - - def save(self, key, dst): - """ Save a resource. """ - with open(dst, 'wb') as f: - f.write(self[key]) - - def view(self, key): - """ View a file using the configured text viewer. """ - try: - self.page_text(self[key]) - except KeyError: - pass - p = Path(self.console.config['WORKSPACE'], expand=True).joinpath(key) - if p.suffix == ".md": - self.page_text(txt_terminal_render(p.text, format="md").strip()) - else: - # if the given key is not in the dictionary of files (APP_FOLDER/files/), it can still be in the workspace - self.page(p) - - @property - def list(self): - """ Get the list of files from the workspace. """ - p = Path(self.console.config['WORKSPACE']).expanduser() - for f in p.walk(filter_func=lambda p: p.is_file(), relative=True): - if all(not re.match(x, f.filename) for x in ["(data|key|store)\.db.*", "history"]): - yield f - - @property - def tempdir(self): - """ Get the temporary directory. """ - if not hasattr(self, "_tempdir"): - self._tempdir = TempPath(prefix="%s-" % self.console.appname, length=16) - return self._tempdir - +# -*- coding: UTF-8 -*- +import re +import requests +from ftplib import FTP, FTP_TLS +from shutil import which +from subprocess import call, PIPE +from tinyscript.helpers import b, ensure_str, txt_terminal_render, Path, TempPath + + +__all__ = ["FilesManager"] + + +class FilesManager(dict): + """ Files dictionary for registering files, if necessary downloading them using multiple supported schemes. """ + root_dir = "." + + def _file(self, locator, *args, **kwargs): + """ Simple local file copier. """ + self[locator] = open(locator.split("://", 1)[1], 'rb') + + def _ftp(self, locator, *args, **kwargs): + """ Simple FTP downloader. """ + scheme = locator.split("://", 1)[0] + client = [FTP, FTP_TLS][scheme == "ftps"]() + client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) + if scheme == "ftps": + client.auth() + usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") + if usr != "" and pswd != "": + client.login(usr, passwd) + #client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) + #FIXME + _ftps = _ftp + + def _http(self, url, *args, **kwargs): + """ Simple HTTP downloader. """ + self[url] = requests.get(url, *args, **kwargs).content + _https = _http + + def edit(self, filename): + """ Edit a file using the configured text editor. """ + #FIXME: edit by calling the locator and manage its local file (e.g. for a URL, point to a temp folder) + ted = self.console.config['TEXT_EDITOR'] + if which(ted) is None: + raise ValueError("'%s' does not exist or is not installed" % ted) + p = Path(self.console.config['WORKSPACE']).joinpath(filename) + if not p.exists(): + p.touch() + call([ted, str(p)], stderr=PIPE) + + def get(self, locator, *args, **kwargs): + """ Get a resource. """ + if locator in self.keys() and not kwargs.pop("force", False): + return self[locator] + scheme, path = locator.split("://") + if scheme in ["http", "https"]: + r = requests.get(locator, *args, **kwargs) + self[locator] = r.content + if r.status_code == 403: + raise ValueError("Forbidden") + elif scheme in ["ftp", "ftps"]: + client = [FTP, FTP_TLS][schem == "ftps"]() + client.connect(kwargs.pop("host", ""), kwargs.pop("port", 21)) + if scheme == "ftps": + client.auth() + usr, pswd = kwargs.pop("user", ""), kwargs.pop("passwd", "") + if usr != "" and pswd != "": + client.login(usr, passwd) + client.retrbinary(kwargs.pop("cmd", None), kwargs.pop("callback", None)) + #FIXME + elif scheme == "file": + with open(path, 'rb') as f: + self[locator] = f.read() + else: + raise ValueError("Unsupported scheme '{}'".format(scheme)) + + def page(self, *filenames): + """ Page a list of files using Less. """ + tvw = self.console.config['TEXT_VIEWER'] + if which(tvw) is None: + raise ValueError("'%s' does not exist or is not installed" % tvw) + filenames = list(map(str, filenames)) + for f in filenames: + if not Path(str(f)).is_file(): + raise OSError("File does not exist") + call([tvw] + filenames, stderr=PIPE) + + def page_text(self, text): + """ Page a text using Less. """ + tmp = self.tempdir.tempfile() + tmp.write_text(text) + self.page(str(tmp)) + + def save(self, key, dst): + """ Save a resource. """ + with open(dst, 'wb') as f: + f.write(self[key]) + + def view(self, key): + """ View a file using the configured text viewer. """ + try: + self.page_text(self[key]) + except KeyError: + pass + p = Path(self.console.config['WORKSPACE'], expand=True).joinpath(key) + if p.suffix == ".md": + self.page_text(txt_terminal_render(p.text, format="md").strip()) + else: + # if the given key is not in the dictionary of files (APP_FOLDER/files/), it can still be in the workspace + self.page(p) + + @property + def list(self): + """ Get the list of files from the workspace. """ + p = Path(self.console.config['WORKSPACE']).expanduser() + for f in p.walk(filter_func=lambda p: p.is_file(), relative=True): + if all(not re.match(x, f.filename) for x in ["(data|key|store)\.db.*", "history"]): + yield f + + @property + def tempdir(self): + """ Get the temporary directory. """ + if not hasattr(self, "_tempdir"): + self._tempdir = TempPath(prefix="%s-" % self.console.appname, length=16) + return self._tempdir + diff --git a/sploitkit/core/components/jobs.py b/src/sploitkit/core/components/jobs.py similarity index 97% rename from sploitkit/core/components/jobs.py rename to src/sploitkit/core/components/jobs.py index 2e38d03..3226ac1 100755 --- a/sploitkit/core/components/jobs.py +++ b/src/sploitkit/core/components/jobs.py @@ -1,128 +1,128 @@ -# -*- coding: UTF-8 -*- -import shlex -import subprocess -from six import string_types -from time import time -from tinyscript.helpers.text import ansi_seq_strip - -from sploitkit.core.components.logger import null_logger - -__all__ = ["JobsPool"] - - -communicate = lambda p, **i: tuple(map(lambda x: x.decode().strip(), p.communicate(**i))) - - -class Job(subprocess.Popen): - """ Subprocess-based job class, bound to its parent pool. """ - def __init__(self, cmd, **kwargs): - self.parent = kwargs.pop('parent') - debug = not kwargs.pop('no_debug', False) - if debug: - self.parent.logger.debug(" ".join(cmd) if isinstance(cmd, (tuple, list)) else cmd) - cmd = shlex.split(cmd) if isinstance(cmd, string_types) and not kwargs.get('shell', False) else cmd - super(Job, self).__init__(cmd, stdout=subprocess.PIPE, **kwargs) - self._debug = debug - - def close(self, wait=True): - for s in ["stdin", "stdout", "stderr"]: - getattr(getattr(self, s, object()), "close", lambda: None)() - if wait: - return self.wait() - - -class JobsPool(object): - """ Subprocess-based pool for managing open jobs. """ - def __init__(self, max_jobs=None): - self.__jobs = {None: []} - self.max = max_jobs - - def __iter__(self): - for j in self.__jobs.items(): - yield j - - def background(self, cmd, **kwargs): - subpool = kwargs.pop('subpool') - self.__jobs.setdefault(subpool, []) - self.__jobs[subpool].append(Job(cmd, parent=self, **kwargs)) - - def call(self, cmd, **kwargs): - kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE - return subprocess.call(shlex.split(cmd), **kwargs) - - def free(self, subpool=None): - for p in self.__jobs[subpool]: - if p.poll(): - p.close(False) - self.__jobs[subpool].remove(p) - - def run(self, cmd, stdin=None, show=False, timeout=None, ansi_strip=True, **kwargs): - kwargs['stderr'] = subprocess.PIPE - kwargs['stdin'] = (None if stdin is None else subprocess.PIPE) - p = Job(cmd, parent=self, **kwargs) - com_kw = {} - if stdin is not None: - com_kw['input'] = stdin.encode() - if timeout is not None: - com_kw['timeout'] = timeout - out, err = "", "" - try: - out, err = tuple(map(lambda x: x.decode().strip(), p.communicate(**com_kw))) - except (KeyboardInterrupt, subprocess.TimeoutExpired): - out = [] - for line in iter(p.stdout.readline, ""): - out.append(line) - out = "\n".join(out) - err = [] - for line in iter(p.stderr.readline, ""): - err.append(line) - err = "\n".join(err) - if out != "" and p._debug: - getattr(self.logger, ["debug", "info"][show])(out) - if err != "" and p._debug: - getattr(self.logger, ["debug", "error"][show])(err) - if ansi_strip: - out = ansi_seq_strip(out) - return out, err - - def run_iter(self, cmd, timeout=None, ansi_strip=True, **kwargs): - kwargs['stderr'] = subprocess.STDOUT - kwargs['universal_newlines'] = True - p = Job(cmd, parent=self, **kwargs) - s = time() - #FIXME: cleanup this part - def readline(): - while True: - try: - l = p.stdout.readline() - if l == "": - break - except UnicodeDecodeError: - continue - yield l - try: - for line in readline(): - if len(line) > 0: - if p._debug: - self.logger.debug(line) - if ansi_strip: - line = ansi_seq_strip(line) - yield line - if timeout is not None and time() - s > timeout: - break - finally: - p.kill() - p.close() - - def terminate(self, subpool=None): - for p in self.__jobs.get(subpool, []): - p.terminate() - p.close() - self.__jobs[subpool].remove(p) - - @property - def logger(self): - if hasattr(self, "console"): - return self.console.logger - return null_logger - +# -*- coding: UTF-8 -*- +import shlex +import subprocess +from six import string_types +from time import time +from tinyscript.helpers.text import ansi_seq_strip + +from sploitkit.core.components.logger import null_logger + +__all__ = ["JobsPool"] + + +communicate = lambda p, **i: tuple(map(lambda x: x.decode().strip(), p.communicate(**i))) + + +class Job(subprocess.Popen): + """ Subprocess-based job class, bound to its parent pool. """ + def __init__(self, cmd, **kwargs): + self.parent = kwargs.pop('parent') + debug = not kwargs.pop('no_debug', False) + if debug: + self.parent.logger.debug(" ".join(cmd) if isinstance(cmd, (tuple, list)) else cmd) + cmd = shlex.split(cmd) if isinstance(cmd, string_types) and not kwargs.get('shell', False) else cmd + super(Job, self).__init__(cmd, stdout=subprocess.PIPE, **kwargs) + self._debug = debug + + def close(self, wait=True): + for s in ["stdin", "stdout", "stderr"]: + getattr(getattr(self, s, object()), "close", lambda: None)() + if wait: + return self.wait() + + +class JobsPool(object): + """ Subprocess-based pool for managing open jobs. """ + def __init__(self, max_jobs=None): + self.__jobs = {None: []} + self.max = max_jobs + + def __iter__(self): + for j in self.__jobs.items(): + yield j + + def background(self, cmd, **kwargs): + subpool = kwargs.pop('subpool') + self.__jobs.setdefault(subpool, []) + self.__jobs[subpool].append(Job(cmd, parent=self, **kwargs)) + + def call(self, cmd, **kwargs): + kwargs['stdout'] = kwargs['stderr'] = subprocess.PIPE + return subprocess.call(shlex.split(cmd), **kwargs) + + def free(self, subpool=None): + for p in self.__jobs[subpool]: + if p.poll(): + p.close(False) + self.__jobs[subpool].remove(p) + + def run(self, cmd, stdin=None, show=False, timeout=None, ansi_strip=True, **kwargs): + kwargs['stderr'] = subprocess.PIPE + kwargs['stdin'] = (None if stdin is None else subprocess.PIPE) + p = Job(cmd, parent=self, **kwargs) + com_kw = {} + if stdin is not None: + com_kw['input'] = stdin.encode() + if timeout is not None: + com_kw['timeout'] = timeout + out, err = "", "" + try: + out, err = tuple(map(lambda x: x.decode().strip(), p.communicate(**com_kw))) + except (KeyboardInterrupt, subprocess.TimeoutExpired): + out = [] + for line in iter(p.stdout.readline, ""): + out.append(line) + out = "\n".join(out) + err = [] + for line in iter(p.stderr.readline, ""): + err.append(line) + err = "\n".join(err) + if out != "" and p._debug: + getattr(self.logger, ["debug", "info"][show])(out) + if err != "" and p._debug: + getattr(self.logger, ["debug", "error"][show])(err) + if ansi_strip: + out = ansi_seq_strip(out) + return out, err + + def run_iter(self, cmd, timeout=None, ansi_strip=True, **kwargs): + kwargs['stderr'] = subprocess.STDOUT + kwargs['universal_newlines'] = True + p = Job(cmd, parent=self, **kwargs) + s = time() + #FIXME: cleanup this part + def readline(): + while True: + try: + l = p.stdout.readline() + if l == "": + break + except UnicodeDecodeError: + continue + yield l + try: + for line in readline(): + if len(line) > 0: + if p._debug: + self.logger.debug(line) + if ansi_strip: + line = ansi_seq_strip(line) + yield line + if timeout is not None and time() - s > timeout: + break + finally: + p.kill() + p.close() + + def terminate(self, subpool=None): + for p in self.__jobs.get(subpool, []): + p.terminate() + p.close() + self.__jobs[subpool].remove(p) + + @property + def logger(self): + if hasattr(self, "console"): + return self.console.logger + return null_logger + diff --git a/sploitkit/core/components/layout.py b/src/sploitkit/core/components/layout.py similarity index 96% rename from sploitkit/core/components/layout.py rename to src/sploitkit/core/components/layout.py index a70d327..ec1702f 100755 --- a/sploitkit/core/components/layout.py +++ b/src/sploitkit/core/components/layout.py @@ -1,20 +1,20 @@ -# -*- coding: UTF-8 -*- -from prompt_toolkit.widgets import FormattedTextToolbar, TextArea -#TODO: do not forget to remove unuseful imports - - -__all__ = ["CustomLayout"] - - -#TODO: determine if this module is still useful ; remove it if necessary - -class AppToolbar(FormattedTextToolbar): - pass - - -class CustomLayout(object): - def __init__(self, console): - self.layout = console._session.app.layout - #self.layout.container.children = self.layout.container.children[:-1] - #print(self.layout.container.children) - +# -*- coding: UTF-8 -*- +from prompt_toolkit.widgets import FormattedTextToolbar, TextArea +#TODO: do not forget to remove unuseful imports + + +__all__ = ["CustomLayout"] + + +#TODO: determine if this module is still useful ; remove it if necessary + +class AppToolbar(FormattedTextToolbar): + pass + + +class CustomLayout(object): + def __init__(self, console): + self.layout = console._session.app.layout + #self.layout.container.children = self.layout.container.children[:-1] + #print(self.layout.container.children) + diff --git a/sploitkit/core/components/logger.py b/src/sploitkit/core/components/logger.py similarity index 97% rename from sploitkit/core/components/logger.py rename to src/sploitkit/core/components/logger.py index 247c3e5..1e0b75c 100755 --- a/sploitkit/core/components/logger.py +++ b/src/sploitkit/core/components/logger.py @@ -1,93 +1,92 @@ -# -*- coding: utf8 -*- -import re -from logging.handlers import RotatingFileHandler -from termcolor import colored -from tinyscript import logging - - -__all__ = ["get_logger", "null_logger", "set_logging_level"] - - -DATETIME_FORMAT = "%m/%d/%y %H:%M:%S" -LOGFILE_FORMAT = "%(asctime)s [%(process)5d] %(levelname)8s %(name)s - %(message)s" -LOG_FORMAT = "%(levelsymbol)s %(message)s" -LOG_FORMAT_DBG = "%(asctime)s %(name)32s %(levelname)8s %(message)s" -LOG_LEVEL_SYMBOLS = { - logging.DETAIL: colored("[#]", "white"), # this is aimed to provide even more info in dev mode - logging.DEBUG: colored("[#]", "white"), # this is aimed to be used in normal mode - logging.INFO: colored("[*]", "blue"), - logging.WARNING: colored("[!]", "yellow"), - logging.SUCCESS: colored("[+]", "green", attrs=['bold']), - logging.ERROR: colored("[-]", "red", attrs=['bold']), - logging.CRITICAL: colored("[X]", "red", attrs=['bold']), - None: colored("[?]", "grey"), -} - - -# this avoids throwing e.g. FutureWarning or DeprecationWarning messages -logging.captureWarnings(True) -logger = logging.getLogger('py.warnings') -logger.setLevel(logging.CRITICAL) - - -# silent sh module's logging -logger = logging.getLogger('sh.command') -logger.setLevel(level=logging.WARNING) -logger = logging.getLogger('sh.streamreader') -logger.setLevel(level=logging.WARNING) -logger = logging.getLogger('sh.stream_bufferer') -logger.setLevel(level=logging.WARNING) - - -# make aliases from logging functions -null_logger = logging.nullLogger -set_logging_level = logging.setLoggingLevel - - -# add a custom message handler for tuning the format with 'levelsymbol' -class ConsoleHandler(logging.StreamHandler): - def emit(self, record): - record.levelsymbol = LOG_LEVEL_SYMBOLS.get(record.levelno, "") - super(ConsoleHandler, self).emit(record) - - -# logging configuration -def get_logger(name, logfile=None, level="INFO", dev=False, enabled=True): - """ Logger initialization function. """ - def _setup_logfile(l): - if logfile is not None and not any(isinstance(h, RotatingFileHandler) for h in l.handlers): - l.__logfile__ = logfile - # setup a FileHandler for logging to a file (at level DEBUG) - fh = RotatingFileHandler(logfile) - fh.setFormatter(logging.Formatter(LOGFILE_FORMAT, datefmt=DATETIME_FORMAT)) - fh.setLevel(level) - l.addHandler(fh) - else: - l.__logfile__ = None - - logger = logging.getLogger(name) - logger.propagate = False - level = getattr(logging, level) if not isinstance(level, int) else level - # distinguish dev and framework-bound logger formats - if dev: - if enabled: - # in dev mode, get a logger as of the native library - logging.configLogger(logger, level, relative=True, fmt=LOG_FORMAT_DBG) - _setup_logfile(logger) - else: - logger.setLevel(1000) - else: - # now use the dedicated class for the logger to be returned - logger.setLevel(level) - if len(logger.handlers) == 0: - # setup a StreamHandler for the console (at level INFO) - ch = ConsoleHandler() - ch.setFormatter(logging.Formatter(fmt=LOG_FORMAT)) - ch.setLevel(level) - logger.addHandler(ch) - _setup_logfile(logger) - else: - for h in logger.handlers: - h.setLevel(level) - return logger - +# -*- coding: utf8 -*- +from logging.handlers import RotatingFileHandler +from termcolor import colored +from tinyscript import logging + + +__all__ = ["get_logger", "null_logger", "set_logging_level"] + + +DATETIME_FORMAT = "%m/%d/%y %H:%M:%S" +LOGFILE_FORMAT = "%(asctime)s [%(process)5d] %(levelname)8s %(name)s - %(message)s" +LOG_FORMAT = "%(levelsymbol)s %(message)s" +LOG_FORMAT_DBG = "%(asctime)s %(name)32s %(levelname)8s %(message)s" +LOG_LEVEL_SYMBOLS = { + logging.DETAIL: colored("[#]", "white"), # this is aimed to provide even more info in dev mode + logging.DEBUG: colored("[#]", "white"), # this is aimed to be used in normal mode + logging.INFO: colored("[*]", "blue"), + logging.WARNING: colored("[!]", "yellow"), + logging.SUCCESS: colored("[+]", "green", attrs=['bold']), + logging.ERROR: colored("[-]", "red", attrs=['bold']), + logging.CRITICAL: colored("[X]", "red", attrs=['bold']), + None: colored("[?]", "grey"), +} + + +# this avoids throwing e.g. FutureWarning or DeprecationWarning messages +logging.captureWarnings(True) +logger = logging.getLogger('py.warnings') +logger.setLevel(logging.CRITICAL) + + +# silent sh module's logging +logger = logging.getLogger('sh.command') +logger.setLevel(level=logging.WARNING) +logger = logging.getLogger('sh.streamreader') +logger.setLevel(level=logging.WARNING) +logger = logging.getLogger('sh.stream_bufferer') +logger.setLevel(level=logging.WARNING) + + +# make aliases from logging functions +null_logger = logging.nullLogger +set_logging_level = logging.setLoggingLevel + + +# add a custom message handler for tuning the format with 'levelsymbol' +class ConsoleHandler(logging.StreamHandler): + def emit(self, record): + record.levelsymbol = LOG_LEVEL_SYMBOLS.get(record.levelno, "") + super(ConsoleHandler, self).emit(record) + + +# logging configuration +def get_logger(name, logfile=None, level="INFO", dev=False, enabled=True): + """ Logger initialization function. """ + def _setup_logfile(l): + if logfile is not None and not any(isinstance(h, RotatingFileHandler) for h in l.handlers): + l.__logfile__ = logfile + # setup a FileHandler for logging to a file (at level DEBUG) + fh = RotatingFileHandler(logfile) + fh.setFormatter(logging.Formatter(LOGFILE_FORMAT, datefmt=DATETIME_FORMAT)) + fh.setLevel(level) + l.addHandler(fh) + else: + l.__logfile__ = None + + logger = logging.getLogger(name) + logger.propagate = False + level = getattr(logging, level) if not isinstance(level, int) else level + # distinguish dev and framework-bound logger formats + if dev: + if enabled: + # in dev mode, get a logger as of the native library + logging.configLogger(logger, level, relative=True, fmt=LOG_FORMAT_DBG) + _setup_logfile(logger) + else: + logger.setLevel(1000) + else: + # now use the dedicated class for the logger to be returned + logger.setLevel(level) + if len(logger.handlers) == 0: + # setup a StreamHandler for the console (at level INFO) + ch = ConsoleHandler() + ch.setFormatter(logging.Formatter(fmt=LOG_FORMAT)) + ch.setLevel(level) + logger.addHandler(ch) + _setup_logfile(logger) + else: + for h in logger.handlers: + h.setLevel(level) + return logger + diff --git a/sploitkit/core/components/recorder.py b/src/sploitkit/core/components/recorder.py similarity index 96% rename from sploitkit/core/components/recorder.py rename to src/sploitkit/core/components/recorder.py index 29ab2be..89b7b10 100755 --- a/sploitkit/core/components/recorder.py +++ b/src/sploitkit/core/components/recorder.py @@ -1,37 +1,37 @@ -# -*- coding: UTF-8 -*- -from tinyscript.helpers import Path - - -__all__ = ["Recorder"] - - -class Recorder(object): - """ Simple text recording class. """ - _filter = ["record"] - root_dir = "." - - def __init__(self): - self.stop() - - @property - def enabled(self): - return self.__file is not None - - def save(self, text): - """ Save the given text to the record file. """ - if self.enabled and text.split()[0] not in self._filter: - self.__file.append_line(text) - - def start(self, filename, overwrite=False): - """ Start the recorder, creating the record file. """ - self.__file = f = Path(filename) - if f.suffix != ".rc": - self.__file = f = Path(self.root_dir).joinpath(filename + ".rc") - if not overwrite and f.exists(): - raise OSError("File already exists") - f.reset() - - def stop(self): - """ Stop the recorder by removing the record file reference. """ - self.__file = None - +# -*- coding: UTF-8 -*- +from tinyscript.helpers import Path + + +__all__ = ["Recorder"] + + +class Recorder(object): + """ Simple text recording class. """ + _filter = ["record"] + root_dir = "." + + def __init__(self): + self.stop() + + @property + def enabled(self): + return self.__file is not None + + def save(self, text): + """ Save the given text to the record file. """ + if self.enabled and text.split()[0] not in self._filter: + self.__file.append_line(text) + + def start(self, filename, overwrite=False): + """ Start the recorder, creating the record file. """ + self.__file = f = Path(filename) + if f.suffix != ".rc": + self.__file = f = Path(self.root_dir).joinpath(filename + ".rc") + if not overwrite and f.exists(): + raise OSError("File already exists") + f.reset() + + def stop(self): + """ Stop the recorder by removing the record file reference. """ + self.__file = None + diff --git a/sploitkit/core/components/sessions.py b/src/sploitkit/core/components/sessions.py similarity index 95% rename from sploitkit/core/components/sessions.py rename to src/sploitkit/core/components/sessions.py index 6c940aa..d4176ef 100755 --- a/sploitkit/core/components/sessions.py +++ b/src/sploitkit/core/components/sessions.py @@ -1,76 +1,76 @@ -# -*- coding: UTF-8 -*- -import os -import shlex -import shutil -from subprocess import Popen, PIPE -from tinyscript.helpers import Path - -__all__ = ["SessionsManager"] - - -class Session(object): - """ Class representing a session object based on a shell command """ - def __init__(self, n, cmd, **kwargs): - self.id = n - self.parent = kwargs.pop('parent') - if isinstance(cmd, str): - cmd = shlex.split(cmd) - self._path = Path(self.parent.console._files.tempdir, "session", str(n), create=True) - for i, s in enumerate(["stdin", "stdout", "stderr"]): - fifo = str(self._path.joinpath(str(i))) - self._named_pipes.append(fifo) - os.mkfifo(fifo, 0o777) - setattr(self, "_" + s, os.open(fifo ,os.O_WRONLY)) - - def close(self): - for s in ["stdin", "stdout", "stderr"]: - getattr(self, "_" + s).close() - shutil.rmtree(str(self._path)) - self._process.wait() - del self.parent[self.id] - - def start(self, **kwargs): - kwargs['close_fds'] = True - kwargs['preexec_fn'] = os.setsid # NB: see subprocess' doc ; preexec_fn is not thread-safe - self._process = Popen(cmd, stdout=self._stdout, stderr=self._stderr, stdin=self._stdin, **kwargs) - - -class SessionsManager(object): - """ Class for managing session objects. """ - def __init__(self, max_sessions=None): - self.__sessions = [] - self.max = max_sessions - - def __delitem__(self, session_id): - self.__sessions[sessin_id] = None - while self.__sessions[-1] is None: - self.__sessions.pop() - - def __getitem__(self, session_id): - return self.__sessions[int(session_id)] - - def __iter__(self): - for i, s in enumerate(self.__sessions): - if s is not None: - yield i, s - - def __len__(self): - n = 0 - for s in self: - n += 1 - return n - - def new(self, session): - for i, s in enumerate(self.__session): - if s is None: - self.__session[i] = session - return session - self.__session.append(session) - return session - - def process(self, cmd, **kwargs): - return self.new(Session(self, i+1, cmd, **kwargs)) - - def shell(self, shell_cls, *args, **kwargs): - return self.new(shell_cls(*args, **kwargs)) - +# -*- coding: UTF-8 -*- +import os +import shlex +import shutil +from subprocess import Popen +from tinyscript.helpers import Path + +__all__ = ["SessionsManager"] + + +class Session(object): + """ Class representing a session object based on a shell command """ + def __init__(self, n, cmd, **kwargs): + self.id = n + self.parent = kwargs.pop('parent') + if isinstance(cmd, str): + cmd = shlex.split(cmd) + self._path = Path(self.parent.console._files.tempdir, "session", str(n), create=True) + for i, s in enumerate(["stdin", "stdout", "stderr"]): + fifo = str(self._path.joinpath(str(i))) + self._named_pipes.append(fifo) + os.mkfifo(fifo, 0o777) + setattr(self, "_" + s, os.open(fifo ,os.O_WRONLY)) + + def close(self): + for s in ["stdin", "stdout", "stderr"]: + getattr(self, "_" + s).close() + shutil.rmtree(str(self._path)) + self._process.wait() + del self.parent[self.id] + + def start(self, **kwargs): + kwargs['close_fds'] = True + kwargs['preexec_fn'] = os.setsid # NB: see subprocess' doc ; preexec_fn is not thread-safe + self._process = Popen(cmd, stdout=self._stdout, stderr=self._stderr, stdin=self._stdin, **kwargs) + + +class SessionsManager(object): + """ Class for managing session objects. """ + def __init__(self, max_sessions=None): + self.__sessions = [] + self.max = max_sessions + + def __delitem__(self, session_id): + self.__sessions[sessin_id] = None + while self.__sessions[-1] is None: + self.__sessions.pop() + + def __getitem__(self, session_id): + return self.__sessions[int(session_id)] + + def __iter__(self): + for i, s in enumerate(self.__sessions): + if s is not None: + yield i, s + + def __len__(self): + n = 0 + for s in self: + n += 1 + return n + + def new(self, session): + for i, s in enumerate(self.__session): + if s is None: + self.__session[i] = session + return session + self.__session.append(session) + return session + + def process(self, cmd, **kwargs): + return self.new(Session(self, i+1, cmd, **kwargs)) + + def shell(self, shell_cls, *args, **kwargs): + return self.new(shell_cls(*args, **kwargs)) + diff --git a/sploitkit/core/components/store.py b/src/sploitkit/core/components/store.py similarity index 97% rename from sploitkit/core/components/store.py rename to src/sploitkit/core/components/store.py index 69fe183..962fdf6 100755 --- a/sploitkit/core/components/store.py +++ b/src/sploitkit/core/components/store.py @@ -1,129 +1,129 @@ -# -*- coding: UTF-8 -*- -import re -from os import remove -from shutil import copy -from peewee import SqliteDatabase - - -__all__ = ["StoragePool"] - - -class StoragePool(object): - """ Storage pool class. """ - __pool = [] - - def __init__(self, ext_class=None): - self._entity_class = getattr(ext_class(), "base_class", None) - self._ext_class = ext_class - - def close(self, remove=False): - """ Close every database in the pool. """ - for db in self.__pool[::-1]: - self.remove(db) if remove else db.close() - - def free(self): - """ Close and remove every database in the pool. """ - self.close(True) - - def get(self, path, *args, **kwargs): - """ Get a database from the pool ; if the DB does not exist yet, create and register it. """ - path = str(path) # ensure the input is str, e.g. not a Path instance - try: - db = [_ for _ in self.__pool if _.path == path][0] - except IndexError: - classes = tuple([Store] + self.extensions) - cls = type("ExtendedStore", classes, {}) - db = cls(path, *args, **kwargs) - db._pool = self - # as the store extension class should subclass Entity, in 'classes', store extension subclasses will be - # present, therefore making ExtendedStore registered in its list of subclasses ; this line prevents from - # having multiple combined classes having the same Store base class - if self._ext_class is not None and hasattr(self._ext_class, "unregister_subclass"): - self._ext_class.unregister_subclass(cls) - self.__pool.append(db) - for m in self.models: - m.bind(db) - db.create_tables(self.models, safe=True) - db.close() # commit and save the created tables - db.connect() - return db - - def remove(self, db): - """ Remove a database from the pool. """ - db.close() - delattr(db, "_pool") - self.__pool.remove(db) - del db - - @property - def extensions(self): - """ Get the list of store extension subclasses. """ - try: - return self._ext_class.subclasses - except AttributeError: - return [] - - -class Store(SqliteDatabase): - """ Storage database class. """ - def __init__(self, path, *args, **kwargs): - self.path = str(path) # ensure the input is str, e.g. not Path - self._last_snapshot = 0 - kwargs.setdefault('pragmas', {}) - # enable automatic VACUUM (to regularly defragment the DB) - kwargs['pragmas'].setdefault('auto_vacuum', 1) - # set page cache size (in KiB) - kwargs['pragmas'].setdefault('cache_size', -64000) - # allow readers and writers to co-exist - kwargs['pragmas'].setdefault('journal_mode', "wal") - # enforce foreign-key constraints - kwargs['pragmas'].setdefault('foreign_keys', 1) - # enforce CHECK constraints - kwargs['pragmas'].setdefault('ignore_check_constraints', 0) - # let OS handle fsync - kwargs['pragmas'].setdefault('synchronous', 0) - # force every transaction in exclusive mode - kwargs['pragmas'].setdefault('locking_mode', 1) - super(Store, self).__init__(path, *args, **kwargs) - - def __getattr__(self, name): - """ Override getattr to handle add_* store methods. """ - if name == "basemodels": - BaseModel = self._pool._entity_class._subclasses["basemodel"] - return self._pool._entity_class._subclasses[BaseModel] - elif name == "models": - Model = self._pool._entity_class._subclasses["model"] - return self._pool._entity_class._subclasses[Model] - elif name == "volatile": - return self.path == ":memory:" - elif re.match(r"^[gs]et_[a-z]+", name) and name != "model": - model = "".join(w.capitalize() for w in name.split("_")[1:]) - cls = self.get_model(model) - if cls is not None: - if name.startswith("get"): - return cls.get - elif hasattr(cls, "set"): - return cls.set - raise AttributeError("Store object has no attribute %r" % name) - - def get_model(self, name, base=False): - """ Get a model class from its name. """ - return self._pool._entity_class.get_subclass("model", name) or \ - self._pool._entity_class.get_subclass("basemodel", name) - - def snapshot(self, save=True): - """ Snapshot the store in order to be able to get back to this state afterwards if the results are corrupted by - a module OR provide the reference number of the snapshot to get back to, and remove every other snapshot - after this number. """ - if not save and self._last_snapshot == 0: - return - self.close() - if save: - self._last_snapshot += 1 - s = "{}.snapshot{}".format(self.path, self._last_snapshot) - copy(self.path, s) if save else copy(s, self.path) - if not save: - remove("{}.snapshot{}".format(self.path, self._last_snapshot)) - self._last_snapshot -= 1 - self.connect() - +# -*- coding: UTF-8 -*- +import re +from os import remove +from shutil import copy +from peewee import SqliteDatabase + + +__all__ = ["StoragePool"] + + +class StoragePool(object): + """ Storage pool class. """ + __pool = [] + + def __init__(self, ext_class=None): + self._entity_class = getattr(ext_class(), "base_class", None) + self._ext_class = ext_class + + def close(self, remove=False): + """ Close every database in the pool. """ + for db in self.__pool[::-1]: + self.remove(db) if remove else db.close() + + def free(self): + """ Close and remove every database in the pool. """ + self.close(True) + + def get(self, path, *args, **kwargs): + """ Get a database from the pool ; if the DB does not exist yet, create and register it. """ + path = str(path) # ensure the input is str, e.g. not a Path instance + try: + db = [_ for _ in self.__pool if _.path == path][0] + except IndexError: + classes = tuple([Store] + self.extensions) + cls = type("ExtendedStore", classes, {}) + db = cls(path, *args, **kwargs) + db._pool = self + # as the store extension class should subclass Entity, in 'classes', store extension subclasses will be + # present, therefore making ExtendedStore registered in its list of subclasses ; this line prevents from + # having multiple combined classes having the same Store base class + if self._ext_class is not None and hasattr(self._ext_class, "unregister_subclass"): + self._ext_class.unregister_subclass(cls) + self.__pool.append(db) + for m in self.models: + m.bind(db) + db.create_tables(self.models, safe=True) + db.close() # commit and save the created tables + db.connect() + return db + + def remove(self, db): + """ Remove a database from the pool. """ + db.close() + delattr(db, "_pool") + self.__pool.remove(db) + del db + + @property + def extensions(self): + """ Get the list of store extension subclasses. """ + try: + return self._ext_class.subclasses + except AttributeError: + return [] + + +class Store(SqliteDatabase): + """ Storage database class. """ + def __init__(self, path, *args, **kwargs): + self.path = str(path) # ensure the input is str, e.g. not Path + self._last_snapshot = 0 + kwargs.setdefault('pragmas', {}) + # enable automatic VACUUM (to regularly defragment the DB) + kwargs['pragmas'].setdefault('auto_vacuum', 1) + # set page cache size (in KiB) + kwargs['pragmas'].setdefault('cache_size', -64000) + # allow readers and writers to co-exist + kwargs['pragmas'].setdefault('journal_mode', "wal") + # enforce foreign-key constraints + kwargs['pragmas'].setdefault('foreign_keys', 1) + # enforce CHECK constraints + kwargs['pragmas'].setdefault('ignore_check_constraints', 0) + # let OS handle fsync + kwargs['pragmas'].setdefault('synchronous', 0) + # force every transaction in exclusive mode + kwargs['pragmas'].setdefault('locking_mode', 1) + super(Store, self).__init__(path, *args, **kwargs) + + def __getattr__(self, name): + """ Override getattr to handle add_* store methods. """ + if name == "basemodels": + BaseModel = self._pool._entity_class._subclasses["basemodel"] + return self._pool._entity_class._subclasses[BaseModel] + elif name == "models": + Model = self._pool._entity_class._subclasses["model"] + return self._pool._entity_class._subclasses[Model] + elif name == "volatile": + return self.path == ":memory:" + elif re.match(r"^[gs]et_[a-z]+", name) and name != "model": + model = "".join(w.capitalize() for w in name.split("_")[1:]) + cls = self.get_model(model) + if cls is not None: + if name.startswith("get"): + return cls.get + elif hasattr(cls, "set"): + return cls.set + raise AttributeError("Store object has no attribute %r" % name) + + def get_model(self, name, base=False): + """ Get a model class from its name. """ + return self._pool._entity_class.get_subclass("model", name) or \ + self._pool._entity_class.get_subclass("basemodel", name) + + def snapshot(self, save=True): + """ Snapshot the store in order to be able to get back to this state afterwards if the results are corrupted by + a module OR provide the reference number of the snapshot to get back to, and remove every other snapshot + after this number. """ + if not save and self._last_snapshot == 0: + return + self.close() + if save: + self._last_snapshot += 1 + s = "{}.snapshot{}".format(self.path, self._last_snapshot) + copy(self.path, s) if save else copy(s, self.path) + if not save: + remove("{}.snapshot{}".format(self.path, self._last_snapshot)) + self._last_snapshot -= 1 + self.connect() + diff --git a/sploitkit/core/components/validator.py b/src/sploitkit/core/components/validator.py similarity index 97% rename from sploitkit/core/components/validator.py rename to src/sploitkit/core/components/validator.py index eef3b5e..0b5d203 100755 --- a/sploitkit/core/components/validator.py +++ b/src/sploitkit/core/components/validator.py @@ -1,6 +1,4 @@ # -*- coding: UTF-8 -*- -import shlex -from inspect import getfullargspec from prompt_toolkit.validation import Validator, ValidationError diff --git a/sploitkit/core/console.py b/src/sploitkit/core/console.py similarity index 97% rename from sploitkit/core/console.py rename to src/sploitkit/core/console.py index 2cdb902..a580383 100755 --- a/sploitkit/core/console.py +++ b/src/sploitkit/core/console.py @@ -1,607 +1,610 @@ -# -*- coding: UTF-8 -*- -import gc -import io -import os -import shlex -import sys -from asciistuff import get_banner, get_quote -from bdb import BdbQuit -from datetime import datetime -from inspect import getfile, isfunction -from itertools import chain -from prompt_toolkit import print_formatted_text as print_ft, PromptSession -from prompt_toolkit.application.current import get_app_session -from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from prompt_toolkit.formatted_text import ANSI, FormattedText -from prompt_toolkit.history import FileHistory -from prompt_toolkit.output import DummyOutput -from prompt_toolkit.styles import Style -from random import choice -from shutil import which -from tinyscript.helpers import filter_bin, get_terminal_size, parse_docstring, Capture, Path - -from .command import * -from .components import * -from .entity import * -from .model import * -from .module import * - - -__all__ = [ - "Entity", - # subclassable main entities - "BaseModel", "Command", "Console", "Model", "Module", "StoreExtension", - # console-related classes - "Config", "ConsoleExit", "ConsoleDuplicate", "FrameworkConsole", "Option", -] - -EDITORS = ["atom", "emacs", "gedit", "mousepad", "nano", "notepad", "notepad++", "vi", "vim"] -VIEWERS = ["bat", "less"] -try: - DEFAULT_EDITOR = filter_bin(*EDITORS)[-1] -except IndexError: - DEFAULT_EDITOR = None -try: - DEFAULT_VIEWER = filter_bin(*VIEWERS)[0] -except IndexError: - DEFAULT_VIEWER = None - -_output = get_app_session().output -dcount = lambda d, n=0: sum([dcount(v, n) if isinstance(v, dict) else n + 1 for v in d.values()]) -logger = get_logger("core.console") - - -def print_formatted_text(*args, **kwargs): - """ Proxy function that uses the global (capturable) _output. """ - kwargs['output'] = kwargs.get('output', _output) - return print_ft(*args, **kwargs) - - -class _CaptureOutput(DummyOutput): - def __init__(self): - self.__file = io.StringIO() - - def __str__(self): - return self.__file.getvalue().strip() - - def write(self, data): - self.__file.write(data) - - -class MetaConsole(MetaEntity): - """ Metaclass of a Console. """ - _has_config = True - - -class Console(Entity, metaclass=MetaConsole): - """ Base console class. """ - # convention: mangled attributes should not be customized when subclassing Console... - _files = FilesManager() - _jobs = JobsPool() - _recorder = Recorder() - _sessions = SessionsManager() - _state = {} # state shared between all the consoles - _storage = StoragePool(StoreExtension) - # ... by opposition to public class attributes that can be tuned - appname = "" - config = Config() - exclude = [] - level = ROOT_LEVEL - message = PROMPT_FORMAT - motd = """ - - """ - parent = None - sources = SOURCES - style = PROMPT_STYLE - - def __init__(self, parent=None, **kwargs): - fail = kwargs.pop("fail", True) - super(Console, self).__init__() - # determine the relevant parent - self.parent = parent - if self.parent is not None and self.parent.level == self.level: - while parent is not None and parent.level == self.level: - parent = parent.parent # go up of one console level - # raise an exception in the context of command's .run() execution, to be propagated to console's .run() - # execution, setting the directly higher level console in argument - raise ConsoleDuplicate(self, parent) - # back-reference the console - self.config.console = self - # configure the console regarding its parenthood - if self.parent is None: - if Console.parent is not None: - raise Exception("Only one parent console can be used") - Console.parent = self - Console.parent._start_time = datetime.now() - Console.appdispname = Console.appname - Console.appname = Console.appname.lower() - self._root = Path(getfile(self.__class__)).resolve() - self.__init(**kwargs) - else: - self.parent.child = self - # reset commands and other bound stuffs - self.reset() - # setup the session with the custom completer and validator - completer, validator = CommandCompleter(), CommandValidator(fail) - completer.console = validator.console = self - message, style = self.prompt - self._session = PromptSession( - message, - completer=completer, - history=FileHistory(Path(self.config.option("WORKSPACE").value).joinpath("history")), - validator=validator, - style=Style.from_dict(style), - ) - CustomLayout(self) - - def __init(self, **kwargs): - """ Initialize the parent console with commands and modules. """ - # setup banners - try: - bsrc = str(choice(self._sources("banners"))) - print_formatted_text("") - # display a random banner from the banners folder - get_banner_func = kwargs.get('get_banner_func', get_banner) - banner_colors = kwargs.get('banner_section_styles', {}) - text = get_banner_func(self.appdispname, bsrc, styles=banner_colors) - if text: - print_formatted_text(ANSI(text)) - # display a random quote from quotes.csv (in the banners folder) - get_quote_func = kwargs.get('get_quote_func', get_quote) - try: - text = get_quote_func(os.path.join(bsrc, "quotes.csv")) - if text: - print_formatted_text(ANSI(text)) - except ValueError: - pass - except IndexError: - pass - # setup libraries - for lib in self._sources("libraries"): - sys.path.insert(0, str(lib)) - # setup entities - self._load_kwargs = {'include_base': kwargs.get("include_base", True), - 'select': kwargs.get("select", {'command': Command._functionalities}), - 'exclude': kwargs.get("exclude", {}), - 'backref': kwargs.get("backref", BACK_REFERENCES), - 'docstr_parser': kwargs.get("docstr_parser", parse_docstring), - 'remove_cache': True} - load_entities([BaseModel, Command, Console, Model, Module, StoreExtension], - *([self._root] + self._sources("entities")), **self._load_kwargs) - Console._storage.models = Model.subclasses + BaseModel.subclasses - # display module stats - print_formatted_text(FormattedText([("#00ff00", Module.get_summary())])) - # setup the prompt message - self.message.insert(0, ('class:appname', self.appname)) - # display warnings - self.reset() - if Entity.has_issues(): - self.logger.warning("There are some issues ; use 'show issues' to see more details") - # console's components back-referencing - for attr in ["_files", "_jobs", "_sessions"]: - setattr(getattr(Console, attr), "console", self) - - def _close(self): - """ Gracefully close the console. """ - self.logger.debug("Exiting {}[{}]".format(self.__class__.__name__, id(self))) - if hasattr(self, "close") and isfunction(self.close): - self.close() - # cleanup references for this console - self.detach() - # important note: do not confuse '_session' (refers to prompt session) with sessions (sessions manager) - if hasattr(self, "_session"): - delattr(self._session.completer, "console") - delattr(self._session.validator, "console") - # remove the singleton instance of the current console - c = self.__class__ - if hasattr(c, "_instance"): - del c._instance - if self.parent is not None: - del self.parent.child - # rebind entities to the parent console - self.parent.reset() - # remove all finished jobs from the pool - self._jobs.free() - else: - # gracefully close every DB in the pool - self._storage.free() - # terminate all running jobs - self._jobs.terminate() - - def _get_tokens(self, text, suffix=("", "\"", "'")): - """ Recursive token split function also handling ' and " (that is, when 'text' is a partial input with a string - not closed by a quote). """ - text = text.lstrip() - try: - tokens = shlex.split(text + suffix[0]) - except ValueError: - return self._get_tokens(text, suffix[1:]) - except IndexError: - return [] - if len(tokens) > 0: - cmd = tokens[0] - if len(tokens) > 2 and getattr(self.commands.get(cmd), "single_arg", False): - tokens = [cmd, " ".join(tokens[1:])] - elif len(tokens) > 3: - tokens = [cmd, tokens[1], " ".join(tokens[2:])] - return tokens - - def _reset_logname(self): - """ Reset logger's name according to console's attributes. """ - try: - self.logger.name = "{}:{}".format(self.level, self.logname) - except AttributeError: - self.logger.name = self.__class__.name - - def _run_if_defined(self, func): - """ Run the given function if it is defined at the module level. """ - if hasattr(self, "module") and hasattr(self.module, func) and \ - not (getattr(self.module._instance, func)() is None): - self.logger.debug("{} failed".format(func)) - return False - return True - - def _sources(self, items): - """ Return the list of sources for the related items [banners|entities|libraries], first trying subclass' one - then Console class' one. Also, resolve paths relative to the path where the parent Console is found. """ - src = self.sources.get(items, Console.sources[items]) - if isinstance(src, (str, Path)): - src = [src] - return [Path(self._root.dirname.joinpath(s).expanduser().resolve()) for s in (src or [])] - - def attach(self, eccls, directref=False, backref=True): - """ Attach an entity child to the calling entity's instance. """ - # handle direct reference from self to eccls - if directref: - # attach new class - setattr(self, eccls.entity, eccls) - # handle back reference from eccls to self - if backref: - setattr(eccls, "console", self) - # create a singleton instance of the entity - eccls._instance = getattr(eccls, "_instance", None) or eccls() - - def detach(self, eccls=None): - """ Detach an entity child class from the console and remove its back-reference. """ - # if no argument, detach every class registered in self._attached - if eccls is None: - for subcls in Entity._subclasses: - self.detach(subcls) - elif eccls in ["command", "module"]: - for ec in [Command, Module][eccls == "module"].subclasses: - if ec.entity == eccls: - self.detach(ec) - else: - if hasattr(eccls, "entity") and hasattr(self, eccls.entity): - delattr(self, eccls.entity) - # remove the singleton instance of the entity previously opened - if hasattr(eccls, "_instance"): - del eccls._instance - - def execute(self, cmd, abort=False): - """ Alias for run. """ - return self.run(cmd, abort) - - def play(self, *commands, capture=False): - """ Execute a list of commands. """ - global _output - if capture: - r = [] - error = False - w, _ = get_terminal_size() - for c in commands: - if capture: - if error: - r.append((c, None, None)) - continue - __tmp = _output - _output = _CaptureOutput() - error = not self.run(c, True) - r.append((c, str(_output))) - _output = __tmp - else: - print_formatted_text("\n" + (" " + c + " ").center(w, "+") + "\n") - if not self.run(c, True): - break - if capture: - if r[-1][0] == "exit": - r.pop(-1) - return r - - def rcfile(self, rcfile, capture=False): - """ Execute commands from a .rc file. """ - with open(rcfile) as f: - commands = [c.strip() for c in f] - return self.play(*commands, capture) - - def reset(self): - """ Setup commands for the current level, reset bindings between commands and the current console then update - store's object. """ - self.detach("command") - # setup level's commands, starting from general-purpose commands - self.commands = {} - # add commands - for n, c in chain(Command.commands.get("general", {}).items(), Command.commands.get(self.level, {}).items()): - self.attach(c) - if self.level not in getattr(c, "except_levels", []) and c.check(): - self.commands[n] = c - else: - self.detach(c) - root = self.config.option('WORKSPACE').value - # get the relevant store and bind it to loaded models - Console.store = Console._storage.get(Path(root).joinpath("store.db")) - # update command recorder's root directory - self._recorder.root_dir = root - - def run(self, cmd, abort=False): - """ Run a framework console command. """ - # assign tokens (or abort if tokens' split gives []) - tokens = self._get_tokens(cmd) - try: - name, args = tokens[0], tokens[1:] - except IndexError: - if abort: - raise - return True - # get the command singleton instance (or abort if name not in self.commands) ; if command arguments should not - # be split, adapt args - try: - obj = self.commands[name]._instance - except KeyError: - if abort: - raise - return True - # now handle the command (and its validation if existing) - try: - if hasattr(obj, "validate"): - obj.validate(*args) - if name != "run" or self._run_if_defined("prerun"): - obj.run(*args) - if name == "run": - self._run_if_defined("postrun") - return True - except BdbQuit: # when using pdb.set_trace() - return True - except ConsoleDuplicate as e: - # pass the higher console instance attached to the exception raised from within a command's .run() execution - # to console's .start(), keeping the current command to be reexecuted - raise ConsoleDuplicate(e.current, e.higher, cmd if e.cmd is None else e.cmd) - except ConsoleExit: - return False - except ValueError as e: - if str(e).startswith("invalid width ") and str(e).endswith(" (must be > 0)"): - self.logger.warning("Cannot display ; terminal width too low") - else: - (self.logger.exception if self.config.option('DEBUG').value else self.logger.failure)(e) - return abort is False - except Exception as e: - self.logger.exception(e) - return abort is False - finally: - gc.collect() - - def start(self): - """ Start looping with console's session prompt. """ - reexec = None - self._reset_logname() - self.logger.debug("Starting {}[{}]".format(self.__class__.__name__, id(self))) - # execute attached module's pre-load function if relevant - self._run_if_defined("preload") - # now start the console loop - while True: - self._reset_logname() - try: - c = reexec if reexec is not None else self._session.prompt( - auto_suggest=AutoSuggestFromHistory(), - #bottom_toolbar="This is\na multiline toolbar", # note: this disables terminal scrolling - #mouse_support=True, - ) - reexec = None - Console._recorder.save(c) - if not self.run(c): - break # console run aborted - except ConsoleDuplicate as e: - # stop raising duplicate when reaching a console with a different level, then reset associated commands - # not to rerun the erroneous one from the context of the just-exited console - if self == e.higher: - reexec = e.cmd - self.reset() - continue - self._close() - # reraise up to the higher (level) console - raise e - except EOFError: - Console._recorder.save("exit") - break - except (KeyboardInterrupt, ValueError): - continue - # execute attached module's post-load function if relevant - self._run_if_defined("postload") - # gracefully close and chain this console instance - self._close() - return self - - @property - def logger(self): - try: - return Console._logger - except: - return null_logger - - @property - def modules(self): - return Module.modules - - @property - def prompt(self): - if self.parent is None: - return self.message, self.style - # setup the prompt message by adding child's message tokens at the end of parent's one (parent's last token is - # then re-appended) if it shall not be reset, otherwise reset it then set child's tokens - if getattr(self, "message_reset", False): - return self.message, self.style - pmessage, pstyle = self.parent.prompt - message = pmessage.copy() # copy parent message tokens - t = message.pop() - message.extend(self.message) - message.append(t) - # setup the style, using this of the parent - style = pstyle.copy() # copy parent style dict - style.update(self.style) - return message, style - - @property - def root(self): - return Console.parent - - @property - def sessions(self): - return list(self._sessions) - - @property - def state(self): - """ Getter for the shared state. """ - return Console._state - - @property - def uptime(self): - """ Get application's uptime. """ - t = datetime.now() - Console.parent._start_time - s = t.total_seconds() - h, _ = divmod(s, 3600) - m, s = divmod(_, 60) - return "{:02}:{:02}:{:02}".format(int(h), int(m), int(s)) - - -class ConsoleDuplicate(Exception): - """ Dedicated exception class for exiting a duplicate (sub)console. """ - def __init__(self, current, higher, cmd=None): - self.cmd, self.current, self.higher = cmd, current, higher - super(ConsoleDuplicate, self).__init__("Another console of the same level is already running") - - -class ConsoleExit(SystemExit): - """ Dedicated exception class for exiting a (sub)console. """ - pass - - -class FrameworkConsole(Console): - """ Framework console subclass for defining specific config options. """ - _entity_class = Console - aliases = [] - config = Config({ - Option( - 'APP_FOLDER', - "folder where application assets (i.e. logs) are saved", - True, - #set_callback=lambda o: o.root._set_app_folder(debug=o.config.option('DEBUG').value), - glob=False, - ): "~/.{appname}", - ROption( - 'DEBUG', - "debug mode", - True, - bool, - set_callback=lambda o: o.root._set_logging(o.value), - glob=False, - ): "false", - ROption( - 'TEXT_EDITOR', - "text file editor to be used", - False, - choices=lambda: filter_bin(*EDITORS), - validate=lambda s, v: which(v) is not None, - glob=False, - ): DEFAULT_EDITOR, - ROption( - 'TEXT_VIEWER', - "text file viewer (pager) to be used", - False, - choices=lambda: filter_bin(*VIEWERS), - validate=lambda s, v: which(v) is not None, - glob=False, - ): DEFAULT_VIEWER, - Option( - 'ENCRYPT_PROJECT', - "ask for a password to encrypt a project when archiving", - True, - bool, - glob=False, - ): "true", - Option( - 'WORKSPACE', - "folder where results are saved", - True, - set_callback=lambda o: o.root._set_workspace(), - glob=False, - ): "~/Notes", - }) - - def __init__(self, appname=None, *args, **kwargs): - Console._dev_mode = kwargs.pop("dev", False) - Console.appname = appname or getattr(self, "appname", Console.appname) - self.opt_prefix = "Console" - o, v = self.config.option('APP_FOLDER'), str(self.config['APP_FOLDER']) - self.config[o] = Path(v.format(appname=self.appname.lower())) - o.old_value = None - self.config['DEBUG'] = kwargs.get('debug', False) - self._set_app_folder(silent=True, **kwargs) - self._set_workspace() - super(FrameworkConsole, self).__init__(*args, **kwargs) - - def __set_folder(self, option, subpath=""): - """ Set a new folder, moving an old to the new one if necessary. """ - o = self.config.option(option) - old, new = o.old_value, o.value - if old == new: - return - try: - if old is not None: - os.rename(old, new) - except Exception as e: - pass - Path(new).joinpath(subpath).mkdir(parents=True, exist_ok=True) - return new - - def _set_app_folder(self, **kwargs): - """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ - self._files.root_dir = self.__set_folder("APP_FOLDER", "files") - self._set_logging(**kwargs) # this is necessary as the log file is located in APP_FOLDER - - def _set_logging(self, debug=False, to_file=True, **kwargs): - """ Set a new logger with the input logging level. """ - l, p1, p2, dev = "INFO", None, None, Console._dev_mode - if debug: - l = "DETAIL" if Console._dev_mode else "DEBUG" - if to_file: - # attach a logger to the console - lpath = self.app_folder.joinpath("logs") - lpath.mkdir(parents=True, exist_ok=True) - p1 = str(lpath.joinpath("main.log")) - if dev: - p2 = str(lpath.joinpath("debug.log")) - if l == "INFO" and not kwargs.get('silent', False): - self.logger.debug("Set logging to INFO") - Console._logger = get_logger(self.appname.lower(), p1, l) - # setup framework's logger with its own get_logger function (configuring other handlers than the default one) - set_logging_level(l, self.appname.lower(), config_func=lambda lgr, lvl: get_logger(lgr.name, p1, lvl)) - # setup internal (dev) loggers with the default logging.configLogger (enhancement to logging from Tinyscript) - set_logging_level(l, "core", config_func=lambda lgr, lvl: get_logger(lgr.name, p2, lvl, True, dev)) - if l != "INFO" and not kwargs.get('silent', False): - self.logger.debug("Set logging to {}".format(l)) - - def _set_workspace(self): - """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ - self.__set_folder("WORKSPACE") - - @property - def app_folder(self): - """ Shortcut to the current application folder. """ - return Path(self.config.option('APP_FOLDER').value) - - @property - def workspace(self): - """ Shortcut to the current workspace. """ - return Path(self.config.option("WORKSPACE").value) - +# -*- coding: UTF-8 -*- +import gc +import io +import os +import shlex +import sys +from asciistuff import get_banner, get_quote +from bdb import BdbQuit +from datetime import datetime +from inspect import getfile, isfunction +from itertools import chain +from prompt_toolkit import print_formatted_text as print_ft, PromptSession +from prompt_toolkit.application.current import get_app_session +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.formatted_text import ANSI, FormattedText +from prompt_toolkit.history import FileHistory +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.styles import Style +from random import choice +from shutil import which +from tinyscript.helpers import filter_bin, get_terminal_size, parse_docstring, Capture, Path + +from .command import * +from .components import * +from .entity import * +from .model import * +from .module import * + + +__all__ = [ + "Entity", + # subclassable main entities + "BaseModel", "Command", "Console", "Model", "Module", "StoreExtension", + # console-related classes + "Config", "ConsoleExit", "ConsoleDuplicate", "FrameworkConsole", "Option", +] + +EDITORS = ["atom", "emacs", "gedit", "mousepad", "nano", "notepad", "notepad++", "vi", "vim"] +VIEWERS = ["bat", "less"] +try: + DEFAULT_EDITOR = filter_bin(*EDITORS)[-1] +except IndexError: + DEFAULT_EDITOR = None +try: + DEFAULT_VIEWER = filter_bin(*VIEWERS)[0] +except IndexError: + DEFAULT_VIEWER = None + +_output = get_app_session().output +dcount = lambda d, n=0: sum([dcount(v, n) if isinstance(v, dict) else n + 1 for v in d.values()]) +logger = get_logger("core.console") + + +def print_formatted_text(*args, **kwargs): + """ Proxy function that uses the global (capturable) _output. """ + kwargs['output'] = kwargs.get('output', _output) + return print_ft(*args, **kwargs) + + +class _CaptureOutput(DummyOutput): + def __init__(self): + self.__file = io.StringIO() + + def __str__(self): + return self.__file.getvalue().strip() + + def write(self, data): + self.__file.write(data) + + +class MetaConsole(MetaEntity): + """ Metaclass of a Console. """ + _has_config = True + + +class Console(Entity, metaclass=MetaConsole): + """ Base console class. """ + # convention: mangled attributes should not be customized when subclassing Console... + _files = FilesManager() + _jobs = JobsPool() + _recorder = Recorder() + _sessions = SessionsManager() + _state = {} # state shared between all the consoles + _storage = StoragePool(StoreExtension) + # ... by opposition to public class attributes that can be tuned + appname = "" + config = Config() + exclude = [] + level = ROOT_LEVEL + message = PROMPT_FORMAT + motd = """ + + """ + parent = None + sources = SOURCES + style = PROMPT_STYLE + + def __init__(self, parent=None, **kwargs): + fail = kwargs.pop("fail", True) + super(Console, self).__init__() + # determine the relevant parent + self.parent = parent + if self.parent is not None and self.parent.level == self.level: + while parent is not None and parent.level == self.level: + parent = parent.parent # go up of one console level + # raise an exception in the context of command's .run() execution, to be propagated to console's .run() + # execution, setting the directly higher level console in argument + raise ConsoleDuplicate(self, parent) + # back-reference the console + self.config.console = self + # configure the console regarding its parenthood + if self.parent is None: + if Console.parent is not None: + raise Exception("Only one parent console can be used") + Console.parent = self + Console.parent._start_time = datetime.now() + Console.appdispname = Console.appname + Console.appname = Console.appname.lower() + self._root = Path(getfile(self.__class__)).resolve() + self.__init(**kwargs) + else: + self.parent.child = self + # reset commands and other bound stuffs + self.reset() + # setup the session with the custom completer and validator + completer, validator = CommandCompleter(), CommandValidator(fail) + completer.console = validator.console = self + message, style = self.prompt + self._session = PromptSession( + message, + completer=completer, + history=FileHistory(Path(self.config.option("WORKSPACE").value).joinpath("history")), + validator=validator, + style=Style.from_dict(style), + ) + CustomLayout(self) + + def __init(self, **kwargs): + """ Initialize the parent console with commands and modules. """ + # setup banners + try: + bsrc = str(choice(self._sources("banners"))) + print_formatted_text("") + # display a random banner from the banners folder + get_banner_func = kwargs.get('get_banner_func', get_banner) + banner_colors = kwargs.get('banner_section_styles', {}) + text = get_banner_func(self.appdispname, bsrc, styles=banner_colors) + if text: + print_formatted_text(ANSI(text)) + # display a random quote from quotes.csv (in the banners folder) + get_quote_func = kwargs.get('get_quote_func', get_quote) + try: + text = get_quote_func(os.path.join(bsrc, "quotes.csv")) + if text: + print_formatted_text(ANSI(text)) + except ValueError: + pass + except IndexError: + pass + # setup libraries + for lib in self._sources("libraries"): + sys.path.insert(0, str(lib)) + # setup entities + self._load_kwargs = {'include_base': kwargs.get("include_base", True), + 'select': kwargs.get("select", {'command': Command._functionalities}), + 'exclude': kwargs.get("exclude", {}), + 'backref': kwargs.get("backref", BACK_REFERENCES), + 'docstr_parser': kwargs.get("docstr_parser", parse_docstring), + 'remove_cache': True} + load_entities([BaseModel, Command, Console, Model, Module, StoreExtension], + *([self._root] + self._sources("entities")), **self._load_kwargs) + Console._storage.models = Model.subclasses + BaseModel.subclasses + # display module stats + print_formatted_text(FormattedText([("#00ff00", Module.get_summary())])) + # setup the prompt message + self.message.insert(0, ('class:appname', self.appname)) + # display warnings + self.reset() + if Entity.has_issues(): + self.logger.warning("There are some issues ; use 'show issues' to see more details") + # console's components back-referencing + for attr in ["_files", "_jobs", "_sessions"]: + setattr(getattr(Console, attr), "console", self) + + def _close(self): + """ Gracefully close the console. """ + self.logger.debug("Exiting {}[{}]".format(self.__class__.__name__, id(self))) + if hasattr(self, "close") and isfunction(self.close): + self.close() + # cleanup references for this console + self.detach() + # important note: do not confuse '_session' (refers to prompt session) with sessions (sessions manager) + if hasattr(self, "_session"): + delattr(self._session.completer, "console") + delattr(self._session.validator, "console") + # remove the singleton instance of the current console + c = self.__class__ + if hasattr(c, "_instance"): + del c._instance + if self.parent is not None: + del self.parent.child + # rebind entities to the parent console + self.parent.reset() + # remove all finished jobs from the pool + self._jobs.free() + else: + # gracefully close every DB in the pool + self._storage.free() + # terminate all running jobs + self._jobs.terminate() + + def _get_tokens(self, text, suffix=("", "\"", "'")): + """ Recursive token split function also handling ' and " (that is, when 'text' is a partial input with a string + not closed by a quote). """ + text = text.lstrip() + try: + tokens = shlex.split(text + suffix[0]) + except ValueError: + return self._get_tokens(text, suffix[1:]) + except IndexError: + return [] + if len(tokens) > 0: + cmd = tokens[0] + if len(tokens) > 2 and getattr(self.commands.get(cmd), "single_arg", False): + tokens = [cmd, " ".join(tokens[1:])] + elif len(tokens) > 3: + tokens = [cmd, tokens[1], " ".join(tokens[2:])] + return tokens + + def _reset_logname(self): + """ Reset logger's name according to console's attributes. """ + try: + self.logger.name = "{}:{}".format(self.level, self.logname) + except AttributeError: + self.logger.name = self.__class__.name + + def _run_if_defined(self, func): + """ Run the given function if it is defined at the module level. """ + if hasattr(self, "module") and hasattr(self.module, func) and \ + not (getattr(self.module._instance, func)() is None): + self.logger.debug("{} failed".format(func)) + return False + return True + + def _sources(self, items): + """ Return the list of sources for the related items [banners|entities|libraries], first trying subclass' one + then Console class' one. Also, resolve paths relative to the path where the parent Console is found. """ + src = self.sources.get(items, Console.sources[items]) + if isinstance(src, (str, Path)): + src = [src] + return [Path(self._root.dirname.joinpath(s).expanduser().resolve()) for s in (src or [])] + + def attach(self, eccls, directref=False, backref=True): + """ Attach an entity child to the calling entity's instance. """ + # handle direct reference from self to eccls + if directref: + # attach new class + setattr(self, eccls.entity, eccls) + # handle back reference from eccls to self + if backref: + setattr(eccls, "console", self) + # create a singleton instance of the entity + eccls._instance = getattr(eccls, "_instance", None) or eccls() + + def detach(self, eccls=None): + """ Detach an entity child class from the console and remove its back-reference. """ + # if no argument, detach every class registered in self._attached + if eccls is None: + for subcls in Entity._subclasses: + self.detach(subcls) + elif eccls in ["command", "module"]: + for ec in [Command, Module][eccls == "module"].subclasses: + if ec.entity == eccls: + self.detach(ec) + else: + if hasattr(eccls, "entity") and hasattr(self, eccls.entity): + delattr(self, eccls.entity) + # remove the singleton instance of the entity previously opened + if hasattr(eccls, "_instance"): + del eccls._instance + + def execute(self, cmd, abort=False): + """ Alias for run. """ + return self.run(cmd, abort) + + def play(self, *commands, capture=False): + """ Execute a list of commands. """ + global _output + if capture: + r = [] + error = False + try: + w, _ = get_terminal_size() + except TypeError: + w = 80 + for c in commands: + if capture: + if error: + r.append((c, None, None)) + continue + __tmp = _output + _output = _CaptureOutput() + error = not self.run(c, True) + r.append((c, str(_output))) + _output = __tmp + else: + print_formatted_text("\n" + (" " + c + " ").center(w, "+") + "\n") + if not self.run(c, True): + break + if capture: + if r[-1][0] == "exit": + r.pop(-1) + return r + + def rcfile(self, rcfile, capture=False): + """ Execute commands from a .rc file. """ + with open(rcfile) as f: + commands = [c.strip() for c in f] + return self.play(*commands, capture) + + def reset(self): + """ Setup commands for the current level, reset bindings between commands and the current console then update + store's object. """ + self.detach("command") + # setup level's commands, starting from general-purpose commands + self.commands = {} + # add commands + for n, c in chain(Command.commands.get("general", {}).items(), Command.commands.get(self.level, {}).items()): + self.attach(c) + if self.level not in getattr(c, "except_levels", []) and c.check(): + self.commands[n] = c + else: + self.detach(c) + root = self.config.option('WORKSPACE').value + # get the relevant store and bind it to loaded models + Console.store = Console._storage.get(Path(root).joinpath("store.db")) + # update command recorder's root directory + self._recorder.root_dir = root + + def run(self, cmd, abort=False): + """ Run a framework console command. """ + # assign tokens (or abort if tokens' split gives []) + tokens = self._get_tokens(cmd) + try: + name, args = tokens[0], tokens[1:] + except IndexError: + if abort: + raise + return True + # get the command singleton instance (or abort if name not in self.commands) ; if command arguments should not + # be split, adapt args + try: + obj = self.commands[name]._instance + except KeyError: + if abort: + raise + return True + # now handle the command (and its validation if existing) + try: + if hasattr(obj, "validate"): + obj.validate(*args) + if name != "run" or self._run_if_defined("prerun"): + obj.run(*args) + if name == "run": + self._run_if_defined("postrun") + return True + except BdbQuit: # when using pdb.set_trace() + return True + except ConsoleDuplicate as e: + # pass the higher console instance attached to the exception raised from within a command's .run() execution + # to console's .start(), keeping the current command to be reexecuted + raise ConsoleDuplicate(e.current, e.higher, cmd if e.cmd is None else e.cmd) + except ConsoleExit: + return False + except ValueError as e: + if str(e).startswith("invalid width ") and str(e).endswith(" (must be > 0)"): + self.logger.warning("Cannot display ; terminal width too low") + else: + (self.logger.exception if self.config.option('DEBUG').value else self.logger.failure)(e) + return abort is False + except Exception as e: + self.logger.exception(e) + return abort is False + finally: + gc.collect() + + def start(self): + """ Start looping with console's session prompt. """ + reexec = None + self._reset_logname() + self.logger.debug("Starting {}[{}]".format(self.__class__.__name__, id(self))) + # execute attached module's pre-load function if relevant + self._run_if_defined("preload") + # now start the console loop + while True: + self._reset_logname() + try: + c = reexec if reexec is not None else self._session.prompt( + auto_suggest=AutoSuggestFromHistory(), + #bottom_toolbar="This is\na multiline toolbar", # note: this disables terminal scrolling + #mouse_support=True, + ) + reexec = None + Console._recorder.save(c) + if not self.run(c): + break # console run aborted + except ConsoleDuplicate as e: + # stop raising duplicate when reaching a console with a different level, then reset associated commands + # not to rerun the erroneous one from the context of the just-exited console + if self == e.higher: + reexec = e.cmd + self.reset() + continue + self._close() + # reraise up to the higher (level) console + raise e + except EOFError: + Console._recorder.save("exit") + break + except (KeyboardInterrupt, ValueError): + continue + # execute attached module's post-load function if relevant + self._run_if_defined("postload") + # gracefully close and chain this console instance + self._close() + return self + + @property + def logger(self): + try: + return Console._logger + except: + return null_logger + + @property + def modules(self): + return Module.modules + + @property + def prompt(self): + if self.parent is None: + return self.message, self.style + # setup the prompt message by adding child's message tokens at the end of parent's one (parent's last token is + # then re-appended) if it shall not be reset, otherwise reset it then set child's tokens + if getattr(self, "message_reset", False): + return self.message, self.style + pmessage, pstyle = self.parent.prompt + message = pmessage.copy() # copy parent message tokens + t = message.pop() + message.extend(self.message) + message.append(t) + # setup the style, using this of the parent + style = pstyle.copy() # copy parent style dict + style.update(self.style) + return message, style + + @property + def root(self): + return Console.parent + + @property + def sessions(self): + return list(self._sessions) + + @property + def state(self): + """ Getter for the shared state. """ + return Console._state + + @property + def uptime(self): + """ Get application's uptime. """ + t = datetime.now() - Console.parent._start_time + s = t.total_seconds() + h, _ = divmod(s, 3600) + m, s = divmod(_, 60) + return "{:02}:{:02}:{:02}".format(int(h), int(m), int(s)) + + +class ConsoleDuplicate(Exception): + """ Dedicated exception class for exiting a duplicate (sub)console. """ + def __init__(self, current, higher, cmd=None): + self.cmd, self.current, self.higher = cmd, current, higher + super(ConsoleDuplicate, self).__init__("Another console of the same level is already running") + + +class ConsoleExit(SystemExit): + """ Dedicated exception class for exiting a (sub)console. """ + pass + + +class FrameworkConsole(Console): + """ Framework console subclass for defining specific config options. """ + _entity_class = Console + aliases = [] + config = Config({ + Option( + 'APP_FOLDER', + "folder where application assets (i.e. logs) are saved", + True, + #set_callback=lambda o: o.root._set_app_folder(debug=o.config.option('DEBUG').value), + glob=False, + ): "~/.{appname}", + ROption( + 'DEBUG', + "debug mode", + True, + bool, + set_callback=lambda o: o.root._set_logging(o.value), + glob=False, + ): "false", + ROption( + 'TEXT_EDITOR', + "text file editor to be used", + False, + choices=lambda: filter_bin(*EDITORS), + validate=lambda s, v: which(v) is not None, + glob=False, + ): DEFAULT_EDITOR, + ROption( + 'TEXT_VIEWER', + "text file viewer (pager) to be used", + False, + choices=lambda: filter_bin(*VIEWERS), + validate=lambda s, v: which(v) is not None, + glob=False, + ): DEFAULT_VIEWER, + Option( + 'ENCRYPT_PROJECT', + "ask for a password to encrypt a project when archiving", + True, + bool, + glob=False, + ): "true", + Option( + 'WORKSPACE', + "folder where results are saved", + True, + set_callback=lambda o: o.root._set_workspace(), + glob=False, + ): "~/Notes", + }) + + def __init__(self, appname=None, *args, **kwargs): + Console._dev_mode = kwargs.pop("dev", False) + Console.appname = appname or getattr(self, "appname", Console.appname) + self.opt_prefix = "Console" + o, v = self.config.option('APP_FOLDER'), str(self.config['APP_FOLDER']) + self.config[o] = Path(v.format(appname=self.appname.lower())) + o.old_value = None + self.config['DEBUG'] = kwargs.get('debug', False) + self._set_app_folder(silent=True, **kwargs) + self._set_workspace() + super(FrameworkConsole, self).__init__(*args, **kwargs) + + def __set_folder(self, option, subpath=""): + """ Set a new folder, moving an old to the new one if necessary. """ + o = self.config.option(option) + old, new = o.old_value, o.value + if old == new: + return + try: + if old is not None: + os.rename(old, new) + except Exception as e: + pass + Path(new).joinpath(subpath).mkdir(parents=True, exist_ok=True) + return new + + def _set_app_folder(self, **kwargs): + """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ + self._files.root_dir = self.__set_folder("APP_FOLDER", "files") + self._set_logging(**kwargs) # this is necessary as the log file is located in APP_FOLDER + + def _set_logging(self, debug=False, to_file=True, **kwargs): + """ Set a new logger with the input logging level. """ + l, p1, p2, dev = "INFO", None, None, Console._dev_mode + if debug: + l = "DETAIL" if Console._dev_mode else "DEBUG" + if to_file: + # attach a logger to the console + lpath = self.app_folder.joinpath("logs") + lpath.mkdir(parents=True, exist_ok=True) + p1 = str(lpath.joinpath("main.log")) + if dev: + p2 = str(lpath.joinpath("debug.log")) + if l == "INFO" and not kwargs.get('silent', False): + self.logger.debug("Set logging to INFO") + Console._logger = get_logger(self.appname.lower(), p1, l) + # setup framework's logger with its own get_logger function (configuring other handlers than the default one) + set_logging_level(l, self.appname.lower(), config_func=lambda lgr, lvl: get_logger(lgr.name, p1, lvl)) + # setup internal (dev) loggers with the default logging.configLogger (enhancement to logging from Tinyscript) + set_logging_level(l, "core", config_func=lambda lgr, lvl: get_logger(lgr.name, p2, lvl, True, dev)) + if l != "INFO" and not kwargs.get('silent', False): + self.logger.debug("Set logging to {}".format(l)) + + def _set_workspace(self): + """ Set a new APP_FOLDER, moving an old to the new one if necessary. """ + self.__set_folder("WORKSPACE") + + @property + def app_folder(self): + """ Shortcut to the current application folder. """ + return Path(self.config.option('APP_FOLDER').value) + + @property + def workspace(self): + """ Shortcut to the current workspace. """ + return Path(self.config.option("WORKSPACE").value) + diff --git a/sploitkit/core/entity.py b/src/sploitkit/core/entity.py similarity index 97% rename from sploitkit/core/entity.py rename to src/sploitkit/core/entity.py index 17c6e8b..84edf06 100755 --- a/sploitkit/core/entity.py +++ b/src/sploitkit/core/entity.py @@ -1,684 +1,684 @@ -# -*- coding: UTF-8 -*- -import gc -import re -from collections import OrderedDict -from importlib.util import find_spec -from inspect import getfile, getmro -from shutil import which -from tinyscript.helpers import is_dict, is_function, is_list, is_str, merge_dict, parse_docstring, \ - BorderlessTable, ClassRegistry, Path, PythonPath - -from .components.config import Config, Option, ProxyConfig -from .components.logger import get_logger - - -__all__ = ["load_entities", "Entity", "MetaEntity", "MetaEntityBase"] - -ENTITIES = [] -logger = get_logger("core.entity") - - -def load_entities(entities, *sources, **kwargs): - """ Load every entity class of the given type found in the given source folders. - - :param sources: paths (either with ~, relative or absolute) to folders containing entity subclasses - :param include_base: include the base entities provided with the package - :param select: selected modules in the source folder - :param exclude: list of entity identifiers (in custom format, or simply the entity class) to be excluded - (useful when including the base but not every entity is required) - :param backref: list of attrs to get entity's class to be bound to - :param docstr_parser: user-defined docstring parser for populating metadata - :param remove_cache: remove Python file cache - """ - global ENTITIES - ENTITIES = [e.__name__ for e in entities] - sources = list(sources) - if kwargs.get("include_base", True): - # this allows to use sploitkit.base for starting a project with a baseline of entities - for n in ENTITIES: - n = n.lower() - for m in kwargs.get("select", {}).get(n, [""]): - m = "../base/{}s/".format(n) + m + [".py", ""][m == ""] - p = Path(__file__).parent.joinpath(m).resolve() - if p.exists(): - sources.insert(0, p) - # load every single source (folder of modules or single module) - for s in sources: - if not s.exists(): - logger.debug("Source does not exist: %s" % s) - continue - # bind the source to the entity main class - for e in entities: - e._source = str(s) - # now, it loads every Python module from the list of source folders ; when loading entity subclasses, these are - # registered to entity's registry for further use (i.e. from the console) - logger.debug("Loading Python source: %s" % s) - # important note: since version 1.23.17 of Tinyscript, support for cached compiled Python files has been added, - # for the PythonPath class, therefore influencing the location path of loaded entities (that - # is, adding __pycache__) - PythonPath(s, remove_cache=kwargs.get("remove_cache", False)) - for e in entities: - tbr = [] - # clean up the temporary attribute - if hasattr(e, "_source"): - delattr(e, "_source") - # remove proxy classes - n = e.__name__.lower() - for c in e.subclasses[:]: - if len(c.__subclasses__()) > 0: - getattr(e, "unregister_%s" % n, Entity.unregister_subclass)(c) - # handle specific entities or sets of entities exclusions ; this will remove them from Entity's registries - excludes = kwargs.get("exclude", {}).get(n) - if excludes is not None: - getattr(e, "unregister_%ss" % n, Entity.unregister_subclasses)(*excludes) - # handle conditional entities ; this will remove entities having a "condition" method returning False - for c in e.subclasses[:]: - # convention: conditional entities are unregistered and removed - if hasattr(c, "condition") and not c().condition(): - getattr(e, "unregister_%s" % n, Entity.unregister_subclass)(c) - # now populate metadata for each class - for c in e.subclasses: - set_metadata(c, kwargs.get("docstr_parser", parse_docstring)) - # bind entity's subclasses to the given attributes for back-reference - backrefs = kwargs.get("backref", {}).get(n) - if backrefs is not None: - for c in e.subclasses: - for br in backrefs: - try: - a, bn = br # [a]ttribute, [b]ackref [n]ame - except ValueError: - a, bn = None, br[0] if isinstance(br, tuple) else br - bc = list(filter(lambda _: _.__name__.lower() == bn, entities))[0] # [b]ackref [c]lass - if a and getattr(c, a, None): - c = getattr(c, a) - setattr(c, bn, lambda: bc._instance) - # then trigger garbage collection (for removed classes) - gc.collect() - - -def set_metadata(c, docstr_parser): - """ Set the metadata for an entity class given a docstring parser. - - :param c: entity subclass - :param docstr_parser: parsing function, taking 'c' as its input - """ - # populate metadata starting by parsing entity class' docstring - c._metadata = docstr_parser(c) - # "meta" or "metadata" attributes then have precedence on the docstr (because of .update()) - for a in ["meta", "metadata"]: - if hasattr(c, a): - c._metadata.update(getattr(c, a)) - try: - delattr(c, a) - except AttributeError: - pass # occurs when class 'c' has no 'meta' anymore, but its base class well - # if the metadata has options, create the config object - for o in c._metadata.pop("options", []): - try: - name, default, required, description = o - except ValueError: - raise ValueError("Bad option ; should be (name, default, required, description)") - c.config[Option(name, description, required)] = default - # dynamically declare properties for each metadata field - for attr, value in c._metadata.items(): - # let the precedence to already existing attributes - if attr not in c.__dict__.keys(): - setattr(c, attr, value) - # add inherited entity classes' metadata - b = c.__base__ - if b and getattr(c, "_inherit_metadata", False): - for b in c.__bases__: - if b is Entity: - continue - set_metadata(b, docstr_parser) - for k, v in b._metadata.items(): - if k not in c._metadata.keys(): - c._metadata[k] = v - - -class Entity(object): - """ Generic Entity class (i.e. a command or a module). """ - _applicable = True - _enabled = True - _metadata = {} - _subclasses = ClassRegistry() - - def __getattribute__(self, name): - if name == "config" and getattr(self.__class__, "_has_config", False): - c = ProxyConfig(self.__class__.config) - # merge parent config if relevant - if hasattr(self, "parent") and self.parent is not None and self.parent is not self: - c += self.parent.config - # back-reference the entity - setattr(c, self.__class__.entity, self) - return c - return super(Entity, self).__getattribute__(name) - - @property - def applicable(self): - """ Boolean indicating if the entity is applicable to the current context (i.e. of attached entities). """ - return self.__class__._applicable - - @property - def base_class(self): - """ Shortcut for accessing Entity, for use instead of __base__ which only leads to the direct base class. """ - return Entity - - @property - def cname(self): - """ Subclass name. """ - return self.__class__.__name__ - - @property - def enabled(self): - """ Boolean indicating if the entity is enabled (i.e. if it has no missing requirement. """ - return self.__class__.enabled - - @classmethod - def check(cls, other_cls=None): - """ Check for entity's requirements. """ - cls = other_cls or cls - if cls is Entity: - good = True - for sc in Entity._subclasses: - good = good and sc.check() - return good - cls._enabled = True - errors = {} - # check for requirements using an explicitely defined function - try: - cls._enabled = cls.check_requirements(cls) - # unpack requirement messages dictionary if relevant - cls._enabled, err = cls._enabled - errors.update(err or {}) - except (AttributeError, TypeError, ValueError): - pass - - def _checkv(func, args, not_): - """ internal convenience function for handling a NOT operator with an input item """ - r = func(*args) if isinstance(args, tuple) else func(args) - return not_ ^ r - - def _unpackv(item): - """ internal convenience function for unpacking a NOT operator from an input item """ - not_ = False - # handle a NOT operator for the given item - if item.startswith("!"): - not_ = True - item = item[1:] - return not_, item - # check requirements from the related attribute - #TODO: also handle lambda functions for requirements - for k, v in getattr(cls, "requirements", {}).items(): - if is_str(v): - v = [v] - # checks if a config option is set to a particular value - if k == "config": - if not is_dict(v): - raise ValueError("Bad config requirements (should be a dictionary)") - for opt, exp_val in v.items(): - bad, cur_val = False, None - not_, opt = _unpackv(opt) - try: - cur_val = cls.config.option(opt.upper()).value - except KeyError: - pass - # compare current and expected values - if not _checkv(lambda v1, v2: v1 is v2 or v1 == v2, (cur_val, exp_val), not_): - cls._enabled = False - break - # checks if a file exists - elif k == "file": - if not is_list(v): - raise ValueError("Bad file requirements (should be a list/set/tuple)") - for fpath in v: - not_, fpath = _unpackv(fpath) - if not _checkv(lambda p: Path(cls.__file__).parent.joinpath(p).exists(), fpath, not_): - errors.setdefault("file", []) - errors["file"].append((fpath, not_)) - cls._enabled = False - # checks if a requirement wrt the console is met - elif k == "internal": - if not is_function(v): - raise ValueError("Bad internal requirement (should be a function)") - if not v(cls): - cls._enabled = False - # checks if a Python package is present - elif k == "python": - if not is_list(v): - raise ValueError("Bad python requirements (should be a list/set/tuple)") - for module in v: - if isinstance(module, tuple): - module, package = module - not_, module = _unpackv(module) - found = find_spec(module, package) - else: - not_, module = _unpackv(module) - package = module - found = find_spec(module) - if not _checkv(lambda _: found is not None, "", not_): - errors.setdefault("python", []) - errors["python"].append((package, not_)) - cls._enabled = False - # checks if a state variable is set to a particular value - elif k == "state": - if is_list(v): - skeys = {sk: None for sk in v} - elif is_dict(v): - skeys = v - else: - raise ValueError("Bad state requirements (should be a list/set/tuple or a dictionary)") - # catch Console from Entity's registered subclasses as Console cannot be imported in this module (cfr - # circular import) - Console = cls.get_class("Console") - _tmp = [] - # e.g. sk=INTERFACES and sv={None:[True,None,None]} - for sk, sv in skeys.items(): - # check if the state key exists - if sk not in Console._state.keys(): - _tmp.append(sk) - # check if the value (if defined) matches - elif sv is not None: - # e.g. cs={wlp4s0:[False,None,"[MAC_addr]"]} - cs = Console._state[sk] - # special case: state value is a dict - if is_dict(sv) and is_dict(cs): - check_key, l = True, list(sv.items()) - if len(l) == 1 and l[0][0] is None: - check_key = False - # case 1: classical dict - if check_key: - for ssk, ssv in sv.items(): - if ssk not in cs.keys() or cs[ssk] != ssv: - _tmp.append("{}={}".format(sk, sv)) - break - # case 2: {None: ...} - else: - # e.g. ssv=[True,None,None] - ssv = l[0][1] - if isinstance(ssv, (tuple, list)): - # e.g. this zips [True,None,None] and [False,None,"[MAC_addr]"] together - found = False - for values in zip(ssv, *list(cs.values())): - ref = values[0] - # None positional values are ignored - if ref is not None and ref in values[1:]: - found = True - if not found: - _tmp.append("{}?{}".format(sk, ref)) - elif is_dict(ssv): - # e.g. {monitor:True} - found = False - for sssk, sssv in ssv.items(): - for csd in cs.values(): - if sssv is None: - if sssk in csd.keys(): - found = True - break - elif csd.get(sssk) == sssv: - found = True - break - if not found: - v = ["{}:{}".format(sssk, sssv), sssv][sssv is None] - _tmp.append("{}?{}".format(sk, v)) - elif ssv not in cs.values(): - _tmp.append("{}?{}".format(sk, ssv)) - break - # exact match between any other type than dict - else: - if sv != Console._state[sk]: - _tmp.append("{}={}".format(sk, sv)) - if len(_tmp) > 0: - errors.setdefault("state", []) - errors['state'].extend(_tmp) - cls._enabled = False - # checks if a system package/binary is installed - elif k == "system": - for tool in v: - _ = tool.split("/") - if len(_) == 1: - package = None - not_, tool = _unpackv(tool) - elif len(_) == 2: - package, tool = _ - not_, package = _unpackv(package) - else: - raise ValueError("Bad system requirements (should be a list)") - if which(tool) is None: - if package is None: - errors.setdefault("tools", []) - errors["tools"].append((tool, not_)) - else: - errors.setdefault("packages", []) - errors["packages"].append((package, not_)) - cls._enabled = False - else: - raise ValueError("Unknown requirements type '{}'".format(k)) - cls._errors = errors - # check for applicability - cls._applicable = True - applies_to = getattr(cls, "applies_to", []) - if len(applies_to) > 0: - cls._applicable = False - chk = getattr(cls, "check_applicability", None) - if chk is not None: - cls._applicable = cls.check_applicability() - else: - # format: ("attr1", "attr2", ..., "attrN", "value") - # e.g.: ("module", "fullpath", "my/module/do_something") - for l in applies_to: - l, must_match, value = list(l[:-1]), l[-1], cls - while len(l) > 0: - value = getattr(value, l.pop(0), None) - if value and value == must_match: - cls._applicable = True - break - return cls._enabled and cls._applicable - - @classmethod - def get_class(cls, name): - """ Get a class (key) from _subclasses by name (useful when the class is not imported in the current scope). """ - return Entity._subclasses[name] - - @classmethod - def get_info(cls, *fields, show_all=False): - """ Display entity's metadata and other information. - - :param fields: metadata fields to be output - :param show_all: also include unselected fields, to be output behind the list of selected fields - """ - t = "" - if len(fields) == 0: - fields = [("name", "description"), ("author", "email", "version", "comments"), ("options",)] - # make a data table with the given fields and corresponding values - data, __used = [], [] - _ = lambda s: s.capitalize() + ":" - for field in fields: - if not isinstance(field, (list, tuple)): - field = (field,) - add_blankline = False - for f in field: - try: - f, alias = f.split("|", 1) - except: - alias = f - __used.append(f) - v = getattr(cls, f, "") - if v is None or len(v) == 0: - continue - elif isinstance(v, (list, tuple)): - data.append([_(alias), v[0]]) - for i in v[1:]: - data.append(["", i]) - else: - data.append([_(alias), v]) - add_blankline = True - if add_blankline: - data.append(["", ""]) - t = BorderlessTable(data, header=False).table + "\n" if len(data) > 0 else "" - # add other metadata if relevant - if show_all: - unused = set(cls._metadata.keys()) - set(__used) - if len(unused) > 0: - t += cls.get_info(*sorted(list(unused))) - return t.rstrip() + "\n" - - @classmethod - def get_issues(cls, subcls_name=None, category=None): - """ List issues as a text. """ - # message formatting function - def msg(scname, key, item): - # try to unpack item for handling the NOT operator - try: - item, not_ = item - except ValueError: - not_ = False - not_s = ["not ", ""][not_] - subcls = Entity.get_subclass(None, scname) - m = getattr(subcls, "requirements_messages", {}).get(key, {}).get(re.split(r"(\=|\?)", item, 1)[0]) - if m is not None: - return m.format(item) - if key == "file": - return "'{}' {}found".format(item, not_s) - elif key == "packages": - return "'{}' system package is {}installed".format(item, not_s) - elif key == "python": - return "'{}' Python package is {}installed".format(item, not_s) - elif key == "tools": - return "'{}' tool is {}installed".format(item, not_s) - elif key == "state": - item = re.split(r"(\=|\?)", item, 1) - if len(item) == 1: - return "'{}' state key is not defined".format(item[0]) - elif item[1] == "=": - return "'{}' state key does not match the expected value '{}'".format(item[0], item[2]) - elif item[1] == "?": - return "'{}' state key is expected to have value '{}' at least once".format(item[0], item[2]) - # list issues using the related class method - t = "\n" - d = OrderedDict() - # this regroups class names for error dictionaries that are the same in order to aggregate issues - for cname, scname, errors in Entity.issues(subcls_name, category): - e = str(errors) - d.setdefault(e, {}) - d[e].setdefault(cname, []) - d[e][cname].append(scname) - # this then displays the issues with their list of related entities having these same issues - for _, names in d.items(): - errors = list(Entity.issues(list(names.values())[0][0]))[0][-1] - t = "" - for cname, scnames in names.items(): - scnames = list(set(scnames)) - cname += ["", "s"][len(scnames) > 1] - t += "{}: {}\n".format(cname, ", ".join(sorted(scnames))) - t += "- " + "\n- ".join(msg(scname, k, e) for k, err in errors.items() for e in err) + "\n" - return "" if t.strip() == "" else t - - @classmethod - def get_subclass(cls, key, name): - """ Get a subclass (value) from _subclasses by name (useful when the related class is not imported in the - current scope). """ - return Entity._subclasses[key, name] - - @classmethod - def has_issues(cls, subcls_name=None, category=None): - """ Tell if issues were encountered while checking entities. """ - for _ in cls.issues(subcls_name, category): - return True - return False - - @classmethod - def issues(cls, subcls_name=None, category=None): - """ List issues encountered while checking all the entities. """ - cls.check() - sc = Entity._subclasses - for c, l in sc.items() if cls is Entity else [cls, cls.subclasses] if cls in sc.keys() \ - else [(cls._entity_class, [cls])]: - for subcls in l: - e = {} - for b in subcls.__bases__: - # do not consider base classes without the issues method (e.g. mixin classes) - if not hasattr(b, "issues"): - continue - # break when at parent entity level - if b in Entity._subclasses.keys() or b is Entity: - break - # update the errors dictionary starting with proxy classes - for _, __, errors in b.issues(category=category): - for categ, i in errors.items(): - e.setdefault(categ, []) - e[categ].extend(i) - # now update the errors dictionary of the selected subclass - if hasattr(subcls, "_errors") and len(subcls._errors) > 0: - for categ, i in subcls._errors.items(): - if category in [None, categ]: - e.setdefault(categ, []) - e[categ].extend(i) - if len(e) > 0: - for categ, i in e.items(): # [categ]ory, [i]ssues - e[categ] = list(sorted(set(i))) - n = subcls.__name__ - if subcls_name in [None, n]: - yield c.__name__, n, e - - @classmethod - def register_subclass(cls, subcls): - """ Maintain a registry of subclasses inheriting from Entity. """ - # get the base entity class - ecls = subcls._entity_class - Entity._subclasses.setdefault(ecls, []) - if subcls not in Entity._subclasses[ecls]: - # now register the subcls, ensured to be an end-subclass of the entity - Entity._subclasses[ecls].append(subcls) - # back-reference the entity from its config if existing - if getattr(cls, "_has_config", False): - setattr(subcls.config, "_" + subcls.entity, lambda: subcls._instance) - # manually get subclass' name because of MetaModel not having the "name" property (would be considered a - # Peewee database field) - n = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', subcls.__name__)).lower() - logger.detail("Registered {} '{}'".format(subcls.entity, n)) - - @classmethod - def unregister_subclass(cls, subcls): - """ Remove an entry from the registry of subclasses. """ - # manually get subclass' name because of MetaModel not having the "name" property (would be considered a Peewee - # database field) - n = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', subcls.__name__)).lower() - if cls in Entity._subclasses.keys(): - try: - Entity._subclasses[cls].remove(subcls) - logger.detail("Unregistered {} '{}'".format(subcls.entity, n)) - except ValueError: - pass - - @classmethod - def unregister_subclasses(cls, *subclss): - """ Remove entries from the registry of subclasses. """ - for subcls in subclss: - cls.unregister_subclass(subcls) - - def run(self, *args, **kwargs): - """ Generic method for running Entity's logic. """ - raise NotImplementedError("{}'s run() method is not implemented".format(self.__class__.__name__)) - - -class MetaEntityBase(type): - """ Metaclass of an Entity, registering all its instances. """ - def __new__(meta, name, bases, clsdict, subcls=None): - subcls = subcls or type.__new__(meta, name, bases, clsdict) - if len(ENTITIES) > 0: - mro_bases = list(getmro(subcls)) - # do not register non-entity classes or base entities - while len(mro_bases) > 0 and mro_bases[-1] is not Entity: - mro_bases.pop() - if len(mro_bases) <= 2 or subcls.__name__ in ENTITIES: - return subcls - # set the base entity class - try: - subcls._entity_class = [c for c in mro_bases if c.__name__ in ENTITIES][0] - except IndexError: - return subcls - # trigger class registration - for b in bases: - if not hasattr(subcls, "registered") or not subcls.registered: - b.register_subclass(subcls) - try: # OPTIONAL: execute entity's own register method - getattr(b, "register_" + subcls.entity)(subcls) - except AttributeError: - pass - return subcls - - def __repr__(self): - return "<%s: %s>" % (self.entity.capitalize(), self.__name__) - - @property - def entity(self): - """ Normalized base entity name. """ - try: - return self._entity_class.__name__.lower() - except AttributeError: - return "entity" - - @property - def identifier(self): - """ Compute a unique identifier for this entity subclass. """ - f = Path(getattr(self, "__file__", getfile(self))) - d, fn = f.dirname, f.filename - if len(d.parts) > 0 and d.parts[-1] == "__pycache__": - parts = fn.split(".") - if re.match(r".?python\-?[23]\d", parts[-2]): - parts.pop(-2) - parts[-1] = "py" - f = d.parent.joinpath(".".join(parts)) - return str(f), self.__name__ - - @property - def registered(self): - """ Boolean indicating if the entity is already registered. """ - e = self._entity_class - Entity._subclasses.setdefault(e, []) - return self.identifier in [x.identifier for x in Entity._subclasses[e]] - - @property - def subclasses(self): - """ List of all classes of the current entity. """ - return Entity._subclasses.get(self, []) - - -class MetaEntity(MetaEntityBase): - """ Metaclass of an Entity, adding some particular properties. """ - def __getattribute__(self, name): - if name == "config" and getattr(self, "_has_config", False): - if "config" not in self.__dict__: - setattr(self, "config", Config()) - c = self.__dict__['config'] - # back-reference the entity - if hasattr(self, "_instance"): - setattr(c, self.entity, self._instance) - c = ProxyConfig(c) - if hasattr(self, "_entity_class"): - for b in self.__bases__: - if b == self._entity_class: - break - _ = getattr(b, "config", None) - if _: - c += _ - return c - elif name in ["requirements", "requirements_messages"]: - r = {} - if hasattr(self, "_entity_class"): - for b in self.__bases__[::-1]: - if b == self._entity_class: - break - merge_dict(r, getattr(b, name, {})) - merge_dict(r, self.__dict__.get(name, {})) - return r - return super(MetaEntity, self).__getattribute__(name) - - @property - def applicable(self): - """ Boolean indicating if the entity is applicable to the current context (i.e. of attached entities). """ - self.check() - return self._applicable - - @property - def enabled(self): - """ Boolean indicating if the entity is enabled (i.e. if it has no missing requirement. """ - self.check() - return self._enabled - - @property - def name(self): - """ Normalized entity subclass name. """ - return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', self.__name__)).lower() - - @property - def options(self): - """ Table of entity options. """ - if hasattr(self, "config") and isinstance(self.config, Config): - data = [["Name", "Value", "Required", "Description"]] - for n, d, v, r in sorted(self.config.items(), key=lambda x: x[0]): - if v is None or n == v: - data.append([n, v, ["N", "Y"][r], d]) - return data - +# -*- coding: UTF-8 -*- +import gc +import re +from collections import OrderedDict +from importlib.util import find_spec +from inspect import getfile, getmro +from shutil import which +from tinyscript.helpers import is_dict, is_function, is_list, is_str, merge_dict, parse_docstring, \ + BorderlessTable, ClassRegistry, Path, PythonPath + +from .components.config import Config, Option, ProxyConfig +from .components.logger import get_logger + + +__all__ = ["load_entities", "Entity", "MetaEntity", "MetaEntityBase"] + +ENTITIES = [] +logger = get_logger("core.entity") + + +def load_entities(entities, *sources, **kwargs): + """ Load every entity class of the given type found in the given source folders. + + :param sources: paths (either with ~, relative or absolute) to folders containing entity subclasses + :param include_base: include the base entities provided with the package + :param select: selected modules in the source folder + :param exclude: list of entity identifiers (in custom format, or simply the entity class) to be excluded + (useful when including the base but not every entity is required) + :param backref: list of attrs to get entity's class to be bound to + :param docstr_parser: user-defined docstring parser for populating metadata + :param remove_cache: remove Python file cache + """ + global ENTITIES + ENTITIES = [e.__name__ for e in entities] + sources = list(sources) + if kwargs.get("include_base", True): + # this allows to use sploitkit.base for starting a project with a baseline of entities + for n in ENTITIES: + n = n.lower() + for m in kwargs.get("select", {}).get(n, [""]): + m = "../base/{}s/".format(n) + m + [".py", ""][m == ""] + p = Path(__file__).parent.joinpath(m).resolve() + if p.exists(): + sources.insert(0, p) + # load every single source (folder of modules or single module) + for s in sources: + if not s.exists(): + logger.debug("Source does not exist: %s" % s) + continue + # bind the source to the entity main class + for e in entities: + e._source = str(s) + # now, it loads every Python module from the list of source folders ; when loading entity subclasses, these are + # registered to entity's registry for further use (i.e. from the console) + logger.debug("Loading Python source: %s" % s) + # important note: since version 1.23.17 of Tinyscript, support for cached compiled Python files has been added, + # for the PythonPath class, therefore influencing the location path of loaded entities (that + # is, adding __pycache__) + PythonPath(s, remove_cache=kwargs.get("remove_cache", False)) + for e in entities: + tbr = [] + # clean up the temporary attribute + if hasattr(e, "_source"): + delattr(e, "_source") + # remove proxy classes + n = e.__name__.lower() + for c in e.subclasses[:]: + if len(c.__subclasses__()) > 0: + getattr(e, "unregister_%s" % n, Entity.unregister_subclass)(c) + # handle specific entities or sets of entities exclusions ; this will remove them from Entity's registries + excludes = kwargs.get("exclude", {}).get(n) + if excludes is not None: + getattr(e, "unregister_%ss" % n, Entity.unregister_subclasses)(*excludes) + # handle conditional entities ; this will remove entities having a "condition" method returning False + for c in e.subclasses[:]: + # convention: conditional entities are unregistered and removed + if hasattr(c, "condition") and not c().condition(): + getattr(e, "unregister_%s" % n, Entity.unregister_subclass)(c) + # now populate metadata for each class + for c in e.subclasses: + set_metadata(c, kwargs.get("docstr_parser", parse_docstring)) + # bind entity's subclasses to the given attributes for back-reference + backrefs = kwargs.get("backref", {}).get(n) + if backrefs is not None: + for c in e.subclasses: + for br in backrefs: + try: + a, bn = br # [a]ttribute, [b]ackref [n]ame + except ValueError: + a, bn = None, br[0] if isinstance(br, tuple) else br + bc = list(filter(lambda _: _.__name__.lower() == bn, entities))[0] # [b]ackref [c]lass + if a and getattr(c, a, None): + c = getattr(c, a) + setattr(c, bn, lambda: bc._instance) + # then trigger garbage collection (for removed classes) + gc.collect() + + +def set_metadata(c, docstr_parser): + """ Set the metadata for an entity class given a docstring parser. + + :param c: entity subclass + :param docstr_parser: parsing function, taking 'c' as its input + """ + # populate metadata starting by parsing entity class' docstring + c._metadata = docstr_parser(c) + # "meta" or "metadata" attributes then have precedence on the docstr (because of .update()) + for a in ["meta", "metadata"]: + if hasattr(c, a): + c._metadata.update(getattr(c, a)) + try: + delattr(c, a) + except AttributeError: + pass # occurs when class 'c' has no 'meta' anymore, but its base class well + # if the metadata has options, create the config object + for o in c._metadata.pop("options", []): + try: + name, default, required, description = o + except ValueError: + raise ValueError("Bad option ; should be (name, default, required, description)") + c.config[Option(name, description, required)] = default + # dynamically declare properties for each metadata field + for attr, value in c._metadata.items(): + # let the precedence to already existing attributes + if attr not in c.__dict__.keys(): + setattr(c, attr, value) + # add inherited entity classes' metadata + b = c.__base__ + if b and getattr(c, "_inherit_metadata", False): + for b in c.__bases__: + if b is Entity: + continue + set_metadata(b, docstr_parser) + for k, v in b._metadata.items(): + if k not in c._metadata.keys(): + c._metadata[k] = v + + +class Entity(object): + """ Generic Entity class (i.e. a command or a module). """ + _applicable = True + _enabled = True + _metadata = {} + _subclasses = ClassRegistry() + + def __getattribute__(self, name): + if name == "config" and getattr(self.__class__, "_has_config", False): + c = ProxyConfig(self.__class__.config) + # merge parent config if relevant + if hasattr(self, "parent") and self.parent is not None and self.parent is not self: + c += self.parent.config + # back-reference the entity + setattr(c, self.__class__.entity, self) + return c + return super(Entity, self).__getattribute__(name) + + @property + def applicable(self): + """ Boolean indicating if the entity is applicable to the current context (i.e. of attached entities). """ + return self.__class__._applicable + + @property + def base_class(self): + """ Shortcut for accessing Entity, for use instead of __base__ which only leads to the direct base class. """ + return Entity + + @property + def cname(self): + """ Subclass name. """ + return self.__class__.__name__ + + @property + def enabled(self): + """ Boolean indicating if the entity is enabled (i.e. if it has no missing requirement. """ + return self.__class__.enabled + + @classmethod + def check(cls, other_cls=None): + """ Check for entity's requirements. """ + cls = other_cls or cls + if cls is Entity: + good = True + for sc in Entity._subclasses: + good = good and sc.check() + return good + cls._enabled = True + errors = {} + # check for requirements using an explicitely defined function + try: + cls._enabled = cls.check_requirements(cls) + # unpack requirement messages dictionary if relevant + cls._enabled, err = cls._enabled + errors.update(err or {}) + except (AttributeError, TypeError, ValueError): + pass + + def _checkv(func, args, not_): + """ internal convenience function for handling a NOT operator with an input item """ + r = func(*args) if isinstance(args, tuple) else func(args) + return not_ ^ r + + def _unpackv(item): + """ internal convenience function for unpacking a NOT operator from an input item """ + not_ = False + # handle a NOT operator for the given item + if item.startswith("!"): + not_ = True + item = item[1:] + return not_, item + # check requirements from the related attribute + #TODO: also handle lambda functions for requirements + for k, v in getattr(cls, "requirements", {}).items(): + if is_str(v): + v = [v] + # checks if a config option is set to a particular value + if k == "config": + if not is_dict(v): + raise ValueError("Bad config requirements (should be a dictionary)") + for opt, exp_val in v.items(): + bad, cur_val = False, None + not_, opt = _unpackv(opt) + try: + cur_val = cls.config.option(opt.upper()).value + except KeyError: + pass + # compare current and expected values + if not _checkv(lambda v1, v2: v1 is v2 or v1 == v2, (cur_val, exp_val), not_): + cls._enabled = False + break + # checks if a file exists + elif k == "file": + if not is_list(v): + raise ValueError("Bad file requirements (should be a list/set/tuple)") + for fpath in v: + not_, fpath = _unpackv(fpath) + if not _checkv(lambda p: Path(cls.__file__).parent.joinpath(p).exists(), fpath, not_): + errors.setdefault("file", []) + errors["file"].append((fpath, not_)) + cls._enabled = False + # checks if a requirement wrt the console is met + elif k == "internal": + if not is_function(v): + raise ValueError("Bad internal requirement (should be a function)") + if not v(cls): + cls._enabled = False + # checks if a Python package is present + elif k == "python": + if not is_list(v): + raise ValueError("Bad python requirements (should be a list/set/tuple)") + for module in v: + if isinstance(module, tuple): + module, package = module + not_, module = _unpackv(module) + found = find_spec(module, package) + else: + not_, module = _unpackv(module) + package = module + found = find_spec(module) + if not _checkv(lambda _: found is not None, "", not_): + errors.setdefault("python", []) + errors["python"].append((package, not_)) + cls._enabled = False + # checks if a state variable is set to a particular value + elif k == "state": + if is_list(v): + skeys = {sk: None for sk in v} + elif is_dict(v): + skeys = v + else: + raise ValueError("Bad state requirements (should be a list/set/tuple or a dictionary)") + # catch Console from Entity's registered subclasses as Console cannot be imported in this module (cfr + # circular import) + Console = cls.get_class("Console") + _tmp = [] + # e.g. sk=INTERFACES and sv={None:[True,None,None]} + for sk, sv in skeys.items(): + # check if the state key exists + if sk not in Console._state.keys(): + _tmp.append(sk) + # check if the value (if defined) matches + elif sv is not None: + # e.g. cs={wlp4s0:[False,None,"[MAC_addr]"]} + cs = Console._state[sk] + # special case: state value is a dict + if is_dict(sv) and is_dict(cs): + check_key, l = True, list(sv.items()) + if len(l) == 1 and l[0][0] is None: + check_key = False + # case 1: classical dict + if check_key: + for ssk, ssv in sv.items(): + if ssk not in cs.keys() or cs[ssk] != ssv: + _tmp.append("{}={}".format(sk, sv)) + break + # case 2: {None: ...} + else: + # e.g. ssv=[True,None,None] + ssv = l[0][1] + if isinstance(ssv, (tuple, list)): + # e.g. this zips [True,None,None] and [False,None,"[MAC_addr]"] together + found = False + for values in zip(ssv, *list(cs.values())): + ref = values[0] + # None positional values are ignored + if ref is not None and ref in values[1:]: + found = True + if not found: + _tmp.append("{}?{}".format(sk, ref)) + elif is_dict(ssv): + # e.g. {monitor:True} + found = False + for sssk, sssv in ssv.items(): + for csd in cs.values(): + if sssv is None: + if sssk in csd.keys(): + found = True + break + elif csd.get(sssk) == sssv: + found = True + break + if not found: + v = ["{}:{}".format(sssk, sssv), sssv][sssv is None] + _tmp.append("{}?{}".format(sk, v)) + elif ssv not in cs.values(): + _tmp.append("{}?{}".format(sk, ssv)) + break + # exact match between any other type than dict + else: + if sv != Console._state[sk]: + _tmp.append("{}={}".format(sk, sv)) + if len(_tmp) > 0: + errors.setdefault("state", []) + errors['state'].extend(_tmp) + cls._enabled = False + # checks if a system package/binary is installed + elif k == "system": + for tool in v: + _ = tool.split("/") + if len(_) == 1: + package = None + not_, tool = _unpackv(tool) + elif len(_) == 2: + package, tool = _ + not_, package = _unpackv(package) + else: + raise ValueError("Bad system requirements (should be a list)") + if which(tool) is None: + if package is None: + errors.setdefault("tools", []) + errors["tools"].append((tool, not_)) + else: + errors.setdefault("packages", []) + errors["packages"].append((package, not_)) + cls._enabled = False + else: + raise ValueError("Unknown requirements type '{}'".format(k)) + cls._errors = errors + # check for applicability + cls._applicable = True + applies_to = getattr(cls, "applies_to", []) + if len(applies_to) > 0: + cls._applicable = False + chk = getattr(cls, "check_applicability", None) + if chk is not None: + cls._applicable = cls.check_applicability() + else: + # format: ("attr1", "attr2", ..., "attrN", "value") + # e.g.: ("module", "fullpath", "my/module/do_something") + for l in applies_to: + l, must_match, value = list(l[:-1]), l[-1], cls + while len(l) > 0: + value = getattr(value, l.pop(0), None) + if value and value == must_match: + cls._applicable = True + break + return cls._enabled and cls._applicable + + @classmethod + def get_class(cls, name): + """ Get a class (key) from _subclasses by name (useful when the class is not imported in the current scope). """ + return Entity._subclasses[name] + + @classmethod + def get_info(cls, *fields, show_all=False): + """ Display entity's metadata and other information. + + :param fields: metadata fields to be output + :param show_all: also include unselected fields, to be output behind the list of selected fields + """ + t = "" + if len(fields) == 0: + fields = [("name", "description"), ("author", "email", "version", "comments"), ("options",)] + # make a data table with the given fields and corresponding values + data, __used = [], [] + _ = lambda s: s.capitalize() + ":" + for field in fields: + if not isinstance(field, (list, tuple)): + field = (field,) + add_blankline = False + for f in field: + try: + f, alias = f.split("|", 1) + except: + alias = f + __used.append(f) + v = getattr(cls, f, "") + if v is None or len(v) == 0: + continue + elif isinstance(v, (list, tuple)): + data.append([_(alias), v[0]]) + for i in v[1:]: + data.append(["", i]) + else: + data.append([_(alias), v]) + add_blankline = True + if add_blankline: + data.append(["", ""]) + t = BorderlessTable(data, header=False).table + "\n" if len(data) > 0 else "" + # add other metadata if relevant + if show_all: + unused = set(cls._metadata.keys()) - set(__used) + if len(unused) > 0: + t += cls.get_info(*sorted(list(unused))) + return t.rstrip() + "\n" + + @classmethod + def get_issues(cls, subcls_name=None, category=None): + """ List issues as a text. """ + # message formatting function + def msg(scname, key, item): + # try to unpack item for handling the NOT operator + try: + item, not_ = item + except ValueError: + not_ = False + not_s = ["not ", ""][not_] + subcls = Entity.get_subclass(None, scname) + m = getattr(subcls, "requirements_messages", {}).get(key, {}).get(re.split(r"(\=|\?)", item, 1)[0]) + if m is not None: + return m.format(item) + if key == "file": + return "'{}' {}found".format(item, not_s) + elif key == "packages": + return "'{}' system package is {}installed".format(item, not_s) + elif key == "python": + return "'{}' Python package is {}installed".format(item, not_s) + elif key == "tools": + return "'{}' tool is {}installed".format(item, not_s) + elif key == "state": + item = re.split(r"(\=|\?)", item, 1) + if len(item) == 1: + return "'{}' state key is not defined".format(item[0]) + elif item[1] == "=": + return "'{}' state key does not match the expected value '{}'".format(item[0], item[2]) + elif item[1] == "?": + return "'{}' state key is expected to have value '{}' at least once".format(item[0], item[2]) + # list issues using the related class method + t = "\n" + d = OrderedDict() + # this regroups class names for error dictionaries that are the same in order to aggregate issues + for cname, scname, errors in Entity.issues(subcls_name, category): + e = str(errors) + d.setdefault(e, {}) + d[e].setdefault(cname, []) + d[e][cname].append(scname) + # this then displays the issues with their list of related entities having these same issues + for _, names in d.items(): + errors = list(Entity.issues(list(names.values())[0][0]))[0][-1] + t = "" + for cname, scnames in names.items(): + scnames = list(set(scnames)) + cname += ["", "s"][len(scnames) > 1] + t += "{}: {}\n".format(cname, ", ".join(sorted(scnames))) + t += "- " + "\n- ".join(msg(scname, k, e) for k, err in errors.items() for e in err) + "\n" + return "" if t.strip() == "" else t + + @classmethod + def get_subclass(cls, key, name): + """ Get a subclass (value) from _subclasses by name (useful when the related class is not imported in the + current scope). """ + return Entity._subclasses[key, name] + + @classmethod + def has_issues(cls, subcls_name=None, category=None): + """ Tell if issues were encountered while checking entities. """ + for _ in cls.issues(subcls_name, category): + return True + return False + + @classmethod + def issues(cls, subcls_name=None, category=None): + """ List issues encountered while checking all the entities. """ + cls.check() + sc = Entity._subclasses + for c, l in sc.items() if cls is Entity else [cls, cls.subclasses] if cls in sc.keys() \ + else [(cls._entity_class, [cls])]: + for subcls in l: + e = {} + for b in subcls.__bases__: + # do not consider base classes without the issues method (e.g. mixin classes) + if not hasattr(b, "issues"): + continue + # break when at parent entity level + if b in Entity._subclasses.keys() or b is Entity: + break + # update the errors dictionary starting with proxy classes + for _, __, errors in b.issues(category=category): + for categ, i in errors.items(): + e.setdefault(categ, []) + e[categ].extend(i) + # now update the errors dictionary of the selected subclass + if hasattr(subcls, "_errors") and len(subcls._errors) > 0: + for categ, i in subcls._errors.items(): + if category in [None, categ]: + e.setdefault(categ, []) + e[categ].extend(i) + if len(e) > 0: + for categ, i in e.items(): # [categ]ory, [i]ssues + e[categ] = list(sorted(set(i))) + n = subcls.__name__ + if subcls_name in [None, n]: + yield c.__name__, n, e + + @classmethod + def register_subclass(cls, subcls): + """ Maintain a registry of subclasses inheriting from Entity. """ + # get the base entity class + ecls = subcls._entity_class + Entity._subclasses.setdefault(ecls, []) + if subcls not in Entity._subclasses[ecls]: + # now register the subcls, ensured to be an end-subclass of the entity + Entity._subclasses[ecls].append(subcls) + # back-reference the entity from its config if existing + if getattr(cls, "_has_config", False): + setattr(subcls.config, "_" + subcls.entity, lambda: subcls._instance) + # manually get subclass' name because of MetaModel not having the "name" property (would be considered a + # Peewee database field) + n = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', subcls.__name__)).lower() + logger.detail("Registered {} '{}'".format(subcls.entity, n)) + + @classmethod + def unregister_subclass(cls, subcls): + """ Remove an entry from the registry of subclasses. """ + # manually get subclass' name because of MetaModel not having the "name" property (would be considered a Peewee + # database field) + n = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', subcls.__name__)).lower() + if cls in Entity._subclasses.keys(): + try: + Entity._subclasses[cls].remove(subcls) + logger.detail("Unregistered {} '{}'".format(subcls.entity, n)) + except ValueError: + pass + + @classmethod + def unregister_subclasses(cls, *subclss): + """ Remove entries from the registry of subclasses. """ + for subcls in subclss: + cls.unregister_subclass(subcls) + + def run(self, *args, **kwargs): + """ Generic method for running Entity's logic. """ + raise NotImplementedError("{}'s run() method is not implemented".format(self.__class__.__name__)) + + +class MetaEntityBase(type): + """ Metaclass of an Entity, registering all its instances. """ + def __new__(meta, name, bases, clsdict, subcls=None): + subcls = subcls or type.__new__(meta, name, bases, clsdict) + if len(ENTITIES) > 0: + mro_bases = list(getmro(subcls)) + # do not register non-entity classes or base entities + while len(mro_bases) > 0 and mro_bases[-1] is not Entity: + mro_bases.pop() + if len(mro_bases) <= 2 or subcls.__name__ in ENTITIES: + return subcls + # set the base entity class + try: + subcls._entity_class = [c for c in mro_bases if c.__name__ in ENTITIES][0] + except IndexError: + return subcls + # trigger class registration + for b in bases: + if not hasattr(subcls, "registered") or not subcls.registered: + b.register_subclass(subcls) + try: # OPTIONAL: execute entity's own register method + getattr(b, "register_" + subcls.entity)(subcls) + except AttributeError: + pass + return subcls + + def __repr__(self): + return "<%s: %s>" % (self.entity.capitalize(), self.__name__) + + @property + def entity(self): + """ Normalized base entity name. """ + try: + return self._entity_class.__name__.lower() + except AttributeError: + return "entity" + + @property + def identifier(self): + """ Compute a unique identifier for this entity subclass. """ + f = Path(getattr(self, "__file__", getfile(self))) + d, fn = f.dirname, f.filename + if len(d.parts) > 0 and d.parts[-1] == "__pycache__": + parts = fn.split(".") + if re.match(r".?python\-?[23]\d", parts[-2]): + parts.pop(-2) + parts[-1] = "py" + f = d.parent.joinpath(".".join(parts)) + return str(f), self.__name__ + + @property + def registered(self): + """ Boolean indicating if the entity is already registered. """ + e = self._entity_class + Entity._subclasses.setdefault(e, []) + return self.identifier in [x.identifier for x in Entity._subclasses[e]] + + @property + def subclasses(self): + """ List of all classes of the current entity. """ + return Entity._subclasses.get(self, []) + + +class MetaEntity(MetaEntityBase): + """ Metaclass of an Entity, adding some particular properties. """ + def __getattribute__(self, name): + if name == "config" and getattr(self, "_has_config", False): + if "config" not in self.__dict__: + setattr(self, "config", Config()) + c = self.__dict__['config'] + # back-reference the entity + if hasattr(self, "_instance"): + setattr(c, self.entity, self._instance) + c = ProxyConfig(c) + if hasattr(self, "_entity_class"): + for b in self.__bases__: + if b == self._entity_class: + break + _ = getattr(b, "config", None) + if _: + c += _ + return c + elif name in ["requirements", "requirements_messages"]: + r = {} + if hasattr(self, "_entity_class"): + for b in self.__bases__[::-1]: + if b == self._entity_class: + break + merge_dict(r, getattr(b, name, {})) + merge_dict(r, self.__dict__.get(name, {})) + return r + return super(MetaEntity, self).__getattribute__(name) + + @property + def applicable(self): + """ Boolean indicating if the entity is applicable to the current context (i.e. of attached entities). """ + self.check() + return self._applicable + + @property + def enabled(self): + """ Boolean indicating if the entity is enabled (i.e. if it has no missing requirement. """ + self.check() + return self._enabled + + @property + def name(self): + """ Normalized entity subclass name. """ + return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', self.__name__)).lower() + + @property + def options(self): + """ Table of entity options. """ + if hasattr(self, "config") and isinstance(self.config, Config): + data = [["Name", "Value", "Required", "Description"]] + for n, d, v, r in sorted(self.config.items(), key=lambda x: x[0]): + if v is None or n == v: + data.append([n, v, ["N", "Y"][r], d]) + return data + diff --git a/sploitkit/core/model.py b/src/sploitkit/core/model.py similarity index 96% rename from sploitkit/core/model.py rename to src/sploitkit/core/model.py index b0eea02..13d6b98 100755 --- a/sploitkit/core/model.py +++ b/src/sploitkit/core/model.py @@ -1,99 +1,99 @@ -# -*- coding: UTF-8 -*- -import datetime -from peewee import * -from peewee import Model as PeeweeModel, ModelBase - -from .components.logger import get_logger -from .entity import Entity, MetaEntityBase - - -__all__ = ["BaseModel", "Model", "StoreExtension"] - - -logger = get_logger("core.model") - - -class MetaModel(ModelBase, MetaEntityBase): - """ Metaclass of a Model. """ - triggers = [] - - def __new__(meta, name, bases, clsdict): - subcls = ModelBase.__new__(meta, name, bases, clsdict) - if subcls.__name__ != "Model": - pass - # add triggers here - #try: - # trigger = "{}_updated".format(subcls._meta.table_name) - # subcls.add_trigger(trigger, "AFTER", "UPDATE", "UPDATE", "SET updated=CURRENT_TIMESTAMP") - #except AttributeError: - # pass - return subcls - - def __repr__(self): - return "<%s: %s>" % (self.entity.capitalize(), self.__name__) - - -class BaseModel(PeeweeModel, Entity, metaclass=MetaModel): - """ Main class handling console store's base models (that is, without pre-attached fields). """ - pass - - -class Model(BaseModel): - """ Main class handling console store's models. """ - source = CharField(null=True) - created = DateTimeField(default=datetime.datetime.now, null=False) - updated = DateTimeField(default=datetime.datetime.now, null=False) - - @classmethod - def add_trigger(cls, trig, when, top, op, sql, safe=True): - """ Add a trigger to model's list of triggers. """ - cls.triggers.append(Trigger(cls, trig, when, top, op, sql, safe)) - - @classmethod - def create_table(cls, **options): - """ Create this table in the bound database.""" - super(Model, cls).create_table(**options) - for trigger in cls.triggers: - try: - cls._meta.database.execute_sql(str(trigger)) - except: - pass - - @classmethod - def set(cls, **items): - """ Insert or update a record. """ - items["updated"] = datetime.datetime.now() - return super(Model, cls).get_or_create(**items) - - -class StoreExtension(Entity, metaclass=MetaEntityBase): - """ Dummy class handling store extensions for the Store class. """ - pass - - -# source: -# https://stackoverflow.com/questions/34142550/sqlite-triggers-datetime-defaults-in-sql-ddl-using-peewee-in-python -class Trigger(object): - """Trigger template wrapper for use with peewee ORM.""" - _template = """ - {create} {name} {when} {trigger_op} - ON {tablename} - BEGIN - {op} {tablename} {sql} WHERE {pk}={old_new}.{pk}; - END; - """ - - def __init__(self, table, name, when, trigger_op, op, sql, safe=True): - self.create = "CREATE TRIGGER" + (" IF NOT EXISTS" if safe else "") - self.tablename = table._meta.name - self.pk = table._meta.primary_key.name - self.name = name - self.when = when - self.trigger_op = trigger_op - self.op = op - self.sql = sql - self.old_new = "new" if trigger_op.lower() == "insert" else "old" - - def __str__(self): - return self._template.format(**self.__dict__) - +# -*- coding: UTF-8 -*- +import datetime +from peewee import * +from peewee import Model as PeeweeModel, ModelBase + +from .components.logger import get_logger +from .entity import Entity, MetaEntityBase + + +__all__ = ["BaseModel", "Model", "StoreExtension"] + + +logger = get_logger("core.model") + + +class MetaModel(ModelBase, MetaEntityBase): + """ Metaclass of a Model. """ + triggers = [] + + def __new__(meta, name, bases, clsdict): + subcls = ModelBase.__new__(meta, name, bases, clsdict) + if subcls.__name__ != "Model": + pass + # add triggers here + #try: + # trigger = "{}_updated".format(subcls._meta.table_name) + # subcls.add_trigger(trigger, "AFTER", "UPDATE", "UPDATE", "SET updated=CURRENT_TIMESTAMP") + #except AttributeError: + # pass + return subcls + + def __repr__(self): + return "<%s: %s>" % (self.entity.capitalize(), self.__name__) + + +class BaseModel(PeeweeModel, Entity, metaclass=MetaModel): + """ Main class handling console store's base models (that is, without pre-attached fields). """ + pass + + +class Model(BaseModel): + """ Main class handling console store's models. """ + source = CharField(null=True) + created = DateTimeField(default=datetime.datetime.now, null=False) + updated = DateTimeField(default=datetime.datetime.now, null=False) + + @classmethod + def add_trigger(cls, trig, when, top, op, sql, safe=True): + """ Add a trigger to model's list of triggers. """ + cls.triggers.append(Trigger(cls, trig, when, top, op, sql, safe)) + + @classmethod + def create_table(cls, **options): + """ Create this table in the bound database.""" + super(Model, cls).create_table(**options) + for trigger in cls.triggers: + try: + cls._meta.database.execute_sql(str(trigger)) + except: + pass + + @classmethod + def set(cls, **items): + """ Insert or update a record. """ + items["updated"] = datetime.datetime.now() + return super(Model, cls).get_or_create(**items) + + +class StoreExtension(Entity, metaclass=MetaEntityBase): + """ Dummy class handling store extensions for the Store class. """ + pass + + +# source: +# https://stackoverflow.com/questions/34142550/sqlite-triggers-datetime-defaults-in-sql-ddl-using-peewee-in-python +class Trigger(object): + """Trigger template wrapper for use with peewee ORM.""" + _template = """ + {create} {name} {when} {trigger_op} + ON {tablename} + BEGIN + {op} {tablename} {sql} WHERE {pk}={old_new}.{pk}; + END; + """ + + def __init__(self, table, name, when, trigger_op, op, sql, safe=True): + self.create = "CREATE TRIGGER" + (" IF NOT EXISTS" if safe else "") + self.tablename = table._meta.name + self.pk = table._meta.primary_key.name + self.name = name + self.when = when + self.trigger_op = trigger_op + self.op = op + self.sql = sql + self.old_new = "new" if trigger_op.lower() == "insert" else "old" + + def __str__(self): + return self._template.format(**self.__dict__) + diff --git a/sploitkit/core/module.py b/src/sploitkit/core/module.py similarity index 97% rename from sploitkit/core/module.py rename to src/sploitkit/core/module.py index 8d3c7de..8565a65 100755 --- a/sploitkit/core/module.py +++ b/src/sploitkit/core/module.py @@ -1,202 +1,202 @@ -# -*- coding: UTF-8 -*- -from inspect import getfile -from tinyscript.helpers import flatten_dict, BorderlessTable, Path, PathBasedDict - -from .components.logger import get_logger -from .entity import Entity, MetaEntity - - -__all__ = ["Module"] - - -logger = get_logger("core.module") - - -class MetaModule(MetaEntity): - """ Metaclass of a Module. """ - _has_config = True - _inherit_metadata = True - - def __new__(meta, name, bases, clsdict): - subcls = type.__new__(meta, name, bases, clsdict) - # compute module's path from its root folder if no path attribute defined on its class - if getattr(subcls, "path", None) is None: - p = Path(getfile(subcls)).parent - # collect the source temporary attribute - s = getattr(subcls, "_source", ".") - try: - scp = p.relative_to(Path(s)) - if len(scp.parts) > 0 and scp.parts[-1] == "__pycache__": - scp = scp.parent - subcls.path = str(scp) - except ValueError: - subcls.path = None - # then pass the subclass with its freshly computed path attribute to the original __new__ method, for - # registration in subclasses and in the list of modules - super(MetaModule, meta).__new__(meta, name, bases, clsdict, subcls) - return subcls - - @property - def base(self): - """ Module's category. """ - return str(Path(self.fullpath).child) if self.category != "" else self.name - - @property - def category(self): - """ Module's category. """ - try: - return str(Path(self.path).parts[0]) - except IndexError: - return "" - - @property - def fullpath(self): - """ Full path of the module, that is, its path joined with its name. """ - return str(Path(self.path).joinpath(self.name)) - - @property - def help(self): - """ Help message for the module. """ - return self.get_info(("name", "description"), "comments") - - @property - def subpath(self): - """ First child path of the module. """ - return str(Path(self.path).child) - - def search(self, text): - """ Search for text in module's attributes. """ - t = text.lower() - return any(t in "".join(v).lower() for v in self._metadata.values()) or t in self.fullpath - - -class Module(Entity, metaclass=MetaModule): - """ Main class handling console modules. """ - modules = PathBasedDict() - - @property - def files(self): - """ Shortcut to bound console's file manager instance. """ - return self.console.__class__._files - - @property - def logger(self): - """ Shortcut to bound console's logger instance. """ - return self.console.logger - - @property - def store(self): - """ Shortcut to bound console's store instance. """ - return self.console.store - - @property - def workspace(self): - """ Shortcut to the current workspace. """ - return self.console.workspace - - @classmethod - def get_count(cls, path=None, **attrs): - """ Count the number of modules under the given path and matching attributes. """ - return cls.modules.count(path, **attrs) - - @classmethod - def get_help(cls, category=None): - """ Display command's help, using its metaclass' properties. """ - uncat = {} - for c, v in cls.modules.items(): - if not isinstance(v, dict): - uncat[c] = v - if category is None: - categories = list(set(cls.modules.keys()) - set(uncat.keys())) - if len(uncat) > 0: - categories += ["uncategorized"] - else: - categories = [category] - s, i = "", 0 - for c in categories: - d = [["Name", "Path", "Enabled", "Description"]] - for n, m in sorted((flatten_dict(cls.modules.get(c, {})) if c != "uncategorized" else uncat).items(), - key=lambda x: x[1].name): - e = ["N", "Y"][m.enabled] - d.append([m.name, m.subpath, e, m.description]) - t = BorderlessTable(d, "{} modules".format(c.capitalize())) - s += t.table + "\n\n" - i += 1 - return "\n" + s.strip() + "\n" if i > 0 else "" - - @classmethod - def get_list(cls): - """ Get the list of modules' fullpath. """ - return sorted([m.fullpath for m in Module.subclasses if m.check()]) - - @classmethod - def get_modules(cls, path=None): - """ Get the subdictionary of modules matching the given path. """ - return cls.modules[path or ""] - - @classmethod - def get_summary(cls): - """ Get the summary of module counts per category. """ - # display module stats - m = [] - uncat = [] - for category in cls.modules.keys(): - if isinstance(cls.modules[category], MetaModule): - uncat.append(cls.modules[category]) - continue - l = "%d %s" % (Module.get_count(category), category) - disabled = Module.get_count(category, enabled=False) - if disabled > 0: - l += " (%d disabled)" % disabled - m.append(l) - if len(uncat) > 0: - l = "%d uncategorized" % len(uncat) - disabled = len([u for u in uncat if not u.enabled]) - if disabled > 0: - l += " (%d disabled)" % disabled - m.append(l) - if len(m) > 0: - mlen = max(map(len, m)) - s = "\n" - for line in m: - s += ("\t-=[ {: <" + str(mlen) + "} ]=-\n").format(line) - return s - return "" - - @classmethod - def register_module(cls, subcls): - """ Register a Module subclass to the dictionary of modules. """ - if subcls.path is None: - return # do not consider orphan modules - cls.modules[subcls.path, subcls.name] = subcls - - @classmethod - def unregister_module(cls, subcls): - """ Unregister a Module subclass from the dictionary of modules. """ - p, n = subcls.path, subcls.name - try: - del cls.modules[n if p == "." else (p, n)] - except KeyError: - pass - for M in Module.subclasses: - if p == M.path and n == M.name: - Module.subclasses.remove(M) - break - logger.detail("Unregistered module '{}/{}'".format(p, n)) - - @classmethod - def unregister_modules(cls, *subcls): - """ Unregister Module subclasses from the dictionary of modules. """ - for sc in subcls: - cls.unregister_module(sc) - - def _feedback(self, success, failmsg): - """ Dummy feedback method using a fail-message formatted with the "not" keyword (to be replaced by a null string - in case of success). """ - if success is None: - return - elif success: - self.logger.success(failmsg.replace("not ", "")) - else: - self.logger.failure(failmsg) - +# -*- coding: UTF-8 -*- +from inspect import getfile +from tinyscript.helpers import flatten_dict, BorderlessTable, Path, PathBasedDict + +from .components.logger import get_logger +from .entity import Entity, MetaEntity + + +__all__ = ["Module"] + + +logger = get_logger("core.module") + + +class MetaModule(MetaEntity): + """ Metaclass of a Module. """ + _has_config = True + _inherit_metadata = True + + def __new__(meta, name, bases, clsdict): + subcls = type.__new__(meta, name, bases, clsdict) + # compute module's path from its root folder if no path attribute defined on its class + if getattr(subcls, "path", None) is None: + p = Path(getfile(subcls)).parent + # collect the source temporary attribute + s = getattr(subcls, "_source", ".") + try: + scp = p.relative_to(Path(s)) + if len(scp.parts) > 0 and scp.parts[-1] == "__pycache__": + scp = scp.parent + subcls.path = str(scp) + except ValueError: + subcls.path = None + # then pass the subclass with its freshly computed path attribute to the original __new__ method, for + # registration in subclasses and in the list of modules + super(MetaModule, meta).__new__(meta, name, bases, clsdict, subcls) + return subcls + + @property + def base(self): + """ Module's category. """ + return str(Path(self.fullpath).child) if self.category != "" else self.name + + @property + def category(self): + """ Module's category. """ + try: + return str(Path(self.path).parts[0]) + except IndexError: + return "" + + @property + def fullpath(self): + """ Full path of the module, that is, its path joined with its name. """ + return str(Path(self.path).joinpath(self.name)) + + @property + def help(self): + """ Help message for the module. """ + return self.get_info(("name", "description"), "comments") + + @property + def subpath(self): + """ First child path of the module. """ + return str(Path(self.path).child) + + def search(self, text): + """ Search for text in module's attributes. """ + t = text.lower() + return any(t in "".join(v).lower() for v in self._metadata.values()) or t in self.fullpath + + +class Module(Entity, metaclass=MetaModule): + """ Main class handling console modules. """ + modules = PathBasedDict() + + @property + def files(self): + """ Shortcut to bound console's file manager instance. """ + return self.console.__class__._files + + @property + def logger(self): + """ Shortcut to bound console's logger instance. """ + return self.console.logger + + @property + def store(self): + """ Shortcut to bound console's store instance. """ + return self.console.store + + @property + def workspace(self): + """ Shortcut to the current workspace. """ + return self.console.workspace + + @classmethod + def get_count(cls, path=None, **attrs): + """ Count the number of modules under the given path and matching attributes. """ + return cls.modules.count(path, **attrs) + + @classmethod + def get_help(cls, category=None): + """ Display command's help, using its metaclass' properties. """ + uncat = {} + for c, v in cls.modules.items(): + if not isinstance(v, dict): + uncat[c] = v + if category is None: + categories = list(set(cls.modules.keys()) - set(uncat.keys())) + if len(uncat) > 0: + categories += ["uncategorized"] + else: + categories = [category] + s, i = "", 0 + for c in categories: + d = [["Name", "Path", "Enabled", "Description"]] + for n, m in sorted((flatten_dict(cls.modules.get(c, {})) if c != "uncategorized" else uncat).items(), + key=lambda x: x[1].name): + e = ["N", "Y"][m.enabled] + d.append([m.name, m.subpath, e, m.description]) + t = BorderlessTable(d, "{} modules".format(c.capitalize())) + s += t.table + "\n\n" + i += 1 + return "\n" + s.strip() + "\n" if i > 0 else "" + + @classmethod + def get_list(cls): + """ Get the list of modules' fullpath. """ + return sorted([m.fullpath for m in Module.subclasses if m.check()]) + + @classmethod + def get_modules(cls, path=None): + """ Get the subdictionary of modules matching the given path. """ + return cls.modules[path or ""] + + @classmethod + def get_summary(cls): + """ Get the summary of module counts per category. """ + # display module stats + m = [] + uncat = [] + for category in cls.modules.keys(): + if isinstance(cls.modules[category], MetaModule): + uncat.append(cls.modules[category]) + continue + l = "%d %s" % (Module.get_count(category), category) + disabled = Module.get_count(category, enabled=False) + if disabled > 0: + l += " (%d disabled)" % disabled + m.append(l) + if len(uncat) > 0: + l = "%d uncategorized" % len(uncat) + disabled = len([u for u in uncat if not u.enabled]) + if disabled > 0: + l += " (%d disabled)" % disabled + m.append(l) + if len(m) > 0: + mlen = max(map(len, m)) + s = "\n" + for line in m: + s += ("\t-=[ {: <" + str(mlen) + "} ]=-\n").format(line) + return s + return "" + + @classmethod + def register_module(cls, subcls): + """ Register a Module subclass to the dictionary of modules. """ + if subcls.path is None: + return # do not consider orphan modules + cls.modules[subcls.path, subcls.name] = subcls + + @classmethod + def unregister_module(cls, subcls): + """ Unregister a Module subclass from the dictionary of modules. """ + p, n = subcls.path, subcls.name + try: + del cls.modules[n if p == "." else (p, n)] + except KeyError: + pass + for M in Module.subclasses: + if p == M.path and n == M.name: + Module.subclasses.remove(M) + break + logger.detail("Unregistered module '{}/{}'".format(p, n)) + + @classmethod + def unregister_modules(cls, *subcls): + """ Unregister Module subclasses from the dictionary of modules. """ + for sc in subcls: + cls.unregister_module(sc) + + def _feedback(self, success, failmsg): + """ Dummy feedback method using a fail-message formatted with the "not" keyword (to be replaced by a null string + in case of success). """ + if success is None: + return + elif success: + self.logger.success(failmsg.replace("not ", "")) + else: + self.logger.failure(failmsg) + diff --git a/tests/utils.py b/tests/utils.py index 257a299..0001924 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,75 +1,75 @@ - #!/usr/bin/env python -# -*- coding: UTF-8 -*- -"""Test utility functions. - -""" -import os -import pytest -import re -import sys -from peewee import DoesNotExist -from subprocess import Popen, PIPE -from tinyscript.helpers import ClassRegistry, Path -from unittest import TestCase -from unittest.mock import patch - -from sploitkit import * -from sploitkit.__info__ import * -from sploitkit.core.entity import load_entities, Entity - -from testsploit.main import MySploitConsole - - -__all__ = ["CONSOLE", "execute", "patch", "rcfile", "reset_entities", "BaseModel", "Command", "Console", "DoesNotExist", - "Entity", "Model", "Module", "StoreExtension", "TestCase"] - - -try: - CONSOLE = MySploitConsole() - CONSOLE.config['APP_FOLDER'] = "testsploit/workspace" - CONSOLE.config['WORKSPACE'] = "testsploit/workspace" -except: - CONSOLE = MySploitConsole.parent -FILE = ".commands.rc" - - -def execute(*commands): - """ Execute commands. """ - c = list(commands) + ["exit"] - p = os.path.join("testsploit", FILE) - with open(p, 'w') as f: - f.write("\n".join(c)) - r = rcfile(FILE) - os.remove(p) - return r - - -def rcfile(rcfile, debug=False): - """ Execute commands using a rcfile. """ - p = os.path.join("testsploit", rcfile) - if not os.path.isfile(p): - raise ValueError("Bad rc file") - cmd = "cd testsploit && python main.py --rcfile %s" % rcfile - if debug: - cmd += " -v" - out, err = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True).communicate() - out = re.split(r"\+{10,}\s.*?\s\+{10,}", out.decode())[1:] - err = "\n".join(l for l in err.decode().splitlines() if not l.startswith("Warning: ")).strip() - c = [] - with open(p) as f: - for l in f: - l = l.strip() - try: - c.append((l, re.sub(r"\x1b\[\??\d{1,3}[hm]", "", out.pop(0)).strip())) - except IndexError: - c.append((l, None)) - if c[-1][0] == "exit": - c.pop(-1) - return c, err - - -def reset_entities(*entities): - entities = list(entities) or [BaseModel, Command, Console, Model, Module, StoreExtension] - Entity._subclasses = ClassRegistry() - load_entities(entities) - + #!/usr/bin/env python +# -*- coding: UTF-8 -*- +"""Test utility functions. + +""" +import os +import pytest +import re +import sys +from peewee import DoesNotExist +from subprocess import Popen, PIPE +from tinyscript.helpers import ClassRegistry, Path +from unittest import TestCase +from unittest.mock import patch + +from sploitkit import * +from sploitkit.__info__ import * +from sploitkit.core.entity import load_entities, Entity + +from testsploit.main import MySploitConsole + + +__all__ = ["CONSOLE", "execute", "patch", "rcfile", "reset_entities", "BaseModel", "Command", "Console", "DoesNotExist", + "Entity", "Model", "Module", "StoreExtension", "TestCase"] + + +try: + CONSOLE = MySploitConsole() + CONSOLE.config['APP_FOLDER'] = "testsploit/workspace" + CONSOLE.config['WORKSPACE'] = "testsploit/workspace" +except: + CONSOLE = MySploitConsole.parent +FILE = ".commands.rc" + + +def execute(*commands): + """ Execute commands. """ + c = list(commands) + ["exit"] + p = os.path.join("testsploit", FILE) + with open(p, 'w') as f: + f.write("\n".join(c)) + r = rcfile(FILE) + os.remove(p) + return r + + +def rcfile(rcfile, debug=False): + """ Execute commands using a rcfile. """ + p = os.path.join("testsploit", rcfile) + if not os.path.isfile(p): + raise ValueError("Bad rc file") + cmd = "cd testsploit && python3 main.py --rcfile %s" % rcfile + if debug: + cmd += " -v" + out, err = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True).communicate() + out = re.split(r"\+{10,}\s.*?\s\+{10,}", out.decode())[1:] + err = "\n".join(l for l in err.decode().splitlines() if not l.startswith("Warning: ")).strip() + c = [] + with open(p) as f: + for l in f: + l = l.strip() + try: + c.append((l, re.sub(r"\x1b\[\??\d{1,3}[hm]", "", out.pop(0)).strip())) + except IndexError: + c.append((l, None)) + if c[-1][0] == "exit": + c.pop(-1) + return c, err + + +def reset_entities(*entities): + entities = list(entities) or [BaseModel, Command, Console, Model, Module, StoreExtension] + Entity._subclasses = ClassRegistry() + load_entities(entities) + diff --git a/testsploit/README b/testsploit/README new file mode 100644 index 0000000..fff59fa --- /dev/null +++ b/testsploit/README @@ -0,0 +1,3 @@ +# {} + +#TODO: Fill in the README \ No newline at end of file diff --git a/testsploit/commands/commands.py b/testsploit/commands/commands.py new file mode 100644 index 0000000..c8762a8 --- /dev/null +++ b/testsploit/commands/commands.py @@ -0,0 +1,37 @@ +from sploitkit import * + + +class CommandWithOneArg(Command): + """ Description here """ + level = "module" + single_arg = True + + def complete_values(self): + #TODO: compute the list of possible values + return [] + + def run(self): + #TODO: compute results here + pass + + def validate(self, value): + #TODO: validate the input value + if value not in self.complete_values(): + raise ValueError("invalid value") + + +class CommandWithTwoArgs(Command): + """ Description here """ + level = "module" + + def complete_keys(self): + #TODO: compute the list of possible keys + return [] + + def complete_values(self, key=None): + #TODO: compute the list of possible values taking the key into account + return [] + + def run(self): + #TODO: compute results here + pass diff --git a/testsploit/main.py b/testsploit/main.py new file mode 100644 index 0000000..c3772d3 --- /dev/null +++ b/testsploit/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/python3 +import site +site.addsitedir("../src") + +from sploitkit import FrameworkConsole +from tinyscript import * + + +class MySploitConsole(FrameworkConsole): + #TODO: set your console attributes + pass + + +if __name__ == '__main__': + parser.add_argument("-d", "--dev", action="store_true", help="enable development mode") + parser.add_argument("-r", "--rcfile", type=ts.file_exists, help="execute commands from a rcfile") + initialize() + c = MySploitConsole( + "MySploit", + #TODO: configure your console settings + dev=args.dev, + ) + c.rcfile(args.rcfile) if args.rcfile else c.start() diff --git a/testsploit/modules/modules.py b/testsploit/modules/modules.py new file mode 100644 index 0000000..8da7d3c --- /dev/null +++ b/testsploit/modules/modules.py @@ -0,0 +1,21 @@ +from sploitkit import * + + +class MyFirstModule(Module): + """ Description here + + Author: your name (your email) + Version: 1.0 + """ + def run(self): + pass + + +class MySecondModule(Module): + """ Description here + + Author: your name (your email) + Version: 1.0 + """ + def run(self): + pass diff --git a/testsploit/requirements.txt b/testsploit/requirements.txt new file mode 100644 index 0000000..7f29d4e --- /dev/null +++ b/testsploit/requirements.txt @@ -0,0 +1 @@ +sploitkit>=0.5.8 diff --git a/testsploit/workspace/store.db b/testsploit/workspace/store.db new file mode 100644 index 0000000..756e88c Binary files /dev/null and b/testsploit/workspace/store.db differ