diff --git a/bbot_server/cli/base.py b/bbot_server/cli/base.py index a1c4296..bf2a23f 100644 --- a/bbot_server/cli/base.py +++ b/bbot_server/cli/base.py @@ -41,6 +41,7 @@ class BaseBBCTL: # allow the command to be invoked without a subcommand _invoke_without_command = False + _no_args_is_help = True # imports for convenience import sys @@ -60,7 +61,7 @@ def __init__(self, parent=None): short_help=self.short_help, epilog=self.epilog, invoke_without_command=self._invoke_without_command, - no_args_is_help=True, + no_args_is_help=self._no_args_is_help, ) # register main method diff --git a/bbot_server/cli/themes.py b/bbot_server/cli/themes.py index 6ece180..a01b62c 100644 --- a/bbot_server/cli/themes.py +++ b/bbot_server/cli/themes.py @@ -1,21 +1,8 @@ import typer -# from textual.theme import Theme COLOR = "bold dark_orange" DARK_COLOR = "grey50" -# # textual theme -# TEXTUAL_THEME = Theme( -# name="bbot", -# primary="#000000", -# secondary="#1a1a1a", -# accent="#FF8400", -# warning="#FF8400", -# error="#ff4500", -# success="#FF8400", -# foreground="#ffffff", -# ) - # typer theme typer.rich_utils.STYLE_OPTION = "bold dark_orange" typer.rich_utils.STYLE_NEGATIVE_OPTION = "bold red" diff --git a/bbot_server/modules/tui/scan_editor.py b/bbot_server/modules/tui/scan_editor.py new file mode 100644 index 0000000..cf4d426 --- /dev/null +++ b/bbot_server/modules/tui/scan_editor.py @@ -0,0 +1,236 @@ +from textual import on +from textual.events import Click +from textual.reactive import var +from textual.app import App, ComposeResult +from textual.containers import Container, VerticalScroll, Vertical, Horizontal +from textual.widgets import Footer, Header, ListView, ListItem, Label, TextArea, Button, Switch, Input + +from bbot.scanner import Scanner, Preset +from bbot.core.helpers.names_generator import random_name + +from .themes import TEXTUAL_THEME + + +class ScanEditor(App): + CSS = """ +#nav-bar { + width: 25 ; + dock: left; + + &.-highlight { + background: #1a1a1a; + } + + ListView { + background-tint: #1a1a1a; + } + + ListItem { + padding: 1 3; + &.-highlight { + color: #ff8400; + background: black; + text-style: bold; + outline-left: thick #ff8400; + } + } +} + +Button { + margin: 0 1; + text-style: none; +} + +#buttons { + dock: bottom; + height: 3; + width: 100%; + align-horizontal: right; +} + +.nav-tab { + width: 100%; + padding: 0; + margin: 0; +} + +.label { + padding: 1; +} + +#buttons-right { + width: auto; +} + +#scan-name-container { + height: 3; + text-style: bold; +} + +#scan-name { + outline: outer #ff8400; +} + +#scan-name-label { + background: #ff8400; + color: black; +} + +#editor-label { + padding: 1 3; + color: #808080; +} +""" + + BINDINGS = [ + ("q", "quit", "Quit"), + ] + + show_tree = var(True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.scan_name = random_name() + self._start_scan = False + self._current_pane = "target" + self._text_panes = { + "target": "", + "whitelist": "", + "blacklist": "", + "preset": f"description: {self.scan_name}\n", + } + self._descriptions = { + "target": "Paste your IPs, domains etc. in here. These are used to seed the scan.", + "whitelist": "If left blank, will default to the target.", + "blacklist": "Takes ultimate precedence over whitelist and target.", + "preset": "BBOT Preset in YAML format. Put your API keys, etc. in here.", + } + self._success = False + + def notify(self, *args, **kwargs): + if "timeout" not in kwargs: + kwargs["timeout"] = 1 + super().notify(*args, **kwargs) + + def do_target(self): + self.notify("Target") + + def compose(self) -> ComposeResult: + """Compose our UI.""" + yield Header() + with Horizontal(id="scan-name-container"): + yield Label("Scan Name", id="scan-name-label", classes="label") + yield Input(value=self.scan_name, placeholder="Scan Name", id="scan-name") + with Container(id="scan-editor"): + with Vertical(id="nav-bar"): + yield ListView( + ListItem(Label("Target", classes="nav-tab target-button"), classes="target-button"), + ListItem(Label("Whitelist", classes="nav-tab whitelist-button"), classes="whitelist-button"), + ListItem(Label("Blacklist", classes="nav-tab blacklist-button"), classes="blacklist-button"), + ListItem(Label("Preset", classes="nav-tab preset-button"), classes="preset-button"), + ) + with VerticalScroll(id="code-view"): + with Vertical(id="editor-container"): + yield Label("", id="editor-label") + yield TextArea.code_editor("", id="code-editor") + with Horizontal(id="buttons"): + with Horizontal(id="buttons-right"): + yield Label("Start Scan", classes="label") + yield Switch(value=False, id="start-scan", animate=False) + yield Button("SAVE", variant="success", id="save") + yield Button("QUIT", id="quit") + yield Footer() + + def save_text(self): + self.scan_name = self.query_one("#scan-name", Input).value + self._text_panes[self._current_pane] = self.query_one("#code-editor", TextArea).text + + def switch_text_pane(self, var_name, syntax=None): + if var_name not in self._text_panes: + raise ValueError(f"Invalid variable name: {var_name}") + self.save_text() + self._current_pane = var_name + text_area = self.query_one("#code-editor", TextArea) + text_area.text = self._text_panes[var_name] + text_area.language = syntax + label = self.query_one("#editor-label") + label.update(self._descriptions[var_name]) + self.focus_editor() + + @on(Click) + def on_click(self, event: Click): + if "target-button" in event.widget.classes: + self.switch_text_pane("target") + elif "whitelist-button" in event.widget.classes: + self.switch_text_pane("whitelist") + elif "blacklist-button" in event.widget.classes: + self.switch_text_pane("blacklist") + elif "preset-button" in event.widget.classes: + self.switch_text_pane("preset", syntax="yaml") + + def select_all_text(self): + self.query_one("#code-editor", TextArea).select_all() + + def on_key(self, event): + if event.key == "ctrl+a": + event.prevent_default() + self.select_all_text() + elif event.key == "ctrl+c": + event.prevent_default() + self.notify("Press CTRL+Q to quit", severity="error") + + def on_switch_changed(self, event: Switch.Changed): + if event.switch.id == "start-scan": + self._start_scan = event.value + + def on_button_pressed(self, event: Button.Pressed) -> None: + button = event.button + + if button.id == "quit": + self.exit() + elif button.id == "save": + self.save_text() + self._success = True + self.exit() + + def focus_editor(self): + self.query_one("#code-editor", TextArea).focus() + + def on_mount(self) -> None: + # Set BBOT theme + self.register_theme(TEXTUAL_THEME) + self.theme = "bbot" + + self.switch_text_pane("target") + + def make_scan(self): + self.run() + if not self._success: + return None, False + + targets = [t for t in self._text_panes["target"].splitlines() if t] + whitelist = [w for w in self._text_panes["whitelist"].splitlines() if w] + blacklist = [b for b in self._text_panes["blacklist"].splitlines() if b] + + preset_str = self._text_panes["preset"].strip() + base_preset = Preset.from_yaml_string(preset_str) + + # validate targets + preset by instantiating a real scan + scan = Scanner( + *targets, scan_name=self.scan_name, whitelist=whitelist, blacklist=blacklist, preset=base_preset + ) + preset_dict_sanitized = scan.preset.to_dict() + + print(scan.preset.to_dict(include_target=True)) + + from bbot_server.applets.scans import Scan + + scan = Scan( + name=self.scan_name, + preset=preset_dict_sanitized, + target=targets, + whitelist=whitelist, + blacklist=blacklist, + ) + + return scan, self._start_scan diff --git a/bbot_server/modules/tui/themes.py b/bbot_server/modules/tui/themes.py new file mode 100644 index 0000000..f79cb7f --- /dev/null +++ b/bbot_server/modules/tui/themes.py @@ -0,0 +1,12 @@ +from textual.theme import Theme + +TEXTUAL_THEME = Theme( + name="bbot", + primary="#000000", + secondary="#1a1a1a", + accent="#FF8400", + warning="#FF8400", + error="#ff4500", + success="#FF8400", + foreground="#ffffff", +) diff --git a/bbot_server/modules/tui/tui_cli.py b/bbot_server/modules/tui/tui_cli.py new file mode 100644 index 0000000..9dbf229 --- /dev/null +++ b/bbot_server/modules/tui/tui_cli.py @@ -0,0 +1,17 @@ +from bbot_server.cli.base import BaseBBCTL + + +class TUI(BaseBBCTL): + command = "ui" + help = "Textual UI Proof of Concept for BBOT" + short_help = "Textual UI Proof of Concept for BBOT" + attach_to = "scan" + + _invoke_without_command = True + _no_args_is_help = False + + def main(self): + from bbot_server.modules.tui.scan_editor import ScanEditor + + app = ScanEditor() + app.run() diff --git a/poetry.lock b/poetry.lock index 899d034..c12b9ad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1043,6 +1043,27 @@ pyyaml = "*" [package.extras] dev = ["build", "hypothesis", "pytest", "setuptools-scm"] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "lockfile" version = "0.12.2" @@ -1224,6 +1245,7 @@ files = [ ] [package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} mdurl = ">=0.1,<1.0" [package.extras] @@ -1362,6 +1384,26 @@ cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] rich = ["rich (>=13.9.4)"] ws = ["websockets (>=15.0.1)"] +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +description = "Collection of plugins for markdown-it-py" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f"}, + {file = "mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6"}, +] + +[package.dependencies] +markdown-it-py = ">=2.0.0,<5.0.0" + +[package.extras] +code-style = ["pre-commit"] +rtd = ["myst-parser", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "mdurl" version = "0.1.2" @@ -1672,6 +1714,23 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "platformdirs" +version = "4.5.1" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, + {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, +] + +[package.extras] +docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] +type = ["mypy (>=1.18.2)"] + [[package]] name = "pluggy" version = "1.6.0" @@ -2660,20 +2719,19 @@ test = ["commentjson", "packaging", "pytest"] [[package]] name = "rich" -version = "13.9.4" +version = "14.2.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["main"] files = [ - {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, - {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, + {file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"}, + {file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" -typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -2995,6 +3053,29 @@ files = [ redis = ">=5,<6" taskiq = ">=0.11.12,<1" +[[package]] +name = "textual" +version = "6.8.0" +description = "Modern Text User Interface framework" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "textual-6.8.0-py3-none-any.whl", hash = "sha256:074d389ba8c6c98c74e2a4fe1493ea3a38f3ee5008697e98f71daa2cf8ab8fda"}, + {file = "textual-6.8.0.tar.gz", hash = "sha256:7efe618ec9197466b8fe536aefabb678edf30658b9dc58a763365d7daed12b62"}, +] + +[package.dependencies] +markdown-it-py = {version = ">=2.1.0", extras = ["linkify"]} +mdit-py-plugins = "*" +platformdirs = ">=3.6.0,<5" +pygments = ">=2.19.2,<3.0.0" +rich = ">=14.2.0" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.25.0) ; python_version >= \"3.10\"", "tree-sitter-bash (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-css (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-go (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-html (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-java (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-javascript (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-json (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-markdown (>=0.3.0) ; python_version >= \"3.10\"", "tree-sitter-python (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-regex (>=0.24.0) ; python_version >= \"3.10\"", "tree-sitter-rust (>=0.23.0) ; python_version >= \"3.10\"", "tree-sitter-sql (>=0.3.11) ; python_version >= \"3.10\"", "tree-sitter-toml (>=0.6.0) ; python_version >= \"3.10\"", "tree-sitter-xml (>=0.7.0) ; python_version >= \"3.10\"", "tree-sitter-yaml (>=0.6.0) ; python_version >= \"3.10\""] + [[package]] name = "tldextract" version = "5.3.0" @@ -3116,6 +3197,21 @@ files = [ [package.dependencies] typing-extensions = ">=4.12.0" +[[package]] +name = "uc-micro-py" +version = "1.0.3" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a"}, + {file = "uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "unidecode" version = "1.4.0" @@ -3721,4 +3817,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "3028e30fe4ac02ffb534077bde4603e16263ebfe8722029243536563ae04b203" +content-hash = "5ab24e66ff725b8405d74cf88e53a951cdfbe751dc3ed1bdff506a020251d9e2" diff --git a/pyproject.toml b/pyproject.toml index ab40ecb..97590c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ license = "GPL-v3.0" python = "^3.10" fastapi = "^0.111.0" orjson = "^3.10.12" -rich = "^13.9.4" cachetools = "^5.5.0" jsondiff = "^2.2.1" websockets = "^14.1" @@ -26,6 +25,8 @@ mcp = "1.7.0" jmespath = "^1.0.1" uvicorn = "^0.35.0" pymongo = "^4.15.3" +rich = "^14.2.0" +textual = "^6.8.0" [tool.poetry.scripts] bbctl = 'bbot_server.cli.bbctl:main'