- Rust 98%
- Nix 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| src | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
PenSuite
A modular terminal UI for penetration testing, built with Ratatui.
Modules are plain Rust files - implement one trait, register one line, recompile.
Features
- TUI with keyboard-driven navigation and a calm, low-contrast colour scheme
- Auto-detects target type (URL, IP, Hostname) and shows only compatible modules
- Each module lists its supported target types as tags in the overview
- Fuzzy search the module list — press
/and type - Modules can request user input — collected in a floating form before the run
- Modules run on a background thread with an animated loading screen (no UI freeze)
- Pass a target directly as a CLI argument to skip the prompt
- Double Ctrl-C to quit from anywhere
- Scrollable module output
Installation
git clone <repo>
cd pensuite
cargo build --release
# binary is at target/release/pensuite
Requires Rust 1.85+ (edition 2024).
Usage
# Interactive — prompts for a target on launch
pensuite
# Skip the prompt — target is classified and the dashboard opens immediately
pensuite https://example.com
pensuite 192.168.1.1
pensuite internal.corp
Keybindings
Target input
| Key | Action |
|---|---|
Enter |
Confirm target (non-empty required) |
Esc |
Cancel and return to dashboard (only if a target is already set) |
Backspace |
Delete last character |
Dashboard
| Key | Action |
|---|---|
t |
Edit current target |
/ |
Search / filter the module list |
↑ / k |
Move selection up |
↓ / j |
Move selection down |
Enter |
Run selected module (or open its input form) |
q |
Quit |
Ctrl-C × 2 |
Quit (works on every screen) |
Search (after pressing /)
| Key | Action |
|---|---|
| (type) | Filter modules by name / description |
↑ / ↓ |
Move selection within the filtered list |
Enter |
Run selected module |
Backspace |
Delete last character |
Esc |
Clear search and exit search mode |
Module input form
Shown automatically when a module declares inputs.
| Key | Action |
|---|---|
| (type) | Edit the focused field |
↑ / ↓ / Tab |
Move between fields |
Enter |
Next field, or run when on the last field |
Esc |
Cancel and return to dashboard |
Running
| Key | Action |
|---|---|
Esc |
Stop waiting and return to dashboard (the worker is detached) |
Module output
| Key | Action |
|---|---|
q / Esc |
Back to dashboard |
↑ / k |
Scroll up |
↓ / j |
Scroll down |
Target detection
When a target is entered the type is detected automatically and displayed as a badge in the header. Only modules that declare support for that type are shown.
| Detected as | Example inputs |
|---|---|
| URL | https://example.com, http://10.0.0.1:8080/path |
| IP | 192.168.1.1, 10.0.0.1:8080, ::1, [::1]:9090 |
| Hostname | example.com, localhost, internal.corp |
| (unknown) | Anything that doesn't match — all modules remain visible |
Built-in modules
| Name | Supported targets | Description |
|---|---|---|
http-get |
URL | GET request — shows status, headers, and body |
port-scan |
IP, Hostname | TCP connect scan across a configurable list of ports (asks for ports and timeout_ms) |
react-ssr-rce |
URL | CVE-2025-55182 React2Shell check (asks for the route to probe) |
Modules that declare inputs prompt for them in a floating form before running; the collected values are passed to the module alongside the target.
Adding a module
1. Create the file — src/modules/my_module.rs:
use crate::target::TargetKind;
use super::{InputSpec, Module, RunContext};
pub struct MyModule;
impl Module for MyModule {
fn name(&self) -> &str { "my-module" }
fn description(&self) -> &str { "Does something useful" }
// Omit supported_targets() to accept any recognised target type,
// or return a slice to restrict:
fn supported_targets(&self) -> &[TargetKind] {
&[TargetKind::Ip, TargetKind::Hostname]
}
// Optional — omit for a module that needs no input. Declared inputs are
// collected in a floating form before run() is called.
fn inputs(&self) -> Vec<InputSpec> {
vec![InputSpec::new("port", "Port to probe", "8080")]
}
// run() executes on a background thread. `ctx` carries the target plus any
// collected inputs; reach them with ctx.target and ctx.input("key").
fn run(&self, ctx: &RunContext) -> Result<String, String> {
Ok(format!("ran against {} on port {}", ctx.target, ctx.input("port")))
}
}
2. Expose it — add one line to src/modules/mod.rs:
pub mod my_module;
3. Register it — add one line to main() in src/main.rs:
app.modules.register(Arc::new(modules::my_module::MyModule));
That's it. Recompile and the module appears in the list for compatible targets.
Modules run on a worker thread, so they must be
Send + Sync(the trait requires it) and are registered asArc<dyn Module>.
Project structure
src/
├── main.rs — entry point, module registration
├── app.rs — state machine, event handling, background runner
├── ui.rs — all rendering (Ratatui)
├── target.rs — TargetKind enum and auto-detection
└── modules/
├── mod.rs — Module trait, InputSpec, RunContext, registry
├── http_get.rs — built-in HTTP GET module
├── port_scan.rs — TCP port scanner (example of user inputs)
└── react_ssr_rce.rs — CVE-2025-55182 React2Shell check