diff --git a/security/lightscope/Makefile b/security/lightscope/Makefile new file mode 100644 index 00000000000..40e58fb981b --- /dev/null +++ b/security/lightscope/Makefile @@ -0,0 +1,32 @@ +PORTNAME= lightscope +DISTVERSION= 1.0 +CATEGORIES= security python +MASTER_SITES= https://github.com/Thelightscope/thelightscope/releases/download/v${DISTVERSION}/ + +MAINTAINER= e@alumni.usc.edu +COMMENT= Network security telescope and honeypot for cybersecurity research +WWW= https://thelightscope.com + +LICENSE= BSD2CLAUSE +LICENSE_FILE= ${WRKSRC}/LICENSE + +RUN_DEPENDS= ${PYTHON_PKGNAMEPREFIX}dpkt>=0:net/py-dpkt@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}requests>=0:www/py-requests@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}psutil>=0:sysutils/py-psutil@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pcapy-ng>=0:net/py-pcapy-ng@${PY_FLAVOR} + +USES= python shebangfix +SHEBANG_FILES= *.py +NO_BUILD= yes +NO_ARCH= yes + +SUB_FILES= lightscope +SUB_LIST= PYTHON_CMD=${PYTHON_CMD} + +do-install: + ${MKDIR} ${STAGEDIR}${DATADIR} + ${INSTALL_SCRIPT} ${WRKSRC}/*.py ${STAGEDIR}${DATADIR}/ + ${INSTALL_DATA} ${WRKSRC}/lightscope.conf.sample ${STAGEDIR}${PREFIX}/etc/ + ${INSTALL_SCRIPT} ${WRKDIR}/lightscope ${STAGEDIR}${PREFIX}/bin/ + +.include diff --git a/security/lightscope/distinfo b/security/lightscope/distinfo new file mode 100644 index 00000000000..a18e0ed74f0 --- /dev/null +++ b/security/lightscope/distinfo @@ -0,0 +1,3 @@ +TIMESTAMP = 1735660000 +SHA256 (lightscope-1.0.tar.gz) = ddef11e6818224e4df0fd477e29a85dcc1da61c960436aaf027a42d5b362fcd1 +SIZE (lightscope-1.0.tar.gz) = 13326 diff --git a/security/lightscope/files/lightscope.in b/security/lightscope/files/lightscope.in new file mode 100644 index 00000000000..816a7d0e8a7 --- /dev/null +++ b/security/lightscope/files/lightscope.in @@ -0,0 +1,6 @@ +#!/bin/sh +# +# LightScope entry point script +# Forwards all commands to the Python daemon +# +exec %%PYTHON_CMD%% %%DATADIR%%/lightscope_daemon.py "$@" diff --git a/security/lightscope/pkg-descr b/security/lightscope/pkg-descr new file mode 100644 index 00000000000..d35cd6ed305 --- /dev/null +++ b/security/lightscope/pkg-descr @@ -0,0 +1,14 @@ +LightScope is a network security telescope and honeypot system designed for +cybersecurity research. It monitors network traffic by analyzing firewall +logs (pflog) to detect and log blocked connection attempts, providing +valuable data for threat intelligence and research. + +Features: +- Real-time pflog monitoring for blocked traffic analysis +- Optional honeypot services to capture attacker behavior +- Automatic data upload to LightScope cloud dashboard +- Low resource footprint suitable for firewall appliances + +LightScope contributes anonymized threat data to benefit the security +research community while helping you understand what attacks target your +network. diff --git a/security/lightscope/pkg-plist b/security/lightscope/pkg-plist new file mode 100644 index 00000000000..1c341b1f932 --- /dev/null +++ b/security/lightscope/pkg-plist @@ -0,0 +1,7 @@ +bin/lightscope +@sample etc/lightscope.conf.sample +%%DATADIR%%/__init__.py +%%DATADIR%%/honeypot.py +%%DATADIR%%/lightscope_daemon.py +%%DATADIR%%/pflog_reader.py +%%DATADIR%%/uploader.py diff --git a/security/lightscope/work/.PLIST.flattened b/security/lightscope/work/.PLIST.flattened new file mode 100644 index 00000000000..789c0d06e1c --- /dev/null +++ b/security/lightscope/work/.PLIST.flattened @@ -0,0 +1,9 @@ +/usr/local/share/licenses/lightscope-1.0/catalog.mk +/usr/local/share/licenses/lightscope-1.0/LICENSE +/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE +/usr/local/bin/lightscope +/usr/local/share/lightscope/__init__.py +/usr/local/share/lightscope/honeypot.py +/usr/local/share/lightscope/lightscope_daemon.py +/usr/local/share/lightscope/pflog_reader.py +/usr/local/share/lightscope/uploader.py diff --git a/security/lightscope/work/.PLIST.lightscope b/security/lightscope/work/.PLIST.lightscope new file mode 100644 index 00000000000..ce64994e603 --- /dev/null +++ b/security/lightscope/work/.PLIST.lightscope @@ -0,0 +1,13 @@ +/usr/local/share/licenses/lightscope-1.0/catalog.mk +/usr/local/share/licenses/lightscope-1.0/LICENSE +/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE +bin/lightscope +@sample etc/lightscope.conf.sample +share/lightscope/__init__.py +share/lightscope/honeypot.py +share/lightscope/lightscope_daemon.py +share/lightscope/pflog_reader.py +share/lightscope/uploader.py +@owner +@group +@mode diff --git a/security/lightscope/work/.PLIST.mktmp b/security/lightscope/work/.PLIST.mktmp new file mode 100644 index 00000000000..ce64994e603 --- /dev/null +++ b/security/lightscope/work/.PLIST.mktmp @@ -0,0 +1,13 @@ +/usr/local/share/licenses/lightscope-1.0/catalog.mk +/usr/local/share/licenses/lightscope-1.0/LICENSE +/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE +bin/lightscope +@sample etc/lightscope.conf.sample +share/lightscope/__init__.py +share/lightscope/honeypot.py +share/lightscope/lightscope_daemon.py +share/lightscope/pflog_reader.py +share/lightscope/uploader.py +@owner +@group +@mode diff --git a/security/lightscope/work/.PLIST.readelf b/security/lightscope/work/.PLIST.readelf new file mode 100644 index 00000000000..340855f2349 --- /dev/null +++ b/security/lightscope/work/.PLIST.readelf @@ -0,0 +1,18 @@ + +File: /usr/local/share/licenses/lightscope-1.0/catalog.mk + +File: /usr/local/share/licenses/lightscope-1.0/LICENSE + +File: /usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE + +File: /usr/local/bin/lightscope + +File: /usr/local/share/lightscope/__init__.py + +File: /usr/local/share/lightscope/honeypot.py + +File: /usr/local/share/lightscope/lightscope_daemon.py + +File: /usr/local/share/lightscope/pflog_reader.py + +File: /usr/local/share/lightscope/uploader.py diff --git a/security/lightscope/work/.PLIST.setuid b/security/lightscope/work/.PLIST.setuid new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.PLIST.writable b/security/lightscope/work/.PLIST.writable new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.build_done.lightscope._usr_local b/security/lightscope/work/.build_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.configure_done.lightscope._usr_local b/security/lightscope/work/.configure_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.extract_done.lightscope._usr_local b/security/lightscope/work/.extract_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.install_done.lightscope._usr_local b/security/lightscope/work/.install_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.license-catalog.mk b/security/lightscope/work/.license-catalog.mk new file mode 100644 index 00000000000..f390b196978 --- /dev/null +++ b/security/lightscope/work/.license-catalog.mk @@ -0,0 +1,5 @@ +_LICENSE=BSD2CLAUSE +_LICENSE_NAME=BSD 2-clause Simplified License +_LICENSE_PERMS=dist-mirror dist-sell pkg-mirror pkg-sell auto-accept +_LICENSE_GROUPS=FSF OSI COPYFREE +_LICENSE_DISTFILES=lightscope-1.0.tar.gz diff --git a/security/lightscope/work/.license-report b/security/lightscope/work/.license-report new file mode 100644 index 00000000000..d6ed4a82b84 --- /dev/null +++ b/security/lightscope/work/.license-report @@ -0,0 +1 @@ +This package has a single license: BSD2CLAUSE (BSD 2-clause Simplified License). diff --git a/security/lightscope/work/.license_done.lightscope._usr_local b/security/lightscope/work/.license_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.patch_done.lightscope._usr_local b/security/lightscope/work/.patch_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/.stage_done.lightscope._usr_local b/security/lightscope/work/.stage_done.lightscope._usr_local new file mode 100644 index 00000000000..e69de29bb2d diff --git a/security/lightscope/work/lightscope b/security/lightscope/work/lightscope new file mode 100644 index 00000000000..3e795038639 --- /dev/null +++ b/security/lightscope/work/lightscope @@ -0,0 +1,6 @@ +#!/bin/sh +# +# LightScope entry point script +# Forwards all commands to the Python daemon +# +exec /usr/local/bin/python3.11 /usr/local/share/lightscope/lightscope_daemon.py "$@" diff --git a/security/lightscope/work/lightscope-1.0/LICENSE b/security/lightscope/work/lightscope-1.0/LICENSE new file mode 100644 index 00000000000..d3ec7dc876b --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2025, USC Information Sciences Institute +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/security/lightscope/work/lightscope-1.0/__init__.py b/security/lightscope/work/lightscope-1.0/__init__.py new file mode 100755 index 00000000000..3ecfb9d2dea --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/__init__.py @@ -0,0 +1 @@ +# LightScope for OPNsense diff --git a/security/lightscope/work/lightscope-1.0/honeypot.py b/security/lightscope/work/lightscope-1.0/honeypot.py new file mode 100755 index 00000000000..2282de53486 --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/honeypot.py @@ -0,0 +1,504 @@ +#!/usr/local/bin/python3.11 +""" +honeypot.py - Honeypot listener with PROXY protocol forwarding + +This module opens sockets on configured ports and forwards connections +to a remote honeypot server using the PROXY protocol to preserve +the original attacker IP address. +""" + +import socket +import select +import threading +import time +import sys +import subprocess +import re + + +class HoneypotListener: + """ + Manages honeypot listener sockets and forwards connections + to remote honeypot server using PROXY protocol. + """ + + def __init__(self, config, upload_pipe, database): + """ + Initialize honeypot listener. + + Args: + config: Dict with honeypot configuration + upload_pipe: Pipe for sending honeypot connection data + database: Database identifier for PROXY header + """ + self.config = config + self.upload_pipe = upload_pipe + self.database = database + + self.honeypot_server = config.get('honeypot_server', '128.9.28.79') + self.ssh_port = int(config.get('honeypot_ssh_port', 12345)) + self.telnet_port = int(config.get('honeypot_telnet_port', 12346)) + + self.sockets = {} # socket -> port mapping + self.sockets_lock = threading.Lock() + self.running = False + + # Connection limiting + self.MAX_CONCURRENT_CONNECTIONS = 50 + self.connection_semaphore = threading.Semaphore(self.MAX_CONCURRENT_CONNECTIONS) + self.active_connections = 0 + self.connection_lock = threading.Lock() + + # Firewall monitoring + self.FIREWALL_CHECK_INTERVAL = 10 # seconds + + def parse_ports(self, ports_string): + """Parse comma-separated port string into list of integers.""" + if not ports_string: + return [] + + ports = [] + for p in ports_string.split(','): + p = p.strip() + if p.isdigit(): + port = int(p) + if 1 <= port <= 65535: + ports.append(port) + return ports + + def get_firewall_allowed_ports(self): + """ + Get list of ports that have PASS/ALLOW rules in the firewall. + These are ports where legitimate services may be running. + Returns a tuple of (set of ports, list of (start, end) range tuples). + """ + allowed_ports = set() + allowed_ranges = [] + + try: + result = subprocess.run( + ["pfctl", "-sr"], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + for line in result.stdout.split('\n'): + # Look for pass rules with port specifications + if line.startswith('pass') and 'proto tcp' in line: + # Match port ranges like "port 1:4343" or "port 80:443" + range_match = re.search(r'port\s*[=]?\s*(\d+):(\d+)', line) + if range_match: + start_port = int(range_match.group(1)) + end_port = int(range_match.group(2)) + allowed_ranges.append((start_port, end_port)) + continue # Don't also match as single port + + # Match single port patterns like "port = 22" or "port 22" + port_match = re.search(r'port\s*[=]?\s*(\d+)(?![\d:])', line) + if port_match: + allowed_ports.add(int(port_match.group(1))) + + # Match port lists in braces + brace_match = re.search(r'port\s*[=]?\s*\{([^}]+)\}', line) + if brace_match: + ports_str = brace_match.group(1) + for p in re.findall(r'\d+', ports_str): + allowed_ports.add(int(p)) + except Exception as e: + print(f"honeypot: Error checking firewall rules: {e}", flush=True) + + return allowed_ports, allowed_ranges + + def is_port_allowed_by_firewall(self, port, allowed_ports, allowed_ranges): + """Check if a port is allowed by firewall rules (including ranges).""" + if port in allowed_ports: + return True + for start, end in allowed_ranges: + if start <= port <= end: + return True + return False + + def is_port_in_use(self, port): + """ + Check if a port is already in use by another service. + Returns True if port is in use, False if available. + """ + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + test_sock.bind(("", port)) + test_sock.close() + return False # Port is available + except OSError: + return True # Port is in use + + def _firewall_monitor_thread(self): + """ + Monitor firewall rules and port usage every FIREWALL_CHECK_INTERVAL seconds. + If a new ALLOW rule is detected or port becomes in use, close that honeypot port. + """ + print("honeypot: Started firewall/port monitor (checking every 10s)", flush=True) + + while self.running: + time.sleep(self.FIREWALL_CHECK_INTERVAL) + if not self.running: + break + + try: + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + + with self.sockets_lock: + sockets_to_close = [] + for sock, port in list(self.sockets.items()): + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Firewall ALLOW rule detected for port {port}, closing honeypot on this port", flush=True) + sockets_to_close.append((sock, port, "firewall conflict")) + + for sock, port, reason in sockets_to_close: + try: + sock.close() + except: + pass + del self.sockets[sock] + print(f"honeypot: Closed listener on port {port} due to {reason}", flush=True) + + except Exception as e: + print(f"honeypot: Error in firewall monitor: {e}", flush=True) + + def open_port(self, port): + """ + Try to bind and listen on a port. + + Returns: + (socket, port) on success, (None, None) on failure + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", port)) + s.listen(10) + s.setblocking(False) + return s, port + except OSError as e: + if e.errno == 48: # EADDRINUSE on FreeBSD + print(f"honeypot: Port {port} already in use", flush=True) + elif e.errno == 13: # EACCES + print(f"honeypot: Permission denied for port {port}", flush=True) + else: + print(f"honeypot: Error binding port {port}: {e}", flush=True) + return None, None + except Exception as e: + print(f"honeypot: Unexpected error binding port {port}: {e}", flush=True) + return None, None + + def start(self, ports_string): + """ + Start honeypot listeners on configured ports. + + Args: + ports_string: Comma-separated list of ports + """ + ports = self.parse_ports(ports_string) + if not ports: + print("honeypot: No valid ports configured", flush=True) + return + + # Check for firewall conflicts and ports already in use + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + safe_ports = [] + for port in ports: + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Port {port} has a firewall ALLOW rule, skipping (may conflict with legitimate service)", flush=True) + elif self.is_port_in_use(port): + print(f"honeypot: WARNING - Port {port} is already in use by another service, skipping", flush=True) + else: + safe_ports.append(port) + + if not safe_ports: + print("honeypot: No ports available (all have conflicts or are in use)", flush=True) + return + + print(f"honeypot: Opening ports: {safe_ports}", flush=True) + + for port in safe_ports: + s, actual_port = self.open_port(port) + if s and actual_port: + with self.sockets_lock: + self.sockets[s] = actual_port + print(f"honeypot: Listening on port {actual_port}", flush=True) + + if not self.sockets: + print("honeypot: Failed to open any ports", flush=True) + return + + self.running = True + + # Start firewall monitor thread + monitor_thread = threading.Thread(target=self._firewall_monitor_thread, daemon=True) + monitor_thread.start() + + self._run_accept_loop() + + def stop(self): + """Stop all honeypot listeners.""" + self.running = False + with self.sockets_lock: + for s in list(self.sockets.keys()): + try: + s.close() + except: + pass + self.sockets.clear() + print("honeypot: Stopped all listeners", flush=True) + + def get_open_ports(self): + """Return list of currently open honeypot ports.""" + with self.sockets_lock: + return list(self.sockets.values()) + + def _run_accept_loop(self): + """Main accept loop for honeypot connections.""" + print("honeypot: Starting accept loop", flush=True) + + while self.running: + with self.sockets_lock: + sock_list = list(self.sockets.keys()) + + if not sock_list: + time.sleep(1.0) + continue + + try: + # Wait for incoming connections + readable, _, _ = select.select(sock_list, [], [], 1.0) + + for sock in readable: + try: + self._handle_connection(sock) + except Exception as e: + print(f"honeypot: Error handling connection: {e}", flush=True) + + except Exception as e: + if self.running: + print(f"honeypot: Error in accept loop: {e}", flush=True) + time.sleep(1.0) + + def _handle_connection(self, listen_sock): + """Handle an incoming connection on a honeypot port.""" + try: + local_conn, addr = listen_sock.accept() + with self.sockets_lock: + port = self.sockets.get(listen_sock) + if port is None: + local_conn.close() + return + attacker_ip = addr[0] + attacker_port = addr[1] + + print(f"honeypot: Connection from {attacker_ip}:{attacker_port} to port {port}", flush=True) + + # Determine service type: even ports = SSH, odd ports = TELNET + if port % 2 == 0: + service = 'SSH' + remote_port = self.ssh_port + else: + service = 'TELNET' + remote_port = self.telnet_port + + # Check connection limit + if not self.connection_semaphore.acquire(blocking=False): + print(f"honeypot: Connection limit reached, rejecting {attacker_ip}", flush=True) + local_conn.close() + return + + with self.connection_lock: + self.active_connections += 1 + + # Start forwarding in a new thread + threading.Thread( + target=self._forward_connection, + args=(local_conn, attacker_ip, attacker_port, port, service, remote_port), + daemon=True + ).start() + + except Exception as e: + print(f"honeypot: Error accepting connection: {e}", flush=True) + + def _forward_connection(self, local_conn, attacker_ip, attacker_port, honeypot_port, service, remote_port): + """ + Forward a connection to the remote honeypot server using PROXY protocol. + """ + remote_conn = None + connection_released = False + + try: + # Connect to remote honeypot + remote_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + remote_conn.settimeout(30.0) + + try: + remote_conn.connect((self.honeypot_server, remote_port)) + except Exception as e: + print(f"honeypot: Failed to connect to remote server: {e}", flush=True) + return + + # Send PROXY protocol header + # Format: PROXY TCP4 src_ip dest_ip src_port dest_port\r\n + proxy_header = f"PROXY TCP4 {attacker_ip} {self.database} {attacker_port} {honeypot_port}\r\n" + print(f"honeypot: Sending PROXY header: {proxy_header.strip()}", flush=True) + remote_conn.sendall(proxy_header.encode()) + + # Set timeouts for idle connections (3 minutes) + CONNECTION_TIMEOUT = 180 + local_conn.settimeout(CONNECTION_TIMEOUT) + remote_conn.settimeout(CONNECTION_TIMEOUT) + + # Send honeypot connection data for logging + self._send_connection_data(attacker_ip, attacker_port, honeypot_port, service) + + # Bidirectional forwarding + def forward(src, dst, direction): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + except Exception: + pass + finally: + try: + src.close() + except: + pass + try: + dst.close() + except: + pass + + # Start forwarding threads + t1 = threading.Thread(target=forward, args=(local_conn, remote_conn, "client->server")) + t2 = threading.Thread(target=forward, args=(remote_conn, local_conn, "server->client")) + t1.daemon = True + t2.daemon = True + t1.start() + t2.start() + + # Wait for both threads to complete + t1.join() + t2.join() + + print(f"honeypot: Connection from {attacker_ip} closed", flush=True) + + except Exception as e: + print(f"honeypot: Forwarding error: {e}", flush=True) + + finally: + # Clean up + for conn in (local_conn, remote_conn): + if conn: + try: + conn.close() + except: + pass + + # Release connection slot + if not connection_released: + self.connection_semaphore.release() + with self.connection_lock: + self.active_connections -= 1 + + def _send_connection_data(self, attacker_ip, attacker_port, honeypot_port, service): + """Send honeypot connection data to upload pipe.""" + try: + data = { + "db_name": self.database, + "system_time": str(time.time()), + "ip_version": "HP", + "ip_ihl": "HP", + "ip_tos": "HP", + "ip_len": "HP", + "ip_id": "HP", + "ip_flags": "HP", + "ip_frag": "HP", + "ip_ttl": "HP", + "ip_proto": "HP", + "ip_chksum": "HP", + "ip_src": attacker_ip, + "ip_dst_randomized": "HP", + "ip_options": "HP", + "tcp_sport": str(attacker_port), + "tcp_dport": str(honeypot_port), + "tcp_seq": 0, + "tcp_ack": "HP", + "tcp_dataofs": "HP", + "tcp_reserved": "HP", + "tcp_flags": "HP", + "tcp_window": "HP", + "tcp_chksum": "HP", + "tcp_urgptr": "HP", + "tcp_options": "HP", + "ext_dst_ip_country": "HP", + "type": "HP", + "ASN": "HP", + "domain": "HP", + "city": "HP", + "as_type": "HP", + "ip_dst_is_private": "HP", + "external_is_private": "HP", + "open_ports": "", + "previously_open_ports": "", + "interface": "honeypot", + "internal_ip_randomized": "HP", + "external_ip_randomized": "HP", + "System_info": "OPNsense", + "Release_info": "HP", + "Version_info": "HP", + "Machine_info": "HP", + "Total_Memory": "HP", + "processor": "HP", + "architecture": "HP", + "honeypot_status": "True", + "payload": service, + "ls_version": "opnsense-1.0" + } + + if self.upload_pipe: + self.upload_pipe.send(data) + + except Exception as e: + print(f"honeypot: Error sending connection data: {e}", flush=True) + + +def run_honeypot(config, upload_pipe, database): + """ + Main entry point for honeypot process. + + Args: + config: Configuration dict + upload_pipe: Pipe for sending connection data + database: Database identifier + """ + listener = HoneypotListener(config, upload_pipe, database) + ports = config.get('honeypot_ports', '') + listener.start(ports) + + +if __name__ == "__main__": + # Test mode + print("honeypot: Running in test mode") + + test_config = { + 'honeypot_ports': '8080,2323', + 'honeypot_server': '128.9.28.79', + 'honeypot_ssh_port': 12345, + 'honeypot_telnet_port': 12346 + } + + listener = HoneypotListener(test_config, None, "test_database") + try: + listener.start(test_config['honeypot_ports']) + except KeyboardInterrupt: + listener.stop() diff --git a/security/lightscope/work/lightscope-1.0/lightscope.conf.sample b/security/lightscope/work/lightscope-1.0/lightscope.conf.sample new file mode 100644 index 00000000000..fd02a09a20c --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/lightscope.conf.sample @@ -0,0 +1,27 @@ +[Settings] +# LightScope Configuration for OPNsense +# +# Database identifier - auto-generated if empty +# This is used to identify your installation on thelightscope.com +database = + +# IP randomization key - auto-generated if empty +# Used to anonymize destination IPs in uploaded data +randomization_key = + +# Comma-separated honeypot ports +# These ports will be opened and forwarded to the remote honeypot server +# Leave empty to disable honeypot functionality +# Default: 8080,2323,8443,3389,5900 +honeypot_ports = 8080,2323,8443,3389,5900 + +# Remote honeypot server +# The server that receives forwarded honeypot connections +honeypot_server = 128.9.28.79 + +# Remote honeypot ports +# SSH connections (even local ports) forward to this port +honeypot_ssh_port = 12345 + +# TELNET connections (odd local ports) forward to this port +honeypot_telnet_port = 12346 diff --git a/security/lightscope/work/lightscope-1.0/lightscope_daemon.py b/security/lightscope/work/lightscope-1.0/lightscope_daemon.py new file mode 100755 index 00000000000..6f783445a0f --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/lightscope_daemon.py @@ -0,0 +1,490 @@ +#!/usr/local/bin/python3.11 +""" +lightscope_daemon.py - Main LightScope daemon for OPNsense + +This is the main entry point that: +1. Reads configuration from OPNsense model or config file +2. Spawns the pflog reader process +3. Spawns the honeypot listener process +4. Spawns the uploader process +5. Processes captured packets and sends to uploader +""" + +import os +import sys +import signal +import time +import hashlib +import ipaddress +import random +import string +import configparser +import multiprocessing +import threading +from collections import deque + +# Add script directory to path for imports +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +from pflog_reader import read_pflog +from honeypot import run_honeypot +from uploader import send_data, send_honeypot_data + +try: + import requests +except ImportError: + print("Error: requests not found", file=sys.stderr) + sys.exit(1) + +try: + import psutil +except ImportError: + psutil = None + +# Version +LS_VERSION = "opnsense-1.0" + +# Configuration paths +CONFIG_FILE = "/usr/local/etc/lightscope.conf" +CONFIG_FILE_SAMPLE = "/usr/local/etc/lightscope.conf.sample" + + +class LightScopeConfig: + """Configuration manager for LightScope.""" + + def __init__(self, config_file=CONFIG_FILE): + self.config_file = config_file + self.database = "" + self.randomization_key = "" + self.honeypot_ports = "8080,2323,8443,3389,5900" + self.honeypot_server = "128.9.28.79" + self.honeypot_ssh_port = 12345 + self.honeypot_telnet_port = 12346 + + self._load_or_create_config() + + def _generate_database_name(self): + """Generate a unique database name with OPNsense prefix.""" + import datetime + today = datetime.date.today().strftime("%Y%m%d") + rand_part = ''.join(random.choices(string.ascii_lowercase, k=44)) + return f"opn{today}_{rand_part}" + + def _generate_randomization_key(self): + """Generate a randomization key.""" + rand_part = ''.join(random.choices(string.ascii_lowercase, k=46)) + return f"randomization_key_{rand_part}" + + def _load_or_create_config(self): + """Load config from file or create with defaults.""" + config = configparser.ConfigParser() + + # Try to load existing config + if os.path.exists(self.config_file): + config.read(self.config_file) + else: + # Copy from sample if available + if os.path.exists(CONFIG_FILE_SAMPLE): + import shutil + shutil.copy(CONFIG_FILE_SAMPLE, self.config_file) + config.read(self.config_file) + + # Ensure Settings section exists + if 'Settings' not in config: + config.add_section('Settings') + + # Load or generate database name + self.database = config.get('Settings', 'database', fallback='').strip() + if not self.database: + self.database = self._generate_database_name() + config.set('Settings', 'database', self.database) + print(f"Generated new database name: {self.database}") + + # Load or generate randomization key + self.randomization_key = config.get('Settings', 'randomization_key', fallback='').strip() + if not self.randomization_key: + self.randomization_key = self._generate_randomization_key() + config.set('Settings', 'randomization_key', self.randomization_key) + + # Load honeypot settings + self.honeypot_ports = config.get('Settings', 'honeypot_ports', fallback=self.honeypot_ports) + self.honeypot_server = config.get('Settings', 'honeypot_server', fallback=self.honeypot_server) + self.honeypot_ssh_port = config.getint('Settings', 'honeypot_ssh_port', fallback=self.honeypot_ssh_port) + self.honeypot_telnet_port = config.getint('Settings', 'honeypot_telnet_port', fallback=self.honeypot_telnet_port) + + print(f"Loaded honeypot_ports from config: '{self.honeypot_ports}'") + + # Save config + try: + with open(self.config_file, 'w') as f: + config.write(f) + except Exception as e: + print(f"Warning: Could not save config: {e}") + + print(f"Database: {self.database}") + print(f"View reports at: https://thelightscope.com/light_table/{self.database}") + + def get_dict(self): + """Return config as dictionary.""" + return { + 'database': self.database, + 'randomization_key': self.randomization_key, + 'honeypot_ports': self.honeypot_ports, + 'honeypot_server': self.honeypot_server, + 'honeypot_ssh_port': self.honeypot_ssh_port, + 'honeypot_telnet_port': self.honeypot_telnet_port + } + + +def fetch_external_info(): + """Fetch external network information from thelightscope.com.""" + try: + resp = requests.get("https://thelightscope.com/ipinfo", timeout=10) + resp.raise_for_status() + data = resp.json() + + queried_ip = data.get("queried_ip") + asn_rec = data["results"].get("asn", {}).get("record", {}) + loc_rec = data["results"].get("location", {}).get("record", {}) + company_rec = data["results"].get("company", {}).get("record", {}) + + return { + "queried_ip": queried_ip, + "ASN": asn_rec.get("asn"), + "domain": asn_rec.get("domain"), + "city": loc_rec.get("city"), + "country": loc_rec.get("country"), + "as_type": company_rec.get("as_type"), + "type": asn_rec.get("type") + } + except Exception as e: + print(f"Warning: Could not fetch external info: {e}") + return { + "queried_ip": "0.0.0.0", + "ASN": "unknown", + "domain": "unknown", + "city": "unknown", + "country": "unknown", + "as_type": "unknown", + "type": "unknown" + } + + +def get_system_info(): + """Get system information.""" + import platform + info = { + "System": platform.system(), + "Release": platform.release(), + "Version": platform.version(), + "Machine": platform.machine(), + "processor": platform.processor(), + "architecture": platform.architecture()[0] + } + + if psutil: + info["Total_Memory"] = f"{psutil.virtual_memory().total / (1024 ** 3):.2f} GB" + else: + info["Total_Memory"] = "unknown" + + return info + + +def randomize_ip(ip_str, key): + """Randomize an IP address for privacy.""" + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return "unknown" + + def hash_segment(segment, k): + combined = f"{segment}-{k}" + h = hashlib.sha256(combined.encode()).hexdigest() + return int(h[:2], 16) % 256 + + orig_bytes = ip.packed + new_bytes = bytes(hash_segment(b, key) for b in orig_bytes) + + try: + rand_ip = ipaddress.IPv4Address(new_bytes) if ip.version == 4 else ipaddress.IPv6Address(new_bytes) + return str(rand_ip) + except: + return "unknown" + + +def check_ip_is_private(ip_str): + """Check if IP is private.""" + try: + ip_obj = ipaddress.ip_address(ip_str) + return "True" if ip_obj.is_private else "False" + except ValueError: + return "unknown" + + +class PacketProcessor: + """Processes packets from pflog and prepares them for upload.""" + + def __init__(self, config, external_info, system_info, upload_pipe): + self.config = config + self.external_info = external_info + self.system_info = system_info + self.upload_pipe = upload_pipe + self.packet_count = 0 + self.HEARTBEAT_INTERVAL = 15 * 60 # 15 minutes + + def process_batch(self, batch): + """Process a batch of packets from pflog.""" + for pkt in batch: + self.packet_count += 1 + self._prepare_and_send(pkt) + + def _prepare_and_send(self, pkt): + """Prepare packet data and send to uploader.""" + payload = { + "db_name": self.config['database'], + "system_time": str(pkt.packet_time), + "ip_version": pkt.ip_version, + "ip_ihl": pkt.ip_ihl, + "ip_tos": pkt.ip_tos, + "ip_len": pkt.ip_len, + "ip_id": pkt.ip_id, + "ip_flags": pkt.ip_flags if isinstance(pkt.ip_flags, str) else ",".join(str(v) for v in pkt.ip_flags), + "ip_frag": pkt.ip_frag, + "ip_ttl": pkt.ip_ttl, + "ip_proto": pkt.ip_proto, + "ip_chksum": pkt.ip_chksum, + "ip_src": pkt.ip_src, + "ip_dst_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "ip_options": pkt.ip_options if isinstance(pkt.ip_options, str) else ",".join(str(v) for v in pkt.ip_options), + "tcp_sport": pkt.tcp_sport, + "tcp_dport": pkt.tcp_dport, + "tcp_seq": pkt.tcp_seq, + "tcp_ack": pkt.tcp_ack, + "tcp_dataofs": pkt.tcp_dataofs, + "tcp_reserved": pkt.tcp_reserved, + "tcp_flags": pkt.tcp_flags, + "tcp_window": pkt.tcp_window, + "tcp_chksum": pkt.tcp_chksum, + "tcp_urgptr": pkt.tcp_urgptr, + "tcp_options": "", + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "ip_dst_is_private": check_ip_is_private(pkt.ip_dst), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": "", + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "honeypot_status": "False", + "payload": "N/A", + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(payload) + except Exception as e: + print(f"Error sending packet data: {e}", flush=True) + + def _send_heartbeat(self): + """Send heartbeat message.""" + heartbeat = { + "db_name": "heartbeats", + "unwanted_db": self.config['database'], + "pkts_last_hb": self.packet_count, + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": self.config['honeypot_ports'], + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": "", + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "open_honeypot_ports": self.config['honeypot_ports'], + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(heartbeat) + self.packet_count = 0 + print("Sent heartbeat", flush=True) + except Exception as e: + print(f"Error sending heartbeat: {e}", flush=True) + + +def packet_handler(pflog_pipe, upload_pipe, config, external_info, system_info): + """ + Process packets from pflog reader and send to uploader. + + This runs in its own process. + """ + processor = PacketProcessor(config, external_info, system_info, upload_pipe) + + print("packet_handler: Started", flush=True) + + # Send initial heartbeat at startup + processor._send_heartbeat() + + # Start a background thread for periodic heartbeats + def heartbeat_loop(): + while True: + time.sleep(processor.HEARTBEAT_INTERVAL) + processor._send_heartbeat() + + hb_thread = threading.Thread(target=heartbeat_loop, daemon=True) + hb_thread.start() + + while True: + try: + batch = pflog_pipe.recv() + processor.process_batch(batch) + except (EOFError, OSError): + print("packet_handler: Pipe closed, exiting", flush=True) + break + except Exception as e: + print(f"packet_handler: Error: {e}", flush=True) + time.sleep(1) + + +def main(): + """Main entry point for LightScope daemon.""" + print(f"LightScope for OPNsense v{LS_VERSION}", flush=True) + print("Starting...", flush=True) + + # Write PID file + pid = os.getpid() + try: + with open("/var/run/lightscope.pid", "w") as f: + f.write(str(pid)) + except Exception as e: + print(f"Warning: Could not write PID file: {e}") + + # Load configuration + config = LightScopeConfig() + config_dict = config.get_dict() + + # Fetch external network info + print("Fetching external network information...", flush=True) + external_info = fetch_external_info() + print(f"External IP: {external_info.get('queried_ip', 'unknown')}", flush=True) + + # Get system info + system_info = get_system_info() + + # Create pipes + pflog_consumer, pflog_producer = multiprocessing.Pipe(duplex=False) + upload_consumer, upload_producer = multiprocessing.Pipe(duplex=False) + hp_upload_consumer, hp_upload_producer = multiprocessing.Pipe(duplex=False) + + # Start processes + processes = [] + + # 1. pflog reader + p_pflog = multiprocessing.Process( + target=read_pflog, + args=(pflog_producer,), + name="pflog_reader" + ) + p_pflog.start() + processes.append(p_pflog) + print("Started pflog reader", flush=True) + + # 2. Packet handler + p_handler = multiprocessing.Process( + target=packet_handler, + args=(pflog_consumer, upload_producer, config_dict, external_info, system_info), + name="packet_handler" + ) + p_handler.start() + processes.append(p_handler) + print("Started packet handler", flush=True) + + # 3. Data uploader + p_uploader = multiprocessing.Process( + target=send_data, + args=(upload_consumer,), + name="uploader" + ) + p_uploader.start() + processes.append(p_uploader) + print("Started uploader", flush=True) + + # 4. Honeypot uploader + p_hp_uploader = multiprocessing.Process( + target=send_honeypot_data, + args=(hp_upload_consumer,), + name="honeypot_uploader" + ) + p_hp_uploader.start() + processes.append(p_hp_uploader) + print("Started honeypot uploader", flush=True) + + # 5. Honeypot listener + if config_dict.get('honeypot_ports'): + p_honeypot = multiprocessing.Process( + target=run_honeypot, + args=(config_dict, hp_upload_producer, config_dict['database']), + name="honeypot" + ) + p_honeypot.start() + processes.append(p_honeypot) + print("Started honeypot listener", flush=True) + else: + print("Honeypot disabled (no ports configured)", flush=True) + + # Signal handler for clean shutdown + def shutdown(signum, frame): + print("\nShutting down...", flush=True) + for p in processes: + if p.is_alive(): + p.terminate() + for p in processes: + p.join(timeout=5) + try: + os.remove("/var/run/lightscope.pid") + except: + pass + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + print("LightScope is running. Press Ctrl+C to stop.", flush=True) + + # Monitor processes + while True: + time.sleep(60) + + for p in processes: + if not p.is_alive(): + print(f"Process {p.name} died, restarting...", flush=True) + # For now, just exit and let the service manager restart us + shutdown(None, None) + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/security/lightscope/work/lightscope-1.0/pflog_reader.py b/security/lightscope/work/lightscope-1.0/pflog_reader.py new file mode 100755 index 00000000000..85735fd7976 --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/pflog_reader.py @@ -0,0 +1,320 @@ +#!/usr/local/bin/python3.11 +""" +pflog_reader.py - Captures blocked TCP SYN packets from pflog0 interface + +This module reads packets from the pflog0 interface (pf firewall log) +and extracts TCP SYN packets that were blocked by the firewall. +""" + +import struct +import socket +import datetime +import time +import sys +from collections import namedtuple, deque +import threading + +# Try to import pcap library (python-libpcap on FreeBSD) +try: + import pcap +except ImportError: + print("Error: pypcap not found. Install with: pkg install py311-pypcap", file=sys.stderr) + sys.exit(1) + +try: + import dpkt +except ImportError: + print("Error: dpkt not found. Install with: pkg install py311-dpkt", file=sys.stderr) + sys.exit(1) + + +# pflog header structure (FreeBSD) +# See /usr/include/net/if_pflog.h +# Structure size: 1+1+1+1+16+16+4+4+4+4+4+4+1+3+4+1+3 = 72 bytes +PFLOG_HDRLEN = 72 # FreeBSD pflog header length + +# pflog action values (from /usr/include/netpfil/pf/pf.h) +PF_PASS = 0 +PF_DROP = 1 + +# PacketInfo namedtuple - compatible with lightscope_core.py +PacketInfo = namedtuple("PacketInfo", [ + "packet_num", "proto", "packet_time", + "ip_version", "ip_ihl", "ip_tos", "ip_len", "ip_id", "ip_flags", "ip_frag", + "ip_ttl", "ip_proto", "ip_chksum", "ip_src", "ip_dst", "ip_options", + "tcp_sport", "tcp_dport", "tcp_seq", "tcp_ack", "tcp_dataofs", + "tcp_reserved", "tcp_flags", "tcp_window", "tcp_chksum", "tcp_urgptr", "tcp_options" +]) + + +def tcp_flags_to_str(flags_value): + """Convert dpkt TCP flags value to a comma-separated string.""" + flag_names = [] + if flags_value & dpkt.tcp.TH_FIN: + flag_names.append("FIN") + if flags_value & dpkt.tcp.TH_SYN: + flag_names.append("SYN") + if flags_value & dpkt.tcp.TH_RST: + flag_names.append("RST") + if flags_value & dpkt.tcp.TH_PUSH: + flag_names.append("PSH") + if flags_value & dpkt.tcp.TH_ACK: + flag_names.append("ACK") + if flags_value & dpkt.tcp.TH_URG: + flag_names.append("URG") + return ",".join(flag_names) if flag_names else "" + + +def parse_pflog_packet(buf, packet_num): + """ + Parse a pflog packet and extract TCP SYN information. + + pflog packets have a pflog header followed by the IP packet. + We only care about blocked TCP SYN packets. + + Returns PacketInfo namedtuple or None if not a blocked TCP SYN. + """ + if len(buf) < PFLOG_HDRLEN: + return None + + # Extract action from pflog header (byte offset 2) + # Only process blocked packets (PF_DROP) + action = buf[2] + if action != PF_DROP: + return None + + # Skip pflog header to get to IP packet + ip_data = buf[PFLOG_HDRLEN:] + + if len(ip_data) < 20: # Minimum IP header length + return None + + # Check IP version + ip_version = (ip_data[0] >> 4) & 0xF + + if ip_version == 4: + try: + ip = dpkt.ip.IP(ip_data) + except Exception: + return None + + # Only process TCP + if ip.p != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip.data) + except Exception: + return None + + # Only capture SYN packets (SYN flag set, ACK flag not set) + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + # Extract IP flags + flags = [] + if ip.df: + flags.append("DF") + if ip.mf: + flags.append("MF") + ip_flags_str = ",".join(flags) if flags else "" + + ip_opts = ip.opts.hex() if ip.opts else "" + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=ip.v, + ip_ihl=ip.hl, + ip_tos=ip.tos, + ip_len=ip.len, + ip_id=ip.id, + ip_flags=ip_flags_str, + ip_frag=ip.offset >> 3, + ip_ttl=ip.ttl, + ip_proto=ip.p, + ip_chksum=ip.sum, + ip_src=socket.inet_ntoa(ip.src), + ip_dst=socket.inet_ntoa(ip.dst), + ip_options=ip_opts, + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + elif ip_version == 6: + try: + ip6 = dpkt.ip6.IP6(ip_data) + except Exception: + return None + + # Only process TCP + if ip6.nxt != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip6.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip6.data) + except Exception: + return None + + # Only capture SYN packets + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=6, + ip_ihl=None, + ip_tos=None, + ip_len=ip6.plen, + ip_id=None, + ip_flags="", + ip_frag=0, + ip_ttl=ip6.hlim, + ip_proto=ip6.nxt, + ip_chksum=None, + ip_src=socket.inet_ntop(socket.AF_INET6, ip6.src), + ip_dst=socket.inet_ntop(socket.AF_INET6, ip6.dst), + ip_options="", + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + return None + + +def read_pflog(output_pipe, interface="pflog0"): + """ + Main pflog reader function. + + Captures packets from pflog0 interface, parses TCP SYNs, + and sends them to output_pipe in batches. + + Args: + output_pipe: multiprocessing.Pipe connection for sending packet batches + interface: Network interface to capture from (default: pflog0) + """ + BATCH_SIZE = 100 + IDLE_FLUSH_SECS = 1.0 + MAX_QUEUE_SIZE = 10000 + + send_deque = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + + def sender_thread(): + """Thread to batch and send packets.""" + nonlocal last_activity + prior_time = time.monotonic() + packets_sent = 0 + + while True: + now = time.monotonic() + to_send = 0 + + # Log stats every second + if (now - prior_time) >= 1.0: + if packets_sent > 0: + print(f"pflog_reader: sent {packets_sent} packets, queue={len(send_deque)}", flush=True) + packets_sent = 0 + prior_time = now + + # Check if we should send a batch + if len(send_deque) >= BATCH_SIZE: + to_send = BATCH_SIZE + elif send_deque and (now - last_activity) >= IDLE_FLUSH_SECS: + to_send = len(send_deque) + else: + time.sleep(0.01) + continue + + # Build and send batch + batch = [send_deque.popleft() for _ in range(to_send)] + try: + output_pipe.send(batch) + packets_sent += len(batch) + except Exception as e: + print(f"pflog_reader: pipe send error: {e}", file=sys.stderr) + return + + # Start sender thread + threading.Thread(target=sender_thread, daemon=True).start() + + # Open pflog0 for capture + try: + # DLT_PFLOG = 117 on FreeBSD + sniffer = pcap.pcap( + name=interface, + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=100 + ) + # Filter for TCP only + sniffer.setfilter("tcp") + except Exception as e: + print(f"pflog_reader: Failed to open {interface}: {e}", file=sys.stderr) + sys.exit(1) + + print(f"pflog_reader: Capturing on {interface}", flush=True) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + + try: + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + send_deque.append(pkt_info) + last_activity = time.monotonic() + except Exception as e: + # Skip malformed packets + continue + + +if __name__ == "__main__": + # Test mode - print captured packets + print("pflog_reader: Running in test mode (printing to stdout)") + + try: + sniffer = pcap.pcap( + name="pflog0", + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=1000 + ) + sniffer.setfilter("tcp") + except Exception as e: + print(f"Failed to open pflog0: {e}") + sys.exit(1) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + print(f"SYN: {pkt_info.ip_src}:{pkt_info.tcp_sport} -> {pkt_info.ip_dst}:{pkt_info.tcp_dport}") diff --git a/security/lightscope/work/lightscope-1.0/uploader.py b/security/lightscope/work/lightscope-1.0/uploader.py new file mode 100755 index 00000000000..957b3cae386 --- /dev/null +++ b/security/lightscope/work/lightscope-1.0/uploader.py @@ -0,0 +1,234 @@ +#!/usr/local/bin/python3.11 +""" +uploader.py - Data upload module for LightScope + +Handles batching and uploading packet data and heartbeats to thelightscope.com +""" + +import time +import threading +import sys +from collections import deque + +try: + import requests + from requests.adapters import HTTPAdapter +except ImportError: + print("Error: requests not found. Install with: pkg install py311-requests", file=sys.stderr) + sys.exit(1) + + +# API Configuration +DATA_URL = "https://thelightscope.com/log_mysql_data" +HEARTBEAT_URL = "https://thelightscope.com/heartbeat" +HEADERS = { + "Content-Type": "application/json", + "X-API-Key": "lightscopeAPIkey2025_please_dont_distribute_me_but_im_write_only_anyways" +} + +# Upload settings +BATCH_SIZE = 600 +IDLE_FLUSH_SEC = 5.0 +RETRY_BACKOFF = 5 +MAX_QUEUE_SIZE = 100000 + + +def send_data(consumer_pipe): + """ + Main data upload function. + + Receives data from consumer_pipe, batches it, and uploads to thelightscope.com. + Handles heartbeat messages separately. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=4, pool_maxsize=4) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + def reader(): + """Thread to read from pipe and add to queue.""" + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"uploader: Error receiving data: {e}", flush=True) + + # Drain any remaining data + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + # Start reader thread + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("uploader: Started data upload service", flush=True) + + try: + while not stop_event.is_set() or queue: + # 1) Process heartbeats first + hb_count = 0 + n = len(queue) + for _ in range(n): + try: + item = queue.popleft() + except IndexError: + break + + if item.get("db_name") == "heartbeats": + hb_count += 1 + try: + resp = session.post( + HEARTBEAT_URL, + json=item, + headers=HEADERS, + timeout=10 + ) + if resp.status_code != 200: + print(f"uploader: Heartbeat rejected ({resp.status_code})", flush=True) + except requests.RequestException as e: + print(f"uploader: Heartbeat error: {e}", flush=True) + else: + # Put non-heartbeat items back + queue.append(item) + + if hb_count: + print(f"uploader: Sent {hb_count} heartbeat(s)", flush=True) + + # 2) Batch and send regular data + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"uploader: Sent {to_send} items", flush=True) + else: + print(f"uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"uploader: Upload error, will retry: {e}", flush=True) + # Put items back at front of queue + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + print("uploader: Shutting down...", flush=True) + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("uploader: Stopped", flush=True) + + +def send_honeypot_data(consumer_pipe): + """ + Honeypot-specific data upload function. + + Similar to send_data but with smaller batch sizes for honeypot data. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive honeypot data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=2, pool_maxsize=2) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + HONEYPOT_BATCH_SIZE = 100 + + def reader(): + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"honeypot_uploader: Error receiving data: {e}", flush=True) + + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("honeypot_uploader: Started", flush=True) + + try: + while not stop_event.is_set() or queue: + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= HONEYPOT_BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), HONEYPOT_BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"honeypot_uploader: Sent {to_send} items", flush=True) + else: + print(f"honeypot_uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"honeypot_uploader: Error, will retry: {e}", flush=True) + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + pass + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("honeypot_uploader: Stopped", flush=True) + + +if __name__ == "__main__": + print("uploader: This module should be run as part of lightscope_daemon.py") diff --git a/security/lightscope/work/stage/usr/local/bin/lightscope b/security/lightscope/work/stage/usr/local/bin/lightscope new file mode 100755 index 00000000000..3e795038639 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/bin/lightscope @@ -0,0 +1,6 @@ +#!/bin/sh +# +# LightScope entry point script +# Forwards all commands to the Python daemon +# +exec /usr/local/bin/python3.11 /usr/local/share/lightscope/lightscope_daemon.py "$@" diff --git a/security/lightscope/work/stage/usr/local/etc/lightscope.conf.sample b/security/lightscope/work/stage/usr/local/etc/lightscope.conf.sample new file mode 100644 index 00000000000..fd02a09a20c --- /dev/null +++ b/security/lightscope/work/stage/usr/local/etc/lightscope.conf.sample @@ -0,0 +1,27 @@ +[Settings] +# LightScope Configuration for OPNsense +# +# Database identifier - auto-generated if empty +# This is used to identify your installation on thelightscope.com +database = + +# IP randomization key - auto-generated if empty +# Used to anonymize destination IPs in uploaded data +randomization_key = + +# Comma-separated honeypot ports +# These ports will be opened and forwarded to the remote honeypot server +# Leave empty to disable honeypot functionality +# Default: 8080,2323,8443,3389,5900 +honeypot_ports = 8080,2323,8443,3389,5900 + +# Remote honeypot server +# The server that receives forwarded honeypot connections +honeypot_server = 128.9.28.79 + +# Remote honeypot ports +# SSH connections (even local ports) forward to this port +honeypot_ssh_port = 12345 + +# TELNET connections (odd local ports) forward to this port +honeypot_telnet_port = 12346 diff --git a/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE new file mode 100644 index 00000000000..d3ec7dc876b --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/BSD2CLAUSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2025, USC Information Sciences Institute +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/LICENSE b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/LICENSE new file mode 100644 index 00000000000..d6ed4a82b84 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/LICENSE @@ -0,0 +1 @@ +This package has a single license: BSD2CLAUSE (BSD 2-clause Simplified License). diff --git a/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/catalog.mk b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/catalog.mk new file mode 100644 index 00000000000..f390b196978 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/licenses/lightscope-1.0/catalog.mk @@ -0,0 +1,5 @@ +_LICENSE=BSD2CLAUSE +_LICENSE_NAME=BSD 2-clause Simplified License +_LICENSE_PERMS=dist-mirror dist-sell pkg-mirror pkg-sell auto-accept +_LICENSE_GROUPS=FSF OSI COPYFREE +_LICENSE_DISTFILES=lightscope-1.0.tar.gz diff --git a/security/lightscope/work/stage/usr/local/share/lightscope/__init__.py b/security/lightscope/work/stage/usr/local/share/lightscope/__init__.py new file mode 100755 index 00000000000..3ecfb9d2dea --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/lightscope/__init__.py @@ -0,0 +1 @@ +# LightScope for OPNsense diff --git a/security/lightscope/work/stage/usr/local/share/lightscope/honeypot.py b/security/lightscope/work/stage/usr/local/share/lightscope/honeypot.py new file mode 100755 index 00000000000..2282de53486 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/lightscope/honeypot.py @@ -0,0 +1,504 @@ +#!/usr/local/bin/python3.11 +""" +honeypot.py - Honeypot listener with PROXY protocol forwarding + +This module opens sockets on configured ports and forwards connections +to a remote honeypot server using the PROXY protocol to preserve +the original attacker IP address. +""" + +import socket +import select +import threading +import time +import sys +import subprocess +import re + + +class HoneypotListener: + """ + Manages honeypot listener sockets and forwards connections + to remote honeypot server using PROXY protocol. + """ + + def __init__(self, config, upload_pipe, database): + """ + Initialize honeypot listener. + + Args: + config: Dict with honeypot configuration + upload_pipe: Pipe for sending honeypot connection data + database: Database identifier for PROXY header + """ + self.config = config + self.upload_pipe = upload_pipe + self.database = database + + self.honeypot_server = config.get('honeypot_server', '128.9.28.79') + self.ssh_port = int(config.get('honeypot_ssh_port', 12345)) + self.telnet_port = int(config.get('honeypot_telnet_port', 12346)) + + self.sockets = {} # socket -> port mapping + self.sockets_lock = threading.Lock() + self.running = False + + # Connection limiting + self.MAX_CONCURRENT_CONNECTIONS = 50 + self.connection_semaphore = threading.Semaphore(self.MAX_CONCURRENT_CONNECTIONS) + self.active_connections = 0 + self.connection_lock = threading.Lock() + + # Firewall monitoring + self.FIREWALL_CHECK_INTERVAL = 10 # seconds + + def parse_ports(self, ports_string): + """Parse comma-separated port string into list of integers.""" + if not ports_string: + return [] + + ports = [] + for p in ports_string.split(','): + p = p.strip() + if p.isdigit(): + port = int(p) + if 1 <= port <= 65535: + ports.append(port) + return ports + + def get_firewall_allowed_ports(self): + """ + Get list of ports that have PASS/ALLOW rules in the firewall. + These are ports where legitimate services may be running. + Returns a tuple of (set of ports, list of (start, end) range tuples). + """ + allowed_ports = set() + allowed_ranges = [] + + try: + result = subprocess.run( + ["pfctl", "-sr"], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + for line in result.stdout.split('\n'): + # Look for pass rules with port specifications + if line.startswith('pass') and 'proto tcp' in line: + # Match port ranges like "port 1:4343" or "port 80:443" + range_match = re.search(r'port\s*[=]?\s*(\d+):(\d+)', line) + if range_match: + start_port = int(range_match.group(1)) + end_port = int(range_match.group(2)) + allowed_ranges.append((start_port, end_port)) + continue # Don't also match as single port + + # Match single port patterns like "port = 22" or "port 22" + port_match = re.search(r'port\s*[=]?\s*(\d+)(?![\d:])', line) + if port_match: + allowed_ports.add(int(port_match.group(1))) + + # Match port lists in braces + brace_match = re.search(r'port\s*[=]?\s*\{([^}]+)\}', line) + if brace_match: + ports_str = brace_match.group(1) + for p in re.findall(r'\d+', ports_str): + allowed_ports.add(int(p)) + except Exception as e: + print(f"honeypot: Error checking firewall rules: {e}", flush=True) + + return allowed_ports, allowed_ranges + + def is_port_allowed_by_firewall(self, port, allowed_ports, allowed_ranges): + """Check if a port is allowed by firewall rules (including ranges).""" + if port in allowed_ports: + return True + for start, end in allowed_ranges: + if start <= port <= end: + return True + return False + + def is_port_in_use(self, port): + """ + Check if a port is already in use by another service. + Returns True if port is in use, False if available. + """ + try: + test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + test_sock.bind(("", port)) + test_sock.close() + return False # Port is available + except OSError: + return True # Port is in use + + def _firewall_monitor_thread(self): + """ + Monitor firewall rules and port usage every FIREWALL_CHECK_INTERVAL seconds. + If a new ALLOW rule is detected or port becomes in use, close that honeypot port. + """ + print("honeypot: Started firewall/port monitor (checking every 10s)", flush=True) + + while self.running: + time.sleep(self.FIREWALL_CHECK_INTERVAL) + if not self.running: + break + + try: + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + + with self.sockets_lock: + sockets_to_close = [] + for sock, port in list(self.sockets.items()): + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Firewall ALLOW rule detected for port {port}, closing honeypot on this port", flush=True) + sockets_to_close.append((sock, port, "firewall conflict")) + + for sock, port, reason in sockets_to_close: + try: + sock.close() + except: + pass + del self.sockets[sock] + print(f"honeypot: Closed listener on port {port} due to {reason}", flush=True) + + except Exception as e: + print(f"honeypot: Error in firewall monitor: {e}", flush=True) + + def open_port(self, port): + """ + Try to bind and listen on a port. + + Returns: + (socket, port) on success, (None, None) on failure + """ + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", port)) + s.listen(10) + s.setblocking(False) + return s, port + except OSError as e: + if e.errno == 48: # EADDRINUSE on FreeBSD + print(f"honeypot: Port {port} already in use", flush=True) + elif e.errno == 13: # EACCES + print(f"honeypot: Permission denied for port {port}", flush=True) + else: + print(f"honeypot: Error binding port {port}: {e}", flush=True) + return None, None + except Exception as e: + print(f"honeypot: Unexpected error binding port {port}: {e}", flush=True) + return None, None + + def start(self, ports_string): + """ + Start honeypot listeners on configured ports. + + Args: + ports_string: Comma-separated list of ports + """ + ports = self.parse_ports(ports_string) + if not ports: + print("honeypot: No valid ports configured", flush=True) + return + + # Check for firewall conflicts and ports already in use + allowed_ports, allowed_ranges = self.get_firewall_allowed_ports() + safe_ports = [] + for port in ports: + if self.is_port_allowed_by_firewall(port, allowed_ports, allowed_ranges): + print(f"honeypot: WARNING - Port {port} has a firewall ALLOW rule, skipping (may conflict with legitimate service)", flush=True) + elif self.is_port_in_use(port): + print(f"honeypot: WARNING - Port {port} is already in use by another service, skipping", flush=True) + else: + safe_ports.append(port) + + if not safe_ports: + print("honeypot: No ports available (all have conflicts or are in use)", flush=True) + return + + print(f"honeypot: Opening ports: {safe_ports}", flush=True) + + for port in safe_ports: + s, actual_port = self.open_port(port) + if s and actual_port: + with self.sockets_lock: + self.sockets[s] = actual_port + print(f"honeypot: Listening on port {actual_port}", flush=True) + + if not self.sockets: + print("honeypot: Failed to open any ports", flush=True) + return + + self.running = True + + # Start firewall monitor thread + monitor_thread = threading.Thread(target=self._firewall_monitor_thread, daemon=True) + monitor_thread.start() + + self._run_accept_loop() + + def stop(self): + """Stop all honeypot listeners.""" + self.running = False + with self.sockets_lock: + for s in list(self.sockets.keys()): + try: + s.close() + except: + pass + self.sockets.clear() + print("honeypot: Stopped all listeners", flush=True) + + def get_open_ports(self): + """Return list of currently open honeypot ports.""" + with self.sockets_lock: + return list(self.sockets.values()) + + def _run_accept_loop(self): + """Main accept loop for honeypot connections.""" + print("honeypot: Starting accept loop", flush=True) + + while self.running: + with self.sockets_lock: + sock_list = list(self.sockets.keys()) + + if not sock_list: + time.sleep(1.0) + continue + + try: + # Wait for incoming connections + readable, _, _ = select.select(sock_list, [], [], 1.0) + + for sock in readable: + try: + self._handle_connection(sock) + except Exception as e: + print(f"honeypot: Error handling connection: {e}", flush=True) + + except Exception as e: + if self.running: + print(f"honeypot: Error in accept loop: {e}", flush=True) + time.sleep(1.0) + + def _handle_connection(self, listen_sock): + """Handle an incoming connection on a honeypot port.""" + try: + local_conn, addr = listen_sock.accept() + with self.sockets_lock: + port = self.sockets.get(listen_sock) + if port is None: + local_conn.close() + return + attacker_ip = addr[0] + attacker_port = addr[1] + + print(f"honeypot: Connection from {attacker_ip}:{attacker_port} to port {port}", flush=True) + + # Determine service type: even ports = SSH, odd ports = TELNET + if port % 2 == 0: + service = 'SSH' + remote_port = self.ssh_port + else: + service = 'TELNET' + remote_port = self.telnet_port + + # Check connection limit + if not self.connection_semaphore.acquire(blocking=False): + print(f"honeypot: Connection limit reached, rejecting {attacker_ip}", flush=True) + local_conn.close() + return + + with self.connection_lock: + self.active_connections += 1 + + # Start forwarding in a new thread + threading.Thread( + target=self._forward_connection, + args=(local_conn, attacker_ip, attacker_port, port, service, remote_port), + daemon=True + ).start() + + except Exception as e: + print(f"honeypot: Error accepting connection: {e}", flush=True) + + def _forward_connection(self, local_conn, attacker_ip, attacker_port, honeypot_port, service, remote_port): + """ + Forward a connection to the remote honeypot server using PROXY protocol. + """ + remote_conn = None + connection_released = False + + try: + # Connect to remote honeypot + remote_conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + remote_conn.settimeout(30.0) + + try: + remote_conn.connect((self.honeypot_server, remote_port)) + except Exception as e: + print(f"honeypot: Failed to connect to remote server: {e}", flush=True) + return + + # Send PROXY protocol header + # Format: PROXY TCP4 src_ip dest_ip src_port dest_port\r\n + proxy_header = f"PROXY TCP4 {attacker_ip} {self.database} {attacker_port} {honeypot_port}\r\n" + print(f"honeypot: Sending PROXY header: {proxy_header.strip()}", flush=True) + remote_conn.sendall(proxy_header.encode()) + + # Set timeouts for idle connections (3 minutes) + CONNECTION_TIMEOUT = 180 + local_conn.settimeout(CONNECTION_TIMEOUT) + remote_conn.settimeout(CONNECTION_TIMEOUT) + + # Send honeypot connection data for logging + self._send_connection_data(attacker_ip, attacker_port, honeypot_port, service) + + # Bidirectional forwarding + def forward(src, dst, direction): + try: + while True: + data = src.recv(4096) + if not data: + break + dst.sendall(data) + except Exception: + pass + finally: + try: + src.close() + except: + pass + try: + dst.close() + except: + pass + + # Start forwarding threads + t1 = threading.Thread(target=forward, args=(local_conn, remote_conn, "client->server")) + t2 = threading.Thread(target=forward, args=(remote_conn, local_conn, "server->client")) + t1.daemon = True + t2.daemon = True + t1.start() + t2.start() + + # Wait for both threads to complete + t1.join() + t2.join() + + print(f"honeypot: Connection from {attacker_ip} closed", flush=True) + + except Exception as e: + print(f"honeypot: Forwarding error: {e}", flush=True) + + finally: + # Clean up + for conn in (local_conn, remote_conn): + if conn: + try: + conn.close() + except: + pass + + # Release connection slot + if not connection_released: + self.connection_semaphore.release() + with self.connection_lock: + self.active_connections -= 1 + + def _send_connection_data(self, attacker_ip, attacker_port, honeypot_port, service): + """Send honeypot connection data to upload pipe.""" + try: + data = { + "db_name": self.database, + "system_time": str(time.time()), + "ip_version": "HP", + "ip_ihl": "HP", + "ip_tos": "HP", + "ip_len": "HP", + "ip_id": "HP", + "ip_flags": "HP", + "ip_frag": "HP", + "ip_ttl": "HP", + "ip_proto": "HP", + "ip_chksum": "HP", + "ip_src": attacker_ip, + "ip_dst_randomized": "HP", + "ip_options": "HP", + "tcp_sport": str(attacker_port), + "tcp_dport": str(honeypot_port), + "tcp_seq": 0, + "tcp_ack": "HP", + "tcp_dataofs": "HP", + "tcp_reserved": "HP", + "tcp_flags": "HP", + "tcp_window": "HP", + "tcp_chksum": "HP", + "tcp_urgptr": "HP", + "tcp_options": "HP", + "ext_dst_ip_country": "HP", + "type": "HP", + "ASN": "HP", + "domain": "HP", + "city": "HP", + "as_type": "HP", + "ip_dst_is_private": "HP", + "external_is_private": "HP", + "open_ports": "", + "previously_open_ports": "", + "interface": "honeypot", + "internal_ip_randomized": "HP", + "external_ip_randomized": "HP", + "System_info": "OPNsense", + "Release_info": "HP", + "Version_info": "HP", + "Machine_info": "HP", + "Total_Memory": "HP", + "processor": "HP", + "architecture": "HP", + "honeypot_status": "True", + "payload": service, + "ls_version": "opnsense-1.0" + } + + if self.upload_pipe: + self.upload_pipe.send(data) + + except Exception as e: + print(f"honeypot: Error sending connection data: {e}", flush=True) + + +def run_honeypot(config, upload_pipe, database): + """ + Main entry point for honeypot process. + + Args: + config: Configuration dict + upload_pipe: Pipe for sending connection data + database: Database identifier + """ + listener = HoneypotListener(config, upload_pipe, database) + ports = config.get('honeypot_ports', '') + listener.start(ports) + + +if __name__ == "__main__": + # Test mode + print("honeypot: Running in test mode") + + test_config = { + 'honeypot_ports': '8080,2323', + 'honeypot_server': '128.9.28.79', + 'honeypot_ssh_port': 12345, + 'honeypot_telnet_port': 12346 + } + + listener = HoneypotListener(test_config, None, "test_database") + try: + listener.start(test_config['honeypot_ports']) + except KeyboardInterrupt: + listener.stop() diff --git a/security/lightscope/work/stage/usr/local/share/lightscope/lightscope_daemon.py b/security/lightscope/work/stage/usr/local/share/lightscope/lightscope_daemon.py new file mode 100755 index 00000000000..6f783445a0f --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/lightscope/lightscope_daemon.py @@ -0,0 +1,490 @@ +#!/usr/local/bin/python3.11 +""" +lightscope_daemon.py - Main LightScope daemon for OPNsense + +This is the main entry point that: +1. Reads configuration from OPNsense model or config file +2. Spawns the pflog reader process +3. Spawns the honeypot listener process +4. Spawns the uploader process +5. Processes captured packets and sends to uploader +""" + +import os +import sys +import signal +import time +import hashlib +import ipaddress +import random +import string +import configparser +import multiprocessing +import threading +from collections import deque + +# Add script directory to path for imports +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +from pflog_reader import read_pflog +from honeypot import run_honeypot +from uploader import send_data, send_honeypot_data + +try: + import requests +except ImportError: + print("Error: requests not found", file=sys.stderr) + sys.exit(1) + +try: + import psutil +except ImportError: + psutil = None + +# Version +LS_VERSION = "opnsense-1.0" + +# Configuration paths +CONFIG_FILE = "/usr/local/etc/lightscope.conf" +CONFIG_FILE_SAMPLE = "/usr/local/etc/lightscope.conf.sample" + + +class LightScopeConfig: + """Configuration manager for LightScope.""" + + def __init__(self, config_file=CONFIG_FILE): + self.config_file = config_file + self.database = "" + self.randomization_key = "" + self.honeypot_ports = "8080,2323,8443,3389,5900" + self.honeypot_server = "128.9.28.79" + self.honeypot_ssh_port = 12345 + self.honeypot_telnet_port = 12346 + + self._load_or_create_config() + + def _generate_database_name(self): + """Generate a unique database name with OPNsense prefix.""" + import datetime + today = datetime.date.today().strftime("%Y%m%d") + rand_part = ''.join(random.choices(string.ascii_lowercase, k=44)) + return f"opn{today}_{rand_part}" + + def _generate_randomization_key(self): + """Generate a randomization key.""" + rand_part = ''.join(random.choices(string.ascii_lowercase, k=46)) + return f"randomization_key_{rand_part}" + + def _load_or_create_config(self): + """Load config from file or create with defaults.""" + config = configparser.ConfigParser() + + # Try to load existing config + if os.path.exists(self.config_file): + config.read(self.config_file) + else: + # Copy from sample if available + if os.path.exists(CONFIG_FILE_SAMPLE): + import shutil + shutil.copy(CONFIG_FILE_SAMPLE, self.config_file) + config.read(self.config_file) + + # Ensure Settings section exists + if 'Settings' not in config: + config.add_section('Settings') + + # Load or generate database name + self.database = config.get('Settings', 'database', fallback='').strip() + if not self.database: + self.database = self._generate_database_name() + config.set('Settings', 'database', self.database) + print(f"Generated new database name: {self.database}") + + # Load or generate randomization key + self.randomization_key = config.get('Settings', 'randomization_key', fallback='').strip() + if not self.randomization_key: + self.randomization_key = self._generate_randomization_key() + config.set('Settings', 'randomization_key', self.randomization_key) + + # Load honeypot settings + self.honeypot_ports = config.get('Settings', 'honeypot_ports', fallback=self.honeypot_ports) + self.honeypot_server = config.get('Settings', 'honeypot_server', fallback=self.honeypot_server) + self.honeypot_ssh_port = config.getint('Settings', 'honeypot_ssh_port', fallback=self.honeypot_ssh_port) + self.honeypot_telnet_port = config.getint('Settings', 'honeypot_telnet_port', fallback=self.honeypot_telnet_port) + + print(f"Loaded honeypot_ports from config: '{self.honeypot_ports}'") + + # Save config + try: + with open(self.config_file, 'w') as f: + config.write(f) + except Exception as e: + print(f"Warning: Could not save config: {e}") + + print(f"Database: {self.database}") + print(f"View reports at: https://thelightscope.com/light_table/{self.database}") + + def get_dict(self): + """Return config as dictionary.""" + return { + 'database': self.database, + 'randomization_key': self.randomization_key, + 'honeypot_ports': self.honeypot_ports, + 'honeypot_server': self.honeypot_server, + 'honeypot_ssh_port': self.honeypot_ssh_port, + 'honeypot_telnet_port': self.honeypot_telnet_port + } + + +def fetch_external_info(): + """Fetch external network information from thelightscope.com.""" + try: + resp = requests.get("https://thelightscope.com/ipinfo", timeout=10) + resp.raise_for_status() + data = resp.json() + + queried_ip = data.get("queried_ip") + asn_rec = data["results"].get("asn", {}).get("record", {}) + loc_rec = data["results"].get("location", {}).get("record", {}) + company_rec = data["results"].get("company", {}).get("record", {}) + + return { + "queried_ip": queried_ip, + "ASN": asn_rec.get("asn"), + "domain": asn_rec.get("domain"), + "city": loc_rec.get("city"), + "country": loc_rec.get("country"), + "as_type": company_rec.get("as_type"), + "type": asn_rec.get("type") + } + except Exception as e: + print(f"Warning: Could not fetch external info: {e}") + return { + "queried_ip": "0.0.0.0", + "ASN": "unknown", + "domain": "unknown", + "city": "unknown", + "country": "unknown", + "as_type": "unknown", + "type": "unknown" + } + + +def get_system_info(): + """Get system information.""" + import platform + info = { + "System": platform.system(), + "Release": platform.release(), + "Version": platform.version(), + "Machine": platform.machine(), + "processor": platform.processor(), + "architecture": platform.architecture()[0] + } + + if psutil: + info["Total_Memory"] = f"{psutil.virtual_memory().total / (1024 ** 3):.2f} GB" + else: + info["Total_Memory"] = "unknown" + + return info + + +def randomize_ip(ip_str, key): + """Randomize an IP address for privacy.""" + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return "unknown" + + def hash_segment(segment, k): + combined = f"{segment}-{k}" + h = hashlib.sha256(combined.encode()).hexdigest() + return int(h[:2], 16) % 256 + + orig_bytes = ip.packed + new_bytes = bytes(hash_segment(b, key) for b in orig_bytes) + + try: + rand_ip = ipaddress.IPv4Address(new_bytes) if ip.version == 4 else ipaddress.IPv6Address(new_bytes) + return str(rand_ip) + except: + return "unknown" + + +def check_ip_is_private(ip_str): + """Check if IP is private.""" + try: + ip_obj = ipaddress.ip_address(ip_str) + return "True" if ip_obj.is_private else "False" + except ValueError: + return "unknown" + + +class PacketProcessor: + """Processes packets from pflog and prepares them for upload.""" + + def __init__(self, config, external_info, system_info, upload_pipe): + self.config = config + self.external_info = external_info + self.system_info = system_info + self.upload_pipe = upload_pipe + self.packet_count = 0 + self.HEARTBEAT_INTERVAL = 15 * 60 # 15 minutes + + def process_batch(self, batch): + """Process a batch of packets from pflog.""" + for pkt in batch: + self.packet_count += 1 + self._prepare_and_send(pkt) + + def _prepare_and_send(self, pkt): + """Prepare packet data and send to uploader.""" + payload = { + "db_name": self.config['database'], + "system_time": str(pkt.packet_time), + "ip_version": pkt.ip_version, + "ip_ihl": pkt.ip_ihl, + "ip_tos": pkt.ip_tos, + "ip_len": pkt.ip_len, + "ip_id": pkt.ip_id, + "ip_flags": pkt.ip_flags if isinstance(pkt.ip_flags, str) else ",".join(str(v) for v in pkt.ip_flags), + "ip_frag": pkt.ip_frag, + "ip_ttl": pkt.ip_ttl, + "ip_proto": pkt.ip_proto, + "ip_chksum": pkt.ip_chksum, + "ip_src": pkt.ip_src, + "ip_dst_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "ip_options": pkt.ip_options if isinstance(pkt.ip_options, str) else ",".join(str(v) for v in pkt.ip_options), + "tcp_sport": pkt.tcp_sport, + "tcp_dport": pkt.tcp_dport, + "tcp_seq": pkt.tcp_seq, + "tcp_ack": pkt.tcp_ack, + "tcp_dataofs": pkt.tcp_dataofs, + "tcp_reserved": pkt.tcp_reserved, + "tcp_flags": pkt.tcp_flags, + "tcp_window": pkt.tcp_window, + "tcp_chksum": pkt.tcp_chksum, + "tcp_urgptr": pkt.tcp_urgptr, + "tcp_options": "", + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "ip_dst_is_private": check_ip_is_private(pkt.ip_dst), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": "", + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": randomize_ip(pkt.ip_dst, self.config['randomization_key']), + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "honeypot_status": "False", + "payload": "N/A", + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(payload) + except Exception as e: + print(f"Error sending packet data: {e}", flush=True) + + def _send_heartbeat(self): + """Send heartbeat message.""" + heartbeat = { + "db_name": "heartbeats", + "unwanted_db": self.config['database'], + "pkts_last_hb": self.packet_count, + "ext_dst_ip_country": self.external_info.get('country', 'unknown'), + "type": self.external_info.get('type', 'unknown'), + "ASN": self.external_info.get('ASN', 'unknown'), + "domain": self.external_info.get('domain', 'unknown'), + "city": self.external_info.get('city', 'unknown'), + "as_type": self.external_info.get('as_type', 'unknown'), + "external_is_private": check_ip_is_private(self.external_info.get('queried_ip', '')), + "open_ports": self.config['honeypot_ports'], + "previously_open_ports": "", + "interface": "pflog0", + "internal_ip_randomized": "", + "external_ip_randomized": randomize_ip(self.external_info.get('queried_ip', ''), self.config['randomization_key']), + "System_info": self.system_info.get('System', 'OPNsense'), + "Release_info": self.system_info.get('Release', 'unknown'), + "Version_info": self.system_info.get('Version', 'unknown'), + "Machine_info": self.system_info.get('Machine', 'unknown'), + "Total_Memory": self.system_info.get('Total_Memory', 'unknown'), + "processor": self.system_info.get('processor', 'unknown'), + "architecture": self.system_info.get('architecture', 'unknown'), + "open_honeypot_ports": self.config['honeypot_ports'], + "ls_version": LS_VERSION + } + + try: + self.upload_pipe.send(heartbeat) + self.packet_count = 0 + print("Sent heartbeat", flush=True) + except Exception as e: + print(f"Error sending heartbeat: {e}", flush=True) + + +def packet_handler(pflog_pipe, upload_pipe, config, external_info, system_info): + """ + Process packets from pflog reader and send to uploader. + + This runs in its own process. + """ + processor = PacketProcessor(config, external_info, system_info, upload_pipe) + + print("packet_handler: Started", flush=True) + + # Send initial heartbeat at startup + processor._send_heartbeat() + + # Start a background thread for periodic heartbeats + def heartbeat_loop(): + while True: + time.sleep(processor.HEARTBEAT_INTERVAL) + processor._send_heartbeat() + + hb_thread = threading.Thread(target=heartbeat_loop, daemon=True) + hb_thread.start() + + while True: + try: + batch = pflog_pipe.recv() + processor.process_batch(batch) + except (EOFError, OSError): + print("packet_handler: Pipe closed, exiting", flush=True) + break + except Exception as e: + print(f"packet_handler: Error: {e}", flush=True) + time.sleep(1) + + +def main(): + """Main entry point for LightScope daemon.""" + print(f"LightScope for OPNsense v{LS_VERSION}", flush=True) + print("Starting...", flush=True) + + # Write PID file + pid = os.getpid() + try: + with open("/var/run/lightscope.pid", "w") as f: + f.write(str(pid)) + except Exception as e: + print(f"Warning: Could not write PID file: {e}") + + # Load configuration + config = LightScopeConfig() + config_dict = config.get_dict() + + # Fetch external network info + print("Fetching external network information...", flush=True) + external_info = fetch_external_info() + print(f"External IP: {external_info.get('queried_ip', 'unknown')}", flush=True) + + # Get system info + system_info = get_system_info() + + # Create pipes + pflog_consumer, pflog_producer = multiprocessing.Pipe(duplex=False) + upload_consumer, upload_producer = multiprocessing.Pipe(duplex=False) + hp_upload_consumer, hp_upload_producer = multiprocessing.Pipe(duplex=False) + + # Start processes + processes = [] + + # 1. pflog reader + p_pflog = multiprocessing.Process( + target=read_pflog, + args=(pflog_producer,), + name="pflog_reader" + ) + p_pflog.start() + processes.append(p_pflog) + print("Started pflog reader", flush=True) + + # 2. Packet handler + p_handler = multiprocessing.Process( + target=packet_handler, + args=(pflog_consumer, upload_producer, config_dict, external_info, system_info), + name="packet_handler" + ) + p_handler.start() + processes.append(p_handler) + print("Started packet handler", flush=True) + + # 3. Data uploader + p_uploader = multiprocessing.Process( + target=send_data, + args=(upload_consumer,), + name="uploader" + ) + p_uploader.start() + processes.append(p_uploader) + print("Started uploader", flush=True) + + # 4. Honeypot uploader + p_hp_uploader = multiprocessing.Process( + target=send_honeypot_data, + args=(hp_upload_consumer,), + name="honeypot_uploader" + ) + p_hp_uploader.start() + processes.append(p_hp_uploader) + print("Started honeypot uploader", flush=True) + + # 5. Honeypot listener + if config_dict.get('honeypot_ports'): + p_honeypot = multiprocessing.Process( + target=run_honeypot, + args=(config_dict, hp_upload_producer, config_dict['database']), + name="honeypot" + ) + p_honeypot.start() + processes.append(p_honeypot) + print("Started honeypot listener", flush=True) + else: + print("Honeypot disabled (no ports configured)", flush=True) + + # Signal handler for clean shutdown + def shutdown(signum, frame): + print("\nShutting down...", flush=True) + for p in processes: + if p.is_alive(): + p.terminate() + for p in processes: + p.join(timeout=5) + try: + os.remove("/var/run/lightscope.pid") + except: + pass + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + print("LightScope is running. Press Ctrl+C to stop.", flush=True) + + # Monitor processes + while True: + time.sleep(60) + + for p in processes: + if not p.is_alive(): + print(f"Process {p.name} died, restarting...", flush=True) + # For now, just exit and let the service manager restart us + shutdown(None, None) + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/security/lightscope/work/stage/usr/local/share/lightscope/pflog_reader.py b/security/lightscope/work/stage/usr/local/share/lightscope/pflog_reader.py new file mode 100755 index 00000000000..85735fd7976 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/lightscope/pflog_reader.py @@ -0,0 +1,320 @@ +#!/usr/local/bin/python3.11 +""" +pflog_reader.py - Captures blocked TCP SYN packets from pflog0 interface + +This module reads packets from the pflog0 interface (pf firewall log) +and extracts TCP SYN packets that were blocked by the firewall. +""" + +import struct +import socket +import datetime +import time +import sys +from collections import namedtuple, deque +import threading + +# Try to import pcap library (python-libpcap on FreeBSD) +try: + import pcap +except ImportError: + print("Error: pypcap not found. Install with: pkg install py311-pypcap", file=sys.stderr) + sys.exit(1) + +try: + import dpkt +except ImportError: + print("Error: dpkt not found. Install with: pkg install py311-dpkt", file=sys.stderr) + sys.exit(1) + + +# pflog header structure (FreeBSD) +# See /usr/include/net/if_pflog.h +# Structure size: 1+1+1+1+16+16+4+4+4+4+4+4+1+3+4+1+3 = 72 bytes +PFLOG_HDRLEN = 72 # FreeBSD pflog header length + +# pflog action values (from /usr/include/netpfil/pf/pf.h) +PF_PASS = 0 +PF_DROP = 1 + +# PacketInfo namedtuple - compatible with lightscope_core.py +PacketInfo = namedtuple("PacketInfo", [ + "packet_num", "proto", "packet_time", + "ip_version", "ip_ihl", "ip_tos", "ip_len", "ip_id", "ip_flags", "ip_frag", + "ip_ttl", "ip_proto", "ip_chksum", "ip_src", "ip_dst", "ip_options", + "tcp_sport", "tcp_dport", "tcp_seq", "tcp_ack", "tcp_dataofs", + "tcp_reserved", "tcp_flags", "tcp_window", "tcp_chksum", "tcp_urgptr", "tcp_options" +]) + + +def tcp_flags_to_str(flags_value): + """Convert dpkt TCP flags value to a comma-separated string.""" + flag_names = [] + if flags_value & dpkt.tcp.TH_FIN: + flag_names.append("FIN") + if flags_value & dpkt.tcp.TH_SYN: + flag_names.append("SYN") + if flags_value & dpkt.tcp.TH_RST: + flag_names.append("RST") + if flags_value & dpkt.tcp.TH_PUSH: + flag_names.append("PSH") + if flags_value & dpkt.tcp.TH_ACK: + flag_names.append("ACK") + if flags_value & dpkt.tcp.TH_URG: + flag_names.append("URG") + return ",".join(flag_names) if flag_names else "" + + +def parse_pflog_packet(buf, packet_num): + """ + Parse a pflog packet and extract TCP SYN information. + + pflog packets have a pflog header followed by the IP packet. + We only care about blocked TCP SYN packets. + + Returns PacketInfo namedtuple or None if not a blocked TCP SYN. + """ + if len(buf) < PFLOG_HDRLEN: + return None + + # Extract action from pflog header (byte offset 2) + # Only process blocked packets (PF_DROP) + action = buf[2] + if action != PF_DROP: + return None + + # Skip pflog header to get to IP packet + ip_data = buf[PFLOG_HDRLEN:] + + if len(ip_data) < 20: # Minimum IP header length + return None + + # Check IP version + ip_version = (ip_data[0] >> 4) & 0xF + + if ip_version == 4: + try: + ip = dpkt.ip.IP(ip_data) + except Exception: + return None + + # Only process TCP + if ip.p != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip.data) + except Exception: + return None + + # Only capture SYN packets (SYN flag set, ACK flag not set) + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + # Extract IP flags + flags = [] + if ip.df: + flags.append("DF") + if ip.mf: + flags.append("MF") + ip_flags_str = ",".join(flags) if flags else "" + + ip_opts = ip.opts.hex() if ip.opts else "" + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=ip.v, + ip_ihl=ip.hl, + ip_tos=ip.tos, + ip_len=ip.len, + ip_id=ip.id, + ip_flags=ip_flags_str, + ip_frag=ip.offset >> 3, + ip_ttl=ip.ttl, + ip_proto=ip.p, + ip_chksum=ip.sum, + ip_src=socket.inet_ntoa(ip.src), + ip_dst=socket.inet_ntoa(ip.dst), + ip_options=ip_opts, + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + elif ip_version == 6: + try: + ip6 = dpkt.ip6.IP6(ip_data) + except Exception: + return None + + # Only process TCP + if ip6.nxt != dpkt.ip.IP_PROTO_TCP: + return None + + try: + tcp = ip6.data + if not isinstance(tcp, dpkt.tcp.TCP): + tcp = dpkt.tcp.TCP(ip6.data) + except Exception: + return None + + # Only capture SYN packets + if not (tcp.flags & dpkt.tcp.TH_SYN) or (tcp.flags & dpkt.tcp.TH_ACK): + return None + + return PacketInfo( + packet_num=packet_num, + proto="TCP", + packet_time=datetime.datetime.now().timestamp(), + ip_version=6, + ip_ihl=None, + ip_tos=None, + ip_len=ip6.plen, + ip_id=None, + ip_flags="", + ip_frag=0, + ip_ttl=ip6.hlim, + ip_proto=ip6.nxt, + ip_chksum=None, + ip_src=socket.inet_ntop(socket.AF_INET6, ip6.src), + ip_dst=socket.inet_ntop(socket.AF_INET6, ip6.dst), + ip_options="", + tcp_sport=tcp.sport, + tcp_dport=tcp.dport, + tcp_seq=tcp.seq, + tcp_ack=tcp.ack, + tcp_dataofs=tcp.off * 4, + tcp_reserved=0, + tcp_flags=tcp_flags_to_str(tcp.flags), + tcp_window=tcp.win, + tcp_chksum=tcp.sum, + tcp_urgptr=tcp.urp, + tcp_options=tcp.opts + ) + + return None + + +def read_pflog(output_pipe, interface="pflog0"): + """ + Main pflog reader function. + + Captures packets from pflog0 interface, parses TCP SYNs, + and sends them to output_pipe in batches. + + Args: + output_pipe: multiprocessing.Pipe connection for sending packet batches + interface: Network interface to capture from (default: pflog0) + """ + BATCH_SIZE = 100 + IDLE_FLUSH_SECS = 1.0 + MAX_QUEUE_SIZE = 10000 + + send_deque = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + + def sender_thread(): + """Thread to batch and send packets.""" + nonlocal last_activity + prior_time = time.monotonic() + packets_sent = 0 + + while True: + now = time.monotonic() + to_send = 0 + + # Log stats every second + if (now - prior_time) >= 1.0: + if packets_sent > 0: + print(f"pflog_reader: sent {packets_sent} packets, queue={len(send_deque)}", flush=True) + packets_sent = 0 + prior_time = now + + # Check if we should send a batch + if len(send_deque) >= BATCH_SIZE: + to_send = BATCH_SIZE + elif send_deque and (now - last_activity) >= IDLE_FLUSH_SECS: + to_send = len(send_deque) + else: + time.sleep(0.01) + continue + + # Build and send batch + batch = [send_deque.popleft() for _ in range(to_send)] + try: + output_pipe.send(batch) + packets_sent += len(batch) + except Exception as e: + print(f"pflog_reader: pipe send error: {e}", file=sys.stderr) + return + + # Start sender thread + threading.Thread(target=sender_thread, daemon=True).start() + + # Open pflog0 for capture + try: + # DLT_PFLOG = 117 on FreeBSD + sniffer = pcap.pcap( + name=interface, + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=100 + ) + # Filter for TCP only + sniffer.setfilter("tcp") + except Exception as e: + print(f"pflog_reader: Failed to open {interface}: {e}", file=sys.stderr) + sys.exit(1) + + print(f"pflog_reader: Capturing on {interface}", flush=True) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + + try: + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + send_deque.append(pkt_info) + last_activity = time.monotonic() + except Exception as e: + # Skip malformed packets + continue + + +if __name__ == "__main__": + # Test mode - print captured packets + print("pflog_reader: Running in test mode (printing to stdout)") + + try: + sniffer = pcap.pcap( + name="pflog0", + snaplen=65535, + promisc=False, + immediate=True, + timeout_ms=1000 + ) + sniffer.setfilter("tcp") + except Exception as e: + print(f"Failed to open pflog0: {e}") + sys.exit(1) + + packet_num = 0 + for ts, buf in sniffer: + packet_num += 1 + pkt_info = parse_pflog_packet(buf, packet_num) + if pkt_info: + print(f"SYN: {pkt_info.ip_src}:{pkt_info.tcp_sport} -> {pkt_info.ip_dst}:{pkt_info.tcp_dport}") diff --git a/security/lightscope/work/stage/usr/local/share/lightscope/uploader.py b/security/lightscope/work/stage/usr/local/share/lightscope/uploader.py new file mode 100755 index 00000000000..957b3cae386 --- /dev/null +++ b/security/lightscope/work/stage/usr/local/share/lightscope/uploader.py @@ -0,0 +1,234 @@ +#!/usr/local/bin/python3.11 +""" +uploader.py - Data upload module for LightScope + +Handles batching and uploading packet data and heartbeats to thelightscope.com +""" + +import time +import threading +import sys +from collections import deque + +try: + import requests + from requests.adapters import HTTPAdapter +except ImportError: + print("Error: requests not found. Install with: pkg install py311-requests", file=sys.stderr) + sys.exit(1) + + +# API Configuration +DATA_URL = "https://thelightscope.com/log_mysql_data" +HEARTBEAT_URL = "https://thelightscope.com/heartbeat" +HEADERS = { + "Content-Type": "application/json", + "X-API-Key": "lightscopeAPIkey2025_please_dont_distribute_me_but_im_write_only_anyways" +} + +# Upload settings +BATCH_SIZE = 600 +IDLE_FLUSH_SEC = 5.0 +RETRY_BACKOFF = 5 +MAX_QUEUE_SIZE = 100000 + + +def send_data(consumer_pipe): + """ + Main data upload function. + + Receives data from consumer_pipe, batches it, and uploads to thelightscope.com. + Handles heartbeat messages separately. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=4, pool_maxsize=4) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + def reader(): + """Thread to read from pipe and add to queue.""" + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"uploader: Error receiving data: {e}", flush=True) + + # Drain any remaining data + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + # Start reader thread + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("uploader: Started data upload service", flush=True) + + try: + while not stop_event.is_set() or queue: + # 1) Process heartbeats first + hb_count = 0 + n = len(queue) + for _ in range(n): + try: + item = queue.popleft() + except IndexError: + break + + if item.get("db_name") == "heartbeats": + hb_count += 1 + try: + resp = session.post( + HEARTBEAT_URL, + json=item, + headers=HEADERS, + timeout=10 + ) + if resp.status_code != 200: + print(f"uploader: Heartbeat rejected ({resp.status_code})", flush=True) + except requests.RequestException as e: + print(f"uploader: Heartbeat error: {e}", flush=True) + else: + # Put non-heartbeat items back + queue.append(item) + + if hb_count: + print(f"uploader: Sent {hb_count} heartbeat(s)", flush=True) + + # 2) Batch and send regular data + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"uploader: Sent {to_send} items", flush=True) + else: + print(f"uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"uploader: Upload error, will retry: {e}", flush=True) + # Put items back at front of queue + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + print("uploader: Shutting down...", flush=True) + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("uploader: Stopped", flush=True) + + +def send_honeypot_data(consumer_pipe): + """ + Honeypot-specific data upload function. + + Similar to send_data but with smaller batch sizes for honeypot data. + + Args: + consumer_pipe: multiprocessing.Pipe connection to receive honeypot data from + """ + session = requests.Session() + adapter = HTTPAdapter(pool_connections=2, pool_maxsize=2) + session.mount("https://", adapter) + session.mount("http://", adapter) + + queue = deque(maxlen=MAX_QUEUE_SIZE) + last_activity = time.monotonic() + stop_event = threading.Event() + + HONEYPOT_BATCH_SIZE = 100 + + def reader(): + nonlocal last_activity + while not stop_event.is_set(): + try: + item = consumer_pipe.recv() + queue.append(item) + last_activity = time.monotonic() + except (EOFError, OSError): + stop_event.set() + break + except Exception as e: + print(f"honeypot_uploader: Error receiving data: {e}", flush=True) + + while consumer_pipe.poll(0): + try: + queue.append(consumer_pipe.recv()) + except: + break + + reader_thread = threading.Thread(target=reader, daemon=True) + reader_thread.start() + + print("honeypot_uploader: Started", flush=True) + + try: + while not stop_event.is_set() or queue: + now = time.monotonic() + elapsed = now - last_activity + + if queue and (len(queue) >= HONEYPOT_BATCH_SIZE or elapsed >= IDLE_FLUSH_SEC): + to_send = min(len(queue), HONEYPOT_BATCH_SIZE) + batch = [queue.popleft() for _ in range(to_send)] + + try: + resp = session.post( + DATA_URL, + json={"batch": batch}, + headers=HEADERS, + timeout=10 + ) + if resp.status_code == 200: + print(f"honeypot_uploader: Sent {to_send} items", flush=True) + else: + print(f"honeypot_uploader: Upload rejected ({resp.status_code})", flush=True) + last_activity = time.monotonic() + + except requests.RequestException as e: + print(f"honeypot_uploader: Error, will retry: {e}", flush=True) + for item in reversed(batch): + queue.appendleft(item) + time.sleep(RETRY_BACKOFF) + else: + time.sleep(0.1) + + except KeyboardInterrupt: + pass + finally: + stop_event.set() + reader_thread.join(timeout=2) + print("honeypot_uploader: Stopped", flush=True) + + +if __name__ == "__main__": + print("uploader: This module should be run as part of lightscope_daemon.py")