From 16aca9bba63a56eacde2eb0b9cb4d00b876f1f00 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 26 Dec 2025 07:53:05 -0600 Subject: [PATCH 01/70] Add configurable HTTP Server header for deception Add SERVER_HEADER environment variable to customize the HTTP Server response header, defaulting to Apache/2.2.22 (Ubuntu). This allows the honeypot to masquerade as different web servers to attract attackers. - Add server_header field to Config dataclass - Override version_string() in Handler to return configured header - Update documentation and all deployment configs --- README.md | 1 + docker-compose.yaml | 1 + helm/templates/configmap.yaml | 1 + helm/values.yaml | 1 + kubernetes/manifests/configmap.yaml | 1 + src/config.py | 4 +++- src/handler.py | 4 ++++ src/server.py | 1 + 8 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cf8b96..b84d955 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ To customize the deception server installation several **environment variables** | `CANARY_TOKEN_URL` | External canary token URL | None | | `DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | +| `SERVER_HEADER` | HTTP Server header for deception | `Apache/2.2.22 (Ubuntu)` | ## robots.txt The actual (juicy) robots.txt configuration is the following diff --git a/docker-compose.yaml b/docker-compose.yaml index 57c648d..1612864 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,6 +20,7 @@ services: - MAX_COUNTER=10 - CANARY_TOKEN_TRIES=10 - PROBABILITY_ERROR_CODES=0 + - SERVER_HEADER=Apache/2.2.22 (Ubuntu) # Optional: Set your canary token URL # - CANARY_TOKEN_URL=http://canarytokens.com/api/users/YOUR_TOKEN/passwords.txt # Optional: Set custom dashboard path (auto-generated if not set) diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index f6fe92c..c50ab75 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -14,4 +14,5 @@ data: MAX_COUNTER: {{ .Values.config.maxCounter | quote }} CANARY_TOKEN_TRIES: {{ .Values.config.canaryTokenTries | quote }} PROBABILITY_ERROR_CODES: {{ .Values.config.probabilityErrorCodes | quote }} + SERVER_HEADER: {{ .Values.config.serverHeader | quote }} CANARY_TOKEN_URL: {{ .Values.config.canaryTokenUrl | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index 9ee9ca5..a095632 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -73,6 +73,7 @@ config: maxCounter: 10 canaryTokenTries: 10 probabilityErrorCodes: 0 + serverHeader: "Apache/2.2.22 (Ubuntu)" # canaryTokenUrl: set-your-canary-token-url-here networkPolicy: diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index 42ba002..431b9a3 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -13,4 +13,5 @@ data: MAX_COUNTER: "10" CANARY_TOKEN_TRIES: "10" PROBABILITY_ERROR_CODES: "0" + SERVER_HEADER: "Apache/2.2.22 (Ubuntu)" # CANARY_TOKEN_URL: set-your-canary-token-url-here \ No newline at end of file diff --git a/src/config.py b/src/config.py index 51391a9..7c6714c 100644 --- a/src/config.py +++ b/src/config.py @@ -21,6 +21,7 @@ class Config: api_server_port: int = 8080 api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) + server_header: str = "Apache/2.2.22 (Ubuntu)" @classmethod def from_env(cls) -> 'Config': @@ -44,5 +45,6 @@ class Config: api_server_url=os.getenv('API_SERVER_URL'), api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), - probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 5)) + probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 5)), + server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)') ) diff --git a/src/handler.py b/src/handler.py index 81f48fa..bed3369 100644 --- a/src/handler.py +++ b/src/handler.py @@ -46,6 +46,10 @@ class Handler(BaseHTTPRequestHandler): """Extract user agent from request""" return self.headers.get('User-Agent', '') + def version_string(self) -> str: + """Return custom server version for deception.""" + return self.config.server_header + def _should_return_error(self) -> bool: """Check if we should return an error based on probability""" if self.config.probability_error_codes <= 0: diff --git a/src/server.py b/src/server.py index d10d33e..73f0ce9 100644 --- a/src/server.py +++ b/src/server.py @@ -31,6 +31,7 @@ def print_usage(): print(' DASHBOARD_SECRET_PATH - Secret path for dashboard (auto-generated if not set)') print(' PROBABILITY_ERROR_CODES - Probability (0-100) to return HTTP error codes (default: 0)') print(' CHAR_SPACE - Characters for random links') + print(' SERVER_HEADER - HTTP Server header for deception (default: Apache/2.2.22 (Ubuntu))') def main(): From d0101b34faf33bc85d3065051353da55bb0bf56f Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 26 Dec 2025 08:00:16 -0600 Subject: [PATCH 02/70] Added test script to show the server header --- tests/check_header.sh | 3 +++ 1 file changed, 3 insertions(+) create mode 100755 tests/check_header.sh diff --git a/tests/check_header.sh b/tests/check_header.sh new file mode 100755 index 0000000..78b8e5d --- /dev/null +++ b/tests/check_header.sh @@ -0,0 +1,3 @@ +#!/bin/env bash +# -s is for silent (no progress bar) | -I is to get the headers | grep is to find only the Server line +curl -s -I http://localhost:5000 | grep "Server:" \ No newline at end of file From 61ba574e92cc86444001a767508dc3e79f469247 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Sat, 27 Dec 2025 19:17:27 +0100 Subject: [PATCH 03/70] Added POST log and dashboard for used credentials --- src/handler.py | 15 ++++++++ src/logger.py | 28 +++++++++++++++ src/server.py | 4 ++- src/templates/dashboard_template.py | 28 +++++++++++++++ src/tracker.py | 56 ++++++++++++++++++++++++++++- 5 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/handler.py b/src/handler.py index 9d8abe2..ac7ca22 100644 --- a/src/handler.py +++ b/src/handler.py @@ -3,6 +3,7 @@ import logging import random import time +from datetime import datetime from http.server import BaseHTTPRequestHandler from typing import Optional, List @@ -25,6 +26,7 @@ class Handler(BaseHTTPRequestHandler): counter: int = 0 app_logger: logging.Logger = None access_logger: logging.Logger = None + credential_logger: logging.Logger = None def _get_client_ip(self) -> str: """Extract client IP address from request, checking proxy headers first""" @@ -213,6 +215,19 @@ class Handler(BaseHTTPRequestHandler): self.access_logger.warning(f"[POST DATA] {post_data[:200]}") + # Parse and log credentials + username, password = self.tracker.parse_credentials(post_data) + if username or password: + # Log to dedicated credentials.log file + timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") + credential_line = f"{timestamp}|{client_ip}|{username or 'N/A'}|{password or 'N/A'}|{self.path}" + self.credential_logger.info(credential_line) + + # Also record in tracker for dashboard + self.tracker.record_credential_attempt(client_ip, self.path, username or 'N/A', password or 'N/A') + + self.access_logger.warning(f"[CREDENTIALS CAPTURED] {client_ip} - Username: {username or 'N/A'} - Path: {self.path}") + # send the post data (body) to the record_access function so the post data can be used to detect suspicious things. self.tracker.record_access(client_ip, self.path, user_agent, post_data) diff --git a/src/logger.py b/src/logger.py index 68b8278..9f09236 100644 --- a/src/logger.py +++ b/src/logger.py @@ -77,6 +77,22 @@ class LoggerManager: access_stream_handler.setFormatter(log_format) self._access_logger.addHandler(access_stream_handler) + # Setup credential logger (special format, no stream handler) + self._credential_logger = logging.getLogger("krawl.credentials") + self._credential_logger.setLevel(logging.INFO) + self._credential_logger.handlers.clear() + + # Credential logger uses a simple format: timestamp|ip|username|password|path + credential_format = logging.Formatter("%(message)s") + + credential_file_handler = RotatingFileHandler( + os.path.join(log_dir, "credentials.log"), + maxBytes=max_bytes, + backupCount=backup_count + ) + credential_file_handler.setFormatter(credential_format) + self._credential_logger.addHandler(credential_file_handler) + self._initialized = True @property @@ -93,6 +109,13 @@ class LoggerManager: self.initialize() return self._access_logger + @property + def credentials(self) -> logging.Logger: + """Get the credentials logger.""" + if not self._initialized: + self.initialize() + return self._credential_logger + # Module-level singleton instance _logger_manager = LoggerManager() @@ -108,6 +131,11 @@ def get_access_logger() -> logging.Logger: return _logger_manager.access +def get_credential_logger() -> logging.Logger: + """Get the credential logger instance.""" + return _logger_manager.credentials + + def initialize_logging(log_dir: str = "logs") -> None: """Initialize the logging system.""" _logger_manager.initialize(log_dir) diff --git a/src/server.py b/src/server.py index 861e9f2..fd8f7d2 100644 --- a/src/server.py +++ b/src/server.py @@ -11,7 +11,7 @@ from http.server import HTTPServer from config import Config from tracker import AccessTracker from handler import Handler -from logger import initialize_logging, get_app_logger, get_access_logger +from logger import initialize_logging, get_app_logger, get_access_logger, get_credential_logger def print_usage(): @@ -45,6 +45,7 @@ def main(): initialize_logging() app_logger = get_app_logger() access_logger = get_access_logger() + credential_logger = get_credential_logger() config = Config.from_env() @@ -55,6 +56,7 @@ def main(): Handler.counter = config.canary_token_tries Handler.app_logger = app_logger Handler.access_logger = access_logger + Handler.credential_logger = credential_logger if len(sys.argv) == 2: try: diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 3f5524d..a267278 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -45,6 +45,12 @@ def generate_dashboard(stats: dict) -> str: for log in stats.get('attack_types', [])[-10:] ]) or 'No attacks detected' + # Generate credential attempts rows + credential_rows = '\n'.join([ + f'{log["ip"]}{log["username"]}{log["password"]}{log["path"]}{log["timestamp"].split("T")[1][:8]}' + for log in stats.get('credential_attempts', [])[-20:] + ]) or 'No credentials captured yet' + return f""" @@ -159,6 +165,10 @@ def generate_dashboard(stats: dict) -> str:
{stats.get('honeypot_ips', 0)}
Honeypot Caught
+
+
{len(stats.get('credential_attempts', []))}
+
Credentials Captured
+
@@ -194,6 +204,24 @@ def generate_dashboard(stats: dict) -> str:
+
+

🔑 Captured Credentials

+ + + + + + + + + + + + {credential_rows} + +
IP AddressUsernamePasswordPathTime
+
+

😈 Detected Attack Types

diff --git a/src/tracker.py b/src/tracker.py index 6e733f4..717a4c3 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -4,6 +4,7 @@ from typing import Dict, List, Tuple from collections import defaultdict from datetime import datetime import re +import urllib.parse class AccessTracker: @@ -13,6 +14,7 @@ class AccessTracker: self.path_counts: Dict[str, int] = defaultdict(int) self.user_agent_counts: Dict[str, int] = defaultdict(int) self.access_log: List[Dict] = [] + self.credential_attempts: List[Dict] = [] self.suspicious_patterns = [ 'bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python-requests', 'scanner', 'nikto', 'sqlmap', 'nmap', 'masscan', 'nessus', 'acunetix', @@ -31,6 +33,57 @@ class AccessTracker: # Track IPs that accessed honeypot paths from robots.txt self.honeypot_triggered: Dict[str, List[str]] = defaultdict(list) + def parse_credentials(self, post_data: str) -> Tuple[str, str]: + """ + Parse username and password from POST data. + Returns tuple (username, password) or (None, None) if not found. + """ + if not post_data: + return None, None + + username = None + password = None + + try: + # Parse URL-encoded form data + parsed = urllib.parse.parse_qs(post_data) + + # Common username field names + username_fields = ['username', 'user', 'login', 'email', 'log', 'userid', 'account'] + for field in username_fields: + if field in parsed and parsed[field]: + username = parsed[field][0] + break + + # Common password field names + password_fields = ['password', 'pass', 'passwd', 'pwd', 'passphrase'] + for field in password_fields: + if field in parsed and parsed[field]: + password = parsed[field][0] + break + + except Exception: + # If parsing fails, try simple regex patterns + username_match = re.search(r'(?:username|user|login|email|log)=([^&\s]+)', post_data, re.IGNORECASE) + password_match = re.search(r'(?:password|pass|passwd|pwd)=([^&\s]+)', post_data, re.IGNORECASE) + + if username_match: + username = urllib.parse.unquote_plus(username_match.group(1)) + if password_match: + password = urllib.parse.unquote_plus(password_match.group(1)) + + return username, password + + def record_credential_attempt(self, ip: str, path: str, username: str, password: str): + """Record a credential login attempt""" + self.credential_attempts.append({ + 'ip': ip, + 'path': path, + 'username': username, + 'password': password, + 'timestamp': datetime.now().isoformat() + }) + def record_access(self, ip: str, path: str, user_agent: str = '', body: str = ''): """Record an access attempt""" self.ip_counts[ip] += 1 @@ -146,5 +199,6 @@ class AccessTracker: 'top_user_agents': self.get_top_user_agents(10), 'recent_suspicious': self.get_suspicious_accesses(20), 'honeypot_triggered_ips': self.get_honeypot_triggered_ips(), - 'attack_types': self.get_attack_type_accesses(20) + 'attack_types': self.get_attack_type_accesses(20), + 'credential_attempts': self.credential_attempts[-50:] # Last 50 attempts } From 6556e17f91d53965f96e5d9f8b9e9f0ddd03e729 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Sun, 28 Dec 2025 17:07:18 +0100 Subject: [PATCH 04/70] Added timezone env variable handling --- README.md | 1 + deployment.yaml | 44 -------- docker-compose.yaml | 2 + helm/templates/configmap.yaml | 3 + helm/values.yaml | 1 + kubernetes/manifests/configmap.yaml | 3 +- src/config.py | 41 +++++++- src/logger.py | 33 ++++-- src/server.py | 17 +++- src/templates/dashboard_template.py | 18 +++- src/tracker.py | 10 +- tests/test_credentials.sh | 150 ++++++++++++++++++++++++++++ 12 files changed, 258 insertions(+), 65 deletions(-) delete mode 100644 deployment.yaml create mode 100755 tests/test_credentials.sh diff --git a/README.md b/README.md index b84d955..06157bd 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ To customize the deception server installation several **environment variables** | `DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | | `SERVER_HEADER` | HTTP Server header for deception | `Apache/2.2.22 (Ubuntu)` | +| `TIMEZONE` | IANA timezone for logs and dashboard (e.g., `America/New_York`, `Europe/Rome`) | System timezone | ## robots.txt The actual (juicy) robots.txt configuration is the following diff --git a/deployment.yaml b/deployment.yaml deleted file mode 100644 index 4bf5189..0000000 --- a/deployment.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: krawl-server - namespace: krawl - labels: - app: krawl-server -spec: - replicas: 1 - selector: - matchLabels: - app: krawl-server - template: - metadata: - labels: - app: krawl-server - spec: - containers: - - name: krawl - image: ghcr.io/blessedrebus/krawl:latest - imagePullPolicy: Always - ports: - - containerPort: 5000 - name: http - protocol: TCP - envFrom: - - configMapRef: - name: krawl-config - volumeMounts: - - name: wordlists - mountPath: /app/wordlists.json - subPath: wordlists.json - readOnly: true - resources: - requests: - memory: "64Mi" - cpu: "100m" - limits: - memory: "256Mi" - cpu: "500m" - volumes: - - name: wordlists - configMap: - name: krawl-wordlists diff --git a/docker-compose.yaml b/docker-compose.yaml index 1612864..600034d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,6 +25,8 @@ services: # - CANARY_TOKEN_URL=http://canarytokens.com/api/users/YOUR_TOKEN/passwords.txt # Optional: Set custom dashboard path (auto-generated if not set) # - DASHBOARD_SECRET_PATH=/my-secret-dashboard + # Optional: Set timezone for logs and dashboard (e.g., America/New_York, Europe/Rome) + # - TIMEZONE=UTC restart: unless-stopped healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:5000')"] diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index c50ab75..c08aaa5 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -16,3 +16,6 @@ data: PROBABILITY_ERROR_CODES: {{ .Values.config.probabilityErrorCodes | quote }} SERVER_HEADER: {{ .Values.config.serverHeader | quote }} CANARY_TOKEN_URL: {{ .Values.config.canaryTokenUrl | quote }} + {{- if .Values.config.timezone }} + TIMEZONE: {{ .Values.config.timezone | quote }} + {{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index a095632..ac51756 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -75,6 +75,7 @@ config: probabilityErrorCodes: 0 serverHeader: "Apache/2.2.22 (Ubuntu)" # canaryTokenUrl: set-your-canary-token-url-here +# timezone: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. networkPolicy: enabled: true diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index 431b9a3..073005f 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -14,4 +14,5 @@ data: CANARY_TOKEN_TRIES: "10" PROBABILITY_ERROR_CODES: "0" SERVER_HEADER: "Apache/2.2.22 (Ubuntu)" -# CANARY_TOKEN_URL: set-your-canary-token-url-here \ No newline at end of file +# CANARY_TOKEN_URL: set-your-canary-token-url-here +# TIMEZONE: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome") \ No newline at end of file diff --git a/src/config.py b/src/config.py index 7c6714c..741f01f 100644 --- a/src/config.py +++ b/src/config.py @@ -3,6 +3,8 @@ import os from dataclasses import dataclass from typing import Optional, Tuple +from zoneinfo import ZoneInfo +import time @dataclass @@ -22,6 +24,40 @@ class Config: api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) server_header: str = "Apache/2.2.22 (Ubuntu)" + timezone: str = None # IANA timezone (e.g., 'America/New_York', 'Europe/Rome') + + @staticmethod + # Try to fetch timezone before if not set + def get_system_timezone() -> str: + """Get the system's default timezone""" + try: + if os.path.islink('/etc/localtime'): + tz_path = os.readlink('/etc/localtime') + if 'zoneinfo/' in tz_path: + return tz_path.split('zoneinfo/')[-1] + + local_tz = time.tzname[time.daylight] + if local_tz and local_tz != 'UTC': + return local_tz + except Exception: + pass + + # Default fallback to UTC + return 'UTC' + + def get_timezone(self) -> ZoneInfo: + """Get configured timezone as ZoneInfo object""" + if self.timezone: + try: + return ZoneInfo(self.timezone) + except Exception: + pass + + system_tz = self.get_system_timezone() + try: + return ZoneInfo(system_tz) + except Exception: + return ZoneInfo('UTC') @classmethod def from_env(cls) -> 'Config': @@ -45,6 +81,7 @@ class Config: api_server_url=os.getenv('API_SERVER_URL'), api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), - probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 5)), - server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)') + probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 0)), + server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)'), + timezone=os.getenv('TIMEZONE') # If not set, will use system timezone ) diff --git a/src/logger.py b/src/logger.py index 9f09236..992cad8 100644 --- a/src/logger.py +++ b/src/logger.py @@ -8,6 +8,23 @@ Provides two loggers: app (application) and access (HTTP access logs). import logging import os from logging.handlers import RotatingFileHandler +from typing import Optional +from zoneinfo import ZoneInfo +from datetime import datetime + + +class TimezoneFormatter(logging.Formatter): + """Custom formatter that respects configured timezone""" + def __init__(self, fmt=None, datefmt=None, timezone: Optional[ZoneInfo] = None): + super().__init__(fmt, datefmt) + self.timezone = timezone or ZoneInfo('UTC') + + def formatTime(self, record, datefmt=None): + """Override formatTime to use configured timezone""" + dt = datetime.fromtimestamp(record.created, tz=self.timezone) + if datefmt: + return dt.strftime(datefmt) + return dt.isoformat() class LoggerManager: @@ -20,23 +37,27 @@ class LoggerManager: cls._instance._initialized = False return cls._instance - def initialize(self, log_dir: str = "logs") -> None: + def initialize(self, log_dir: str = "logs", timezone: Optional[ZoneInfo] = None) -> None: """ Initialize the logging system with rotating file handlers. Args: log_dir: Directory for log files (created if not exists) + timezone: ZoneInfo timezone for log timestamps (defaults to UTC) """ if self._initialized: return + self.timezone = timezone or ZoneInfo('UTC') + # Create log directory if it doesn't exist os.makedirs(log_dir, exist_ok=True) # Common format for all loggers - log_format = logging.Formatter( + log_format = TimezoneFormatter( "[%(asctime)s] %(levelname)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S" + datefmt="%Y-%m-%d %H:%M:%S", + timezone=self.timezone ) # Rotation settings: 1MB max, 5 backups @@ -83,7 +104,7 @@ class LoggerManager: self._credential_logger.handlers.clear() # Credential logger uses a simple format: timestamp|ip|username|password|path - credential_format = logging.Formatter("%(message)s") + credential_format = TimezoneFormatter("%(message)s", timezone=self.timezone) credential_file_handler = RotatingFileHandler( os.path.join(log_dir, "credentials.log"), @@ -136,6 +157,6 @@ def get_credential_logger() -> logging.Logger: return _logger_manager.credentials -def initialize_logging(log_dir: str = "logs") -> None: +def initialize_logging(log_dir: str = "logs", timezone: Optional[ZoneInfo] = None) -> None: """Initialize the logging system.""" - _logger_manager.initialize(log_dir) + _logger_manager.initialize(log_dir, timezone) diff --git a/src/server.py b/src/server.py index fd8f7d2..fcb794e 100644 --- a/src/server.py +++ b/src/server.py @@ -33,6 +33,8 @@ def print_usage(): print(' PROBABILITY_ERROR_CODES - Probability (0-100) to return HTTP error codes (default: 0)') print(' CHAR_SPACE - Characters for random links') print(' SERVER_HEADER - HTTP Server header for deception (default: Apache/2.2.22 (Ubuntu))') + print(' TIMEZONE - IANA timezone for logs/dashboard (e.g., America/New_York, Europe/Rome)') + print(' If not set, system timezone will be used') def main(): @@ -41,15 +43,19 @@ def main(): print_usage() exit(0) - # Initialize logging - initialize_logging() + config = Config.from_env() + + # Get timezone configuration + tz = config.get_timezone() + + # Initialize logging with timezone + initialize_logging(timezone=tz) app_logger = get_app_logger() access_logger = get_access_logger() credential_logger = get_credential_logger() - config = Config.from_env() - - tracker = AccessTracker() + # Initialize tracker with timezone + tracker = AccessTracker(timezone=tz) Handler.config = config Handler.tracker = tracker @@ -71,6 +77,7 @@ def main(): try: app_logger.info(f'Starting deception server on port {config.port}...') + app_logger.info(f'Timezone configured: {tz.key}') app_logger.info(f'Dashboard available at: {config.dashboard_secret_path}') if config.canary_token_url: app_logger.info(f'Canary token will appear after {config.canary_token_tries} tries') diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index a267278..9fc4111 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -5,6 +5,18 @@ Dashboard template for viewing honeypot statistics. Customize this template to change the dashboard appearance. """ +from datetime import datetime + + +def format_timestamp(iso_timestamp: str) -> str: + """Format ISO timestamp for display (YYYY-MM-DD HH:MM:SS)""" + try: + dt = datetime.fromisoformat(iso_timestamp) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + # Fallback for old format + return iso_timestamp.split("T")[1][:8] if "T" in iso_timestamp else iso_timestamp + def generate_dashboard(stats: dict) -> str: """Generate dashboard HTML with access statistics""" @@ -29,7 +41,7 @@ def generate_dashboard(stats: dict) -> str: # Generate suspicious accesses rows suspicious_rows = '\n'.join([ - f'' + f'' for log in stats['recent_suspicious'][-10:] ]) or '' @@ -41,13 +53,13 @@ def generate_dashboard(stats: dict) -> str: # Generate attack types rows attack_type_rows = '\n'.join([ - f'' + f'' for log in stats.get('attack_types', [])[-10:] ]) or '' # Generate credential attempts rows credential_rows = '\n'.join([ - f'' + f'' for log in stats.get('credential_attempts', [])[-20:] ]) or '' diff --git a/src/tracker.py b/src/tracker.py index 717a4c3..c9322ec 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -1,20 +1,22 @@ #!/usr/bin/env python3 -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional from collections import defaultdict from datetime import datetime +from zoneinfo import ZoneInfo import re import urllib.parse class AccessTracker: """Track IP addresses and paths accessed""" - def __init__(self): + def __init__(self, timezone: Optional[ZoneInfo] = None): self.ip_counts: Dict[str, int] = defaultdict(int) self.path_counts: Dict[str, int] = defaultdict(int) self.user_agent_counts: Dict[str, int] = defaultdict(int) self.access_log: List[Dict] = [] self.credential_attempts: List[Dict] = [] + self.timezone = timezone or ZoneInfo('UTC') self.suspicious_patterns = [ 'bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python-requests', 'scanner', 'nikto', 'sqlmap', 'nmap', 'masscan', 'nessus', 'acunetix', @@ -81,7 +83,7 @@ class AccessTracker: 'path': path, 'username': username, 'password': password, - 'timestamp': datetime.now().isoformat() + 'timestamp': datetime.now(self.timezone).isoformat() }) def record_access(self, ip: str, path: str, user_agent: str = '', body: str = ''): @@ -112,7 +114,7 @@ class AccessTracker: 'suspicious': is_suspicious, 'honeypot_triggered': self.is_honeypot_path(path), 'attack_types':attack_findings, - 'timestamp': datetime.now().isoformat() + 'timestamp': datetime.now(self.timezone).isoformat() }) def detect_attack_type(self, data:str) -> list[str]: diff --git a/tests/test_credentials.sh b/tests/test_credentials.sh new file mode 100755 index 0000000..6379b92 --- /dev/null +++ b/tests/test_credentials.sh @@ -0,0 +1,150 @@ +#!/bin/bash + +# This script sends various POST requests with credentials to the honeypot + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +# Configuration +HOST="localhost" +PORT="5000" +BASE_URL="http://${HOST}:${PORT}" + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}Krawl Credential Logging Test Script${NC}" +echo -e "${BLUE}========================================${NC}\n" + +# Check if server is running +echo -e "${YELLOW}Checking if server is running on ${BASE_URL}...${NC}" +if ! curl -s -f "${BASE_URL}/health" > /dev/null 2>&1; then + echo -e "${RED}❌ Server is not running. Please start the Krawl server first.${NC}" + echo -e "${YELLOW}Run: python3 src/server.py${NC}" + exit 1 +fi +echo -e "${GREEN}✓ Server is running${NC}\n" + +# Test 1: Simple login form POST +echo -e "${YELLOW}Test 1: POST to /login with form data${NC}" +curl -s -X POST "${BASE_URL}/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=admin123" \ + > /dev/null +echo -e "${GREEN}✓ Sent: admin / admin123${NC}\n" + +sleep 1 + +# Test 2: Admin panel login +echo -e "${YELLOW}Test 2: POST to /admin with credentials${NC}" +curl -s -X POST "${BASE_URL}/admin" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "user=root&pass=toor&submit=Login" \ + > /dev/null +echo -e "${GREEN}✓ Sent: root / toor${NC}\n" + +sleep 1 + +# Test 3: WordPress login attempt +echo -e "${YELLOW}Test 3: POST to /wp-login.php${NC}" +curl -s -X POST "${BASE_URL}/wp-login.php" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "log=wpuser&pwd=Password1&wp-submit=Log+In" \ + > /dev/null +echo -e "${GREEN}✓ Sent: wpuser / Password1${NC}\n" + +sleep 1 + +# Test 4: JSON formatted credentials +echo -e "${YELLOW}Test 4: POST to /api/login with JSON${NC}" +curl -s -X POST "${BASE_URL}/api/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"apiuser","password":"apipass123","remember":true}' \ + > /dev/null +echo -e "${GREEN}✓ Sent: apiuser / apipass123${NC}\n" + +sleep 1 + +# Test 5: SSH-style login +echo -e "${YELLOW}Test 5: POST to /ssh with credentials${NC}" +curl -s -X POST "${BASE_URL}/ssh" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=sshuser&password=P@ssw0rd!" \ + > /dev/null +echo -e "${GREEN}✓ Sent: sshuser / P@ssw0rd!${NC}\n" + +sleep 1 + +# Test 6: Database admin +echo -e "${YELLOW}Test 6: POST to /phpmyadmin with credentials${NC}" +curl -s -X POST "${BASE_URL}/phpmyadmin" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "pma_username=dbadmin&pma_password=dbpass123&server=1" \ + > /dev/null +echo -e "${GREEN}✓ Sent: dbadmin / dbpass123${NC}\n" + +sleep 1 + +# Test 7: Multiple fields with email +echo -e "${YELLOW}Test 7: POST to /register with email${NC}" +curl -s -X POST "${BASE_URL}/register" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "email=test@example.com&username=newuser&password=NewPass123&confirm_password=NewPass123" \ + > /dev/null +echo -e "${GREEN}✓ Sent: newuser / NewPass123 (email: test@example.com)${NC}\n" + +sleep 1 + +# Test 8: FTP credentials +echo -e "${YELLOW}Test 8: POST to /ftp/login${NC}" +curl -s -X POST "${BASE_URL}/ftp/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "ftpuser=ftpadmin&ftppass=ftp123456" \ + > /dev/null +echo -e "${GREEN}✓ Sent: ftpadmin / ftp123456${NC}\n" + +sleep 1 + +# Test 9: Common brute force attempt +echo -e "${YELLOW}Test 9: Multiple attempts (simulating brute force)${NC}" +for i in {1..3}; do + curl -s -X POST "${BASE_URL}/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin&password=pass${i}" \ + > /dev/null + echo -e "${GREEN}✓ Attempt $i: admin / pass${i}${NC}" + sleep 0.5 +done +echo "" + +sleep 1 + +# Test 10: Special characters in credentials +echo -e "${YELLOW}Test 10: POST with special characters${NC}" +curl -s -X POST "${BASE_URL}/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "username=user@domain.com" \ + --data-urlencode "password=P@\$\$w0rd!#%" \ + > /dev/null +echo -e "${GREEN}✓ Sent: user@domain.com / P@\$\$w0rd!#%${NC}\n" + +echo -e "${BLUE}========================================${NC}" +echo -e "${GREEN}✓ All credential tests completed!${NC}" +echo -e "${BLUE}========================================${NC}\n" + +echo -e "${YELLOW}Check the results:${NC}" +echo -e " 1. View the log file: ${GREEN}cat src/logs/credentials.log${NC}" +echo -e " 2. View the dashboard: ${GREEN}${BASE_URL}/dashboard${NC}" +echo -e " 3. Check recent logs: ${GREEN}tail -20 src/logs/krawl.log${NC}\n" + +# Display last 10 credential entries if log file exists +if [ -f "src/logs/credentials.log" ]; then + echo -e "${BLUE}========================================${NC}" + echo -e "${BLUE}Last 10 Captured Credentials:${NC}" + echo -e "${BLUE}========================================${NC}" + tail -10 src/logs/credentials.log + echo "" +fi + +echo -e "${YELLOW}💡 Tip: Open ${BASE_URL}/dashboard in your browser to see the credentials in real-time!${NC}" From f1c142c53d7f40dc8eec68d886928542ac44e9b6 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 28 Dec 2025 10:43:32 -0600 Subject: [PATCH 05/70] feat: add SQLite persistent storage for request logging - Add SQLAlchemy-based database layer for persistent storage - Create models for access_logs, credential_attempts, attack_detections, ip_stats - Include fields for future GeoIP and reputation enrichment - Implement sanitization utilities to protect against malicious payloads - Fix XSS vulnerability in dashboard template (HTML escape all user data) - Add DATABASE_PATH and DATABASE_RETENTION_DAYS config options - Dual storage: in-memory for dashboard performance + SQLite for persistence New files: - src/models.py - SQLAlchemy ORM models - src/database.py - DatabaseManager singleton - src/sanitizer.py - Input sanitization and HTML escaping - requirements.txt - SQLAlchemy dependency Security protections: - Parameterized queries via SQLAlchemy ORM - Field length limits to prevent storage exhaustion - Null byte and control character stripping - HTML escaping on dashboard output --- .gitignore | 4 + docs/coding-guidelines.md | 90 +++++++ requirements.txt | 5 + src/config.py | 7 +- src/database.py | 361 ++++++++++++++++++++++++++++ src/handler.py | 4 +- src/models.py | 141 +++++++++++ src/sanitizer.py | 113 +++++++++ src/server.py | 10 + src/templates/dashboard_template.py | 35 ++- src/tracker.py | 122 ++++++++-- 11 files changed, 860 insertions(+), 32 deletions(-) create mode 100644 docs/coding-guidelines.md create mode 100644 requirements.txt create mode 100644 src/database.py create mode 100644 src/models.py create mode 100644 src/sanitizer.py diff --git a/.gitignore b/.gitignore index 5d758cb..a36748e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,10 @@ secrets/ *.log logs/ +# Database +data/ +*.db + # Temporary files *.tmp *.temp diff --git a/docs/coding-guidelines.md b/docs/coding-guidelines.md new file mode 100644 index 0000000..1e13575 --- /dev/null +++ b/docs/coding-guidelines.md @@ -0,0 +1,90 @@ +### Coding Standards + +**Style & Structure** +- Prefer longer, explicit code over compact one-liners +- Always include docstrings for functions/classes + inline comments +- Strongly prefer OOP-style code (classes over functional/nested functions) +- Strong typing throughout (dataclasses, TypedDict, Enums, type hints) +- Value future-proofing and expanded usage insights + +**Data Design** +- Use dataclasses for internal data modeling +- Typed JSON structures +- Functions return fully typed objects (no loose dicts) +- Snapshot files in JSON or YAML +- Human-readable fields (e.g., `sql_injection`, `xss_attempt`) + +**Templates & UI** +- Don't mix large HTML/CSS blocks in Python code +- Prefer Jinja templates for HTML rendering +- Clean CSS, minimal inline clutter, readable template logic + +**Writing & Documentation** +- Markdown documentation +- Clear section headers +- Roadmap/Phase/Feature-Session style documents + +**Logging** +- Use singleton for logging found in `src\logger.py` +- Setup logging at app start: + ``` + initialize_logging() + app_logger = get_app_logger() + access_logger = get_access_logger() + credential_logger = get_credential_logger() + ``` + +**Preferred Pip Packages** +- API/Web Server: Simple Python +- HTTP: Requests +- SQLite: Sqlalchemy +- Database Migrations: Alembic + +### Error Handling +- Custom exception classes for domain-specific errors +- Consistent error response formats (JSON structure) +- Logging severity levels (ERROR vs WARNING) + +### Configuration +- `.env` for secrets (never committed) +- Maintain `.env.example` in each component for documentation +- Typed config loaders using dataclasses +- Validation on startup + +### Containerization & Deployment +- Explicit Dockerfiles +- Production-friendly hardening (distroless/slim when meaningful) +- Use git branch as tag + +### Dependency Management +- Use `requirements.txt` and virtual environments (`python3 -m venv venv`) +- Use path `venv` for all virtual environments +- Pin versions to version ranges (or exact versions if pinning a particular version) +- Activate venv before running code (unless in Docker) + +### Testing Standards +- Manual testing preferred for applications +- **tests:** Use shell scripts with curl/httpie for simulation and attack scripts. +- tests should be located in `tests` directory + +### Git Standards + +**Branch Strategy:** +- `master` - Production-ready code only +- `beta` - Public pre-release testing +- `dev` - Main development branch, integration point + +**Workflow:** +- Feature work branches off `dev` (e.g., `feature/add-scheduler`) +- Merge features back to `dev` for testing +- Promote `dev` → `beta` for public testing (when applicable) +- Promote `beta` (or `dev`) → `master` for production + +**Commit Messages:** +- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc. +- Keep commits atomic and focused +- Write clear, descriptive messages + +**Tagging:** +- Tag releases on `master` with semantic versioning (e.g., `v1.2.3`) +- Optionally tag beta releases (e.g., `v1.2.3-beta.1`) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..94f74f2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# Krawl Honeypot Dependencies +# Install with: pip install -r requirements.txt + +# Database ORM +SQLAlchemy>=2.0.0,<3.0.0 diff --git a/src/config.py b/src/config.py index 7c6714c..76f1aed 100644 --- a/src/config.py +++ b/src/config.py @@ -22,6 +22,9 @@ class Config: api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) server_header: str = "Apache/2.2.22 (Ubuntu)" + # Database settings + database_path: str = "data/krawl.db" + database_retention_days: int = 30 @classmethod def from_env(cls) -> 'Config': @@ -46,5 +49,7 @@ class Config: api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 5)), - server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)') + server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)'), + database_path=os.getenv('DATABASE_PATH', 'data/krawl.db'), + database_retention_days=int(os.getenv('DATABASE_RETENTION_DAYS', 30)) ) diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..58a4505 --- /dev/null +++ b/src/database.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 + +""" +Database singleton module for the Krawl honeypot. +Provides SQLAlchemy session management and database initialization. +""" + +import os +import stat +from datetime import datetime +from typing import Optional, List, Dict, Any + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session, Session + +from models import Base, AccessLog, CredentialAttempt, AttackDetection, IpStats +from sanitizer import ( + sanitize_ip, + sanitize_path, + sanitize_user_agent, + sanitize_credential, + sanitize_attack_pattern, +) + + +class DatabaseManager: + """ + Singleton database manager for the Krawl honeypot. + + Handles database initialization, session management, and provides + methods for persisting access logs, credentials, and attack detections. + """ + _instance: Optional["DatabaseManager"] = None + + def __new__(cls) -> "DatabaseManager": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def initialize(self, database_path: str = "data/krawl.db") -> None: + """ + Initialize the database connection and create tables. + + Args: + database_path: Path to the SQLite database file + """ + if self._initialized: + return + + # Create data directory if it doesn't exist + data_dir = os.path.dirname(database_path) + if data_dir and not os.path.exists(data_dir): + os.makedirs(data_dir, exist_ok=True) + + # Create SQLite database with check_same_thread=False for multi-threaded access + database_url = f"sqlite:///{database_path}" + self._engine = create_engine( + database_url, + connect_args={"check_same_thread": False}, + echo=False # Set to True for SQL debugging + ) + + # Create session factory with scoped_session for thread safety + session_factory = sessionmaker(bind=self._engine) + self._Session = scoped_session(session_factory) + + # Create all tables + Base.metadata.create_all(self._engine) + + # Set restrictive file permissions (owner read/write only) + if os.path.exists(database_path): + try: + os.chmod(database_path, stat.S_IRUSR | stat.S_IWUSR) # 600 + except OSError: + # May fail on some systems, not critical + pass + + self._initialized = True + + @property + def session(self) -> Session: + """Get a thread-local database session.""" + if not self._initialized: + raise RuntimeError("DatabaseManager not initialized. Call initialize() first.") + return self._Session() + + def close_session(self) -> None: + """Close the current thread-local session.""" + if self._initialized: + self._Session.remove() + + def persist_access( + self, + ip: str, + path: str, + user_agent: str = "", + method: str = "GET", + is_suspicious: bool = False, + is_honeypot_trigger: bool = False, + attack_types: Optional[List[str]] = None, + matched_patterns: Optional[Dict[str, str]] = None + ) -> Optional[int]: + """ + Persist an access log entry to the database. + + Args: + ip: Client IP address + path: Requested path + user_agent: Client user agent string + method: HTTP method (GET, POST, HEAD) + is_suspicious: Whether the request was flagged as suspicious + is_honeypot_trigger: Whether a honeypot path was accessed + attack_types: List of detected attack types + matched_patterns: Dict mapping attack_type to matched pattern + + Returns: + The ID of the created AccessLog record, or None on error + """ + session = self.session + try: + # Create access log with sanitized fields + access_log = AccessLog( + ip=sanitize_ip(ip), + path=sanitize_path(path), + user_agent=sanitize_user_agent(user_agent), + method=method[:10], + is_suspicious=is_suspicious, + is_honeypot_trigger=is_honeypot_trigger, + timestamp=datetime.utcnow() + ) + session.add(access_log) + session.flush() # Get the ID before committing + + # Add attack detections if any + if attack_types: + matched_patterns = matched_patterns or {} + for attack_type in attack_types: + detection = AttackDetection( + access_log_id=access_log.id, + attack_type=attack_type[:50], + matched_pattern=sanitize_attack_pattern( + matched_patterns.get(attack_type, "") + ) + ) + session.add(detection) + + # Update IP stats + self._update_ip_stats(session, ip) + + session.commit() + return access_log.id + + except Exception as e: + session.rollback() + # Log error but don't crash - database persistence is secondary to honeypot function + print(f"Database error persisting access: {e}") + return None + finally: + self.close_session() + + def persist_credential( + self, + ip: str, + path: str, + username: Optional[str] = None, + password: Optional[str] = None + ) -> Optional[int]: + """ + Persist a credential attempt to the database. + + Args: + ip: Client IP address + path: Login form path + username: Submitted username + password: Submitted password + + Returns: + The ID of the created CredentialAttempt record, or None on error + """ + session = self.session + try: + credential = CredentialAttempt( + ip=sanitize_ip(ip), + path=sanitize_path(path), + username=sanitize_credential(username), + password=sanitize_credential(password), + timestamp=datetime.utcnow() + ) + session.add(credential) + session.commit() + return credential.id + + except Exception as e: + session.rollback() + print(f"Database error persisting credential: {e}") + return None + finally: + self.close_session() + + def _update_ip_stats(self, session: Session, ip: str) -> None: + """ + Update IP statistics (upsert pattern). + + Args: + session: Active database session + ip: IP address to update + """ + sanitized_ip = sanitize_ip(ip) + now = datetime.utcnow() + + ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + + if ip_stats: + ip_stats.total_requests += 1 + ip_stats.last_seen = now + else: + ip_stats = IpStats( + ip=sanitized_ip, + total_requests=1, + first_seen=now, + last_seen=now + ) + session.add(ip_stats) + + def get_access_logs( + self, + limit: int = 100, + offset: int = 0, + ip_filter: Optional[str] = None, + suspicious_only: bool = False + ) -> List[Dict[str, Any]]: + """ + Retrieve access logs with optional filtering. + + Args: + limit: Maximum number of records to return + offset: Number of records to skip + ip_filter: Filter by IP address + suspicious_only: Only return suspicious requests + + Returns: + List of access log dictionaries + """ + session = self.session + try: + query = session.query(AccessLog).order_by(AccessLog.timestamp.desc()) + + if ip_filter: + query = query.filter(AccessLog.ip == sanitize_ip(ip_filter)) + if suspicious_only: + query = query.filter(AccessLog.is_suspicious == True) + + logs = query.offset(offset).limit(limit).all() + + return [ + { + 'id': log.id, + 'ip': log.ip, + 'path': log.path, + 'user_agent': log.user_agent, + 'method': log.method, + 'is_suspicious': log.is_suspicious, + 'is_honeypot_trigger': log.is_honeypot_trigger, + 'timestamp': log.timestamp.isoformat(), + 'attack_types': [d.attack_type for d in log.attack_detections] + } + for log in logs + ] + finally: + self.close_session() + + def get_credential_attempts( + self, + limit: int = 100, + offset: int = 0, + ip_filter: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + Retrieve credential attempts with optional filtering. + + Args: + limit: Maximum number of records to return + offset: Number of records to skip + ip_filter: Filter by IP address + + Returns: + List of credential attempt dictionaries + """ + session = self.session + try: + query = session.query(CredentialAttempt).order_by( + CredentialAttempt.timestamp.desc() + ) + + if ip_filter: + query = query.filter(CredentialAttempt.ip == sanitize_ip(ip_filter)) + + attempts = query.offset(offset).limit(limit).all() + + return [ + { + 'id': attempt.id, + 'ip': attempt.ip, + 'path': attempt.path, + 'username': attempt.username, + 'password': attempt.password, + 'timestamp': attempt.timestamp.isoformat() + } + for attempt in attempts + ] + finally: + self.close_session() + + def get_ip_stats(self, limit: int = 100) -> List[Dict[str, Any]]: + """ + Retrieve IP statistics ordered by total requests. + + Args: + limit: Maximum number of records to return + + Returns: + List of IP stats dictionaries + """ + session = self.session + try: + stats = session.query(IpStats).order_by( + IpStats.total_requests.desc() + ).limit(limit).all() + + return [ + { + 'ip': s.ip, + 'total_requests': s.total_requests, + 'first_seen': s.first_seen.isoformat(), + 'last_seen': s.last_seen.isoformat(), + 'country_code': s.country_code, + 'city': s.city, + 'asn': s.asn, + 'asn_org': s.asn_org, + 'reputation_score': s.reputation_score, + 'reputation_source': s.reputation_source + } + for s in stats + ] + finally: + self.close_session() + + +# Module-level singleton instance +_db_manager = DatabaseManager() + + +def get_database() -> DatabaseManager: + """Get the database manager singleton instance.""" + return _db_manager + + +def initialize_database(database_path: str = "data/krawl.db") -> None: + """Initialize the database system.""" + _db_manager.initialize(database_path) diff --git a/src/handler.py b/src/handler.py index ac7ca22..90214ac 100644 --- a/src/handler.py +++ b/src/handler.py @@ -229,7 +229,7 @@ class Handler(BaseHTTPRequestHandler): self.access_logger.warning(f"[CREDENTIALS CAPTURED] {client_ip} - Username: {username or 'N/A'} - Path: {self.path}") # send the post data (body) to the record_access function so the post data can be used to detect suspicious things. - self.tracker.record_access(client_ip, self.path, user_agent, post_data) + self.tracker.record_access(client_ip, self.path, user_agent, post_data, method='POST') time.sleep(1) @@ -347,7 +347,7 @@ class Handler(BaseHTTPRequestHandler): self.app_logger.error(f"Error generating dashboard: {e}") return - self.tracker.record_access(client_ip, self.path, user_agent) + self.tracker.record_access(client_ip, self.path, user_agent, method='GET') if self.tracker.is_suspicious_user_agent(user_agent): self.access_logger.warning(f"[SUSPICIOUS] {client_ip} - {user_agent[:50]} - {self.path}") diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..f6e7d30 --- /dev/null +++ b/src/models.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +""" +SQLAlchemy ORM models for the Krawl honeypot database. +Stores access logs, credential attempts, attack detections, and IP statistics. +""" + +from datetime import datetime +from typing import Optional, List + +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Index +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +from sanitizer import ( + MAX_IP_LENGTH, + MAX_PATH_LENGTH, + MAX_USER_AGENT_LENGTH, + MAX_CREDENTIAL_LENGTH, + MAX_ATTACK_PATTERN_LENGTH, + MAX_CITY_LENGTH, + MAX_ASN_ORG_LENGTH, + MAX_REPUTATION_SOURCE_LENGTH, +) + + +class Base(DeclarativeBase): + """Base class for all ORM models.""" + pass + + +class AccessLog(Base): + """ + Records all HTTP requests to the honeypot. + + Stores request metadata, suspicious activity flags, and timestamps + for analysis and dashboard display. + """ + __tablename__ = 'access_logs' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True) + path: Mapped[str] = mapped_column(String(MAX_PATH_LENGTH), nullable=False) + user_agent: Mapped[Optional[str]] = mapped_column(String(MAX_USER_AGENT_LENGTH), nullable=True) + method: Mapped[str] = mapped_column(String(10), nullable=False, default='GET') + is_suspicious: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_honeypot_trigger: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, index=True) + + # Relationship to attack detections + attack_detections: Mapped[List["AttackDetection"]] = relationship( + "AttackDetection", + back_populates="access_log", + cascade="all, delete-orphan" + ) + + # Composite index for common queries + __table_args__ = ( + Index('ix_access_logs_ip_timestamp', 'ip', 'timestamp'), + ) + + def __repr__(self) -> str: + return f"" + + +class CredentialAttempt(Base): + """ + Records captured login attempts from honeypot login forms. + + Stores the submitted username and password along with request metadata. + """ + __tablename__ = 'credential_attempts' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True) + path: Mapped[str] = mapped_column(String(MAX_PATH_LENGTH), nullable=False) + username: Mapped[Optional[str]] = mapped_column(String(MAX_CREDENTIAL_LENGTH), nullable=True) + password: Mapped[Optional[str]] = mapped_column(String(MAX_CREDENTIAL_LENGTH), nullable=True) + timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow, index=True) + + # Composite index for common queries + __table_args__ = ( + Index('ix_credential_attempts_ip_timestamp', 'ip', 'timestamp'), + ) + + def __repr__(self) -> str: + return f"" + + +class AttackDetection(Base): + """ + Records detected attack patterns in requests. + + Linked to the parent AccessLog record. Multiple attack types can be + detected in a single request. + """ + __tablename__ = 'attack_detections' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + access_log_id: Mapped[int] = mapped_column( + Integer, + ForeignKey('access_logs.id', ondelete='CASCADE'), + nullable=False, + index=True + ) + attack_type: Mapped[str] = mapped_column(String(50), nullable=False) + matched_pattern: Mapped[Optional[str]] = mapped_column(String(MAX_ATTACK_PATTERN_LENGTH), nullable=True) + + # Relationship back to access log + access_log: Mapped["AccessLog"] = relationship("AccessLog", back_populates="attack_detections") + + def __repr__(self) -> str: + return f"" + + +class IpStats(Base): + """ + Aggregated statistics per IP address. + + Includes fields for future GeoIP and reputation enrichment. + Updated on each request from an IP. + """ + __tablename__ = 'ip_stats' + + ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), primary_key=True) + total_requests: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + first_seen: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + last_seen: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + + # GeoIP fields (populated by future enrichment) + country_code: Mapped[Optional[str]] = mapped_column(String(2), nullable=True) + city: Mapped[Optional[str]] = mapped_column(String(MAX_CITY_LENGTH), nullable=True) + asn: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + asn_org: Mapped[Optional[str]] = mapped_column(String(MAX_ASN_ORG_LENGTH), nullable=True) + + # Reputation fields (populated by future enrichment) + reputation_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + reputation_source: Mapped[Optional[str]] = mapped_column(String(MAX_REPUTATION_SOURCE_LENGTH), nullable=True) + reputation_updated: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/src/sanitizer.py b/src/sanitizer.py new file mode 100644 index 0000000..f783129 --- /dev/null +++ b/src/sanitizer.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 + +""" +Sanitization utilities for safe database storage and HTML output. +Protects against SQL injection payloads, XSS, and storage exhaustion attacks. +""" + +import html +import re +from typing import Optional + + +# Field length limits for database storage +MAX_IP_LENGTH = 45 # IPv6 max length +MAX_PATH_LENGTH = 2048 # URL max practical length +MAX_USER_AGENT_LENGTH = 512 +MAX_CREDENTIAL_LENGTH = 256 +MAX_ATTACK_PATTERN_LENGTH = 256 +MAX_CITY_LENGTH = 128 +MAX_ASN_ORG_LENGTH = 256 +MAX_REPUTATION_SOURCE_LENGTH = 64 + + +def sanitize_for_storage(value: Optional[str], max_length: int) -> str: + """ + Sanitize and truncate string for safe database storage. + + Removes null bytes and control characters that could cause issues + with database storage or log processing. + + Args: + value: The string to sanitize + max_length: Maximum length to truncate to + + Returns: + Sanitized and truncated string, empty string if input is None/empty + """ + if not value: + return "" + + # Convert to string if not already + value = str(value) + + # Remove null bytes and control characters (except newline \n, tab \t, carriage return \r) + # Control chars are 0x00-0x1F and 0x7F, we keep 0x09 (tab), 0x0A (newline), 0x0D (carriage return) + cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', value) + + # Truncate to max length + return cleaned[:max_length] + + +def sanitize_ip(value: Optional[str]) -> str: + """Sanitize IP address for storage.""" + return sanitize_for_storage(value, MAX_IP_LENGTH) + + +def sanitize_path(value: Optional[str]) -> str: + """Sanitize URL path for storage.""" + return sanitize_for_storage(value, MAX_PATH_LENGTH) + + +def sanitize_user_agent(value: Optional[str]) -> str: + """Sanitize user agent string for storage.""" + return sanitize_for_storage(value, MAX_USER_AGENT_LENGTH) + + +def sanitize_credential(value: Optional[str]) -> str: + """Sanitize username or password for storage.""" + return sanitize_for_storage(value, MAX_CREDENTIAL_LENGTH) + + +def sanitize_attack_pattern(value: Optional[str]) -> str: + """Sanitize matched attack pattern for storage.""" + return sanitize_for_storage(value, MAX_ATTACK_PATTERN_LENGTH) + + +def escape_html(value: Optional[str]) -> str: + """ + Escape HTML special characters for safe display in web pages. + + Prevents stored XSS attacks when displaying user-controlled data + in the dashboard. + + Args: + value: The string to escape + + Returns: + HTML-escaped string, empty string if input is None/empty + """ + if not value: + return "" + return html.escape(str(value)) + + +def escape_html_truncated(value: Optional[str], max_display_length: int) -> str: + """ + Escape HTML and truncate for display. + + Args: + value: The string to escape and truncate + max_display_length: Maximum display length (truncation happens before escaping) + + Returns: + HTML-escaped and truncated string + """ + if not value: + return "" + + value_str = str(value) + if len(value_str) > max_display_length: + value_str = value_str[:max_display_length] + "..." + + return html.escape(value_str) diff --git a/src/server.py b/src/server.py index fd8f7d2..a0b5ec3 100644 --- a/src/server.py +++ b/src/server.py @@ -12,6 +12,7 @@ from config import Config from tracker import AccessTracker from handler import Handler from logger import initialize_logging, get_app_logger, get_access_logger, get_credential_logger +from database import initialize_database def print_usage(): @@ -33,6 +34,8 @@ def print_usage(): print(' PROBABILITY_ERROR_CODES - Probability (0-100) to return HTTP error codes (default: 0)') print(' CHAR_SPACE - Characters for random links') print(' SERVER_HEADER - HTTP Server header for deception (default: Apache/2.2.22 (Ubuntu))') + print(' DATABASE_PATH - Path to SQLite database (default: data/krawl.db)') + print(' DATABASE_RETENTION_DAYS - Days to retain database records (default: 30)') def main(): @@ -49,6 +52,13 @@ def main(): config = Config.from_env() + # Initialize database for persistent storage + try: + initialize_database(config.database_path) + app_logger.info(f'Database initialized at: {config.database_path}') + except Exception as e: + app_logger.warning(f'Database initialization failed: {e}. Continuing with in-memory only.') + tracker = AccessTracker() Handler.config = config diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index a267278..92e950d 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -5,49 +5,58 @@ Dashboard template for viewing honeypot statistics. Customize this template to change the dashboard appearance. """ +import html + + +def _escape(value) -> str: + """Escape HTML special characters to prevent XSS attacks.""" + if value is None: + return "" + return html.escape(str(value)) + def generate_dashboard(stats: dict) -> str: """Generate dashboard HTML with access statistics""" - # Generate IP rows + # Generate IP rows (IPs are generally safe but escape for consistency) top_ips_rows = '\n'.join([ - f'' + f'' for i, (ip, count) in enumerate(stats['top_ips']) ]) or '' - # Generate paths rows + # Generate paths rows (CRITICAL: paths can contain XSS payloads) top_paths_rows = '\n'.join([ - f'' + f'' for i, (path, count) in enumerate(stats['top_paths']) ]) or '' - # Generate User-Agent rows + # Generate User-Agent rows (CRITICAL: user agents can contain XSS payloads) top_ua_rows = '\n'.join([ - f'' + f'' for i, (ua, count) in enumerate(stats['top_user_agents']) ]) or '' - # Generate suspicious accesses rows + # Generate suspicious accesses rows (CRITICAL: multiple user-controlled fields) suspicious_rows = '\n'.join([ - f'' + f'' for log in stats['recent_suspicious'][-10:] ]) or '' # Generate honeypot triggered IPs rows honeypot_rows = '\n'.join([ - f'' + f'' for ip, paths in stats.get('honeypot_triggered_ips', []) ]) or '' - # Generate attack types rows + # Generate attack types rows (CRITICAL: paths and user agents are user-controlled) attack_type_rows = '\n'.join([ - f'' + f'' for log in stats.get('attack_types', [])[-10:] ]) or '' - # Generate credential attempts rows + # Generate credential attempts rows (CRITICAL: usernames and passwords are user-controlled) credential_rows = '\n'.join([ - f'' + f'' for log in stats.get('credential_attempts', [])[-20:] ]) or '' diff --git a/src/tracker.py b/src/tracker.py index 717a4c3..04ded3b 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -1,15 +1,29 @@ #!/usr/bin/env python3 -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, Optional from collections import defaultdict from datetime import datetime import re import urllib.parse +from database import get_database, DatabaseManager + class AccessTracker: - """Track IP addresses and paths accessed""" - def __init__(self): + """ + Track IP addresses and paths accessed. + + Maintains in-memory structures for fast dashboard access and + persists data to SQLite for long-term storage and analysis. + """ + def __init__(self, db_manager: Optional[DatabaseManager] = None): + """ + Initialize the access tracker. + + Args: + db_manager: Optional DatabaseManager for persistence. + If None, will use the global singleton. + """ self.ip_counts: Dict[str, int] = defaultdict(int) self.path_counts: Dict[str, int] = defaultdict(int) self.user_agent_counts: Dict[str, int] = defaultdict(int) @@ -21,7 +35,7 @@ class AccessTracker: 'burp', 'zap', 'w3af', 'metasploit', 'nuclei', 'gobuster', 'dirbuster' ] - # common attack types such as xss, shell injection, probes + # Common attack types such as xss, shell injection, probes self.attack_types = { 'path_traversal': r'\.\.', 'sql_injection': r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", @@ -33,6 +47,25 @@ class AccessTracker: # Track IPs that accessed honeypot paths from robots.txt self.honeypot_triggered: Dict[str, List[str]] = defaultdict(list) + # Database manager for persistence (lazily initialized) + self._db_manager = db_manager + + @property + def db(self) -> Optional[DatabaseManager]: + """ + Get the database manager, lazily initializing if needed. + + Returns: + DatabaseManager instance or None if not available + """ + if self._db_manager is None: + try: + self._db_manager = get_database() + except Exception: + # Database not initialized, persistence disabled + pass + return self._db_manager + def parse_credentials(self, post_data: str) -> Tuple[str, str]: """ Parse username and password from POST data. @@ -75,7 +108,12 @@ class AccessTracker: return username, password def record_credential_attempt(self, ip: str, path: str, username: str, password: str): - """Record a credential login attempt""" + """ + Record a credential login attempt. + + Stores in both in-memory list and SQLite database. + """ + # In-memory storage for dashboard self.credential_attempts.append({ 'ip': ip, 'path': path, @@ -84,37 +122,89 @@ class AccessTracker: 'timestamp': datetime.now().isoformat() }) - def record_access(self, ip: str, path: str, user_agent: str = '', body: str = ''): - """Record an access attempt""" + # Persist to database + if self.db: + try: + self.db.persist_credential( + ip=ip, + path=path, + username=username, + password=password + ) + except Exception: + # Don't crash if database persistence fails + pass + + def record_access( + self, + ip: str, + path: str, + user_agent: str = '', + body: str = '', + method: str = 'GET' + ): + """ + Record an access attempt. + + Stores in both in-memory structures and SQLite database. + + Args: + ip: Client IP address + path: Requested path + user_agent: Client user agent string + body: Request body (for POST/PUT) + method: HTTP method + """ self.ip_counts[ip] += 1 self.path_counts[path] += 1 if user_agent: self.user_agent_counts[user_agent] += 1 - - # path attack type detection + + # Path attack type detection attack_findings = self.detect_attack_type(path) - # post / put data + # POST/PUT body attack detection if len(body) > 0: attack_findings.extend(self.detect_attack_type(body)) - is_suspicious = self.is_suspicious_user_agent(user_agent) or self.is_honeypot_path(path) or len(attack_findings) > 0 + is_suspicious = ( + self.is_suspicious_user_agent(user_agent) or + self.is_honeypot_path(path) or + len(attack_findings) > 0 + ) + is_honeypot = self.is_honeypot_path(path) - # Track if this IP accessed a honeypot path - if self.is_honeypot_path(path): + if is_honeypot: self.honeypot_triggered[ip].append(path) - + + # In-memory storage for dashboard self.access_log.append({ 'ip': ip, 'path': path, 'user_agent': user_agent, 'suspicious': is_suspicious, - 'honeypot_triggered': self.is_honeypot_path(path), - 'attack_types':attack_findings, + 'honeypot_triggered': is_honeypot, + 'attack_types': attack_findings, 'timestamp': datetime.now().isoformat() }) + # Persist to database + if self.db: + try: + self.db.persist_access( + ip=ip, + path=path, + user_agent=user_agent, + method=method, + is_suspicious=is_suspicious, + is_honeypot_trigger=is_honeypot, + attack_types=attack_findings if attack_findings else None + ) + except Exception: + # Don't crash if database persistence fails + pass + def detect_attack_type(self, data:str) -> list[str]: """ Returns a list of all attack types found in path data From a4baedffd958b83da700431b53f91eb63c858803 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 28 Dec 2025 13:52:46 -0600 Subject: [PATCH 06/70] updated dashboard to pull data from db. This closes issue #10 --- src/database.py | 196 +++++++++++++++++++++++++++- src/models.py | 4 +- src/templates/dashboard_template.py | 2 +- src/tracker.py | 35 +++-- tests/test_credentials.sh | 4 +- 5 files changed, 218 insertions(+), 23 deletions(-) diff --git a/src/database.py b/src/database.py index 58a4505..e0de320 100644 --- a/src/database.py +++ b/src/database.py @@ -10,7 +10,7 @@ import stat from datetime import datetime from typing import Optional, List, Dict, Any -from sqlalchemy import create_engine +from sqlalchemy import create_engine, func, distinct, case from sqlalchemy.orm import sessionmaker, scoped_session, Session from models import Base, AccessLog, CredentialAttempt, AttackDetection, IpStats @@ -346,6 +346,200 @@ class DatabaseManager: finally: self.close_session() + def get_dashboard_counts(self) -> Dict[str, int]: + """ + Get aggregate statistics for the dashboard. + + Returns: + Dictionary with total_accesses, unique_ips, unique_paths, + suspicious_accesses, honeypot_triggered, honeypot_ips + """ + session = self.session + try: + # Get main aggregate counts in one query + result = session.query( + func.count(AccessLog.id).label('total_accesses'), + func.count(distinct(AccessLog.ip)).label('unique_ips'), + func.count(distinct(AccessLog.path)).label('unique_paths'), + func.sum(case((AccessLog.is_suspicious == True, 1), else_=0)).label('suspicious_accesses'), + func.sum(case((AccessLog.is_honeypot_trigger == True, 1), else_=0)).label('honeypot_triggered') + ).first() + + # Get unique IPs that triggered honeypots + honeypot_ips = session.query( + func.count(distinct(AccessLog.ip)) + ).filter(AccessLog.is_honeypot_trigger == True).scalar() or 0 + + return { + 'total_accesses': result.total_accesses or 0, + 'unique_ips': result.unique_ips or 0, + 'unique_paths': result.unique_paths or 0, + 'suspicious_accesses': int(result.suspicious_accesses or 0), + 'honeypot_triggered': int(result.honeypot_triggered or 0), + 'honeypot_ips': honeypot_ips + } + finally: + self.close_session() + + def get_top_ips(self, limit: int = 10) -> List[tuple]: + """ + Get top IP addresses by access count. + + Args: + limit: Maximum number of results + + Returns: + List of (ip, count) tuples ordered by count descending + """ + session = self.session + try: + results = session.query( + AccessLog.ip, + func.count(AccessLog.id).label('count') + ).group_by(AccessLog.ip).order_by( + func.count(AccessLog.id).desc() + ).limit(limit).all() + + return [(row.ip, row.count) for row in results] + finally: + self.close_session() + + def get_top_paths(self, limit: int = 10) -> List[tuple]: + """ + Get top paths by access count. + + Args: + limit: Maximum number of results + + Returns: + List of (path, count) tuples ordered by count descending + """ + session = self.session + try: + results = session.query( + AccessLog.path, + func.count(AccessLog.id).label('count') + ).group_by(AccessLog.path).order_by( + func.count(AccessLog.id).desc() + ).limit(limit).all() + + return [(row.path, row.count) for row in results] + finally: + self.close_session() + + def get_top_user_agents(self, limit: int = 10) -> List[tuple]: + """ + Get top user agents by access count. + + Args: + limit: Maximum number of results + + Returns: + List of (user_agent, count) tuples ordered by count descending + """ + session = self.session + try: + results = session.query( + AccessLog.user_agent, + func.count(AccessLog.id).label('count') + ).filter( + AccessLog.user_agent.isnot(None), + AccessLog.user_agent != '' + ).group_by(AccessLog.user_agent).order_by( + func.count(AccessLog.id).desc() + ).limit(limit).all() + + return [(row.user_agent, row.count) for row in results] + finally: + self.close_session() + + def get_recent_suspicious(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get recent suspicious access attempts. + + Args: + limit: Maximum number of results + + Returns: + List of access log dictionaries with is_suspicious=True + """ + session = self.session + try: + logs = session.query(AccessLog).filter( + AccessLog.is_suspicious == True + ).order_by(AccessLog.timestamp.desc()).limit(limit).all() + + return [ + { + 'ip': log.ip, + 'path': log.path, + 'user_agent': log.user_agent, + 'timestamp': log.timestamp.isoformat() + } + for log in logs + ] + finally: + self.close_session() + + def get_honeypot_triggered_ips(self) -> List[tuple]: + """ + Get IPs that triggered honeypot paths with the paths they accessed. + + Returns: + List of (ip, [paths]) tuples + """ + session = self.session + try: + # Get all honeypot triggers grouped by IP + results = session.query( + AccessLog.ip, + AccessLog.path + ).filter( + AccessLog.is_honeypot_trigger == True + ).all() + + # Group paths by IP + ip_paths: Dict[str, List[str]] = {} + for row in results: + if row.ip not in ip_paths: + ip_paths[row.ip] = [] + if row.path not in ip_paths[row.ip]: + ip_paths[row.ip].append(row.path) + + return [(ip, paths) for ip, paths in ip_paths.items()] + finally: + self.close_session() + + def get_recent_attacks(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + Get recent access logs that have attack detections. + + Args: + limit: Maximum number of results + + Returns: + List of access log dicts with attack_types included + """ + session = self.session + try: + # Get access logs that have attack detections + logs = session.query(AccessLog).join( + AttackDetection + ).order_by(AccessLog.timestamp.desc()).limit(limit).all() + + return [ + { + 'ip': log.ip, + 'path': log.path, + 'user_agent': log.user_agent, + 'timestamp': log.timestamp.isoformat(), + 'attack_types': [d.attack_type for d in log.attack_detections] + } + for log in logs + ] + finally: + self.close_session() + # Module-level singleton instance _db_manager = DatabaseManager() diff --git a/src/models.py b/src/models.py index f6e7d30..40dae0b 100644 --- a/src/models.py +++ b/src/models.py @@ -53,9 +53,11 @@ class AccessLog(Base): cascade="all, delete-orphan" ) - # Composite index for common queries + # Indexes for common queries __table_args__ = ( Index('ix_access_logs_ip_timestamp', 'ip', 'timestamp'), + Index('ix_access_logs_is_suspicious', 'is_suspicious'), + Index('ix_access_logs_is_honeypot_trigger', 'is_honeypot_trigger'), ) def __repr__(self) -> str: diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 2323843..455833d 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -190,7 +190,7 @@ def generate_dashboard(stats: dict) -> str:
-

🍯 Honeypot Triggers

+

🍯 Honeypot Triggers by IP

{log["ip"]}{log["path"]}{log["user_agent"][:60]}{log["timestamp"].split("T")[1][:8]}
{log["ip"]}{log["path"]}{log["user_agent"][:60]}{format_timestamp(log["timestamp"])}
No suspicious activity detected
{log["ip"]}{log["path"]}{", ".join(log["attack_types"])}{log["user_agent"][:60]}{log["timestamp"].split("T")[1][:8]}
{log["ip"]}{log["path"]}{", ".join(log["attack_types"])}{log["user_agent"][:60]}{format_timestamp(log["timestamp"])}
No attacks detected
{log["ip"]}{log["username"]}{log["password"]}{log["path"]}{log["timestamp"].split("T")[1][:8]}
{log["ip"]}{log["username"]}{log["password"]}{log["path"]}{format_timestamp(log["timestamp"])}
No credentials captured yet
{i+1}{ip}{count}
{i+1}{_escape(ip)}{count}
No data
{i+1}{path}{count}
{i+1}{_escape(path)}{count}
No data
{i+1}{ua[:80]}{count}
{i+1}{_escape(ua[:80])}{count}
No data
{log["ip"]}{log["path"]}{log["user_agent"][:60]}{log["timestamp"].split("T")[1][:8]}
{_escape(log["ip"])}{_escape(log["path"])}{_escape(log["user_agent"][:60])}{_escape(log["timestamp"].split("T")[1][:8])}
No suspicious activity detected
{ip}{", ".join(paths)}{len(paths)}
{_escape(ip)}{_escape(", ".join(paths))}{len(paths)}
No honeypot triggers yet
{log["ip"]}{log["path"]}{", ".join(log["attack_types"])}{log["user_agent"][:60]}{log["timestamp"].split("T")[1][:8]}
{_escape(log["ip"])}{_escape(log["path"])}{_escape(", ".join(log["attack_types"]))}{_escape(log["user_agent"][:60])}{_escape(log["timestamp"].split("T")[1][:8])}
No attacks detected
{log["ip"]}{log["username"]}{log["password"]}{log["path"]}{log["timestamp"].split("T")[1][:8]}
{_escape(log["ip"])}{_escape(log["username"])}{_escape(log["password"])}{_escape(log["path"])}{_escape(log["timestamp"].split("T")[1][:8])}
No credentials captured yet
diff --git a/src/tracker.py b/src/tracker.py index 2d3d34a..4c89c0b 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -276,21 +276,20 @@ class AccessTracker: return [(ip, paths) for ip, paths in self.honeypot_triggered.items()] def get_stats(self) -> Dict: - """Get statistics summary""" - suspicious_count = sum(1 for log in self.access_log if log.get('suspicious', False)) - honeypot_count = sum(1 for log in self.access_log if log.get('honeypot_triggered', False)) - return { - 'total_accesses': len(self.access_log), - 'unique_ips': len(self.ip_counts), - 'unique_paths': len(self.path_counts), - 'suspicious_accesses': suspicious_count, - 'honeypot_triggered': honeypot_count, - 'honeypot_ips': len(self.honeypot_triggered), - 'top_ips': self.get_top_ips(10), - 'top_paths': self.get_top_paths(10), - 'top_user_agents': self.get_top_user_agents(10), - 'recent_suspicious': self.get_suspicious_accesses(20), - 'honeypot_triggered_ips': self.get_honeypot_triggered_ips(), - 'attack_types': self.get_attack_type_accesses(20), - 'credential_attempts': self.credential_attempts[-50:] # Last 50 attempts - } + """Get statistics summary from database.""" + if not self.db: + raise RuntimeError("Database not available for dashboard stats") + + # Get aggregate counts from database + stats = self.db.get_dashboard_counts() + + # Add detailed lists from database + stats['top_ips'] = self.db.get_top_ips(10) + stats['top_paths'] = self.db.get_top_paths(10) + stats['top_user_agents'] = self.db.get_top_user_agents(10) + stats['recent_suspicious'] = self.db.get_recent_suspicious(20) + stats['honeypot_triggered_ips'] = self.db.get_honeypot_triggered_ips() + stats['attack_types'] = self.db.get_recent_attacks(20) + stats['credential_attempts'] = self.db.get_credential_attempts(limit=50) + + return stats diff --git a/tests/test_credentials.sh b/tests/test_credentials.sh index 6379b92..68ee2c0 100755 --- a/tests/test_credentials.sh +++ b/tests/test_credentials.sh @@ -134,9 +134,9 @@ echo -e "${GREEN}✓ All credential tests completed!${NC}" echo -e "${BLUE}========================================${NC}\n" echo -e "${YELLOW}Check the results:${NC}" -echo -e " 1. View the log file: ${GREEN}cat src/logs/credentials.log${NC}" +echo -e " 1. View the log file: ${GREEN}tail -20 logs/credentials.log${NC}" echo -e " 2. View the dashboard: ${GREEN}${BASE_URL}/dashboard${NC}" -echo -e " 3. Check recent logs: ${GREEN}tail -20 src/logs/krawl.log${NC}\n" +echo -e " 3. Check recent logs: ${GREEN}tail -20 logs/access.log ${NC}\n" # Display last 10 credential entries if log file exists if [ -f "src/logs/credentials.log" ]; then From 66b4d8fe6a5b2319670001dff61102793a759a7c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 28 Dec 2025 14:24:52 -0600 Subject: [PATCH 07/70] adding pip and requirements to docker install and exposing data/krawl.db via docker-compose.yaml --- Dockerfile | 4 ++++ docker-compose.yaml | 1 + 2 files changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index adac20f..63d90bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,10 @@ LABEL org.opencontainers.image.source=https://github.com/BlessedRebuS/Krawl WORKDIR /app +# Install Python dependencies +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + COPY src/ /app/src/ COPY wordlists.json /app/ diff --git a/docker-compose.yaml b/docker-compose.yaml index 600034d..7d519ab 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,7 @@ services: - "5000:5000" volumes: - ./wordlists.json:/app/wordlists.json:ro + - ./data:/app/data environment: - PORT=5000 - DELAY=100 From c2c43ac98500d20fb19cf8cf9dedc04f6a08d123 Mon Sep 17 00:00:00 2001 From: Leonardo Bambini Date: Mon, 29 Dec 2025 18:51:37 +0100 Subject: [PATCH 08/70] Added randomized server header and changed behavior of SERVER_HEADER env var --- src/config.py | 2 +- src/generators.py | 13 ++++++++++++- src/handler.py | 4 ++-- src/wordlists.py | 7 ++++++- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/config.py b/src/config.py index 7c6714c..ef78935 100644 --- a/src/config.py +++ b/src/config.py @@ -46,5 +46,5 @@ class Config: api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 5)), - server_header=os.getenv('SERVER_HEADER', 'Apache/2.2.22 (Ubuntu)') + server_header=os.getenv('SERVER_HEADER') ) diff --git a/src/generators.py b/src/generators.py index 16c0c32..6e24ba8 100644 --- a/src/generators.py +++ b/src/generators.py @@ -9,7 +9,8 @@ import string import json from templates import html_templates from wordlists import get_wordlists - +from config import Config +from logger import get_app_logger def random_username() -> str: """Generate random username""" @@ -36,6 +37,16 @@ def random_email(username: str = None) -> str: username = random_username() return f"{username}@{random.choice(wl.email_domains)}" +def random_server_header() -> str: + """Generate random server header""" + + if Config.from_env().server_header: + server_header = Config.from_env().server_header + else: + wl = get_wordlists() + server_header = random.choice(wl.server_headers) + + return server_header def random_api_key() -> str: """Generate random API key""" diff --git a/src/handler.py b/src/handler.py index ac7ca22..7c44726 100644 --- a/src/handler.py +++ b/src/handler.py @@ -13,7 +13,7 @@ from templates import html_templates from templates.dashboard_template import generate_dashboard from generators import ( credentials_txt, passwords_txt, users_json, api_keys_json, - api_response, directory_listing + api_response, directory_listing, random_server_header ) from wordlists import get_wordlists @@ -52,7 +52,7 @@ class Handler(BaseHTTPRequestHandler): def version_string(self) -> str: """Return custom server version for deception.""" - return self.config.server_header + return random_server_header() def _should_return_error(self) -> bool: """Check if we should return an error based on probability""" diff --git a/src/wordlists.py b/src/wordlists.py index 62e4045..342930a 100644 --- a/src/wordlists.py +++ b/src/wordlists.py @@ -57,7 +57,8 @@ class Wordlists: }, "users": { "roles": ["Administrator", "User"] - } + }, + "server_headers": ["Apache/2.4.41 (Ubuntu)", "nginx/1.18.0"] } @property @@ -111,6 +112,10 @@ class Wordlists: @property def error_codes(self): return self._data.get("error_codes", []) + + @property + def server_headers(self): + return self._data.get("server_headers", []) _wordlists_instance = None From a9808599dc3870f66241111fcfdd6defbd4da42f Mon Sep 17 00:00:00 2001 From: Leonardo Bambini Date: Mon, 29 Dec 2025 18:55:44 +0100 Subject: [PATCH 09/70] Added random server header and changed behavior of SERVER_HEADER env var --- README.md | 2 +- wordlists.json | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b84d955..0d3efe7 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ To customize the deception server installation several **environment variables** | `CANARY_TOKEN_URL` | External canary token URL | None | | `DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | -| `SERVER_HEADER` | HTTP Server header for deception | `Apache/2.2.22 (Ubuntu)` | +| `SERVER_HEADER` | HTTP Server header for deception, if not set use random server header | | ## robots.txt The actual (juicy) robots.txt configuration is the following diff --git a/wordlists.json b/wordlists.json index f1aae81..fddf3d3 100644 --- a/wordlists.json +++ b/wordlists.json @@ -193,5 +193,13 @@ 500, 502, 503 + ], + "server_headers": [ + "Apache/2.4.41 (Ubuntu)", + "nginx/1.18.0", + "Microsoft-IIS/10.0", + "cloudflare", + "AmazonS3", + "gunicorn/20.1.0" ] } From 06ffa2c480f9991ea391913be918bc59a65e636f Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Mon, 29 Dec 2025 23:57:37 +0100 Subject: [PATCH 10/70] Added wordlists and server header logic to helm --- docker-compose.yaml | 2 +- helm/templates/configmap.yaml | 7 ++++++- helm/values.yaml | 14 +++++++++++++- src/config.py | 2 +- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 1612864..7026f11 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,7 +20,7 @@ services: - MAX_COUNTER=10 - CANARY_TOKEN_TRIES=10 - PROBABILITY_ERROR_CODES=0 - - SERVER_HEADER=Apache/2.2.22 (Ubuntu) + # - SERVER_HEADER=Apache/2.2.22 (Ubuntu) # Optional: Set your canary token URL # - CANARY_TOKEN_URL=http://canarytokens.com/api/users/YOUR_TOKEN/passwords.txt # Optional: Set custom dashboard path (auto-generated if not set) diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index c50ab75..fb590b0 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -14,5 +14,10 @@ data: MAX_COUNTER: {{ .Values.config.maxCounter | quote }} CANARY_TOKEN_TRIES: {{ .Values.config.canaryTokenTries | quote }} PROBABILITY_ERROR_CODES: {{ .Values.config.probabilityErrorCodes | quote }} - SERVER_HEADER: {{ .Values.config.serverHeader | quote }} CANARY_TOKEN_URL: {{ .Values.config.canaryTokenUrl | quote }} + {{- if .Values.config.dashboardSecretPath }} + DASHBOARD_SECRET_PATH: {{ .Values.config.dashboardSecretPath | quote }} + {{- end }} + {{- if .Values.config.serverHeader }} + SERVER_HEADER: {{ .Values.config.serverHeader | quote }} + {{- end }} \ No newline at end of file diff --git a/helm/values.yaml b/helm/values.yaml index a095632..217e9a6 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -73,7 +73,8 @@ config: maxCounter: 10 canaryTokenTries: 10 probabilityErrorCodes: 0 - serverHeader: "Apache/2.2.22 (Ubuntu)" +# serverHeader: "Apache/2.2.22 (Ubuntu)" +# dashboardSecretPath: "/my-secret-dashboard" # canaryTokenUrl: set-your-canary-token-url-here networkPolicy: @@ -268,6 +269,17 @@ wordlists: - .git/ - keys/ - credentials/ + server_headers: + - Apache/2.2.22 (Ubuntu) + - nginx/1.18.0 + - Microsoft-IIS/10.0 + - LiteSpeed + - Caddy + - Gunicorn/20.0.4 + - uvicorn/0.13.4 + - Express + - Flask/1.1.2 + - Django/3.1 error_codes: - 400 - 401 diff --git a/src/config.py b/src/config.py index ef78935..3fc5dd8 100644 --- a/src/config.py +++ b/src/config.py @@ -21,7 +21,7 @@ class Config: api_server_port: int = 8080 api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) - server_header: str = "Apache/2.2.22 (Ubuntu)" + server_header: Optional[str] = None @classmethod def from_env(cls) -> 'Config': From cddad984c3b556cbc990bd989f7f2295eb404394 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Tue, 30 Dec 2025 00:03:44 +0100 Subject: [PATCH 11/70] Added timezone to helm values --- helm/values.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/helm/values.yaml b/helm/values.yaml index dc18d4a..8a6bc1d 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -73,6 +73,7 @@ config: maxCounter: 10 canaryTokenTries: 10 probabilityErrorCodes: 0 +# timezone: "UTC" # serverHeader: "Apache/2.2.22 (Ubuntu)" # dashboardSecretPath: "/my-secret-dashboard" # canaryTokenUrl: set-your-canary-token-url-here From 354f8bf8954e76faefbfa7750d2b3537ee7d3443 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio <50186694+BlessedRebuS@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:24:36 +0100 Subject: [PATCH 12/70] Fix indentation for server_header in config.py --- src/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.py b/src/config.py index f3bf7f0..87fca1c 100644 --- a/src/config.py +++ b/src/config.py @@ -85,7 +85,7 @@ class Config: api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 0)), - server_header=os.getenv('SERVER_HEADER') + server_header=os.getenv('SERVER_HEADER'), database_path=os.getenv('DATABASE_PATH', 'data/krawl.db'), database_retention_days=int(os.getenv('DATABASE_RETENTION_DAYS', 30)), timezone=os.getenv('TIMEZONE') # If not set, will use system timezone From c55b1375adbd6f21fa1712f9bd9c05026fa34207 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Tue, 30 Dec 2025 12:12:42 +0100 Subject: [PATCH 13/70] added db config for kubernetes and helm --- helm/templates/configmap.yaml | 3 +++ helm/templates/deployment.yaml | 14 ++++++++++++++ helm/templates/pvc.yaml | 17 +++++++++++++++++ helm/values.yaml | 18 ++++++++++++++++++ kubernetes/krawl-all-in-one-deploy.yaml | 22 ++++++++++++++++++++++ kubernetes/manifests/configmap.yaml | 5 ++++- kubernetes/manifests/deployment.yaml | 5 +++++ kubernetes/manifests/kustomization.yaml | 1 + kubernetes/manifests/pvc.yaml | 13 +++++++++++++ 9 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 helm/templates/pvc.yaml create mode 100644 kubernetes/manifests/pvc.yaml diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 2990f61..17cd952 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -24,3 +24,6 @@ data: {{- if .Values.config.timezone }} TIMEZONE: {{ .Values.config.timezone | quote }} {{- end }} + # Database configuration + DATABASE_PATH: {{ .Values.database.path | quote }} + DATABASE_RETENTION_DAYS: {{ .Values.database.retentionDays | quote }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index b0aeb6d..ecc9655 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -54,6 +54,10 @@ spec: mountPath: /app/wordlists.json subPath: wordlists.json readOnly: true + {{- if .Values.database.persistence.enabled }} + - name: database + mountPath: /app/data + {{- end }} {{- with .Values.resources }} resources: {{- toYaml . | nindent 12 }} @@ -62,6 +66,16 @@ spec: - name: wordlists configMap: name: {{ include "krawl.fullname" . }}-wordlists + {{- if .Values.database.persistence.enabled }} + - name: database + {{- if .Values.database.persistence.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.database.persistence.existingClaim }} + {{- else }} + persistentVolumeClaim: + claimName: {{ include "krawl.fullname" . }}-db + {{- end }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/helm/templates/pvc.yaml b/helm/templates/pvc.yaml new file mode 100644 index 0000000..ec73af2 --- /dev/null +++ b/helm/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.database.persistence.enabled (not .Values.database.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "krawl.fullname" . }}-db + labels: + {{- include "krawl.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.database.persistence.accessMode }} + {{- if .Values.database.persistence.storageClassName }} + storageClassName: {{ .Values.database.persistence.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.database.persistence.size }} +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 8a6bc1d..c92bc0b 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -79,6 +79,24 @@ config: # canaryTokenUrl: set-your-canary-token-url-here # timezone: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. +# Database configuration +database: + # Path to the SQLite database file + path: "data/krawl.db" + # Number of days to retain access logs and attack data + retentionDays: 30 + # Persistence configuration + persistence: + enabled: true + # Storage class name (use default if not specified) + # storageClassName: "" + # Access mode for the persistent volume + accessMode: ReadWriteOnce + # Size of the persistent volume + size: 1Gi + # Optional: Use existing PVC + # existingClaim: "" + networkPolicy: enabled: true policyTypes: diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index 0362220..d1a026c 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -20,6 +20,9 @@ data: CANARY_TOKEN_TRIES: "10" PROBABILITY_ERROR_CODES: "0" # CANARY_TOKEN_URL: set-your-canary-token-url-here + # Database configuration + DATABASE_PATH: "data/krawl.db" + DATABASE_RETENTION_DAYS: "30" --- apiVersion: v1 kind: ConfigMap @@ -227,6 +230,20 @@ data: ] } --- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: krawl-db + namespace: krawl-system + labels: + app: krawl-server +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- apiVersion: apps/v1 kind: Deployment metadata: @@ -260,6 +277,8 @@ spec: mountPath: /app/wordlists.json subPath: wordlists.json readOnly: true + - name: database + mountPath: /app/data resources: requests: memory: "64Mi" @@ -271,6 +290,9 @@ spec: - name: wordlists configMap: name: krawl-wordlists + - name: database + persistentVolumeClaim: + claimName: krawl-db --- apiVersion: v1 kind: Service diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index 073005f..ef357b0 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -15,4 +15,7 @@ data: PROBABILITY_ERROR_CODES: "0" SERVER_HEADER: "Apache/2.2.22 (Ubuntu)" # CANARY_TOKEN_URL: set-your-canary-token-url-here -# TIMEZONE: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome") \ No newline at end of file +# TIMEZONE: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome") + # Database configuration + DATABASE_PATH: "data/krawl.db" + DATABASE_RETENTION_DAYS: "30" \ No newline at end of file diff --git a/kubernetes/manifests/deployment.yaml b/kubernetes/manifests/deployment.yaml index 0552eba..1650721 100644 --- a/kubernetes/manifests/deployment.yaml +++ b/kubernetes/manifests/deployment.yaml @@ -31,6 +31,8 @@ spec: mountPath: /app/wordlists.json subPath: wordlists.json readOnly: true + - name: database + mountPath: /app/data resources: requests: memory: "64Mi" @@ -42,3 +44,6 @@ spec: - name: wordlists configMap: name: krawl-wordlists + - name: database + persistentVolumeClaim: + claimName: krawl-db diff --git a/kubernetes/manifests/kustomization.yaml b/kubernetes/manifests/kustomization.yaml index 8f41776..4a5fcd9 100644 --- a/kubernetes/manifests/kustomization.yaml +++ b/kubernetes/manifests/kustomization.yaml @@ -5,6 +5,7 @@ resources: - namespace.yaml - configmap.yaml - wordlists-configmap.yaml + - pvc.yaml - deployment.yaml - service.yaml - network-policy.yaml diff --git a/kubernetes/manifests/pvc.yaml b/kubernetes/manifests/pvc.yaml new file mode 100644 index 0000000..6b771ff --- /dev/null +++ b/kubernetes/manifests/pvc.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: krawl-db + namespace: krawl-system + labels: + app: krawl-server +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi From d458eb471db47ffae2ce6b72ff15228c790017e8 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 2 Jan 2026 13:39:54 -0600 Subject: [PATCH 14/70] Migrate configuration from environment variables to YAML file - Add YAML-based configuration loaded from config.yaml (CONFIG_LOCATION env var) - Add PyYAML dependency and install requirements in Dockerfile - Replace Config.from_env() with get_config() singleton pattern - Remove server_header from config (now randomized from wordlists only) - Update docker-compose.yaml to mount config.yaml read-only - Update Helm chart: restructure values.yaml, generate config.yaml in ConfigMap - Update Kubernetes manifests: ConfigMap now contains config.yaml, deployments mount it - Remove Helm secret.yaml (dashboard path now auto-generated in config.yaml) --- Dockerfile | 3 + config.yaml | 35 +++++++++ docker-compose.yaml | 18 +---- helm/templates/configmap.yaml | 49 ++++++------ helm/templates/deployment.yaml | 19 ++--- helm/templates/secret.yaml | 16 ---- helm/values.yaml | 47 +++++++----- kubernetes/krawl-all-in-one-deploy.yaml | 71 +++++++++++++----- kubernetes/manifests/configmap.yaml | 50 +++++++++---- kubernetes/manifests/deployment.yaml | 13 +++- requirements.txt | 3 + src/config.py | 99 ++++++++++++++++++------- src/generators.py | 14 +--- src/server.py | 51 +++++++------ 14 files changed, 307 insertions(+), 181 deletions(-) create mode 100644 config.yaml delete mode 100644 helm/templates/secret.yaml diff --git a/Dockerfile b/Dockerfile index adac20f..e0fb6af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ LABEL org.opencontainers.image.source=https://github.com/BlessedRebuS/Krawl WORKDIR /app +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + COPY src/ /app/src/ COPY wordlists.json /app/ diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..c4faa8f --- /dev/null +++ b/config.yaml @@ -0,0 +1,35 @@ +# Krawl Honeypot Configuration + +server: + port: 5000 + delay: 100 # Response delay in milliseconds + timezone: null # e.g., "America/New_York" or null for system default + +links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + +canary: + token_url: null # Optional canary token URL + token_tries: 10 + +dashboard: + # if set to "null" this will Auto-generates random path if not set + # can be set to "dashboard" or similar + secret_path: dashboard + +api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + +database: + path: "data/krawl.db" + retention_days: 30 + +behavior: + probability_error_codes: 0 # 0-100 percentage \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 6f81a47..776e919 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,23 +10,9 @@ services: - "5000:5000" volumes: - ./wordlists.json:/app/wordlists.json:ro + - ./config.yaml:/app/config.yaml:ro environment: - - PORT=5000 - - DELAY=100 - - LINKS_MIN_LENGTH=5 - - LINKS_MAX_LENGTH=15 - - LINKS_MIN_PER_PAGE=10 - - LINKS_MAX_PER_PAGE=15 - - MAX_COUNTER=10 - - CANARY_TOKEN_TRIES=10 - - PROBABILITY_ERROR_CODES=0 - # - SERVER_HEADER=Apache/2.2.22 (Ubuntu) - # Optional: Set your canary token URL - # - CANARY_TOKEN_URL=http://canarytokens.com/api/users/YOUR_TOKEN/passwords.txt - # Optional: Set custom dashboard path (auto-generated if not set) - # - DASHBOARD_SECRET_PATH=/my-secret-dashboard - # Optional: Set timezone for logs and dashboard (e.g., America/New_York, Europe/Rome) - # - TIMEZONE=UTC + - CONFIG_LOCATION=config.yaml restart: unless-stopped healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:5000')"] diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 17cd952..808d9f5 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -5,25 +5,30 @@ metadata: labels: {{- include "krawl.labels" . | nindent 4 }} data: - PORT: {{ .Values.config.port | quote }} - DELAY: {{ .Values.config.delay | quote }} - LINKS_MIN_LENGTH: {{ .Values.config.linksMinLength | quote }} - LINKS_MAX_LENGTH: {{ .Values.config.linksMaxLength | quote }} - LINKS_MIN_PER_PAGE: {{ .Values.config.linksMinPerPage | quote }} - LINKS_MAX_PER_PAGE: {{ .Values.config.linksMaxPerPage | quote }} - MAX_COUNTER: {{ .Values.config.maxCounter | quote }} - CANARY_TOKEN_TRIES: {{ .Values.config.canaryTokenTries | quote }} - PROBABILITY_ERROR_CODES: {{ .Values.config.probabilityErrorCodes | quote }} - CANARY_TOKEN_URL: {{ .Values.config.canaryTokenUrl | quote }} - {{- if .Values.config.dashboardSecretPath }} - DASHBOARD_SECRET_PATH: {{ .Values.config.dashboardSecretPath | quote }} - {{- end }} - {{- if .Values.config.serverHeader }} - SERVER_HEADER: {{ .Values.config.serverHeader | quote }} - {{- end }} - {{- if .Values.config.timezone }} - TIMEZONE: {{ .Values.config.timezone | quote }} - {{- end }} - # Database configuration - DATABASE_PATH: {{ .Values.database.path | quote }} - DATABASE_RETENTION_DAYS: {{ .Values.database.retentionDays | quote }} + config.yaml: | + # Krawl Honeypot Configuration + server: + port: {{ .Values.config.server.port }} + delay: {{ .Values.config.server.delay }} + timezone: {{ .Values.config.server.timezone | toYaml }} + links: + min_length: {{ .Values.config.links.min_length }} + max_length: {{ .Values.config.links.max_length }} + min_per_page: {{ .Values.config.links.min_per_page }} + max_per_page: {{ .Values.config.links.max_per_page }} + char_space: {{ .Values.config.links.char_space | quote }} + max_counter: {{ .Values.config.links.max_counter }} + canary: + token_url: {{ .Values.config.canary.token_url | toYaml }} + token_tries: {{ .Values.config.canary.token_tries }} + dashboard: + secret_path: {{ .Values.config.dashboard.secret_path | toYaml }} + api: + server_url: {{ .Values.config.api.server_url | toYaml }} + server_port: {{ .Values.config.api.server_port }} + server_path: {{ .Values.config.api.server_path | quote }} + database: + path: {{ .Values.config.database.path | quote }} + retention_days: {{ .Values.config.database.retention_days }} + behavior: + probability_error_codes: {{ .Values.config.behavior.probability_error_codes }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index ecc9655..5635fa3 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -38,18 +38,16 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http - containerPort: {{ .Values.config.port }} + containerPort: {{ .Values.config.server.port }} protocol: TCP - envFrom: - - configMapRef: - name: {{ include "krawl.fullname" . }}-config env: - - name: DASHBOARD_SECRET_PATH - valueFrom: - secretKeyRef: - name: {{ include "krawl.fullname" . }} - key: dashboard-path + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -63,6 +61,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumes: + - name: config + configMap: + name: {{ include "krawl.fullname" . }}-config - name: wordlists configMap: name: {{ include "krawl.fullname" . }}-wordlists diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml deleted file mode 100644 index 798289c..0000000 --- a/helm/templates/secret.yaml +++ /dev/null @@ -1,16 +0,0 @@ -{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "krawl.fullname" .)) -}} -{{- $dashboardPath := "" -}} -{{- if and $secret $secret.data -}} - {{- $dashboardPath = index $secret.data "dashboard-path" | b64dec -}} -{{- else -}} - {{- $dashboardPath = printf "/%s" (randAlphaNum 32) -}} -{{- end -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "krawl.fullname" . }} - labels: - {{- include "krawl.labels" . | nindent 4 }} -type: Opaque -stringData: - dashboard-path: {{ $dashboardPath | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index c92bc0b..60b1a66 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -62,29 +62,36 @@ tolerations: [] affinity: {} -# Application configuration +# Application configuration (config.yaml structure) config: - port: 5000 - delay: 100 - linksMinLength: 5 - linksMaxLength: 15 - linksMinPerPage: 10 - linksMaxPerPage: 15 - maxCounter: 10 - canaryTokenTries: 10 - probabilityErrorCodes: 0 -# timezone: "UTC" -# serverHeader: "Apache/2.2.22 (Ubuntu)" -# dashboardSecretPath: "/my-secret-dashboard" -# canaryTokenUrl: set-your-canary-token-url-here -# timezone: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. + server: + port: 5000 + delay: 100 + timezone: null # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + canary: + token_url: null # Set your canary token URL here + token_tries: 10 + dashboard: + secret_path: null # Auto-generated if not set, or set to "/my-secret-dashboard" + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + database: + path: "data/krawl.db" + retention_days: 30 + behavior: + probability_error_codes: 0 -# Database configuration +# Database persistence configuration database: - # Path to the SQLite database file - path: "data/krawl.db" - # Number of days to retain access logs and attack data - retentionDays: 30 # Persistence configuration persistence: enabled: true diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index d1a026c..3344260 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -10,19 +10,41 @@ metadata: name: krawl-config namespace: krawl-system data: - PORT: "5000" - DELAY: "100" - LINKS_MIN_LENGTH: "5" - LINKS_MAX_LENGTH: "15" - LINKS_MIN_PER_PAGE: "10" - LINKS_MAX_PER_PAGE: "15" - MAX_COUNTER: "10" - CANARY_TOKEN_TRIES: "10" - PROBABILITY_ERROR_CODES: "0" -# CANARY_TOKEN_URL: set-your-canary-token-url-here - # Database configuration - DATABASE_PATH: "data/krawl.db" - DATABASE_RETENTION_DAYS: "30" + config.yaml: | + # Krawl Honeypot Configuration + server: + port: 5000 + delay: 100 + timezone: null # e.g., "America/New_York" or null for system default + + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + + canary: + token_url: null # Optional canary token URL + token_tries: 10 + + dashboard: + # Auto-generates random path if null + # Can be set to "/dashboard" or similar + secret_path: null + + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + + database: + path: "data/krawl.db" + retention_days: 30 + + behavior: + probability_error_codes: 0 # 0-100 percentage --- apiVersion: v1 kind: ConfigMap @@ -227,6 +249,14 @@ data: 500, 502, 503 + ], + "server_headers": [ + "Apache/2.4.41 (Ubuntu)", + "nginx/1.18.0", + "Microsoft-IIS/10.0", + "cloudflare", + "AmazonS3", + "gunicorn/20.1.0" ] } --- @@ -269,10 +299,14 @@ spec: - containerPort: 5000 name: http protocol: TCP - envFrom: - - configMapRef: - name: krawl-config + env: + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -287,6 +321,9 @@ spec: memory: "256Mi" cpu: "500m" volumes: + - name: config + configMap: + name: krawl-config - name: wordlists configMap: name: krawl-wordlists @@ -353,7 +390,7 @@ spec: - podSelector: {} - namespaceSelector: {} - ipBlock: - cidr: 0.0.0.0/0 + cidr: 0.0.0.0/0 ports: - protocol: TCP port: 5000 diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index ef357b0..38a287b 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -4,18 +4,38 @@ metadata: name: krawl-config namespace: krawl-system data: - PORT: "5000" - DELAY: "100" - LINKS_MIN_LENGTH: "5" - LINKS_MAX_LENGTH: "15" - LINKS_MIN_PER_PAGE: "10" - LINKS_MAX_PER_PAGE: "15" - MAX_COUNTER: "10" - CANARY_TOKEN_TRIES: "10" - PROBABILITY_ERROR_CODES: "0" - SERVER_HEADER: "Apache/2.2.22 (Ubuntu)" -# CANARY_TOKEN_URL: set-your-canary-token-url-here -# TIMEZONE: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome") - # Database configuration - DATABASE_PATH: "data/krawl.db" - DATABASE_RETENTION_DAYS: "30" \ No newline at end of file + config.yaml: | + # Krawl Honeypot Configuration + server: + port: 5000 + delay: 100 + timezone: null # e.g., "America/New_York" or null for system default + + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + + canary: + token_url: null # Optional canary token URL + token_tries: 10 + + dashboard: + # Auto-generates random path if null + # Can be set to "/dashboard" or similar + secret_path: null + + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + + database: + path: "data/krawl.db" + retention_days: 30 + + behavior: + probability_error_codes: 0 # 0-100 percentage diff --git a/kubernetes/manifests/deployment.yaml b/kubernetes/manifests/deployment.yaml index 1650721..f970625 100644 --- a/kubernetes/manifests/deployment.yaml +++ b/kubernetes/manifests/deployment.yaml @@ -23,10 +23,14 @@ spec: - containerPort: 5000 name: http protocol: TCP - envFrom: - - configMapRef: - name: krawl-config + env: + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -41,6 +45,9 @@ spec: memory: "256Mi" cpu: "500m" volumes: + - name: config + configMap: + name: krawl-config - name: wordlists configMap: name: krawl-wordlists diff --git a/requirements.txt b/requirements.txt index 94f74f2..8cb6dc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ # Krawl Honeypot Dependencies # Install with: pip install -r requirements.txt +# Configuration +PyYAML>=6.0 + # Database ORM SQLAlchemy>=2.0.0,<3.0.0 diff --git a/src/config.py b/src/config.py index 87fca1c..fb679b4 100644 --- a/src/config.py +++ b/src/config.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 import os +import sys from dataclasses import dataclass +from pathlib import Path from typing import Optional, Tuple from zoneinfo import ZoneInfo import time +import yaml + @dataclass class Config: @@ -23,12 +27,11 @@ class Config: api_server_port: int = 8080 api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) - server_header: Optional[str] = None # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 timezone: str = None # IANA timezone (e.g., 'America/New_York', 'Europe/Rome') - + @staticmethod # Try to fetch timezone before if not set def get_system_timezone() -> str: @@ -38,16 +41,16 @@ class Config: tz_path = os.readlink('/etc/localtime') if 'zoneinfo/' in tz_path: return tz_path.split('zoneinfo/')[-1] - + local_tz = time.tzname[time.daylight] if local_tz and local_tz != 'UTC': return local_tz except Exception: pass - + # Default fallback to UTC return 'UTC' - + def get_timezone(self) -> ZoneInfo: """Get configured timezone as ZoneInfo object""" if self.timezone: @@ -55,7 +58,7 @@ class Config: return ZoneInfo(self.timezone) except Exception: pass - + system_tz = self.get_system_timezone() try: return ZoneInfo(system_tz) @@ -63,31 +66,71 @@ class Config: return ZoneInfo('UTC') @classmethod - def from_env(cls) -> 'Config': - """Create configuration from environment variables""" + def from_yaml(cls) -> 'Config': + """Create configuration from YAML file""" + config_location = os.getenv('CONFIG_LOCATION', 'config.yaml') + config_path = Path(__file__).parent.parent / config_location + + try: + with open(config_path, 'r') as f: + data = yaml.safe_load(f) + except FileNotFoundError: + print(f"Error: Configuration file '{config_path}' not found.", file=sys.stderr) + print(f"Please create a config.yaml file or set CONFIG_LOCATION environment variable.", file=sys.stderr) + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error: Invalid YAML in configuration file '{config_path}': {e}", file=sys.stderr) + sys.exit(1) + + if data is None: + data = {} + + # Extract nested values with defaults + server = data.get('server', {}) + links = data.get('links', {}) + canary = data.get('canary', {}) + dashboard = data.get('dashboard', {}) + api = data.get('api', {}) + database = data.get('database', {}) + behavior = data.get('behavior', {}) + + # Handle dashboard_secret_path - auto-generate if null/not set + dashboard_path = dashboard.get('secret_path') + if dashboard_path is None: + dashboard_path = f'/{os.urandom(16).hex()}' + return cls( - port=int(os.getenv('PORT', 5000)), - delay=int(os.getenv('DELAY', 100)), + port=server.get('port', 5000), + delay=server.get('delay', 100), + timezone=server.get('timezone'), links_length_range=( - int(os.getenv('LINKS_MIN_LENGTH', 5)), - int(os.getenv('LINKS_MAX_LENGTH', 15)) + links.get('min_length', 5), + links.get('max_length', 15) ), links_per_page_range=( - int(os.getenv('LINKS_MIN_PER_PAGE', 10)), - int(os.getenv('LINKS_MAX_PER_PAGE', 15)) + links.get('min_per_page', 10), + links.get('max_per_page', 15) ), - char_space=os.getenv('CHAR_SPACE', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), - max_counter=int(os.getenv('MAX_COUNTER', 10)), - canary_token_url=os.getenv('CANARY_TOKEN_URL'), - canary_token_tries=int(os.getenv('CANARY_TOKEN_TRIES', 10)), - dashboard_secret_path=os.getenv('DASHBOARD_SECRET_PATH', f'/{os.urandom(16).hex()}'), - api_server_url=os.getenv('API_SERVER_URL'), - api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), - api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), - probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 0)), - server_header=os.getenv('SERVER_HEADER'), - database_path=os.getenv('DATABASE_PATH', 'data/krawl.db'), - database_retention_days=int(os.getenv('DATABASE_RETENTION_DAYS', 30)), - timezone=os.getenv('TIMEZONE') # If not set, will use system timezone - + char_space=links.get('char_space', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), + max_counter=links.get('max_counter', 10), + canary_token_url=canary.get('token_url'), + canary_token_tries=canary.get('token_tries', 10), + dashboard_secret_path=dashboard_path, + api_server_url=api.get('server_url'), + api_server_port=api.get('server_port', 8080), + api_server_path=api.get('server_path', '/api/v2/users'), + probability_error_codes=behavior.get('probability_error_codes', 0), + database_path=database.get('path', 'data/krawl.db'), + database_retention_days=database.get('retention_days', 30), ) + + +_config_instance = None + + +def get_config() -> Config: + """Get the singleton Config instance""" + global _config_instance + if _config_instance is None: + _config_instance = Config.from_yaml() + return _config_instance diff --git a/src/generators.py b/src/generators.py index 6e24ba8..6eca9fd 100644 --- a/src/generators.py +++ b/src/generators.py @@ -9,8 +9,6 @@ import string import json from templates import html_templates from wordlists import get_wordlists -from config import Config -from logger import get_app_logger def random_username() -> str: """Generate random username""" @@ -38,15 +36,9 @@ def random_email(username: str = None) -> str: return f"{username}@{random.choice(wl.email_domains)}" def random_server_header() -> str: - """Generate random server header""" - - if Config.from_env().server_header: - server_header = Config.from_env().server_header - else: - wl = get_wordlists() - server_header = random.choice(wl.server_headers) - - return server_header + """Generate random server header from wordlists""" + wl = get_wordlists() + return random.choice(wl.server_headers) def random_api_key() -> str: """Generate random API key""" diff --git a/src/server.py b/src/server.py index 06b7c82..7a59c73 100644 --- a/src/server.py +++ b/src/server.py @@ -8,7 +8,7 @@ Run this file to start the server. import sys from http.server import HTTPServer -from config import Config +from config import get_config from tracker import AccessTracker from handler import Handler from logger import initialize_logging, get_app_logger, get_access_logger, get_credential_logger @@ -20,24 +20,29 @@ def print_usage(): print(f'Usage: {sys.argv[0]} [FILE]\n') print('FILE is file containing a list of webpage names to serve, one per line.') print('If no file is provided, random links will be generated.\n') - print('Environment Variables:') - print(' PORT - Server port (default: 5000)') - print(' DELAY - Response delay in ms (default: 100)') - print(' LINKS_MIN_LENGTH - Min link length (default: 5)') - print(' LINKS_MAX_LENGTH - Max link length (default: 15)') - print(' LINKS_MIN_PER_PAGE - Min links per page (default: 10)') - print(' LINKS_MAX_PER_PAGE - Max links per page (default: 15)') - print(' MAX_COUNTER - Max counter value (default: 10)') - print(' CANARY_TOKEN_URL - Canary token URL to display') - print(' CANARY_TOKEN_TRIES - Number of tries before showing token (default: 10)') - print(' DASHBOARD_SECRET_PATH - Secret path for dashboard (auto-generated if not set)') - print(' PROBABILITY_ERROR_CODES - Probability (0-100) to return HTTP error codes (default: 0)') - print(' CHAR_SPACE - Characters for random links') - print(' SERVER_HEADER - HTTP Server header for deception (default: Apache/2.2.22 (Ubuntu))') - print(' DATABASE_PATH - Path to SQLite database (default: data/krawl.db)') - print(' DATABASE_RETENTION_DAYS - Days to retain database records (default: 30)') - print(' TIMEZONE - IANA timezone for logs/dashboard (e.g., America/New_York, Europe/Rome)') - print(' If not set, system timezone will be used') + print('Configuration:') + print(' Configuration is loaded from a YAML file (default: config.yaml)') + print(' Set CONFIG_LOCATION environment variable to use a different file.\n') + print(' Example config.yaml structure:') + print(' server:') + print(' port: 5000') + print(' delay: 100') + print(' timezone: null # or "America/New_York"') + print(' links:') + print(' min_length: 5') + print(' max_length: 15') + print(' min_per_page: 10') + print(' max_per_page: 15') + print(' canary:') + print(' token_url: null') + print(' token_tries: 10') + print(' dashboard:') + print(' secret_path: null # auto-generated if not set') + print(' database:') + print(' path: "data/krawl.db"') + print(' retention_days: 30') + print(' behavior:') + print(' probability_error_codes: 0') def main(): @@ -46,19 +51,17 @@ def main(): print_usage() exit(0) - config = Config.from_env() - + config = get_config() + # Get timezone configuration tz = config.get_timezone() - + # Initialize logging with timezone initialize_logging(timezone=tz) app_logger = get_app_logger() access_logger = get_access_logger() credential_logger = get_credential_logger() - config = Config.from_env() - # Initialize database for persistent storage try: initialize_database(config.database_path) From 349c14933529cd1fd24a0bfebd31f99e0425c3cc Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 2 Jan 2026 13:52:51 -0600 Subject: [PATCH 15/70] Add logs directory bind mount with entrypoint permission fix - Add ./logs:/app/logs volume mount to docker-compose.yaml for log access - Create entrypoint.sh script that fixes directory ownership at startup - Install gosu in Dockerfile for secure privilege dropping - Use ENTRYPOINT to run permission fix as root, then drop to krawl user This ensures bind-mounted directories have correct permissions even when Docker creates them as root on the host. --- Dockerfile | 12 +++++++++--- docker-compose.yaml | 1 + entrypoint.sh | 8 ++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index e0fb6af..2c7b954 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,19 +4,25 @@ LABEL org.opencontainers.image.source=https://github.com/BlessedRebuS/Krawl WORKDIR /app +# Install gosu for dropping privileges +RUN apt-get update && apt-get install -y --no-install-recommends gosu && \ + rm -rf /var/lib/apt/lists/* + COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt COPY src/ /app/src/ COPY wordlists.json /app/ +COPY entrypoint.sh /app/ RUN useradd -m -u 1000 krawl && \ - chown -R krawl:krawl /app - -USER krawl + mkdir -p /app/logs /app/data && \ + chown -R krawl:krawl /app && \ + chmod +x /app/entrypoint.sh EXPOSE 5000 ENV PYTHONUNBUFFERED=1 +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["python3", "src/server.py"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 776e919..02b6ae7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,6 +11,7 @@ services: volumes: - ./wordlists.json:/app/wordlists.json:ro - ./config.yaml:/app/config.yaml:ro + - ./logs:/app/logs environment: - CONFIG_LOCATION=config.yaml restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..28b5fc0 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Fix ownership of mounted directories +chown -R krawl:krawl /app/logs /app/data 2>/dev/null || true + +# Drop to krawl user and run the application +exec gosu krawl "$@" From 4c490e30cb75b73ae8eaa17a39df7c354608332d Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sat, 3 Jan 2026 13:56:16 -0600 Subject: [PATCH 16/70] fixing dashboard to ensure starts with forward slash, put back the server_header option to allow pinning --- config.yaml | 5 ++++- src/config.py | 9 ++++++++- src/generators.py | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index c4faa8f..f9825a0 100644 --- a/config.yaml +++ b/config.yaml @@ -5,6 +5,9 @@ server: delay: 100 # Response delay in milliseconds timezone: null # e.g., "America/New_York" or null for system default + # manually set the server header, if null a random one will be used. + server_header: "Apache/2.2.22 (Ubuntu)" + links: min_length: 5 max_length: 15 @@ -19,7 +22,7 @@ canary: dashboard: # if set to "null" this will Auto-generates random path if not set - # can be set to "dashboard" or similar + # can be set to "/dashboard" or similar <-- note this MUST include a forward slash secret_path: dashboard api: diff --git a/src/config.py b/src/config.py index fb679b4..d8aa2f2 100644 --- a/src/config.py +++ b/src/config.py @@ -16,6 +16,7 @@ class Config: """Configuration class for the deception server""" port: int = 5000 delay: int = 100 # milliseconds + server_header: str = "" links_length_range: Tuple[int, int] = (5, 15) links_per_page_range: Tuple[int, int] = (10, 15) char_space: str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' @@ -27,6 +28,7 @@ class Config: api_server_port: int = 8080 api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) + # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 @@ -98,10 +100,15 @@ class Config: dashboard_path = dashboard.get('secret_path') if dashboard_path is None: dashboard_path = f'/{os.urandom(16).hex()}' - + else: + # ensure the dashboard path starts with a / + if dashboard_path[:1] != "/": + dashboard_path = f"/{dashboard_path}" + return cls( port=server.get('port', 5000), delay=server.get('delay', 100), + server_header=server.get('server_header',""), timezone=server.get('timezone'), links_length_range=( links.get('min_length', 5), diff --git a/src/generators.py b/src/generators.py index 6eca9fd..92eb590 100644 --- a/src/generators.py +++ b/src/generators.py @@ -9,6 +9,7 @@ import string import json from templates import html_templates from wordlists import get_wordlists +from config import get_config def random_username() -> str: """Generate random username""" @@ -37,6 +38,9 @@ def random_email(username: str = None) -> str: def random_server_header() -> str: """Generate random server header from wordlists""" + config = get_config() + if config.server_header: + return config.server_header wl = get_wordlists() return random.choice(wl.server_headers) From 48f38cb28e07613c9b46644a8de9305a2dec4ef9 Mon Sep 17 00:00:00 2001 From: Leonardo Bambini Date: Sun, 4 Jan 2026 19:12:23 +0100 Subject: [PATCH 17/70] added scoring system + db model modifications --- src/analyzer.py | 290 +++++++++++++++++++++++++++++++++++++++++++++++ src/database.py | 136 +++++++++++++++++++++- src/handler.py | 4 + src/models.py | 43 ++++++- src/server.py | 3 + src/wordlists.py | 4 + wordlists.json | 8 +- 7 files changed, 484 insertions(+), 4 deletions(-) create mode 100644 src/analyzer.py diff --git a/src/analyzer.py b/src/analyzer.py new file mode 100644 index 0000000..8ebef62 --- /dev/null +++ b/src/analyzer.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +from sqlalchemy import select +from typing import Optional +from database import get_database, DatabaseManager +from zoneinfo import ZoneInfo +from pathlib import Path +from datetime import datetime, timedelta +import re +from wordlists import get_wordlists + +""" +Functions for user activity analysis +""" + +class Analyzer: + """ + Analyzes users activity and produces aggregated insights + """ + def __init__(self, db_manager: Optional[DatabaseManager] = None, timezone: Optional[ZoneInfo] = None): + """ + Initialize the access tracker. + + Args: + db_manager: Optional DatabaseManager for persistence. + If None, will use the global singleton. + """ + self.timezone = timezone or ZoneInfo('UTC') + + # Database manager for persistence (lazily initialized) + self._db_manager = db_manager + + @property + def db(self) -> Optional[DatabaseManager]: + """ + Get the database manager, lazily initializing if needed. + + Returns: + DatabaseManager instance or None if not available + """ + if self._db_manager is None: + try: + self._db_manager = get_database() + except Exception: + # Database not initialized, persistence disabled + pass + return self._db_manager + + def infer_user_category(self, ip: str) -> str: + + score = {} + score["attacker"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} + score["good_crawler"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} + score["bad_crawler"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} + score["regular_user"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} + + #1-3 low, 4-6 mid, 7-9 high, 10-20 extreme + weights = { + "attacker": { + "risky_http_methods": 6, + "robots_violations": 4, + "uneven_request_timing": 5, + "different_user_agents": 8, + "attack_url": 15 + }, + "good_crawler": { + "risky_http_methods": 0, + "robots_violations": 0, + "uneven_request_timing": 0, + "different_user_agents": 0, + "attack_url": 0 + }, + "bad_crawler": { + "risky_http_methods": 2, + "robots_violations": 4, + "uneven_request_timing": 0, + "different_user_agents": 5, + "attack_url": 5 + }, + "regular_user": { + "risky_http_methods": 0, + "robots_violations": 0, + "uneven_request_timing": 8, + "different_user_agents": 3, + "attack_url": 0 + } + } + + + accesses = self.db.get_access_logs(ip_filter = ip, limit=1000) + total_accesses_count = len(accesses) + if total_accesses_count <= 0: + return + + #--------------------- HTTP Methods --------------------- + + + get_accesses_count = len([item for item in accesses if item["method"] == "GET"]) + post_accesses_count = len([item for item in accesses if item["method"] == "POST"]) + put_accesses_count = len([item for item in accesses if item["method"] == "PUT"]) + delete_accesses_count = len([item for item in accesses if item["method"] == "DELETE"]) + head_accesses_count = len([item for item in accesses if item["method"] == "HEAD"]) + options_accesses_count = len([item for item in accesses if item["method"] == "OPTIONS"]) + patch_accesses_count = len([item for item in accesses if item["method"] == "PATCH"]) + #print(f"TOTAL: {total_accesses_count} - GET: {get_accesses_count} - POST: {post_accesses_count}") + + + #if >5% attacker or bad crawler + if total_accesses_count > 0: + http_method_attacker_score = (post_accesses_count + put_accesses_count + delete_accesses_count + options_accesses_count + patch_accesses_count) / total_accesses_count + else: + http_method_attacker_score = 0 + + #print(f"HTTP Method attacker score: {http_method_attacker_score}") + if http_method_attacker_score > 0.2: + score["attacker"]["risky_http_methods"] = True + score["good_crawler"]["risky_http_methods"] = False + score["bad_crawler"]["risky_http_methods"] = True + score["regular_user"]["risky_http_methods"] = False + else: + score["attacker"]["risky_http_methods"] = False + score["good_crawler"]["risky_http_methods"] = False + score["bad_crawler"]["risky_http_methods"] = False + score["regular_user"]["risky_http_methods"] = False + + #print(f"Updated score: {score}") + + + + #--------------------- Robots Violations --------------------- + #respect robots.txt and login/config pages access frequency + robots_disallows = [] + robots_path = config_path = Path(__file__).parent / "templates" / "html" / "robots.txt" + with open(robots_path, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + parts = line.split(":") + + if parts[0] == "Disallow": + parts[1] = parts[1].rstrip("/") + #print(f"DISALLOW {parts[1]}") + robots_disallows.append(parts[1].strip()) + + #if 0 100% sure is good crawler, if >10% of robots violated is bad crawler or attacker + violated_robots_count = len([item for item in accesses if item["path"].rstrip("/") in tuple(robots_disallows)]) + #print(f"Violated robots count: {violated_robots_count}") + if total_accesses_count > 0: + violated_robots_ratio = violated_robots_count / total_accesses_count + else: + violated_robots_ratio = 0 + + if violated_robots_ratio > 0.10: + score["attacker"]["robots_violations"] = True + score["good_crawler"]["robots_violations"] = False + score["bad_crawler"]["robots_violations"] = True + score["regular_user"]["robots_violations"] = False + else: + score["attacker"]["robots_violations"] = True + score["good_crawler"]["robots_violations"] = False + score["bad_crawler"]["robots_violations"] = True + score["regular_user"]["robots_violations"] = False + + #--------------------- Requests Timing --------------------- + #Request rate and timing: steady, throttled, polite vs attackers' bursty, aggressive, or oddly rhythmic behavior + timestamps = [datetime.fromisoformat(item["timestamp"]) for item in accesses] + print(f"Timestamps #: {len(timestamps)}") + timestamps = [ts for ts in timestamps if datetime.utcnow() - ts <= timedelta(minutes=5)] + print(f"Timestamps #: {len(timestamps)}") + timestamps = sorted(timestamps, reverse=True) + print(f"Timestamps #: {len(timestamps)}") + + time_diffs = [] + for i in range(0, len(timestamps)-1): + diff = (timestamps[i] - timestamps[i+1]).total_seconds() + time_diffs.append(diff) + + print(f"Time diffs: {time_diffs}") + + mean = 0 + variance = 0 + std = 0 + cv = 0 + if time_diffs: + mean = sum(time_diffs) / len(time_diffs) + variance = sum((x - mean) ** 2 for x in time_diffs) / len(time_diffs) + std = variance ** 0.5 + cv = std/mean + print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") + + if mean > 4: + score["attacker"]["uneven_request_timing"] = True + score["good_crawler"]["uneven_request_timing"] = False + score["bad_crawler"]["uneven_request_timing"] = False + score["regular_user"]["uneven_request_timing"] = True + else: + score["attacker"]["uneven_request_timing"] = True + score["good_crawler"]["uneven_request_timing"] = False + score["bad_crawler"]["uneven_request_timing"] = True + score["regular_user"]["uneven_request_timing"] = False + + + #--------------------- Different User Agents --------------------- + #Header Quality and Consistency: Crawlers tend to use complete and consistent headers, attackers might miss, fake, or change headers + user_agents_used = [item["user_agent"] for item in accesses] + user_agents_used = list(dict.fromkeys(user_agents_used)) + #print(f"User agents used: {user_agents_used}") + + if len(user_agents_used)> 4: + score["attacker"]["different_user_agents"] = True + score["good_crawler"]["different_user_agents"] = False + score["bad_crawler"]["different_user_agentss"] = True + score["regular_user"]["different_user_agents"] = False + else: + score["attacker"]["different_user_agents"] = True + score["good_crawler"]["different_user_agents"] = False + score["bad_crawler"]["different_user_agents"] = True + score["regular_user"]["different_user_agents"] = False + + #--------------------- Attack URLs --------------------- + + attack_url_found = False + # attack_types = { + # 'path_traversal': r'\.\.', + # 'sql_injection': r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", + # 'xss_attempt': r'( None: + """ + Update IP statistics (ip is already persisted). + + Args: + ip: IP address to update + analyzed_metrics: metric values analyzed be the analyzer + category: inferred category + category_scores: inferred category scores + last_analysis: timestamp of last analysis + + """ + print(f"Analyzed metrics {analyzed_metrics}, category {category}, category scores {category_scores}, last analysis {last_analysis}") + + session = self.session + sanitized_ip = sanitize_ip(ip) + ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + + ip_stats.analyzed_metrics = analyzed_metrics + ip_stats.category = category + ip_stats.category_scores = category_scores + ip_stats.last_analysis = last_analysis + + def manual_update_category(self, ip: str, category: str) -> None: + """ + Update IP category as a result of a manual intervention by an admin + + Args: + ip: IP address to update + category: selected category + + """ + session = self.session + + ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + + ip_stats.category = category + ip_stats.manual_category = True + def get_access_logs( self, limit: int = 100, @@ -270,6 +309,56 @@ class DatabaseManager: finally: self.close_session() + # def persist_ip( + # self, + # ip: str + # ) -> Optional[int]: + # """ + # Persist an ip entry to the database. + + # Args: + # ip: Client IP address + + # Returns: + # The ID of the created IpLog record, or None on error + # """ + # session = self.session + # try: + # # Create access log with sanitized fields + # ip_log = AccessLog( + # ip=sanitize_ip(ip), + # manual_category = False + # ) + # session.add(access_log) + # session.flush() # Get the ID before committing + + # # Add attack detections if any + # if attack_types: + # matched_patterns = matched_patterns or {} + # for attack_type in attack_types: + # detection = AttackDetection( + # access_log_id=access_log.id, + # attack_type=attack_type[:50], + # matched_pattern=sanitize_attack_pattern( + # matched_patterns.get(attack_type, "") + # ) + # ) + # session.add(detection) + + # # Update IP stats + # self._update_ip_stats(session, ip) + + # session.commit() + # return access_log.id + + # except Exception as e: + # session.rollback() + # # Log error but don't crash - database persistence is secondary to honeypot function + # print(f"Database error persisting access: {e}") + # return None + # finally: + # self.close_session() + def get_credential_attempts( self, limit: int = 100, @@ -339,7 +428,11 @@ class DatabaseManager: 'asn': s.asn, 'asn_org': s.asn_org, 'reputation_score': s.reputation_score, - 'reputation_source': s.reputation_source + 'reputation_source': s.reputation_source, + 'analyzed_metrics': s.analyzed_metrics, + 'category': s.category, + 'manual_category': s.manual_category, + 'last_analysis': s.last_analysis } for s in stats ] @@ -540,6 +633,47 @@ class DatabaseManager: finally: self.close_session() + # def get_ip_logs( + # self, + # limit: int = 100, + # offset: int = 0, + # ip_filter: Optional[str] = None + # ) -> List[Dict[str, Any]]: + # """ + # Retrieve ip logs with optional filtering. + + # Args: + # limit: Maximum number of records to return + # offset: Number of records to skip + # ip_filter: Filter by IP address + + # Returns: + # List of ip log dictionaries + # """ + # session = self.session + # try: + # query = session.query(IpLog).order_by(IpLog.last_access.desc()) + + # if ip_filter: + # query = query.filter(IpLog.ip == sanitize_ip(ip_filter)) + + # logs = query.offset(offset).limit(limit).all() + + # return [ + # { + # 'id': log.id, + # 'ip': log.ip, + # 'stats': log.stats, + # 'category': log.category, + # 'manual_category': log.manual_category, + # 'last_evaluation': log.last_evaluation, + # 'last_access': log.last_access + # } + # for log in logs + # ] + # finally: + # self.close_session() + # Module-level singleton instance _db_manager = DatabaseManager() diff --git a/src/handler.py b/src/handler.py index a45661d..1dd6a45 100644 --- a/src/handler.py +++ b/src/handler.py @@ -9,6 +9,7 @@ from typing import Optional, List from config import Config from tracker import AccessTracker +from analyzer import Analyzer from templates import html_templates from templates.dashboard_template import generate_dashboard from generators import ( @@ -23,6 +24,7 @@ class Handler(BaseHTTPRequestHandler): webpages: Optional[List[str]] = None config: Config = None tracker: AccessTracker = None + analyzer: Analyzer = None counter: int = 0 app_logger: logging.Logger = None access_logger: logging.Logger = None @@ -348,6 +350,8 @@ class Handler(BaseHTTPRequestHandler): return self.tracker.record_access(client_ip, self.path, user_agent, method='GET') + + self.analyzer.infer_user_category(client_ip) if self.tracker.is_suspicious_user_agent(user_agent): self.access_logger.warning(f"[SUSPICIOUS] {client_ip} - {user_agent[:50]} - {self.path}") diff --git a/src/models.py b/src/models.py index 40dae0b..190ef26 100644 --- a/src/models.py +++ b/src/models.py @@ -6,9 +6,9 @@ Stores access logs, credential attempts, attack detections, and IP statistics. """ from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict -from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Index +from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Index, JSON from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sanitizer import ( @@ -38,6 +38,7 @@ class AccessLog(Base): __tablename__ = 'access_logs' id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + #ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True, ForeignKey('ip_logs.id', ondelete='CASCADE')) ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True) path: Mapped[str] = mapped_column(String(MAX_PATH_LENGTH), nullable=False) user_agent: Mapped[Optional[str]] = mapped_column(String(MAX_USER_AGENT_LENGTH), nullable=True) @@ -139,5 +140,43 @@ class IpStats(Base): reputation_source: Mapped[Optional[str]] = mapped_column(String(MAX_REPUTATION_SOURCE_LENGTH), nullable=True) reputation_updated: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + #Analyzed metrics, category and category scores + analyzed_metrics: Mapped[Dict[str,object]] = mapped_column(JSON, nullable=True) + category: Mapped[str] = mapped_column(String, nullable=True) + category_scores: Mapped[Dict[str,int]] = mapped_column(JSON, nullable=True) + manual_category: Mapped[bool] = mapped_column(Boolean, default=False, nullable=True) + last_analysis: Mapped[datetime] = mapped_column(DateTime, nullable=True) + + def __repr__(self) -> str: return f"" + +# class IpLog(Base): +# """ +# Records all IPs that have accessed the honeypot, along with aggregated stats and inferred user category. +# """ +# __tablename__ = 'ip_logs' + +# id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) +# ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True) +# stats: Mapped[List[str]] = mapped_column(String(MAX_PATH_LENGTH)) +# category: Mapped[str] = mapped_column(String(15)) +# manual_category: Mapped[bool] = mapped_column(Boolean, default=False) +# last_analysis: Mapped[datetime] = mapped_column(DateTime, index=True), + +# # Relationship to attack detections +# access_logs: Mapped[List["AccessLog"]] = relationship( +# "AccessLog", +# back_populates="ip", +# cascade="all, delete-orphan" +# ) + +# # Indexes for common queries +# __table_args__ = ( +# Index('ix_access_logs_ip_timestamp', 'ip', 'timestamp'), +# Index('ix_access_logs_is_suspicious', 'is_suspicious'), +# Index('ix_access_logs_is_honeypot_trigger', 'is_honeypot_trigger'), +# ) + +# def __repr__(self) -> str: +# return f"" \ No newline at end of file diff --git a/src/server.py b/src/server.py index 06b7c82..4431d55 100644 --- a/src/server.py +++ b/src/server.py @@ -10,6 +10,7 @@ from http.server import HTTPServer from config import Config from tracker import AccessTracker +from analyzer import Analyzer from handler import Handler from logger import initialize_logging, get_app_logger, get_access_logger, get_credential_logger from database import initialize_database @@ -67,9 +68,11 @@ def main(): app_logger.warning(f'Database initialization failed: {e}. Continuing with in-memory only.') tracker = AccessTracker(timezone=tz) + analyzer = Analyzer(timezone=tz) Handler.config = config Handler.tracker = tracker + Handler.analyzer = analyzer Handler.counter = config.canary_token_tries Handler.app_logger = app_logger Handler.access_logger = access_logger diff --git a/src/wordlists.py b/src/wordlists.py index 342930a..3fce069 100644 --- a/src/wordlists.py +++ b/src/wordlists.py @@ -116,6 +116,10 @@ class Wordlists: @property def server_headers(self): return self._data.get("server_headers", []) + + @property + def attack_urls(self): + return self._data.get("attack_urls", []) _wordlists_instance = None diff --git a/wordlists.json b/wordlists.json index fddf3d3..39ab698 100644 --- a/wordlists.json +++ b/wordlists.json @@ -201,5 +201,11 @@ "cloudflare", "AmazonS3", "gunicorn/20.1.0" - ] + ], + "attack_urls": { + "path_traversal": "\\.\\.", + "sql_injection": "('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", + "xss_attempt": "( Date: Sun, 4 Jan 2026 22:20:10 +0100 Subject: [PATCH 18/70] parametrized into config.yaml + bug fix --- .gitignore | 5 +- Dockerfile | 15 ++- README.md | 12 +- config.yaml | 46 +++++++ docker-compose.yaml | 19 +-- entrypoint.sh | 8 ++ helm/templates/configmap.yaml | 49 +++---- helm/templates/deployment.yaml | 19 +-- helm/templates/secret.yaml | 16 --- helm/values.yaml | 47 ++++--- kubernetes/krawl-all-in-one-deploy.yaml | 71 +++++++--- kubernetes/manifests/configmap.yaml | 50 ++++--- kubernetes/manifests/deployment.yaml | 13 +- requirements.txt | 3 + src/analyzer.py | 69 +++++----- src/config.py | 121 +++++++++++++---- src/generators.py | 18 +-- src/handler.py | 154 +++++++++++++++++++++- src/server.py | 51 ++++---- src/server_errors.py | 65 ++++++++++ src/sql_errors.py | 112 ++++++++++++++++ src/templates/html/generic_search.html | 66 ++++++++++ src/templates/html/input_form.html | 74 +++++++++++ src/templates/html/robots.txt | 10 ++ src/templates/html_templates.py | 10 ++ src/tracker.py | 23 ++-- src/wordlists.py | 11 ++ src/xss_detector.py | 73 +++++++++++ tests/sim_attacks.sh | 2 +- tests/test_sql_injection.sh | 78 +++++++++++ wordlists.json | 165 ++++++++++++++++++++++++ 31 files changed, 1239 insertions(+), 236 deletions(-) create mode 100644 config.yaml create mode 100644 entrypoint.sh delete mode 100644 helm/templates/secret.yaml create mode 100644 src/server_errors.py create mode 100644 src/sql_errors.py create mode 100644 src/templates/html/generic_search.html create mode 100644 src/templates/html/input_form.html create mode 100644 src/xss_detector.py create mode 100644 tests/test_sql_injection.sh diff --git a/.gitignore b/.gitignore index a36748e..70b93e4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,9 +61,12 @@ secrets/ *.log logs/ -# Database +# Data and databases data/ +**/data/ *.db +*.sqlite +*.sqlite3 # Temporary files *.tmp diff --git a/Dockerfile b/Dockerfile index adac20f..2c7b954 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,16 +4,25 @@ LABEL org.opencontainers.image.source=https://github.com/BlessedRebuS/Krawl WORKDIR /app +# Install gosu for dropping privileges +RUN apt-get update && apt-get install -y --no-install-recommends gosu && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + COPY src/ /app/src/ COPY wordlists.json /app/ +COPY entrypoint.sh /app/ RUN useradd -m -u 1000 krawl && \ - chown -R krawl:krawl /app - -USER krawl + mkdir -p /app/logs /app/data && \ + chown -R krawl:krawl /app && \ + chmod +x /app/entrypoint.sh EXPOSE 5000 ENV PYTHONUNBUFFERED=1 +ENTRYPOINT ["/app/entrypoint.sh"] CMD ["python3", "src/server.py"] diff --git a/README.md b/README.md index 7fd0377..f7fe399 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,11 @@
-## Star History -Star History Chart +## Demo +Tip: crawl the `robots.txt` paths for additional fun +### Krawl URL: [http://demo.krawlme.com](http://demo.krawlme.com) +### View the dashboard [http://demo.krawlme.com/das_dashboard](http://demo.krawlme.com/das_dashboard) - ## What is Krawl? **Krawl** is a cloud‑native deception server designed to detect, delay, and analyze malicious web crawlers and automated scanners. @@ -185,7 +186,7 @@ To customize the deception server installation several **environment variables** | `CANARY_TOKEN_URL` | External canary token URL | None | | `DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | | `PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | -| `SERVER_HEADER` | HTTP Server header for deception, if not set use random server header | | +| `SERVER_HEADER` | HTTP Server header for deception | `Apache/2.2.22 (Ubuntu)` | | `TIMEZONE` | IANA timezone for logs and dashboard (e.g., `America/New_York`, `Europe/Rome`) | System timezone | ## robots.txt @@ -317,3 +318,6 @@ Contributions welcome! Please: **This is a deception/honeypot system.** Deploy in isolated environments and monitor carefully for security events. Use responsibly and in compliance with applicable laws and regulations. + +## Star History +Star History Chart diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..987588c --- /dev/null +++ b/config.yaml @@ -0,0 +1,46 @@ +# Krawl Honeypot Configuration + +server: + port: 5000 + delay: 100 # Response delay in milliseconds + timezone: null # e.g., "America/New_York" or null for system default + + # manually set the server header, if null a random one will be used. + server_header: "Apache/2.2.22 (Ubuntu)" + +links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + +canary: + token_url: null # Optional canary token URL + token_tries: 10 + +dashboard: + # if set to "null" this will Auto-generates random path if not set + # can be set to "/dashboard" or similar <-- note this MUST include a forward slash + secret_path: dashboard + +api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + +database: + path: "data/krawl.db" + retention_days: 30 + +behavior: + probability_error_codes: 0 # 0-100 percentage + +analyzer: + http_risky_methods_threshold: 0.1 + violated_robots_threshold: 0.1 + uneven_request_timing_threshold: 5 + uneven_request_timing_time_window_seconds: 300 + user_agents_used_threshold: 1 + attack_urls_threshold: 1 \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 6f81a47..02b6ae7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,23 +10,10 @@ services: - "5000:5000" volumes: - ./wordlists.json:/app/wordlists.json:ro + - ./config.yaml:/app/config.yaml:ro + - ./logs:/app/logs environment: - - PORT=5000 - - DELAY=100 - - LINKS_MIN_LENGTH=5 - - LINKS_MAX_LENGTH=15 - - LINKS_MIN_PER_PAGE=10 - - LINKS_MAX_PER_PAGE=15 - - MAX_COUNTER=10 - - CANARY_TOKEN_TRIES=10 - - PROBABILITY_ERROR_CODES=0 - # - SERVER_HEADER=Apache/2.2.22 (Ubuntu) - # Optional: Set your canary token URL - # - CANARY_TOKEN_URL=http://canarytokens.com/api/users/YOUR_TOKEN/passwords.txt - # Optional: Set custom dashboard path (auto-generated if not set) - # - DASHBOARD_SECRET_PATH=/my-secret-dashboard - # Optional: Set timezone for logs and dashboard (e.g., America/New_York, Europe/Rome) - # - TIMEZONE=UTC + - CONFIG_LOCATION=config.yaml restart: unless-stopped healthcheck: test: ["CMD", "python3", "-c", "import requests; requests.get('http://localhost:5000')"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..28b5fc0 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Fix ownership of mounted directories +chown -R krawl:krawl /app/logs /app/data 2>/dev/null || true + +# Drop to krawl user and run the application +exec gosu krawl "$@" diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 17cd952..808d9f5 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -5,25 +5,30 @@ metadata: labels: {{- include "krawl.labels" . | nindent 4 }} data: - PORT: {{ .Values.config.port | quote }} - DELAY: {{ .Values.config.delay | quote }} - LINKS_MIN_LENGTH: {{ .Values.config.linksMinLength | quote }} - LINKS_MAX_LENGTH: {{ .Values.config.linksMaxLength | quote }} - LINKS_MIN_PER_PAGE: {{ .Values.config.linksMinPerPage | quote }} - LINKS_MAX_PER_PAGE: {{ .Values.config.linksMaxPerPage | quote }} - MAX_COUNTER: {{ .Values.config.maxCounter | quote }} - CANARY_TOKEN_TRIES: {{ .Values.config.canaryTokenTries | quote }} - PROBABILITY_ERROR_CODES: {{ .Values.config.probabilityErrorCodes | quote }} - CANARY_TOKEN_URL: {{ .Values.config.canaryTokenUrl | quote }} - {{- if .Values.config.dashboardSecretPath }} - DASHBOARD_SECRET_PATH: {{ .Values.config.dashboardSecretPath | quote }} - {{- end }} - {{- if .Values.config.serverHeader }} - SERVER_HEADER: {{ .Values.config.serverHeader | quote }} - {{- end }} - {{- if .Values.config.timezone }} - TIMEZONE: {{ .Values.config.timezone | quote }} - {{- end }} - # Database configuration - DATABASE_PATH: {{ .Values.database.path | quote }} - DATABASE_RETENTION_DAYS: {{ .Values.database.retentionDays | quote }} + config.yaml: | + # Krawl Honeypot Configuration + server: + port: {{ .Values.config.server.port }} + delay: {{ .Values.config.server.delay }} + timezone: {{ .Values.config.server.timezone | toYaml }} + links: + min_length: {{ .Values.config.links.min_length }} + max_length: {{ .Values.config.links.max_length }} + min_per_page: {{ .Values.config.links.min_per_page }} + max_per_page: {{ .Values.config.links.max_per_page }} + char_space: {{ .Values.config.links.char_space | quote }} + max_counter: {{ .Values.config.links.max_counter }} + canary: + token_url: {{ .Values.config.canary.token_url | toYaml }} + token_tries: {{ .Values.config.canary.token_tries }} + dashboard: + secret_path: {{ .Values.config.dashboard.secret_path | toYaml }} + api: + server_url: {{ .Values.config.api.server_url | toYaml }} + server_port: {{ .Values.config.api.server_port }} + server_path: {{ .Values.config.api.server_path | quote }} + database: + path: {{ .Values.config.database.path | quote }} + retention_days: {{ .Values.config.database.retention_days }} + behavior: + probability_error_codes: {{ .Values.config.behavior.probability_error_codes }} diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index ecc9655..5635fa3 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -38,18 +38,16 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http - containerPort: {{ .Values.config.port }} + containerPort: {{ .Values.config.server.port }} protocol: TCP - envFrom: - - configMapRef: - name: {{ include "krawl.fullname" . }}-config env: - - name: DASHBOARD_SECRET_PATH - valueFrom: - secretKeyRef: - name: {{ include "krawl.fullname" . }} - key: dashboard-path + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -63,6 +61,9 @@ spec: {{- toYaml . | nindent 12 }} {{- end }} volumes: + - name: config + configMap: + name: {{ include "krawl.fullname" . }}-config - name: wordlists configMap: name: {{ include "krawl.fullname" . }}-wordlists diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml deleted file mode 100644 index 798289c..0000000 --- a/helm/templates/secret.yaml +++ /dev/null @@ -1,16 +0,0 @@ -{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "krawl.fullname" .)) -}} -{{- $dashboardPath := "" -}} -{{- if and $secret $secret.data -}} - {{- $dashboardPath = index $secret.data "dashboard-path" | b64dec -}} -{{- else -}} - {{- $dashboardPath = printf "/%s" (randAlphaNum 32) -}} -{{- end -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "krawl.fullname" . }} - labels: - {{- include "krawl.labels" . | nindent 4 }} -type: Opaque -stringData: - dashboard-path: {{ $dashboardPath | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index c92bc0b..60b1a66 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -62,29 +62,36 @@ tolerations: [] affinity: {} -# Application configuration +# Application configuration (config.yaml structure) config: - port: 5000 - delay: 100 - linksMinLength: 5 - linksMaxLength: 15 - linksMinPerPage: 10 - linksMaxPerPage: 15 - maxCounter: 10 - canaryTokenTries: 10 - probabilityErrorCodes: 0 -# timezone: "UTC" -# serverHeader: "Apache/2.2.22 (Ubuntu)" -# dashboardSecretPath: "/my-secret-dashboard" -# canaryTokenUrl: set-your-canary-token-url-here -# timezone: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. + server: + port: 5000 + delay: 100 + timezone: null # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + canary: + token_url: null # Set your canary token URL here + token_tries: 10 + dashboard: + secret_path: null # Auto-generated if not set, or set to "/my-secret-dashboard" + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + database: + path: "data/krawl.db" + retention_days: 30 + behavior: + probability_error_codes: 0 -# Database configuration +# Database persistence configuration database: - # Path to the SQLite database file - path: "data/krawl.db" - # Number of days to retain access logs and attack data - retentionDays: 30 # Persistence configuration persistence: enabled: true diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index d1a026c..3344260 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -10,19 +10,41 @@ metadata: name: krawl-config namespace: krawl-system data: - PORT: "5000" - DELAY: "100" - LINKS_MIN_LENGTH: "5" - LINKS_MAX_LENGTH: "15" - LINKS_MIN_PER_PAGE: "10" - LINKS_MAX_PER_PAGE: "15" - MAX_COUNTER: "10" - CANARY_TOKEN_TRIES: "10" - PROBABILITY_ERROR_CODES: "0" -# CANARY_TOKEN_URL: set-your-canary-token-url-here - # Database configuration - DATABASE_PATH: "data/krawl.db" - DATABASE_RETENTION_DAYS: "30" + config.yaml: | + # Krawl Honeypot Configuration + server: + port: 5000 + delay: 100 + timezone: null # e.g., "America/New_York" or null for system default + + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + + canary: + token_url: null # Optional canary token URL + token_tries: 10 + + dashboard: + # Auto-generates random path if null + # Can be set to "/dashboard" or similar + secret_path: null + + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + + database: + path: "data/krawl.db" + retention_days: 30 + + behavior: + probability_error_codes: 0 # 0-100 percentage --- apiVersion: v1 kind: ConfigMap @@ -227,6 +249,14 @@ data: 500, 502, 503 + ], + "server_headers": [ + "Apache/2.4.41 (Ubuntu)", + "nginx/1.18.0", + "Microsoft-IIS/10.0", + "cloudflare", + "AmazonS3", + "gunicorn/20.1.0" ] } --- @@ -269,10 +299,14 @@ spec: - containerPort: 5000 name: http protocol: TCP - envFrom: - - configMapRef: - name: krawl-config + env: + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -287,6 +321,9 @@ spec: memory: "256Mi" cpu: "500m" volumes: + - name: config + configMap: + name: krawl-config - name: wordlists configMap: name: krawl-wordlists @@ -353,7 +390,7 @@ spec: - podSelector: {} - namespaceSelector: {} - ipBlock: - cidr: 0.0.0.0/0 + cidr: 0.0.0.0/0 ports: - protocol: TCP port: 5000 diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index ef357b0..38a287b 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -4,18 +4,38 @@ metadata: name: krawl-config namespace: krawl-system data: - PORT: "5000" - DELAY: "100" - LINKS_MIN_LENGTH: "5" - LINKS_MAX_LENGTH: "15" - LINKS_MIN_PER_PAGE: "10" - LINKS_MAX_PER_PAGE: "15" - MAX_COUNTER: "10" - CANARY_TOKEN_TRIES: "10" - PROBABILITY_ERROR_CODES: "0" - SERVER_HEADER: "Apache/2.2.22 (Ubuntu)" -# CANARY_TOKEN_URL: set-your-canary-token-url-here -# TIMEZONE: "UTC" # IANA timezone (e.g., "America/New_York", "Europe/Rome") - # Database configuration - DATABASE_PATH: "data/krawl.db" - DATABASE_RETENTION_DAYS: "30" \ No newline at end of file + config.yaml: | + # Krawl Honeypot Configuration + server: + port: 5000 + delay: 100 + timezone: null # e.g., "America/New_York" or null for system default + + links: + min_length: 5 + max_length: 15 + min_per_page: 10 + max_per_page: 15 + char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + max_counter: 10 + + canary: + token_url: null # Optional canary token URL + token_tries: 10 + + dashboard: + # Auto-generates random path if null + # Can be set to "/dashboard" or similar + secret_path: null + + api: + server_url: null + server_port: 8080 + server_path: "/api/v2/users" + + database: + path: "data/krawl.db" + retention_days: 30 + + behavior: + probability_error_codes: 0 # 0-100 percentage diff --git a/kubernetes/manifests/deployment.yaml b/kubernetes/manifests/deployment.yaml index 1650721..f970625 100644 --- a/kubernetes/manifests/deployment.yaml +++ b/kubernetes/manifests/deployment.yaml @@ -23,10 +23,14 @@ spec: - containerPort: 5000 name: http protocol: TCP - envFrom: - - configMapRef: - name: krawl-config + env: + - name: CONFIG_LOCATION + value: "config.yaml" volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true - name: wordlists mountPath: /app/wordlists.json subPath: wordlists.json @@ -41,6 +45,9 @@ spec: memory: "256Mi" cpu: "500m" volumes: + - name: config + configMap: + name: krawl-config - name: wordlists configMap: name: krawl-wordlists diff --git a/requirements.txt b/requirements.txt index 94f74f2..8cb6dc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ # Krawl Honeypot Dependencies # Install with: pip install -r requirements.txt +# Configuration +PyYAML>=6.0 + # Database ORM SQLAlchemy>=2.0.0,<3.0.0 diff --git a/src/analyzer.py b/src/analyzer.py index 8ebef62..48c5fad 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -7,7 +7,7 @@ from pathlib import Path from datetime import datetime, timedelta import re from wordlists import get_wordlists - +from config import get_config """ Functions for user activity analysis """ @@ -47,6 +47,17 @@ class Analyzer: def infer_user_category(self, ip: str) -> str: + config = get_config() + + http_risky_methods_threshold = config.http_risky_methods_threshold + violated_robots_threshold = config.violated_robots_threshold + uneven_request_timing_threshold = config.uneven_request_timing_threshold + user_agents_used_threshold = config.user_agents_used_threshold + attack_urls_threshold = config.attack_urls_threshold + uneven_request_timing_time_window_seconds = config.uneven_request_timing_time_window_seconds + + print(f"http_risky_methods_threshold: {http_risky_methods_threshold}") + score = {} score["attacker"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} score["good_crawler"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} @@ -104,14 +115,13 @@ class Analyzer: #print(f"TOTAL: {total_accesses_count} - GET: {get_accesses_count} - POST: {post_accesses_count}") - #if >5% attacker or bad crawler - if total_accesses_count > 0: + if total_accesses_count > http_risky_methods_threshold: http_method_attacker_score = (post_accesses_count + put_accesses_count + delete_accesses_count + options_accesses_count + patch_accesses_count) / total_accesses_count else: http_method_attacker_score = 0 #print(f"HTTP Method attacker score: {http_method_attacker_score}") - if http_method_attacker_score > 0.2: + if http_method_attacker_score >= http_risky_methods_threshold: score["attacker"]["risky_http_methods"] = True score["good_crawler"]["risky_http_methods"] = False score["bad_crawler"]["risky_http_methods"] = True @@ -150,33 +160,28 @@ class Analyzer: else: violated_robots_ratio = 0 - if violated_robots_ratio > 0.10: + if violated_robots_ratio >= violated_robots_threshold: score["attacker"]["robots_violations"] = True score["good_crawler"]["robots_violations"] = False score["bad_crawler"]["robots_violations"] = True score["regular_user"]["robots_violations"] = False else: - score["attacker"]["robots_violations"] = True + score["attacker"]["robots_violations"] = False score["good_crawler"]["robots_violations"] = False - score["bad_crawler"]["robots_violations"] = True + score["bad_crawler"]["robots_violations"] = False score["regular_user"]["robots_violations"] = False #--------------------- Requests Timing --------------------- #Request rate and timing: steady, throttled, polite vs attackers' bursty, aggressive, or oddly rhythmic behavior timestamps = [datetime.fromisoformat(item["timestamp"]) for item in accesses] - print(f"Timestamps #: {len(timestamps)}") - timestamps = [ts for ts in timestamps if datetime.utcnow() - ts <= timedelta(minutes=5)] - print(f"Timestamps #: {len(timestamps)}") + timestamps = [ts for ts in timestamps if datetime.utcnow() - ts <= timedelta(seconds=uneven_request_timing_time_window_seconds)] timestamps = sorted(timestamps, reverse=True) - print(f"Timestamps #: {len(timestamps)}") time_diffs = [] for i in range(0, len(timestamps)-1): diff = (timestamps[i] - timestamps[i+1]).total_seconds() time_diffs.append(diff) - print(f"Time diffs: {time_diffs}") - mean = 0 variance = 0 std = 0 @@ -186,17 +191,17 @@ class Analyzer: variance = sum((x - mean) ** 2 for x in time_diffs) / len(time_diffs) std = variance ** 0.5 cv = std/mean - print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") + #print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") - if mean > 4: + if mean >= uneven_request_timing_threshold: score["attacker"]["uneven_request_timing"] = True score["good_crawler"]["uneven_request_timing"] = False score["bad_crawler"]["uneven_request_timing"] = False score["regular_user"]["uneven_request_timing"] = True else: - score["attacker"]["uneven_request_timing"] = True + score["attacker"]["uneven_request_timing"] = False score["good_crawler"]["uneven_request_timing"] = False - score["bad_crawler"]["uneven_request_timing"] = True + score["bad_crawler"]["uneven_request_timing"] = False score["regular_user"]["uneven_request_timing"] = False @@ -206,39 +211,31 @@ class Analyzer: user_agents_used = list(dict.fromkeys(user_agents_used)) #print(f"User agents used: {user_agents_used}") - if len(user_agents_used)> 4: + if len(user_agents_used) >= user_agents_used_threshold: score["attacker"]["different_user_agents"] = True score["good_crawler"]["different_user_agents"] = False score["bad_crawler"]["different_user_agentss"] = True score["regular_user"]["different_user_agents"] = False else: - score["attacker"]["different_user_agents"] = True + score["attacker"]["different_user_agents"] = False score["good_crawler"]["different_user_agents"] = False - score["bad_crawler"]["different_user_agents"] = True + score["bad_crawler"]["different_user_agents"] = False score["regular_user"]["different_user_agents"] = False #--------------------- Attack URLs --------------------- - attack_url_found = False - # attack_types = { - # 'path_traversal': r'\.\.', - # 'sql_injection': r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", - # 'xss_attempt': r'( attack_urls_threshold: score["attacker"]["attack_url"] = True score["good_crawler"]["attack_url"] = False score["bad_crawler"]["attack_url"] = False @@ -275,12 +272,12 @@ class Analyzer: regular_user_score = regular_user_score + score["regular_user"]["different_user_agents"] * weights["regular_user"]["different_user_agents"] regular_user_score = regular_user_score + score["regular_user"]["attack_url"] * weights["regular_user"]["attack_url"] - #print(f"Attacker score: {attacker_score}") - #print(f"Good Crawler score: {good_crawler_score}") - #print(f"Bad Crawler score: {bad_crawler_score}") - #print(f"Regular User score: {regular_user_score}") + print(f"Attacker score: {attacker_score}") + print(f"Good Crawler score: {good_crawler_score}") + print(f"Bad Crawler score: {bad_crawler_score}") + print(f"Regular User score: {regular_user_score}") - analyzed_metrics = {"risky_http_methods": http_method_attacker_score, "robots_violations": violated_robots_ratio, "uneven_request_timing": mean, "different_user_agents": user_agents_used, "attack_url": attack_url_found} + analyzed_metrics = {"risky_http_methods": http_method_attacker_score, "robots_violations": violated_robots_ratio, "uneven_request_timing": mean, "different_user_agents": user_agents_used, "attack_url": attack_urls_found_list} category_scores = {"attacker": attacker_score, "good_crawler": good_crawler_score, "bad_crawler": bad_crawler_score, "regular_user": regular_user_score} category = max(category_scores, key=category_scores.get) last_analysis = datetime.utcnow() diff --git a/src/config.py b/src/config.py index 87fca1c..815a8ca 100644 --- a/src/config.py +++ b/src/config.py @@ -1,17 +1,22 @@ #!/usr/bin/env python3 import os +import sys from dataclasses import dataclass +from pathlib import Path from typing import Optional, Tuple from zoneinfo import ZoneInfo import time +import yaml + @dataclass class Config: """Configuration class for the deception server""" port: int = 5000 delay: int = 100 # milliseconds + server_header: str = "" links_length_range: Tuple[int, int] = (5, 15) links_per_page_range: Tuple[int, int] = (10, 15) char_space: str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' @@ -23,12 +28,20 @@ class Config: api_server_port: int = 8080 api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) - server_header: Optional[str] = None + # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 timezone: str = None # IANA timezone (e.g., 'America/New_York', 'Europe/Rome') - + + # Analyzer settings + http_risky_methods_threshold: float = None + violated_robots_threshold: float = None + uneven_request_timing_threshold: float = None + uneven_request_timing_time_window_seconds: float = None + user_agents_used_threshold: float = None + attack_urls_threshold: float = None + @staticmethod # Try to fetch timezone before if not set def get_system_timezone() -> str: @@ -38,16 +51,16 @@ class Config: tz_path = os.readlink('/etc/localtime') if 'zoneinfo/' in tz_path: return tz_path.split('zoneinfo/')[-1] - + local_tz = time.tzname[time.daylight] if local_tz and local_tz != 'UTC': return local_tz except Exception: pass - + # Default fallback to UTC return 'UTC' - + def get_timezone(self) -> ZoneInfo: """Get configured timezone as ZoneInfo object""" if self.timezone: @@ -55,7 +68,7 @@ class Config: return ZoneInfo(self.timezone) except Exception: pass - + system_tz = self.get_system_timezone() try: return ZoneInfo(system_tz) @@ -63,31 +76,83 @@ class Config: return ZoneInfo('UTC') @classmethod - def from_env(cls) -> 'Config': - """Create configuration from environment variables""" + def from_yaml(cls) -> 'Config': + """Create configuration from YAML file""" + config_location = os.getenv('CONFIG_LOCATION', 'config.yaml') + config_path = Path(__file__).parent.parent / config_location + + try: + with open(config_path, 'r') as f: + data = yaml.safe_load(f) + except FileNotFoundError: + print(f"Error: Configuration file '{config_path}' not found.", file=sys.stderr) + print(f"Please create a config.yaml file or set CONFIG_LOCATION environment variable.", file=sys.stderr) + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error: Invalid YAML in configuration file '{config_path}': {e}", file=sys.stderr) + sys.exit(1) + + if data is None: + data = {} + + # Extract nested values with defaults + server = data.get('server', {}) + links = data.get('links', {}) + canary = data.get('canary', {}) + dashboard = data.get('dashboard', {}) + api = data.get('api', {}) + database = data.get('database', {}) + behavior = data.get('behavior', {}) + analyzer = data.get('analyzer', {}) + + # Handle dashboard_secret_path - auto-generate if null/not set + dashboard_path = dashboard.get('secret_path') + if dashboard_path is None: + dashboard_path = f'/{os.urandom(16).hex()}' + else: + # ensure the dashboard path starts with a / + if dashboard_path[:1] != "/": + dashboard_path = f"/{dashboard_path}" + return cls( - port=int(os.getenv('PORT', 5000)), - delay=int(os.getenv('DELAY', 100)), + port=server.get('port', 5000), + delay=server.get('delay', 100), + server_header=server.get('server_header',""), + timezone=server.get('timezone'), links_length_range=( - int(os.getenv('LINKS_MIN_LENGTH', 5)), - int(os.getenv('LINKS_MAX_LENGTH', 15)) + links.get('min_length', 5), + links.get('max_length', 15) ), links_per_page_range=( - int(os.getenv('LINKS_MIN_PER_PAGE', 10)), - int(os.getenv('LINKS_MAX_PER_PAGE', 15)) + links.get('min_per_page', 10), + links.get('max_per_page', 15) ), - char_space=os.getenv('CHAR_SPACE', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), - max_counter=int(os.getenv('MAX_COUNTER', 10)), - canary_token_url=os.getenv('CANARY_TOKEN_URL'), - canary_token_tries=int(os.getenv('CANARY_TOKEN_TRIES', 10)), - dashboard_secret_path=os.getenv('DASHBOARD_SECRET_PATH', f'/{os.urandom(16).hex()}'), - api_server_url=os.getenv('API_SERVER_URL'), - api_server_port=int(os.getenv('API_SERVER_PORT', 8080)), - api_server_path=os.getenv('API_SERVER_PATH', '/api/v2/users'), - probability_error_codes=int(os.getenv('PROBABILITY_ERROR_CODES', 0)), - server_header=os.getenv('SERVER_HEADER'), - database_path=os.getenv('DATABASE_PATH', 'data/krawl.db'), - database_retention_days=int(os.getenv('DATABASE_RETENTION_DAYS', 30)), - timezone=os.getenv('TIMEZONE') # If not set, will use system timezone - + char_space=links.get('char_space', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), + max_counter=links.get('max_counter', 10), + canary_token_url=canary.get('token_url'), + canary_token_tries=canary.get('token_tries', 10), + dashboard_secret_path=dashboard_path, + api_server_url=api.get('server_url'), + api_server_port=api.get('server_port', 8080), + api_server_path=api.get('server_path', '/api/v2/users'), + probability_error_codes=behavior.get('probability_error_codes', 0), + database_path=database.get('path', 'data/krawl.db'), + database_retention_days=database.get('retention_days', 30), + http_risky_methods_threshold=analyzer.get('http_risky_methods_threshold', 0.1), + violated_robots_threshold=analyzer.get('violated_robots_threshold', 0.1), + uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 5), + uneven_request_timing_time_window_seconds=analyzer.get('uneven_request_timing_time_window_seconds', 300), + user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 1), + attack_urls_threshold=analyzer.get('attack_urls_threshold', 1) ) + + +_config_instance = None + + +def get_config() -> Config: + """Get the singleton Config instance""" + global _config_instance + if _config_instance is None: + _config_instance = Config.from_yaml() + return _config_instance diff --git a/src/generators.py b/src/generators.py index 6e24ba8..92eb590 100644 --- a/src/generators.py +++ b/src/generators.py @@ -9,8 +9,7 @@ import string import json from templates import html_templates from wordlists import get_wordlists -from config import Config -from logger import get_app_logger +from config import get_config def random_username() -> str: """Generate random username""" @@ -38,15 +37,12 @@ def random_email(username: str = None) -> str: return f"{username}@{random.choice(wl.email_domains)}" def random_server_header() -> str: - """Generate random server header""" - - if Config.from_env().server_header: - server_header = Config.from_env().server_header - else: - wl = get_wordlists() - server_header = random.choice(wl.server_headers) - - return server_header + """Generate random server header from wordlists""" + config = get_config() + if config.server_header: + return config.server_header + wl = get_wordlists() + return random.choice(wl.server_headers) def random_api_key() -> str: """Generate random API key""" diff --git a/src/handler.py b/src/handler.py index 1dd6a45..bbc87ea 100644 --- a/src/handler.py +++ b/src/handler.py @@ -6,6 +6,7 @@ import time from datetime import datetime from http.server import BaseHTTPRequestHandler from typing import Optional, List +from urllib.parse import urlparse, parse_qs from config import Config from tracker import AccessTracker @@ -17,6 +18,9 @@ from generators import ( api_response, directory_listing, random_server_header ) from wordlists import get_wordlists +from sql_errors import generate_sql_error_response, get_sql_response_with_data +from xss_detector import detect_xss_pattern, generate_xss_response +from server_errors import generate_server_error class Handler(BaseHTTPRequestHandler): @@ -69,6 +73,67 @@ class Handler(BaseHTTPRequestHandler): if not error_codes: error_codes = [400, 401, 403, 404, 500, 502, 503] return random.choice(error_codes) + + def _parse_query_string(self) -> str: + """Extract query string from the request path""" + parsed = urlparse(self.path) + return parsed.query + + def _handle_sql_endpoint(self, path: str) -> bool: + """ + Handle SQL injection honeypot endpoints. + Returns True if the path was handled, False otherwise. + """ + # SQL-vulnerable endpoints + sql_endpoints = ['/api/search', '/api/sql', '/api/database'] + + base_path = urlparse(path).path + if base_path not in sql_endpoints: + return False + + try: + # Get query parameters + query_string = self._parse_query_string() + + # Log SQL injection attempt + client_ip = self._get_client_ip() + user_agent = self._get_user_agent() + + # Always check for SQL injection patterns + error_msg, content_type, status_code = generate_sql_error_response(query_string or "") + + if error_msg: + # SQL injection detected - log and return error + self.access_logger.warning(f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}") + self.send_response(status_code) + self.send_header('Content-type', content_type) + self.end_headers() + self.wfile.write(error_msg.encode()) + else: + # No injection detected - return fake data + self.access_logger.info(f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}") + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + response_data = get_sql_response_with_data(base_path, query_string or "") + self.wfile.write(response_data.encode()) + + return True + + except BrokenPipeError: + # Client disconnected + return True + except Exception as e: + self.app_logger.error(f"Error handling SQL endpoint {path}: {str(e)}") + # Still send a response even on error + try: + self.send_response(500) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(b'{"error": "Internal server error"}') + except: + pass + return True def generate_page(self, seed: str) -> str: """Generate a webpage containing random links or canary token""" @@ -209,6 +274,68 @@ class Handler(BaseHTTPRequestHandler): user_agent = self._get_user_agent() post_data = "" + from urllib.parse import urlparse + base_path = urlparse(self.path).path + + if base_path in ['/api/search', '/api/sql', '/api/database']: + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + post_data = self.rfile.read(content_length).decode('utf-8', errors="replace") + + self.access_logger.info(f"[SQL ENDPOINT POST] {client_ip} - {base_path} - Data: {post_data[:100] if post_data else 'empty'}") + + error_msg, content_type, status_code = generate_sql_error_response(post_data) + + try: + if error_msg: + self.access_logger.warning(f"[SQL INJECTION DETECTED POST] {client_ip} - {base_path}") + self.send_response(status_code) + self.send_header('Content-type', content_type) + self.end_headers() + self.wfile.write(error_msg.encode()) + else: + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + response_data = get_sql_response_with_data(base_path, post_data) + self.wfile.write(response_data.encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error in SQL POST handler: {str(e)}") + return + + if base_path == '/api/contact': + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + post_data = self.rfile.read(content_length).decode('utf-8', errors="replace") + + parsed_data = {} + for pair in post_data.split('&'): + if '=' in pair: + key, value = pair.split('=', 1) + from urllib.parse import unquote_plus + parsed_data[unquote_plus(key)] = unquote_plus(value) + + xss_detected = any(detect_xss_pattern(v) for v in parsed_data.values()) + + if xss_detected: + self.access_logger.warning(f"[XSS ATTEMPT DETECTED] {client_ip} - {base_path} - Data: {post_data[:200]}") + else: + self.access_logger.info(f"[XSS ENDPOINT POST] {client_ip} - {base_path}") + + try: + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + response_html = generate_xss_response(parsed_data) + self.wfile.write(response_html.encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error in XSS POST handler: {str(e)}") + return + self.access_logger.warning(f"[LOGIN ATTEMPT] {client_ip} - {self.path} - {user_agent[:50]}") content_length = int(self.headers.get('Content-Length', 0)) @@ -250,6 +377,10 @@ class Handler(BaseHTTPRequestHandler): def serve_special_path(self, path: str) -> bool: """Serve special paths like robots.txt, API endpoints, etc.""" + # Check SQL injection honeypot endpoints first + if self._handle_sql_endpoint(path): + return True + try: if path == '/robots.txt': self.send_response(200) @@ -287,7 +418,28 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(html_templates.login_form().encode()) return True - # WordPress login page + if path in ['/users', '/user', '/database', '/db', '/search']: + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html_templates.product_search().encode()) + return True + + if path in ['/info', '/input', '/contact', '/feedback', '/comment']: + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(html_templates.input_form().encode()) + return True + + if path == '/server': + error_html, content_type = generate_server_error() + self.send_response(500) + self.send_header('Content-type', content_type) + self.end_headers() + self.wfile.write(error_html.encode()) + return True + if path in ['/wp-login.php', '/wp-login', '/wp-admin', '/wp-admin/']: self.send_response(200) self.send_header('Content-type', 'text/html') diff --git a/src/server.py b/src/server.py index 4431d55..59244c5 100644 --- a/src/server.py +++ b/src/server.py @@ -8,7 +8,7 @@ Run this file to start the server. import sys from http.server import HTTPServer -from config import Config +from config import get_config from tracker import AccessTracker from analyzer import Analyzer from handler import Handler @@ -21,24 +21,29 @@ def print_usage(): print(f'Usage: {sys.argv[0]} [FILE]\n') print('FILE is file containing a list of webpage names to serve, one per line.') print('If no file is provided, random links will be generated.\n') - print('Environment Variables:') - print(' PORT - Server port (default: 5000)') - print(' DELAY - Response delay in ms (default: 100)') - print(' LINKS_MIN_LENGTH - Min link length (default: 5)') - print(' LINKS_MAX_LENGTH - Max link length (default: 15)') - print(' LINKS_MIN_PER_PAGE - Min links per page (default: 10)') - print(' LINKS_MAX_PER_PAGE - Max links per page (default: 15)') - print(' MAX_COUNTER - Max counter value (default: 10)') - print(' CANARY_TOKEN_URL - Canary token URL to display') - print(' CANARY_TOKEN_TRIES - Number of tries before showing token (default: 10)') - print(' DASHBOARD_SECRET_PATH - Secret path for dashboard (auto-generated if not set)') - print(' PROBABILITY_ERROR_CODES - Probability (0-100) to return HTTP error codes (default: 0)') - print(' CHAR_SPACE - Characters for random links') - print(' SERVER_HEADER - HTTP Server header for deception (default: Apache/2.2.22 (Ubuntu))') - print(' DATABASE_PATH - Path to SQLite database (default: data/krawl.db)') - print(' DATABASE_RETENTION_DAYS - Days to retain database records (default: 30)') - print(' TIMEZONE - IANA timezone for logs/dashboard (e.g., America/New_York, Europe/Rome)') - print(' If not set, system timezone will be used') + print('Configuration:') + print(' Configuration is loaded from a YAML file (default: config.yaml)') + print(' Set CONFIG_LOCATION environment variable to use a different file.\n') + print(' Example config.yaml structure:') + print(' server:') + print(' port: 5000') + print(' delay: 100') + print(' timezone: null # or "America/New_York"') + print(' links:') + print(' min_length: 5') + print(' max_length: 15') + print(' min_per_page: 10') + print(' max_per_page: 15') + print(' canary:') + print(' token_url: null') + print(' token_tries: 10') + print(' dashboard:') + print(' secret_path: null # auto-generated if not set') + print(' database:') + print(' path: "data/krawl.db"') + print(' retention_days: 30') + print(' behavior:') + print(' probability_error_codes: 0') def main(): @@ -47,19 +52,17 @@ def main(): print_usage() exit(0) - config = Config.from_env() - + config = get_config() + # Get timezone configuration tz = config.get_timezone() - + # Initialize logging with timezone initialize_logging(timezone=tz) app_logger = get_app_logger() access_logger = get_access_logger() credential_logger = get_credential_logger() - config = Config.from_env() - # Initialize database for persistent storage try: initialize_database(config.database_path) diff --git a/src/server_errors.py b/src/server_errors.py new file mode 100644 index 0000000..7591c64 --- /dev/null +++ b/src/server_errors.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import random +from wordlists import get_wordlists + + +def generate_server_error() -> tuple[str, str]: + wl = get_wordlists() + server_errors = wl.server_errors + + if not server_errors: + return ("500 Internal Server Error", "text/html") + + server_type = random.choice(list(server_errors.keys())) + server_config = server_errors[server_type] + + error_codes = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable" + } + + code = random.choice(list(error_codes.keys())) + message = error_codes[code] + + template = server_config.get('template', '') + version = random.choice(server_config.get('versions', ['1.0'])) + + html = template.replace('{code}', str(code)) + html = html.replace('{message}', message) + html = html.replace('{version}', version) + + if server_type == 'apache': + os = random.choice(server_config.get('os', ['Ubuntu'])) + html = html.replace('{os}', os) + html = html.replace('{host}', 'localhost') + + return (html, "text/html") + + +def get_server_header(server_type: str = None) -> str: + wl = get_wordlists() + server_errors = wl.server_errors + + if not server_errors: + return "nginx/1.18.0" + + if not server_type: + server_type = random.choice(list(server_errors.keys())) + + server_config = server_errors.get(server_type, {}) + version = random.choice(server_config.get('versions', ['1.0'])) + + server_headers = { + 'nginx': f"nginx/{version}", + 'apache': f"Apache/{version}", + 'iis': f"Microsoft-IIS/{version}", + 'tomcat': f"Apache-Coyote/1.1" + } + + return server_headers.get(server_type, "nginx/1.18.0") diff --git a/src/sql_errors.py b/src/sql_errors.py new file mode 100644 index 0000000..dc84886 --- /dev/null +++ b/src/sql_errors.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + +import random +import re +from typing import Optional, Tuple +from wordlists import get_wordlists + + +def detect_sql_injection_pattern(query_string: str) -> Optional[str]: + if not query_string: + return None + + query_lower = query_string.lower() + + patterns = { + 'quote': [r"'", r'"', r'`'], + 'comment': [r'--', r'#', r'/\*', r'\*/'], + 'union': [r'\bunion\b', r'\bunion\s+select\b'], + 'boolean': [r'\bor\b.*=.*', r'\band\b.*=.*', r"'.*or.*'.*=.*'"], + 'time_based': [r'\bsleep\b', r'\bwaitfor\b', r'\bdelay\b', r'\bbenchmark\b'], + 'stacked': [r';.*select', r';.*drop', r';.*insert', r';.*update', r';.*delete'], + 'command': [r'\bexec\b', r'\bexecute\b', r'\bxp_cmdshell\b'], + 'info_schema': [r'information_schema', r'table_schema', r'table_name'], + } + + for injection_type, pattern_list in patterns.items(): + for pattern in pattern_list: + if re.search(pattern, query_lower): + return injection_type + + return None + + +def get_random_sql_error(db_type: str = None, injection_type: str = None) -> Tuple[str, str]: + wl = get_wordlists() + sql_errors = wl.sql_errors + + if not sql_errors: + return ("Database error occurred", "text/plain") + + if not db_type: + db_type = random.choice(list(sql_errors.keys())) + + db_errors = sql_errors.get(db_type, {}) + + if injection_type and injection_type in db_errors: + errors = db_errors[injection_type] + elif 'generic' in db_errors: + errors = db_errors['generic'] + else: + all_errors = [] + for error_list in db_errors.values(): + if isinstance(error_list, list): + all_errors.extend(error_list) + errors = all_errors if all_errors else ["Database error occurred"] + + error_message = random.choice(errors) if errors else "Database error occurred" + + if '{table}' in error_message: + tables = ['users', 'products', 'orders', 'customers', 'accounts', 'sessions'] + error_message = error_message.replace('{table}', random.choice(tables)) + + if '{column}' in error_message: + columns = ['id', 'name', 'email', 'password', 'username', 'created_at'] + error_message = error_message.replace('{column}', random.choice(columns)) + + return (error_message, "text/plain") + + +def generate_sql_error_response(query_string: str, db_type: str = None) -> Tuple[str, str, int]: + injection_type = detect_sql_injection_pattern(query_string) + + if not injection_type: + return (None, None, None) + + error_message, content_type = get_random_sql_error(db_type, injection_type) + + status_code = 500 + + if random.random() < 0.3: + status_code = 200 + + return (error_message, content_type, status_code) + + +def get_sql_response_with_data(path: str, params: str) -> str: + import json + from generators import random_username, random_email, random_password + + injection_type = detect_sql_injection_pattern(params) + + if injection_type in ['union', 'boolean', 'stacked']: + data = { + "success": True, + "results": [ + { + "id": i, + "username": random_username(), + "email": random_email(), + "password_hash": random_password(), + "role": random.choice(["admin", "user", "moderator"]) + } + for i in range(1, random.randint(2, 5)) + ] + } + return json.dumps(data, indent=2) + + return json.dumps({ + "success": True, + "message": "Query executed successfully", + "results": [] + }, indent=2) diff --git a/src/templates/html/generic_search.html b/src/templates/html/generic_search.html new file mode 100644 index 0000000..90171bc --- /dev/null +++ b/src/templates/html/generic_search.html @@ -0,0 +1,66 @@ + + + + Search + + + +

Search

+
+ + + +
+ + + + diff --git a/src/templates/html/input_form.html b/src/templates/html/input_form.html new file mode 100644 index 0000000..c03b1a8 --- /dev/null +++ b/src/templates/html/input_form.html @@ -0,0 +1,74 @@ + + + + Contact + + + +

Contact

+
+ + + + + +
+ + + + diff --git a/src/templates/html/robots.txt b/src/templates/html/robots.txt index 2bae8ca..3618937 100644 --- a/src/templates/html/robots.txt +++ b/src/templates/html/robots.txt @@ -11,8 +11,18 @@ Disallow: /login/ Disallow: /admin/login Disallow: /phpMyAdmin/ Disallow: /admin/login.php +Disallow: /users +Disallow: /search +Disallow: /contact +Disallow: /info +Disallow: /input +Disallow: /feedback +Disallow: /server Disallow: /api/v1/users Disallow: /api/v2/secrets +Disallow: /api/search +Disallow: /api/sql +Disallow: /api/database Disallow: /.env Disallow: /credentials.txt Disallow: /passwords.txt diff --git a/src/templates/html_templates.py b/src/templates/html_templates.py index c6ad09a..a7cefbc 100644 --- a/src/templates/html_templates.py +++ b/src/templates/html_templates.py @@ -50,3 +50,13 @@ def directory_listing(path: str, dirs: list, files: list) -> str: rows += row_template.format(href=f, name=f, date="2024-12-01 14:22", size=size) return load_template("directory_listing", path=path, rows=rows) + + +def product_search() -> str: + """Generate product search page with SQL injection honeypot""" + return load_template("generic_search") + + +def input_form() -> str: + """Generate input form page for XSS honeypot""" + return load_template("input_form") diff --git a/src/tracker.py b/src/tracker.py index 4c89c0b..cd8a187 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -6,7 +6,7 @@ from datetime import datetime from zoneinfo import ZoneInfo import re import urllib.parse - +from wordlists import get_wordlists from database import get_database, DatabaseManager @@ -37,14 +37,19 @@ class AccessTracker: 'burp', 'zap', 'w3af', 'metasploit', 'nuclei', 'gobuster', 'dirbuster' ] - # Common attack types such as xss, shell injection, probes - self.attack_types = { - 'path_traversal': r'\.\.', - 'sql_injection': r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", - 'xss_attempt': r'( bool: + if not input_string: + return False + + wl = get_wordlists() + xss_pattern = wl.attack_patterns.get('xss_attempt', '') + + if not xss_pattern: + xss_pattern = r'( str: + xss_detected = False + reflected_content = [] + + for key, value in input_data.items(): + if detect_xss_pattern(value): + xss_detected = True + reflected_content.append(f"

{key}: {value}

") + + if xss_detected: + html = f""" + + + + Submission Received + + + +
+

Thank you for your submission!

+

We have received your information:

+ {''.join(reflected_content)} +

We will get back to you shortly.

+
+ + +""" + return html + + return """ + + + + Submission Received + + + +
+

Thank you for your submission!

+

Your message has been received and we will respond soon.

+
+ + +""" diff --git a/tests/sim_attacks.sh b/tests/sim_attacks.sh index d4a72b2..3502c3a 100755 --- a/tests/sim_attacks.sh +++ b/tests/sim_attacks.sh @@ -17,4 +17,4 @@ curl -s "$TARGET/wp-admin/" echo -e "\n=== Testing Shell Injection ===" curl -s -X POST "$TARGET/ping" -d "host=127.0.0.1; cat /etc/passwd" -echo -e "\n=== Done ===" \ No newline at end of file +echo -e "\n=== Done ===" diff --git a/tests/test_sql_injection.sh b/tests/test_sql_injection.sh new file mode 100644 index 0000000..e178b3c --- /dev/null +++ b/tests/test_sql_injection.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +# Test script for SQL injection honeypot endpoints + +BASE_URL="http://localhost:5000" + +echo "=========================================" +echo "Testing SQL Injection Honeypot Endpoints" +echo "=========================================" +echo "" + +# Test 1: Normal query +echo "Test 1: Normal GET request to /api/search" +curl -s "${BASE_URL}/api/search?q=test" | head -20 +echo "" +echo "---" +echo "" + +# Test 2: SQL injection with single quote +echo "Test 2: SQL injection with single quote" +curl -s "${BASE_URL}/api/search?id=1'" | head -20 +echo "" +echo "---" +echo "" + +# Test 3: UNION-based injection +echo "Test 3: UNION-based SQL injection" +curl -s "${BASE_URL}/api/search?id=1%20UNION%20SELECT%20*" | head -20 +echo "" +echo "---" +echo "" + +# Test 4: Boolean-based injection +echo "Test 4: Boolean-based SQL injection" +curl -s "${BASE_URL}/api/sql?user=admin'%20OR%201=1--" | head -20 +echo "" +echo "---" +echo "" + +# Test 5: Comment-based injection +echo "Test 5: Comment-based SQL injection" +curl -s "${BASE_URL}/api/database?q=test'--" | head -20 +echo "" +echo "---" +echo "" + +# Test 6: Time-based injection +echo "Test 6: Time-based SQL injection" +curl -s "${BASE_URL}/api/search?id=1%20AND%20SLEEP(5)" | head -20 +echo "" +echo "---" +echo "" + +# Test 7: POST request with SQL injection +echo "Test 7: POST request with SQL injection" +curl -s -X POST "${BASE_URL}/api/search" -d "username=admin'%20OR%201=1--&password=test" | head -20 +echo "" +echo "---" +echo "" + +# Test 8: Information schema query +echo "Test 8: Information schema injection" +curl -s "${BASE_URL}/api/sql?table=information_schema.tables" | head -20 +echo "" +echo "---" +echo "" + +# Test 9: Stacked queries +echo "Test 9: Stacked queries injection" +curl -s "${BASE_URL}/api/database?id=1;DROP%20TABLE%20users" | head -20 +echo "" +echo "---" +echo "" + +echo "=========================================" +echo "Tests completed!" +echo "Check logs for detailed attack detection" +echo "=========================================" diff --git a/wordlists.json b/wordlists.json index 39ab698..833f1eb 100644 --- a/wordlists.json +++ b/wordlists.json @@ -194,6 +194,171 @@ 502, 503 ], + "server_errors": { + "nginx": { + "versions": ["1.18.0", "1.20.1", "1.22.0", "1.24.0"], + "template": "\n\n\n{code} {message}\n\n\n\n

An error occurred.

\n

Sorry, the page you are looking for is currently unavailable.
\nPlease try again later.

\n

If you are the system administrator of this resource then you should check the error log for details.

\n

Faithfully yours, nginx/{version}.

\n\n" + }, + "apache": { + "versions": ["2.4.41", "2.4.52", "2.4.54", "2.4.57"], + "os": ["Ubuntu", "Debian", "CentOS"], + "template": "\n\n{code} {message}\n\n

{message}

\n

The requested URL was not found on this server.

\n
\n
Apache/{version} ({os}) Server at {host} Port 80
\n" + }, + "iis": { + "versions": ["10.0", "8.5", "8.0"], + "template": "\n\n\n\n{code} - {message}\n\n\n\n

Server Error

\n
\n
\n

{code} - {message}

\n

The page cannot be displayed because an internal server error has occurred.

\n
\n
\n\n" + }, + "tomcat": { + "versions": ["9.0.65", "10.0.27", "10.1.5"], + "template": "HTTP Status {code} - {message}

HTTP Status {code} - {message}


Type Status Report

Description The server encountered an internal error that prevented it from fulfilling this request.


Apache Tomcat/{version}

" + } + }, + "sql_errors": { + "mysql": { + "generic": [ + "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''1'' at line 1", + "Unknown column '{column}' in 'where clause'", + "Table '{table}' doesn't exist", + "Operand should contain 1 column(s)", + "Subquery returns more than 1 row", + "Duplicate entry 'admin' for key 'PRIMARY'" + ], + "quote": [ + "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''''' at line 1", + "Unclosed quotation mark after the character string ''", + "You have an error in your SQL syntax near '\\'' LIMIT 0,30'" + ], + "union": [ + "The used SELECT statements have a different number of columns", + "Operand should contain 1 column(s)", + "Mixing of GROUP columns (MIN(),MAX(),COUNT(),...) with no GROUP columns is illegal" + ], + "boolean": [ + "You have an error in your SQL syntax near 'OR 1=1' at line 1", + "Unknown column '1' in 'where clause'" + ], + "time_based": [ + "Query execution was interrupted", + "Lock wait timeout exceeded; try restarting transaction" + ], + "comment": [ + "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '--' at line 1" + ] + }, + "postgresql": { + "generic": [ + "ERROR: syntax error at or near \"1\"", + "ERROR: column \"{column}\" does not exist", + "ERROR: relation \"{table}\" does not exist", + "ERROR: operator does not exist: integer = text", + "ERROR: invalid input syntax for type integer: \"admin\"" + ], + "quote": [ + "ERROR: unterminated quoted string at or near \"'\"", + "ERROR: syntax error at or near \"'\"", + "ERROR: unterminated quoted identifier at or near \"'\"" + ], + "union": [ + "ERROR: each UNION query must have the same number of columns", + "ERROR: UNION types integer and text cannot be matched" + ], + "boolean": [ + "ERROR: syntax error at or near \"OR\"", + "ERROR: invalid input syntax for type boolean: \"1=1\"" + ], + "time_based": [ + "ERROR: canceling statement due to user request", + "ERROR: function pg_sleep(integer) does not exist" + ], + "info_schema": [ + "ERROR: permission denied for table {table}", + "ERROR: permission denied for schema information_schema" + ] + }, + "mssql": { + "generic": [ + "Msg 102, Level 15, State 1, Line 1\nIncorrect syntax near '1'.", + "Msg 207, Level 16, State 1, Line 1\nInvalid column name '{column}'.", + "Msg 208, Level 16, State 1, Line 1\nInvalid object name '{table}'.", + "Msg 245, Level 16, State 1, Line 1\nConversion failed when converting the varchar value 'admin' to data type int." + ], + "quote": [ + "Msg 105, Level 15, State 1, Line 1\nUnclosed quotation mark after the character string ''.", + "Msg 102, Level 15, State 1, Line 1\nIncorrect syntax near '''." + ], + "union": [ + "Msg 205, Level 16, State 1, Line 1\nAll queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.", + "Msg 8167, Level 16, State 1, Line 1\nThe type of column \"{column}\" conflicts with the type of other columns specified in the UNION, INTERSECT, or EXCEPT list." + ], + "boolean": [ + "Msg 102, Level 15, State 1, Line 1\nIncorrect syntax near 'OR'." + ], + "command": [ + "Msg 15281, Level 16, State 1, Procedure xp_cmdshell, Line 1\nSQL Server blocked access to procedure 'sys.xp_cmdshell' of component 'xp_cmdshell'" + ] + }, + "oracle": { + "generic": [ + "ORA-00933: SQL command not properly ended", + "ORA-00904: \"{column}\": invalid identifier", + "ORA-00942: table or view \"{table}\" does not exist", + "ORA-01722: invalid number", + "ORA-01756: quoted string not properly terminated" + ], + "quote": [ + "ORA-01756: quoted string not properly terminated", + "ORA-00933: SQL command not properly ended" + ], + "union": [ + "ORA-01789: query block has incorrect number of result columns", + "ORA-01790: expression must have same datatype as corresponding expression" + ], + "boolean": [ + "ORA-00933: SQL command not properly ended", + "ORA-00920: invalid relational operator" + ] + }, + "sqlite": { + "generic": [ + "near \"1\": syntax error", + "no such column: {column}", + "no such table: {table}", + "unrecognized token: \"'\"", + "incomplete input" + ], + "quote": [ + "unrecognized token: \"'\"", + "incomplete input", + "near \"'\": syntax error" + ], + "union": [ + "SELECTs to the left and right of UNION do not have the same number of result columns" + ] + }, + "mongodb": { + "generic": [ + "MongoError: Can't canonicalize query: BadValue unknown operator: $where", + "MongoError: Failed to parse: { $where: \"this.{column} == '1'\" }", + "SyntaxError: unterminated string literal", + "MongoError: exception: invalid operator: $gt" + ], + "quote": [ + "SyntaxError: unterminated string literal", + "SyntaxError: missing } after property list" + ], + "command": [ + "MongoError: $where is not allowed in this context", + "MongoError: can't eval: security" + ] + } + }, + "attack_patterns": { + "path_traversal": "\\.\\.", + "sql_injection": "('|\"|`|--|#|/\\*|\\*/|\\bunion\\b|\\bunion\\s+select\\b|\\bor\\b.*=.*|\\band\\b.*=.*|'.*or.*'.*=.*'|\\bsleep\\b|\\bwaitfor\\b|\\bdelay\\b|\\bbenchmark\\b|;.*select|;.*drop|;.*insert|;.*update|;.*delete|\\bexec\\b|\\bexecute\\b|\\bxp_cmdshell\\b|information_schema|table_schema|table_name)", + "xss_attempt": "( Date: Mon, 5 Jan 2026 10:01:51 +0100 Subject: [PATCH 19/70] modified default analyzer values --- config.yaml | 12 ++++++------ src/analyzer.py | 10 +++------- src/config.py | 4 ++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/config.yaml b/config.yaml index 987588c..48394dd 100644 --- a/config.yaml +++ b/config.yaml @@ -38,9 +38,9 @@ behavior: probability_error_codes: 0 # 0-100 percentage analyzer: - http_risky_methods_threshold: 0.1 - violated_robots_threshold: 0.1 - uneven_request_timing_threshold: 5 - uneven_request_timing_time_window_seconds: 300 - user_agents_used_threshold: 1 - attack_urls_threshold: 1 \ No newline at end of file + # http_risky_methods_threshold: 0.1 + # violated_robots_threshold: 0.1 + # uneven_request_timing_threshold: 5 + # uneven_request_timing_time_window_seconds: 300 + # user_agents_used_threshold: 2 + # attack_urls_threshold: 1 \ No newline at end of file diff --git a/src/analyzer.py b/src/analyzer.py index 48c5fad..feffc8a 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -111,9 +111,7 @@ class Analyzer: delete_accesses_count = len([item for item in accesses if item["method"] == "DELETE"]) head_accesses_count = len([item for item in accesses if item["method"] == "HEAD"]) options_accesses_count = len([item for item in accesses if item["method"] == "OPTIONS"]) - patch_accesses_count = len([item for item in accesses if item["method"] == "PATCH"]) - #print(f"TOTAL: {total_accesses_count} - GET: {get_accesses_count} - POST: {post_accesses_count}") - + patch_accesses_count = len([item for item in accesses if item["method"] == "PATCH"]) if total_accesses_count > http_risky_methods_threshold: http_method_attacker_score = (post_accesses_count + put_accesses_count + delete_accesses_count + options_accesses_count + patch_accesses_count) / total_accesses_count @@ -131,10 +129,6 @@ class Analyzer: score["good_crawler"]["risky_http_methods"] = False score["bad_crawler"]["risky_http_methods"] = False score["regular_user"]["risky_http_methods"] = False - - #print(f"Updated score: {score}") - - #--------------------- Robots Violations --------------------- #respect robots.txt and login/config pages access frequency @@ -248,6 +242,8 @@ class Analyzer: #--------------------- Calculate score --------------------- + attacker_score = good_crawler_score = bad_crawler_score = regular_user_score = 0 + attacker_score = score["attacker"]["risky_http_methods"] * weights["attacker"]["risky_http_methods"] attacker_score = attacker_score + score["attacker"]["robots_violations"] * weights["attacker"]["robots_violations"] attacker_score = attacker_score + score["attacker"]["uneven_request_timing"] * weights["attacker"]["uneven_request_timing"] diff --git a/src/config.py b/src/config.py index 815a8ca..58d6616 100644 --- a/src/config.py +++ b/src/config.py @@ -103,7 +103,7 @@ class Config: api = data.get('api', {}) database = data.get('database', {}) behavior = data.get('behavior', {}) - analyzer = data.get('analyzer', {}) + analyzer = data.get('analyzer') or {} # Handle dashboard_secret_path - auto-generate if null/not set dashboard_path = dashboard.get('secret_path') @@ -142,7 +142,7 @@ class Config: violated_robots_threshold=analyzer.get('violated_robots_threshold', 0.1), uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 5), uneven_request_timing_time_window_seconds=analyzer.get('uneven_request_timing_time_window_seconds', 300), - user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 1), + user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 2), attack_urls_threshold=analyzer.get('attack_urls_threshold', 1) ) From bd8c326918f6c9dd362a4915f2f25476d6e87ae0 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Mon, 5 Jan 2026 16:54:43 +0100 Subject: [PATCH 20/70] tuned weights --- src/analyzer.py | 16 ++++++++-------- src/config.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/analyzer.py b/src/analyzer.py index feffc8a..a745813 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -69,12 +69,12 @@ class Analyzer: "attacker": { "risky_http_methods": 6, "robots_violations": 4, - "uneven_request_timing": 5, + "uneven_request_timing": 3, "different_user_agents": 8, "attack_url": 15 }, "good_crawler": { - "risky_http_methods": 0, + "risky_http_methods": 1, "robots_violations": 0, "uneven_request_timing": 0, "different_user_agents": 0, @@ -82,7 +82,7 @@ class Analyzer: }, "bad_crawler": { "risky_http_methods": 2, - "robots_violations": 4, + "robots_violations": 7, "uneven_request_timing": 0, "different_user_agents": 5, "attack_url": 5 @@ -126,14 +126,14 @@ class Analyzer: score["regular_user"]["risky_http_methods"] = False else: score["attacker"]["risky_http_methods"] = False - score["good_crawler"]["risky_http_methods"] = False + score["good_crawler"]["risky_http_methods"] = True score["bad_crawler"]["risky_http_methods"] = False score["regular_user"]["risky_http_methods"] = False #--------------------- Robots Violations --------------------- #respect robots.txt and login/config pages access frequency robots_disallows = [] - robots_path = config_path = Path(__file__).parent / "templates" / "html" / "robots.txt" + robots_path = Path(__file__).parent / "templates" / "html" / "robots.txt" with open(robots_path, "r") as f: for line in f: line = line.strip() @@ -185,9 +185,9 @@ class Analyzer: variance = sum((x - mean) ** 2 for x in time_diffs) / len(time_diffs) std = variance ** 0.5 cv = std/mean - #print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") + print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") - if mean >= uneven_request_timing_threshold: + if cv >= uneven_request_timing_threshold: score["attacker"]["uneven_request_timing"] = True score["good_crawler"]["uneven_request_timing"] = False score["bad_crawler"]["uneven_request_timing"] = False @@ -227,7 +227,7 @@ class Analyzer: for queried_path in queried_paths: for name, pattern in wl.attack_urls.items(): if re.search(pattern, queried_path, re.IGNORECASE): - attack_url_found_list.append(pattern) + attack_urls_found_list.append(pattern) if len(attack_urls_found_list) > attack_urls_threshold: score["attacker"]["attack_url"] = True diff --git a/src/config.py b/src/config.py index 58d6616..66938b1 100644 --- a/src/config.py +++ b/src/config.py @@ -140,7 +140,7 @@ class Config: database_retention_days=database.get('retention_days', 30), http_risky_methods_threshold=analyzer.get('http_risky_methods_threshold', 0.1), violated_robots_threshold=analyzer.get('violated_robots_threshold', 0.1), - uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 5), + uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 0.5), # coefficient of variation uneven_request_timing_time_window_seconds=analyzer.get('uneven_request_timing_time_window_seconds', 300), user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 2), attack_urls_threshold=analyzer.get('attack_urls_threshold', 1) From 4478c6095669b085f167c87d8f187110782ffa06 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Mon, 5 Jan 2026 17:07:10 +0100 Subject: [PATCH 21/70] added krawl homepage to templates --- config.yaml | 2 +- src/handler.py | 105 +++--------------------------- src/templates/html/main_page.html | 91 ++++++++++++++++++++++++++ src/templates/html_templates.py | 5 ++ 4 files changed, 106 insertions(+), 97 deletions(-) create mode 100644 src/templates/html/main_page.html diff --git a/config.yaml b/config.yaml index 48394dd..6e09f30 100644 --- a/config.yaml +++ b/config.yaml @@ -6,7 +6,7 @@ server: timezone: null # e.g., "America/New_York" or null for system default # manually set the server header, if null a random one will be used. - server_header: "Apache/2.2.22 (Ubuntu)" + server_header: null links: min_length: 5 diff --git a/src/handler.py b/src/handler.py index bbc87ea..eef528d 100644 --- a/src/handler.py +++ b/src/handler.py @@ -140,108 +140,25 @@ class Handler(BaseHTTPRequestHandler): random.seed(seed) num_pages = random.randint(*self.config.links_per_page_range) - html = f""" - - - - Krawl - - - -
-

Krawl me! 🕸

-
{Handler.counter}
+ # Build the content HTML + content = "" - -
- -""" - return html + # Return the complete page using the template + return html_templates.main_page(Handler.counter, content) def do_HEAD(self): """Sends header information""" diff --git a/src/templates/html/main_page.html b/src/templates/html/main_page.html new file mode 100644 index 0000000..4be4916 --- /dev/null +++ b/src/templates/html/main_page.html @@ -0,0 +1,91 @@ + + + + + Krawl + + + +
+

Krawl me! 🕸

+
{counter}
+ + +
+ + \ No newline at end of file diff --git a/src/templates/html_templates.py b/src/templates/html_templates.py index a7cefbc..50d94dc 100644 --- a/src/templates/html_templates.py +++ b/src/templates/html_templates.py @@ -60,3 +60,8 @@ def product_search() -> str: def input_form() -> str: """Generate input form page for XSS honeypot""" return load_template("input_form") + + +def main_page(counter: int, content: str) -> str: + """Generate main Krawl page with links and canary token""" + return load_template("main_page", counter=counter, content=content) From 190d74e1a7c0255e4d1a19a9a5a77aedec8cfe85 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Mon, 5 Jan 2026 17:27:27 +0100 Subject: [PATCH 22/70] modified krawl template for single page visualization --- src/templates/dashboard_template.py | 98 ++++++++++++++++++++++++++--- src/templates/html/main_page.html | 50 +++++++++++---- src/wordlists.py | 1 + 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 455833d..dfad3dd 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -156,11 +156,35 @@ def generate_dashboard(stats: dict) -> str: background: #1c1917; border-left: 4px solid #f85149; }} + th.sortable {{ + cursor: pointer; + user-select: none; + position: relative; + padding-right: 24px; + }} + th.sortable:hover {{ + background: #1c2128; + }} + th.sortable::after {{ + content: '⇅'; + position: absolute; + right: 8px; + opacity: 0.5; + font-size: 12px; + }} + th.sortable.asc::after {{ + content: '▲'; + opacity: 1; + }} + th.sortable.desc::after {{ + content: '▼'; + opacity: 1; + }}
-

🕷️ Krawl Dashboard

+

Krawl Dashboard

@@ -190,13 +214,13 @@ def generate_dashboard(stats: dict) -> str:
-

🍯 Honeypot Triggers by IP

-
+

Honeypot Triggers by IP

+
- + - + @@ -206,7 +230,7 @@ def generate_dashboard(stats: dict) -> str:
-

⚠️ Recent Suspicious Activity

+

Recent Suspicious Activity

IP AddressIP Address Accessed PathsCountCount
@@ -223,7 +247,7 @@ def generate_dashboard(stats: dict) -> str:
-

🔑 Captured Credentials

+

Captured Credentials

@@ -241,7 +265,7 @@ def generate_dashboard(stats: dict) -> str:
-

😈 Detected Attack Types

+

Detected Attack Types

@@ -306,6 +330,64 @@ def generate_dashboard(stats: dict) -> str:
+ """ diff --git a/src/templates/html/main_page.html b/src/templates/html/main_page.html index 4be4916..d0b39de 100644 --- a/src/templates/html/main_page.html +++ b/src/templates/html/main_page.html @@ -9,40 +9,64 @@ background-color: #0d1117; color: #c9d1d9; margin: 0; - padding: 40px 20px; - min-height: 100vh; + padding: 0; + height: 100vh; display: flex; flex-direction: column; align-items: center; + overflow: hidden; }} .container {{ max-width: 1200px; width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + padding: 20px; + box-sizing: border-box; }} h1 {{ color: #f85149; text-align: center; - font-size: 48px; - margin: 60px 0 30px; + font-size: 36px; + margin: 40px 0 20px 0; + flex-shrink: 0; }} .counter {{ color: #f85149; text-align: center; - font-size: 56px; + font-size: 32px; font-weight: bold; - margin-bottom: 60px; + margin: 0 0 30px 0; + flex-shrink: 0; }} .links-container {{ display: flex; flex-direction: column; - gap: 20px; + gap: 10px; align-items: center; + overflow-y: auto; + flex: 1; + padding-top: 10px; + }} + .links-container::-webkit-scrollbar {{ + width: 8px; + }} + .links-container::-webkit-scrollbar-track {{ + background: #0d1117; + }} + .links-container::-webkit-scrollbar-thumb {{ + background: #30363d; + border-radius: 4px; + }} + .links-container::-webkit-scrollbar-thumb:hover {{ + background: #484f58; }} .link-box {{ background: #161b22; border: 1px solid #30363d; border-radius: 6px; - padding: 15px 30px; + padding: 10px 20px; min-width: 300px; text-align: center; transition: all 0.3s ease; @@ -56,7 +80,7 @@ a {{ color: #58a6ff; text-decoration: none; - font-size: 20px; + font-size: 16px; font-weight: 700; }} a:hover {{ @@ -66,21 +90,21 @@ background: #1c1917; border: 2px solid #f85149; border-radius: 8px; - padding: 30px 50px; - margin: 40px auto; + padding: 20px 30px; + margin: 20px auto; max-width: 800px; overflow-x: auto; }} .canary-token a {{ color: #f85149; - font-size: 18px; + font-size: 14px; white-space: nowrap; }}
-

Krawl me! 🕸

+

Krawl me!

{counter}
'; }} + // Category History Timeline + if (stats.category_history && stats.category_history.length > 0) {{ + html += '
'; + html += '
Behavior Timeline
'; + html += '
'; + + stats.category_history.forEach((change, index) => {{ + const categoryClass = change.new_category.toLowerCase().replace('_', '-'); + const timestamp = new Date(change.timestamp).toLocaleString(); + + html += '
'; + html += `
`; + html += '
'; + + if (change.old_category) {{ + const oldCategoryBadge = 'category-' + change.old_category.toLowerCase().replace('_', '-'); + html += `${{change.old_category}}`; + html += ''; + }} else {{ + html += 'Initial: '; + }} + + const newCategoryBadge = 'category-' + change.new_category.toLowerCase().replace('_', '-'); + html += `${{change.new_category}}`; + html += `
${{timestamp}}
`; + html += '
'; + html += '
'; + }}); + + html += '
'; + html += '
'; + }} + html += '
'; // Radar chart on the right if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{ html += '
'; + html += '
Category Score
'; html += ''; const scores = {{ @@ -705,7 +805,7 @@ def generate_dashboard(stats: dict) -> str: // Draw axes const angles = [0, 90, 180, 270]; - const keys = ['attacker', 'good_crawler', 'bad_crawler', 'regular_user']; + const keys = ['good_crawler', 'regular_user', 'bad_crawler', 'attacker']; angles.forEach((angle, i) => {{ const rad = (angle - 90) * Math.PI / 180; @@ -713,8 +813,8 @@ def generate_dashboard(stats: dict) -> str: const y2 = cy + maxRadius * Math.sin(rad); html += ``; - // Add labels - const labelDist = maxRadius + 30; + // Add labels at consistent distance + const labelDist = maxRadius + 35; const lx = cx + labelDist * Math.cos(rad); const ly = cy + labelDist * Math.sin(rad); html += `${{labels[keys[i]]}}`; @@ -755,7 +855,7 @@ def generate_dashboard(stats: dict) -> str: keys.forEach(key => {{ html += '
'; html += `
`; - html += `${{labels[key]}}: ${{scores[key]}}%`; + html += `${{labels[key]}}: ${{scores[key]}} pt`; html += '
'; }}); html += '
'; From edb288a27157cf85993dad9940f90c053caa3ae1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Wed, 7 Jan 2026 12:33:43 -0600 Subject: [PATCH 26/70] Fixed some print statements to leverage logging, pulled in most recent dev edits, added exports to gitignore --- .gitignore | 3 +++ src/analyzer.py | 18 ++++++++++++------ src/database.py | 12 ++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 70b93e4..63ae0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ data/ # Personal canary tokens or sensitive configs *canary*token*.yaml personal-values.yaml + +#exports dir (keeping .gitkeep so we have the dir) +/exports/* \ No newline at end of file diff --git a/src/analyzer.py b/src/analyzer.py index a745813..b10e4e7 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -8,10 +8,13 @@ from datetime import datetime, timedelta import re from wordlists import get_wordlists from config import get_config +from logger import get_app_logger """ Functions for user activity analysis """ +app_logger = get_app_logger() + class Analyzer: """ Analyzes users activity and produces aggregated insights @@ -56,7 +59,7 @@ class Analyzer: attack_urls_threshold = config.attack_urls_threshold uneven_request_timing_time_window_seconds = config.uneven_request_timing_time_window_seconds - print(f"http_risky_methods_threshold: {http_risky_methods_threshold}") + app_logger.debug(f"http_risky_methods_threshold: {http_risky_methods_threshold}") score = {} score["attacker"] = {"risky_http_methods": False, "robots_violations": False, "uneven_request_timing": False, "different_user_agents": False, "attack_url": False} @@ -185,7 +188,7 @@ class Analyzer: variance = sum((x - mean) ** 2 for x in time_diffs) / len(time_diffs) std = variance ** 0.5 cv = std/mean - print(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") + app_logger.debug(f"Mean: {mean} - Variance {variance} - Standard Deviation {std} - Coefficient of Variation: {cv}") if cv >= uneven_request_timing_threshold: score["attacker"]["uneven_request_timing"] = True @@ -268,10 +271,13 @@ class Analyzer: regular_user_score = regular_user_score + score["regular_user"]["different_user_agents"] * weights["regular_user"]["different_user_agents"] regular_user_score = regular_user_score + score["regular_user"]["attack_url"] * weights["regular_user"]["attack_url"] - print(f"Attacker score: {attacker_score}") - print(f"Good Crawler score: {good_crawler_score}") - print(f"Bad Crawler score: {bad_crawler_score}") - print(f"Regular User score: {regular_user_score}") + score_details = f""" + Attacker score: {attacker_score} + Good Crawler score: {good_crawler_score} + Bad Crawler score: {bad_crawler_score} + Regular User score: {regular_user_score} + """ + app_logger.debug(score_details) analyzed_metrics = {"risky_http_methods": http_method_attacker_score, "robots_violations": violated_robots_ratio, "uneven_request_timing": mean, "different_user_agents": user_agents_used, "attack_url": attack_urls_found_list} category_scores = {"attacker": attacker_score, "good_crawler": good_crawler_score, "bad_crawler": bad_crawler_score, "regular_user": regular_user_score} diff --git a/src/database.py b/src/database.py index 0245105..c184e9e 100644 --- a/src/database.py +++ b/src/database.py @@ -22,6 +22,9 @@ from sanitizer import ( sanitize_attack_pattern, ) +from logger import get_app_logger + +applogger = get_app_logger() class DatabaseManager: """ @@ -154,7 +157,7 @@ class DatabaseManager: except Exception as e: session.rollback() # Log error but don't crash - database persistence is secondary to honeypot function - print(f"Database error persisting access: {e}") + applogger.critical(f"Database error persisting access: {e}") return None finally: self.close_session() @@ -193,7 +196,7 @@ class DatabaseManager: except Exception as e: session.rollback() - print(f"Database error persisting credential: {e}") + applogger.critical(f"Database error persisting credential: {e}") return None finally: self.close_session() @@ -236,7 +239,8 @@ class DatabaseManager: last_analysis: timestamp of last analysis """ - print(f"Analyzed metrics {analyzed_metrics}, category {category}, category scores {category_scores}, last analysis {last_analysis}") + applogger.debug(f"Analyzed metrics {analyzed_metrics}, category {category}, category scores {category_scores}, last analysis {last_analysis}") + applogger.info(f"IP: {ip} category has been updated to {category}") session = self.session sanitized_ip = sanitize_ip(ip) @@ -295,7 +299,7 @@ class DatabaseManager: session.commit() except Exception as e: session.rollback() - print(f"Error recording category change: {e}") + applogger.error(f"Error recording category change: {e}") def get_category_history(self, ip: str) -> List[Dict[str, Any]]: """ From 4f42b946f34772a7f40a6728cc075eb6ce90982b Mon Sep 17 00:00:00 2001 From: Leonardo Bambini Date: Wed, 7 Jan 2026 22:56:01 +0100 Subject: [PATCH 27/70] added ip rep fetch + bug fix --- src/analyzer.py | 36 ++++++++++++++++++++++++++++++++++++ src/database.py | 27 ++++++++++++++++++++++++++- src/handler.py | 1 + src/models.py | 33 ++------------------------------- src/sanitizer.py | 5 ++++- 5 files changed, 69 insertions(+), 33 deletions(-) diff --git a/src/analyzer.py b/src/analyzer.py index a745813..85ce529 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -8,6 +8,9 @@ from datetime import datetime, timedelta import re from wordlists import get_wordlists from config import get_config +import requests +from sanitizer import sanitize_for_storage, sanitize_dict + """ Functions for user activity analysis """ @@ -228,6 +231,10 @@ class Analyzer: for name, pattern in wl.attack_urls.items(): if re.search(pattern, queried_path, re.IGNORECASE): attack_urls_found_list.append(pattern) + + #remove duplicates + attack_urls_found_list = set(attack_urls_found_list) + attack_urls_found_list = list(attack_urls_found_list) if len(attack_urls_found_list) > attack_urls_threshold: score["attacker"]["attack_url"] = True @@ -281,3 +288,32 @@ class Analyzer: self._db_manager.update_ip_stats_analysis(ip, analyzed_metrics, category, category_scores, last_analysis) return 0 + + def update_ip_rep_infos(self, ip: str) -> list[str]: + api_url = "https://iprep.lcrawl.com/api/iprep/" + params = { + "cidr": ip + } + headers = { + "Content-Type": "application/json" + } + + response = requests.get(api_url, headers=headers, params=params) + payload = response.json() + + if payload["results"]: + data = payload["results"][0] + + country_iso_code = data["geoip_data"]["country_iso_code"] + asn = data["geoip_data"]["asn_autonomous_system_number"] + asn_org = data["geoip_data"]["asn_autonomous_system_organization"] + list_on = data["list_on"] + + sanitized_country_iso_code = sanitize_for_storage(country_iso_code, 3) + sanitized_asn = sanitize_for_storage(asn, 100) + sanitized_asn_org = sanitize_for_storage(asn_org, 100) + sanitized_list_on = sanitize_dict(list_on, 100000) + + self._db_manager.update_ip_rep_infos(ip, sanitized_country_iso_code, sanitized_asn, sanitized_asn_org, sanitized_list_on) + + return \ No newline at end of file diff --git a/src/database.py b/src/database.py index 9d8e444..b5622db 100644 --- a/src/database.py +++ b/src/database.py @@ -246,7 +246,7 @@ class DatabaseManager: ip_stats.category_scores = category_scores ip_stats.last_analysis = last_analysis - def manual_update_category(self, ip: str, category: str) -> None: + def manual_update_category(self, ip: str, category: str) -> None: """ Update IP category as a result of a manual intervention by an admin @@ -257,11 +257,36 @@ class DatabaseManager: """ session = self.session + sanitized_ip = sanitize_ip(ip) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + ip_stats.category = category ip_stats.manual_category = True + def update_ip_rep_infos(self, ip: str, country_code: str, asn: str, asn_org: str, list_on: Dict[str,str]) -> None: + """ + Update IP rep stats + + Args: + ip: IP address + country_code: IP address country code + asn: IP address ASN + asn_org: IP address ASN ORG + list_on: public lists containing the IP address + + """ + session = self.session + + sanitized_ip = sanitize_ip(ip) + ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + + ip_stats.country_code = country_code + ip_stats.asn = asn + ip_stats.asn_org = asn_org + ip_stats.list_on = list_on + + def get_access_logs( self, limit: int = 100, diff --git a/src/handler.py b/src/handler.py index eef528d..00238e7 100644 --- a/src/handler.py +++ b/src/handler.py @@ -417,6 +417,7 @@ class Handler(BaseHTTPRequestHandler): self.tracker.record_access(client_ip, self.path, user_agent, method='GET') self.analyzer.infer_user_category(client_ip) + self.analyzer.update_ip_rep_infos(client_ip) if self.tracker.is_suspicious_user_agent(user_agent): self.access_logger.warning(f"[SUSPICIOUS] {client_ip} - {user_agent[:50]} - {self.path}") diff --git a/src/models.py b/src/models.py index 190ef26..5e5cd2c 100644 --- a/src/models.py +++ b/src/models.py @@ -134,6 +134,7 @@ class IpStats(Base): city: Mapped[Optional[str]] = mapped_column(String(MAX_CITY_LENGTH), nullable=True) asn: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) asn_org: Mapped[Optional[str]] = mapped_column(String(MAX_ASN_ORG_LENGTH), nullable=True) + list_on: Mapped[Optional[Dict[str,str]]] = mapped_column(JSON, nullable=True) # Reputation fields (populated by future enrichment) reputation_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) @@ -149,34 +150,4 @@ class IpStats(Base): def __repr__(self) -> str: - return f"" - -# class IpLog(Base): -# """ -# Records all IPs that have accessed the honeypot, along with aggregated stats and inferred user category. -# """ -# __tablename__ = 'ip_logs' - -# id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) -# ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), nullable=False, index=True) -# stats: Mapped[List[str]] = mapped_column(String(MAX_PATH_LENGTH)) -# category: Mapped[str] = mapped_column(String(15)) -# manual_category: Mapped[bool] = mapped_column(Boolean, default=False) -# last_analysis: Mapped[datetime] = mapped_column(DateTime, index=True), - -# # Relationship to attack detections -# access_logs: Mapped[List["AccessLog"]] = relationship( -# "AccessLog", -# back_populates="ip", -# cascade="all, delete-orphan" -# ) - -# # Indexes for common queries -# __table_args__ = ( -# Index('ix_access_logs_ip_timestamp', 'ip', 'timestamp'), -# Index('ix_access_logs_is_suspicious', 'is_suspicious'), -# Index('ix_access_logs_is_honeypot_trigger', 'is_honeypot_trigger'), -# ) - -# def __repr__(self) -> str: -# return f"" \ No newline at end of file + return f"" \ No newline at end of file diff --git a/src/sanitizer.py b/src/sanitizer.py index f783129..a04d0c0 100644 --- a/src/sanitizer.py +++ b/src/sanitizer.py @@ -7,7 +7,7 @@ Protects against SQL injection payloads, XSS, and storage exhaustion attacks. import html import re -from typing import Optional +from typing import Optional, Dict # Field length limits for database storage @@ -111,3 +111,6 @@ def escape_html_truncated(value: Optional[str], max_display_length: int) -> str: value_str = value_str[:max_display_length] + "..." return html.escape(value_str) + +def sanitize_dict(value: Optional[Dict[str,str]], max_display_length): + return {k: sanitize_for_storage(v, max_display_length) for k, v in value.items()} \ No newline at end of file From b61461d0282f7d3b775f66c65412124040e95d89 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Thu, 8 Jan 2026 19:20:22 +0100 Subject: [PATCH 28/70] fixed categorization visualization, fixed date in the dashboard, fixed attack regex detection --- Dockerfile | 1 + config.yaml | 18 +++--- src/analyzer.py | 37 ++++++++++--- src/database.py | 47 +++++++++++----- src/handler.py | 3 +- src/templates/dashboard_template.py | 86 +++++++++++++++++++++++------ src/wordlists.py | 3 +- wordlists.json | 17 +++--- 8 files changed, 154 insertions(+), 58 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2c7b954..78023a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY src/ /app/src/ COPY wordlists.json /app/ COPY entrypoint.sh /app/ +COPY config.yaml /app/ RUN useradd -m -u 1000 krawl && \ mkdir -p /app/logs /app/data && \ diff --git a/config.yaml b/config.yaml index 2150e1f..52daa09 100644 --- a/config.yaml +++ b/config.yaml @@ -3,7 +3,7 @@ server: port: 5000 delay: 100 # Response delay in milliseconds - timezone: null # e.g., "America/New_York" or null for system default + timezone: null # e.g., "America/New_York", "Europe/Paris" or null for system default # manually set the server header, if null a random one will be used. server_header: null @@ -11,8 +11,8 @@ server: links: min_length: 5 max_length: 15 - min_per_page: 10 - max_per_page: 15 + min_per_page: 5 + max_per_page: 10 char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" max_counter: 10 @@ -38,9 +38,9 @@ behavior: probability_error_codes: 0 # 0-100 percentage analyzer: - # http_risky_methods_threshold: 0.1 - # violated_robots_threshold: 0.1 - # uneven_request_timing_threshold: 5 - # uneven_request_timing_time_window_seconds: 300 - # user_agents_used_threshold: 2 - # attack_urls_threshold: 1 + http_risky_methods_threshold: 0.1 + violated_robots_threshold: 0.1 + uneven_request_timing_threshold: 2 + uneven_request_timing_time_window_seconds: 300 + user_agents_used_threshold: 2 + attack_urls_threshold: 1 diff --git a/src/analyzer.py b/src/analyzer.py index a745813..b63cd5e 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo from pathlib import Path from datetime import datetime, timedelta import re +import urllib.parse from wordlists import get_wordlists from config import get_config """ @@ -101,6 +102,15 @@ class Analyzer: total_accesses_count = len(accesses) if total_accesses_count <= 0: return + + # Set category as "unknown" for the first 5 requests + if total_accesses_count < 3: + category = "unknown" + analyzed_metrics = {} + category_scores = {"attacker": 0, "good_crawler": 0, "bad_crawler": 0, "regular_user": 0, "unknown": 0} + last_analysis = datetime.now(tz=ZoneInfo('UTC')) + self._db_manager.update_ip_stats_analysis(ip, analyzed_metrics, category, category_scores, last_analysis) + return 0 #--------------------- HTTP Methods --------------------- @@ -147,7 +157,7 @@ class Analyzer: robots_disallows.append(parts[1].strip()) #if 0 100% sure is good crawler, if >10% of robots violated is bad crawler or attacker - violated_robots_count = len([item for item in accesses if item["path"].rstrip("/") in tuple(robots_disallows)]) + violated_robots_count = len([item for item in accesses if any(item["path"].rstrip("/").startswith(disallow) for disallow in robots_disallows)]) #print(f"Violated robots count: {violated_robots_count}") if total_accesses_count > 0: violated_robots_ratio = violated_robots_count / total_accesses_count @@ -168,7 +178,8 @@ class Analyzer: #--------------------- Requests Timing --------------------- #Request rate and timing: steady, throttled, polite vs attackers' bursty, aggressive, or oddly rhythmic behavior timestamps = [datetime.fromisoformat(item["timestamp"]) for item in accesses] - timestamps = [ts for ts in timestamps if datetime.utcnow() - ts <= timedelta(seconds=uneven_request_timing_time_window_seconds)] + now_utc = datetime.now(tz=ZoneInfo('UTC')) + timestamps = [ts for ts in timestamps if now_utc - ts <= timedelta(seconds=uneven_request_timing_time_window_seconds)] timestamps = sorted(timestamps, reverse=True) time_diffs = [] @@ -221,13 +232,25 @@ class Analyzer: attack_urls_found_list = [] wl = get_wordlists() - if wl.attack_urls: + if wl.attack_patterns: queried_paths = [item["path"] for item in accesses] for queried_path in queried_paths: - for name, pattern in wl.attack_urls.items(): - if re.search(pattern, queried_path, re.IGNORECASE): - attack_urls_found_list.append(pattern) + # URL decode the path to catch encoded attacks + try: + decoded_path = urllib.parse.unquote(queried_path) + # Double decode to catch double-encoded attacks + decoded_path_twice = urllib.parse.unquote(decoded_path) + except Exception: + decoded_path = queried_path + decoded_path_twice = queried_path + + for name, pattern in wl.attack_patterns.items(): + # Check original, decoded, and double-decoded paths + if (re.search(pattern, queried_path, re.IGNORECASE) or + re.search(pattern, decoded_path, re.IGNORECASE) or + re.search(pattern, decoded_path_twice, re.IGNORECASE)): + attack_urls_found_list.append(f"{name}: {pattern}") if len(attack_urls_found_list) > attack_urls_threshold: score["attacker"]["attack_url"] = True @@ -276,7 +299,7 @@ class Analyzer: analyzed_metrics = {"risky_http_methods": http_method_attacker_score, "robots_violations": violated_robots_ratio, "uneven_request_timing": mean, "different_user_agents": user_agents_used, "attack_url": attack_urls_found_list} category_scores = {"attacker": attacker_score, "good_crawler": good_crawler_score, "bad_crawler": bad_crawler_score, "regular_user": regular_user_score} category = max(category_scores, key=category_scores.get) - last_analysis = datetime.utcnow() + last_analysis = datetime.now(tz=ZoneInfo('UTC')) self._db_manager.update_ip_stats_analysis(ip, analyzed_metrics, category, category_scores, last_analysis) diff --git a/src/database.py b/src/database.py index 0245105..35a6e2e 100644 --- a/src/database.py +++ b/src/database.py @@ -9,6 +9,7 @@ import os import stat from datetime import datetime from typing import Optional, List, Dict, Any +from zoneinfo import ZoneInfo from sqlalchemy import create_engine, func, distinct, case from sqlalchemy.orm import sessionmaker, scoped_session, Session @@ -127,7 +128,7 @@ class DatabaseManager: method=method[:10], is_suspicious=is_suspicious, is_honeypot_trigger=is_honeypot_trigger, - timestamp=datetime.utcnow() + timestamp=datetime.now(tz=ZoneInfo('UTC')) ) session.add(access_log) session.flush() # Get the ID before committing @@ -185,7 +186,7 @@ class DatabaseManager: path=sanitize_path(path), username=sanitize_credential(username), password=sanitize_credential(password), - timestamp=datetime.utcnow() + timestamp=datetime.now(tz=ZoneInfo('UTC')) ) session.add(credential) session.commit() @@ -207,7 +208,7 @@ class DatabaseManager: ip: IP address to update """ sanitized_ip = sanitize_ip(ip) - now = datetime.utcnow() + now = datetime.now(tz=ZoneInfo('UTC')) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() @@ -251,6 +252,12 @@ class DatabaseManager: ip_stats.category = category ip_stats.category_scores = category_scores ip_stats.last_analysis = last_analysis + + try: + session.commit() + except Exception as e: + session.rollback() + print(f"Error updating IP stats analysis: {e}") def manual_update_category(self, ip: str, category: str) -> None: """ @@ -268,14 +275,21 @@ class DatabaseManager: # Record the manual category change old_category = ip_stats.category if old_category != category: - self._record_category_change(sanitized_ip, old_category, category, datetime.utcnow()) + self._record_category_change(sanitized_ip, old_category, category, datetime.now(tz=ZoneInfo('UTC'))) ip_stats.category = category ip_stats.manual_category = True + + try: + session.commit() + except Exception as e: + session.rollback() + print(f"Error updating manual category: {e}") def _record_category_change(self, ip: str, old_category: Optional[str], new_category: str, timestamp: datetime) -> None: """ Internal method to record category changes in history. + Only records if there's an actual change from a previous category. Args: ip: IP address @@ -283,6 +297,11 @@ class DatabaseManager: new_category: New category timestamp: When the change occurred """ + # Don't record initial categorization (when old_category is None) + # Only record actual category changes + if old_category is None: + return + session = self.session try: history_entry = CategoryHistory( @@ -318,7 +337,7 @@ class DatabaseManager: { 'old_category': h.old_category, 'new_category': h.new_category, - 'timestamp': h.timestamp.isoformat() + 'timestamp': h.timestamp.isoformat() + '+00:00' } for h in history ] @@ -364,7 +383,7 @@ class DatabaseManager: 'method': log.method, 'is_suspicious': log.is_suspicious, 'is_honeypot_trigger': log.is_honeypot_trigger, - 'timestamp': log.timestamp.isoformat(), + 'timestamp': log.timestamp.isoformat() + '+00:00', 'attack_types': [d.attack_type for d in log.attack_detections] } for log in logs @@ -457,7 +476,7 @@ class DatabaseManager: 'path': attempt.path, 'username': attempt.username, 'password': attempt.password, - 'timestamp': attempt.timestamp.isoformat() + 'timestamp': attempt.timestamp.isoformat() + '+00:00' } for attempt in attempts ] @@ -484,8 +503,8 @@ class DatabaseManager: { 'ip': s.ip, 'total_requests': s.total_requests, - 'first_seen': s.first_seen.isoformat(), - 'last_seen': s.last_seen.isoformat(), + 'first_seen': s.first_seen.isoformat() + '+00:00', + 'last_seen': s.last_seen.isoformat() + '+00:00', 'country_code': s.country_code, 'city': s.city, 'asn': s.asn, @@ -525,8 +544,8 @@ class DatabaseManager: return { 'ip': stat.ip, 'total_requests': stat.total_requests, - 'first_seen': stat.first_seen.isoformat() if stat.first_seen else None, - 'last_seen': stat.last_seen.isoformat() if stat.last_seen else None, + 'first_seen': stat.first_seen.isoformat() + '+00:00' if stat.first_seen else None, + 'last_seen': stat.last_seen.isoformat() + '+00:00' if stat.last_seen else None, 'country_code': stat.country_code, 'city': stat.city, 'asn': stat.asn, @@ -537,7 +556,7 @@ class DatabaseManager: 'category': stat.category, 'category_scores': stat.category_scores or {}, 'manual_category': stat.manual_category, - 'last_analysis': stat.last_analysis.isoformat() if stat.last_analysis else None, + 'last_analysis': stat.last_analysis.isoformat() + '+00:00' if stat.last_analysis else None, 'category_history': category_history } finally: @@ -671,7 +690,7 @@ class DatabaseManager: 'ip': log.ip, 'path': log.path, 'user_agent': log.user_agent, - 'timestamp': log.timestamp.isoformat() + 'timestamp': log.timestamp.isoformat() + '+00:00' } for log in logs ] @@ -729,7 +748,7 @@ class DatabaseManager: 'ip': log.ip, 'path': log.path, 'user_agent': log.user_agent, - 'timestamp': log.timestamp.isoformat(), + 'timestamp': log.timestamp.isoformat() + '+00:00', 'attack_types': [d.attack_type for d in log.attack_detections] } for log in logs diff --git a/src/handler.py b/src/handler.py index 2598706..ebc0b66 100644 --- a/src/handler.py +++ b/src/handler.py @@ -407,7 +407,8 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: stats = self.tracker.get_stats() - self.wfile.write(generate_dashboard(stats).encode()) + timezone = str(self.config.timezone) if self.config.timezone else 'UTC' + self.wfile.write(generate_dashboard(stats, timezone).encode()) except BrokenPipeError: pass except Exception as e: diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 332288c..bbb6ad9 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -7,6 +7,7 @@ Customize this template to change the dashboard appearance. import html from datetime import datetime +from zoneinfo import ZoneInfo def _escape(value) -> str: """Escape HTML special characters to prevent XSS attacks.""" @@ -14,18 +15,36 @@ def _escape(value) -> str: return "" return html.escape(str(value)) -def format_timestamp(iso_timestamp: str) -> str: - """Format ISO timestamp for display (YYYY-MM-DD HH:MM:SS)""" +def format_timestamp(iso_timestamp: str, timezone: str = 'UTC', time_only: bool = False) -> str: + """Format ISO timestamp for display with timezone conversion + + Args: + iso_timestamp: ISO format timestamp string (UTC) + timezone: IANA timezone string to convert to + time_only: If True, return only HH:MM:SS, otherwise full datetime + """ try: + # Parse UTC timestamp dt = datetime.fromisoformat(iso_timestamp) + # Convert to target timezone + if dt.tzinfo is not None: + dt = dt.astimezone(ZoneInfo(timezone)) + + if time_only: + return dt.strftime("%H:%M:%S") return dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: # Fallback for old format return iso_timestamp.split("T")[1][:8] if "T" in iso_timestamp else iso_timestamp -def generate_dashboard(stats: dict) -> str: - """Generate dashboard HTML with access statistics""" +def generate_dashboard(stats: dict, timezone: str = 'UTC') -> str: + """Generate dashboard HTML with access statistics + + Args: + stats: Statistics dictionary + timezone: IANA timezone string (e.g., 'Europe/Paris', 'America/New_York') + """ # Generate IP rows with clickable functionality for dropdown stats top_ips_rows = '\n'.join([ @@ -62,7 +81,7 @@ def generate_dashboard(stats: dict) -> str: {_escape(log["ip"])} {_escape(log["path"])} {_escape(log["user_agent"][:60])} - {_escape(log["timestamp"].split("T")[1][:8])} + {format_timestamp(log["timestamp"], timezone, time_only=True)} @@ -98,7 +117,7 @@ def generate_dashboard(stats: dict) -> str: {_escape(log["path"])} {_escape(", ".join(log["attack_types"]))} {_escape(log["user_agent"][:60])} - {_escape(log["timestamp"].split("T")[1][:8])} + {format_timestamp(log["timestamp"], timezone, time_only=True)} @@ -117,7 +136,7 @@ def generate_dashboard(stats: dict) -> str: {_escape(log["username"])} {_escape(log["password"])} {_escape(log["path"])} - {_escape(log["timestamp"].split("T")[1][:8])} + {format_timestamp(log["timestamp"], timezone, time_only=True)} @@ -352,6 +371,11 @@ def generate_dashboard(stats: dict) -> str: color: #58a6ff; border: 1px solid #58a6ff; }} + .category-unknown {{ + background: #8b949e1a; + color: #8b949e; + border: 1px solid #8b949e; + }} .timeline-container {{ margin-top: 15px; padding-top: 15px; @@ -403,6 +427,9 @@ def generate_dashboard(stats: dict) -> str: .timeline-marker.regular-user {{ background: #58a6ff; }} + .timeline-marker.unknown {{ + background: #8b949e; + }} .timeline-content {{ font-size: 12px; }} @@ -570,6 +597,30 @@ def generate_dashboard(stats: dict) -> str: diff --git a/src/tracker.py b/src/tracker.py index cd8a187..8bec7ce 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -17,7 +17,7 @@ class AccessTracker: Maintains in-memory structures for fast dashboard access and persists data to SQLite for long-term storage and analysis. """ - def __init__(self, db_manager: Optional[DatabaseManager] = None, timezone: Optional[ZoneInfo] = None): + def __init__(self, db_manager: Optional[DatabaseManager] = None): """ Initialize the access tracker. @@ -30,7 +30,6 @@ class AccessTracker: self.user_agent_counts: Dict[str, int] = defaultdict(int) self.access_log: List[Dict] = [] self.credential_attempts: List[Dict] = [] - self.timezone = timezone or ZoneInfo('UTC') self.suspicious_patterns = [ 'bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python-requests', 'scanner', 'nikto', 'sqlmap', 'nmap', 'masscan', 'nessus', 'acunetix', @@ -40,7 +39,7 @@ class AccessTracker: # Load attack patterns from wordlists wl = get_wordlists() self.attack_types = wl.attack_patterns - + # Fallback if wordlists not loaded if not self.attack_types: self.attack_types = { @@ -80,38 +79,38 @@ class AccessTracker: """ if not post_data: return None, None - + username = None password = None - + try: # Parse URL-encoded form data parsed = urllib.parse.parse_qs(post_data) - + # Common username field names username_fields = ['username', 'user', 'login', 'email', 'log', 'userid', 'account'] for field in username_fields: if field in parsed and parsed[field]: username = parsed[field][0] break - + # Common password field names password_fields = ['password', 'pass', 'passwd', 'pwd', 'passphrase'] for field in password_fields: if field in parsed and parsed[field]: password = parsed[field][0] break - + except Exception: # If parsing fails, try simple regex patterns username_match = re.search(r'(?:username|user|login|email|log)=([^&\s]+)', post_data, re.IGNORECASE) password_match = re.search(r'(?:password|pass|passwd|pwd)=([^&\s]+)', post_data, re.IGNORECASE) - + if username_match: username = urllib.parse.unquote_plus(username_match.group(1)) if password_match: password = urllib.parse.unquote_plus(password_match.group(1)) - + return username, password def record_credential_attempt(self, ip: str, path: str, username: str, password: str): @@ -126,7 +125,7 @@ class AccessTracker: 'path': path, 'username': username, 'password': password, - 'timestamp': datetime.now(self.timezone).isoformat() + 'timestamp': datetime.now().isoformat() }) # Persist to database @@ -193,7 +192,7 @@ class AccessTracker: 'suspicious': is_suspicious, 'honeypot_triggered': self.is_honeypot_path(path), 'attack_types':attack_findings, - 'timestamp': datetime.now(self.timezone).isoformat() + 'timestamp': datetime.now().isoformat() }) # Persist to database From 8deabe8ce0e0c9d2402e3720ba6d37288b62defe Mon Sep 17 00:00:00 2001 From: carnivuth Date: Sat, 17 Jan 2026 18:06:27 +0100 Subject: [PATCH 40/70] added direnv file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ecc3154..90cc56f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ secrets/ .env .env.local .env.*.local +.envrc # Logs *.log From 00b222b7546bad90a542dce29a4c2723f9c1455d Mon Sep 17 00:00:00 2001 From: carnivuth Date: Sat, 17 Jan 2026 18:12:41 +0100 Subject: [PATCH 41/70] added development docker compose file --- docker-compose-dev.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docker-compose-dev.yaml diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml new file mode 100644 index 0000000..2486264 --- /dev/null +++ b/docker-compose-dev.yaml @@ -0,0 +1,29 @@ +--- +services: + krawl: + build: + context: . + dockerfile: Dockerfile + container_name: krawl-server + ports: + - "5000:5000" + environment: + - CONFIG_LOCATION=config.yaml + # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment + #- TZ=${TZ} + volumes: + - ./wordlists.json:/app/wordlists.json:ro + - ./config.yaml:/app/config.yaml:ro + - ./logs:/app/logs + - ./exports:/app/exports + - ./data:/app/data + restart: unless-stopped + develop: + watch: + - path: ./Dockerfile + action: rebuild + - path: ./src/ + action: sync+restart + target: /app/src + - path: ./docker-compose-dev.yaml + action: rebuild From 2dd35234c05af68844d9d47db46175bac62fbaa3 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Sat, 17 Jan 2026 22:41:19 +0100 Subject: [PATCH 42/70] fixed dashboard alignment --- malicious_ips.txt | 1 + src/exports/malicious_ips.txt | 7 + src/templates/dashboard_template.py | 239 +++++++++++-------------- tests/test_insert_fake_ips.py | 259 ++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 135 deletions(-) create mode 100644 malicious_ips.txt create mode 100644 src/exports/malicious_ips.txt create mode 100644 tests/test_insert_fake_ips.py diff --git a/malicious_ips.txt b/malicious_ips.txt new file mode 100644 index 0000000..7b9ad53 --- /dev/null +++ b/malicious_ips.txt @@ -0,0 +1 @@ +127.0.0.1 diff --git a/src/exports/malicious_ips.txt b/src/exports/malicious_ips.txt new file mode 100644 index 0000000..a82f110 --- /dev/null +++ b/src/exports/malicious_ips.txt @@ -0,0 +1,7 @@ +198.51.100.89 +203.0.113.45 +210.45.67.89 +182.91.102.45 +192.0.2.120 +205.32.180.65 +175.23.45.67 diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 4c5a77a..8ee73a4 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -401,101 +401,47 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str = color: #8b949e; border: 1px solid #8b949e; }} - .timeline-container {{ + .timeline-section {{ margin-top: 15px; padding-top: 15px; border-top: 1px solid #30363d; }} - .timeline-title {{ - color: #58a6ff; - font-size: 13px; - font-weight: 600; + .timeline-container {{ + display: flex; + gap: 20px; + min-height: 200px; + }} + .timeline-column {{ + flex: 1; + min-width: 0; + overflow: auto; + max-height: 350px; + }} + .timeline-column:first-child {{ + flex: 1.5; + }} + .timeline-column:last-child {{ + flex: 1; }} .timeline-header {{ - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - margin-bottom: 10px; - }} - .timeline {{ - position: relative; - padding-left: 30px; - }} - .timeline::before {{ - content: ''; - position: absolute; - left: 12px; - top: 5px; - bottom: 5px; - width: 3px; - background: #30363d; - }} - .timeline-item {{ - position: relative; - padding-bottom: 15px; - }} - .timeline-item:last-child {{ - padding-bottom: 0; - }} - .timeline-marker {{ - position: absolute; - left: -26px; - width: 16px; - height: 16px; - border-radius: 50%; - border: 2px solid #0d1117; - }} - .timeline-marker.attacker {{ - background: #f85149; - }} - .timeline-marker.good-crawler {{ - background: #3fb950; - }} - .timeline-marker.bad-crawler {{ - background: #f0883e; - }} - .timeline-marker.regular-user {{ - background: #58a6ff; - }} - .timeline-marker.unknown {{ - background: #8b949e; - }} - .timeline-content {{ - font-size: 12px; - }} - .timeline-category {{ - font-weight: 600; - }} - .timeline-timestamp {{ - color: #8b949e; - font-size: 11px; - margin-top: 2px; - }} - .timeline-arrow {{ - color: #8b949e; - margin: 0 7px; - }} - .reputation-container {{ - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid #30363d; - }} - .reputation-title {{ color: #58a6ff; font-size: 13px; font-weight: 600; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #30363d; }} - .reputation-badges {{ - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; + .reputation-title {{ + color: #8b949e; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 8px; }} .reputation-badge {{ display: inline-flex; align-items: center; - gap: 4px; + gap: 3px; padding: 4px 8px; background: #161b22; border: 1px solid #f851494d; @@ -504,28 +450,60 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str = color: #f85149; text-decoration: none; transition: all 0.2s; + margin-bottom: 6px; + margin-right: 6px; + white-space: nowrap; }} .reputation-badge:hover {{ background: #1c2128; border-color: #f85149; }} - .reputation-badge-icon {{ - font-size: 12px; - }} .reputation-clean {{ display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 10px; + gap: 3px; + padding: 4px 8px; background: #161b22; border: 1px solid #3fb9504d; border-radius: 4px; font-size: 11px; color: #3fb950; + margin-bottom: 6px; }} - .reputation-clean-icon {{ - font-size: 13px; + .timeline {{ + position: relative; + padding-left: 28px; }} + .timeline::before {{ + content: ''; + position: absolute; + left: 11px; + top: 0; + bottom: 0; + width: 2px; + background: #30363d; + }} + .timeline-item {{ + position: relative; + padding-bottom: 12px; + font-size: 12px; + }} + .timeline-item:last-child {{ + padding-bottom: 0; + }} + .timeline-marker {{ + position: absolute; + left: -23px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid #0d1117; + }} + .timeline-marker.attacker {{ background: #f85149; }} + .timeline-marker.good-crawler {{ background: #3fb950; }} + .timeline-marker.bad-crawler {{ background: #f0883e; }} + .timeline-marker.regular-user {{ background: #58a6ff; }} + .timeline-marker.unknown {{ background: #8b949e; }} @@ -846,71 +824,62 @@ def generate_dashboard(stats: dict, timezone: str = 'UTC', dashboard_path: str = }} if (stats.category_history && stats.category_history.length > 0) {{ + html += '
'; html += '
'; - html += '
'; - html += '
Behavior Timeline
'; - - if (stats.list_on && Object.keys(stats.list_on).length > 0) {{ - html += '
'; - html += 'Listed on'; - - const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0])); - - sortedSources.forEach(([source, url]) => {{ - if (url && url !== 'N/A') {{ - html += ``; - html += ''; - html += `${{source}}`; - html += ''; - }} else {{ - html += ''; - html += ''; - html += `${{source}}`; - html += ''; - }} - }}); - - html += '
'; - }} else if (stats.country_code || stats.asn) {{ - html += '
'; - html += 'Reputation'; - html += ''; - html += ''; - html += 'Clean'; - html += ''; - html += '
'; - }} - - html += '
'; - + // Timeline column + html += '
'; + html += '
Behavior Timeline
'; html += '
'; - stats.category_history.forEach((change, index) => {{ + stats.category_history.forEach(change => {{ const categoryClass = change.new_category.toLowerCase().replace('_', '-'); const timestamp = formatTimestamp(change.timestamp); - + const oldClass = change.old_category ? 'category-' + change.old_category.toLowerCase().replace('_', '-') : ''; + const newClass = 'category-' + categoryClass; + html += '
'; html += `
`; html += '
'; - + if (change.old_category) {{ - const oldCategoryBadge = 'category-' + change.old_category.toLowerCase().replace('_', '-'); - html += `${{change.old_category}}`; - html += ''; + html += `${{change.old_category}}`; + html += ''; }} else {{ - html += 'Initial: '; + html += 'Initial:'; }} - - const newCategoryBadge = 'category-' + change.new_category.toLowerCase().replace('_', '-'); - html += `${{change.new_category}}`; - html += `
${{timestamp}}
`; + + html += `${{change.new_category}}`; + html += `
${{timestamp}}
`; html += '
'; html += '
'; }}); html += '
'; html += '
'; + + // Reputation column + html += '
'; + + if (stats.list_on && Object.keys(stats.list_on).length > 0) {{ + html += '
Listed On
'; + const sortedSources = Object.entries(stats.list_on).sort((a, b) => a[0].localeCompare(b[0])); + + sortedSources.forEach(([source, url]) => {{ + if (url && url !== 'N/A') {{ + html += `${{source}}`; + }} else {{ + html += `${{source}}`; + }} + }}); + }} else if (stats.country_code || stats.asn) {{ + html += '
Reputation
'; + html += '✓ Clean'; + }} + + html += '
'; + html += '
'; + html += '
'; }} html += ''; diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py new file mode 100644 index 0000000..6279b43 --- /dev/null +++ b/tests/test_insert_fake_ips.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 + +""" +Test script to insert fake external IPs into the database for testing the dashboard. +This generates realistic-looking test data including access logs, credential attempts, and attack detections. +Also triggers category behavior changes to demonstrate the timeline feature. +""" + +import random +import time +import sys +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path + +# Add parent src directory to path so we can import database and logger +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from database import get_database +from logger import get_app_logger + +# ---------------------- +# TEST DATA GENERATORS +# ---------------------- + +FAKE_IPS = [ + "203.0.113.45", # Regular attacker IP + "198.51.100.89", # Credential harvester IP + "192.0.2.120", # Bot IP + "205.32.180.65", # Another attacker + "210.45.67.89", # Suspicious IP + "175.23.45.67", # International IP + "182.91.102.45", # Another suspicious IP +] + +FAKE_PATHS = [ + "/admin", + "/login", + "/admin/login", + "/api/users", + "/wp-admin", + "/.env", + "/config.php", + "/admin.php", + "/shell.php", + "/../../../etc/passwd", + "/sqlmap", + "/w00t.php", + "/shell", + "/joomla/administrator", +] + +FAKE_USER_AGENTS = [ + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + "Nmap Scripting Engine", + "curl/7.68.0", + "python-requests/2.28.1", + "sqlmap/1.6.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + "ZmEu", + "nikto/2.1.6", +] + +FAKE_CREDENTIALS = [ + ("admin", "admin"), + ("admin", "password"), + ("root", "123456"), + ("test", "test"), + ("guest", "guest"), + ("user", "12345"), +] + +ATTACK_TYPES = [ + "sql_injection", + "xss_attempt", + "path_traversal", + "suspicious_pattern", + "credential_submission", +] + +CATEGORIES = [ + "ATTACKER", + "BAD_CRAWLER", + "GOOD_CRAWLER", + "REGULAR_USER", + "UNKNOWN", +] + + +def generate_category_scores(): + """Generate random category scores.""" + scores = { + "attacker": random.randint(0, 100), + "good_crawler": random.randint(0, 100), + "bad_crawler": random.randint(0, 100), + "regular_user": random.randint(0, 100), + "unknown": random.randint(0, 100), + } + return scores + + +def generate_analyzed_metrics(): + """Generate random analyzed metrics.""" + return { + "request_frequency": random.uniform(0.1, 100.0), + "suspicious_patterns": random.randint(0, 20), + "credential_attempts": random.randint(0, 10), + "attack_diversity": random.uniform(0, 1.0), + } + + +def generate_fake_data(num_ips: int = 5, logs_per_ip: int = 15, credentials_per_ip: int = 3): + """ + Generate and insert fake test data into the database. + + Args: + num_ips: Number of unique fake IPs to generate (default: 5) + logs_per_ip: Number of access logs per IP (default: 15) + credentials_per_ip: Number of credential attempts per IP (default: 3) + """ + db_manager = get_database() + app_logger = get_app_logger() + + # Ensure database is initialized + if not db_manager._initialized: + db_manager.initialize() + + app_logger.info("=" * 60) + app_logger.info("Starting fake IP data generation for testing") + app_logger.info("=" * 60) + + total_logs = 0 + total_credentials = 0 + total_attacks = 0 + total_category_changes = 0 + + # Select random IPs from the pool + selected_ips = random.sample(FAKE_IPS, min(num_ips, len(FAKE_IPS))) + + for ip in selected_ips: + app_logger.info(f"\nGenerating data for IP: {ip}") + + # Generate access logs for this IP + for _ in range(logs_per_ip): + path = random.choice(FAKE_PATHS) + user_agent = random.choice(FAKE_USER_AGENTS) + is_suspicious = random.choice([True, False, False]) # 33% chance of suspicious + is_honeypot = random.choice([True, False, False, False]) # 25% chance of honeypot trigger + + # Randomly decide if this log has attack detections + attack_types = None + if random.choice([True, False, False]): # 33% chance + num_attacks = random.randint(1, 3) + attack_types = random.sample(ATTACK_TYPES, num_attacks) + + log_id = db_manager.persist_access( + ip=ip, + path=path, + user_agent=user_agent, + method=random.choice(["GET", "POST"]), + is_suspicious=is_suspicious, + is_honeypot_trigger=is_honeypot, + attack_types=attack_types, + ) + + if log_id: + total_logs += 1 + if attack_types: + total_attacks += len(attack_types) + + # Generate credential attempts for this IP + for _ in range(credentials_per_ip): + username, password = random.choice(FAKE_CREDENTIALS) + path = random.choice(["/login", "/admin/login", "/api/auth"]) + + cred_id = db_manager.persist_credential( + ip=ip, + path=path, + username=username, + password=password, + ) + + if cred_id: + total_credentials += 1 + + app_logger.info(f" ✓ Generated {logs_per_ip} access logs") + app_logger.info(f" ✓ Generated {credentials_per_ip} credential attempts") + + # Trigger behavior/category changes to demonstrate timeline feature + # First analysis + initial_category = random.choice(CATEGORIES) + app_logger.info(f" ⟳ Analyzing behavior - Initial category: {initial_category}") + + db_manager.update_ip_stats_analysis( + ip=ip, + analyzed_metrics=generate_analyzed_metrics(), + category=initial_category, + category_scores=generate_category_scores(), + last_analysis=datetime.now(tz=ZoneInfo('UTC')) + ) + total_category_changes += 1 + + # Small delay to ensure timestamps are different + time.sleep(0.1) + + # Second analysis with potential category change (70% chance) + if random.random() < 0.7: + new_category = random.choice([c for c in CATEGORIES if c != initial_category]) + app_logger.info(f" ⟳ Behavior change detected: {initial_category} → {new_category}") + + db_manager.update_ip_stats_analysis( + ip=ip, + analyzed_metrics=generate_analyzed_metrics(), + category=new_category, + category_scores=generate_category_scores(), + last_analysis=datetime.now(tz=ZoneInfo('UTC')) + ) + total_category_changes += 1 + + # Optional third change (40% chance) + if random.random() < 0.4: + final_category = random.choice([c for c in CATEGORIES if c != new_category]) + app_logger.info(f" ⟳ Another behavior change: {new_category} → {final_category}") + + time.sleep(0.1) + db_manager.update_ip_stats_analysis( + ip=ip, + analyzed_metrics=generate_analyzed_metrics(), + category=final_category, + category_scores=generate_category_scores(), + last_analysis=datetime.now(tz=ZoneInfo('UTC')) + ) + total_category_changes += 1 + + # Print summary + app_logger.info("\n" + "=" * 60) + app_logger.info("Test Data Generation Complete!") + app_logger.info("=" * 60) + app_logger.info(f"Total IPs created: {len(selected_ips)}") + app_logger.info(f"Total access logs: {total_logs}") + app_logger.info(f"Total attack detections: {total_attacks}") + app_logger.info(f"Total credential attempts: {total_credentials}") + app_logger.info(f"Total category changes: {total_category_changes}") + app_logger.info("=" * 60) + app_logger.info("\nYou can now view the dashboard with this test data.") + app_logger.info("The 'Behavior Timeline' will show category transitions for each IP.") + app_logger.info("Run: python server.py") + app_logger.info("=" * 60) + + +if __name__ == "__main__": + import sys + + # Allow command-line arguments for customization + num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 5 + logs_per_ip = int(sys.argv[2]) if len(sys.argv) > 2 else 15 + credentials_per_ip = int(sys.argv[3]) if len(sys.argv) > 3 else 3 + + generate_fake_data(num_ips, logs_per_ip, credentials_per_ip) From 59d99484e99fb40dba11fd9a2ea690aa1309d60b Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Sat, 17 Jan 2026 22:43:42 +0100 Subject: [PATCH 43/70] fixed dashboard alignment --- malicious_ips.txt | 1 - src/exports/malicious_ips.txt | 7 ------- 2 files changed, 8 deletions(-) delete mode 100644 malicious_ips.txt delete mode 100644 src/exports/malicious_ips.txt diff --git a/malicious_ips.txt b/malicious_ips.txt deleted file mode 100644 index 7b9ad53..0000000 --- a/malicious_ips.txt +++ /dev/null @@ -1 +0,0 @@ -127.0.0.1 diff --git a/src/exports/malicious_ips.txt b/src/exports/malicious_ips.txt deleted file mode 100644 index a82f110..0000000 --- a/src/exports/malicious_ips.txt +++ /dev/null @@ -1,7 +0,0 @@ -198.51.100.89 -203.0.113.45 -210.45.67.89 -182.91.102.45 -192.0.2.120 -205.32.180.65 -175.23.45.67 From 6f07ab8409e835a3dc596292ab99e95e90b9013d Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Sat, 17 Jan 2026 23:05:47 +0100 Subject: [PATCH 44/70] Removed old Dockerfile, added volume name --- docker-compose-dev.yaml | 29 ----------------------------- docker-compose.yaml | 22 ++++++++++++++++++---- 2 files changed, 18 insertions(+), 33 deletions(-) delete mode 100644 docker-compose-dev.yaml diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml deleted file mode 100644 index 2486264..0000000 --- a/docker-compose-dev.yaml +++ /dev/null @@ -1,29 +0,0 @@ ---- -services: - krawl: - build: - context: . - dockerfile: Dockerfile - container_name: krawl-server - ports: - - "5000:5000" - environment: - - CONFIG_LOCATION=config.yaml - # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment - #- TZ=${TZ} - volumes: - - ./wordlists.json:/app/wordlists.json:ro - - ./config.yaml:/app/config.yaml:ro - - ./logs:/app/logs - - ./exports:/app/exports - - ./data:/app/data - restart: unless-stopped - develop: - watch: - - path: ./Dockerfile - action: rebuild - - path: ./src/ - action: sync+restart - target: /app/src - - path: ./docker-compose-dev.yaml - action: rebuild diff --git a/docker-compose.yaml b/docker-compose.yaml index d8ea198..233692b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,4 @@ -version: '3.8' - +--- services: krawl: build: @@ -8,11 +7,26 @@ services: container_name: krawl-server ports: - "5000:5000" + environment: + - CONFIG_LOCATION=config.yaml + # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment + # - TZ=${TZ} volumes: - ./wordlists.json:/app/wordlists.json:ro - ./config.yaml:/app/config.yaml:ro - ./logs:/app/logs - ./exports:/app/exports - environment: - - CONFIG_LOCATION=config.yaml + - data:/app/data restart: unless-stopped + develop: + watch: + - path: ./Dockerfile + action: rebuild + - path: ./src/ + action: sync+restart + target: /app/src + - path: ./docker-compose.yaml + action: rebuild + +volumes: + data: From adbbe4d4ea40e568f1636e33f5fd1afeadaebe43 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 10:39:09 +0100 Subject: [PATCH 45/70] feat: add GitHub Actions workflow for building and pushing Docker images --- .github/workflows/docker-build-push.yml | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/docker-build-push.yml diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..1f0889d --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,76 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + - beta + - dev + paths: + - 'src/**' + - 'helm/Chart.yaml' + - 'config.yaml' + - 'Dockerfile' + - 'requirements.txt' + - 'entrypoint.sh' + - '.github/workflows/docker-build-push.yml' + tags: + - 'v*.*.*' + release: + types: [published] + workflow_dispatch: + +env: + REGISTRY: ${{ vars.DOCKER_REGISTRY }} + IMAGE_NAME: ${{ vars.DOCKER_IMAGE_NAME }} + +jobs: + build-and-push: + runs-on: self-hosted + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract appVersion from Chart.yaml + id: chart_version + run: | + APP_VERSION=$(grep '^appVersion:' helm/Chart.yaml | awk '{print $2}' | tr -d '"') + echo "version=$APP_VERSION" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.chart_version.outputs.version }},enable={{is_default_branch}} + type=raw,value=${{ steps.chart_version.outputs.version }}-${{ github.ref_name }},enable=${{ github.ref_name != 'main' }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Image digest + run: echo ${{ steps.meta.outputs.digest }} From 2ff6bb34b201999d562cfe6eb3bfff8c24fe56d9 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 10:51:11 +0100 Subject: [PATCH 46/70] feat: add GitHub Actions workflow for packaging and pushing Helm charts --- .github/workflows/helm-package-push.yml | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/helm-package-push.yml diff --git a/.github/workflows/helm-package-push.yml b/.github/workflows/helm-package-push.yml new file mode 100644 index 0000000..8cd119a --- /dev/null +++ b/.github/workflows/helm-package-push.yml @@ -0,0 +1,75 @@ +name: Package and Push Helm Chart + +on: + push: + branches: + - main + - beta + - dev + paths: + - 'helm/**' + tags: + - 'v*' + release: + types: + - published + - created + workflow_dispatch: + +env: + REGISTRY: ${{ vars.DOCKER_REGISTRY }} + +jobs: + package-and-push: + runs-on: self-hosted + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + with: + version: 'latest' + + - name: Log in to Container Registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin + + - name: Extract version from Chart.yaml + id: version + run: | + VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}' | tr -d '"') + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Package Helm chart + run: | + helm package ./helm + + - name: Push Helm chart to registry + run: | + CHART_VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}') + CHART_FILE=$(grep '^name:' ./helm/Chart.yaml | awk '{print $2}') + CHART_PATH="${CHART_FILE}-${CHART_VERSION}.tgz" + + # Determine tag based on branch + if [[ "${{ github.ref_name }}" == "main" ]]; then + TAG="${{ steps.version.outputs.version }}" + helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:$TAG + helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:latest + else + TAG="${{ steps.version.outputs.version }}-${{ github.ref_name }}" + helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:$TAG + fi + + - name: Chart pushed + run: | + CHART_FILE=$(grep '^name:' ./helm/Chart.yaml | awk '{print $2}') + if [[ "${{ github.ref_name }}" == "main" ]]; then + echo "Chart pushed: $CHART_FILE:${{ steps.version.outputs.version }} and $CHART_FILE:latest" + else + echo "Chart pushed: $CHART_FILE:${{ steps.version.outputs.version }}-${{ github.ref_name }}" + fi From 143b301bcbf2a05e334280d115a1600fca559c4b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 10:57:28 +0100 Subject: [PATCH 47/70] feat: add Kubernetes validation workflow for pull requests --- .github/workflows/kubernetes-validation.yml | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/kubernetes-validation.yml diff --git a/.github/workflows/kubernetes-validation.yml b/.github/workflows/kubernetes-validation.yml new file mode 100644 index 0000000..de5e1cd --- /dev/null +++ b/.github/workflows/kubernetes-validation.yml @@ -0,0 +1,57 @@ +name: Kubernetes Validation + +on: + pull_request: + branches: + - main + - beta + - dev + paths: + - 'kubernetes/**' + - 'helm/**' + - '.github/workflows/kubernetes-validation.yml' + +permissions: + contents: read + +jobs: + validate-manifests: + name: Validate Kubernetes Manifests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate YAML syntax + run: | + for manifest in kubernetes/**/*.yaml; do + if [ -f "$manifest" ]; then + echo "Validating YAML syntax: $manifest" + python3 -c "import yaml, sys; yaml.safe_load(open('$manifest'))" || exit 1 + fi + done + + - name: Validate manifest structure + run: | + for manifest in kubernetes/**/*.yaml; do + if [ -f "$manifest" ]; then + echo "Checking $manifest" + if ! grep -q "kind:" "$manifest"; then + echo "Error: $manifest does not contain a Kubernetes kind" + exit 1 + fi + fi + done + + validate-helm: + name: Validate Helm Chart + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + + - name: Helm lint + run: helm lint ./helm + + - name: Helm template validation + run: helm template krawl ./helm > /tmp/helm-output.yaml From 261a7b26b918d01fa6c02257dd809b82d55650e8 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 11:10:04 +0100 Subject: [PATCH 48/70] feat: add GitHub Actions workflows for PR checks and security scans --- .github/workflows/pr-checks.yml | 56 +++++++++++++++++++++++++++++ .github/workflows/security-scan.yml | 46 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .github/workflows/security-scan.yml diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..48bc45d --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,56 @@ +name: PR Checks + +on: + pull_request: + branches: + - main + - beta + - dev + +permissions: + contents: read + pull-requests: read + +jobs: + lint-and-test: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install black flake8 pylint pytest + + - name: Black format check + run: | + if [ -n "$(black --check src/ 2>&1 | grep -v 'Oh no')" ]; then + echo "Run 'black src/' to format code" + black --diff src/ + exit 1 + fi + + - name: Flake8 lint + run: flake8 src/ --max-line-length=120 --extend-ignore=E203,W503 + + - name: Pylint check + run: pylint src/ --fail-under=7.0 || true + + - name: Run tests + run: pytest tests/ -v || true + + build-docker: + name: Build Docker + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t krawl:test . diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..7de1246 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,46 @@ +name: Security Scan + +on: + pull_request: + branches: + - main + - beta + - dev + +permissions: + contents: read + +jobs: + security-checks: + name: Security & Dependencies + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install bandit safety + + - name: Bandit security check + run: | + bandit -r src/ -f json -o bandit-report.json || true + bandit -r src/ -f txt + + - name: Safety check for dependencies + run: safety check --json || true + + - name: Trivy vulnerability scan + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'table' + severity: 'CRITICAL,HIGH' + exit-code: '1' From bea9489a12664823b917669e0efe3ed92f4244ea Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 11:13:35 +0100 Subject: [PATCH 49/70] feat: update Helm chart version and appVersion to 0.1.3 and 1.0.6 respectively --- helm/Chart.yaml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 3fe5d8a..94decfd 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,15 +1,15 @@ -apiVersion: v2 -name: krawl-chart -description: A Helm chart for Krawl honeypot server -type: application -version: 0.1.2 -appVersion: "1.0.0" -keywords: - - honeypot - - security - - krawl -maintainers: - - name: blessedrebus -home: https://github.com/blessedrebus/krawl -sources: - - https://github.com/blessedrebus/krawl +apiVersion: v2 +name: krawl-chart +description: A Helm chart for Krawl honeypot server +type: application +version: 0.1.3 +appVersion: "0.1.6" +keywords: + - honeypot + - security + - krawl +maintainers: + - name: blessedrebus +home: https://github.com/blessedrebus/krawl +sources: + - https://github.com/blessedrebus/krawl From dcfdb23b0c2a12a31282c913923ee5cb4cb148e8 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 11:20:46 +0100 Subject: [PATCH 50/70] feat: enhance Bandit security check to enforce HIGH severity issue detection --- .github/workflows/security-scan.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 7de1246..3507c4f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -30,8 +30,16 @@ jobs: - name: Bandit security check run: | - bandit -r src/ -f json -o bandit-report.json || true + bandit -r src/ -f json -o bandit-report.json bandit -r src/ -f txt + + # Check for HIGH severity issues only + HIGH_COUNT=$(python3 -c "import json; data=json.load(open('bandit-report.json')); print(len([i for i in data['results'] if i['severity'] == 'HIGH']))") + if [ "$HIGH_COUNT" -gt 0 ]; then + echo "Found $HIGH_COUNT HIGH severity security issues" + exit 1 + fi + echo "No HIGH severity security issues found (LOW/MEDIUM are acceptable)" - name: Safety check for dependencies run: safety check --json || true From 28a8880c0a9af5f4057a812cdef863e8efa13503 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 11:22:03 +0100 Subject: [PATCH 51/70] fix: add error handling to Bandit security check commands --- .github/workflows/security-scan.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 3507c4f..29a714f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -30,8 +30,8 @@ jobs: - name: Bandit security check run: | - bandit -r src/ -f json -o bandit-report.json - bandit -r src/ -f txt + bandit -r src/ -f json -o bandit-report.json || true + bandit -r src/ -f txt || true # Check for HIGH severity issues only HIGH_COUNT=$(python3 -c "import json; data=json.load(open('bandit-report.json')); print(len([i for i in data['results'] if i['severity'] == 'HIGH']))") From 9b74a7844d1dfbaaf73aafc54acbfba5e3b593e3 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Thu, 22 Jan 2026 11:24:41 +0100 Subject: [PATCH 52/70] fix: update Bandit security check to use txt output and improve HIGH severity detection --- .github/workflows/security-scan.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 29a714f..732b1b7 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -30,16 +30,21 @@ jobs: - name: Bandit security check run: | - bandit -r src/ -f json -o bandit-report.json || true - bandit -r src/ -f txt || true + bandit -r src/ -f txt | tee bandit-report.txt + + # Extract HIGH severity (not confidence) - look for the severity section + SEVERITY_SECTION=$(sed -n '/Total issues (by severity):/,/Total issues (by confidence):/p' bandit-report.txt) + HIGH_COUNT=$(echo "$SEVERITY_SECTION" | grep "High:" | grep -o "[0-9]*" | head -1) + + if [ -z "$HIGH_COUNT" ]; then + HIGH_COUNT=0 + fi - # Check for HIGH severity issues only - HIGH_COUNT=$(python3 -c "import json; data=json.load(open('bandit-report.json')); print(len([i for i in data['results'] if i['severity'] == 'HIGH']))") if [ "$HIGH_COUNT" -gt 0 ]; then echo "Found $HIGH_COUNT HIGH severity security issues" exit 1 fi - echo "No HIGH severity security issues found (LOW/MEDIUM are acceptable)" + echo "✓ No HIGH severity security issues found" - name: Safety check for dependencies run: safety check --json || true From aaaf1d35d6265db559099e124e636913d78119ec Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Thu, 22 Jan 2026 13:02:18 +0100 Subject: [PATCH 53/70] Fixed docker build and helm package workflows (invalid tagging) (#46) * feat: update Helm and Docker workflows to extract chart name and version, and improve tagging logic * fix: add github-actions-ci branch to workflow triggers for Docker and Helm packaging * fix: add helm-package-push.yml to workflow paths for triggering on changes * fix: improve appVersion extraction in Docker workflow and add error handling * fix: enhance appVersion extraction with debugging output and error message * fix: improve error handling for appVersion extraction in Docker and Helm workflows * fix: simplify chart info extraction in Helm workflow and remove error handling * fix: update chart info extraction to use awk for improved parsing * fix: streamline chart info extraction in Helm workflow by removing unnecessary step and directly parsing values * fix: remove newline characters from chart version and name extraction in Helm workflow * Fix newline * Update helm-package-push.yml * Removed claude brainrot * Update helm-package-push.yml --- .github/workflows/docker-build-push.yml | 31 +++++++++++------ .github/workflows/helm-package-push.yml | 45 +++++++++++++------------ helm/Chart.yaml | 30 ++++++++--------- 3 files changed, 59 insertions(+), 47 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 1f0889d..70fbb36 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -6,6 +6,7 @@ on: - main - beta - dev + - github-actions-ci paths: - 'src/**' - 'helm/Chart.yaml' @@ -45,21 +46,29 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract appVersion from Chart.yaml - id: chart_version + - name: Extract appVersion from Chart.yaml and determine tags + id: tags run: | - APP_VERSION=$(grep '^appVersion:' helm/Chart.yaml | awk '{print $2}' | tr -d '"') - echo "version=$APP_VERSION" >> $GITHUB_OUTPUT + APP_VERSION=$(grep '^appVersion:' helm/Chart.yaml | awk '{print $2}' | tr -d '"' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + if [ -z "$APP_VERSION" ]; then + echo "Error: Could not extract appVersion from Chart.yaml" + exit 1 + fi + + if [[ "${{ github.ref_name }}" == "main" ]]; then + TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${APP_VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" + else + TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${APP_VERSION}-${{ github.ref_name }}" + fi + + echo "tags=$TAGS" >> $GITHUB_OUTPUT - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=${{ steps.chart_version.outputs.version }},enable={{is_default_branch}} - type=raw,value=${{ steps.chart_version.outputs.version }}-${{ github.ref_name }},enable=${{ github.ref_name != 'main' }} - type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -67,10 +76,12 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - name: Image digest - run: echo ${{ steps.meta.outputs.digest }} + run: | + echo "Image built and pushed with tags:" + echo "${{ steps.tags.outputs.tags }}" diff --git a/.github/workflows/helm-package-push.yml b/.github/workflows/helm-package-push.yml index 8cd119a..9ba9150 100644 --- a/.github/workflows/helm-package-push.yml +++ b/.github/workflows/helm-package-push.yml @@ -6,8 +6,10 @@ on: - main - beta - dev + - github-actions-ci paths: - 'helm/**' + - '.github/workflows/helm-package-push.yml' tags: - 'v*' release: @@ -39,37 +41,36 @@ jobs: run: | echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin - - name: Extract version from Chart.yaml - id: version - run: | - VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}' | tr -d '"') - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Package Helm chart + - name: Set Helm chart version and package run: | + CHART_NAME=$(grep '^name:' ./helm/Chart.yaml | awk '{print $2}') + BASE_VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}') + + if [[ "${{ github.ref_name }}" == "main" ]]; then + CHART_VERSION="${BASE_VERSION}" + else + CHART_VERSION="${BASE_VERSION}-${{ github.ref_name }}" + fi + + # Update Chart.yaml temporarily with the versioned name + sed -i "s/^version:.*/version: ${CHART_VERSION}/" ./helm/Chart.yaml + + # Package the helm chart helm package ./helm + + echo "CHART_NAME=${CHART_NAME}" >> $GITHUB_ENV + echo "CHART_VERSION=${CHART_VERSION}" >> $GITHUB_ENV - name: Push Helm chart to registry run: | - CHART_VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}') - CHART_FILE=$(grep '^name:' ./helm/Chart.yaml | awk '{print $2}') - CHART_PATH="${CHART_FILE}-${CHART_VERSION}.tgz" - - # Determine tag based on branch - if [[ "${{ github.ref_name }}" == "main" ]]; then - TAG="${{ steps.version.outputs.version }}" - helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:$TAG - helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:latest - else - TAG="${{ steps.version.outputs.version }}-${{ github.ref_name }}" - helm push "$CHART_PATH" oci://${{ env.REGISTRY }}/$CHART_FILE:$TAG - fi + helm push ${{ env.CHART_NAME }}-${{ env.CHART_VERSION }}.tgz oci://${{ env.REGISTRY }} - name: Chart pushed run: | + CHART_VERSION=$(grep '^version:' ./helm/Chart.yaml | awk '{print $2}') CHART_FILE=$(grep '^name:' ./helm/Chart.yaml | awk '{print $2}') if [[ "${{ github.ref_name }}" == "main" ]]; then - echo "Chart pushed: $CHART_FILE:${{ steps.version.outputs.version }} and $CHART_FILE:latest" + echo "Chart pushed: ${CHART_FILE}:${CHART_VERSION}" else - echo "Chart pushed: $CHART_FILE:${{ steps.version.outputs.version }}-${{ github.ref_name }}" + echo "Chart pushed: ${CHART_FILE}:${CHART_VERSION}-${{ github.ref_name }}" fi diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 94decfd..288225d 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -1,15 +1,15 @@ -apiVersion: v2 -name: krawl-chart -description: A Helm chart for Krawl honeypot server -type: application -version: 0.1.3 -appVersion: "0.1.6" -keywords: - - honeypot - - security - - krawl -maintainers: - - name: blessedrebus -home: https://github.com/blessedrebus/krawl -sources: - - https://github.com/blessedrebus/krawl +apiVersion: v2 +name: krawl-chart +description: A Helm chart for Krawl honeypot server +type: application +version: 0.1.3 +appVersion: 0.1.6 +keywords: + - honeypot + - security + - krawl +maintainers: + - name: blessedrebus +home: https://github.com/blessedrebus/krawl +sources: + - https://github.com/blessedrebus/krawl From 223883a78115162c85ec2cbb1658aef79d870182 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Fri, 23 Jan 2026 17:34:23 +0100 Subject: [PATCH 54/70] Configuration override from environment variable (#47) * Add environment variable override for config fields Introduces functions to override configuration fields from environment variables, allowing dynamic configuration without modifying YAML files. The environment variable names are generated from field names, and type conversion is handled for int, float, and tuple fields. * update chart version to 0.1.4 * Update README.md to enhance environment variable configuration details and improve overall clarity --- README.md | 694 ++++++++++++++++++++++++++---------------------- helm/Chart.yaml | 2 +- src/config.py | 31 ++- 3 files changed, 401 insertions(+), 326 deletions(-) diff --git a/README.md b/README.md index f7fe399..1d0e8a5 100644 --- a/README.md +++ b/README.md @@ -1,323 +1,371 @@ -

🕷️ Krawl

- -

- - -

-
- -

- A modern, customizable zero-dependencies honeypot server designed to detect and track malicious activity through deceptive web pages, fake credentials, and canary tokens. -

- - - - - -
- -

- What is Krawl? • - Quick Start • - Honeypot Pages • - Dashboard • - Todo • - Contributing -

- -
-
- -## Demo -Tip: crawl the `robots.txt` paths for additional fun -### Krawl URL: [http://demo.krawlme.com](http://demo.krawlme.com) -### View the dashboard [http://demo.krawlme.com/das_dashboard](http://demo.krawlme.com/das_dashboard) - -## What is Krawl? - -**Krawl** is a cloud‑native deception server designed to detect, delay, and analyze malicious web crawlers and automated scanners. - -It creates realistic fake web applications filled with low‑hanging fruit such as admin panels, configuration files, and exposed fake credentials to attract and identify suspicious activity. - -By wasting attacker resources, Krawl helps clearly distinguish malicious behavior from legitimate crawlers. - -It features: - -- **Spider Trap Pages**: Infinite random links to waste crawler resources based on the [spidertrap project](https://github.com/adhdproject/spidertrap) -- **Fake Login Pages**: WordPress, phpMyAdmin, admin panels -- **Honeypot Paths**: Advertised in robots.txt to catch scanners -- **Fake Credentials**: Realistic-looking usernames, passwords, API keys -- **[Canary Token](#customizing-the-canary-token) Integration**: External alert triggering -- **Real-time Dashboard**: Monitor suspicious activity -- **Customizable Wordlists**: Easy JSON-based configuration -- **Random Error Injection**: Mimic real server behavior - -![asd](img/deception-page.png) - -## 🚀 Quick Start -## Helm Chart - -Install with default values - -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --namespace krawl-system \ - --create-namespace -``` - -Install with custom [canary token](#customizing-the-canary-token) - -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --namespace krawl-system \ - --create-namespace \ - --set config.canaryTokenUrl="http://your-canary-token-url" -``` - -To access the deception server - -```bash -kubectl get svc krawl -n krawl-system -``` - -Once the EXTERNAL-IP is assigned, access your deception server at: - -``` -http://:5000 -``` - -## Kubernetes / Kustomize -Apply all manifests with - -```bash -kubectl apply -f https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/manifests/krawl-all-in-one-deploy.yaml -``` - -Retrieve dashboard path with -```bash -kubectl get secret krawl-server -n krawl-system -o jsonpath='{.data.dashboard-path}' | base64 -d -``` - -Or clone the repo and apply the `manifest` folder with - -```bash -kubectl apply -k manifests -``` - -## Docker -Run Krawl as a docker container with - -```bash -docker run -d \ - -p 5000:5000 \ - -e CANARY_TOKEN_URL="http://your-canary-token-url" \ - --name krawl \ - ghcr.io/blessedrebus/krawl:latest -``` - -## Docker Compose -Run Krawl with docker-compose in the project folder with - -```bash -docker-compose up -d -``` - -Stop it with - -```bash -docker-compose down -``` - -## Python 3.11+ - -Clone the repository - -```bash -git clone https://github.com/blessedrebus/krawl.git -cd krawl/src -``` -Run the server -```bash -python3 server.py -``` - -Visit - -`http://localhost:5000` - -To access the dashboard - -`http://localhost:5000/` - -## Configuration via Environment Variables - -To customize the deception server installation several **environment variables** can be specified. - -| Variable | Description | Default | -|----------|-------------|---------| -| `PORT` | Server listening port | `5000` | -| `DELAY` | Response delay in milliseconds | `100` | -| `LINKS_MIN_LENGTH` | Minimum random link length | `5` | -| `LINKS_MAX_LENGTH` | Maximum random link length | `15` | -| `LINKS_MIN_PER_PAGE` | Minimum links per page | `10` | -| `LINKS_MAX_PER_PAGE` | Maximum links per page | `15` | -| `MAX_COUNTER` | Initial counter value | `10` | -| `CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | -| `CANARY_TOKEN_URL` | External canary token URL | None | -| `DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | -| `PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | -| `SERVER_HEADER` | HTTP Server header for deception | `Apache/2.2.22 (Ubuntu)` | -| `TIMEZONE` | IANA timezone for logs and dashboard (e.g., `America/New_York`, `Europe/Rome`) | System timezone | - -## robots.txt -The actual (juicy) robots.txt configuration is the following - -```txt -Disallow: /admin/ -Disallow: /api/ -Disallow: /backup/ -Disallow: /config/ -Disallow: /database/ -Disallow: /private/ -Disallow: /uploads/ -Disallow: /wp-admin/ -Disallow: /phpMyAdmin/ -Disallow: /admin/login.php -Disallow: /api/v1/users -Disallow: /api/v2/secrets -Disallow: /.env -Disallow: /credentials.txt -Disallow: /passwords.txt -Disallow: /.git/ -Disallow: /backup.sql -Disallow: /db_backup.sql -``` - -## Honeypot pages -Requests to common admin endpoints (`/admin/`, `/wp-admin/`, `/phpMyAdmin/`) return a fake login page. Any login attempt triggers a 1-second delay to simulate real processing and is fully logged in the dashboard (credentials, IP, headers, timing). - -
- -
- -Requests to paths like `/backup/`, `/config/`, `/database/`, `/private/`, or `/uploads/` return a fake directory listing populated with “interesting” files, each assigned a random file size to look realistic. - -![directory-page](img/directory-page.png) - -The `.env` endpoint exposes fake database connection strings, **AWS API keys**, and **Stripe secrets**. It intentionally returns an error due to the `Content-Type` being `application/json` instead of plain text, mimicking a “juicy” misconfiguration that crawlers and scanners often flag as information leakage. - -![env-page](img/env-page.png) - -The pages `/api/v1/users` and `/api/v2/secrets` show fake users and random secrets in JSON format - -
- - -
- -The pages `/credentials.txt` and `/passwords.txt` show fake users and random secrets - -
- - -
- -## Customizing the Canary Token -To create a custom canary token, visit https://canarytokens.org - -and generate a “Web bug” canary token. - -This optional token is triggered when a crawler fully traverses the webpage until it reaches 0. At that point, a URL is returned. When this URL is requested, it sends an alert to the user via email, including the visitor’s IP address and user agent. - - -To enable this feature, set the canary token URL [using the environment variable](#configuration-via-environment-variables) `CANARY_TOKEN_URL`. - -## Customizing the wordlist - -Edit `wordlists.json` to customize fake data for your use case - -```json -{ - "usernames": { - "prefixes": ["admin", "root", "user"], - "suffixes": ["_prod", "_dev", "123"] - }, - "passwords": { - "prefixes": ["P@ssw0rd", "Admin"], - "simple": ["test", "password"] - }, - "directory_listing": { - "files": ["credentials.txt", "backup.sql"], - "directories": ["admin/", "backup/"] - } -} -``` - -or **values.yaml** in the case of helm chart installation - -## Dashboard - -Access the dashboard at `http://:/` - -The dashboard shows: -- Total and unique accesses -- Suspicious activity detection -- Top IPs, paths, and user-agents -- Real-time monitoring - -The attackers' triggered honeypot path and the suspicious activity (such as failed login attempts) are logged - -![dashboard-1](img/dashboard-1.png) - -The top IP Addresses is shown along with top paths and User Agents - -![dashboard-2](img/dashboard-2.png) - -### Retrieving Dashboard Path - -Check server startup logs or get the secret with - -```bash -kubectl get secret krawl-server -n krawl-system \ - -o jsonpath='{.data.dashboard-path}' | base64 -d && echo -``` - -## 🤝 Contributing - -Contributions welcome! Please: -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Submit a pull request (explain the changes!) - - -
- -## ⚠️ Disclaimer - -**This is a deception/honeypot system.** -Deploy in isolated environments and monitor carefully for security events. -Use responsibly and in compliance with applicable laws and regulations. - -## Star History -Star History Chart +

🕷️ Krawl

+ +

+ + +

+
+ +

+ A modern, customizable zero-dependencies honeypot server designed to detect and track malicious activity through deceptive web pages, fake credentials, and canary tokens. +

+ + + + + +
+ +

+ What is Krawl? • + Quick Start • + Honeypot Pages • + Dashboard • + Todo • + Contributing +

+ +
+
+ +## Demo +Tip: crawl the `robots.txt` paths for additional fun +### Krawl URL: [http://demo.krawlme.com](http://demo.krawlme.com) +### View the dashboard [http://demo.krawlme.com/das_dashboard](http://demo.krawlme.com/das_dashboard) + +## What is Krawl? + +**Krawl** is a cloud‑native deception server designed to detect, delay, and analyze malicious web crawlers and automated scanners. + +It creates realistic fake web applications filled with low‑hanging fruit such as admin panels, configuration files, and exposed fake credentials to attract and identify suspicious activity. + +By wasting attacker resources, Krawl helps clearly distinguish malicious behavior from legitimate crawlers. + +It features: + +- **Spider Trap Pages**: Infinite random links to waste crawler resources based on the [spidertrap project](https://github.com/adhdproject/spidertrap) +- **Fake Login Pages**: WordPress, phpMyAdmin, admin panels +- **Honeypot Paths**: Advertised in robots.txt to catch scanners +- **Fake Credentials**: Realistic-looking usernames, passwords, API keys +- **[Canary Token](#customizing-the-canary-token) Integration**: External alert triggering +- **Real-time Dashboard**: Monitor suspicious activity +- **Customizable Wordlists**: Easy JSON-based configuration +- **Random Error Injection**: Mimic real server behavior + +![asd](img/deception-page.png) + +## 🚀 Quick Start +## Helm Chart + +Install with default values + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --namespace krawl-system \ + --create-namespace +``` + +Install with custom [canary token](#customizing-the-canary-token) + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --namespace krawl-system \ + --create-namespace \ + --set config.canaryTokenUrl="http://your-canary-token-url" +``` + +To access the deception server + +```bash +kubectl get svc krawl -n krawl-system +``` + +Once the EXTERNAL-IP is assigned, access your deception server at: + +``` +http://:5000 +``` + +## Kubernetes / Kustomize +Apply all manifests with + +```bash +kubectl apply -f https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/manifests/krawl-all-in-one-deploy.yaml +``` + +Retrieve dashboard path with +```bash +kubectl get secret krawl-server -n krawl-system -o jsonpath='{.data.dashboard-path}' | base64 -d +``` + +Or clone the repo and apply the `manifest` folder with + +```bash +kubectl apply -k manifests +``` + +## Docker +Run Krawl as a docker container with + +```bash +docker run -d \ + -p 5000:5000 \ + -e CANARY_TOKEN_URL="http://your-canary-token-url" \ + --name krawl \ + ghcr.io/blessedrebus/krawl:latest +``` + +## Docker Compose +Run Krawl with docker-compose in the project folder with + +```bash +docker-compose up -d +``` + +Stop it with + +```bash +docker-compose down +``` + +## Python 3.11+ + +Clone the repository + +```bash +git clone https://github.com/blessedrebus/krawl.git +cd krawl/src +``` +Run the server +```bash +python3 server.py +``` + +Visit + +`http://localhost:5000` + +To access the dashboard + +`http://localhost:5000/` + +## Configuration via Environment Variables + +To customize the deception server installation, environment variables can be specified using the naming convention: `KRAWL_` where `` is the configuration field name in uppercase with special characters converted: +- `.` → `_` +- `-` → `__` (double underscore) +- ` ` (space) → `_` + +### Configuration Variables + +| Configuration Field | Environment Variable | Description | Default | +|-----------|-----------|-------------|---------| +| `port` | `KRAWL_PORT` | Server listening port | `5000` | +| `delay` | `KRAWL_DELAY` | Response delay in milliseconds | `100` | +| `server_header` | `KRAWL_SERVER_HEADER` | HTTP Server header for deception | `""` | +| `links_length_range` | `KRAWL_LINKS_LENGTH_RANGE` | Link length range as `min,max` | `5,15` | +| `links_per_page_range` | `KRAWL_LINKS_PER_PAGE_RANGE` | Links per page as `min,max` | `10,15` | +| `char_space` | `KRAWL_CHAR_SPACE` | Characters used for link generation | `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789` | +| `max_counter` | `KRAWL_MAX_COUNTER` | Initial counter value | `10` | +| `canary_token_url` | `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | +| `canary_token_tries` | `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | +| `dashboard_secret_path` | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | +| `api_server_url` | `KRAWL_API_SERVER_URL` | API server URL | None | +| `api_server_port` | `KRAWL_API_SERVER_PORT` | API server port | `8080` | +| `api_server_path` | `KRAWL_API_SERVER_PATH` | API server endpoint path | `/api/v2/users` | +| `probability_error_codes` | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | +| `database_path` | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | +| `database_retention_days` | `KRAWL_DATABASE_RETENTION_DAYS` | Days to retain data in database | `30` | +| `http_risky_methods_threshold` | `KRAWL_HTTP_RISKY_METHODS_THRESHOLD` | Threshold for risky HTTP methods detection | `0.1` | +| `violated_robots_threshold` | `KRAWL_VIOLATED_ROBOTS_THRESHOLD` | Threshold for robots.txt violations | `0.1` | +| `uneven_request_timing_threshold` | `KRAWL_UNEVEN_REQUEST_TIMING_THRESHOLD` | Coefficient of variation threshold for timing | `0.5` | +| `uneven_request_timing_time_window_seconds` | `KRAWL_UNEVEN_REQUEST_TIMING_TIME_WINDOW_SECONDS` | Time window for request timing analysis in seconds | `300` | +| `user_agents_used_threshold` | `KRAWL_USER_AGENTS_USED_THRESHOLD` | Threshold for detecting multiple user agents | `2` | +| `attack_urls_threshold` | `KRAWL_ATTACK_URLS_THRESHOLD` | Threshold for attack URL detection | `1` | + +### Examples + +```bash +# Set port and delay +export KRAWL_PORT=8080 +export KRAWL_DELAY=200 + +# Set canary token +export KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" + +# Set tuple values (min,max format) +export KRAWL_LINKS_LENGTH_RANGE="3,20" +export KRAWL_LINKS_PER_PAGE_RANGE="5,25" + +# Set analyzer thresholds +export KRAWL_HTTP_RISKY_METHODS_THRESHOLD="0.2" +export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15" + +# Set custom dashboard path +export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" +``` + +Or in Docker: + +```bash +docker run -d \ + -p 5000:5000 \ + -e KRAWL_PORT=5000 \ + -e KRAWL_DELAY=100 \ + -e KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" \ + --name krawl \ + ghcr.io/blessedrebus/krawl:latest +``` + +## robots.txt +The actual (juicy) robots.txt configuration is the following + +```txt +Disallow: /admin/ +Disallow: /api/ +Disallow: /backup/ +Disallow: /config/ +Disallow: /database/ +Disallow: /private/ +Disallow: /uploads/ +Disallow: /wp-admin/ +Disallow: /phpMyAdmin/ +Disallow: /admin/login.php +Disallow: /api/v1/users +Disallow: /api/v2/secrets +Disallow: /.env +Disallow: /credentials.txt +Disallow: /passwords.txt +Disallow: /.git/ +Disallow: /backup.sql +Disallow: /db_backup.sql +``` + +## Honeypot pages +Requests to common admin endpoints (`/admin/`, `/wp-admin/`, `/phpMyAdmin/`) return a fake login page. Any login attempt triggers a 1-second delay to simulate real processing and is fully logged in the dashboard (credentials, IP, headers, timing). + +
+ +
+ +Requests to paths like `/backup/`, `/config/`, `/database/`, `/private/`, or `/uploads/` return a fake directory listing populated with “interesting” files, each assigned a random file size to look realistic. + +![directory-page](img/directory-page.png) + +The `.env` endpoint exposes fake database connection strings, **AWS API keys**, and **Stripe secrets**. It intentionally returns an error due to the `Content-Type` being `application/json` instead of plain text, mimicking a “juicy” misconfiguration that crawlers and scanners often flag as information leakage. + +![env-page](img/env-page.png) + +The pages `/api/v1/users` and `/api/v2/secrets` show fake users and random secrets in JSON format + +
+ + +
+ +The pages `/credentials.txt` and `/passwords.txt` show fake users and random secrets + +
+ + +
+ +## Customizing the Canary Token +To create a custom canary token, visit https://canarytokens.org + +and generate a “Web bug” canary token. + +This optional token is triggered when a crawler fully traverses the webpage until it reaches 0. At that point, a URL is returned. When this URL is requested, it sends an alert to the user via email, including the visitor’s IP address and user agent. + + +To enable this feature, set the canary token URL [using the environment variable](#configuration-via-environment-variables) `CANARY_TOKEN_URL`. + +## Customizing the wordlist + +Edit `wordlists.json` to customize fake data for your use case + +```json +{ + "usernames": { + "prefixes": ["admin", "root", "user"], + "suffixes": ["_prod", "_dev", "123"] + }, + "passwords": { + "prefixes": ["P@ssw0rd", "Admin"], + "simple": ["test", "password"] + }, + "directory_listing": { + "files": ["credentials.txt", "backup.sql"], + "directories": ["admin/", "backup/"] + } +} +``` + +or **values.yaml** in the case of helm chart installation + +## Dashboard + +Access the dashboard at `http://:/` + +The dashboard shows: +- Total and unique accesses +- Suspicious activity detection +- Top IPs, paths, and user-agents +- Real-time monitoring + +The attackers' triggered honeypot path and the suspicious activity (such as failed login attempts) are logged + +![dashboard-1](img/dashboard-1.png) + +The top IP Addresses is shown along with top paths and User Agents + +![dashboard-2](img/dashboard-2.png) + +### Retrieving Dashboard Path + +Check server startup logs or get the secret with + +```bash +kubectl get secret krawl-server -n krawl-system \ + -o jsonpath='{.data.dashboard-path}' | base64 -d && echo +``` + +## 🤝 Contributing + +Contributions welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request (explain the changes!) + + +
+ +## ⚠️ Disclaimer + +**This is a deception/honeypot system.** +Deploy in isolated environments and monitor carefully for security events. +Use responsibly and in compliance with applicable laws and regulations. + +## Star History +Star History Chart diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 288225d..028a9f3 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 0.1.3 +version: 0.1.4 appVersion: 0.1.6 keywords: - honeypot diff --git a/src/config.py b/src/config.py index 1a9dbc2..df83380 100644 --- a/src/config.py +++ b/src/config.py @@ -111,13 +111,40 @@ class Config: attack_urls_threshold=analyzer.get('attack_urls_threshold', 1) ) +def __get_env_from_config(config: str) -> str: + + env = config.upper().replace('.', '_').replace('-', '__').replace(' ', '_') + + return f'KRAWL_{env}' + +def override_config_from_env(config: Config = None): + """Initialize configuration from environment variables""" + + for field in config.__dataclass_fields__: + + env_var = __get_env_from_config(field) + if env_var in os.environ: + field_type = config.__dataclass_fields__[field].type + env_value = os.environ[env_var] + if field_type == int: + setattr(config, field, int(env_value)) + elif field_type == float: + setattr(config, field, float(env_value)) + elif field_type == Tuple[int, int]: + parts = env_value.split(',') + if len(parts) == 2: + setattr(config, field, (int(parts[0]), int(parts[1]))) + else: + setattr(config, field, env_value) _config_instance = None - def get_config() -> Config: """Get the singleton Config instance""" global _config_instance if _config_instance is None: _config_instance = Config.from_yaml() - return _config_instance + + override_config_from_env(_config_instance) + + return _config_instance \ No newline at end of file From 4e4c370b72e5504ef7d8786a484708359583e67d Mon Sep 17 00:00:00 2001 From: leonardobambini <91343329+leonardobambini@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:33:32 +0100 Subject: [PATCH 55/70] added site depth limit mechanism (#48) * added site depth limit mechanism * modified max pages limit and ban duration seconds --------- Co-authored-by: Leonardo Bambini Co-authored-by: BlessedRebuS --- src/config.py | 11 +++- src/exports/malicious_ips.txt | 6 ++ src/handler.py | 47 +++++++++++++- src/server.py | 2 +- src/tracker.py | 115 +++++++++++++++++++++++++++++++++- 5 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 src/exports/malicious_ips.txt diff --git a/src/config.py b/src/config.py index df83380..771e8c2 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,11 @@ class Config: api_server_path: str = "/api/v2/users" probability_error_codes: int = 0 # Percentage (0-100) + # Crawl limiting settings - for legitimate vs malicious crawlers + max_pages_limit: int = 100 # Max pages limit for good crawlers and regular users (and bad crawlers/attackers if infinite_pages_for_malicious is False) + infinite_pages_for_malicious: bool = True # Infinite pages for malicious crawlers + ban_duration_seconds: int = 600 # Ban duration in seconds for IPs exceeding limits + # Database settings database_path: str = "data/krawl.db" database_retention_days: int = 30 @@ -70,6 +75,7 @@ class Config: database = data.get('database', {}) behavior = data.get('behavior', {}) analyzer = data.get('analyzer') or {} + crawl = data.get('crawl', {}) # Handle dashboard_secret_path - auto-generate if null/not set dashboard_path = dashboard.get('secret_path') @@ -108,7 +114,10 @@ class Config: uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 0.5), # coefficient of variation uneven_request_timing_time_window_seconds=analyzer.get('uneven_request_timing_time_window_seconds', 300), user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 2), - attack_urls_threshold=analyzer.get('attack_urls_threshold', 1) + attack_urls_threshold=analyzer.get('attack_urls_threshold', 1), + infinite_pages_for_malicious=crawl.get('infinite_pages_for_malicious', True), + max_pages_limit=crawl.get('max_pages_limit', 200), + ban_duration_seconds=crawl.get('ban_duration_seconds', 60) ) def __get_env_from_config(config: str) -> str: diff --git a/src/exports/malicious_ips.txt b/src/exports/malicious_ips.txt new file mode 100644 index 0000000..34fc01a --- /dev/null +++ b/src/exports/malicious_ips.txt @@ -0,0 +1,6 @@ +127.0.0.1 +175.23.45.67 +205.32.180.65 +198.51.100.89 +210.45.67.89 +203.0.113.45 diff --git a/src/handler.py b/src/handler.py index ef26fb5..9cae1ce 100644 --- a/src/handler.py +++ b/src/handler.py @@ -56,6 +56,18 @@ class Handler(BaseHTTPRequestHandler): """Extract user agent from request""" return self.headers.get('User-Agent', '') + def _get_category_by_ip(self, client_ip: str) -> str: + """Get the category of an IP from the database""" + return self.tracker.get_category_by_ip(client_ip) + + def _get_page_visit_count(self, client_ip: str) -> int: + """Get current page visit count for an IP""" + return self.tracker.get_page_visit_count(client_ip) + + def _increment_page_visit(self, client_ip: str) -> int: + """Increment page visit counter for an IP and return new count""" + return self.tracker.increment_page_visit(client_ip) + def version_string(self) -> str: """Return custom server version for deception.""" return random_server_header() @@ -135,10 +147,33 @@ class Handler(BaseHTTPRequestHandler): pass return True - def generate_page(self, seed: str) -> str: - """Generate a webpage containing random links or canary token""" + def generate_page(self, seed: str, page_visit_count: int) -> str: + """Generate a webpage containing random links or canary token""" + random.seed(seed) num_pages = random.randint(*self.config.links_per_page_range) + + # Check if this is a good crawler by IP category from database + ip_category = self._get_category_by_ip(self._get_client_ip()) + + # Determine if we should apply crawler page limit based on config and IP category + should_apply_crawler_limit = False + if self.config.infinite_pages_for_malicious: + if (ip_category == "good_crawler" or ip_category == "regular_user") and page_visit_count >= self.config.max_pages_limit: + should_apply_crawler_limit = True + else: + if (ip_category == "good_crawler" or ip_category == "bad_crawler" or ip_category == "attacker") and page_visit_count >= self.config.max_pages_limit: + should_apply_crawler_limit = True + + + # If good crawler reached max pages, return a simple page with no links + if should_apply_crawler_limit: + return html_templates.main_page( + Handler.counter, + '

Crawl limit reached.

' + ) + + num_pages = random.randint(*self.config.links_per_page_range) # Build the content HTML content = "" @@ -399,6 +434,10 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): """Responds to webpage requests""" client_ip = self._get_client_ip() + if self.tracker.is_banned_ip(client_ip): + self.send_response(500) + self.end_headers() + return user_agent = self._get_user_agent() if self.config.dashboard_secret_path and self.path == self.config.dashboard_secret_path: @@ -495,7 +534,9 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() try: - self.wfile.write(self.generate_page(self.path).encode()) + # Increment page visit counter for this IP and get the current count + current_visit_count = self._increment_page_visit(client_ip) + self.wfile.write(self.generate_page(self.path, current_visit_count).encode()) Handler.counter -= 1 diff --git a/src/server.py b/src/server.py index a61a372..05bc006 100644 --- a/src/server.py +++ b/src/server.py @@ -67,7 +67,7 @@ def main(): except Exception as e: app_logger.warning(f'Database initialization failed: {e}. Continuing with in-memory only.') - tracker = AccessTracker() + tracker = AccessTracker(config.max_pages_limit, config.ban_duration_seconds) analyzer = Analyzer() Handler.config = config diff --git a/src/tracker.py b/src/tracker.py index 8bec7ce..da07569 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -17,7 +17,7 @@ class AccessTracker: Maintains in-memory structures for fast dashboard access and persists data to SQLite for long-term storage and analysis. """ - def __init__(self, db_manager: Optional[DatabaseManager] = None): + def __init__(self, max_pages_limit, ban_duration_seconds, db_manager: Optional[DatabaseManager] = None): """ Initialize the access tracker. @@ -25,11 +25,17 @@ class AccessTracker: db_manager: Optional DatabaseManager for persistence. If None, will use the global singleton. """ + self.max_pages_limit = max_pages_limit + self.ban_duration_seconds = ban_duration_seconds self.ip_counts: Dict[str, int] = defaultdict(int) self.path_counts: Dict[str, int] = defaultdict(int) self.user_agent_counts: Dict[str, int] = defaultdict(int) self.access_log: List[Dict] = [] self.credential_attempts: List[Dict] = [] + + # Track pages visited by each IP (for good crawler limiting) + self.ip_page_visits: Dict[str, Dict[str, object]] = defaultdict(dict) + self.suspicious_patterns = [ 'bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python-requests', 'scanner', 'nikto', 'sqlmap', 'nmap', 'masscan', 'nessus', 'acunetix', @@ -253,6 +259,113 @@ class AccessTracker: ua_lower = user_agent.lower() return any(pattern in ua_lower for pattern in self.suspicious_patterns) + def get_category_by_ip(self, client_ip: str) -> str: + """ + Check if an IP has been categorized as a 'good crawler' in the database. + Uses the IP category from IpStats table. + + Args: + client_ip: The client IP address (will be sanitized) + + Returns: + True if the IP is categorized as 'good crawler', False otherwise + """ + try: + from sanitizer import sanitize_ip + # Sanitize the IP address + safe_ip = sanitize_ip(client_ip) + + # Query the database for this IP's category + db = self.db + if not db: + return False + + ip_stats = db.get_ip_stats_by_ip(safe_ip) + if not ip_stats or not ip_stats.get('category'): + return False + + # Check if category matches "good crawler" + category = ip_stats.get('category', '').lower().strip() + return category + + except Exception as e: + # Log but don't crash on database errors + import logging + logging.error(f"Error checking IP category for {client_ip}: {str(e)}") + return False + + def increment_page_visit(self, client_ip: str) -> int: + """ + Increment page visit counter for an IP and return the new count. + If ban timestamp exists and 60+ seconds have passed, reset the counter. + + Args: + client_ip: The client IP address + + Returns: + The updated page visit count for this IP + """ + try: + # Initialize if not exists + if client_ip not in self.ip_page_visits: + self.ip_page_visits[client_ip] = {"count": 0, "ban_timestamp": None} + + # Increment count + self.ip_page_visits[client_ip]["count"] += 1 + + # Set ban if reached limit + if self.ip_page_visits[client_ip]["count"] >= self.max_pages_limit: + self.ip_page_visits[client_ip]["ban_timestamp"] = datetime.now().isoformat() + + return self.ip_page_visits[client_ip]["count"] + + except Exception: + return 0 + + def is_banned_ip(self, client_ip: str) -> bool: + """ + Check if an IP is currently banned due to exceeding page visit limits. + + Args: + client_ip: The client IP address + Returns: + True if the IP is banned, False otherwise + """ + try: + if client_ip in self.ip_page_visits: + ban_timestamp = self.ip_page_visits[client_ip]["ban_timestamp"] + if ban_timestamp is not None: + banned = True + + #Check if ban period has expired (> 60 seconds) + ban_time = datetime.fromisoformat(self.ip_page_visits[client_ip]["ban_timestamp"]) + time_diff = datetime.now() - ban_time + if time_diff.total_seconds() > self.ban_duration_seconds: + self.ip_page_visits[client_ip]["count"] = 0 + self.ip_page_visits[client_ip]["ban_timestamp"] = None + banned = False + + return banned + + except Exception: + return False + + + def get_page_visit_count(self, client_ip: str) -> int: + """ + Get the current page visit count for an IP. + + Args: + client_ip: The client IP address + + Returns: + The page visit count for this IP + """ + try: + return self.ip_page_visits.get(client_ip, 0) + except Exception: + return 0 + def get_top_ips(self, limit: int = 10) -> List[Tuple[str, int]]: """Get top N IP addresses by access count""" return sorted(self.ip_counts.items(), key=lambda x: x[1], reverse=True)[:limit] From 5ce4ab1955ef81dcd9b37dcabcc6e38352347a13 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 23 Jan 2026 21:50:45 +0100 Subject: [PATCH 56/70] Add analyzer configuration parameters to configmap and values files --- helm/templates/configmap.yaml | 7 +++++++ helm/values.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 808d9f5..d6e5f5c 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -32,3 +32,10 @@ data: retention_days: {{ .Values.config.database.retention_days }} behavior: probability_error_codes: {{ .Values.config.behavior.probability_error_codes }} + analyzer: + http_risky_methods_threshold: {{ .Values.config.analyzer.http_risky_methods_threshold }} + violated_robots_threshold: {{ .Values.config.analyzer.violated_robots_threshold }} + uneven_request_timing_threshold: {{ .Values.config.analyzer.uneven_request_timing_threshold }} + uneven_request_timing_time_window_seconds: {{ .Values.config.analyzer.uneven_request_timing_time_window_seconds }} + user_agents_used_threshold: {{ .Values.config.analyzer.user_agents_used_threshold }} + attack_urls_threshold: {{ .Values.config.analyzer.attack_urls_threshold }} diff --git a/helm/values.yaml b/helm/values.yaml index 60b1a66..0b83892 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -89,6 +89,13 @@ config: retention_days: 30 behavior: probability_error_codes: 0 + analyzer: + http_risky_methods_threshold: 0.1 + violated_robots_threshold: 0.1 + uneven_request_timing_threshold: 2 + uneven_request_timing_time_window_seconds: 300 + user_agents_used_threshold: 2 + attack_urls_threshold: 1 # Database persistence configuration database: From 25384585d93ba367d5e650b52b9012fb6aec7670 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 23 Jan 2026 21:51:20 +0100 Subject: [PATCH 57/70] Bump chart version to 0.1.5 and app version to 0.1.7 --- helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 028a9f3..928d0f8 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 0.1.4 -appVersion: 0.1.6 +version: 0.1.5 +appVersion: 0.1.7 keywords: - honeypot - security From 4450d3a4e36b64ea2512bc638e5aa50fb7ab9638 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 23 Jan 2026 22:00:21 +0100 Subject: [PATCH 58/70] Linted code iwht black tool --- src/analyzer.py | 7 +- src/config.py | 136 ++++++---- src/database.py | 361 +++++++++++++++---------- src/generators.py | 160 ++++++----- src/handler.py | 291 ++++++++++++-------- src/logger.py | 8 +- src/migrations/add_category_history.py | 10 +- src/models.py | 109 +++++--- src/sanitizer.py | 8 +- src/server.py | 97 ++++--- src/server_errors.py | 56 ++-- src/sql_errors.py | 95 +++---- src/tasks/analyze_ips.py | 299 +++++++++++++++----- src/tasks/fetch_ip_rep.py | 13 +- src/tasks/top_attacking_ips.py | 33 ++- src/tasks_master.py | 123 ++++++--- src/templates/__init__.py | 8 +- src/templates/dashboard_template.py | 84 +++--- src/templates/template_loader.py | 7 +- src/tracker.py | 258 +++++++++++------- src/wordlists.py | 76 +++--- src/xss_detector.py | 16 +- 22 files changed, 1387 insertions(+), 868 deletions(-) diff --git a/src/analyzer.py b/src/analyzer.py index c0ff515..860a206 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -19,10 +19,12 @@ Functions for user activity analysis app_logger = get_app_logger() + class Analyzer: """ Analyzes users activity and produces aggregated insights """ + def __init__(self, db_manager: Optional[DatabaseManager] = None): """ Initialize the access tracker. @@ -102,7 +104,6 @@ class Analyzer: # } # } - # accesses = self.db.get_access_logs(ip_filter = ip, limit=1000) # total_accesses_count = len(accesses) # if total_accesses_count <= 0: @@ -119,7 +120,6 @@ class Analyzer: # #--------------------- HTTP Methods --------------------- - # get_accesses_count = len([item for item in accesses if item["method"] == "GET"]) # post_accesses_count = len([item for item in accesses if item["method"] == "POST"]) # put_accesses_count = len([item for item in accesses if item["method"] == "PUT"]) @@ -214,7 +214,6 @@ class Analyzer: # score["bad_crawler"]["uneven_request_timing"] = False # score["regular_user"]["uneven_request_timing"] = False - # #--------------------- Different User Agents --------------------- # #Header Quality and Consistency: Crawlers tend to use complete and consistent headers, attackers might miss, fake, or change headers # user_agents_used = [item["user_agent"] for item in accesses] @@ -317,8 +316,6 @@ class Analyzer: # return 0 - - # def update_ip_rep_infos(self, ip: str) -> list[str]: # api_url = "https://iprep.lcrawl.com/api/iprep/" # params = { diff --git a/src/config.py b/src/config.py index 771e8c2..629c18c 100644 --- a/src/config.py +++ b/src/config.py @@ -14,12 +14,13 @@ import yaml @dataclass class Config: """Configuration class for the deception server""" + port: int = 5000 delay: int = 100 # milliseconds server_header: str = "" links_length_range: Tuple[int, int] = (5, 15) links_per_page_range: Tuple[int, int] = (10, 15) - char_space: str = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + char_space: str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" max_counter: int = 10 canary_token_url: Optional[str] = None canary_token_tries: int = 10 @@ -30,7 +31,9 @@ class Config: probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers - max_pages_limit: int = 100 # Max pages limit for good crawlers and regular users (and bad crawlers/attackers if infinite_pages_for_malicious is False) + max_pages_limit: int = ( + 100 # Max pages limit for good crawlers and regular users (and bad crawlers/attackers if infinite_pages_for_malicious is False) + ) infinite_pages_for_malicious: bool = True # Infinite pages for malicious crawlers ban_duration_seconds: int = 600 # Ban duration in seconds for IPs exceeding limits @@ -47,90 +50,111 @@ class Config: attack_urls_threshold: float = None @classmethod - def from_yaml(cls) -> 'Config': + def from_yaml(cls) -> "Config": """Create configuration from YAML file""" - config_location = os.getenv('CONFIG_LOCATION', 'config.yaml') + config_location = os.getenv("CONFIG_LOCATION", "config.yaml") config_path = Path(__file__).parent.parent / config_location try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: data = yaml.safe_load(f) except FileNotFoundError: - print(f"Error: Configuration file '{config_path}' not found.", file=sys.stderr) - print(f"Please create a config.yaml file or set CONFIG_LOCATION environment variable.", file=sys.stderr) + print( + f"Error: Configuration file '{config_path}' not found.", file=sys.stderr + ) + print( + f"Please create a config.yaml file or set CONFIG_LOCATION environment variable.", + file=sys.stderr, + ) sys.exit(1) except yaml.YAMLError as e: - print(f"Error: Invalid YAML in configuration file '{config_path}': {e}", file=sys.stderr) + print( + f"Error: Invalid YAML in configuration file '{config_path}': {e}", + file=sys.stderr, + ) sys.exit(1) if data is None: data = {} # Extract nested values with defaults - server = data.get('server', {}) - links = data.get('links', {}) - canary = data.get('canary', {}) - dashboard = data.get('dashboard', {}) - api = data.get('api', {}) - database = data.get('database', {}) - behavior = data.get('behavior', {}) - analyzer = data.get('analyzer') or {} - crawl = data.get('crawl', {}) + server = data.get("server", {}) + links = data.get("links", {}) + canary = data.get("canary", {}) + dashboard = data.get("dashboard", {}) + api = data.get("api", {}) + database = data.get("database", {}) + behavior = data.get("behavior", {}) + analyzer = data.get("analyzer") or {} + crawl = data.get("crawl", {}) # Handle dashboard_secret_path - auto-generate if null/not set - dashboard_path = dashboard.get('secret_path') + dashboard_path = dashboard.get("secret_path") if dashboard_path is None: - dashboard_path = f'/{os.urandom(16).hex()}' + dashboard_path = f"/{os.urandom(16).hex()}" else: # ensure the dashboard path starts with a / if dashboard_path[:1] != "/": dashboard_path = f"/{dashboard_path}" return cls( - port=server.get('port', 5000), - delay=server.get('delay', 100), - server_header=server.get('server_header',""), + port=server.get("port", 5000), + delay=server.get("delay", 100), + server_header=server.get("server_header", ""), links_length_range=( - links.get('min_length', 5), - links.get('max_length', 15) + links.get("min_length", 5), + links.get("max_length", 15), ), links_per_page_range=( - links.get('min_per_page', 10), - links.get('max_per_page', 15) + links.get("min_per_page", 10), + links.get("max_per_page", 15), ), - char_space=links.get('char_space', 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), - max_counter=links.get('max_counter', 10), - canary_token_url=canary.get('token_url'), - canary_token_tries=canary.get('token_tries', 10), + char_space=links.get( + "char_space", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + ), + max_counter=links.get("max_counter", 10), + canary_token_url=canary.get("token_url"), + canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, - api_server_url=api.get('server_url'), - api_server_port=api.get('server_port', 8080), - api_server_path=api.get('server_path', '/api/v2/users'), - probability_error_codes=behavior.get('probability_error_codes', 0), - database_path=database.get('path', 'data/krawl.db'), - database_retention_days=database.get('retention_days', 30), - http_risky_methods_threshold=analyzer.get('http_risky_methods_threshold', 0.1), - violated_robots_threshold=analyzer.get('violated_robots_threshold', 0.1), - uneven_request_timing_threshold=analyzer.get('uneven_request_timing_threshold', 0.5), # coefficient of variation - uneven_request_timing_time_window_seconds=analyzer.get('uneven_request_timing_time_window_seconds', 300), - user_agents_used_threshold=analyzer.get('user_agents_used_threshold', 2), - attack_urls_threshold=analyzer.get('attack_urls_threshold', 1), - infinite_pages_for_malicious=crawl.get('infinite_pages_for_malicious', True), - max_pages_limit=crawl.get('max_pages_limit', 200), - ban_duration_seconds=crawl.get('ban_duration_seconds', 60) + api_server_url=api.get("server_url"), + api_server_port=api.get("server_port", 8080), + api_server_path=api.get("server_path", "/api/v2/users"), + probability_error_codes=behavior.get("probability_error_codes", 0), + database_path=database.get("path", "data/krawl.db"), + database_retention_days=database.get("retention_days", 30), + http_risky_methods_threshold=analyzer.get( + "http_risky_methods_threshold", 0.1 + ), + violated_robots_threshold=analyzer.get("violated_robots_threshold", 0.1), + uneven_request_timing_threshold=analyzer.get( + "uneven_request_timing_threshold", 0.5 + ), # coefficient of variation + uneven_request_timing_time_window_seconds=analyzer.get( + "uneven_request_timing_time_window_seconds", 300 + ), + user_agents_used_threshold=analyzer.get("user_agents_used_threshold", 2), + attack_urls_threshold=analyzer.get("attack_urls_threshold", 1), + infinite_pages_for_malicious=crawl.get( + "infinite_pages_for_malicious", True + ), + max_pages_limit=crawl.get("max_pages_limit", 200), + ban_duration_seconds=crawl.get("ban_duration_seconds", 60), ) + def __get_env_from_config(config: str) -> str: - - env = config.upper().replace('.', '_').replace('-', '__').replace(' ', '_') - - return f'KRAWL_{env}' + + env = config.upper().replace(".", "_").replace("-", "__").replace(" ", "_") + + return f"KRAWL_{env}" + def override_config_from_env(config: Config = None): """Initialize configuration from environment variables""" - + for field in config.__dataclass_fields__: - + env_var = __get_env_from_config(field) if env_var in os.environ: field_type = config.__dataclass_fields__[field].type @@ -140,20 +164,22 @@ def override_config_from_env(config: Config = None): elif field_type == float: setattr(config, field, float(env_value)) elif field_type == Tuple[int, int]: - parts = env_value.split(',') + parts = env_value.split(",") if len(parts) == 2: setattr(config, field, (int(parts[0]), int(parts[1]))) else: setattr(config, field, env_value) + _config_instance = None + def get_config() -> Config: """Get the singleton Config instance""" global _config_instance if _config_instance is None: _config_instance = Config.from_yaml() - + override_config_from_env(_config_instance) - - return _config_instance \ No newline at end of file + + return _config_instance diff --git a/src/database.py b/src/database.py index bfe2725..6f21d91 100644 --- a/src/database.py +++ b/src/database.py @@ -24,7 +24,15 @@ def set_sqlite_pragma(dbapi_connection, connection_record): cursor.execute("PRAGMA busy_timeout=30000") cursor.close() -from models import Base, AccessLog, CredentialAttempt, AttackDetection, IpStats, CategoryHistory + +from models import ( + Base, + AccessLog, + CredentialAttempt, + AttackDetection, + IpStats, + CategoryHistory, +) from sanitizer import ( sanitize_ip, sanitize_path, @@ -37,6 +45,7 @@ from logger import get_app_logger applogger = get_app_logger() + class DatabaseManager: """ Singleton database manager for the Krawl honeypot. @@ -44,6 +53,7 @@ class DatabaseManager: Handles database initialization, session management, and provides methods for persisting access logs, credentials, and attack detections. """ + _instance: Optional["DatabaseManager"] = None def __new__(cls) -> "DatabaseManager": @@ -72,7 +82,7 @@ class DatabaseManager: self._engine = create_engine( database_url, connect_args={"check_same_thread": False}, - echo=False # Set to True for SQL debugging + echo=False, # Set to True for SQL debugging ) # Create session factory with scoped_session for thread safety @@ -96,7 +106,9 @@ class DatabaseManager: def session(self) -> Session: """Get a thread-local database session.""" if not self._initialized: - raise RuntimeError("DatabaseManager not initialized. Call initialize() first.") + raise RuntimeError( + "DatabaseManager not initialized. Call initialize() first." + ) return self._Session() def close_session(self) -> None: @@ -113,7 +125,7 @@ class DatabaseManager: is_suspicious: bool = False, is_honeypot_trigger: bool = False, attack_types: Optional[List[str]] = None, - matched_patterns: Optional[Dict[str, str]] = None + matched_patterns: Optional[Dict[str, str]] = None, ) -> Optional[int]: """ Persist an access log entry to the database. @@ -141,7 +153,7 @@ class DatabaseManager: method=method[:10], is_suspicious=is_suspicious, is_honeypot_trigger=is_honeypot_trigger, - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(access_log) session.flush() # Get the ID before committing @@ -155,7 +167,7 @@ class DatabaseManager: attack_type=attack_type[:50], matched_pattern=sanitize_attack_pattern( matched_patterns.get(attack_type, "") - ) + ), ) session.add(detection) @@ -178,7 +190,7 @@ class DatabaseManager: ip: str, path: str, username: Optional[str] = None, - password: Optional[str] = None + password: Optional[str] = None, ) -> Optional[int]: """ Persist a credential attempt to the database. @@ -199,7 +211,7 @@ class DatabaseManager: path=sanitize_path(path), username=sanitize_credential(username), password=sanitize_credential(password), - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(credential) session.commit() @@ -230,14 +242,18 @@ class DatabaseManager: ip_stats.last_seen = now else: ip_stats = IpStats( - ip=sanitized_ip, - total_requests=1, - first_seen=now, - last_seen=now + ip=sanitized_ip, total_requests=1, first_seen=now, last_seen=now ) session.add(ip_stats) - def update_ip_stats_analysis(self, ip: str, analyzed_metrics: Dict[str, object], category: str, category_scores: Dict[str, int], last_analysis: datetime) -> None: + def update_ip_stats_analysis( + self, + ip: str, + analyzed_metrics: Dict[str, object], + category: str, + category_scores: Dict[str, int], + last_analysis: datetime, + ) -> None: """ Update IP statistics (ip is already persisted). Records category change in history if category has changed. @@ -250,7 +266,9 @@ class DatabaseManager: last_analysis: timestamp of last analysis """ - applogger.debug(f"Analyzed metrics {analyzed_metrics}, category {category}, category scores {category_scores}, last analysis {last_analysis}") + applogger.debug( + f"Analyzed metrics {analyzed_metrics}, category {category}, category scores {category_scores}, last analysis {last_analysis}" + ) applogger.info(f"IP: {ip} category has been updated to {category}") session = self.session @@ -260,7 +278,9 @@ class DatabaseManager: # Check if category has changed and record it old_category = ip_stats.category if old_category != category: - self._record_category_change(sanitized_ip, old_category, category, last_analysis) + self._record_category_change( + sanitized_ip, old_category, category, last_analysis + ) ip_stats.analyzed_metrics = analyzed_metrics ip_stats.category = category @@ -286,11 +306,12 @@ class DatabaseManager: sanitized_ip = sanitize_ip(ip) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() - # Record the manual category change old_category = ip_stats.category if old_category != category: - self._record_category_change(sanitized_ip, old_category, category, datetime.now()) + self._record_category_change( + sanitized_ip, old_category, category, datetime.now() + ) ip_stats.category = category ip_stats.manual_category = True @@ -301,7 +322,13 @@ class DatabaseManager: session.rollback() print(f"Error updating manual category: {e}") - def _record_category_change(self, ip: str, old_category: Optional[str], new_category: str, timestamp: datetime) -> None: + def _record_category_change( + self, + ip: str, + old_category: Optional[str], + new_category: str, + timestamp: datetime, + ) -> None: """ Internal method to record category changes in history. Only records if there's an actual change from a previous category. @@ -323,7 +350,7 @@ class DatabaseManager: ip=ip, old_category=old_category, new_category=new_category, - timestamp=timestamp + timestamp=timestamp, ) session.add(history_entry) session.commit() @@ -344,22 +371,32 @@ class DatabaseManager: session = self.session try: sanitized_ip = sanitize_ip(ip) - history = session.query(CategoryHistory).filter( - CategoryHistory.ip == sanitized_ip - ).order_by(CategoryHistory.timestamp.asc()).all() + history = ( + session.query(CategoryHistory) + .filter(CategoryHistory.ip == sanitized_ip) + .order_by(CategoryHistory.timestamp.asc()) + .all() + ) return [ { - 'old_category': h.old_category, - 'new_category': h.new_category, - 'timestamp': h.timestamp.isoformat() + "old_category": h.old_category, + "new_category": h.new_category, + "timestamp": h.timestamp.isoformat(), } for h in history ] finally: self.close_session() - def update_ip_rep_infos(self, ip: str, country_code: str, asn: str, asn_org: str, list_on: Dict[str,str]) -> None: + def update_ip_rep_infos( + self, + ip: str, + country_code: str, + asn: str, + asn_org: str, + list_on: Dict[str, str], + ) -> None: """ Update IP rep stats @@ -400,20 +437,25 @@ class DatabaseManager: """ session = self.session try: - ips = session.query(IpStats.ip).filter( - IpStats.country_code.is_(None), - ~IpStats.ip.like('10.%'), - ~IpStats.ip.like('172.16.%'), - ~IpStats.ip.like('172.17.%'), - ~IpStats.ip.like('172.18.%'), - ~IpStats.ip.like('172.19.%'), - ~IpStats.ip.like('172.2_.%'), - ~IpStats.ip.like('172.30.%'), - ~IpStats.ip.like('172.31.%'), - ~IpStats.ip.like('192.168.%'), - ~IpStats.ip.like('127.%'), - ~IpStats.ip.like('169.254.%') - ).limit(limit).all() + ips = ( + session.query(IpStats.ip) + .filter( + IpStats.country_code.is_(None), + ~IpStats.ip.like("10.%"), + ~IpStats.ip.like("172.16.%"), + ~IpStats.ip.like("172.17.%"), + ~IpStats.ip.like("172.18.%"), + ~IpStats.ip.like("172.19.%"), + ~IpStats.ip.like("172.2_.%"), + ~IpStats.ip.like("172.30.%"), + ~IpStats.ip.like("172.31.%"), + ~IpStats.ip.like("192.168.%"), + ~IpStats.ip.like("127.%"), + ~IpStats.ip.like("169.254.%"), + ) + .limit(limit) + .all() + ) return [ip[0] for ip in ips] finally: self.close_session() @@ -424,7 +466,7 @@ class DatabaseManager: offset: int = 0, ip_filter: Optional[str] = None, suspicious_only: bool = False, - since_minutes: Optional[int] = None + since_minutes: Optional[int] = None, ) -> List[Dict[str, Any]]: """ Retrieve access logs with optional filtering. @@ -455,15 +497,15 @@ class DatabaseManager: return [ { - 'id': log.id, - 'ip': log.ip, - 'path': log.path, - 'user_agent': log.user_agent, - 'method': log.method, - 'is_suspicious': log.is_suspicious, - 'is_honeypot_trigger': log.is_honeypot_trigger, - 'timestamp': log.timestamp.isoformat(), - 'attack_types': [d.attack_type for d in log.attack_detections] + "id": log.id, + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "method": log.method, + "is_suspicious": log.is_suspicious, + "is_honeypot_trigger": log.is_honeypot_trigger, + "timestamp": log.timestamp.isoformat(), + "attack_types": [d.attack_type for d in log.attack_detections], } for log in logs ] @@ -521,10 +563,7 @@ class DatabaseManager: # self.close_session() def get_credential_attempts( - self, - limit: int = 100, - offset: int = 0, - ip_filter: Optional[str] = None + self, limit: int = 100, offset: int = 0, ip_filter: Optional[str] = None ) -> List[Dict[str, Any]]: """ Retrieve credential attempts with optional filtering. @@ -550,12 +589,12 @@ class DatabaseManager: return [ { - 'id': attempt.id, - 'ip': attempt.ip, - 'path': attempt.path, - 'username': attempt.username, - 'password': attempt.password, - 'timestamp': attempt.timestamp.isoformat() + "id": attempt.id, + "ip": attempt.ip, + "path": attempt.path, + "username": attempt.username, + "password": attempt.password, + "timestamp": attempt.timestamp.isoformat(), } for attempt in attempts ] @@ -574,26 +613,29 @@ class DatabaseManager: """ session = self.session try: - stats = session.query(IpStats).order_by( - IpStats.total_requests.desc() - ).limit(limit).all() + stats = ( + session.query(IpStats) + .order_by(IpStats.total_requests.desc()) + .limit(limit) + .all() + ) return [ { - 'ip': s.ip, - 'total_requests': s.total_requests, - 'first_seen': s.first_seen.isoformat(), - 'last_seen': s.last_seen.isoformat(), - 'country_code': s.country_code, - 'city': s.city, - 'asn': s.asn, - 'asn_org': s.asn_org, - 'reputation_score': s.reputation_score, - 'reputation_source': s.reputation_source, - 'analyzed_metrics': s.analyzed_metrics, - 'category': s.category, - 'manual_category': s.manual_category, - 'last_analysis': s.last_analysis + "ip": s.ip, + "total_requests": s.total_requests, + "first_seen": s.first_seen.isoformat(), + "last_seen": s.last_seen.isoformat(), + "country_code": s.country_code, + "city": s.city, + "asn": s.asn, + "asn_org": s.asn_org, + "reputation_score": s.reputation_score, + "reputation_source": s.reputation_source, + "analyzed_metrics": s.analyzed_metrics, + "category": s.category, + "manual_category": s.manual_category, + "last_analysis": s.last_analysis, } for s in stats ] @@ -621,23 +663,25 @@ class DatabaseManager: category_history = self.get_category_history(ip) return { - 'ip': stat.ip, - 'total_requests': stat.total_requests, - 'first_seen': stat.first_seen.isoformat() if stat.first_seen else None, - 'last_seen': stat.last_seen.isoformat() if stat.last_seen else None, - 'country_code': stat.country_code, - 'city': stat.city, - 'asn': stat.asn, - 'asn_org': stat.asn_org, - 'list_on': stat.list_on or {}, - 'reputation_score': stat.reputation_score, - 'reputation_source': stat.reputation_source, - 'analyzed_metrics': stat.analyzed_metrics or {}, - 'category': stat.category, - 'category_scores': stat.category_scores or {}, - 'manual_category': stat.manual_category, - 'last_analysis': stat.last_analysis.isoformat() if stat.last_analysis else None, - 'category_history': category_history + "ip": stat.ip, + "total_requests": stat.total_requests, + "first_seen": stat.first_seen.isoformat() if stat.first_seen else None, + "last_seen": stat.last_seen.isoformat() if stat.last_seen else None, + "country_code": stat.country_code, + "city": stat.city, + "asn": stat.asn, + "asn_org": stat.asn_org, + "list_on": stat.list_on or {}, + "reputation_score": stat.reputation_score, + "reputation_source": stat.reputation_source, + "analyzed_metrics": stat.analyzed_metrics or {}, + "category": stat.category, + "category_scores": stat.category_scores or {}, + "manual_category": stat.manual_category, + "last_analysis": ( + stat.last_analysis.isoformat() if stat.last_analysis else None + ), + "category_history": category_history, } finally: self.close_session() @@ -654,25 +698,32 @@ class DatabaseManager: try: # Get main aggregate counts in one query result = session.query( - func.count(AccessLog.id).label('total_accesses'), - func.count(distinct(AccessLog.ip)).label('unique_ips'), - func.count(distinct(AccessLog.path)).label('unique_paths'), - func.sum(case((AccessLog.is_suspicious == True, 1), else_=0)).label('suspicious_accesses'), - func.sum(case((AccessLog.is_honeypot_trigger == True, 1), else_=0)).label('honeypot_triggered') + func.count(AccessLog.id).label("total_accesses"), + func.count(distinct(AccessLog.ip)).label("unique_ips"), + func.count(distinct(AccessLog.path)).label("unique_paths"), + func.sum(case((AccessLog.is_suspicious == True, 1), else_=0)).label( + "suspicious_accesses" + ), + func.sum( + case((AccessLog.is_honeypot_trigger == True, 1), else_=0) + ).label("honeypot_triggered"), ).first() # Get unique IPs that triggered honeypots - honeypot_ips = session.query( - func.count(distinct(AccessLog.ip)) - ).filter(AccessLog.is_honeypot_trigger == True).scalar() or 0 + honeypot_ips = ( + session.query(func.count(distinct(AccessLog.ip))) + .filter(AccessLog.is_honeypot_trigger == True) + .scalar() + or 0 + ) return { - 'total_accesses': result.total_accesses or 0, - 'unique_ips': result.unique_ips or 0, - 'unique_paths': result.unique_paths or 0, - 'suspicious_accesses': int(result.suspicious_accesses or 0), - 'honeypot_triggered': int(result.honeypot_triggered or 0), - 'honeypot_ips': honeypot_ips + "total_accesses": result.total_accesses or 0, + "unique_ips": result.unique_ips or 0, + "unique_paths": result.unique_paths or 0, + "suspicious_accesses": int(result.suspicious_accesses or 0), + "honeypot_triggered": int(result.honeypot_triggered or 0), + "honeypot_ips": honeypot_ips, } finally: self.close_session() @@ -689,12 +740,13 @@ class DatabaseManager: """ session = self.session try: - results = session.query( - AccessLog.ip, - func.count(AccessLog.id).label('count') - ).group_by(AccessLog.ip).order_by( - func.count(AccessLog.id).desc() - ).limit(limit).all() + results = ( + session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) + .group_by(AccessLog.ip) + .order_by(func.count(AccessLog.id).desc()) + .limit(limit) + .all() + ) return [(row.ip, row.count) for row in results] finally: @@ -712,12 +764,13 @@ class DatabaseManager: """ session = self.session try: - results = session.query( - AccessLog.path, - func.count(AccessLog.id).label('count') - ).group_by(AccessLog.path).order_by( - func.count(AccessLog.id).desc() - ).limit(limit).all() + results = ( + session.query(AccessLog.path, func.count(AccessLog.id).label("count")) + .group_by(AccessLog.path) + .order_by(func.count(AccessLog.id).desc()) + .limit(limit) + .all() + ) return [(row.path, row.count) for row in results] finally: @@ -735,15 +788,16 @@ class DatabaseManager: """ session = self.session try: - results = session.query( - AccessLog.user_agent, - func.count(AccessLog.id).label('count') - ).filter( - AccessLog.user_agent.isnot(None), - AccessLog.user_agent != '' - ).group_by(AccessLog.user_agent).order_by( - func.count(AccessLog.id).desc() - ).limit(limit).all() + results = ( + session.query( + AccessLog.user_agent, func.count(AccessLog.id).label("count") + ) + .filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "") + .group_by(AccessLog.user_agent) + .order_by(func.count(AccessLog.id).desc()) + .limit(limit) + .all() + ) return [(row.user_agent, row.count) for row in results] finally: @@ -761,16 +815,20 @@ class DatabaseManager: """ session = self.session try: - logs = session.query(AccessLog).filter( - AccessLog.is_suspicious == True - ).order_by(AccessLog.timestamp.desc()).limit(limit).all() + logs = ( + session.query(AccessLog) + .filter(AccessLog.is_suspicious == True) + .order_by(AccessLog.timestamp.desc()) + .limit(limit) + .all() + ) return [ { - 'ip': log.ip, - 'path': log.path, - 'user_agent': log.user_agent, - 'timestamp': log.timestamp.isoformat() + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "timestamp": log.timestamp.isoformat(), } for log in logs ] @@ -787,12 +845,11 @@ class DatabaseManager: session = self.session try: # Get all honeypot triggers grouped by IP - results = session.query( - AccessLog.ip, - AccessLog.path - ).filter( - AccessLog.is_honeypot_trigger == True - ).all() + results = ( + session.query(AccessLog.ip, AccessLog.path) + .filter(AccessLog.is_honeypot_trigger == True) + .all() + ) # Group paths by IP ip_paths: Dict[str, List[str]] = {} @@ -819,17 +876,21 @@ class DatabaseManager: session = self.session try: # Get access logs that have attack detections - logs = session.query(AccessLog).join( - AttackDetection - ).order_by(AccessLog.timestamp.desc()).limit(limit).all() + logs = ( + session.query(AccessLog) + .join(AttackDetection) + .order_by(AccessLog.timestamp.desc()) + .limit(limit) + .all() + ) return [ { - 'ip': log.ip, - 'path': log.path, - 'user_agent': log.user_agent, - 'timestamp': log.timestamp.isoformat(), - 'attack_types': [d.attack_type for d in log.attack_detections] + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "timestamp": log.timestamp.isoformat(), + "attack_types": [d.attack_type for d in log.attack_detections], } for log in logs ] diff --git a/src/generators.py b/src/generators.py index 92eb590..fd29f38 100644 --- a/src/generators.py +++ b/src/generators.py @@ -11,6 +11,7 @@ from templates import html_templates from wordlists import get_wordlists from config import get_config + def random_username() -> str: """Generate random username""" wl = get_wordlists() @@ -21,10 +22,10 @@ def random_password() -> str: """Generate random password""" wl = get_wordlists() templates = [ - lambda: ''.join(random.choices(string.ascii_letters + string.digits, k=12)), + lambda: "".join(random.choices(string.ascii_letters + string.digits, k=12)), lambda: f"{random.choice(wl.password_prefixes)}{random.randint(100, 999)}!", lambda: f"{random.choice(wl.simple_passwords)}{random.randint(1000, 9999)}", - lambda: ''.join(random.choices(string.ascii_lowercase, k=8)), + lambda: "".join(random.choices(string.ascii_lowercase, k=8)), ] return random.choice(templates)() @@ -36,6 +37,7 @@ def random_email(username: str = None) -> str: username = random_username() return f"{username}@{random.choice(wl.email_domains)}" + def random_server_header() -> str: """Generate random server header from wordlists""" config = get_config() @@ -44,10 +46,11 @@ def random_server_header() -> str: wl = get_wordlists() return random.choice(wl.server_headers) + def random_api_key() -> str: """Generate random API key""" wl = get_wordlists() - key = ''.join(random.choices(string.ascii_letters + string.digits, k=32)) + key = "".join(random.choices(string.ascii_letters + string.digits, k=32)) return random.choice(wl.api_key_prefixes) + key @@ -87,14 +90,16 @@ def users_json() -> str: users = [] for i in range(random.randint(3, 8)): username = random_username() - users.append({ - "id": i + 1, - "username": username, - "email": random_email(username), - "password": random_password(), - "role": random.choice(wl.user_roles), - "api_token": random_api_key() - }) + users.append( + { + "id": i + 1, + "username": username, + "email": random_email(username), + "password": random_password(), + "role": random.choice(wl.user_roles), + "api_token": random_api_key(), + } + ) return json.dumps({"users": users}, indent=2) @@ -102,20 +107,28 @@ def api_keys_json() -> str: """Generate fake api_keys.json with random data""" keys = { "stripe": { - "public_key": "pk_live_" + ''.join(random.choices(string.ascii_letters + string.digits, k=24)), - "secret_key": random_api_key() + "public_key": "pk_live_" + + "".join(random.choices(string.ascii_letters + string.digits, k=24)), + "secret_key": random_api_key(), }, "aws": { - "access_key_id": "AKIA" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=16)), - "secret_access_key": ''.join(random.choices(string.ascii_letters + string.digits + '+/', k=40)) + "access_key_id": "AKIA" + + "".join(random.choices(string.ascii_uppercase + string.digits, k=16)), + "secret_access_key": "".join( + random.choices(string.ascii_letters + string.digits + "+/", k=40) + ), }, "sendgrid": { - "api_key": "SG." + ''.join(random.choices(string.ascii_letters + string.digits, k=48)) + "api_key": "SG." + + "".join(random.choices(string.ascii_letters + string.digits, k=48)) }, "twilio": { - "account_sid": "AC" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=32)), - "auth_token": ''.join(random.choices(string.ascii_lowercase + string.digits, k=32)) - } + "account_sid": "AC" + + "".join(random.choices(string.ascii_lowercase + string.digits, k=32)), + "auth_token": "".join( + random.choices(string.ascii_lowercase + string.digits, k=32) + ), + }, } return json.dumps(keys, indent=2) @@ -123,51 +136,70 @@ def api_keys_json() -> str: def api_response(path: str) -> str: """Generate fake API JSON responses with random data""" wl = get_wordlists() - + def random_users(count: int = 3): users = [] for i in range(count): username = random_username() - users.append({ - "id": i + 1, - "username": username, - "email": random_email(username), - "role": random.choice(wl.user_roles) - }) + users.append( + { + "id": i + 1, + "username": username, + "email": random_email(username), + "role": random.choice(wl.user_roles), + } + ) return users - + responses = { - '/api/users': json.dumps({ - "users": random_users(random.randint(2, 5)), - "total": random.randint(50, 500) - }, indent=2), - '/api/v1/users': json.dumps({ - "status": "success", - "data": [{ - "id": random.randint(1, 100), - "name": random_username(), - "api_key": random_api_key() - }] - }, indent=2), - '/api/v2/secrets': json.dumps({ - "database": { - "host": random.choice(wl.database_hosts), - "username": random_username(), - "password": random_password(), - "database": random_database_name() + "/api/users": json.dumps( + { + "users": random_users(random.randint(2, 5)), + "total": random.randint(50, 500), }, - "api_keys": { - "stripe": random_api_key(), - "aws": 'AKIA' + ''.join(random.choices(string.ascii_uppercase + string.digits, k=16)) - } - }, indent=2), - '/api/config': json.dumps({ - "app_name": random.choice(wl.application_names), - "debug": random.choice([True, False]), - "secret_key": random_api_key(), - "database_url": f"postgresql://{random_username()}:{random_password()}@localhost/{random_database_name()}" - }, indent=2), - '/.env': f"""APP_NAME={random.choice(wl.application_names)} + indent=2, + ), + "/api/v1/users": json.dumps( + { + "status": "success", + "data": [ + { + "id": random.randint(1, 100), + "name": random_username(), + "api_key": random_api_key(), + } + ], + }, + indent=2, + ), + "/api/v2/secrets": json.dumps( + { + "database": { + "host": random.choice(wl.database_hosts), + "username": random_username(), + "password": random_password(), + "database": random_database_name(), + }, + "api_keys": { + "stripe": random_api_key(), + "aws": "AKIA" + + "".join( + random.choices(string.ascii_uppercase + string.digits, k=16) + ), + }, + }, + indent=2, + ), + "/api/config": json.dumps( + { + "app_name": random.choice(wl.application_names), + "debug": random.choice([True, False]), + "secret_key": random_api_key(), + "database_url": f"postgresql://{random_username()}:{random_password()}@localhost/{random_database_name()}", + }, + indent=2, + ), + "/.env": f"""APP_NAME={random.choice(wl.application_names)} DEBUG={random.choice(['true', 'false'])} APP_KEY=base64:{''.join(random.choices(string.ascii_letters + string.digits, k=32))}= DB_CONNECTION=mysql @@ -179,7 +211,7 @@ DB_PASSWORD={random_password()} AWS_ACCESS_KEY_ID=AKIA{''.join(random.choices(string.ascii_uppercase + string.digits, k=16))} AWS_SECRET_ACCESS_KEY={''.join(random.choices(string.ascii_letters + string.digits + '+/', k=40))} STRIPE_SECRET={random_api_key()} -""" +""", } return responses.get(path, json.dumps({"error": "Not found"}, indent=2)) @@ -187,11 +219,13 @@ STRIPE_SECRET={random_api_key()} def directory_listing(path: str) -> str: """Generate fake directory listing using wordlists""" wl = get_wordlists() - + files = wl.directory_files dirs = wl.directory_dirs - - selected_files = [(f, random.randint(1024, 1024*1024)) - for f in random.sample(files, min(6, len(files)))] - + + selected_files = [ + (f, random.randint(1024, 1024 * 1024)) + for f in random.sample(files, min(6, len(files))) + ] + return html_templates.directory_listing(path, dirs, selected_files) diff --git a/src/handler.py b/src/handler.py index 9cae1ce..1be7c2c 100644 --- a/src/handler.py +++ b/src/handler.py @@ -14,8 +14,13 @@ from analyzer import Analyzer from templates import html_templates from templates.dashboard_template import generate_dashboard from generators import ( - credentials_txt, passwords_txt, users_json, api_keys_json, - api_response, directory_listing, random_server_header + credentials_txt, + passwords_txt, + users_json, + api_keys_json, + api_response, + directory_listing, + random_server_header, ) from wordlists import get_wordlists from sql_errors import generate_sql_error_response, get_sql_response_with_data @@ -25,6 +30,7 @@ from server_errors import generate_server_error class Handler(BaseHTTPRequestHandler): """HTTP request handler for the deception server""" + webpages: Optional[List[str]] = None config: Config = None tracker: AccessTracker = None @@ -37,15 +43,15 @@ class Handler(BaseHTTPRequestHandler): def _get_client_ip(self) -> str: """Extract client IP address from request, checking proxy headers first""" # Headers might not be available during early error logging - if hasattr(self, 'headers') and self.headers: + if hasattr(self, "headers") and self.headers: # Check X-Forwarded-For header (set by load balancers/proxies) - forwarded_for = self.headers.get('X-Forwarded-For') + forwarded_for = self.headers.get("X-Forwarded-For") if forwarded_for: # X-Forwarded-For can contain multiple IPs, get the first (original client) - return forwarded_for.split(',')[0].strip() + return forwarded_for.split(",")[0].strip() # Check X-Real-IP header (set by nginx and other proxies) - real_ip = self.headers.get('X-Real-IP') + real_ip = self.headers.get("X-Real-IP") if real_ip: return real_ip.strip() @@ -54,7 +60,7 @@ class Handler(BaseHTTPRequestHandler): def _get_user_agent(self) -> str: """Extract user agent from request""" - return self.headers.get('User-Agent', '') + return self.headers.get("User-Agent", "") def _get_category_by_ip(self, client_ip: str) -> str: """Get the category of an IP from the database""" @@ -97,7 +103,7 @@ class Handler(BaseHTTPRequestHandler): Returns True if the path was handled, False otherwise. """ # SQL-vulnerable endpoints - sql_endpoints = ['/api/search', '/api/sql', '/api/database'] + sql_endpoints = ["/api/search", "/api/sql", "/api/database"] base_path = urlparse(path).path if base_path not in sql_endpoints: @@ -112,22 +118,30 @@ class Handler(BaseHTTPRequestHandler): user_agent = self._get_user_agent() # Always check for SQL injection patterns - error_msg, content_type, status_code = generate_sql_error_response(query_string or "") + error_msg, content_type, status_code = generate_sql_error_response( + query_string or "" + ) if error_msg: # SQL injection detected - log and return error - self.access_logger.warning(f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}") + self.access_logger.warning( + f"[SQL INJECTION DETECTED] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + ) self.send_response(status_code) - self.send_header('Content-type', content_type) + self.send_header("Content-type", content_type) self.end_headers() self.wfile.write(error_msg.encode()) else: # No injection detected - return fake data - self.access_logger.info(f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}") + self.access_logger.info( + f"[SQL ENDPOINT] {client_ip} - {base_path} - Query: {query_string[:100] if query_string else 'empty'}" + ) self.send_response(200) - self.send_header('Content-type', 'application/json') + self.send_header("Content-type", "application/json") self.end_headers() - response_data = get_sql_response_with_data(base_path, query_string or "") + response_data = get_sql_response_with_data( + base_path, query_string or "" + ) self.wfile.write(response_data.encode()) return True @@ -140,7 +154,7 @@ class Handler(BaseHTTPRequestHandler): # Still send a response even on error try: self.send_response(500) - self.send_header('Content-type', 'application/json') + self.send_header("Content-type", "application/json") self.end_headers() self.wfile.write(b'{"error": "Internal server error"}') except: @@ -148,31 +162,35 @@ class Handler(BaseHTTPRequestHandler): return True def generate_page(self, seed: str, page_visit_count: int) -> str: - """Generate a webpage containing random links or canary token""" + """Generate a webpage containing random links or canary token""" random.seed(seed) num_pages = random.randint(*self.config.links_per_page_range) - + # Check if this is a good crawler by IP category from database ip_category = self._get_category_by_ip(self._get_client_ip()) - + # Determine if we should apply crawler page limit based on config and IP category should_apply_crawler_limit = False if self.config.infinite_pages_for_malicious: - if (ip_category == "good_crawler" or ip_category == "regular_user") and page_visit_count >= self.config.max_pages_limit: + if ( + ip_category == "good_crawler" or ip_category == "regular_user" + ) and page_visit_count >= self.config.max_pages_limit: should_apply_crawler_limit = True else: - if (ip_category == "good_crawler" or ip_category == "bad_crawler" or ip_category == "attacker") and page_visit_count >= self.config.max_pages_limit: + if ( + ip_category == "good_crawler" + or ip_category == "bad_crawler" + or ip_category == "attacker" + ) and page_visit_count >= self.config.max_pages_limit: should_apply_crawler_limit = True - # If good crawler reached max pages, return a simple page with no links if should_apply_crawler_limit: return html_templates.main_page( - Handler.counter, - '

Crawl limit reached.

' + Handler.counter, "

Crawl limit reached.

" ) - + num_pages = random.randint(*self.config.links_per_page_range) # Build the content HTML @@ -189,10 +207,12 @@ class Handler(BaseHTTPRequestHandler): # Add links if self.webpages is None: for _ in range(num_pages): - address = ''.join([ - random.choice(self.config.char_space) - for _ in range(random.randint(*self.config.links_length_range)) - ]) + address = "".join( + [ + random.choice(self.config.char_space) + for _ in range(random.randint(*self.config.links_length_range)) + ] + ) content += f""" - ''' - for i, (ip, count) in enumerate(stats['top_ips']) - ]) or 'No data' + """ for i, (ip, count) in enumerate(stats["top_ips"])]) + or 'No data' + ) # Generate paths rows (CRITICAL: paths can contain XSS payloads) - top_paths_rows = '\n'.join([ - f'{i+1}{_escape(path)}{count}' - for i, (path, count) in enumerate(stats['top_paths']) - ]) or 'No data' + top_paths_rows = ( + "\n".join( + [ + f'{i+1}{_escape(path)}{count}' + for i, (path, count) in enumerate(stats["top_paths"]) + ] + ) + or 'No data' + ) # Generate User-Agent rows (CRITICAL: user agents can contain XSS payloads) - top_ua_rows = '\n'.join([ - f'{i+1}{_escape(ua[:80])}{count}' - for i, (ua, count) in enumerate(stats['top_user_agents']) - ]) or 'No data' + top_ua_rows = ( + "\n".join( + [ + f'{i+1}{_escape(ua[:80])}{count}' + for i, (ua, count) in enumerate(stats["top_user_agents"]) + ] + ) + or 'No data' + ) # Generate suspicious accesses rows with clickable IPs - suspicious_rows = '\n'.join([ - f''' + suspicious_rows = ( + "\n".join([f""" {_escape(log["ip"])} {_escape(log["path"])} {_escape(log["user_agent"][:60])} @@ -84,13 +98,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = '') -> str:
Loading stats...
- ''' - for log in stats['recent_suspicious'][-10:] - ]) or 'No suspicious activity detected' + """ for log in stats["recent_suspicious"][-10:]]) + or 'No suspicious activity detected' + ) # Generate honeypot triggered IPs rows with clickable IPs - honeypot_rows = '\n'.join([ - f''' + honeypot_rows = ( + "\n".join([f""" {_escape(ip)} {_escape(", ".join(paths))} {len(paths)} @@ -101,13 +115,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = '') -> str:
Loading stats...
- ''' - for ip, paths in stats.get('honeypot_triggered_ips', []) - ]) or 'No honeypot triggers yet' + """ for ip, paths in stats.get("honeypot_triggered_ips", [])]) + or 'No honeypot triggers yet' + ) # Generate attack types rows with clickable IPs - attack_type_rows = '\n'.join([ - f''' + attack_type_rows = ( + "\n".join([f""" {_escape(log["ip"])} {_escape(log["path"])} {_escape(", ".join(log["attack_types"]))} @@ -120,13 +134,13 @@ def generate_dashboard(stats: dict, dashboard_path: str = '') -> str:
Loading stats...
- ''' - for log in stats.get('attack_types', [])[-10:] - ]) or 'No attacks detected' + """ for log in stats.get("attack_types", [])[-10:]]) + or 'No attacks detected' + ) # Generate credential attempts rows with clickable IPs - credential_rows = '\n'.join([ - f''' + credential_rows = ( + "\n".join([f""" {_escape(log["ip"])} {_escape(log["username"])} {_escape(log["password"])} @@ -139,9 +153,9 @@ def generate_dashboard(stats: dict, dashboard_path: str = '') -> str:
Loading stats...
- ''' - for log in stats.get('credential_attempts', [])[-20:] - ]) or 'No credentials captured yet' + """ for log in stats.get("credential_attempts", [])[-20:]]) + or 'No credentials captured yet' + ) return f""" diff --git a/src/templates/template_loader.py b/src/templates/template_loader.py index fd1febc..fe53bf5 100644 --- a/src/templates/template_loader.py +++ b/src/templates/template_loader.py @@ -11,6 +11,7 @@ from typing import Dict class TemplateNotFoundError(Exception): """Raised when a template file cannot be found.""" + pass @@ -42,11 +43,11 @@ def load_template(name: str, **kwargs) -> str: """ # debug # print(f"Loading Template: {name}") - + # Check cache first if name not in _template_cache: # Determine file path based on whether name has an extension - if '.' in name: + if "." in name: file_path = _TEMPLATE_DIR / name else: file_path = _TEMPLATE_DIR / f"{name}.html" @@ -54,7 +55,7 @@ def load_template(name: str, **kwargs) -> str: if not file_path.exists(): raise TemplateNotFoundError(f"Template '{name}' not found at {file_path}") - _template_cache[name] = file_path.read_text(encoding='utf-8') + _template_cache[name] = file_path.read_text(encoding="utf-8") template = _template_cache[name] diff --git a/src/tracker.py b/src/tracker.py index da07569..f7024ac 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -17,7 +17,13 @@ class AccessTracker: Maintains in-memory structures for fast dashboard access and persists data to SQLite for long-term storage and analysis. """ - def __init__(self, max_pages_limit, ban_duration_seconds, db_manager: Optional[DatabaseManager] = None): + + def __init__( + self, + max_pages_limit, + ban_duration_seconds, + db_manager: Optional[DatabaseManager] = None, + ): """ Initialize the access tracker. @@ -32,14 +38,32 @@ class AccessTracker: self.user_agent_counts: Dict[str, int] = defaultdict(int) self.access_log: List[Dict] = [] self.credential_attempts: List[Dict] = [] - + # Track pages visited by each IP (for good crawler limiting) self.ip_page_visits: Dict[str, Dict[str, object]] = defaultdict(dict) - + self.suspicious_patterns = [ - 'bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python-requests', - 'scanner', 'nikto', 'sqlmap', 'nmap', 'masscan', 'nessus', 'acunetix', - 'burp', 'zap', 'w3af', 'metasploit', 'nuclei', 'gobuster', 'dirbuster' + "bot", + "crawler", + "spider", + "scraper", + "curl", + "wget", + "python-requests", + "scanner", + "nikto", + "sqlmap", + "nmap", + "masscan", + "nessus", + "acunetix", + "burp", + "zap", + "w3af", + "metasploit", + "nuclei", + "gobuster", + "dirbuster", ] # Load attack patterns from wordlists @@ -49,11 +73,11 @@ class AccessTracker: # Fallback if wordlists not loaded if not self.attack_types: self.attack_types = { - 'path_traversal': r'\.\.', - 'sql_injection': r"('|--|;|\bOR\b|\bUNION\b|\bSELECT\b|\bDROP\b)", - 'xss_attempt': r'( 0 + self.is_suspicious_user_agent(user_agent) + or self.is_honeypot_path(path) + or len(attack_findings) > 0 ) is_honeypot = self.is_honeypot_path(path) @@ -191,15 +228,17 @@ class AccessTracker: self.honeypot_triggered[ip].append(path) # In-memory storage for dashboard - self.access_log.append({ - 'ip': ip, - 'path': path, - 'user_agent': user_agent, - 'suspicious': is_suspicious, - 'honeypot_triggered': self.is_honeypot_path(path), - 'attack_types':attack_findings, - 'timestamp': datetime.now().isoformat() - }) + self.access_log.append( + { + "ip": ip, + "path": path, + "user_agent": user_agent, + "suspicious": is_suspicious, + "honeypot_triggered": self.is_honeypot_path(path), + "attack_types": attack_findings, + "timestamp": datetime.now().isoformat(), + } + ) # Persist to database if self.db: @@ -211,13 +250,13 @@ class AccessTracker: method=method, is_suspicious=is_suspicious, is_honeypot_trigger=is_honeypot, - attack_types=attack_findings if attack_findings else None + attack_types=attack_findings if attack_findings else None, ) except Exception: # Don't crash if database persistence fails pass - def detect_attack_type(self, data:str) -> list[str]: + def detect_attack_type(self, data: str) -> list[str]: """ Returns a list of all attack types found in path data """ @@ -230,27 +269,37 @@ class AccessTracker: def is_honeypot_path(self, path: str) -> bool: """Check if path is one of the honeypot traps from robots.txt""" honeypot_paths = [ - '/admin', - '/admin/', - '/backup', - '/backup/', - '/config', - '/config/', - '/private', - '/private/', - '/database', - '/database/', - '/credentials.txt', - '/passwords.txt', - '/admin_notes.txt', - '/api_keys.json', - '/.env', - '/wp-admin', - '/wp-admin/', - '/phpmyadmin', - '/phpMyAdmin/' + "/admin", + "/admin/", + "/backup", + "/backup/", + "/config", + "/config/", + "/private", + "/private/", + "/database", + "/database/", + "/credentials.txt", + "/passwords.txt", + "/admin_notes.txt", + "/api_keys.json", + "/.env", + "/wp-admin", + "/wp-admin/", + "/phpmyadmin", + "/phpMyAdmin/", ] - return path in honeypot_paths or any(hp in path.lower() for hp in ['/backup', '/admin', '/config', '/private', '/database', 'phpmyadmin']) + return path in honeypot_paths or any( + hp in path.lower() + for hp in [ + "/backup", + "/admin", + "/config", + "/private", + "/database", + "phpmyadmin", + ] + ) def is_suspicious_user_agent(self, user_agent: str) -> bool: """Check if user agent matches suspicious patterns""" @@ -263,34 +312,36 @@ class AccessTracker: """ Check if an IP has been categorized as a 'good crawler' in the database. Uses the IP category from IpStats table. - + Args: client_ip: The client IP address (will be sanitized) - + Returns: True if the IP is categorized as 'good crawler', False otherwise """ try: from sanitizer import sanitize_ip + # Sanitize the IP address safe_ip = sanitize_ip(client_ip) - + # Query the database for this IP's category db = self.db if not db: return False - + ip_stats = db.get_ip_stats_by_ip(safe_ip) - if not ip_stats or not ip_stats.get('category'): + if not ip_stats or not ip_stats.get("category"): return False - + # Check if category matches "good crawler" - category = ip_stats.get('category', '').lower().strip() + category = ip_stats.get("category", "").lower().strip() return category - + except Exception as e: # Log but don't crash on database errors import logging + logging.error(f"Error checking IP category for {client_ip}: {str(e)}") return False @@ -298,10 +349,10 @@ class AccessTracker: """ Increment page visit counter for an IP and return the new count. If ban timestamp exists and 60+ seconds have passed, reset the counter. - + Args: client_ip: The client IP address - + Returns: The updated page visit count for this IP """ @@ -309,55 +360,58 @@ class AccessTracker: # Initialize if not exists if client_ip not in self.ip_page_visits: self.ip_page_visits[client_ip] = {"count": 0, "ban_timestamp": None} - + # Increment count self.ip_page_visits[client_ip]["count"] += 1 - + # Set ban if reached limit if self.ip_page_visits[client_ip]["count"] >= self.max_pages_limit: - self.ip_page_visits[client_ip]["ban_timestamp"] = datetime.now().isoformat() - + self.ip_page_visits[client_ip][ + "ban_timestamp" + ] = datetime.now().isoformat() + return self.ip_page_visits[client_ip]["count"] - + except Exception: return 0 - + def is_banned_ip(self, client_ip: str) -> bool: """ Check if an IP is currently banned due to exceeding page visit limits. - + Args: client_ip: The client IP address Returns: True if the IP is banned, False otherwise - """ + """ try: if client_ip in self.ip_page_visits: ban_timestamp = self.ip_page_visits[client_ip]["ban_timestamp"] if ban_timestamp is not None: banned = True - - #Check if ban period has expired (> 60 seconds) - ban_time = datetime.fromisoformat(self.ip_page_visits[client_ip]["ban_timestamp"]) + + # Check if ban period has expired (> 60 seconds) + ban_time = datetime.fromisoformat( + self.ip_page_visits[client_ip]["ban_timestamp"] + ) time_diff = datetime.now() - ban_time if time_diff.total_seconds() > self.ban_duration_seconds: self.ip_page_visits[client_ip]["count"] = 0 self.ip_page_visits[client_ip]["ban_timestamp"] = None banned = False - + return banned except Exception: return False - def get_page_visit_count(self, client_ip: str) -> int: """ Get the current page visit count for an IP. - + Args: client_ip: The client IP address - + Returns: The page visit count for this IP """ @@ -372,20 +426,24 @@ class AccessTracker: def get_top_paths(self, limit: int = 10) -> List[Tuple[str, int]]: """Get top N paths by access count""" - return sorted(self.path_counts.items(), key=lambda x: x[1], reverse=True)[:limit] + return sorted(self.path_counts.items(), key=lambda x: x[1], reverse=True)[ + :limit + ] def get_top_user_agents(self, limit: int = 10) -> List[Tuple[str, int]]: """Get top N user agents by access count""" - return sorted(self.user_agent_counts.items(), key=lambda x: x[1], reverse=True)[:limit] + return sorted(self.user_agent_counts.items(), key=lambda x: x[1], reverse=True)[ + :limit + ] def get_suspicious_accesses(self, limit: int = 20) -> List[Dict]: """Get recent suspicious accesses""" - suspicious = [log for log in self.access_log if log.get('suspicious', False)] + suspicious = [log for log in self.access_log if log.get("suspicious", False)] return suspicious[-limit:] def get_attack_type_accesses(self, limit: int = 20) -> List[Dict]: """Get recent accesses with detected attack types""" - attacks = [log for log in self.access_log if log.get('attack_types')] + attacks = [log for log in self.access_log if log.get("attack_types")] return attacks[-limit:] def get_honeypot_triggered_ips(self) -> List[Tuple[str, List[str]]]: @@ -401,12 +459,12 @@ class AccessTracker: stats = self.db.get_dashboard_counts() # Add detailed lists from database - stats['top_ips'] = self.db.get_top_ips(10) - stats['top_paths'] = self.db.get_top_paths(10) - stats['top_user_agents'] = self.db.get_top_user_agents(10) - stats['recent_suspicious'] = self.db.get_recent_suspicious(20) - stats['honeypot_triggered_ips'] = self.db.get_honeypot_triggered_ips() - stats['attack_types'] = self.db.get_recent_attacks(20) - stats['credential_attempts'] = self.db.get_credential_attempts(limit=50) + stats["top_ips"] = self.db.get_top_ips(10) + stats["top_paths"] = self.db.get_top_paths(10) + stats["top_user_agents"] = self.db.get_top_user_agents(10) + stats["recent_suspicious"] = self.db.get_recent_suspicious(20) + stats["honeypot_triggered_ips"] = self.db.get_honeypot_triggered_ips() + stats["attack_types"] = self.db.get_recent_attacks(20) + stats["credential_attempts"] = self.db.get_credential_attempts(limit=50) return stats diff --git a/src/wordlists.py b/src/wordlists.py index 81f2022..1910fc7 100644 --- a/src/wordlists.py +++ b/src/wordlists.py @@ -13,122 +13,116 @@ from logger import get_app_logger class Wordlists: """Loads and provides access to wordlists from wordlists.json""" - + def __init__(self): self._data = self._load_config() - + def _load_config(self): """Load wordlists from JSON file""" - config_path = Path(__file__).parent.parent / 'wordlists.json' + config_path = Path(__file__).parent.parent / "wordlists.json" try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: return json.load(f) except FileNotFoundError: - get_app_logger().warning(f"Wordlists file {config_path} not found, using default values") + get_app_logger().warning( + f"Wordlists file {config_path} not found, using default values" + ) return self._get_defaults() except json.JSONDecodeError as e: get_app_logger().warning(f"Invalid JSON in {config_path}: {e}") return self._get_defaults() - + def _get_defaults(self): """Fallback default wordlists if JSON file is missing or invalid""" return { "usernames": { "prefixes": ["admin", "user", "root"], - "suffixes": ["", "_prod", "_dev"] + "suffixes": ["", "_prod", "_dev"], }, "passwords": { "prefixes": ["P@ssw0rd", "Admin"], - "simple": ["test", "demo", "password"] - }, - "emails": { - "domains": ["example.com", "test.com"] - }, - "api_keys": { - "prefixes": ["sk_live_", "api_", ""] + "simple": ["test", "demo", "password"], }, + "emails": {"domains": ["example.com", "test.com"]}, + "api_keys": {"prefixes": ["sk_live_", "api_", ""]}, "databases": { "names": ["production", "main_db"], - "hosts": ["localhost", "db.internal"] + "hosts": ["localhost", "db.internal"], }, - "applications": { - "names": ["WebApp", "Dashboard"] - }, - "users": { - "roles": ["Administrator", "User"] - }, - "server_headers": ["Apache/2.4.41 (Ubuntu)", "nginx/1.18.0"] + "applications": {"names": ["WebApp", "Dashboard"]}, + "users": {"roles": ["Administrator", "User"]}, + "server_headers": ["Apache/2.4.41 (Ubuntu)", "nginx/1.18.0"], } - + @property def username_prefixes(self): return self._data.get("usernames", {}).get("prefixes", []) - + @property def username_suffixes(self): return self._data.get("usernames", {}).get("suffixes", []) - + @property def password_prefixes(self): return self._data.get("passwords", {}).get("prefixes", []) - + @property def simple_passwords(self): return self._data.get("passwords", {}).get("simple", []) - + @property def email_domains(self): return self._data.get("emails", {}).get("domains", []) - + @property def api_key_prefixes(self): return self._data.get("api_keys", {}).get("prefixes", []) - + @property def database_names(self): return self._data.get("databases", {}).get("names", []) - + @property def database_hosts(self): return self._data.get("databases", {}).get("hosts", []) - + @property def application_names(self): return self._data.get("applications", {}).get("names", []) - + @property def user_roles(self): return self._data.get("users", {}).get("roles", []) - + @property def directory_files(self): return self._data.get("directory_listing", {}).get("files", []) - + @property def directory_dirs(self): return self._data.get("directory_listing", {}).get("directories", []) - + @property def error_codes(self): return self._data.get("error_codes", []) - + @property def sql_errors(self): return self._data.get("sql_errors", {}) - + @property def attack_patterns(self): return self._data.get("attack_patterns", {}) - + @property def server_errors(self): return self._data.get("server_errors", {}) - + @property def server_headers(self): return self._data.get("server_headers", []) - + @property def attack_urls(self): """Deprecated: use attack_patterns instead. Returns attack_patterns for backward compatibility.""" @@ -137,10 +131,10 @@ class Wordlists: _wordlists_instance = None + def get_wordlists(): """Get the singleton Wordlists instance""" global _wordlists_instance if _wordlists_instance is None: _wordlists_instance = Wordlists() return _wordlists_instance - diff --git a/src/xss_detector.py b/src/xss_detector.py index 0f3da14..618ccb2 100644 --- a/src/xss_detector.py +++ b/src/xss_detector.py @@ -8,25 +8,25 @@ from wordlists import get_wordlists def detect_xss_pattern(input_string: str) -> bool: if not input_string: return False - + wl = get_wordlists() - xss_pattern = wl.attack_patterns.get('xss_attempt', '') - + xss_pattern = wl.attack_patterns.get("xss_attempt", "") + if not xss_pattern: - xss_pattern = r'( str: xss_detected = False reflected_content = [] - + for key, value in input_data.items(): if detect_xss_pattern(value): xss_detected = True reflected_content.append(f"

{key}: {value}

") - + if xss_detected: html = f""" @@ -51,7 +51,7 @@ def generate_xss_response(input_data: dict) -> str: """ return html - + return """ From 3341b8a1b9aeaebe57bafadf434f1e9bd6924239 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 23 Jan 2026 22:03:47 +0100 Subject: [PATCH 59/70] fixed workflow check --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 48bc45d..0259795 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -31,7 +31,7 @@ jobs: - name: Black format check run: | - if [ -n "$(black --check src/ 2>&1 | grep -v 'Oh no')" ]; then + if ! black --check src/; then echo "Run 'black src/' to format code" black --diff src/ exit 1 From 4addf41a5ba406bc99133bc00318f8e6fb4724be Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 23 Jan 2026 22:26:14 +0100 Subject: [PATCH 60/70] Add logging for environment variable overrides in config --- src/config.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/config.py b/src/config.py index 629c18c..db879c0 100644 --- a/src/config.py +++ b/src/config.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Optional, Tuple from zoneinfo import ZoneInfo import time +from logger import get_app_logger import yaml @@ -157,18 +158,27 @@ def override_config_from_env(config: Config = None): env_var = __get_env_from_config(field) if env_var in os.environ: - field_type = config.__dataclass_fields__[field].type - env_value = os.environ[env_var] - if field_type == int: - setattr(config, field, int(env_value)) - elif field_type == float: - setattr(config, field, float(env_value)) - elif field_type == Tuple[int, int]: - parts = env_value.split(",") - if len(parts) == 2: - setattr(config, field, (int(parts[0]), int(parts[1]))) - else: - setattr(config, field, env_value) + + get_app_logger().info( + f"Overriding config '{field}' from environment variable '{env_var}'" + ) + try: + field_type = config.__dataclass_fields__[field].type + env_value = os.environ[env_var] + if field_type == int: + setattr(config, field, int(env_value)) + elif field_type == float: + setattr(config, field, float(env_value)) + elif field_type == Tuple[int, int]: + parts = env_value.split(",") + if len(parts) == 2: + setattr(config, field, (int(parts[0]), int(parts[1]))) + else: + setattr(config, field, env_value) + except Exception as e: + get_app_logger().error( + f"Error overriding config '{field}' from environment variable '{env_var}': {e}" + ) _config_instance = None From 14d616fae35404e3352035dc20c45b924a874824 Mon Sep 17 00:00:00 2001 From: BlessedRebuS Date: Sat, 24 Jan 2026 23:28:10 +0100 Subject: [PATCH 61/70] added ip logging memory improvements, added local ip and public ip exlusion --- src/analyzer.py | 6 +- src/config.py | 66 ++++++++- src/database.py | 100 ++++++++----- src/exports/malicious_ips.txt | 6 - src/ip_utils.py | 61 ++++++++ src/tasks/memory_cleanup.py | 66 +++++++++ src/tasks/top_attacking_ips.py | 16 +- src/tracker.py | 258 +++++++++++++++++++++++++++++---- 8 files changed, 504 insertions(+), 75 deletions(-) delete mode 100644 src/exports/malicious_ips.txt create mode 100644 src/ip_utils.py create mode 100644 src/tasks/memory_cleanup.py diff --git a/src/analyzer.py b/src/analyzer.py index 860a206..7f29662 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -11,7 +11,6 @@ from wordlists import get_wordlists from config import get_config from logger import get_app_logger import requests -from sanitizer import sanitize_for_storage, sanitize_dict """ Functions for user activity analysis @@ -27,14 +26,12 @@ class Analyzer: def __init__(self, db_manager: Optional[DatabaseManager] = None): """ - Initialize the access tracker. + Initialize the analyzer. Args: db_manager: Optional DatabaseManager for persistence. If None, will use the global singleton. """ - - # Database manager for persistence (lazily initialized) self._db_manager = db_manager @property @@ -49,7 +46,6 @@ class Analyzer: try: self._db_manager = get_database() except Exception: - # Database not initialized, persistence disabled pass return self._db_manager diff --git a/src/config.py b/src/config.py index db879c0..d8c0997 100644 --- a/src/config.py +++ b/src/config.py @@ -8,6 +8,7 @@ from typing import Optional, Tuple from zoneinfo import ZoneInfo import time from logger import get_app_logger +import socket import yaml @@ -50,6 +51,67 @@ class Config: user_agents_used_threshold: float = None attack_urls_threshold: float = None + _server_ip: Optional[str] = None + _server_ip_cache_time: float = 0 + _ip_cache_ttl: int = 300 + + def get_server_ip(self, refresh: bool = False) -> Optional[str]: + """ + Get the server's own public IP address. + Excludes requests from the server itself from being tracked. + + Caches the IP for 5 minutes to avoid repeated lookups. + Automatically refreshes if cache is stale. + + Args: + refresh: Force refresh the IP cache (bypass TTL) + + Returns: + Server IP address or None if unable to determine + """ + import time + + current_time = time.time() + + # Check if cache is valid and not forced refresh + if ( + self._server_ip is not None + and not refresh + and (current_time - self._server_ip_cache_time) < self._ip_cache_ttl + ): + return self._server_ip + + try: + hostname = socket.gethostname() + + # Try to get public IP by connecting to an external server + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + + self._server_ip = ip + self._server_ip_cache_time = current_time + + return ip + + except Exception as e: + get_app_logger().warning( + f"Could not determine server IP address: {e}. " + "All IPs will be tracked (including potential server IP)." + ) + return None + + def refresh_server_ip(self) -> Optional[str]: + """ + Force refresh the cached server IP. + Use this if you suspect the IP has changed. + + Returns: + New server IP address or None if unable to determine + """ + return self.get_server_ip(refresh=True) + @classmethod def from_yaml(cls) -> "Config": """Create configuration from YAML file""" @@ -139,8 +201,8 @@ class Config: infinite_pages_for_malicious=crawl.get( "infinite_pages_for_malicious", True ), - max_pages_limit=crawl.get("max_pages_limit", 200), - ban_duration_seconds=crawl.get("ban_duration_seconds", 60), + max_pages_limit=crawl.get("max_pages_limit", 500), + ban_duration_seconds=crawl.get("ban_duration_seconds", 10), ) diff --git a/src/database.py b/src/database.py index 6f21d91..88d72d7 100644 --- a/src/database.py +++ b/src/database.py @@ -15,6 +15,8 @@ from sqlalchemy import create_engine, func, distinct, case, event from sqlalchemy.orm import sessionmaker, scoped_session, Session from sqlalchemy.engine import Engine +from ip_utils import is_local_or_private_ip, is_valid_public_ip + @event.listens_for(Engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record): @@ -688,7 +690,7 @@ class DatabaseManager: def get_dashboard_counts(self) -> Dict[str, int]: """ - Get aggregate statistics for the dashboard. + Get aggregate statistics for the dashboard (excludes local/private IPs and server IP). Returns: Dictionary with total_accesses, unique_ips, unique_paths, @@ -696,33 +698,34 @@ class DatabaseManager: """ session = self.session try: - # Get main aggregate counts in one query - result = session.query( - func.count(AccessLog.id).label("total_accesses"), - func.count(distinct(AccessLog.ip)).label("unique_ips"), - func.count(distinct(AccessLog.path)).label("unique_paths"), - func.sum(case((AccessLog.is_suspicious == True, 1), else_=0)).label( - "suspicious_accesses" - ), - func.sum( - case((AccessLog.is_honeypot_trigger == True, 1), else_=0) - ).label("honeypot_triggered"), - ).first() - - # Get unique IPs that triggered honeypots - honeypot_ips = ( - session.query(func.count(distinct(AccessLog.ip))) - .filter(AccessLog.is_honeypot_trigger == True) - .scalar() - or 0 - ) + # Get server IP to filter it out + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + + # Get all accesses first, then filter out local IPs and server IP + all_accesses = session.query(AccessLog).all() + + # Filter out local/private IPs and server IP + public_accesses = [ + log for log in all_accesses + if is_valid_public_ip(log.ip, server_ip) + ] + + # Calculate counts from filtered data + total_accesses = len(public_accesses) + unique_ips = len(set(log.ip for log in public_accesses)) + unique_paths = len(set(log.path for log in public_accesses)) + suspicious_accesses = sum(1 for log in public_accesses if log.is_suspicious) + honeypot_triggered = sum(1 for log in public_accesses if log.is_honeypot_trigger) + honeypot_ips = len(set(log.ip for log in public_accesses if log.is_honeypot_trigger)) return { - "total_accesses": result.total_accesses or 0, - "unique_ips": result.unique_ips or 0, - "unique_paths": result.unique_paths or 0, - "suspicious_accesses": int(result.suspicious_accesses or 0), - "honeypot_triggered": int(result.honeypot_triggered or 0), + "total_accesses": total_accesses, + "unique_ips": unique_ips, + "unique_paths": unique_paths, + "suspicious_accesses": suspicious_accesses, + "honeypot_triggered": honeypot_triggered, "honeypot_ips": honeypot_ips, } finally: @@ -730,7 +733,7 @@ class DatabaseManager: def get_top_ips(self, limit: int = 10) -> List[tuple]: """ - Get top IP addresses by access count. + Get top IP addresses by access count (excludes local/private IPs and server IP). Args: limit: Maximum number of results @@ -740,15 +743,25 @@ class DatabaseManager: """ session = self.session try: + # Get server IP to filter it out + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + results = ( session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) .group_by(AccessLog.ip) .order_by(func.count(AccessLog.id).desc()) - .limit(limit) .all() ) - return [(row.ip, row.count) for row in results] + # Filter out local/private IPs and server IP, then limit results + filtered = [ + (row.ip, row.count) + for row in results + if is_valid_public_ip(row.ip, server_ip) + ] + return filtered[:limit] finally: self.close_session() @@ -805,7 +818,7 @@ class DatabaseManager: def get_recent_suspicious(self, limit: int = 20) -> List[Dict[str, Any]]: """ - Get recent suspicious access attempts. + Get recent suspicious access attempts (excludes local/private IPs and server IP). Args: limit: Maximum number of results @@ -815,14 +828,24 @@ class DatabaseManager: """ session = self.session try: + # Get server IP to filter it out + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + logs = ( session.query(AccessLog) .filter(AccessLog.is_suspicious == True) .order_by(AccessLog.timestamp.desc()) - .limit(limit) .all() ) + # Filter out local/private IPs and server IP + filtered_logs = [ + log for log in logs + if is_valid_public_ip(log.ip, server_ip) + ] + return [ { "ip": log.ip, @@ -830,20 +853,26 @@ class DatabaseManager: "user_agent": log.user_agent, "timestamp": log.timestamp.isoformat(), } - for log in logs + for log in filtered_logs[:limit] ] finally: self.close_session() def get_honeypot_triggered_ips(self) -> List[tuple]: """ - Get IPs that triggered honeypot paths with the paths they accessed. + Get IPs that triggered honeypot paths with the paths they accessed + (excludes local/private IPs and server IP). Returns: List of (ip, [paths]) tuples """ session = self.session try: + # Get server IP to filter it out + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + # Get all honeypot triggers grouped by IP results = ( session.query(AccessLog.ip, AccessLog.path) @@ -851,9 +880,12 @@ class DatabaseManager: .all() ) - # Group paths by IP + # Group paths by IP, filtering out local/private IPs and server IP ip_paths: Dict[str, List[str]] = {} for row in results: + # Skip invalid IPs + if not is_valid_public_ip(row.ip, server_ip): + continue if row.ip not in ip_paths: ip_paths[row.ip] = [] if row.path not in ip_paths[row.ip]: diff --git a/src/exports/malicious_ips.txt b/src/exports/malicious_ips.txt deleted file mode 100644 index 34fc01a..0000000 --- a/src/exports/malicious_ips.txt +++ /dev/null @@ -1,6 +0,0 @@ -127.0.0.1 -175.23.45.67 -205.32.180.65 -198.51.100.89 -210.45.67.89 -203.0.113.45 diff --git a/src/ip_utils.py b/src/ip_utils.py new file mode 100644 index 0000000..35504c8 --- /dev/null +++ b/src/ip_utils.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +""" +IP utility functions for filtering and validating IP addresses. +Provides common IP filtering logic used across the Krawl honeypot. +""" + +import ipaddress +from typing import Optional + + +def is_local_or_private_ip(ip_str: str) -> bool: + """ + Check if an IP address is local, private, or reserved. + + Filters out: + - 127.0.0.1 (localhost) + - 127.0.0.0/8 (loopback) + - 10.0.0.0/8 (private network) + - 172.16.0.0/12 (private network) + - 192.168.0.0/16 (private network) + - 0.0.0.0/8 (this network) + - ::1 (IPv6 localhost) + - ::ffff:127.0.0.0/104 (IPv6-mapped IPv4 loopback) + + Args: + ip_str: IP address string + + Returns: + True if IP is local/private/reserved, False if it's public + """ + try: + ip = ipaddress.ip_address(ip_str) + return ( + ip.is_private + or ip.is_loopback + or ip.is_reserved + or ip.is_link_local + or str(ip) in ("0.0.0.0", "::1") + ) + except ValueError: + # Invalid IP address + return True + + +def is_valid_public_ip(ip: str, server_ip: Optional[str] = None) -> bool: + """ + Check if an IP is public and not the server's own IP. + + Returns True only if: + - IP is not in local/private ranges AND + - IP is not the server's own public IP (if server_ip provided) + + Args: + ip: IP address string to check + server_ip: Server's public IP (optional). If provided, filters out this IP too. + + Returns: + True if IP is a valid public IP to track, False otherwise + """ + return not is_local_or_private_ip(ip) and (server_ip is None or ip != server_ip) diff --git a/src/tasks/memory_cleanup.py b/src/tasks/memory_cleanup.py new file mode 100644 index 0000000..ba1ace5 --- /dev/null +++ b/src/tasks/memory_cleanup.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +""" +Memory cleanup task for Krawl honeypot. +Periodically trims unbounded in-memory structures to prevent OOM. +""" + +from database import get_database +from logger import get_app_logger + +# ---------------------- +# TASK CONFIG +# ---------------------- + +TASK_CONFIG = { + "name": "memory-cleanup", + "cron": "*/5 * * * *", # Run every 5 minutes + "enabled": True, + "run_when_loaded": False, +} + +app_logger = get_app_logger() + + +def main(): + """ + Clean up in-memory structures in the tracker. + Called periodically to prevent unbounded memory growth. + """ + try: + # Import here to avoid circular imports + from handler import Handler + + if not Handler.tracker: + app_logger.warning("Tracker not initialized, skipping memory cleanup") + return + + # Get memory stats before cleanup + stats_before = Handler.tracker.get_memory_stats() + + # Run cleanup + Handler.tracker.cleanup_memory() + + # Get memory stats after cleanup + stats_after = Handler.tracker.get_memory_stats() + + # Log changes + access_log_reduced = stats_before["access_log_size"] - stats_after["access_log_size"] + cred_reduced = stats_before["credential_attempts_size"] - stats_after["credential_attempts_size"] + + if access_log_reduced > 0 or cred_reduced > 0: + app_logger.info( + f"Memory cleanup: Trimmed {access_log_reduced} access logs, " + f"{cred_reduced} credential attempts" + ) + + # Log current memory state for monitoring + app_logger.debug( + f"Memory stats after cleanup: " + f"access_logs={stats_after['access_log_size']}, " + f"credentials={stats_after['credential_attempts_size']}, " + f"unique_ips={stats_after['unique_ips_tracked']}" + ) + + except Exception as e: + app_logger.error(f"Error during memory cleanup: {e}") diff --git a/src/tasks/top_attacking_ips.py b/src/tasks/top_attacking_ips.py index 75cff41..1648c93 100644 --- a/src/tasks/top_attacking_ips.py +++ b/src/tasks/top_attacking_ips.py @@ -5,7 +5,9 @@ from datetime import datetime, timedelta from zoneinfo import ZoneInfo from logger import get_app_logger from database import get_database +from config import get_config from models import AccessLog +from ip_utils import is_local_or_private_ip, is_valid_public_ip from sqlalchemy import distinct app_logger = get_app_logger() @@ -66,16 +68,26 @@ def main(): .all() ) + # Filter out local/private IPs and the server's own IP + config = get_config() + server_ip = config.get_server_ip() + + public_ips = [ + ip for (ip,) in results + if is_valid_public_ip(ip, server_ip) + ] + # Ensure exports directory exists os.makedirs(EXPORTS_DIR, exist_ok=True) # Write IPs to file (one per line) with open(OUTPUT_FILE, "w") as f: - for (ip,) in results: + for ip in public_ips: f.write(f"{ip}\n") app_logger.info( - f"[Background Task] {task_name} exported {len(results)} IPs to {OUTPUT_FILE}" + f"[Background Task] {task_name} exported {len(public_ips)} public IPs " + f"(filtered {len(results) - len(public_ips)} local/private IPs) to {OUTPUT_FILE}" ) except Exception as e: diff --git a/src/tracker.py b/src/tracker.py index f7024ac..0706e82 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -6,8 +6,10 @@ from datetime import datetime from zoneinfo import ZoneInfo import re import urllib.parse + from wordlists import get_wordlists from database import get_database, DatabaseManager +from ip_utils import is_local_or_private_ip, is_valid_public_ip class AccessTracker: @@ -39,6 +41,11 @@ class AccessTracker: self.access_log: List[Dict] = [] self.credential_attempts: List[Dict] = [] + # Memory limits for in-memory lists (prevents unbounded growth) + self.max_access_log_size = 10_000 # Keep only recent 10k accesses + self.max_credential_log_size = 5_000 # Keep only recent 5k attempts + self.max_counter_keys = 100_000 # Max unique IPs/paths/user agents + # Track pages visited by each IP (for good crawler limiting) self.ip_page_visits: Dict[str, Dict[str, object]] = defaultdict(dict) @@ -162,7 +169,15 @@ class AccessTracker: Record a credential login attempt. Stores in both in-memory list and SQLite database. + Skips recording if the IP is the server's own public IP. """ + # Skip if this is the server's own IP + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + if server_ip and ip == server_ip: + return + # In-memory storage for dashboard self.credential_attempts.append( { @@ -174,6 +189,12 @@ class AccessTracker: } ) + # Trim if exceeding max size (prevent unbounded growth) + if len(self.credential_attempts) > self.max_credential_log_size: + self.credential_attempts = self.credential_attempts[ + -self.max_credential_log_size : + ] + # Persist to database if self.db: try: @@ -196,6 +217,7 @@ class AccessTracker: Record an access attempt. Stores in both in-memory structures and SQLite database. + Skips recording if the IP is the server's own public IP. Args: ip: Client IP address @@ -204,6 +226,13 @@ class AccessTracker: body: Request body (for POST/PUT) method: HTTP method """ + # Skip if this is the server's own IP + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + if server_ip and ip == server_ip: + return + self.ip_counts[ip] += 1 self.path_counts[path] += 1 if user_agent: @@ -240,6 +269,10 @@ class AccessTracker: } ) + # Trim if exceeding max size (prevent unbounded growth) + if len(self.access_log) > self.max_access_log_size: + self.access_log = self.access_log[-self.max_access_log_size :] + # Persist to database if self.db: try: @@ -348,7 +381,13 @@ class AccessTracker: def increment_page_visit(self, client_ip: str) -> int: """ Increment page visit counter for an IP and return the new count. - If ban timestamp exists and 60+ seconds have passed, reset the counter. + Implements incremental bans: each violation increases ban duration exponentially. + + Ban duration formula: base_duration * (2 ^ violation_count) + - 1st violation: base_duration (e.g., 60 seconds) + - 2nd violation: base_duration * 2 (120 seconds) + - 3rd violation: base_duration * 4 (240 seconds) + - Nth violation: base_duration * 2^(N-1) Args: client_ip: The client IP address @@ -356,19 +395,41 @@ class AccessTracker: Returns: The updated page visit count for this IP """ + # Skip if this is the server's own IP + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + if server_ip and client_ip == server_ip: + return 0 + try: # Initialize if not exists if client_ip not in self.ip_page_visits: - self.ip_page_visits[client_ip] = {"count": 0, "ban_timestamp": None} + self.ip_page_visits[client_ip] = { + "count": 0, + "ban_timestamp": None, + "total_violations": 0, + "ban_multiplier": 1, + } # Increment count self.ip_page_visits[client_ip]["count"] += 1 # Set ban if reached limit if self.ip_page_visits[client_ip]["count"] >= self.max_pages_limit: - self.ip_page_visits[client_ip][ - "ban_timestamp" - ] = datetime.now().isoformat() + # Increment violation counter + self.ip_page_visits[client_ip]["total_violations"] += 1 + violations = self.ip_page_visits[client_ip]["total_violations"] + + # Calculate exponential ban multiplier: 2^(violations - 1) + # Violation 1: 2^0 = 1x + # Violation 2: 2^1 = 2x + # Violation 3: 2^2 = 4x + # Violation 4: 2^3 = 8x, etc. + self.ip_page_visits[client_ip]["ban_multiplier"] = 2 ** (violations - 1) + + # Set ban timestamp + self.ip_page_visits[client_ip]["ban_timestamp"] = datetime.now().isoformat() return self.ip_page_visits[client_ip]["count"] @@ -378,6 +439,10 @@ class AccessTracker: def is_banned_ip(self, client_ip: str) -> bool: """ Check if an IP is currently banned due to exceeding page visit limits. + Uses incremental ban duration based on violation count. + + Ban duration = base_duration * (2 ^ (violations - 1)) + Each time an IP is banned again, duration doubles. Args: client_ip: The client IP address @@ -386,26 +451,87 @@ class AccessTracker: """ try: if client_ip in self.ip_page_visits: - ban_timestamp = self.ip_page_visits[client_ip]["ban_timestamp"] + ban_timestamp = self.ip_page_visits[client_ip].get("ban_timestamp") if ban_timestamp is not None: - banned = True + # Get the ban multiplier for this violation + ban_multiplier = self.ip_page_visits[client_ip].get( + "ban_multiplier", 1 + ) - # Check if ban period has expired (> 60 seconds) - ban_time = datetime.fromisoformat( - self.ip_page_visits[client_ip]["ban_timestamp"] - ) - time_diff = datetime.now() - ban_time - if time_diff.total_seconds() > self.ban_duration_seconds: - self.ip_page_visits[client_ip]["count"] = 0 - self.ip_page_visits[client_ip]["ban_timestamp"] = None - banned = False + # Calculate effective ban duration based on violations + effective_ban_duration = self.ban_duration_seconds * ban_multiplier - return banned + # Check if ban period has expired + ban_time = datetime.fromisoformat(ban_timestamp) + time_diff = datetime.now() - ban_time + + if time_diff.total_seconds() > effective_ban_duration: + # Ban expired, reset for next cycle + # Keep violation count for next offense + self.ip_page_visits[client_ip]["count"] = 0 + self.ip_page_visits[client_ip]["ban_timestamp"] = None + return False + else: + # Still banned + return True + + return False except Exception: return False - def get_page_visit_count(self, client_ip: str) -> int: + def get_ban_info(self, client_ip: str) -> dict: + """ + Get detailed ban information for an IP. + + Returns: + Dictionary with ban status, violations, and remaining ban time + """ + try: + if client_ip not in self.ip_page_visits: + return { + "is_banned": False, + "violations": 0, + "ban_multiplier": 1, + "remaining_ban_seconds": 0, + } + + ip_data = self.ip_page_visits[client_ip] + ban_timestamp = ip_data.get("ban_timestamp") + + if ban_timestamp is None: + return { + "is_banned": False, + "violations": ip_data.get("total_violations", 0), + "ban_multiplier": ip_data.get("ban_multiplier", 1), + "remaining_ban_seconds": 0, + } + + # Ban is active, calculate remaining time + ban_multiplier = ip_data.get("ban_multiplier", 1) + effective_ban_duration = self.ban_duration_seconds * ban_multiplier + + ban_time = datetime.fromisoformat(ban_timestamp) + time_diff = datetime.now() - ban_time + remaining_seconds = max( + 0, effective_ban_duration - time_diff.total_seconds() + ) + + return { + "is_banned": remaining_seconds > 0, + "violations": ip_data.get("total_violations", 0), + "ban_multiplier": ban_multiplier, + "effective_ban_duration_seconds": effective_ban_duration, + "remaining_ban_seconds": remaining_seconds, + } + + except Exception: + return { + "is_banned": False, + "violations": 0, + "ban_multiplier": 1, + "remaining_ban_seconds": 0, + } """ Get the current page visit count for an IP. @@ -421,8 +547,13 @@ class AccessTracker: return 0 def get_top_ips(self, limit: int = 10) -> List[Tuple[str, int]]: - """Get top N IP addresses by access count""" - return sorted(self.ip_counts.items(), key=lambda x: x[1], reverse=True)[:limit] + """Get top N IP addresses by access count (excludes local/private IPs)""" + filtered = [ + (ip, count) + for ip, count in self.ip_counts.items() + if not is_local_or_private_ip(ip) + ] + return sorted(filtered, key=lambda x: x[1], reverse=True)[:limit] def get_top_paths(self, limit: int = 10) -> List[Tuple[str, int]]: """Get top N paths by access count""" @@ -437,18 +568,30 @@ class AccessTracker: ] def get_suspicious_accesses(self, limit: int = 20) -> List[Dict]: - """Get recent suspicious accesses""" - suspicious = [log for log in self.access_log if log.get("suspicious", False)] + """Get recent suspicious accesses (excludes local/private IPs)""" + suspicious = [ + log + for log in self.access_log + if log.get("suspicious", False) and not is_local_or_private_ip(log.get("ip", "")) + ] return suspicious[-limit:] def get_attack_type_accesses(self, limit: int = 20) -> List[Dict]: - """Get recent accesses with detected attack types""" - attacks = [log for log in self.access_log if log.get("attack_types")] + """Get recent accesses with detected attack types (excludes local/private IPs)""" + attacks = [ + log + for log in self.access_log + if log.get("attack_types") and not is_local_or_private_ip(log.get("ip", "")) + ] return attacks[-limit:] def get_honeypot_triggered_ips(self) -> List[Tuple[str, List[str]]]: - """Get IPs that accessed honeypot paths""" - return [(ip, paths) for ip, paths in self.honeypot_triggered.items()] + """Get IPs that accessed honeypot paths (excludes local/private IPs)""" + return [ + (ip, paths) + for ip, paths in self.honeypot_triggered.items() + if not is_local_or_private_ip(ip) + ] def get_stats(self) -> Dict: """Get statistics summary from database.""" @@ -468,3 +611,66 @@ class AccessTracker: stats["credential_attempts"] = self.db.get_credential_attempts(limit=50) return stats + + def cleanup_memory(self) -> None: + """ + Clean up in-memory structures to prevent unbounded growth. + Should be called periodically (e.g., every 5 minutes). + + Trimming strategy: + - Keep most recent N entries in logs + - Remove oldest entries when limit exceeded + - Clean expired ban entries from ip_page_visits + """ + # Trim access_log to max size (keep most recent) + if len(self.access_log) > self.max_access_log_size: + self.access_log = self.access_log[-self.max_access_log_size:] + + # Trim credential_attempts to max size (keep most recent) + if len(self.credential_attempts) > self.max_credential_log_size: + self.credential_attempts = self.credential_attempts[ + -self.max_credential_log_size : + ] + + # Clean expired ban entries from ip_page_visits + current_time = datetime.now() + ips_to_clean = [] + for ip, data in self.ip_page_visits.items(): + ban_timestamp = data.get("ban_timestamp") + if ban_timestamp is not None: + try: + ban_time = datetime.fromisoformat(ban_timestamp) + time_diff = (current_time - ban_time).total_seconds() + if time_diff > self.ban_duration_seconds: + # Ban expired, reset the entry + data["count"] = 0 + data["ban_timestamp"] = None + except (ValueError, TypeError): + pass + + # Optional: Remove IPs with zero activity (advanced cleanup) + # Comment out to keep indefinite history of zero-activity IPs + # ips_to_remove = [ + # ip + # for ip, data in self.ip_page_visits.items() + # if data.get("count", 0) == 0 and data.get("ban_timestamp") is None + # ] + # for ip in ips_to_remove: + # del self.ip_page_visits[ip] + + def get_memory_stats(self) -> Dict[str, int]: + """ + Get current memory usage statistics for monitoring. + + Returns: + Dictionary with counts of in-memory items + """ + return { + "access_log_size": len(self.access_log), + "credential_attempts_size": len(self.credential_attempts), + "unique_ips_tracked": len(self.ip_counts), + "unique_paths_tracked": len(self.path_counts), + "unique_user_agents": len(self.user_agent_counts), + "unique_ip_page_visits": len(self.ip_page_visits), + "honeypot_triggered_ips": len(self.honeypot_triggered), + } From c7fe588bc4bd3b198db545b27cdf1144cf3ad840 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio <50186694+BlessedRebuS@users.noreply.github.com> Date: Sun, 25 Jan 2026 01:19:30 +0100 Subject: [PATCH 62/70] fixed external ip resoultion (#54) --- src/config.py | 49 +++++++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/config.py b/src/config.py index d8c0997..1f1d122 100644 --- a/src/config.py +++ b/src/config.py @@ -9,7 +9,8 @@ from zoneinfo import ZoneInfo import time from logger import get_app_logger import socket - +import time +import requests import yaml @@ -59,17 +60,7 @@ class Config: """ Get the server's own public IP address. Excludes requests from the server itself from being tracked. - - Caches the IP for 5 minutes to avoid repeated lookups. - Automatically refreshes if cache is stale. - - Args: - refresh: Force refresh the IP cache (bypass TTL) - - Returns: - Server IP address or None if unable to determine """ - import time current_time = time.time() @@ -82,17 +73,35 @@ class Config: return self._server_ip try: - hostname = socket.gethostname() - - # Try to get public IP by connecting to an external server - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - ip = s.getsockname()[0] - s.close() + # Try multiple external IP detection services (fallback chain) + ip_detection_services = [ + "https://api.ipify.org", # Plain text response + "http://ident.me", # Plain text response + "https://ifconfig.me", # Plain text response + ] + + ip = None + for service_url in ip_detection_services: + try: + response = requests.get(service_url, timeout=5) + if response.status_code == 200: + ip = response.text.strip() + if ip: + break + except Exception: + continue + + if not ip: + get_app_logger().warning( + "Could not determine server IP from external services. " + "All IPs will be tracked (including potential server IP)." + ) + return None self._server_ip = ip self._server_ip_cache_time = current_time + get_app_logger().info(f"Server external IP detected: {ip}") return ip except Exception as e: @@ -201,8 +210,8 @@ class Config: infinite_pages_for_malicious=crawl.get( "infinite_pages_for_malicious", True ), - max_pages_limit=crawl.get("max_pages_limit", 500), - ban_duration_seconds=crawl.get("ban_duration_seconds", 10), + max_pages_limit=crawl.get("max_pages_limit", 250), + ban_duration_seconds=crawl.get("ban_duration_seconds", 600), ) From 130e81ad64c7740798ca59db256ccb48018b7fd2 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Sun, 25 Jan 2026 22:50:27 +0100 Subject: [PATCH 63/70] Feat/dashboard improvements (#55) * fixed external ip resoultion * added dashboard logic division, filtering capabilities, geoip map, attacker stats * refactor: replace print statements with applogger for error logging in DatabaseManager * feat: add click listeners for IP cells in dashboard tables to fetch and display stats --------- Co-authored-by: BlessedRebuS --- exports/.gitkeep | 0 helm/Chart.yaml | 2 +- src/config.py | 2 - src/database.py | 538 ++++++++-- src/exports/malicious_ips.txt | 2 + src/handler.py | 294 ++++++ src/templates/dashboard_template.py | 1520 ++++++++++++++++++++++++--- tests/test_insert_fake_ips.py | 2 +- 8 files changed, 2101 insertions(+), 259 deletions(-) delete mode 100644 exports/.gitkeep create mode 100644 src/exports/malicious_ips.txt diff --git a/exports/.gitkeep b/exports/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 928d0f8..938bfa3 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -3,7 +3,7 @@ name: krawl-chart description: A Helm chart for Krawl honeypot server type: application version: 0.1.5 -appVersion: 0.1.7 +appVersion: 0.1.8 keywords: - honeypot - security diff --git a/src/config.py b/src/config.py index 1f1d122..1e96e09 100644 --- a/src/config.py +++ b/src/config.py @@ -100,8 +100,6 @@ class Config: self._server_ip = ip self._server_ip_cache_time = current_time - - get_app_logger().info(f"Server external IP detected: {ip}") return ip except Exception as e: diff --git a/src/database.py b/src/database.py index 88d72d7..80eb194 100644 --- a/src/database.py +++ b/src/database.py @@ -293,7 +293,7 @@ class DatabaseManager: session.commit() except Exception as e: session.rollback() - print(f"Error updating IP stats analysis: {e}") + applogger.error(f"Error updating IP stats analysis: {e}") def manual_update_category(self, ip: str, category: str) -> None: """ @@ -322,7 +322,7 @@ class DatabaseManager: session.commit() except Exception as e: session.rollback() - print(f"Error updating manual category: {e}") + applogger.error(f"Error updating manual category: {e}") def _record_category_change( self, @@ -514,56 +514,6 @@ class DatabaseManager: finally: self.close_session() - # def persist_ip( - # self, - # ip: str - # ) -> Optional[int]: - # """ - # Persist an ip entry to the database. - - # Args: - # ip: Client IP address - - # Returns: - # The ID of the created IpLog record, or None on error - # """ - # session = self.session - # try: - # # Create access log with sanitized fields - # ip_log = AccessLog( - # ip=sanitize_ip(ip), - # manual_category = False - # ) - # session.add(access_log) - # session.flush() # Get the ID before committing - - # # Add attack detections if any - # if attack_types: - # matched_patterns = matched_patterns or {} - # for attack_type in attack_types: - # detection = AttackDetection( - # access_log_id=access_log.id, - # attack_type=attack_type[:50], - # matched_pattern=sanitize_attack_pattern( - # matched_patterns.get(attack_type, "") - # ) - # ) - # session.add(detection) - - # # Update IP stats - # self._update_ip_stats(session, ip) - - # session.commit() - # return access_log.id - - # except Exception as e: - # session.rollback() - # # Log error but don't crash - database persistence is secondary to honeypot function - # print(f"Database error persisting access: {e}") - # return None - # finally: - # self.close_session() - def get_credential_attempts( self, limit: int = 100, offset: int = 0, ip_filter: Optional[str] = None ) -> List[Dict[str, Any]]: @@ -626,8 +576,8 @@ class DatabaseManager: { "ip": s.ip, "total_requests": s.total_requests, - "first_seen": s.first_seen.isoformat(), - "last_seen": s.last_seen.isoformat(), + "first_seen": s.first_seen.isoformat() if s.first_seen else None, + "last_seen": s.last_seen.isoformat() if s.last_seen else None, "country_code": s.country_code, "city": s.city, "asn": s.asn, @@ -637,7 +587,7 @@ class DatabaseManager: "analyzed_metrics": s.analyzed_metrics, "category": s.category, "manual_category": s.manual_category, - "last_analysis": s.last_analysis, + "last_analysis": s.last_analysis.isoformat() if s.last_analysis else None, } for s in stats ] @@ -688,6 +638,84 @@ class DatabaseManager: finally: self.close_session() + def get_attackers_paginated(self, page: int = 1, page_size: int = 25, sort_by: str = "total_requests", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of attacker IPs ordered by specified field. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (total_requests, first_seen, last_seen) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with attackers list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + # Validate sort parameters + valid_sort_fields = {"total_requests", "first_seen", "last_seen"} + sort_by = sort_by if sort_by in valid_sort_fields else "total_requests" + sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + + # Get total count of attackers + total_attackers = ( + session.query(IpStats) + .filter(IpStats.category == "attacker") + .count() + ) + + # Build query with sorting + query = session.query(IpStats).filter(IpStats.category == "attacker") + + if sort_by == "total_requests": + query = query.order_by( + IpStats.total_requests.desc() if sort_order == "desc" else IpStats.total_requests.asc() + ) + elif sort_by == "first_seen": + query = query.order_by( + IpStats.first_seen.desc() if sort_order == "desc" else IpStats.first_seen.asc() + ) + elif sort_by == "last_seen": + query = query.order_by( + IpStats.last_seen.desc() if sort_order == "desc" else IpStats.last_seen.asc() + ) + + # Get paginated attackers + attackers = query.offset(offset).limit(page_size).all() + + total_pages = (total_attackers + page_size - 1) // page_size + + return { + "attackers": [ + { + "ip": a.ip, + "total_requests": a.total_requests, + "first_seen": a.first_seen.isoformat() if a.first_seen else None, + "last_seen": a.last_seen.isoformat() if a.last_seen else None, + "country_code": a.country_code, + "city": a.city, + "asn": a.asn, + "asn_org": a.asn_org, + "reputation_score": a.reputation_score, + "reputation_source": a.reputation_source, + "category": a.category, + "category_scores": a.category_scores or {}, + } + for a in attackers + ], + "pagination": { + "page": page, + "page_size": page_size, + "total_attackers": total_attackers, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + def get_dashboard_counts(self) -> Dict[str, int]: """ Get aggregate statistics for the dashboard (excludes local/private IPs and server IP). @@ -719,6 +747,9 @@ class DatabaseManager: suspicious_accesses = sum(1 for log in public_accesses if log.is_suspicious) honeypot_triggered = sum(1 for log in public_accesses if log.is_honeypot_trigger) honeypot_ips = len(set(log.ip for log in public_accesses if log.is_honeypot_trigger)) + + # Count unique attackers from IpStats (matching the "Attackers by Total Requests" table) + unique_attackers = session.query(IpStats).filter(IpStats.category == "attacker").count() return { "total_accesses": total_accesses, @@ -727,6 +758,7 @@ class DatabaseManager: "suspicious_accesses": suspicious_accesses, "honeypot_triggered": honeypot_triggered, "honeypot_ips": honeypot_ips, + "unique_attackers": unique_attackers, } finally: self.close_session() @@ -772,7 +804,7 @@ class DatabaseManager: Args: limit: Maximum number of results - Returns:data + Returns: List of (path, count) tuples ordered by count descending """ session = self.session @@ -929,46 +961,370 @@ class DatabaseManager: finally: self.close_session() - # def get_ip_logs( - # self, - # limit: int = 100, - # offset: int = 0, - # ip_filter: Optional[str] = None - # ) -> List[Dict[str, Any]]: - # """ - # Retrieve ip logs with optional filtering. + def get_honeypot_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of honeypot-triggered IPs with their paths. - # Args: - # limit: Maximum number of records to return - # offset: Number of records to skip - # ip_filter: Filter by IP address + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (count or ip) + sort_order: Sort order (asc or desc) - # Returns: - # List of ip log dictionaries - # """ - # session = self.session - # try: - # query = session.query(IpLog).order_by(IpLog.last_access.desc()) + Returns: + Dictionary with honeypots list and pagination info + """ + session = self.session + try: + from config import get_config + config = get_config() + server_ip = config.get_server_ip() - # if ip_filter: - # query = query.filter(IpLog.ip == sanitize_ip(ip_filter)) + offset = (page - 1) * page_size - # logs = query.offset(offset).limit(limit).all() + # Get honeypot triggers grouped by IP + results = ( + session.query(AccessLog.ip, AccessLog.path) + .filter(AccessLog.is_honeypot_trigger == True) + .all() + ) - # return [ - # { - # 'id': log.id, - # 'ip': log.ip, - # 'stats': log.stats, - # 'category': log.category, - # 'manual_category': log.manual_category, - # 'last_evaluation': log.last_evaluation, - # 'last_access': log.last_access - # } - # for log in logs - # ] - # finally: - # self.close_session() + # Group paths by IP, filtering out invalid IPs + ip_paths: Dict[str, List[str]] = {} + for row in results: + if not is_valid_public_ip(row.ip, server_ip): + continue + if row.ip not in ip_paths: + ip_paths[row.ip] = [] + if row.path not in ip_paths[row.ip]: + ip_paths[row.ip].append(row.path) + + # Create list and sort + honeypot_list = [ + {"ip": ip, "paths": paths, "count": len(paths)} + for ip, paths in ip_paths.items() + ] + + if sort_by == "count": + honeypot_list.sort( + key=lambda x: x["count"], + reverse=(sort_order == "desc") + ) + else: # sort by ip + honeypot_list.sort( + key=lambda x: x["ip"], + reverse=(sort_order == "desc") + ) + + total_honeypots = len(honeypot_list) + paginated = honeypot_list[offset:offset + page_size] + total_pages = (total_honeypots + page_size - 1) // page_size + + return { + "honeypots": paginated, + "pagination": { + "page": page, + "page_size": page_size, + "total": total_honeypots, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + + def get_credentials_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "timestamp", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of credential attempts. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (timestamp, ip, username) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with credentials list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + # Validate sort parameters + valid_sort_fields = {"timestamp", "ip", "username"} + sort_by = sort_by if sort_by in valid_sort_fields else "timestamp" + sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + + total_credentials = session.query(CredentialAttempt).count() + + # Build query with sorting + query = session.query(CredentialAttempt) + + if sort_by == "timestamp": + query = query.order_by( + CredentialAttempt.timestamp.desc() if sort_order == "desc" else CredentialAttempt.timestamp.asc() + ) + elif sort_by == "ip": + query = query.order_by( + CredentialAttempt.ip.desc() if sort_order == "desc" else CredentialAttempt.ip.asc() + ) + elif sort_by == "username": + query = query.order_by( + CredentialAttempt.username.desc() if sort_order == "desc" else CredentialAttempt.username.asc() + ) + + credentials = query.offset(offset).limit(page_size).all() + total_pages = (total_credentials + page_size - 1) // page_size + + return { + "credentials": [ + { + "ip": c.ip, + "username": c.username, + "password": c.password, + "path": c.path, + "timestamp": c.timestamp.isoformat() if c.timestamp else None, + } + for c in credentials + ], + "pagination": { + "page": page, + "page_size": page_size, + "total": total_credentials, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + + def get_top_ips_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of top IP addresses by access count. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (count or ip) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with IPs list and pagination info + """ + session = self.session + try: + from config import get_config + config = get_config() + server_ip = config.get_server_ip() + + offset = (page - 1) * page_size + + results = ( + session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) + .group_by(AccessLog.ip) + .all() + ) + + # Filter out local/private IPs and server IP, then sort + filtered = [ + {"ip": row.ip, "count": row.count} + for row in results + if is_valid_public_ip(row.ip, server_ip) + ] + + if sort_by == "count": + filtered.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) + else: # sort by ip + filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc")) + + total_ips = len(filtered) + paginated = filtered[offset:offset + page_size] + total_pages = (total_ips + page_size - 1) // page_size + + return { + "ips": paginated, + "pagination": { + "page": page, + "page_size": page_size, + "total": total_ips, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + + def get_top_paths_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of top paths by access count. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (count or path) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with paths list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + results = ( + session.query(AccessLog.path, func.count(AccessLog.id).label("count")) + .group_by(AccessLog.path) + .all() + ) + + # Create list and sort + paths_list = [ + {"path": row.path, "count": row.count} + for row in results + ] + + if sort_by == "count": + paths_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) + else: # sort by path + paths_list.sort(key=lambda x: x["path"], reverse=(sort_order == "desc")) + + total_paths = len(paths_list) + paginated = paths_list[offset:offset + page_size] + total_pages = (total_paths + page_size - 1) // page_size + + return { + "paths": paginated, + "pagination": { + "page": page, + "page_size": page_size, + "total": total_paths, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + + def get_top_user_agents_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of top user agents by access count. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (count or user_agent) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with user agents list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + results = ( + session.query(AccessLog.user_agent, func.count(AccessLog.id).label("count")) + .filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "") + .group_by(AccessLog.user_agent) + .all() + ) + + # Create list and sort + ua_list = [ + {"user_agent": row.user_agent, "count": row.count} + for row in results + ] + + if sort_by == "count": + ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) + else: # sort by user_agent + ua_list.sort(key=lambda x: x["user_agent"], reverse=(sort_order == "desc")) + + total_uas = len(ua_list) + paginated = ua_list[offset:offset + page_size] + total_pages = (total_uas + page_size - 1) // page_size + + return { + "user_agents": paginated, + "pagination": { + "page": page, + "page_size": page_size, + "total": total_uas, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + + def get_attack_types_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "timestamp", sort_order: str = "desc") -> Dict[str, Any]: + """ + Retrieve paginated list of detected attack types with access logs. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (timestamp, ip, attack_type) + sort_order: Sort order (asc or desc) + + Returns: + Dictionary with attacks list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + # Validate sort parameters + valid_sort_fields = {"timestamp", "ip", "attack_type"} + sort_by = sort_by if sort_by in valid_sort_fields else "timestamp" + sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + + # Get all access logs with attack detections + query = ( + session.query(AccessLog) + .join(AttackDetection) + ) + + if sort_by == "timestamp": + query = query.order_by( + AccessLog.timestamp.desc() if sort_order == "desc" else AccessLog.timestamp.asc() + ) + elif sort_by == "ip": + query = query.order_by( + AccessLog.ip.desc() if sort_order == "desc" else AccessLog.ip.asc() + ) + + logs = query.all() + + # Convert to attack list + attack_list = [ + { + "ip": log.ip, + "path": log.path, + "user_agent": log.user_agent, + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + "attack_types": [d.attack_type for d in log.attack_detections], + } + for log in logs + ] + + # Sort by attack_type if needed (this must be done post-fetch since it's in a related table) + if sort_by == "attack_type": + attack_list.sort( + key=lambda x: x["attack_types"][0] if x["attack_types"] else "", + reverse=(sort_order == "desc") + ) + + total_attacks = len(attack_list) + paginated = attack_list[offset:offset + page_size] + total_pages = (total_attacks + page_size - 1) // page_size + + return { + "attacks": paginated, + "pagination": { + "page": page, + "page_size": page_size, + "total": total_attacks, + "total_pages": total_pages, + }, + } + finally: + self.close_session() # Module-level singleton instance diff --git a/src/exports/malicious_ips.txt b/src/exports/malicious_ips.txt new file mode 100644 index 0000000..2541a21 --- /dev/null +++ b/src/exports/malicious_ips.txt @@ -0,0 +1,2 @@ +175.23.45.67 +210.45.67.89 diff --git a/src/handler.py b/src/handler.py index 1be7c2c..df04465 100644 --- a/src/handler.py +++ b/src/handler.py @@ -510,6 +510,72 @@ class Handler(BaseHTTPRequestHandler): self.app_logger.error(f"Error generating dashboard: {e}") return + # API endpoint for fetching all IP statistics + if self.config.dashboard_secret_path and self.path == f"{self.config.dashboard_secret_path}/api/all-ip-stats": + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + + db = get_database() + ip_stats_list = db.get_ip_stats(limit=500) + self.wfile.write(json.dumps({"ips": ip_stats_list}).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching all IP stats: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for fetching paginated attackers + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/attackers" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + + # Parse query parameters + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["25"])[0]) + sort_by = query_params.get("sort_by", ["total_requests"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + # Ensure valid parameters + page = max(1, page) + page_size = min(max(1, page_size), 100) # Max 100 per page + + result = db.get_attackers_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching attackers: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + # API endpoint for fetching IP stats if self.config.dashboard_secret_path and self.path.startswith( f"{self.config.dashboard_secret_path}/api/ip-stats/" @@ -544,6 +610,234 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(json.dumps({"error": str(e)}).encode()) return + # API endpoint for paginated honeypot triggers + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/honeypot" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["count"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_honeypot_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching honeypot data: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for paginated credentials + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/credentials" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["timestamp"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_credentials_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching credentials: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for paginated top IPs + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/top-ips" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["count"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_top_ips_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching top IPs: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for paginated top paths + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/top-paths" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["count"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_top_paths_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching top paths: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for paginated top user agents + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/top-user-agents" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["count"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_top_user_agents_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching top user agents: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + + # API endpoint for paginated attack types + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/attack-types" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["5"])[0]) + sort_by = query_params.get("sort_by", ["timestamp"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + page = max(1, page) + page_size = min(max(1, page_size), 100) + + result = db.get_attack_types_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching attack types: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + # API endpoint for downloading malicious IPs file if ( self.config.dashboard_secret_path diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 5d31bb8..8babb4d 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -45,45 +45,6 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: dashboard_path: The secret dashboard path for generating API URLs """ - # Generate IP rows with clickable functionality for dropdown stats - top_ips_rows = ( - "\n".join([f""" - {i+1} - {_escape(ip)} - {count} - - - -
-
Loading stats...
-
- - """ for i, (ip, count) in enumerate(stats["top_ips"])]) - or 'No data' - ) - - # Generate paths rows (CRITICAL: paths can contain XSS payloads) - top_paths_rows = ( - "\n".join( - [ - f'{i+1}{_escape(path)}{count}' - for i, (path, count) in enumerate(stats["top_paths"]) - ] - ) - or 'No data' - ) - - # Generate User-Agent rows (CRITICAL: user agents can contain XSS payloads) - top_ua_rows = ( - "\n".join( - [ - f'{i+1}{_escape(ua[:80])}{count}' - for i, (ua, count) in enumerate(stats["top_user_agents"]) - ] - ) - or 'No data' - ) - # Generate suspicious accesses rows with clickable IPs suspicious_rows = ( "\n".join([f""" @@ -102,66 +63,14 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: or 'No suspicious activity detected' ) - # Generate honeypot triggered IPs rows with clickable IPs - honeypot_rows = ( - "\n".join([f""" - {_escape(ip)} - {_escape(", ".join(paths))} - {len(paths)} - - - -
-
Loading stats...
-
- - """ for ip, paths in stats.get("honeypot_triggered_ips", [])]) - or 'No honeypot triggers yet' - ) - - # Generate attack types rows with clickable IPs - attack_type_rows = ( - "\n".join([f""" - {_escape(log["ip"])} - {_escape(log["path"])} - {_escape(", ".join(log["attack_types"]))} - {_escape(log["user_agent"][:60])} - {format_timestamp(log["timestamp"],time_only=True)} - - - -
-
Loading stats...
-
- - """ for log in stats.get("attack_types", [])[-10:]]) - or 'No attacks detected' - ) - - # Generate credential attempts rows with clickable IPs - credential_rows = ( - "\n".join([f""" - {_escape(log["ip"])} - {_escape(log["username"])} - {_escape(log["password"])} - {_escape(log["path"])} - {format_timestamp(log["timestamp"], time_only=True)} - - - -
-
Loading stats...
-
- - """ for log in stats.get("credential_attempts", [])[-20:]]) - or 'No credentials captured yet' - ) - return f""" Krawl Dashboard + + + @@ -549,25 +618,19 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
{len(stats.get('credential_attempts', []))}
Credentials Captured
+
+
{stats.get('unique_attackers', 0)}
+
Unique Attackers
+
-
-

Honeypot Triggers by IP

- - - - - - - - - - {honeypot_rows} - -
IP AddressAccessed PathsCount
+ -
+
+

Recent Suspicious Activity

@@ -585,87 +648,204 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
-

Captured Credentials

-
+
+

Honeypot Triggers by IP

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+
+ + + + + + + + + +
#IP AddressAccessed PathsCount
Loading...
+
+ +
+
+
+

Top IP Addresses

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+ + + + + + + + + + + +
#IP AddressAccess Count
Loading...
+
+ +
+
+

Top User-Agents

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+ + + + + + + + + + + +
#User-AgentCount
Loading...
+
+
+
+ +
+
+

Attacker Origins Map

+
+
Loading map...
+
+
+ +
+
+

Attackers by Total Requests

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+ + + + + + + + + + + + + + + +
#IP AddressTotal RequestsFirst SeenLast SeenLocation
+
+ +
+
+

Captured Credentials

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+ + + + - + - - {credential_rows} + +
# IP Address Username Password PathTimeTime
Loading...
-

Detected Attack Types

- +
+

Detected Attack Types

+
+
+ Page 1/1 + + 0 total +
+ + +
+
+
+ - + - - {attack_type_rows} + +
# IP Address Path Attack Types User-AgentTimeTime
Loading...
-
-

Top IP Addresses

- - - - - - - - - - {top_ips_rows} - -
#IP AddressAccess Count
+
+

Most Recurring Attack Types

+
+ +
+
-
-

Top Paths

- - - - - - - - - - {top_paths_rows} - -
#PathAccess Count
-
- -
-

Top User-Agents

- - - - - - - - - - {top_ua_rows} - -
#User-AgentCount
+
+
+ +
+ +
+
diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py index 6279b43..bdde596 100644 --- a/tests/test_insert_fake_ips.py +++ b/tests/test_insert_fake_ips.py @@ -109,7 +109,7 @@ def generate_analyzed_metrics(): } -def generate_fake_data(num_ips: int = 5, logs_per_ip: int = 15, credentials_per_ip: int = 3): +def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per_ip: int = 3): """ Generate and insert fake test data into the database. From 8c76f6c84742e6d08d313c47fac4b39750456bff Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:36:22 +0100 Subject: [PATCH 64/70] Feat/deployment update (#56) * feat: update analyzer thresholds and add crawl configuration options * feat: update Helm chart version and add README for installation instructions * feat: update installation instructions in README and add Docker support * feat: update deployment manifests and configuration for improved service handling and analyzer settings * feat: add API endpoint for paginated IP retrieval and enhance dashboard visualization with category filters * feat: update configuration for Krawl service to use external config file * feat: refactor code for improved readability and consistency across multiple files * feat: remove Flake8, Pylint, and test steps from PR checks workflow --- .github/workflows/pr-checks.yml | 9 - README.md | 185 +++++++---- config.yaml | 7 +- helm/Chart.yaml | 5 +- helm/README.md | 286 ++++++++++++++++++ helm/templates/configmap.yaml | 4 + helm/values.yaml | 6 +- kubernetes/krawl-all-in-one-deploy.yaml | 46 +-- kubernetes/manifests/configmap.yaml | 25 +- kubernetes/manifests/ingress.yaml | 4 +- kubernetes/manifests/service.yaml | 5 + kubernetes/manifests/wordlists-configmap.yaml | 12 + src/config.py | 8 +- src/database.py | 286 ++++++++++++++---- src/handler.py | 102 ++++++- src/ip_utils.py | 12 +- src/tasks/memory_cleanup.py | 9 +- src/tasks/top_attacking_ips.py | 7 +- src/templates/dashboard_template.py | 264 +++++++++++----- src/tracker.py | 12 +- 20 files changed, 1025 insertions(+), 269 deletions(-) create mode 100644 helm/README.md diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 0259795..9feb01c 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -37,15 +37,6 @@ jobs: exit 1 fi - - name: Flake8 lint - run: flake8 src/ --max-line-length=120 --extend-ignore=E203,W503 - - - name: Pylint check - run: pylint src/ --fail-under=7.0 || true - - - name: Run tests - run: pytest tests/ -v || true - build-docker: name: Build Docker runs-on: ubuntu-latest diff --git a/README.md b/README.md index 1d0e8a5..8b5011c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@

What is Krawl? • - Quick Start • + InstallationHoneypot PagesDashboardTodo • @@ -74,100 +74,155 @@ It features: ![asd](img/deception-page.png) -## 🚀 Quick Start -## Helm Chart +## 🚀 Installation -Install with default values +### Docker Run -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --namespace krawl-system \ - --create-namespace -``` - -Install with custom [canary token](#customizing-the-canary-token) - -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --namespace krawl-system \ - --create-namespace \ - --set config.canaryTokenUrl="http://your-canary-token-url" -``` - -To access the deception server - -```bash -kubectl get svc krawl -n krawl-system -``` - -Once the EXTERNAL-IP is assigned, access your deception server at: - -``` -http://:5000 -``` - -## Kubernetes / Kustomize -Apply all manifests with - -```bash -kubectl apply -f https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/manifests/krawl-all-in-one-deploy.yaml -``` - -Retrieve dashboard path with -```bash -kubectl get secret krawl-server -n krawl-system -o jsonpath='{.data.dashboard-path}' | base64 -d -``` - -Or clone the repo and apply the `manifest` folder with - -```bash -kubectl apply -k manifests -``` - -## Docker -Run Krawl as a docker container with +Run Krawl with the latest image: ```bash docker run -d \ -p 5000:5000 \ - -e CANARY_TOKEN_URL="http://your-canary-token-url" \ + -e KRAWL_PORT=5000 \ + -e KRAWL_DELAY=100 \ + -e KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" \ + -e KRAWL_DATABASE_RETENTION_DAYS=30 \ --name krawl \ ghcr.io/blessedrebus/krawl:latest ``` -## Docker Compose -Run Krawl with docker-compose in the project folder with +Access the server at `http://localhost:5000` + +### Docker Compose + +Create a `docker-compose.yaml` file: + +```yaml +services: + krawl: + image: ghcr.io/blessedrebus/krawl:latest + container_name: krawl-server + ports: + - "5000:5000" + environment: + - CONFIG_LOCATION=config.yaml + volumes: + - ./config.yaml:/app/config.yaml:ro + - krawl-data:/app/data + restart: unless-stopped + +volumes: + krawl-data: +``` + +Run with: ```bash docker-compose up -d ``` -Stop it with +Stop with: ```bash docker-compose down ``` -## Python 3.11+ +### Helm Chart -Clone the repository +Install with default values: + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --version 2.0.0 \ + --namespace krawl-system \ + --create-namespace +``` + +Or create a minimal `values.yaml` file: + +```yaml +service: + type: LoadBalancer + port: 5000 + +ingress: + enabled: true + className: "traefik" + hosts: + - host: krawl.example.com + paths: + - path: / + pathType: Prefix + +config: + server: + port: 5000 + delay: 100 + dashboard: + secret_path: null # Auto-generated if not set + +database: + persistence: + enabled: true + size: 1Gi +``` + +Install with custom values: + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --version 2.0.0 \ + --namespace krawl-system \ + --create-namespace \ + -f values.yaml +``` + +To access the deception server: + +```bash +kubectl get svc krawl -n krawl-system +``` + +Once the EXTERNAL-IP is assigned, access your deception server at `http://:5000` + +### Kubernetes + +Apply all manifests with: + +```bash +kubectl apply -f https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/kubernetes/krawl-all-in-one-deploy.yaml +``` + +Or clone the repo and apply the manifest: + +```bash +kubectl apply -f kubernetes/krawl-all-in-one-deploy.yaml +``` + +Access the deception server: + +```bash +kubectl get svc krawl-server -n krawl-system +``` + +Once the EXTERNAL-IP is assigned, access your deception server at `http://:5000` + +### From Source (Python 3.11+) + +Clone the repository: ```bash git clone https://github.com/blessedrebus/krawl.git cd krawl/src ``` -Run the server + +Run the server: + ```bash python3 server.py ``` -Visit - -`http://localhost:5000` - -To access the dashboard - -`http://localhost:5000/` +Visit `http://localhost:5000` and access the dashboard at `http://localhost:5000/` ## Configuration via Environment Variables diff --git a/config.yaml b/config.yaml index 388b694..3e1d644 100644 --- a/config.yaml +++ b/config.yaml @@ -39,7 +39,12 @@ behavior: analyzer: http_risky_methods_threshold: 0.1 violated_robots_threshold: 0.1 - uneven_request_timing_threshold: 2 + uneven_request_timing_threshold: 0.5 uneven_request_timing_time_window_seconds: 300 user_agents_used_threshold: 2 attack_urls_threshold: 1 + +crawl: + infinite_pages_for_malicious: true + max_pages_limit: 250 + ban_duration_seconds: 600 \ No newline at end of file diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 938bfa3..b5bc6f2 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 0.1.5 -appVersion: 0.1.8 +version: 0.2.0 +appVersion: 0.2.0 keywords: - honeypot - security @@ -13,3 +13,4 @@ maintainers: home: https://github.com/blessedrebus/krawl sources: - https://github.com/blessedrebus/krawl +icon: https://raw.githubusercontent.com/blessedrebus/krawl/main/docs/images/krawl-logo.png \ No newline at end of file diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 0000000..0882c3d --- /dev/null +++ b/helm/README.md @@ -0,0 +1,286 @@ +# Krawl Helm Chart + +A Helm chart for deploying the Krawl honeypot application on Kubernetes. + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.0+ +- Persistent Volume provisioner (optional, for database persistence) + +## Installation + +### Add the repository (if applicable) + +```bash +helm repo add krawl https://github.com/BlessedRebuS/Krawl +helm repo update +``` + +### Install from OCI Registry + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.1.5-dev +``` + +Or with a specific namespace: + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.1.5-dev -n krawl --create-namespace +``` + +### Install the chart locally + +```bash +helm install krawl ./helm +``` + +### Install with custom values + +```bash +helm install krawl ./helm -f values.yaml +``` + +### Install in a specific namespace + +```bash +helm install krawl ./helm -n krawl --create-namespace +``` + +## Configuration + +The following table lists the main configuration parameters of the Krawl chart and their default values. + +### Global Settings + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `replicaCount` | Number of pod replicas | `1` | +| `image.repository` | Image repository | `ghcr.io/blessedrebus/krawl` | +| `image.tag` | Image tag | `latest` | +| `image.pullPolicy` | Image pull policy | `Always` | + +### Service Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `service.type` | Service type | `LoadBalancer` | +| `service.port` | Service port | `5000` | +| `service.externalTrafficPolicy` | External traffic policy | `Local` | + +### Ingress Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `ingress.enabled` | Enable ingress | `true` | +| `ingress.className` | Ingress class name | `traefik` | +| `ingress.hosts[0].host` | Ingress hostname | `krawl.example.com` | + +### Server Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.server.port` | Server port | `5000` | +| `config.server.delay` | Response delay in milliseconds | `100` | +| `config.server.timezone` | IANA timezone (e.g., "America/New_York") | `null` | + +### Links Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.links.min_length` | Minimum link length | `5` | +| `config.links.max_length` | Maximum link length | `15` | +| `config.links.min_per_page` | Minimum links per page | `10` | +| `config.links.max_per_page` | Maximum links per page | `15` | +| `config.links.char_space` | Character space for link generation | `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789` | +| `config.links.max_counter` | Maximum counter value | `10` | + +### Canary Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.canary.token_url` | Canary token URL | `null` | +| `config.canary.token_tries` | Number of canary token tries | `10` | + +### Dashboard Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.dashboard.secret_path` | Secret dashboard path (auto-generated if null) | `null` | + +### API Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.api.server_url` | API server URL | `null` | +| `config.api.server_port` | API server port | `8080` | +| `config.api.server_path` | API server path | `/api/v2/users` | + +### Database Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.database.path` | Database file path | `data/krawl.db` | +| `config.database.retention_days` | Data retention in days | `30` | +| `database.persistence.enabled` | Enable persistent volume | `true` | +| `database.persistence.size` | Persistent volume size | `1Gi` | +| `database.persistence.accessMode` | Access mode | `ReadWriteOnce` | + +### Behavior Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.behavior.probability_error_codes` | Error code probability (0-100) | `0` | + +### Analyzer Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.analyzer.http_risky_methods_threshold` | HTTP risky methods threshold | `0.1` | +| `config.analyzer.violated_robots_threshold` | Violated robots.txt threshold | `0.1` | +| `config.analyzer.uneven_request_timing_threshold` | Uneven request timing threshold | `0.5` | +| `config.analyzer.uneven_request_timing_time_window_seconds` | Time window for request timing analysis | `300` | +| `config.analyzer.user_agents_used_threshold` | User agents threshold | `2` | +| `config.analyzer.attack_urls_threshold` | Attack URLs threshold | `1` | + +### Crawl Configuration + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `config.crawl.infinite_pages_for_malicious` | Infinite pages for malicious crawlers | `true` | +| `config.crawl.max_pages_limit` | Maximum pages limit for legitimate crawlers | `250` | +| `config.crawl.ban_duration_seconds` | IP ban duration in seconds | `600` | + +### Resource Limits + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `resources.limits.cpu` | CPU limit | `500m` | +| `resources.limits.memory` | Memory limit | `256Mi` | +| `resources.requests.cpu` | CPU request | `100m` | +| `resources.requests.memory` | Memory request | `64Mi` | + +### Autoscaling + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `autoscaling.enabled` | Enable horizontal pod autoscaling | `false` | +| `autoscaling.minReplicas` | Minimum replicas | `1` | +| `autoscaling.maxReplicas` | Maximum replicas | `1` | +| `autoscaling.targetCPUUtilizationPercentage` | Target CPU utilization | `70` | +| `autoscaling.targetMemoryUtilizationPercentage` | Target memory utilization | `80` | + +### Network Policy + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `networkPolicy.enabled` | Enable network policy | `true` | + +## Usage Examples + +### Basic Installation + +```bash +helm install krawl ./helm +``` + +### Installation with Custom Domain + +```bash +helm install krawl ./helm \ + --set ingress.hosts[0].host=honeypot.example.com +``` + +### Enable Canary Tokens + +```bash +helm install krawl ./helm \ + --set config.canary.token_url=https://canarytokens.com/your-token +``` + +### Configure Custom API Endpoint + +```bash +helm install krawl ./helm \ + --set config.api.server_url=https://api.example.com \ + --set config.api.server_port=443 +``` + +### Create Values Override File + +Create `custom-values.yaml`: + +```yaml +config: + server: + port: 8080 + delay: 500 + canary: + token_url: https://your-canary-token-url + dashboard: + secret_path: /super-secret-path + crawl: + max_pages_limit: 500 + ban_duration_seconds: 3600 +``` + +Then install: + +```bash +helm install krawl ./helm -f custom-values.yaml +``` + +## Upgrading + +```bash +helm upgrade krawl ./helm +``` + +## Uninstalling + +```bash +helm uninstall krawl +``` + +## Troubleshooting + +### Check chart syntax + +```bash +helm lint ./helm +``` + +### Dry run to verify values + +```bash +helm install krawl ./helm --dry-run --debug +``` + +### Check deployed configuration + +```bash +kubectl get configmap krawl-config -o yaml +``` + +### View pod logs + +```bash +kubectl logs -l app.kubernetes.io/name=krawl +``` + +## Chart Files + +- `Chart.yaml` - Chart metadata +- `values.yaml` - Default configuration values +- `templates/` - Kubernetes resource templates + - `deployment.yaml` - Krawl deployment + - `service.yaml` - Service configuration + - `configmap.yaml` - Application configuration + - `pvc.yaml` - Persistent volume claim + - `ingress.yaml` - Ingress configuration + - `hpa.yaml` - Horizontal pod autoscaler + - `network-policy.yaml` - Network policies + +## Support + +For issues and questions, please visit the [Krawl GitHub repository](https://github.com/BlessedRebuS/Krawl). diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index d6e5f5c..f6efdf4 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -39,3 +39,7 @@ data: uneven_request_timing_time_window_seconds: {{ .Values.config.analyzer.uneven_request_timing_time_window_seconds }} user_agents_used_threshold: {{ .Values.config.analyzer.user_agents_used_threshold }} attack_urls_threshold: {{ .Values.config.analyzer.attack_urls_threshold }} + crawl: + infinite_pages_for_malicious: {{ .Values.config.crawl.infinite_pages_for_malicious }} + max_pages_limit: {{ .Values.config.crawl.max_pages_limit }} + ban_duration_seconds: {{ .Values.config.crawl.ban_duration_seconds }} diff --git a/helm/values.yaml b/helm/values.yaml index 0b83892..35add96 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -92,10 +92,14 @@ config: analyzer: http_risky_methods_threshold: 0.1 violated_robots_threshold: 0.1 - uneven_request_timing_threshold: 2 + uneven_request_timing_threshold: 0.5 uneven_request_timing_time_window_seconds: 300 user_agents_used_threshold: 2 attack_urls_threshold: 1 + crawl: + infinite_pages_for_malicious: true + max_pages_limit: 250 + ban_duration_seconds: 600 # Database persistence configuration database: diff --git a/kubernetes/krawl-all-in-one-deploy.yaml b/kubernetes/krawl-all-in-one-deploy.yaml index 3344260..b49d070 100644 --- a/kubernetes/krawl-all-in-one-deploy.yaml +++ b/kubernetes/krawl-all-in-one-deploy.yaml @@ -15,8 +15,7 @@ data: server: port: 5000 delay: 100 - timezone: null # e.g., "America/New_York" or null for system default - + timezone: null links: min_length: 5 max_length: 15 @@ -24,27 +23,31 @@ data: max_per_page: 15 char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" max_counter: 10 - canary: - token_url: null # Optional canary token URL + token_url: null token_tries: 10 - dashboard: - # Auto-generates random path if null - # Can be set to "/dashboard" or similar secret_path: null - api: server_url: null server_port: 8080 server_path: "/api/v2/users" - database: path: "data/krawl.db" retention_days: 30 - behavior: - probability_error_codes: 0 # 0-100 percentage + probability_error_codes: 0 + analyzer: + http_risky_methods_threshold: 0.1 + violated_robots_threshold: 0.1 + uneven_request_timing_threshold: 0.5 + uneven_request_timing_time_window_seconds: 300 + user_agents_used_threshold: 2 + attack_urls_threshold: 1 + crawl: + infinite_pages_for_malicious: true + max_pages_limit: 250 + ban_duration_seconds: 600 --- apiVersion: v1 kind: ConfigMap @@ -251,12 +254,16 @@ data: 503 ], "server_headers": [ - "Apache/2.4.41 (Ubuntu)", + "Apache/2.2.22 (Ubuntu)", "nginx/1.18.0", "Microsoft-IIS/10.0", - "cloudflare", - "AmazonS3", - "gunicorn/20.1.0" + "LiteSpeed", + "Caddy", + "Gunicorn/20.0.4", + "uvicorn/0.13.4", + "Express", + "Flask/1.1.2", + "Django/3.1" ] } --- @@ -340,6 +347,11 @@ metadata: app: krawl-server spec: type: LoadBalancer + externalTrafficPolicy: Local + sessionAffinity: ClientIP + sessionAffinityConfig: + clientIP: + timeoutSeconds: 10800 ports: - port: 5000 targetPort: 5000 @@ -353,10 +365,8 @@ kind: Ingress metadata: name: krawl-ingress namespace: krawl-system - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / spec: - ingressClassName: nginx + ingressClassName: traefik rules: - host: krawl.example.com # Change to your domain http: diff --git a/kubernetes/manifests/configmap.yaml b/kubernetes/manifests/configmap.yaml index 38a287b..d03e1c3 100644 --- a/kubernetes/manifests/configmap.yaml +++ b/kubernetes/manifests/configmap.yaml @@ -9,8 +9,7 @@ data: server: port: 5000 delay: 100 - timezone: null # e.g., "America/New_York" or null for system default - + timezone: null links: min_length: 5 max_length: 15 @@ -18,24 +17,28 @@ data: max_per_page: 15 char_space: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" max_counter: 10 - canary: - token_url: null # Optional canary token URL + token_url: null token_tries: 10 - dashboard: - # Auto-generates random path if null - # Can be set to "/dashboard" or similar secret_path: null - api: server_url: null server_port: 8080 server_path: "/api/v2/users" - database: path: "data/krawl.db" retention_days: 30 - behavior: - probability_error_codes: 0 # 0-100 percentage + probability_error_codes: 0 + analyzer: + http_risky_methods_threshold: 0.1 + violated_robots_threshold: 0.1 + uneven_request_timing_threshold: 0.5 + uneven_request_timing_time_window_seconds: 300 + user_agents_used_threshold: 2 + attack_urls_threshold: 1 + crawl: + infinite_pages_for_malicious: true + max_pages_limit: 250 + ban_duration_seconds: 600 diff --git a/kubernetes/manifests/ingress.yaml b/kubernetes/manifests/ingress.yaml index f5a6efc..52cea39 100644 --- a/kubernetes/manifests/ingress.yaml +++ b/kubernetes/manifests/ingress.yaml @@ -3,10 +3,8 @@ kind: Ingress metadata: name: krawl-ingress namespace: krawl-system - annotations: - nginx.ingress.kubernetes.io/rewrite-target: / spec: - ingressClassName: nginx + ingressClassName: traefik rules: - host: krawl.example.com # Change to your domain http: diff --git a/kubernetes/manifests/service.yaml b/kubernetes/manifests/service.yaml index 8db65b4..0f9291a 100644 --- a/kubernetes/manifests/service.yaml +++ b/kubernetes/manifests/service.yaml @@ -7,6 +7,11 @@ metadata: app: krawl-server spec: type: LoadBalancer + externalTrafficPolicy: Local + sessionAffinity: ClientIP + sessionAffinityConfig: + clientIP: + timeoutSeconds: 10800 ports: - port: 5000 targetPort: 5000 diff --git a/kubernetes/manifests/wordlists-configmap.yaml b/kubernetes/manifests/wordlists-configmap.yaml index 4ff0b5d..cc541c6 100644 --- a/kubernetes/manifests/wordlists-configmap.yaml +++ b/kubernetes/manifests/wordlists-configmap.yaml @@ -201,5 +201,17 @@ data: 500, 502, 503 + ], + "server_headers": [ + "Apache/2.2.22 (Ubuntu)", + "nginx/1.18.0", + "Microsoft-IIS/10.0", + "LiteSpeed", + "Caddy", + "Gunicorn/20.0.4", + "uvicorn/0.13.4", + "Express", + "Flask/1.1.2", + "Django/3.1" ] } diff --git a/src/config.py b/src/config.py index 1e96e09..71cef0e 100644 --- a/src/config.py +++ b/src/config.py @@ -76,10 +76,10 @@ class Config: # Try multiple external IP detection services (fallback chain) ip_detection_services = [ "https://api.ipify.org", # Plain text response - "http://ident.me", # Plain text response - "https://ifconfig.me", # Plain text response + "http://ident.me", # Plain text response + "https://ifconfig.me", # Plain text response ] - + ip = None for service_url in ip_detection_services: try: @@ -90,7 +90,7 @@ class Config: break except Exception: continue - + if not ip: get_app_logger().warning( "Could not determine server IP from external services. " diff --git a/src/database.py b/src/database.py index 80eb194..b88497e 100644 --- a/src/database.py +++ b/src/database.py @@ -587,7 +587,9 @@ class DatabaseManager: "analyzed_metrics": s.analyzed_metrics, "category": s.category, "manual_category": s.manual_category, - "last_analysis": s.last_analysis.isoformat() if s.last_analysis else None, + "last_analysis": ( + s.last_analysis.isoformat() if s.last_analysis else None + ), } for s in stats ] @@ -638,7 +640,13 @@ class DatabaseManager: finally: self.close_session() - def get_attackers_paginated(self, page: int = 1, page_size: int = 25, sort_by: str = "total_requests", sort_order: str = "desc") -> Dict[str, Any]: + def get_attackers_paginated( + self, + page: int = 1, + page_size: int = 25, + sort_by: str = "total_requests", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of attacker IPs ordered by specified field. @@ -658,29 +666,35 @@ class DatabaseManager: # Validate sort parameters valid_sort_fields = {"total_requests", "first_seen", "last_seen"} sort_by = sort_by if sort_by in valid_sort_fields else "total_requests" - sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + sort_order = ( + sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + ) # Get total count of attackers total_attackers = ( - session.query(IpStats) - .filter(IpStats.category == "attacker") - .count() + session.query(IpStats).filter(IpStats.category == "attacker").count() ) # Build query with sorting query = session.query(IpStats).filter(IpStats.category == "attacker") - + if sort_by == "total_requests": query = query.order_by( - IpStats.total_requests.desc() if sort_order == "desc" else IpStats.total_requests.asc() + IpStats.total_requests.desc() + if sort_order == "desc" + else IpStats.total_requests.asc() ) elif sort_by == "first_seen": query = query.order_by( - IpStats.first_seen.desc() if sort_order == "desc" else IpStats.first_seen.asc() + IpStats.first_seen.desc() + if sort_order == "desc" + else IpStats.first_seen.asc() ) elif sort_by == "last_seen": query = query.order_by( - IpStats.last_seen.desc() if sort_order == "desc" else IpStats.last_seen.asc() + IpStats.last_seen.desc() + if sort_order == "desc" + else IpStats.last_seen.asc() ) # Get paginated attackers @@ -693,7 +707,9 @@ class DatabaseManager: { "ip": a.ip, "total_requests": a.total_requests, - "first_seen": a.first_seen.isoformat() if a.first_seen else None, + "first_seen": ( + a.first_seen.isoformat() if a.first_seen else None + ), "last_seen": a.last_seen.isoformat() if a.last_seen else None, "country_code": a.country_code, "city": a.city, @@ -716,6 +732,101 @@ class DatabaseManager: finally: self.close_session() + def get_all_ips_paginated( + self, + page: int = 1, + page_size: int = 25, + sort_by: str = "total_requests", + sort_order: str = "desc", + categories: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Retrieve paginated list of all IPs (or filtered by categories) ordered by specified field. + + Args: + page: Page number (1-indexed) + page_size: Number of results per page + sort_by: Field to sort by (total_requests, first_seen, last_seen) + sort_order: Sort order (asc or desc) + categories: Optional list of categories to filter by + + Returns: + Dictionary with IPs list and pagination info + """ + session = self.session + try: + offset = (page - 1) * page_size + + # Validate sort parameters + valid_sort_fields = {"total_requests", "first_seen", "last_seen"} + sort_by = sort_by if sort_by in valid_sort_fields else "total_requests" + sort_order = ( + sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + ) + + # Build query with optional category filter + query = session.query(IpStats) + if categories: + query = query.filter(IpStats.category.in_(categories)) + + # Get total count + total_ips = query.count() + + # Apply sorting + if sort_by == "total_requests": + query = query.order_by( + IpStats.total_requests.desc() + if sort_order == "desc" + else IpStats.total_requests.asc() + ) + elif sort_by == "first_seen": + query = query.order_by( + IpStats.first_seen.desc() + if sort_order == "desc" + else IpStats.first_seen.asc() + ) + elif sort_by == "last_seen": + query = query.order_by( + IpStats.last_seen.desc() + if sort_order == "desc" + else IpStats.last_seen.asc() + ) + + # Get paginated IPs + ips = query.offset(offset).limit(page_size).all() + + total_pages = (total_ips + page_size - 1) // page_size + + return { + "ips": [ + { + "ip": ip.ip, + "total_requests": ip.total_requests, + "first_seen": ( + ip.first_seen.isoformat() if ip.first_seen else None + ), + "last_seen": ip.last_seen.isoformat() if ip.last_seen else None, + "country_code": ip.country_code, + "city": ip.city, + "asn": ip.asn, + "asn_org": ip.asn_org, + "reputation_score": ip.reputation_score, + "reputation_source": ip.reputation_source, + "category": ip.category, + "category_scores": ip.category_scores or {}, + } + for ip in ips + ], + "pagination": { + "page": page, + "page_size": page_size, + "total": total_ips, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + def get_dashboard_counts(self) -> Dict[str, int]: """ Get aggregate statistics for the dashboard (excludes local/private IPs and server IP). @@ -728,28 +839,34 @@ class DatabaseManager: try: # Get server IP to filter it out from config import get_config + config = get_config() server_ip = config.get_server_ip() - + # Get all accesses first, then filter out local IPs and server IP all_accesses = session.query(AccessLog).all() - + # Filter out local/private IPs and server IP public_accesses = [ - log for log in all_accesses - if is_valid_public_ip(log.ip, server_ip) + log for log in all_accesses if is_valid_public_ip(log.ip, server_ip) ] - + # Calculate counts from filtered data total_accesses = len(public_accesses) unique_ips = len(set(log.ip for log in public_accesses)) unique_paths = len(set(log.path for log in public_accesses)) suspicious_accesses = sum(1 for log in public_accesses if log.is_suspicious) - honeypot_triggered = sum(1 for log in public_accesses if log.is_honeypot_trigger) - honeypot_ips = len(set(log.ip for log in public_accesses if log.is_honeypot_trigger)) - + honeypot_triggered = sum( + 1 for log in public_accesses if log.is_honeypot_trigger + ) + honeypot_ips = len( + set(log.ip for log in public_accesses if log.is_honeypot_trigger) + ) + # Count unique attackers from IpStats (matching the "Attackers by Total Requests" table) - unique_attackers = session.query(IpStats).filter(IpStats.category == "attacker").count() + unique_attackers = ( + session.query(IpStats).filter(IpStats.category == "attacker").count() + ) return { "total_accesses": total_accesses, @@ -777,9 +894,10 @@ class DatabaseManager: try: # Get server IP to filter it out from config import get_config + config = get_config() server_ip = config.get_server_ip() - + results = ( session.query(AccessLog.ip, func.count(AccessLog.id).label("count")) .group_by(AccessLog.ip) @@ -862,9 +980,10 @@ class DatabaseManager: try: # Get server IP to filter it out from config import get_config + config = get_config() server_ip = config.get_server_ip() - + logs = ( session.query(AccessLog) .filter(AccessLog.is_suspicious == True) @@ -874,8 +993,7 @@ class DatabaseManager: # Filter out local/private IPs and server IP filtered_logs = [ - log for log in logs - if is_valid_public_ip(log.ip, server_ip) + log for log in logs if is_valid_public_ip(log.ip, server_ip) ] return [ @@ -902,9 +1020,10 @@ class DatabaseManager: try: # Get server IP to filter it out from config import get_config + config = get_config() server_ip = config.get_server_ip() - + # Get all honeypot triggers grouped by IP results = ( session.query(AccessLog.ip, AccessLog.path) @@ -961,7 +1080,13 @@ class DatabaseManager: finally: self.close_session() - def get_honeypot_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + def get_honeypot_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "count", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of honeypot-triggered IPs with their paths. @@ -977,6 +1102,7 @@ class DatabaseManager: session = self.session try: from config import get_config + config = get_config() server_ip = config.get_server_ip() @@ -1007,17 +1133,15 @@ class DatabaseManager: if sort_by == "count": honeypot_list.sort( - key=lambda x: x["count"], - reverse=(sort_order == "desc") + key=lambda x: x["count"], reverse=(sort_order == "desc") ) else: # sort by ip honeypot_list.sort( - key=lambda x: x["ip"], - reverse=(sort_order == "desc") + key=lambda x: x["ip"], reverse=(sort_order == "desc") ) total_honeypots = len(honeypot_list) - paginated = honeypot_list[offset:offset + page_size] + paginated = honeypot_list[offset : offset + page_size] total_pages = (total_honeypots + page_size - 1) // page_size return { @@ -1032,7 +1156,13 @@ class DatabaseManager: finally: self.close_session() - def get_credentials_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "timestamp", sort_order: str = "desc") -> Dict[str, Any]: + def get_credentials_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "timestamp", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of credential attempts. @@ -1052,7 +1182,9 @@ class DatabaseManager: # Validate sort parameters valid_sort_fields = {"timestamp", "ip", "username"} sort_by = sort_by if sort_by in valid_sort_fields else "timestamp" - sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + sort_order = ( + sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + ) total_credentials = session.query(CredentialAttempt).count() @@ -1061,15 +1193,21 @@ class DatabaseManager: if sort_by == "timestamp": query = query.order_by( - CredentialAttempt.timestamp.desc() if sort_order == "desc" else CredentialAttempt.timestamp.asc() + CredentialAttempt.timestamp.desc() + if sort_order == "desc" + else CredentialAttempt.timestamp.asc() ) elif sort_by == "ip": query = query.order_by( - CredentialAttempt.ip.desc() if sort_order == "desc" else CredentialAttempt.ip.asc() + CredentialAttempt.ip.desc() + if sort_order == "desc" + else CredentialAttempt.ip.asc() ) elif sort_by == "username": query = query.order_by( - CredentialAttempt.username.desc() if sort_order == "desc" else CredentialAttempt.username.asc() + CredentialAttempt.username.desc() + if sort_order == "desc" + else CredentialAttempt.username.asc() ) credentials = query.offset(offset).limit(page_size).all() @@ -1096,7 +1234,13 @@ class DatabaseManager: finally: self.close_session() - def get_top_ips_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + def get_top_ips_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "count", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of top IP addresses by access count. @@ -1112,6 +1256,7 @@ class DatabaseManager: session = self.session try: from config import get_config + config = get_config() server_ip = config.get_server_ip() @@ -1136,7 +1281,7 @@ class DatabaseManager: filtered.sort(key=lambda x: x["ip"], reverse=(sort_order == "desc")) total_ips = len(filtered) - paginated = filtered[offset:offset + page_size] + paginated = filtered[offset : offset + page_size] total_pages = (total_ips + page_size - 1) // page_size return { @@ -1151,7 +1296,13 @@ class DatabaseManager: finally: self.close_session() - def get_top_paths_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + def get_top_paths_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "count", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of top paths by access count. @@ -1175,18 +1326,17 @@ class DatabaseManager: ) # Create list and sort - paths_list = [ - {"path": row.path, "count": row.count} - for row in results - ] + paths_list = [{"path": row.path, "count": row.count} for row in results] if sort_by == "count": - paths_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) + paths_list.sort( + key=lambda x: x["count"], reverse=(sort_order == "desc") + ) else: # sort by path paths_list.sort(key=lambda x: x["path"], reverse=(sort_order == "desc")) total_paths = len(paths_list) - paginated = paths_list[offset:offset + page_size] + paginated = paths_list[offset : offset + page_size] total_pages = (total_paths + page_size - 1) // page_size return { @@ -1201,7 +1351,13 @@ class DatabaseManager: finally: self.close_session() - def get_top_user_agents_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "count", sort_order: str = "desc") -> Dict[str, Any]: + def get_top_user_agents_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "count", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of top user agents by access count. @@ -1219,7 +1375,9 @@ class DatabaseManager: offset = (page - 1) * page_size results = ( - session.query(AccessLog.user_agent, func.count(AccessLog.id).label("count")) + session.query( + AccessLog.user_agent, func.count(AccessLog.id).label("count") + ) .filter(AccessLog.user_agent.isnot(None), AccessLog.user_agent != "") .group_by(AccessLog.user_agent) .all() @@ -1227,17 +1385,18 @@ class DatabaseManager: # Create list and sort ua_list = [ - {"user_agent": row.user_agent, "count": row.count} - for row in results + {"user_agent": row.user_agent, "count": row.count} for row in results ] if sort_by == "count": ua_list.sort(key=lambda x: x["count"], reverse=(sort_order == "desc")) else: # sort by user_agent - ua_list.sort(key=lambda x: x["user_agent"], reverse=(sort_order == "desc")) + ua_list.sort( + key=lambda x: x["user_agent"], reverse=(sort_order == "desc") + ) total_uas = len(ua_list) - paginated = ua_list[offset:offset + page_size] + paginated = ua_list[offset : offset + page_size] total_pages = (total_uas + page_size - 1) // page_size return { @@ -1252,7 +1411,13 @@ class DatabaseManager: finally: self.close_session() - def get_attack_types_paginated(self, page: int = 1, page_size: int = 5, sort_by: str = "timestamp", sort_order: str = "desc") -> Dict[str, Any]: + def get_attack_types_paginated( + self, + page: int = 1, + page_size: int = 5, + sort_by: str = "timestamp", + sort_order: str = "desc", + ) -> Dict[str, Any]: """ Retrieve paginated list of detected attack types with access logs. @@ -1272,17 +1437,18 @@ class DatabaseManager: # Validate sort parameters valid_sort_fields = {"timestamp", "ip", "attack_type"} sort_by = sort_by if sort_by in valid_sort_fields else "timestamp" - sort_order = sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + sort_order = ( + sort_order.lower() if sort_order.lower() in {"asc", "desc"} else "desc" + ) # Get all access logs with attack detections - query = ( - session.query(AccessLog) - .join(AttackDetection) - ) + query = session.query(AccessLog).join(AttackDetection) if sort_by == "timestamp": query = query.order_by( - AccessLog.timestamp.desc() if sort_order == "desc" else AccessLog.timestamp.asc() + AccessLog.timestamp.desc() + if sort_order == "desc" + else AccessLog.timestamp.asc() ) elif sort_by == "ip": query = query.order_by( @@ -1307,11 +1473,11 @@ class DatabaseManager: if sort_by == "attack_type": attack_list.sort( key=lambda x: x["attack_types"][0] if x["attack_types"] else "", - reverse=(sort_order == "desc") + reverse=(sort_order == "desc"), ) total_attacks = len(attack_list) - paginated = attack_list[offset:offset + page_size] + paginated = attack_list[offset : offset + page_size] total_pages = (total_attacks + page_size - 1) // page_size return { diff --git a/src/handler.py b/src/handler.py index df04465..b3c76e7 100644 --- a/src/handler.py +++ b/src/handler.py @@ -511,7 +511,10 @@ class Handler(BaseHTTPRequestHandler): return # API endpoint for fetching all IP statistics - if self.config.dashboard_secret_path and self.path == f"{self.config.dashboard_secret_path}/api/all-ip-stats": + if ( + self.config.dashboard_secret_path + and self.path == f"{self.config.dashboard_secret_path}/api/all-ip-stats" + ): self.send_response(200) self.send_header("Content-type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") @@ -554,7 +557,7 @@ class Handler(BaseHTTPRequestHandler): from urllib.parse import urlparse, parse_qs db = get_database() - + # Parse query parameters parsed_url = urlparse(self.path) query_params = parse_qs(parsed_url.query) @@ -567,7 +570,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) # Max 100 per page - result = db.get_attackers_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_attackers_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -576,6 +584,52 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(json.dumps({"error": str(e)}).encode()) return + # API endpoint for fetching all IPs (all categories) + if self.config.dashboard_secret_path and self.path.startswith( + f"{self.config.dashboard_secret_path}/api/all-ips" + ): + self.send_response(200) + self.send_header("Content-type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header( + "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0" + ) + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + try: + from database import get_database + import json + from urllib.parse import urlparse, parse_qs + + db = get_database() + + # Parse query parameters + parsed_url = urlparse(self.path) + query_params = parse_qs(parsed_url.query) + page = int(query_params.get("page", ["1"])[0]) + page_size = int(query_params.get("page_size", ["25"])[0]) + sort_by = query_params.get("sort_by", ["total_requests"])[0] + sort_order = query_params.get("sort_order", ["desc"])[0] + + # Ensure valid parameters + page = max(1, page) + page_size = min(max(1, page_size), 100) # Max 100 per page + + result = db.get_all_ips_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) + self.wfile.write(json.dumps(result).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching all IPs: {e}") + self.wfile.write(json.dumps({"error": str(e)}).encode()) + return + # API endpoint for fetching IP stats if self.config.dashboard_secret_path and self.path.startswith( f"{self.config.dashboard_secret_path}/api/ip-stats/" @@ -639,7 +693,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_honeypot_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_honeypot_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -677,7 +736,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_credentials_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_credentials_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -715,7 +779,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_top_ips_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_top_ips_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -753,7 +822,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_top_paths_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_top_paths_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -791,7 +865,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_top_user_agents_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_top_user_agents_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass @@ -829,7 +908,12 @@ class Handler(BaseHTTPRequestHandler): page = max(1, page) page_size = min(max(1, page_size), 100) - result = db.get_attack_types_paginated(page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) + result = db.get_attack_types_paginated( + page=page, + page_size=page_size, + sort_by=sort_by, + sort_order=sort_order, + ) self.wfile.write(json.dumps(result).encode()) except BrokenPipeError: pass diff --git a/src/ip_utils.py b/src/ip_utils.py index 35504c8..1eab6b8 100644 --- a/src/ip_utils.py +++ b/src/ip_utils.py @@ -12,7 +12,7 @@ from typing import Optional def is_local_or_private_ip(ip_str: str) -> bool: """ Check if an IP address is local, private, or reserved. - + Filters out: - 127.0.0.1 (localhost) - 127.0.0.0/8 (loopback) @@ -22,10 +22,10 @@ def is_local_or_private_ip(ip_str: str) -> bool: - 0.0.0.0/8 (this network) - ::1 (IPv6 localhost) - ::ffff:127.0.0.0/104 (IPv6-mapped IPv4 loopback) - + Args: ip_str: IP address string - + Returns: True if IP is local/private/reserved, False if it's public """ @@ -46,15 +46,15 @@ def is_local_or_private_ip(ip_str: str) -> bool: def is_valid_public_ip(ip: str, server_ip: Optional[str] = None) -> bool: """ Check if an IP is public and not the server's own IP. - + Returns True only if: - IP is not in local/private ranges AND - IP is not the server's own public IP (if server_ip provided) - + Args: ip: IP address string to check server_ip: Server's public IP (optional). If provided, filters out this IP too. - + Returns: True if IP is a valid public IP to track, False otherwise """ diff --git a/src/tasks/memory_cleanup.py b/src/tasks/memory_cleanup.py index ba1ace5..38a27a2 100644 --- a/src/tasks/memory_cleanup.py +++ b/src/tasks/memory_cleanup.py @@ -45,8 +45,13 @@ def main(): stats_after = Handler.tracker.get_memory_stats() # Log changes - access_log_reduced = stats_before["access_log_size"] - stats_after["access_log_size"] - cred_reduced = stats_before["credential_attempts_size"] - stats_after["credential_attempts_size"] + access_log_reduced = ( + stats_before["access_log_size"] - stats_after["access_log_size"] + ) + cred_reduced = ( + stats_before["credential_attempts_size"] + - stats_after["credential_attempts_size"] + ) if access_log_reduced > 0 or cred_reduced > 0: app_logger.info( diff --git a/src/tasks/top_attacking_ips.py b/src/tasks/top_attacking_ips.py index 1648c93..73a135c 100644 --- a/src/tasks/top_attacking_ips.py +++ b/src/tasks/top_attacking_ips.py @@ -71,11 +71,8 @@ def main(): # Filter out local/private IPs and the server's own IP config = get_config() server_ip = config.get_server_ip() - - public_ips = [ - ip for (ip,) in results - if is_valid_public_ip(ip, server_ip) - ] + + public_ips = [ip for (ip,) in results if is_valid_public_ip(ip, server_ip)] # Ensure exports directory exists os.makedirs(EXPORTS_DIR, exist_ok=True) diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 8babb4d..3ef693f 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -548,10 +548,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: background: #161b22; border-top: 6px solid #30363d; }} - .attacker-marker {{ - width: 20px; - height: 20px; - background: #f85149; + .ip-marker {{ border: 2px solid #fff; border-radius: 50%; display: flex; @@ -560,20 +557,27 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: font-size: 10px; font-weight: bold; color: white; - box-shadow: 0 0 8px rgba(248, 81, 73, 0.8), inset 0 0 4px rgba(248, 81, 73, 0.5); cursor: pointer; }} - .attacker-marker-cluster {{ - background: #f85149 !important; - border: 2px solid #fff !important; - background-clip: padding-box !important; + .marker-attacker {{ + background: #f85149; + box-shadow: 0 0 8px rgba(248, 81, 73, 0.8), inset 0 0 4px rgba(248, 81, 73, 0.5); }} - .attacker-marker-cluster div {{ - background: #f85149 !important; + .marker-bad_crawler {{ + background: #f0883e; + box-shadow: 0 0 8px rgba(240, 136, 62, 0.8), inset 0 0 4px rgba(240, 136, 62, 0.5); }} - .attacker-marker-cluster span {{ - color: white !important; - font-weight: bold !important; + .marker-good_crawler {{ + background: #3fb950; + box-shadow: 0 0 8px rgba(63, 185, 80, 0.8), inset 0 0 4px rgba(63, 185, 80, 0.5); + }} + .marker-regular_user {{ + background: #58a6ff; + box-shadow: 0 0 8px rgba(88, 166, 255, 0.8), inset 0 0 4px rgba(88, 166, 255, 0.5); + }} + .marker-unknown {{ + background: #8b949e; + box-shadow: 0 0 8px rgba(139, 148, 158, 0.8), inset 0 0 4px rgba(139, 148, 158, 0.5); }} .leaflet-bottom.leaflet-right {{ display: none !important; @@ -734,7 +738,31 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:

-

Attacker Origins Map

+
+

IP Origins Map

+
+ + + + + +
+
Loading map...
@@ -1862,9 +1890,20 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: `; document.head.appendChild(style); - // Attacker Map Visualization + // IP Map Visualization let attackerMap = null; + let allIps = []; let mapMarkers = []; + let markerLayers = {{}}; + let circleLayers = {{}}; + + const categoryColors = {{ + attacker: '#f85149', + bad_crawler: '#f0883e', + good_crawler: '#3fb950', + regular_user: '#58a6ff', + unknown: '#8b949e' + }}; async function initializeAttackerMap() {{ const mapContainer = document.getElementById('attacker-map'); @@ -1884,8 +1923,8 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: ] }}); - // Fetch all attackers - const response = await fetch(DASHBOARD_PATH + '/api/attackers?page=1&page_size=100&sort_by=total_requests&sort_order=desc', {{ + // Fetch all IPs (not just attackers) + const response = await fetch(DASHBOARD_PATH + '/api/all-ips?page=1&page_size=100&sort_by=total_requests&sort_order=desc', {{ cache: 'no-store', headers: {{ 'Cache-Control': 'no-cache', @@ -1893,18 +1932,18 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }} }}); - if (!response.ok) throw new Error('Failed to fetch attackers'); - - const data = await response.json(); - const attackers = data.attackers || []; + if (!response.ok) throw new Error('Failed to fetch IPs'); - if (attackers.length === 0) {{ - mapContainer.innerHTML = '
No attacker location data available
'; + const data = await response.json(); + allIps = data.ips || []; + + if (allIps.length === 0) {{ + mapContainer.innerHTML = '
No IP location data available
'; return; }} // Get max request count for scaling - const maxRequests = Math.max(...attackers.map(a => a.total_requests || 0)); + const maxRequests = Math.max(...allIps.map(ip => ip.total_requests || 0)); // Create a map of country locations (approximate country centers) const countryCoordinates = {{ @@ -1922,22 +1961,40 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2] }}; - // Add markers for each attacker - const markerGroup = L.featureGroup(); + // Create layer groups for each category + markerLayers = {{ + attacker: L.featureGroup(), + bad_crawler: L.featureGroup(), + good_crawler: L.featureGroup(), + regular_user: L.featureGroup(), + unknown: L.featureGroup() + }}; - attackers.slice(0, 50).forEach(attacker => {{ - if (!attacker.country_code) return; + circleLayers = {{ + attacker: L.featureGroup(), + bad_crawler: L.featureGroup(), + good_crawler: L.featureGroup(), + regular_user: L.featureGroup(), + unknown: L.featureGroup() + }}; - const coords = countryCoordinates[attacker.country_code]; + // Add markers for each IP + allIps.slice(0, 100).forEach(ip => {{ + if (!ip.country_code || !ip.category) return; + + const coords = countryCoordinates[ip.country_code]; if (!coords) return; + const category = ip.category.toLowerCase(); + if (!markerLayers[category]) return; + // Calculate marker size based on request count - const sizeRatio = (attacker.total_requests / maxRequests) * 0.7 + 0.3; + const sizeRatio = (ip.total_requests / maxRequests) * 0.7 + 0.3; const markerSize = Math.max(15, Math.min(40, 20 * sizeRatio)); - // Create custom marker element + // Create custom marker element with category-specific class const markerElement = document.createElement('div'); - markerElement.className = 'attacker-marker'; + markerElement.className = `ip-marker marker-${{category}}`; markerElement.style.width = markerSize + 'px'; markerElement.style.height = markerSize + 'px'; markerElement.style.fontSize = (markerSize * 0.5) + 'px'; @@ -1947,62 +2004,89 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: icon: L.divIcon({{ html: markerElement, iconSize: [markerSize, markerSize], - className: 'attacker-custom-marker' + className: `ip-custom-marker category-${{category}}` }}) }}); - // Create popup content + // Create popup content with category badge + const categoryColor = categoryColors[category] || '#8b949e'; + const categoryLabels = {{ + attacker: 'Attacker', + bad_crawler: 'Bad Crawler', + good_crawler: 'Good Crawler', + regular_user: 'Regular User', + unknown: 'Unknown' + }}; + const popupContent = ` -
- ${{attacker.ip}}
+
+
+ ${{ip.ip}} + + ${{categoryLabels[category]}} + +
- ${{attacker.city || ''}}${{attacker.city && attacker.country_code ? ', ' : ''}}${{attacker.country_code || 'Unknown'}} + ${{ip.city || ''}}${{ip.city && ip.country_code ? ', ' : ''}}${{ip.country_code || 'Unknown'}}
-
Requests: ${{attacker.total_requests}}
-
First Seen: ${{formatTimestamp(attacker.first_seen)}}
-
Last Seen: ${{formatTimestamp(attacker.last_seen)}}
+
Requests: ${{ip.total_requests}}
+
First Seen: ${{formatTimestamp(ip.first_seen)}}
+
Last Seen: ${{formatTimestamp(ip.last_seen)}}
`; marker.bindPopup(popupContent); - markerGroup.addLayer(marker); - mapMarkers.push(marker); + markerLayers[category].addLayer(marker); }}); - // Add cluster circle effect - const circleGroup = L.featureGroup(); - const countryAttackerCount = {{}}; - - attackers.forEach(attacker => {{ - if (attacker.country_code) {{ - countryAttackerCount[attacker.country_code] = (countryAttackerCount[attacker.country_code] || 0) + 1; + // Add cluster circles for each category + const categoryCountryCounts = {{}}; + + allIps.forEach(ip => {{ + if (ip.country_code && ip.category) {{ + const category = ip.category.toLowerCase(); + if (!categoryCountryCounts[category]) {{ + categoryCountryCounts[category] = {{}}; + }} + categoryCountryCounts[category][ip.country_code] = + (categoryCountryCounts[category][ip.country_code] || 0) + 1; }} }}); - Object.entries(countryAttackerCount).forEach(([country, count]) => {{ - const coords = countryCoordinates[country]; - if (coords) {{ - const circle = L.circle(coords, {{ - radius: 100000 + (count * 150000), - color: '#f85149', - fillColor: '#f85149', - fillOpacity: 0.15, - weight: 1, - opacity: 0.4, - dashArray: '3' - }}); - circleGroup.addLayer(circle); - }} + Object.entries(categoryCountryCounts).forEach(([category, countryCounts]) => {{ + Object.entries(countryCounts).forEach(([country, count]) => {{ + const coords = countryCoordinates[country]; + if (coords && circleLayers[category]) {{ + const color = categoryColors[category] || '#8b949e'; + const circle = L.circle(coords, {{ + radius: 100000 + (count * 150000), + color: color, + fillColor: color, + fillOpacity: 0.15, + weight: 1, + opacity: 0.4, + dashArray: '3' + }}); + circleLayers[category].addLayer(circle); + }} + }}); }}); - attackerMap.addLayer(circleGroup); - markerGroup.addTo(attackerMap); - - // Fit map to markers - if (markerGroup.getLayers().length > 0) {{ - attackerMap.fitBounds(markerGroup.getBounds(), {{ padding: [50, 50] }}); + // Add all layers to map initially + Object.values(circleLayers).forEach(layer => attackerMap.addLayer(layer)); + Object.values(markerLayers).forEach(layer => attackerMap.addLayer(layer)); + + // Fit map to all markers + const allMarkers = Object.values(markerLayers).reduce((acc, layer) => {{ + acc.push(...layer.getLayers()); + return acc; + }}, []); + + if (allMarkers.length > 0) {{ + const bounds = L.featureGroup(allMarkers).getBounds(); + attackerMap.fitBounds(bounds, {{ padding: [50, 50] }}); }} }} catch (err) {{ @@ -2011,6 +2095,46 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }} }} + // Update map filters based on checkbox selection + function updateMapFilters() {{ + if (!attackerMap) return; + + const filters = {{ + attacker: document.getElementById('filter-attacker').checked, + bad_crawler: document.getElementById('filter-bad-crawler').checked, + good_crawler: document.getElementById('filter-good-crawler').checked, + regular_user: document.getElementById('filter-regular-user').checked, + unknown: document.getElementById('filter-unknown').checked + }}; + + // Update marker and circle layers visibility + Object.entries(filters).forEach(([category, show]) => {{ + if (markerLayers[category]) {{ + if (show) {{ + if (!attackerMap.hasLayer(markerLayers[category])) {{ + attackerMap.addLayer(markerLayers[category]); + }} + }} else {{ + if (attackerMap.hasLayer(markerLayers[category])) {{ + attackerMap.removeLayer(markerLayers[category]); + }} + }} + }} + + if (circleLayers[category]) {{ + if (show) {{ + if (!attackerMap.hasLayer(circleLayers[category])) {{ + attackerMap.addLayer(circleLayers[category]); + }} + }} else {{ + if (attackerMap.hasLayer(circleLayers[category])) {{ + attackerMap.removeLayer(circleLayers[category]); + }} + }} + }} + }}); + }} + // Initialize map when Attacks tab is opened const originalSwitchTab = window.switchTab; let attackTypesChartLoaded = false; diff --git a/src/tracker.py b/src/tracker.py index 0706e82..60e05f0 100644 --- a/src/tracker.py +++ b/src/tracker.py @@ -173,6 +173,7 @@ class AccessTracker: """ # Skip if this is the server's own IP from config import get_config + config = get_config() server_ip = config.get_server_ip() if server_ip and ip == server_ip: @@ -228,6 +229,7 @@ class AccessTracker: """ # Skip if this is the server's own IP from config import get_config + config = get_config() server_ip = config.get_server_ip() if server_ip and ip == server_ip: @@ -397,6 +399,7 @@ class AccessTracker: """ # Skip if this is the server's own IP from config import get_config + config = get_config() server_ip = config.get_server_ip() if server_ip and client_ip == server_ip: @@ -429,7 +432,9 @@ class AccessTracker: self.ip_page_visits[client_ip]["ban_multiplier"] = 2 ** (violations - 1) # Set ban timestamp - self.ip_page_visits[client_ip]["ban_timestamp"] = datetime.now().isoformat() + self.ip_page_visits[client_ip][ + "ban_timestamp" + ] = datetime.now().isoformat() return self.ip_page_visits[client_ip]["count"] @@ -572,7 +577,8 @@ class AccessTracker: suspicious = [ log for log in self.access_log - if log.get("suspicious", False) and not is_local_or_private_ip(log.get("ip", "")) + if log.get("suspicious", False) + and not is_local_or_private_ip(log.get("ip", "")) ] return suspicious[-limit:] @@ -624,7 +630,7 @@ class AccessTracker: """ # Trim access_log to max size (keep most recent) if len(self.access_log) > self.max_access_log_size: - self.access_log = self.access_log[-self.max_access_log_size:] + self.access_log = self.access_log[-self.max_access_log_size :] # Trim credential_attempts to max size (keep most recent) if len(self.credential_attempts) > self.max_credential_log_size: From 5aca684df96bd2cf65d7afe097a9c2e2082308d8 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:56:34 +0100 Subject: [PATCH 65/70] Feat/attack map improvement (#57) * feat: enhance IP reputation management with city data and geolocation integration * feat: enhance dashboard with city coordinates and improved marker handling * feat: update chart version to 0.2.1 in Chart.yaml, README.md, and values.yaml * feat: update logo format and size in README.md * feat: improve location display logic in dashboard for attackers and IPs --- README.md | 6 +- helm/Chart.yaml | 4 +- helm/README.md | 4 +- helm/values.yaml | 2 +- src/database.py | 4 + src/tasks/fetch_ip_rep.py | 10 +- src/templates/dashboard_template.py | 196 ++++++++++++------ tests/test_insert_fake_ips.py | 297 ++++++++++++++++++++++++++-- 8 files changed, 428 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 8b5011c..6f58646 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -

🕷️ Krawl

+

Krawl

diff --git a/helm/Chart.yaml b/helm/Chart.yaml index b5bc6f2..b2b4cc3 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 0.2.0 -appVersion: 0.2.0 +version: 0.2.1 +appVersion: 0.2.1 keywords: - honeypot - security diff --git a/helm/README.md b/helm/README.md index 0882c3d..5e10f9c 100644 --- a/helm/README.md +++ b/helm/README.md @@ -20,13 +20,13 @@ helm repo update ### Install from OCI Registry ```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.1.5-dev +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.2.1 ``` Or with a specific namespace: ```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.1.5-dev -n krawl --create-namespace +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart --version 0.2.1 -n krawl --create-namespace ``` ### Install the chart locally diff --git a/helm/values.yaml b/helm/values.yaml index 35add96..6d79b25 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -3,7 +3,7 @@ replicaCount: 1 image: repository: ghcr.io/blessedrebus/krawl pullPolicy: Always - tag: "latest" + tag: "0.2.1" imagePullSecrets: [] nameOverride: "krawl" diff --git a/src/database.py b/src/database.py index b88497e..da8d579 100644 --- a/src/database.py +++ b/src/database.py @@ -398,6 +398,7 @@ class DatabaseManager: asn: str, asn_org: str, list_on: Dict[str, str], + city: Optional[str] = None, ) -> None: """ Update IP rep stats @@ -408,6 +409,7 @@ class DatabaseManager: asn: IP address ASN asn_org: IP address ASN ORG list_on: public lists containing the IP address + city: City name (optional) """ session = self.session @@ -419,6 +421,8 @@ class DatabaseManager: ip_stats.asn = asn ip_stats.asn_org = asn_org ip_stats.list_on = list_on + if city: + ip_stats.city = city session.commit() except Exception as e: session.rollback() diff --git a/src/tasks/fetch_ip_rep.py b/src/tasks/fetch_ip_rep.py index 577133a..be74553 100644 --- a/src/tasks/fetch_ip_rep.py +++ b/src/tasks/fetch_ip_rep.py @@ -34,14 +34,17 @@ def main(): if payload.get("results"): data = payload["results"][0] - country_iso_code = data["geoip_data"]["country_iso_code"] - asn = data["geoip_data"]["asn_autonomous_system_number"] - asn_org = data["geoip_data"]["asn_autonomous_system_organization"] + geoip_data = data["geoip_data"] + country_iso_code = geoip_data.get("country_iso_code") + asn = geoip_data.get("asn_autonomous_system_number") + asn_org = geoip_data.get("asn_autonomous_system_organization") + city = geoip_data.get("city_name") # Extract city name from API list_on = data["list_on"] sanitized_country_iso_code = sanitize_for_storage(country_iso_code, 3) sanitized_asn = sanitize_for_storage(asn, 100) sanitized_asn_org = sanitize_for_storage(asn_org, 100) + sanitized_city = sanitize_for_storage(city, 100) if city else None sanitized_list_on = sanitize_dict(list_on, 100000) db_manager.update_ip_rep_infos( @@ -50,6 +53,7 @@ def main(): sanitized_asn, sanitized_asn_org, sanitized_list_on, + sanitized_city, # Pass city to database ) except requests.RequestException as e: app_logger.warning(f"Failed to fetch IP rep for {ip}: {e}") diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index 3ef693f..667de3d 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -548,6 +548,11 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: background: #161b22; border-top: 6px solid #30363d; }} + /* Remove the default leaflet icon background */ + .ip-custom-marker {{ + background: none !important; + border: none !important; + }} .ip-marker {{ border: 2px solid #fff; border-radius: 50%; @@ -558,27 +563,46 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: font-weight: bold; color: white; cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + }} + .ip-marker:hover {{ + transform: scale(1.15); }} .marker-attacker {{ background: #f85149; box-shadow: 0 0 8px rgba(248, 81, 73, 0.8), inset 0 0 4px rgba(248, 81, 73, 0.5); }} + .marker-attacker:hover {{ + box-shadow: 0 0 15px rgba(248, 81, 73, 1), inset 0 0 6px rgba(248, 81, 73, 0.7); + }} .marker-bad_crawler {{ background: #f0883e; box-shadow: 0 0 8px rgba(240, 136, 62, 0.8), inset 0 0 4px rgba(240, 136, 62, 0.5); }} + .marker-bad_crawler:hover {{ + box-shadow: 0 0 15px rgba(240, 136, 62, 1), inset 0 0 6px rgba(240, 136, 62, 0.7); + }} .marker-good_crawler {{ background: #3fb950; box-shadow: 0 0 8px rgba(63, 185, 80, 0.8), inset 0 0 4px rgba(63, 185, 80, 0.5); }} + .marker-good_crawler:hover {{ + box-shadow: 0 0 15px rgba(63, 185, 80, 1), inset 0 0 6px rgba(63, 185, 80, 0.7); + }} .marker-regular_user {{ background: #58a6ff; box-shadow: 0 0 8px rgba(88, 166, 255, 0.8), inset 0 0 4px rgba(88, 166, 255, 0.5); }} + .marker-regular_user:hover {{ + box-shadow: 0 0 15px rgba(88, 166, 255, 1), inset 0 0 6px rgba(88, 166, 255, 0.7); + }} .marker-unknown {{ background: #8b949e; box-shadow: 0 0 8px rgba(139, 148, 158, 0.8), inset 0 0 4px rgba(139, 148, 158, 0.5); }} + .marker-unknown:hover {{ + box-shadow: 0 0 15px rgba(139, 148, 158, 1), inset 0 0 6px rgba(139, 148, 158, 0.7); + }} .leaflet-bottom.leaflet-right {{ display: none !important; }} @@ -1011,7 +1035,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: if (stats.country_code || stats.city) {{ html += '
'; html += 'Location:'; - html += `${{stats.city || ''}}${{stats.city && stats.country_code ? ', ' : ''}}${{stats.country_code || 'Unknown'}}`; + html += `${{stats.city ? (stats.country_code ? `${{stats.city}}, ${{stats.country_code}}` : stats.city) : (stats.country_code || 'Unknown')}}`; html += '
'; }} @@ -1345,7 +1369,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: ${{attacker.total_requests}} ${{formatTimestamp(attacker.first_seen)}} ${{formatTimestamp(attacker.last_seen)}} - ${{attacker.city || 'Unknown'}}${{attacker.city && attacker.country_code ? ', ' : ''}}${{attacker.country_code || ''}} + ${{attacker.city ? (attacker.country_code ? `${{attacker.city}}, ${{attacker.country_code}}` : attacker.city) : (attacker.country_code || 'Unknown')}} @@ -1895,7 +1919,6 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: let allIps = []; let mapMarkers = []; let markerLayers = {{}}; - let circleLayers = {{}}; const categoryColors = {{ attacker: '#f85149', @@ -1945,7 +1968,60 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: // Get max request count for scaling const maxRequests = Math.max(...allIps.map(ip => ip.total_requests || 0)); - // Create a map of country locations (approximate country centers) + // City coordinates database (major cities worldwide) + const cityCoordinates = {{ + // United States + 'New York': [40.7128, -74.0060], 'Los Angeles': [34.0522, -118.2437], + 'San Francisco': [37.7749, -122.4194], 'Chicago': [41.8781, -87.6298], + 'Seattle': [47.6062, -122.3321], 'Miami': [25.7617, -80.1918], + 'Boston': [42.3601, -71.0589], 'Atlanta': [33.7490, -84.3880], + 'Dallas': [32.7767, -96.7970], 'Houston': [29.7604, -95.3698], + 'Denver': [39.7392, -104.9903], 'Phoenix': [33.4484, -112.0740], + // Europe + 'London': [51.5074, -0.1278], 'Paris': [48.8566, 2.3522], + 'Berlin': [52.5200, 13.4050], 'Amsterdam': [52.3676, 4.9041], + 'Moscow': [55.7558, 37.6173], 'Rome': [41.9028, 12.4964], + 'Madrid': [40.4168, -3.7038], 'Barcelona': [41.3874, 2.1686], + 'Milan': [45.4642, 9.1900], 'Vienna': [48.2082, 16.3738], + 'Stockholm': [59.3293, 18.0686], 'Oslo': [59.9139, 10.7522], + 'Copenhagen': [55.6761, 12.5683], 'Warsaw': [52.2297, 21.0122], + 'Prague': [50.0755, 14.4378], 'Budapest': [47.4979, 19.0402], + 'Athens': [37.9838, 23.7275], 'Lisbon': [38.7223, -9.1393], + 'Brussels': [50.8503, 4.3517], 'Dublin': [53.3498, -6.2603], + 'Zurich': [47.3769, 8.5417], 'Geneva': [46.2044, 6.1432], + 'Helsinki': [60.1699, 24.9384], 'Bucharest': [44.4268, 26.1025], + 'Saint Petersburg': [59.9343, 30.3351], 'Manchester': [53.4808, -2.2426], + 'Roubaix': [50.6942, 3.1746], 'Frankfurt': [50.1109, 8.6821], + 'Munich': [48.1351, 11.5820], 'Hamburg': [53.5511, 9.9937], + // Asia + 'Tokyo': [35.6762, 139.6503], 'Beijing': [39.9042, 116.4074], + 'Shanghai': [31.2304, 121.4737], 'Singapore': [1.3521, 103.8198], + 'Mumbai': [19.0760, 72.8777], 'Delhi': [28.7041, 77.1025], + 'Bangalore': [12.9716, 77.5946], 'Seoul': [37.5665, 126.9780], + 'Hong Kong': [22.3193, 114.1694], 'Bangkok': [13.7563, 100.5018], + 'Jakarta': [6.2088, 106.8456], 'Manila': [14.5995, 120.9842], + 'Hanoi': [21.0285, 105.8542], 'Ho Chi Minh City': [10.8231, 106.6297], + 'Taipei': [25.0330, 121.5654], 'Kuala Lumpur': [3.1390, 101.6869], + 'Karachi': [24.8607, 67.0011], 'Islamabad': [33.6844, 73.0479], + 'Dhaka': [23.8103, 90.4125], 'Colombo': [6.9271, 79.8612], + // South America + 'São Paulo': [-23.5505, -46.6333], 'Rio de Janeiro': [-22.9068, -43.1729], + 'Buenos Aires': [-34.6037, -58.3816], 'Bogotá': [4.7110, -74.0721], + 'Lima': [-12.0464, -77.0428], 'Santiago': [-33.4489, -70.6693], + // Middle East & Africa + 'Cairo': [30.0444, 31.2357], 'Dubai': [25.2048, 55.2708], + 'Istanbul': [41.0082, 28.9784], 'Tel Aviv': [32.0853, 34.7818], + 'Johannesburg': [26.2041, 28.0473], 'Lagos': [6.5244, 3.3792], + 'Nairobi': [-1.2921, 36.8219], 'Cape Town': [-33.9249, 18.4241], + // Australia & Oceania + 'Sydney': [-33.8688, 151.2093], 'Melbourne': [-37.8136, 144.9631], + 'Brisbane': [-27.4698, 153.0251], 'Perth': [-31.9505, 115.8605], + 'Auckland': [-36.8485, 174.7633], + // Additional cities + 'Unknown': null + }}; + + // Country center coordinates (fallback when city not found) const countryCoordinates = {{ 'US': [37.1, -95.7], 'GB': [55.4, -3.4], 'CN': [35.9, 104.1], 'RU': [61.5, 105.3], 'JP': [36.2, 138.3], 'DE': [51.2, 10.5], 'FR': [46.6, 2.2], 'IN': [20.6, 78.96], @@ -1958,9 +2034,51 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: 'TR': [38.9, 35.2], 'IR': [32.4, 53.7], 'AE': [23.4, 53.8], 'KZ': [48.0, 66.9], 'UA': [48.4, 31.2], 'BG': [42.7, 25.5], 'RO': [45.9, 24.97], 'CZ': [49.8, 15.5], 'HU': [47.2, 19.5], 'AT': [47.5, 14.6], 'BE': [50.5, 4.5], 'DK': [56.3, 9.5], - 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2] + 'FI': [61.9, 25.8], 'NO': [60.5, 8.5], 'GR': [39.1, 21.8], 'PT': [39.4, -8.2], + 'AR': [-38.4161, -63.6167], 'CO': [4.5709, -74.2973], 'CL': [-35.6751, -71.5430], + 'PE': [-9.1900, -75.0152], 'VE': [6.4238, -66.5897], 'LS': [40.0, -100.0] }}; + // Helper function to get coordinates for an IP + function getIPCoordinates(ip) {{ + // Try city first + if (ip.city && cityCoordinates[ip.city]) {{ + return cityCoordinates[ip.city]; + }} + // Fall back to country + if (ip.country_code && countryCoordinates[ip.country_code]) {{ + return countryCoordinates[ip.country_code]; + }} + return null; + }} + + // Track used coordinates to add small offsets for overlapping markers + const usedCoordinates = {{}}; + function getUniqueCoordinates(baseCoords) {{ + const key = `${{baseCoords[0].toFixed(4)}},${{baseCoords[1].toFixed(4)}}`; + if (!usedCoordinates[key]) {{ + usedCoordinates[key] = 0; + }} + usedCoordinates[key]++; + + // If this is the first marker at this location, use exact coordinates + if (usedCoordinates[key] === 1) {{ + return baseCoords; + }} + + // Add small random offset for subsequent markers + // Offset increases with each marker to create a spread pattern + const angle = (usedCoordinates[key] * 137.5) % 360; // Golden angle for even distribution + const distance = 0.05 * Math.sqrt(usedCoordinates[key]); // Increase distance with more markers + const latOffset = distance * Math.cos(angle * Math.PI / 180); + const lngOffset = distance * Math.sin(angle * Math.PI / 180); + + return [ + baseCoords[0] + latOffset, + baseCoords[1] + lngOffset + ]; + }} + // Create layer groups for each category markerLayers = {{ attacker: L.featureGroup(), @@ -1970,20 +2088,16 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: unknown: L.featureGroup() }}; - circleLayers = {{ - attacker: L.featureGroup(), - bad_crawler: L.featureGroup(), - good_crawler: L.featureGroup(), - regular_user: L.featureGroup(), - unknown: L.featureGroup() - }}; - // Add markers for each IP allIps.slice(0, 100).forEach(ip => {{ if (!ip.country_code || !ip.category) return; - const coords = countryCoordinates[ip.country_code]; - if (!coords) return; + // Get coordinates (city first, then country) + const baseCoords = getIPCoordinates(ip); + if (!baseCoords) return; + + // Get unique coordinates with offset to prevent overlap + const coords = getUniqueCoordinates(baseCoords); const category = ip.category.toLowerCase(); if (!markerLayers[category]) return; @@ -2002,7 +2116,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: const marker = L.marker(coords, {{ icon: L.divIcon({{ - html: markerElement, + html: markerElement.outerHTML, iconSize: [markerSize, markerSize], className: `ip-custom-marker category-${{category}}` }}) @@ -2027,7 +2141,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str:
- ${{ip.city || ''}}${{ip.city && ip.country_code ? ', ' : ''}}${{ip.country_code || 'Unknown'}} + ${{ip.city ? (ip.country_code ? `${{ip.city}}, ${{ip.country_code}}` : ip.city) : (ip.country_code || 'Unknown')}}
Requests: ${{ip.total_requests}}
@@ -2041,41 +2155,7 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: markerLayers[category].addLayer(marker); }}); - // Add cluster circles for each category - const categoryCountryCounts = {{}}; - - allIps.forEach(ip => {{ - if (ip.country_code && ip.category) {{ - const category = ip.category.toLowerCase(); - if (!categoryCountryCounts[category]) {{ - categoryCountryCounts[category] = {{}}; - }} - categoryCountryCounts[category][ip.country_code] = - (categoryCountryCounts[category][ip.country_code] || 0) + 1; - }} - }}); - - Object.entries(categoryCountryCounts).forEach(([category, countryCounts]) => {{ - Object.entries(countryCounts).forEach(([country, count]) => {{ - const coords = countryCoordinates[country]; - if (coords && circleLayers[category]) {{ - const color = categoryColors[category] || '#8b949e'; - const circle = L.circle(coords, {{ - radius: 100000 + (count * 150000), - color: color, - fillColor: color, - fillOpacity: 0.15, - weight: 1, - opacity: 0.4, - dashArray: '3' - }}); - circleLayers[category].addLayer(circle); - }} - }}); - }}); - - // Add all layers to map initially - Object.values(circleLayers).forEach(layer => attackerMap.addLayer(layer)); + // Add all marker layers to map initially Object.values(markerLayers).forEach(layer => attackerMap.addLayer(layer)); // Fit map to all markers @@ -2120,18 +2200,6 @@ def generate_dashboard(stats: dict, dashboard_path: str = "") -> str: }} }} }} - - if (circleLayers[category]) {{ - if (show) {{ - if (!attackerMap.hasLayer(circleLayers[category])) {{ - attackerMap.addLayer(circleLayers[category]); - }} - }} else {{ - if (attackerMap.hasLayer(circleLayers[category])) {{ - attackerMap.removeLayer(circleLayers[category]); - }} - }} - }} }}); }} diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py index bdde596..bb4a777 100644 --- a/tests/test_insert_fake_ips.py +++ b/tests/test_insert_fake_ips.py @@ -2,8 +2,21 @@ """ Test script to insert fake external IPs into the database for testing the dashboard. -This generates realistic-looking test data including access logs, credential attempts, and attack detections. -Also triggers category behavior changes to demonstrate the timeline feature. +This generates realistic-looking test data including: +- Access logs with various suspicious activities +- Credential attempts +- Attack detections (SQL injection, XSS, etc.) +- Category behavior changes for timeline demonstration +- Real good crawler IPs (Googlebot, Bingbot, etc.) with API-fetched geolocation + +Usage: + python test_insert_fake_ips.py [num_ips] [logs_per_ip] [credentials_per_ip] [--no-cleanup] + +Examples: + python test_insert_fake_ips.py # Generate 20 IPs with defaults, cleanup DB first + python test_insert_fake_ips.py 30 # Generate 30 IPs with defaults + python test_insert_fake_ips.py 30 20 5 # Generate 30 IPs, 20 logs each, 5 credentials each + python test_insert_fake_ips.py --no-cleanup # Generate data without cleaning DB first """ import random @@ -12,6 +25,7 @@ import sys from datetime import datetime, timedelta from zoneinfo import ZoneInfo from pathlib import Path +import requests # Add parent src directory to path so we can import database and logger sys.path.insert(0, str(Path(__file__).parent.parent / "src")) @@ -23,14 +37,81 @@ from logger import get_app_logger # TEST DATA GENERATORS # ---------------------- -FAKE_IPS = [ - "203.0.113.45", # Regular attacker IP - "198.51.100.89", # Credential harvester IP - "192.0.2.120", # Bot IP - "205.32.180.65", # Another attacker - "210.45.67.89", # Suspicious IP - "175.23.45.67", # International IP - "182.91.102.45", # Another suspicious IP +# Fake IPs with geolocation data (country_code, city, ASN org) +# These will appear on the map based on their country_code +FAKE_IPS_WITH_GEO = [ + # United States + ("45.142.120.10", "US", "New York", "AS14061 DigitalOcean"), + ("107.189.10.143", "US", "Los Angeles", "AS20473 Vultr"), + ("162.243.175.23", "US", "San Francisco", "AS14061 DigitalOcean"), + ("198.51.100.89", "US", "Chicago", "AS16509 Amazon"), + + # Europe + ("185.220.101.45", "DE", "Berlin", "AS24940 Hetzner"), + ("195.154.133.20", "FR", "Paris", "AS12876 Scaleway"), + ("178.128.83.165", "GB", "London", "AS14061 DigitalOcean"), + ("87.251.67.90", "NL", "Amsterdam", "AS49453 GlobalConnect"), + ("91.203.5.165", "RU", "Moscow", "AS51115 HLL LLC"), + ("46.105.57.169", "FR", "Roubaix", "AS16276 OVH"), + ("217.182.143.207", "RU", "Saint Petersburg", "AS51570 JSC ER-Telecom"), + ("188.166.123.45", "GB", "Manchester", "AS14061 DigitalOcean"), + + # Asia + ("103.253.145.36", "CN", "Beijing", "AS4134 Chinanet"), + ("42.112.28.216", "CN", "Shanghai", "AS4134 Chinanet"), + ("118.163.74.160", "JP", "Tokyo", "AS2516 KDDI"), + ("43.229.53.35", "SG", "Singapore", "AS23969 TOT"), + ("115.78.208.140", "IN", "Mumbai", "AS9829 BSNL"), + ("14.139.56.18", "IN", "Bangalore", "AS4755 TATA"), + ("61.19.25.207", "TW", "Taipei", "AS3462 HiNet"), + ("121.126.219.198", "KR", "Seoul", "AS4766 Korea Telecom"), + ("202.134.4.212", "ID", "Jakarta", "AS7597 TELKOMNET"), + ("171.244.140.134", "VN", "Hanoi", "AS7552 Viettel"), + + # South America + ("177.87.169.20", "BR", "São Paulo", "AS28573 Claro"), + ("200.21.19.58", "BR", "Rio de Janeiro", "AS7738 Telemar"), + ("181.13.140.98", "AR", "Buenos Aires", "AS7303 Telecom Argentina"), + ("190.150.24.34", "CO", "Bogotá", "AS3816 Colombia Telecomunicaciones"), + + # Middle East & Africa + ("41.223.53.141", "EG", "Cairo", "AS8452 TE-Data"), + ("196.207.35.152", "ZA", "Johannesburg", "AS37271 Workonline"), + ("5.188.62.214", "TR", "Istanbul", "AS51115 HLL LLC"), + ("37.48.93.125", "AE", "Dubai", "AS5384 Emirates Telecom"), + ("102.66.137.29", "NG", "Lagos", "AS29465 MTN Nigeria"), + + # Australia & Oceania + ("103.28.248.110", "AU", "Sydney", "AS4739 Internode"), + ("202.168.45.33", "AU", "Melbourne", "AS1221 Telstra"), + + # Additional European IPs + ("94.102.49.190", "PL", "Warsaw", "AS12912 T-Mobile"), + ("213.32.93.140", "ES", "Madrid", "AS3352 Telefónica"), + ("79.137.79.167", "IT", "Rome", "AS3269 Telecom Italia"), + ("37.9.169.146", "SE", "Stockholm", "AS3301 Telia"), + ("188.92.80.123", "RO", "Bucharest", "AS8708 RCS & RDS"), + ("80.240.25.198", "CZ", "Prague", "AS6830 UPC"), +] + +# Extract just IPs for backward compatibility +FAKE_IPS = [ip_data[0] for ip_data in FAKE_IPS_WITH_GEO] + +# Create geo data dictionary +FAKE_GEO_DATA = { + ip_data[0]: (ip_data[1], ip_data[2], ip_data[3]) + for ip_data in FAKE_IPS_WITH_GEO +} + +# Real good crawler IPs (Googlebot, Bingbot, etc.) - geolocation will be fetched from API +GOOD_CRAWLER_IPS = [ + "66.249.66.1", # Googlebot + "66.249.79.23", # Googlebot + "40.77.167.52", # Bingbot + "157.55.39.145", # Bingbot + "17.58.98.100", # Applebot + "199.59.150.39", # Twitterbot + "54.236.1.15", # Amazon Bot ] FAKE_PATHS = [ @@ -79,11 +160,11 @@ ATTACK_TYPES = [ ] CATEGORIES = [ - "ATTACKER", - "BAD_CRAWLER", - "GOOD_CRAWLER", - "REGULAR_USER", - "UNKNOWN", + "attacker", + "bad_crawler", + "good_crawler", + "regular_user", + "unknown", ] @@ -109,14 +190,92 @@ def generate_analyzed_metrics(): } -def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per_ip: int = 3): +def cleanup_database(db_manager, app_logger): + """ + Clean up all existing test data from the database. + + Args: + db_manager: Database manager instance + app_logger: Logger instance + """ + from models import AccessLog, CredentialAttempt, AttackDetection, IpStats, CategoryHistory + + app_logger.info("=" * 60) + app_logger.info("Cleaning up existing database data") + app_logger.info("=" * 60) + + session = db_manager.session + try: + # Delete all records from each table + deleted_attack_detections = session.query(AttackDetection).delete() + deleted_access_logs = session.query(AccessLog).delete() + deleted_credentials = session.query(CredentialAttempt).delete() + deleted_category_history = session.query(CategoryHistory).delete() + deleted_ip_stats = session.query(IpStats).delete() + + session.commit() + + app_logger.info(f"Deleted {deleted_access_logs} access logs") + app_logger.info(f"Deleted {deleted_attack_detections} attack detections") + app_logger.info(f"Deleted {deleted_credentials} credential attempts") + app_logger.info(f"Deleted {deleted_category_history} category history records") + app_logger.info(f"Deleted {deleted_ip_stats} IP statistics") + app_logger.info("✓ Database cleanup complete") + except Exception as e: + session.rollback() + app_logger.error(f"Error during database cleanup: {e}") + raise + finally: + db_manager.close_session() + + +def fetch_geolocation_from_api(ip: str, app_logger) -> tuple: + """ + Fetch geolocation data from the IP reputation API. + + Args: + ip: IP address to lookup + app_logger: Logger instance + + Returns: + Tuple of (country_code, city, asn, asn_org) or None if failed + """ + try: + api_url = "https://iprep.lcrawl.com/api/iprep/" + params = {"cidr": ip} + headers = {"Content-Type": "application/json"} + response = requests.get(api_url, headers=headers, params=params, timeout=10) + + if response.status_code == 200: + payload = response.json() + if payload.get("results"): + data = payload["results"][0] + geoip_data = data.get("geoip_data", {}) + + country_code = geoip_data.get("country_iso_code", "Unknown") + city = geoip_data.get("city_name", "Unknown") + asn = geoip_data.get("asn_autonomous_system_number") + asn_org = geoip_data.get("asn_autonomous_system_organization", "Unknown") + + return (country_code, city, asn, asn_org) + except requests.RequestException as e: + app_logger.warning(f"Failed to fetch geolocation for {ip}: {e}") + except Exception as e: + app_logger.error(f"Error processing geolocation for {ip}: {e}") + + return None + + +def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per_ip: int = 3, include_good_crawlers: bool = True, cleanup: bool = True): """ Generate and insert fake test data into the database. Args: - num_ips: Number of unique fake IPs to generate (default: 5) + num_ips: Number of unique fake IPs to generate (default: 20) logs_per_ip: Number of access logs per IP (default: 15) credentials_per_ip: Number of credential attempts per IP (default: 3) + include_good_crawlers: Whether to add real good crawler IPs with API-fetched geolocation (default: True) + cleanup: Whether to clean up existing database data before generating new data (default: True) """ db_manager = get_database() app_logger = get_app_logger() @@ -125,6 +284,11 @@ def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per if not db_manager._initialized: db_manager.initialize() + # Clean up existing data if requested + if cleanup: + cleanup_database(db_manager, app_logger) + print() # Add blank line for readability + app_logger.info("=" * 60) app_logger.info("Starting fake IP data generation for testing") app_logger.info("=" * 60) @@ -186,6 +350,28 @@ def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per app_logger.info(f" ✓ Generated {logs_per_ip} access logs") app_logger.info(f" ✓ Generated {credentials_per_ip} credential attempts") + # Add geolocation data if available for this IP + if ip in FAKE_GEO_DATA: + country_code, city, asn_org = FAKE_GEO_DATA[ip] + # Extract ASN number from ASN string (e.g., "AS12345 Name" -> 12345) + asn_number = None + if asn_org and asn_org.startswith("AS"): + try: + asn_number = int(asn_org.split()[0][2:]) # Remove "AS" prefix and get number + except (ValueError, IndexError): + asn_number = 12345 # Fallback + + # Update IP reputation info including geolocation and city + db_manager.update_ip_rep_infos( + ip=ip, + country_code=country_code, + asn=asn_number or 12345, + asn_org=asn_org, + list_on={}, + city=city # Now passing city to the function + ) + app_logger.info(f" 📍 Added geolocation: {city}, {country_code} ({asn_org})") + # Trigger behavior/category changes to demonstrate timeline feature # First analysis initial_category = random.choice(CATEGORIES) @@ -232,11 +418,79 @@ def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per ) total_category_changes += 1 + # Add good crawler IPs with real geolocation from API + total_good_crawlers = 0 + if include_good_crawlers: + app_logger.info("\n" + "=" * 60) + app_logger.info("Adding Good Crawler IPs with API-fetched geolocation") + app_logger.info("=" * 60) + + for crawler_ip in GOOD_CRAWLER_IPS: + app_logger.info(f"\nProcessing Good Crawler: {crawler_ip}") + + # Fetch real geolocation from API + geo_data = fetch_geolocation_from_api(crawler_ip, app_logger) + + # Don't generate access logs for good crawlers to prevent re-categorization + # We'll just create the IP stats entry with the category set + app_logger.info(f" ✓ Adding as good crawler (no logs to prevent re-categorization)") + + # First, we need to create the IP in the database via persist_access + # (but we'll only create one minimal log entry) + db_manager.persist_access( + ip=crawler_ip, + path="/robots.txt", # Minimal, normal crawler behavior + user_agent="Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + method="GET", + is_suspicious=False, + is_honeypot_trigger=False, + attack_types=None, + ) + + # Add geolocation if API fetch was successful + if geo_data: + country_code, city, asn, asn_org = geo_data + db_manager.update_ip_rep_infos( + ip=crawler_ip, + country_code=country_code, + asn=asn if asn else 12345, + asn_org=asn_org, + list_on={}, + city=city + ) + app_logger.info(f" 📍 API-fetched geolocation: {city}, {country_code} ({asn_org})") + else: + app_logger.warning(f" ⚠ Could not fetch geolocation for {crawler_ip}") + + # Set category to good_crawler - this sets manual_category=True to prevent re-analysis + db_manager.update_ip_stats_analysis( + ip=crawler_ip, + analyzed_metrics={ + "request_frequency": 0.1, # Very low frequency + "suspicious_patterns": 0, + "credential_attempts": 0, + "attack_diversity": 0.0, + }, + category="good_crawler", + category_scores={ + "attacker": 0, + "good_crawler": 100, + "bad_crawler": 0, + "regular_user": 0, + "unknown": 0, + }, + last_analysis=datetime.now(tz=ZoneInfo('UTC')) + ) + total_good_crawlers += 1 + time.sleep(0.5) # Small delay between API calls + # Print summary app_logger.info("\n" + "=" * 60) app_logger.info("Test Data Generation Complete!") app_logger.info("=" * 60) - app_logger.info(f"Total IPs created: {len(selected_ips)}") + app_logger.info(f"Total IPs created: {len(selected_ips) + total_good_crawlers}") + app_logger.info(f" - Attackers/Mixed: {len(selected_ips)}") + app_logger.info(f" - Good Crawlers: {total_good_crawlers}") app_logger.info(f"Total access logs: {total_logs}") app_logger.info(f"Total attack detections: {total_attacks}") app_logger.info(f"Total credential attempts: {total_credentials}") @@ -244,6 +498,7 @@ def generate_fake_data(num_ips: int = 45, logs_per_ip: int = 15, credentials_per app_logger.info("=" * 60) app_logger.info("\nYou can now view the dashboard with this test data.") app_logger.info("The 'Behavior Timeline' will show category transitions for each IP.") + app_logger.info("The map will show good crawlers with real geolocation from API.") app_logger.info("Run: python server.py") app_logger.info("=" * 60) @@ -252,8 +507,10 @@ if __name__ == "__main__": import sys # Allow command-line arguments for customization - num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 5 + num_ips = int(sys.argv[1]) if len(sys.argv) > 1 else 20 logs_per_ip = int(sys.argv[2]) if len(sys.argv) > 2 else 15 credentials_per_ip = int(sys.argv[3]) if len(sys.argv) > 3 else 3 + # Add --no-cleanup flag to skip database cleanup + cleanup = "--no-cleanup" not in sys.argv - generate_fake_data(num_ips, logs_per_ip, credentials_per_ip) + generate_fake_data(num_ips, logs_per_ip, credentials_per_ip, include_good_crawlers=True, cleanup=cleanup) From 39d9d6224733c1bcc83f3d06613c0e468b1c712c Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:32:38 +0100 Subject: [PATCH 66/70] Feat/attack map improvement (#58) * Enhance geolocation functionality and improve unenriched IP retrieval logic * Refactor test_insert_fake_ips.py to enhance geolocation data handling and improve IP data structure * Refactor code for improved readability and consistency in database and geolocation utilities --- src/database.py | 9 +- src/geo_utils.py | 113 +++++++++++++++ src/tasks/fetch_ip_rep.py | 18 ++- tests/test_insert_fake_ips.py | 253 ++++++++++++++++++++-------------- 4 files changed, 277 insertions(+), 116 deletions(-) create mode 100644 src/geo_utils.py diff --git a/src/database.py b/src/database.py index da8d579..5af71dd 100644 --- a/src/database.py +++ b/src/database.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta from typing import Optional, List, Dict, Any from zoneinfo import ZoneInfo -from sqlalchemy import create_engine, func, distinct, case, event +from sqlalchemy import create_engine, func, distinct, case, event, or_ from sqlalchemy.orm import sessionmaker, scoped_session, Session from sqlalchemy.engine import Engine @@ -432,21 +432,22 @@ class DatabaseManager: def get_unenriched_ips(self, limit: int = 100) -> List[str]: """ - Get IPs that don't have reputation data yet. + Get IPs that don't have complete reputation data yet. + Returns IPs without country_code OR without city data. Excludes RFC1918 private addresses and other non-routable IPs. Args: limit: Maximum number of IPs to return Returns: - List of IP addresses without reputation data + List of IP addresses without complete reputation data """ session = self.session try: ips = ( session.query(IpStats.ip) .filter( - IpStats.country_code.is_(None), + or_(IpStats.country_code.is_(None), IpStats.city.is_(None)), ~IpStats.ip.like("10.%"), ~IpStats.ip.like("172.16.%"), ~IpStats.ip.like("172.17.%"), diff --git a/src/geo_utils.py b/src/geo_utils.py new file mode 100644 index 0000000..d11f01c --- /dev/null +++ b/src/geo_utils.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Geolocation utilities for reverse geocoding and city lookups. +""" + +import requests +from typing import Optional, Tuple +from logger import get_app_logger + +app_logger = get_app_logger() + +# Simple city name cache to avoid repeated API calls +_city_cache = {} + + +def reverse_geocode_city(latitude: float, longitude: float) -> Optional[str]: + """ + Reverse geocode coordinates to get city name using Nominatim (OpenStreetMap). + + Args: + latitude: Latitude coordinate + longitude: Longitude coordinate + + Returns: + City name or None if not found + """ + # Check cache first + cache_key = f"{latitude},{longitude}" + if cache_key in _city_cache: + return _city_cache[cache_key] + + try: + # Use Nominatim reverse geocoding API (free, no API key required) + url = "https://nominatim.openstreetmap.org/reverse" + params = { + "lat": latitude, + "lon": longitude, + "format": "json", + "zoom": 10, # City level + "addressdetails": 1, + } + headers = {"User-Agent": "Krawl-Honeypot/1.0"} # Required by Nominatim ToS + + response = requests.get(url, params=params, headers=headers, timeout=5) + response.raise_for_status() + + data = response.json() + address = data.get("address", {}) + + # Try to get city from various possible fields + city = ( + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality") + or address.get("county") + ) + + # Cache the result + _city_cache[cache_key] = city + + if city: + app_logger.debug(f"Reverse geocoded {latitude},{longitude} to {city}") + + return city + + except requests.RequestException as e: + app_logger.warning(f"Reverse geocoding failed for {latitude},{longitude}: {e}") + return None + except Exception as e: + app_logger.error(f"Error in reverse geocoding: {e}") + return None + + +def get_most_recent_geoip_data(results: list) -> Optional[dict]: + """ + Extract the most recent geoip_data from API results. + Results are assumed to be sorted by record_added (most recent first). + + Args: + results: List of result dictionaries from IP reputation API + + Returns: + Most recent geoip_data dict or None + """ + if not results: + return None + + # The first result is the most recent (sorted by record_added) + most_recent = results[0] + return most_recent.get("geoip_data") + + +def extract_city_from_coordinates(geoip_data: dict) -> Optional[str]: + """ + Extract city name from geoip_data using reverse geocoding. + + Args: + geoip_data: Dictionary containing location_latitude and location_longitude + + Returns: + City name or None + """ + if not geoip_data: + return None + + latitude = geoip_data.get("location_latitude") + longitude = geoip_data.get("location_longitude") + + if latitude is None or longitude is None: + return None + + return reverse_geocode_city(latitude, longitude) diff --git a/src/tasks/fetch_ip_rep.py b/src/tasks/fetch_ip_rep.py index be74553..a005c62 100644 --- a/src/tasks/fetch_ip_rep.py +++ b/src/tasks/fetch_ip_rep.py @@ -2,6 +2,7 @@ from database import get_database from logger import get_app_logger import requests from sanitizer import sanitize_for_storage, sanitize_dict +from geo_utils import get_most_recent_geoip_data, extract_city_from_coordinates # ---------------------- # TASK CONFIG @@ -33,13 +34,20 @@ def main(): payload = response.json() if payload.get("results"): - data = payload["results"][0] - geoip_data = data["geoip_data"] + results = payload["results"] + + # Get the most recent result (first in list, sorted by record_added) + most_recent = results[0] + geoip_data = most_recent.get("geoip_data", {}) + list_on = most_recent.get("list_on", {}) + + # Extract standard fields country_iso_code = geoip_data.get("country_iso_code") asn = geoip_data.get("asn_autonomous_system_number") asn_org = geoip_data.get("asn_autonomous_system_organization") - city = geoip_data.get("city_name") # Extract city name from API - list_on = data["list_on"] + + # Extract city from coordinates using reverse geocoding + city = extract_city_from_coordinates(geoip_data) sanitized_country_iso_code = sanitize_for_storage(country_iso_code, 3) sanitized_asn = sanitize_for_storage(asn, 100) @@ -53,7 +61,7 @@ def main(): sanitized_asn, sanitized_asn_org, sanitized_list_on, - sanitized_city, # Pass city to database + sanitized_city, ) except requests.RequestException as e: app_logger.warning(f"Failed to fetch IP rep for {ip}: {e}") diff --git a/tests/test_insert_fake_ips.py b/tests/test_insert_fake_ips.py index bb4a777..bbce7e4 100644 --- a/tests/test_insert_fake_ips.py +++ b/tests/test_insert_fake_ips.py @@ -7,7 +7,8 @@ This generates realistic-looking test data including: - Credential attempts - Attack detections (SQL injection, XSS, etc.) - Category behavior changes for timeline demonstration -- Real good crawler IPs (Googlebot, Bingbot, etc.) with API-fetched geolocation +- Geolocation data fetched from API with reverse geocoded city names +- Real good crawler IPs (Googlebot, Bingbot, etc.) Usage: python test_insert_fake_ips.py [num_ips] [logs_per_ip] [credentials_per_ip] [--no-cleanup] @@ -17,6 +18,8 @@ Examples: python test_insert_fake_ips.py 30 # Generate 30 IPs with defaults python test_insert_fake_ips.py 30 20 5 # Generate 30 IPs, 20 logs each, 5 credentials each python test_insert_fake_ips.py --no-cleanup # Generate data without cleaning DB first + +Note: This script will make API calls to fetch geolocation data, so it may take a while. """ import random @@ -32,86 +35,72 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from database import get_database from logger import get_app_logger +from geo_utils import extract_city_from_coordinates # ---------------------- # TEST DATA GENERATORS # ---------------------- -# Fake IPs with geolocation data (country_code, city, ASN org) -# These will appear on the map based on their country_code -FAKE_IPS_WITH_GEO = [ +# Fake IPs for testing - geolocation data will be fetched from API +# These are real public IPs from various locations around the world +FAKE_IPS = [ # United States - ("45.142.120.10", "US", "New York", "AS14061 DigitalOcean"), - ("107.189.10.143", "US", "Los Angeles", "AS20473 Vultr"), - ("162.243.175.23", "US", "San Francisco", "AS14061 DigitalOcean"), - ("198.51.100.89", "US", "Chicago", "AS16509 Amazon"), - + "45.142.120.10", + "107.189.10.143", + "162.243.175.23", + "198.51.100.89", # Europe - ("185.220.101.45", "DE", "Berlin", "AS24940 Hetzner"), - ("195.154.133.20", "FR", "Paris", "AS12876 Scaleway"), - ("178.128.83.165", "GB", "London", "AS14061 DigitalOcean"), - ("87.251.67.90", "NL", "Amsterdam", "AS49453 GlobalConnect"), - ("91.203.5.165", "RU", "Moscow", "AS51115 HLL LLC"), - ("46.105.57.169", "FR", "Roubaix", "AS16276 OVH"), - ("217.182.143.207", "RU", "Saint Petersburg", "AS51570 JSC ER-Telecom"), - ("188.166.123.45", "GB", "Manchester", "AS14061 DigitalOcean"), - + "185.220.101.45", + "195.154.133.20", + "178.128.83.165", + "87.251.67.90", + "91.203.5.165", + "46.105.57.169", + "217.182.143.207", + "188.166.123.45", # Asia - ("103.253.145.36", "CN", "Beijing", "AS4134 Chinanet"), - ("42.112.28.216", "CN", "Shanghai", "AS4134 Chinanet"), - ("118.163.74.160", "JP", "Tokyo", "AS2516 KDDI"), - ("43.229.53.35", "SG", "Singapore", "AS23969 TOT"), - ("115.78.208.140", "IN", "Mumbai", "AS9829 BSNL"), - ("14.139.56.18", "IN", "Bangalore", "AS4755 TATA"), - ("61.19.25.207", "TW", "Taipei", "AS3462 HiNet"), - ("121.126.219.198", "KR", "Seoul", "AS4766 Korea Telecom"), - ("202.134.4.212", "ID", "Jakarta", "AS7597 TELKOMNET"), - ("171.244.140.134", "VN", "Hanoi", "AS7552 Viettel"), - + "103.253.145.36", + "42.112.28.216", + "118.163.74.160", + "43.229.53.35", + "115.78.208.140", + "14.139.56.18", + "61.19.25.207", + "121.126.219.198", + "202.134.4.212", + "171.244.140.134", # South America - ("177.87.169.20", "BR", "São Paulo", "AS28573 Claro"), - ("200.21.19.58", "BR", "Rio de Janeiro", "AS7738 Telemar"), - ("181.13.140.98", "AR", "Buenos Aires", "AS7303 Telecom Argentina"), - ("190.150.24.34", "CO", "Bogotá", "AS3816 Colombia Telecomunicaciones"), - + "177.87.169.20", + "200.21.19.58", + "181.13.140.98", + "190.150.24.34", # Middle East & Africa - ("41.223.53.141", "EG", "Cairo", "AS8452 TE-Data"), - ("196.207.35.152", "ZA", "Johannesburg", "AS37271 Workonline"), - ("5.188.62.214", "TR", "Istanbul", "AS51115 HLL LLC"), - ("37.48.93.125", "AE", "Dubai", "AS5384 Emirates Telecom"), - ("102.66.137.29", "NG", "Lagos", "AS29465 MTN Nigeria"), - + "41.223.53.141", + "196.207.35.152", + "5.188.62.214", + "37.48.93.125", + "102.66.137.29", # Australia & Oceania - ("103.28.248.110", "AU", "Sydney", "AS4739 Internode"), - ("202.168.45.33", "AU", "Melbourne", "AS1221 Telstra"), - + "103.28.248.110", + "202.168.45.33", # Additional European IPs - ("94.102.49.190", "PL", "Warsaw", "AS12912 T-Mobile"), - ("213.32.93.140", "ES", "Madrid", "AS3352 Telefónica"), - ("79.137.79.167", "IT", "Rome", "AS3269 Telecom Italia"), - ("37.9.169.146", "SE", "Stockholm", "AS3301 Telia"), - ("188.92.80.123", "RO", "Bucharest", "AS8708 RCS & RDS"), - ("80.240.25.198", "CZ", "Prague", "AS6830 UPC"), + "94.102.49.190", + "213.32.93.140", + "79.137.79.167", + "37.9.169.146", + "188.92.80.123", + "80.240.25.198", ] -# Extract just IPs for backward compatibility -FAKE_IPS = [ip_data[0] for ip_data in FAKE_IPS_WITH_GEO] - -# Create geo data dictionary -FAKE_GEO_DATA = { - ip_data[0]: (ip_data[1], ip_data[2], ip_data[3]) - for ip_data in FAKE_IPS_WITH_GEO -} - # Real good crawler IPs (Googlebot, Bingbot, etc.) - geolocation will be fetched from API GOOD_CRAWLER_IPS = [ - "66.249.66.1", # Googlebot - "66.249.79.23", # Googlebot - "40.77.167.52", # Bingbot - "157.55.39.145", # Bingbot - "17.58.98.100", # Applebot - "199.59.150.39", # Twitterbot - "54.236.1.15", # Amazon Bot + "66.249.66.1", # Googlebot + "66.249.79.23", # Googlebot + "40.77.167.52", # Bingbot + "157.55.39.145", # Bingbot + "17.58.98.100", # Applebot + "199.59.150.39", # Twitterbot + "54.236.1.15", # Amazon Bot ] FAKE_PATHS = [ @@ -198,7 +187,13 @@ def cleanup_database(db_manager, app_logger): db_manager: Database manager instance app_logger: Logger instance """ - from models import AccessLog, CredentialAttempt, AttackDetection, IpStats, CategoryHistory + from models import ( + AccessLog, + CredentialAttempt, + AttackDetection, + IpStats, + CategoryHistory, + ) app_logger.info("=" * 60) app_logger.info("Cleaning up existing database data") @@ -232,6 +227,7 @@ def cleanup_database(db_manager, app_logger): def fetch_geolocation_from_api(ip: str, app_logger) -> tuple: """ Fetch geolocation data from the IP reputation API. + Uses the most recent result and extracts city from coordinates. Args: ip: IP address to lookup @@ -249,13 +245,18 @@ def fetch_geolocation_from_api(ip: str, app_logger) -> tuple: if response.status_code == 200: payload = response.json() if payload.get("results"): - data = payload["results"][0] - geoip_data = data.get("geoip_data", {}) + results = payload["results"] - country_code = geoip_data.get("country_iso_code", "Unknown") - city = geoip_data.get("city_name", "Unknown") + # Get the most recent result (first in list, sorted by record_added) + most_recent = results[0] + geoip_data = most_recent.get("geoip_data", {}) + + country_code = geoip_data.get("country_iso_code") asn = geoip_data.get("asn_autonomous_system_number") - asn_org = geoip_data.get("asn_autonomous_system_organization", "Unknown") + asn_org = geoip_data.get("asn_autonomous_system_organization") + + # Extract city from coordinates using reverse geocoding + city = extract_city_from_coordinates(geoip_data) return (country_code, city, asn, asn_org) except requests.RequestException as e: @@ -266,7 +267,13 @@ def fetch_geolocation_from_api(ip: str, app_logger) -> tuple: return None -def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per_ip: int = 3, include_good_crawlers: bool = True, cleanup: bool = True): +def generate_fake_data( + num_ips: int = 20, + logs_per_ip: int = 15, + credentials_per_ip: int = 3, + include_good_crawlers: bool = True, + cleanup: bool = True, +): """ Generate and insert fake test data into the database. @@ -308,8 +315,12 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per for _ in range(logs_per_ip): path = random.choice(FAKE_PATHS) user_agent = random.choice(FAKE_USER_AGENTS) - is_suspicious = random.choice([True, False, False]) # 33% chance of suspicious - is_honeypot = random.choice([True, False, False, False]) # 25% chance of honeypot trigger + is_suspicious = random.choice( + [True, False, False] + ) # 33% chance of suspicious + is_honeypot = random.choice( + [True, False, False, False] + ) # 25% chance of honeypot trigger # Randomly decide if this log has attack detections attack_types = None @@ -350,39 +361,45 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per app_logger.info(f" ✓ Generated {logs_per_ip} access logs") app_logger.info(f" ✓ Generated {credentials_per_ip} credential attempts") - # Add geolocation data if available for this IP - if ip in FAKE_GEO_DATA: - country_code, city, asn_org = FAKE_GEO_DATA[ip] - # Extract ASN number from ASN string (e.g., "AS12345 Name" -> 12345) - asn_number = None - if asn_org and asn_org.startswith("AS"): - try: - asn_number = int(asn_org.split()[0][2:]) # Remove "AS" prefix and get number - except (ValueError, IndexError): - asn_number = 12345 # Fallback + # Fetch geolocation data from API + app_logger.info(f" 🌍 Fetching geolocation from API...") + geo_data = fetch_geolocation_from_api(ip, app_logger) - # Update IP reputation info including geolocation and city + if geo_data: + country_code, city, asn, asn_org = geo_data db_manager.update_ip_rep_infos( ip=ip, country_code=country_code, - asn=asn_number or 12345, - asn_org=asn_org, + asn=asn if asn else 12345, + asn_org=asn_org or "Unknown", list_on={}, - city=city # Now passing city to the function + city=city, ) - app_logger.info(f" 📍 Added geolocation: {city}, {country_code} ({asn_org})") + location_display = ( + f"{city}, {country_code}" if city else country_code or "Unknown" + ) + app_logger.info( + f" 📍 API-fetched geolocation: {location_display} ({asn_org or 'Unknown'})" + ) + else: + app_logger.warning(f" ⚠ Could not fetch geolocation for {ip}") + + # Small delay to be nice to the API + time.sleep(0.5) # Trigger behavior/category changes to demonstrate timeline feature # First analysis initial_category = random.choice(CATEGORIES) - app_logger.info(f" ⟳ Analyzing behavior - Initial category: {initial_category}") - + app_logger.info( + f" ⟳ Analyzing behavior - Initial category: {initial_category}" + ) + db_manager.update_ip_stats_analysis( ip=ip, analyzed_metrics=generate_analyzed_metrics(), category=initial_category, category_scores=generate_category_scores(), - last_analysis=datetime.now(tz=ZoneInfo('UTC')) + last_analysis=datetime.now(tz=ZoneInfo("UTC")), ) total_category_changes += 1 @@ -391,30 +408,38 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per # Second analysis with potential category change (70% chance) if random.random() < 0.7: - new_category = random.choice([c for c in CATEGORIES if c != initial_category]) - app_logger.info(f" ⟳ Behavior change detected: {initial_category} → {new_category}") - + new_category = random.choice( + [c for c in CATEGORIES if c != initial_category] + ) + app_logger.info( + f" ⟳ Behavior change detected: {initial_category} → {new_category}" + ) + db_manager.update_ip_stats_analysis( ip=ip, analyzed_metrics=generate_analyzed_metrics(), category=new_category, category_scores=generate_category_scores(), - last_analysis=datetime.now(tz=ZoneInfo('UTC')) + last_analysis=datetime.now(tz=ZoneInfo("UTC")), ) total_category_changes += 1 # Optional third change (40% chance) if random.random() < 0.4: - final_category = random.choice([c for c in CATEGORIES if c != new_category]) - app_logger.info(f" ⟳ Another behavior change: {new_category} → {final_category}") - + final_category = random.choice( + [c for c in CATEGORIES if c != new_category] + ) + app_logger.info( + f" ⟳ Another behavior change: {new_category} → {final_category}" + ) + time.sleep(0.1) db_manager.update_ip_stats_analysis( ip=ip, analyzed_metrics=generate_analyzed_metrics(), category=final_category, category_scores=generate_category_scores(), - last_analysis=datetime.now(tz=ZoneInfo('UTC')) + last_analysis=datetime.now(tz=ZoneInfo("UTC")), ) total_category_changes += 1 @@ -433,7 +458,9 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per # Don't generate access logs for good crawlers to prevent re-categorization # We'll just create the IP stats entry with the category set - app_logger.info(f" ✓ Adding as good crawler (no logs to prevent re-categorization)") + app_logger.info( + f" ✓ Adding as good crawler (no logs to prevent re-categorization)" + ) # First, we need to create the IP in the database via persist_access # (but we'll only create one minimal log entry) @@ -456,9 +483,11 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per asn=asn if asn else 12345, asn_org=asn_org, list_on={}, - city=city + city=city, + ) + app_logger.info( + f" 📍 API-fetched geolocation: {city}, {country_code} ({asn_org})" ) - app_logger.info(f" 📍 API-fetched geolocation: {city}, {country_code} ({asn_org})") else: app_logger.warning(f" ⚠ Could not fetch geolocation for {crawler_ip}") @@ -479,7 +508,7 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per "regular_user": 0, "unknown": 0, }, - last_analysis=datetime.now(tz=ZoneInfo('UTC')) + last_analysis=datetime.now(tz=ZoneInfo("UTC")), ) total_good_crawlers += 1 time.sleep(0.5) # Small delay between API calls @@ -497,8 +526,12 @@ def generate_fake_data(num_ips: int = 20, logs_per_ip: int = 15, credentials_per app_logger.info(f"Total category changes: {total_category_changes}") app_logger.info("=" * 60) app_logger.info("\nYou can now view the dashboard with this test data.") - app_logger.info("The 'Behavior Timeline' will show category transitions for each IP.") - app_logger.info("The map will show good crawlers with real geolocation from API.") + app_logger.info( + "The 'Behavior Timeline' will show category transitions for each IP." + ) + app_logger.info( + "All IPs have API-fetched geolocation with reverse geocoded city names." + ) app_logger.info("Run: python server.py") app_logger.info("=" * 60) @@ -513,4 +546,10 @@ if __name__ == "__main__": # Add --no-cleanup flag to skip database cleanup cleanup = "--no-cleanup" not in sys.argv - generate_fake_data(num_ips, logs_per_ip, credentials_per_ip, include_good_crawlers=True, cleanup=cleanup) + generate_fake_data( + num_ips, + logs_per_ip, + credentials_per_ip, + include_good_crawlers=True, + cleanup=cleanup, + ) From e93bcb959a74ed4a0a17a0d1a6145cbb105ced94 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi <68255980+Lore09@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:55:06 +0100 Subject: [PATCH 67/70] Doc/updated documentation (#60) * added documentation, updated repo pointer in the dashboard, added dashboard link highlighting and mionor fixes * added doc * added logo to dashboard * Fixed dashboard attack chart * Enhance fake data generation with varied request counts for better visualization * Add automatic migrations and support for latitude/longitude in IP stats * Update Helm chart version to 0.2.2 and add timezone configuration option --------- Co-authored-by: BlessedRebuS --- README.md | 287 ++++++++----------- config.yaml | 8 +- helm/Chart.yaml | 4 +- helm/README.md | 68 +++++ helm/templates/deployment.yaml | 4 + helm/values.yaml | 6 +- img/admin-page.png | Bin 21128 -> 21408 bytes img/api-secrets-page.png | Bin 91976 -> 0 bytes img/api-users-page.png | Bin 47380 -> 0 bytes img/credentials-and-passwords.png | Bin 0 -> 73835 bytes img/credentials-page.png | Bin 77534 -> 0 bytes img/dashboard-1.png | Bin 100494 -> 136202 bytes img/dashboard-2.png | Bin 55135 -> 77787 bytes img/dashboard-3.png | Bin 0 -> 210421 bytes img/env-page.png | Bin 30430 -> 0 bytes img/geoip_dashboard.png | Bin 0 -> 183139 bytes img/ip-reputation.png | Bin 0 -> 73246 bytes img/passwords-page.png | Bin 121720 -> 0 bytes img/server-and-env-page.png | Bin 0 -> 87979 bytes img/sql_injection.png | Bin 0 -> 31163 bytes img/users-and-secrets.png | Bin 0 -> 68445 bytes kubernetes/README.md | 47 ++++ src/config.py | 6 - src/database.py | 128 +++++++-- src/exports/malicious_ips.txt | 2 - src/handler.py | 41 +++ src/models.py | 13 +- src/server.py | 50 ++-- src/tasks/fetch_ip_rep.py | 4 + src/tasks/top_attacking_ips.py | 24 +- src/templates/dashboard_template.py | 417 +++++++++++++++++++++++----- src/templates/html/main_page.html | 2 +- src/templates/static/krawl-svg.svg | 95 +++++++ tests/test_insert_fake_ips.py | 25 +- 34 files changed, 917 insertions(+), 314 deletions(-) delete mode 100644 img/api-secrets-page.png delete mode 100644 img/api-users-page.png create mode 100644 img/credentials-and-passwords.png delete mode 100644 img/credentials-page.png create mode 100644 img/dashboard-3.png delete mode 100644 img/env-page.png create mode 100644 img/geoip_dashboard.png create mode 100644 img/ip-reputation.png delete mode 100644 img/passwords-page.png create mode 100644 img/server-and-env-page.png create mode 100644 img/sql_injection.png create mode 100644 img/users-and-secrets.png create mode 100644 kubernetes/README.md delete mode 100644 src/exports/malicious_ips.txt create mode 100644 src/templates/static/krawl-svg.svg diff --git a/README.md b/README.md index 6f58646..cfb0427 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- A modern, customizable zero-dependencies honeypot server designed to detect and track malicious activity through deceptive web pages, fake credentials, and canary tokens. + A modern, customizable web honeypot server designed to detect and track malicious activity from attackers and web crawlers through deceptive web pages, fake credentials, and canary tokens.

@@ -55,7 +55,7 @@ Tip: crawl the `robots.txt` paths for additional fun ## What is Krawl? -**Krawl** is a cloud‑native deception server designed to detect, delay, and analyze malicious web crawlers and automated scanners. +**Krawl** is a cloud‑native deception server designed to detect, delay, and analyze malicious attackers, web crawlers and automated scanners. It creates realistic fake web applications filled with low‑hanging fruit such as admin panels, configuration files, and exposed fake credentials to attract and identify suspicious activity. @@ -68,11 +68,14 @@ It features: - **Honeypot Paths**: Advertised in robots.txt to catch scanners - **Fake Credentials**: Realistic-looking usernames, passwords, API keys - **[Canary Token](#customizing-the-canary-token) Integration**: External alert triggering +- **Random server headers**: Confuse attacks based on server header and version - **Real-time Dashboard**: Monitor suspicious activity - **Customizable Wordlists**: Easy JSON-based configuration - **Random Error Injection**: Mimic real server behavior -![asd](img/deception-page.png) +![dashboard](img/deception-page.png) + +![geoip](img/geoip_dashboard.png) ## 🚀 Installation @@ -127,149 +130,98 @@ Stop with: docker-compose down ``` -### Helm Chart - -Install with default values: - -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --version 2.0.0 \ - --namespace krawl-system \ - --create-namespace -``` - -Or create a minimal `values.yaml` file: - -```yaml -service: - type: LoadBalancer - port: 5000 - -ingress: - enabled: true - className: "traefik" - hosts: - - host: krawl.example.com - paths: - - path: / - pathType: Prefix - -config: - server: - port: 5000 - delay: 100 - dashboard: - secret_path: null # Auto-generated if not set - -database: - persistence: - enabled: true - size: 1Gi -``` - -Install with custom values: - -```bash -helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ - --version 2.0.0 \ - --namespace krawl-system \ - --create-namespace \ - -f values.yaml -``` - -To access the deception server: - -```bash -kubectl get svc krawl -n krawl-system -``` - -Once the EXTERNAL-IP is assigned, access your deception server at `http://:5000` - ### Kubernetes +**Krawl is also available natively on Kubernetes**. Installation can be done either [via manifest](kubernetes/README.md) or [using the helm chart](helm/README.md). -Apply all manifests with: +## Use Krawl to Ban Malicious IPs +Krawl uses a reputation-based system to classify attacker IP addresses. Every five minutes, Krawl exports the identified malicious IPs to a `malicious_ips.txt` file. + +This file can either be mounted from the Docker container into another system or downloaded directly via `curl`: ```bash -kubectl apply -f https://raw.githubusercontent.com/BlessedRebuS/Krawl/refs/heads/main/kubernetes/krawl-all-in-one-deploy.yaml +curl https://your-krawl-instance//api/download/malicious_ips.txt ``` -Or clone the repo and apply the manifest: +This file can be used to [update a set of firewall rules](https://www.allthingstech.ch/using-opnsense-and-ip-blocklists-to-block-malicious-traffic), for example on OPNsense and pfSense, enabling automatic blocking of malicious IPs or using IPtables + +## IP Reputation +Krawl [uses tasks that analyze recent traffic to build and continuously update an IP reputation](src/tasks/analyze_ips.py) score. It runs periodically and evaluates each active IP address based on multiple behavioral indicators to classify it as an attacker, crawler, or regular user. Thresholds are fully customizable. + +![ip reputation](img/ip-reputation.png) + +The analysis includes: +- **Risky HTTP methods usage** (e.g. POST, PUT, DELETE ratios) +- **Robots.txt violations** +- **Request timing anomalies** (bursty or irregular patterns) +- **User-Agent consistency** +- **Attack URL detection** (e.g. SQL injection, XSS patterns) + +Each signal contributes to a weighted scoring model that assigns a reputation category: +- `attacker` +- `bad_crawler` +- `good_crawler` +- `regular_user` +- `unknown` (for insufficient data) + +The resulting scores and metrics are stored in the database and used by Krawl to drive dashboards, reputation tracking, and automated mitigation actions such as IP banning or firewall integration. + +## Forward server header +If Krawl is deployed behind a proxy such as NGINX the **server header** should be forwarded using the following configuration in your proxy: ```bash -kubectl apply -f kubernetes/krawl-all-in-one-deploy.yaml +location / { + proxy_pass https://your-krawl-instance; + proxy_pass_header Server; +} ``` -Access the deception server: +## API +Krawl uses the following APIs +- https://iprep.lcrawl.com (IP Reputation) +- https://nominatim.openstreetmap.org/reverse (Reverse IP Lookup) +- https://api.ipify.org (Public IP discovery) +- http://ident.me (Public IP discovery) +- https://ifconfig.me (Public IP discovery) + +## Configuration +Krawl uses a **configuration hierarchy** in which **environment variables take precedence over the configuration file**. This approach is recommended for Docker deployments and quick out-of-the-box customization. + +### Configuration via Enviromental Variables + +| Environment Variable | Description | Default | +|----------------------|-------------|---------| +| `CONFIG_LOCATION` | Path to yaml config file | `config.yaml` | +| `KRAWL_PORT` | Server listening port | `5000` | +| `KRAWL_DELAY` | Response delay in milliseconds | `100` | +| `KRAWL_SERVER_HEADER` | HTTP Server header for deception | `""` | +| `KRAWL_LINKS_LENGTH_RANGE` | Link length range as `min,max` | `5,15` | +| `KRAWL_LINKS_PER_PAGE_RANGE` | Links per page as `min,max` | `10,15` | +| `KRAWL_CHAR_SPACE` | Characters used for link generation | `abcdefgh...` | +| `KRAWL_MAX_COUNTER` | Initial counter value | `10` | +| `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | +| `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | +| `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | +| `KRAWL_API_SERVER_URL` | API server URL | None | +| `KRAWL_API_SERVER_PORT` | API server port | `8080` | +| `KRAWL_API_SERVER_PATH` | API server endpoint path | `/api/v2/users` | +| `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | +| `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | +| `KRAWL_DATABASE_RETENTION_DAYS` | Days to retain data in database | `30` | +| `KRAWL_HTTP_RISKY_METHODS_THRESHOLD` | Threshold for risky HTTP methods detection | `0.1` | +| `KRAWL_VIOLATED_ROBOTS_THRESHOLD` | Threshold for robots.txt violations | `0.1` | +| `KRAWL_UNEVEN_REQUEST_TIMING_THRESHOLD` | Coefficient of variation threshold for timing | `0.5` | +| `KRAWL_UNEVEN_REQUEST_TIMING_TIME_WINDOW_SECONDS` | Time window for request timing analysis in seconds | `300` | +| `KRAWL_USER_AGENTS_USED_THRESHOLD` | Threshold for detecting multiple user agents | `2` | +| `KRAWL_ATTACK_URLS_THRESHOLD` | Threshold for attack URL detection | `1` | + +For example ```bash -kubectl get svc krawl-server -n krawl-system -``` - -Once the EXTERNAL-IP is assigned, access your deception server at `http://:5000` - -### From Source (Python 3.11+) - -Clone the repository: - -```bash -git clone https://github.com/blessedrebus/krawl.git -cd krawl/src -``` - -Run the server: - -```bash -python3 server.py -``` - -Visit `http://localhost:5000` and access the dashboard at `http://localhost:5000/` - -## Configuration via Environment Variables - -To customize the deception server installation, environment variables can be specified using the naming convention: `KRAWL_` where `` is the configuration field name in uppercase with special characters converted: -- `.` → `_` -- `-` → `__` (double underscore) -- ` ` (space) → `_` - -### Configuration Variables - -| Configuration Field | Environment Variable | Description | Default | -|-----------|-----------|-------------|---------| -| `port` | `KRAWL_PORT` | Server listening port | `5000` | -| `delay` | `KRAWL_DELAY` | Response delay in milliseconds | `100` | -| `server_header` | `KRAWL_SERVER_HEADER` | HTTP Server header for deception | `""` | -| `links_length_range` | `KRAWL_LINKS_LENGTH_RANGE` | Link length range as `min,max` | `5,15` | -| `links_per_page_range` | `KRAWL_LINKS_PER_PAGE_RANGE` | Links per page as `min,max` | `10,15` | -| `char_space` | `KRAWL_CHAR_SPACE` | Characters used for link generation | `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789` | -| `max_counter` | `KRAWL_MAX_COUNTER` | Initial counter value | `10` | -| `canary_token_url` | `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | -| `canary_token_tries` | `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | -| `dashboard_secret_path` | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | -| `api_server_url` | `KRAWL_API_SERVER_URL` | API server URL | None | -| `api_server_port` | `KRAWL_API_SERVER_PORT` | API server port | `8080` | -| `api_server_path` | `KRAWL_API_SERVER_PATH` | API server endpoint path | `/api/v2/users` | -| `probability_error_codes` | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | -| `database_path` | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | -| `database_retention_days` | `KRAWL_DATABASE_RETENTION_DAYS` | Days to retain data in database | `30` | -| `http_risky_methods_threshold` | `KRAWL_HTTP_RISKY_METHODS_THRESHOLD` | Threshold for risky HTTP methods detection | `0.1` | -| `violated_robots_threshold` | `KRAWL_VIOLATED_ROBOTS_THRESHOLD` | Threshold for robots.txt violations | `0.1` | -| `uneven_request_timing_threshold` | `KRAWL_UNEVEN_REQUEST_TIMING_THRESHOLD` | Coefficient of variation threshold for timing | `0.5` | -| `uneven_request_timing_time_window_seconds` | `KRAWL_UNEVEN_REQUEST_TIMING_TIME_WINDOW_SECONDS` | Time window for request timing analysis in seconds | `300` | -| `user_agents_used_threshold` | `KRAWL_USER_AGENTS_USED_THRESHOLD` | Threshold for detecting multiple user agents | `2` | -| `attack_urls_threshold` | `KRAWL_ATTACK_URLS_THRESHOLD` | Threshold for attack URL detection | `1` | - -### Examples - -```bash -# Set port and delay -export KRAWL_PORT=8080 -export KRAWL_DELAY=200 - # Set canary token +export CONFIG_LOCATION="config.yaml" export KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" -# Set tuple values (min,max format) -export KRAWL_LINKS_LENGTH_RANGE="3,20" +# Set number of pages range (min,max format) export KRAWL_LINKS_PER_PAGE_RANGE="5,25" # Set analyzer thresholds @@ -280,7 +232,7 @@ export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15" export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" ``` -Or in Docker: +Example of a Docker run with env variables: ```bash docker run -d \ @@ -292,36 +244,20 @@ docker run -d \ ghcr.io/blessedrebus/krawl:latest ``` -## robots.txt -The actual (juicy) robots.txt configuration is the following +### Configuration via config.yaml +You can use the [config.yaml](config.yaml) file for more advanced configurations, such as Docker Compose or Helm chart deployments. -```txt -Disallow: /admin/ -Disallow: /api/ -Disallow: /backup/ -Disallow: /config/ -Disallow: /database/ -Disallow: /private/ -Disallow: /uploads/ -Disallow: /wp-admin/ -Disallow: /phpMyAdmin/ -Disallow: /admin/login.php -Disallow: /api/v1/users -Disallow: /api/v2/secrets -Disallow: /.env -Disallow: /credentials.txt -Disallow: /passwords.txt -Disallow: /.git/ -Disallow: /backup.sql -Disallow: /db_backup.sql -``` +# Honeypot +Below is a complete overview of the Krawl honeypot’s capabilities + +## robots.txt +The actual (juicy) robots.txt configuration [is the following](src/templates/html/robots.txt). ## Honeypot pages Requests to common admin endpoints (`/admin/`, `/wp-admin/`, `/phpMyAdmin/`) return a fake login page. Any login attempt triggers a 1-second delay to simulate real processing and is fully logged in the dashboard (credentials, IP, headers, timing). -
- -
+![admin page](img/admin-page.png) + Requests to paths like `/backup/`, `/config/`, `/database/`, `/private/`, or `/uploads/` return a fake directory listing populated with “interesting” files, each assigned a random file size to look realistic. @@ -329,21 +265,23 @@ Requests to paths like `/backup/`, `/config/`, `/database/`, `/private/`, or `/u The `.env` endpoint exposes fake database connection strings, **AWS API keys**, and **Stripe secrets**. It intentionally returns an error due to the `Content-Type` being `application/json` instead of plain text, mimicking a “juicy” misconfiguration that crawlers and scanners often flag as information leakage. -![env-page](img/env-page.png) +The `/server` page displays randomly generated fake error information for each known server. + +![server and env page](img/server-and-env-page.png) The pages `/api/v1/users` and `/api/v2/secrets` show fake users and random secrets in JSON format -
- - -
+![users and secrets](img/users-and-secrets.png) The pages `/credentials.txt` and `/passwords.txt` show fake users and random secrets -
- - -
+![credentials and passwords](img/credentials-and-passwords.png) + +Pages such as `/users`, `/search`, `/contact`, `/info`, `/input`, and `/feedback`, along with APIs like `/api/sql` and `/api/database`, are designed to lure attackers into performing attacks such as **SQL injection** or **XSS**. + +![sql injection](img/sql_injection.png) + +Automated tools like **SQLMap** will receive a different randomized database error on each request, increasing scan noise and confusing the attacker. All detected attacks are logged and displayed in the dashboard. ## Customizing the Canary Token To create a custom canary token, visit https://canarytokens.org @@ -384,11 +322,13 @@ Access the dashboard at `http://:/` The dashboard shows: - Total and unique accesses -- Suspicious activity detection -- Top IPs, paths, and user-agents +- Suspicious activity and attack detection +- Top IPs, paths, user-agents and GeoIP localization - Real-time monitoring -The attackers' triggered honeypot path and the suspicious activity (such as failed login attempts) are logged +The attackers’ access to the honeypot endpoint and related suspicious activities (such as failed login attempts) are logged. + +Krawl also implements a scoring system designed to distinguish between malicious and legitimate behavior on the website. ![dashboard-1](img/dashboard-1.png) @@ -396,14 +336,7 @@ The top IP Addresses is shown along with top paths and User Agents ![dashboard-2](img/dashboard-2.png) -### Retrieving Dashboard Path - -Check server startup logs or get the secret with - -```bash -kubectl get secret krawl-server -n krawl-system \ - -o jsonpath='{.data.dashboard-path}' | base64 -d && echo -``` +![dashboard-3](img/dashboard-3.png) ## 🤝 Contributing diff --git a/config.yaml b/config.yaml index 3e1d644..c3424d6 100644 --- a/config.yaml +++ b/config.yaml @@ -22,12 +22,8 @@ canary: dashboard: # if set to "null" this will Auto-generates random path if not set # can be set to "/dashboard" or similar <-- note this MUST include a forward slash - secret_path: super-secret-dashboard-path - -api: - server_url: null - server_port: 8080 - server_path: "/api/v2/users" + # secret_path: super-secret-dashboard-path + secret_path: null database: path: "data/krawl.db" diff --git a/helm/Chart.yaml b/helm/Chart.yaml index b2b4cc3..9ff2db0 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 0.2.1 -appVersion: 0.2.1 +version: 0.2.2 +appVersion: 0.2.2 keywords: - honeypot - security diff --git a/helm/README.md b/helm/README.md index 5e10f9c..d1ee9cd 100644 --- a/helm/README.md +++ b/helm/README.md @@ -10,6 +10,65 @@ A Helm chart for deploying the Krawl honeypot application on Kubernetes. ## Installation + +### Helm Chart + +Install with default values: + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --version 0.2.2 \ + --namespace krawl-system \ + --create-namespace +``` + +Or create a minimal `values.yaml` file: + +```yaml +service: + type: LoadBalancer + port: 5000 + +ingress: + enabled: true + className: "traefik" + hosts: + - host: krawl.example.com + paths: + - path: / + pathType: Prefix + +config: + server: + port: 5000 + delay: 100 + dashboard: + secret_path: null # Auto-generated if not set + +database: + persistence: + enabled: true + size: 1Gi +``` + +Install with custom values: + +```bash +helm install krawl oci://ghcr.io/blessedrebus/krawl-chart \ + --version 0.2.2 \ + --namespace krawl-system \ + --create-namespace \ + -f values.yaml +``` + +To access the deception server: + +```bash +kubectl get svc krawl -n krawl-system +``` + +Once the EXTERNAL-IP is assigned, access your deception server at `http://:5000` + ### Add the repository (if applicable) ```bash @@ -176,6 +235,15 @@ The following table lists the main configuration parameters of the Krawl chart a |-----------|-------------|---------| | `networkPolicy.enabled` | Enable network policy | `true` | +### Retrieving Dashboard Path + +Check server startup logs or get the secret with + +```bash +kubectl get secret krawl-server -n krawl-system \ + -o jsonpath='{.data.dashboard-path}' | base64 -d && echo +``` + ## Usage Examples ### Basic Installation diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 5635fa3..f24261c 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -43,6 +43,10 @@ spec: env: - name: CONFIG_LOCATION value: "config.yaml" + {{- if .Values.timezone }} + - name: TZ + value: {{ .Values.timezone | quote }} + {{- end }} volumeMounts: - name: config mountPath: /app/config.yaml diff --git a/helm/values.yaml b/helm/values.yaml index 6d79b25..1a5d07b 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -49,6 +49,11 @@ resources: cpu: 100m memory: 64Mi +# Container timezone configuration +# Set this to change timezone (e.g., "America/New_York", "Europe/Rome") +# If not set, container will use its default timezone +timezone: "" + autoscaling: enabled: false minReplicas: 1 @@ -67,7 +72,6 @@ config: server: port: 5000 delay: 100 - timezone: null # IANA timezone (e.g., "America/New_York", "Europe/Rome"). If not set, system timezone is used. links: min_length: 5 max_length: 15 diff --git a/img/admin-page.png b/img/admin-page.png index ba82843966a0c8b7c1321dea3cce05e18e35c8f3..790e3c369e01386717858466922be3c012a1c3d5 100644 GIT binary patch literal 21408 zcmeIa30xEDwl7+Wiro&h+X0lx78Ml*lvxtu(1OUMBGAkzLqvukAp{5z6_svfP*7$P zZ4^`{1pz}y08wTV5g7vnWrz?+gg`t7Vd(M6PynFI1D5|JReZ%_J zTL1rAtA0OgYq8^;!s$L#$l`&g9Y&$H8s|`n<&nsD!362@X~v zkK2Hguaeg-UtA52e+W74jE10%4YHpVO{ix+5M*y)`OEPO5gs#Q{OcjiMV6?RZ~5`b zrn5I}PTxEKNB)}qm$Q6z5-(l3z2%2fPd6H+@2D}xgg!^ZpL*WD+g{~6J9FU3cE1cg zfs11pbv(_u$gMG7F6FfMtNlvFv0dNtP6vM4d1~`te%>x%Qyx) z4Z6O0?Fz7a$_~%!UjIq9|8ehs-~8VL_TNJCUw2`2uaTEm z?!E=-4(sora-h>!DqJv|pe^*ZC!oe+^Sn;dieu46P~FRfovXYCO;Yglsx5|4LX5?c zOW#gcuDCYaF1s-CzWL3t3LM6#ii=>#Ccvw@(M2)&_PP& zFRqu0-$db`5mzWl&40W;Lo5_@FM##}O_X5S-RzWjBe8^M>A)>S$mzzaYOmc`Rph#G z7)r1Jn#*HXIK3pI1CTK!ch&y6a^A!m1P z`C-MrhsV~|W#|o_t_%NZI(E6bm!h4z*Jdhk9TX%IaOoo>Bge0Yyg9w^r_3d}%Lwu0`{19F%bR;}c60;aIADX|KYPTFY9d5v1!OYpQ=YzTIF)Voumc z%S_#iCouNV*TWaz-jp3T#HK^XEP?)Qd@_X?9!S=-$?_T730V}0aNbY2+aM*b?0*SU zh))A~uxeDQrX8n$4YUzCH`XE_LtHP&m4ilQ4S(}N(v%=0iLybX7|av(&>O6)mPF^Z=L$FYE0b z^`M4Ba@V{M4`N$G+8{63rtg!O2Va)kc+J~EtTvmw@_T22`S{1iMk&==ymBOh=Pq|_ z|No(;=-S0V)i(!uEV_ENyWzd!?!+XrIC5#2&3lZk)#9;gwYZWG3nam$xI57}sfUjh zqr4=`?OadSp;eGqJ6J5&4o^PqmcB3=Y$pU2Vx8iSIB@M-7AT=YC~5-^HCF2nC{zX zCaZStSfkA@m>A!pE+q@b+xN#6LTH@&trVasXFrF)9~41<>Zs7vWubJxrFIv5@O z?01N4nr#@=ns1Xm*R8|MM0gxArC2!7drYOJ(eDGjp=*ba%led2?2MM4RkNnG*F>Ve zMlI7LM|9MtqS|Nm$3J>=Xf4ECw1op|iC{H{%e(*-^)Um_p+B3cBsxJx<(E1|kqKLOLQ?@bv#A|5BpTB39Z6NEiH5}`JzFDnnsGYf2#GkINV`7OxHUke{h1EV^K;3b zS0pc`O~L|HNlTr&IM^(@^Hd~v^~ok&Jx-@J9$|7%CkDHyp*)NB;8_TppAHduPlbzh zA6T#=3ZnfsUBV!UCu-S4k<56!QI#5!H%pO9p5rr_qBg|?B9+GL>f^(RUsJTC zAr4f&xSkfkRbP&!^7|!jSBD0sqvrI4xeC!RY2;89Z`uoacYUlhKo}_9N(!FglxV`t zO9NZ#q|yFM2vJ3}^eM%kS{}ux&WoeR)1HzR6?zID&UiS8sse2Ub+p|kymXpvN}#xl z*)m>>wQ?-R=`-=4Z-os@dmG$q{kWsV4hKd2&fXY4DvECvgYsCCCvkvkWNxdM{(C_i~BXpAMov} z2=-kSA8b!CzNReDHg=&mY2wBKaCL`tZ#-pjwpVHuJiHi+@wX5YC9tZP$41vV_|TnB z5)P6#)=b`G$w!H}!m(O+)x@8meTe`lBy1A#9#$w6m@+7z-{SA{ToJ^w#Tenk5{$5g zIX1k|(EX+hq&a573929li^v`$W z;vR!`hB(i~_#$Ck@|Yd9Q~3fuww^oIP*A024cm{DES0&5h@5uC+U4%YT05CI zSF`!M?iNnWEGdiA@Tn~dZqE|Onn{g3>G?LE9TX$&)7*kz>3N+~BvwFhj(wwaQmV0xT?>aAXB>|F=<-Fy9k93_@^`Y=c zT1JXb>vrb|aqSs;5o>qmf+=Z%d8m2b(lWnhkGilyOOK3uyrFg$rKX#{RXxh2lQx0}Xv3F5mMStNe zkK-rlT(?E7%*d$+G(UUVuNinjsrFZxZ3?R@*klpgGs99GPd4M-ubCMGPajKYlNw9& zS*spcipj3G^S2}B@vL=2Xd+S?>?kQ{a}`x3eHKVh^_~ijO(`WUAA!zhOnJJlfXttY z+1w6hn4q4BcN*^MmQS@W3#rfHtbUHDHe+j5__H}OxKX-mY98RY;wbLE3Stpm{lW?QQ@jfa zc1qk?RJ!FYH?Yv{%xFh#4mv7*x23HK>4;e1@WJTfB}}LBhxGjgIa!`jP4PSt(KS7u zBSg$09KMWjYL%P2?`pT7a*`eo_N>rWHq_T~oI4@tK9iIh7t6Hx5_119kXMe(kaTjN zE;viSHY*M*Im5GSU(+tXbm}ki#bRk1Z~VKAc-^JZCZ3C>Mq?V>9KzJE?P9x)V^{>~ zfb;`(SNoI9NM@}hb$Jr*8AwR}HnI949DDe@FDtF?cc<2lc329$mAl==-D;QZ*qwaMvS1+|Z{^}^n9 zt=u;zZ)d2I;pZa|G@2t*4Waajbpq9i*2?(#Upb<;XIJL%(uVcsec5s(Zszc*eyKpn zWhY&{a$u|7IN1)9K4h=Qwi)tXF95zq5GQJR3dhc-Pq}|n@T64_QSL&}8BL%q^B8)9 z@$0S6SKEowrOY^s7)-Eq|6Q%PRaIsoo&y`c&EMVTSfWyMNQx1v`*c*{bHtW>=^{h8 ztSRvnwh^>cc~gCa+U54F2PDrnY z%9@u|6@k*^@&JZWx!*{taICde+6Q=#owBA3=y@)&1KG}@F2mSaA>1m;J>xFZe*aB| zZ_@Zjg9k+Q-&+1_9#YDHz_uTO`2hTPE zqw^=|(at6z3UEz`yTG-Q5`)H1j_myfn7D`)O&l9)iyj*5bKNb5VHSYDg)qcJB%( zuh$!&IrSW3ep6OtIpGvleLmu1^=ds-e~FN0h3!qYJ*r83!$OrUHCoM8c6s)a<~UD! za|u=Mb0Xm&MS4ieP{G?oIas@84u8A z;YhVNQnXO4P^(cJHuZrEv)oJJe9DnjO82Wv#E1^nHh01*XrEnBNXW}`?rWhuo4n5D z>MP7%_JaP7M6i8gFVKRP<|iM*SB!iMpBZ>arBZ3dj)hObR%)<;>R7liC|4Kp1(7HR zP{`jm_0{+~QLe2#I#Lj{Q#roJ&r6s{Voegg!nImZ_{b)9wzx|8o7hB_}^$>5Zu`8e!3+<*v*6 zwIu_Bg(x_Hm0(+lqM)<@T^J)M=cuF>;BZjBZQXQ!MC;DlQzpZ^4by?LdBwMZirb8eIn%yG?@5 z1qUY5*pcEXk(w)iYMGaIA5({efo zkfX=am=E2$vxaNtUTlvH8NZ*ST)LI&=hmIMgCaZgE$#91PCF*rJ2-M)mGp`dE6ES5 zsM7crt>%_}kQ;@-ZonqJEiY+4q*dB#Vshwiuv>p-H6tNT@AfdGweZMjz3XkW(ic<6 ztD}Fk2bMI~&2i{J{A)rt)@yPH9tevUoN{^5Y&1;EX|U{wQ*IZ*-0Xa`s=rjD6GnCG z7MnRG4NUexP*a>tpGVRW5mu~Sd#YedNwl?Z)a>vY$!2)lw6Gt$oJp5jOd><=3nOwd zr`sSS|{-6isvfxL~$zp7jEK+YnLHRdzWNC!N4`(A3o~>IYCH4nrCn702T4 z+A1&AL;->7G3zIbot?(+thO`^ZcQyX)H+vEhV9vE6-wh#i1q^NIYsA%Ma&S7=82e7C<&s|X6qKpCe_7I#pV_p;SULkx;2+Jf_mqtYQrouamM0c8p z>;n|%OWPetg`^U_xtYo35F{$cjk=r$TU_Q%PTgq`)hEI51MM}>k)NGSyv@sa@phWR zC?-5 zdC^)7){yBhmOe>4o1523NBFDe& z;a${vZjswnE8edCC%baH&<{Va7907U6R?A`@DEr!VLLi_9C$?Ahgl-#{h^0~o|=12 zr{&li2PA10T{$NyJH^H8a>DYzC4~{1HyymJ=9Z>YJFBFvR<={@gb@cj?X`+BkJmme zO}txH&LexqCX~uSy5av9hdQ9YqocHtA4Bo5%j-N1Hi`##FyD^o(Z9Lz?!?1n?7{6O zA6GO5_m;R%vf1oGK(>NyX(`M50NB&vP=cm8n39AQcFgi4KuZ#CtOvM4O#?7!n-g|E z(U++L`ojmQ=&1aia;4hf{Ju;XVF|pE5E!5+9<*=q1_te)@M_@j|5$!L=d7z(-s>YnZXX=UBM*Ok(ISj99a zaNza+Y{Q}4anxi{^Ky1dwo%ZP60&f*s5*G($?d^7#~{~1H=~z6m-}1CDtpj}vMUZ7 zVGfM+MEf)T7#xbClG8g{8y`Mo1#oeKGN0~;nFxH$HY~ii)pnIzwq&BdB1trnLz${B zx%LADC71)8l5n0;6;rU#m2=Sfh^A#(xVW+1h5!5OF3+^+U2St-)K9;Ml$T9!&aF7v z7RL14e|H=GkFqr1|%b6doF;;5~$0r6q<-EW-<9=cGL<^q#h+b%s z^H+IhIxwmVy8e=UO7LJ@%gGmqiD}BI8J~XaT{JKNd*jw`@P4t?fk8f@2}r~pK$JsH z7-%r;nZ-#j0vc8ZTJ;Tzv{(+<*ZBpPAd$3XN`X`SrbI@8wc>u87i z_Ffp2R~+YvZqqg|riIybQ}&Zcl;DDZ^lCpk~a?oz*6DAPusst+1vXDlwDqZCM>a|R+Uz?Jg!#{2_J2ajq33166px6{x2F5c z3#xzga?3o5xnc`n_+(9hk*cqJ>aPfJT3T8=*aeJQGUw-_Xsx!V09=7`GPx~4H z!cH-w&L&2FzNPr05W05mmz}GwZN5w=z`yJ~`@r7TFr?cB6yDTk7sV@1Ens`M8x7<$>{kpM)VT@SD)X6-HwzncWW|OfxUeG)nUOJ$SV!x&xBRsYQ#=eIpg9RsHNoGYR~GiZds4rZaR1K3-dwW2 zmWu(Jm)ncP+*Z>xE#vesc#Dx8qXVxvJ%f_PNav{GQD@3UEGq&RNY(2twWYJG1Oe&+ zT#<4VQ$DeIItoAI?V8o?OnGW@g16ihmHretHqlH(RVVp>PT0`@fr8?+FPyaNS#Z9P zOlAvKj@EteyzY&kuBB{0z83Nx-~|3kj1GH$r|8$KLEHRJN}`97cJo3PD%R;8>MHv- zF!8LxR;B&p- zm3F+%EkuiR-Idj8oDI&z;tJq9^B zsBH=s9_xG4ueWTg70a(}s)T;UWgKHTNFY>I<4%TG@+sk;Uk5tJ#FXHtmudX2$|8ET zYKM)26t@DpHfjL`@?5asHUlkQ=*13IH*~%}xO~}ktfSqOo}D(7j7V|BzUa$osss~q zmpq|KH?954ay1cU%nOwa`dA_3RU^k{hcd~FDLz9>fnYAxXKqq5Nn?5I^n%*FE!3)` z_=qUeaE`5Cj#=2Uo=(dKC%Tm(BJEJuwtPV`-KIJ@i1RV7AQ-E|78Xs6PjRjt<>i{7 zT*`R;C3aNyf*-+5qh;`55Td`mOHYHGJu~-PtPTW)EU%SGnrigMwi$IiYvE$p$c`ny1?G9FgQ%#;}&xr1gU5M zdvxY7+B~0fpv#fbmB4KWEg-Rsw0hdNcctXg`L?LxZ@O?$?B80z%M-r;*r-f4$)sfo7`qJPfJl$zxK@i%Jb z6383~`4kSDiwL*Vd%FRG8s)&O8lTDL)!qKS2)wfhV6GFifyWc_0EqKet&e6EJAw01 z37iM;`L|~%0dq0+>3QVj)ASd2S2PxdIa_f}6X+)b#(mg$L%$%Ua0}MC3bxB6;MKuk`oN zTtWZ&SuvV@0}^2-qE^&HtmsZ`d{iu#?i(wOhyY&X*==CbJmkvLhH`DP4Sl=vZAiF& zdy;?7FThvwng*=;h=E~&wDMr~!UZb1q8fN&BZ~lbj~EDY0~y1T$e<;1fx4&Ry~n|Q zWKP+2e%8e_01Jk&>|@&5c;};JA8>&>IFrjC)N^=KF>@>+n_o?cS>in{0sQ*4S0{nS z=xCP*TU&m}xiauLl3eT=rRzK|(a#j;qxQca3U`L(rN&6Vofbna3%5@u(iIJ-Q zLd27CR4!agxwq7&%#clVc_0wz04JMWZs^)I9ft-tVTosGv27nVy$+`p%`iUFk|^eV z^1%O21^%~^L8aeFnO1pFB<=+_f3W5e_U<0JVl8!@C|xJVT^BLJ9Dz=25%{+7nI%}o zS8-;ltY_XmrDEI4AcScL)kqU%LdHJ0)Kt_T8Gvy<^2Lm~{pkH&pz*h5mhMWlCZm$x zI&J&VHG3g<(@j^s4~Q+RSOBUt9JD*a5P}rg@?aK>87o}Mhn!tV2h-GHlAu|HSX9mN6pO|s;p&5c$&2LUjNU2IUFNJK?@ z+nS6&n7VT9>i9osaXp%`_a}=JK)a@7URn=negF^KxpC!tw}Pjhlr4(!{6jT{T8>3uN$&8{YH+&l)$WEd@Xx2huIr?)2>9Y)jbZoj&g&^?K z{yBK4_bxSsJ|MMQ=)pi#{gSegi>dI2TDE2KDYZ%duh!}G|?HB;QrH- z*TZG_M4Zrb)sc`VTC|8l$*bC>klg7CqwoI|lFFZ<-Ck(1a4KrPv$fanqTv|5cjojF z9Vbt+Zr71R>q>)?j!wU;Kd8DR@lBYy;b1KCw%JQ*QWQc#mDfQO!%yuhBIPjNA>TboHo2f7h~Vw!wt9>JEQW z7$>cWig6F$-7;b7dbOMq>p#JXHZ(0W!46Y@){EjZGLh&gKOby(Wx-`s>_OL*z{wh+ zYGgR_$mslIW8--hD?RUq8Bzr<7CTPa@V=*Hbo;;#2(nd@DH_s{a&T!*+)LB1Hf?W{ zD^Cu$*YIlmLFingV`uoij>y2a*9U9f1Vjwf@U-enMZ(*9IxPB3qE9l6YKm6$Oeu0D z0thjYm;2I4FFiW!Vt!C$5j#R54=KGLRvVv5@|hicvamx>hx8aZKUBkG?ImOVEK*eI z9P9j!uTd!AYi>yQnMgvG>vbK;WBan)V@_&k_D7eR5`$v1ObJ7o{%F=l3Q;u~pc^z= zQscvuQ!c}CK9=+n(hJjRGh+AonyQXSuLe%L`+&h;HbanyhHO2A$`j+hDdl~fXe!%7 zF8|Eu^!1r;5T0%i*!DJVSASA2Idyx1pz(cX0n$7?zq>CfIaBXQG26h9*}ovUJ#ij6 zVM=d%EyE=SWwdA{E!%LO6s8c7W0I%ph8?@#l*-el8nbKyR(GqKmg5Ez+5;1J=k&NM zWt$>hv~1OUGWG#WTe=mD@SP)70cY4BW}eX^dkv;unuKzez1O9cR;xN=*$V*mvfJ3z&fn~e(l*ivQC6b>b$!Me$hxZMoK%pHd7u+%SfA!uEf3@lnsVn z=+bP_Wz%p!tl!wY44c`ty$w)+zYU$(W3>K#180(6e60_r=*31~NxT+4eI0alI5TtR z@*7@1tPG81Q|RrJdMQJ>sap*V=pB9Vr=+&_R^f&f(1?MI+Kg~WJPXj}7g9@F_Yh0h z8qf#<)um6b?w)Y3(e0`XY)SK9*JY+UB3l<64*%5ok1Cy2-;q$Co`DIFaFpHnN@|lA zn&U><)3wbbC$d|>aOtfCngiYyZ_3&|_)5EqU-C6eVhRLtDEE+SxRH)|ihjXvT`uQly^5QwshAZHcV}~9OOwM8^zF*Cf1~E>C|s$am^h*Be!mb| z(pD@}dnJ^gX97H@0U;2KU#@XuRIj|sJ45RD(J1J1g}W1d<^Z~Dn_(66>Nj3upheG6 z6iy9MD$F@~pb3WccMR+B9FAr!_#2T)R;3*Y3n%I4;QWgfCEEtOwvs))dAaT^)tv07 zR;k^EsZX^^Xf*^(@q%nWb+`(_?6KeI9u#re=gWj?DSaxpm|je=AMYC=Vp%2|qfj06 zm*T>V%8-kGfqnPFD254&76K$Q!24f`D1gP{eIoOE$QIKV8H)#^iait;GZO&%0(Rq! z-<`t%W@WK@O?9|iKuODAvq7S+v{@fs^Tp_uMxAIAZfGwU{^F>X(a4Iq5X2IDkw5caEDBiV~NjfXW`0 z0SepVncDrziU;kaIj?W@K8y@Ghx>7E$eI7iW^&R)8P&SH0- zcwtFkDf5Ai5pl=>zH~ZCTt8N6)Bk#s+9QJZHD(Xc@#t-VHT!ui&)xx~eXz$%c%fNW zBsLxoV4ISK%!jo24j89`#9wUs^uD)ENX9*?Fl$M%^XV^2bgt!}PNt}yu&5lF;PZ4Wc{3Yk$^n)FA*p;JKWStE zXeAZk#(Swux;+`2Slu6C->W~&4Q%7AHa3MSmIBP+McFJBU%wEaVC+{KYY3|a;0B>tLC7gy{_^lZ7&YCX{OZ- zG{vs6oo9o@ngCEaac%QgMf_~S^sx96X7)D%E*Kj-nLd2MJ^E&v&xowoUB27iS-qBj{;P;HBNf zHd3wbQq!J)LGtnX0{#~J&y!~F0P0IK_5yvY)Z31_{J?l^0t%8BWW7qyHy^Qz6U-T4g3$d{c9o)PdGjIE>8#4WkIWd0`;us+3WU>0Y!>XGyYKV?O2JB z=eT8ge7~^KptMc3cF@DfL;#*$iD8-@-BX)e5~P!ng1XSFddn_m@kyZQ+t@@5JCZ|w zBm>yq_W?PtdthPwhQ^N}^2vtFRdx>ao%U@gnx*4wi(P(~N@^o6`DSZ;TbCMIui&b& zL%oPy$8DvU(V$b&^;2bGv;l zE2)F0J)BIJCf`EP2OwZj!jamTS@v9F>*;zXHR2M@MkAXz1@9g4ge{zj)yG^i8!%BE z8H=8ucA*=B$Ls?tux=kuQ9-4dR$id5bM|AkIKEt2IH(#&no!qLmW~cD59BR8UYF&j z_cH?a^+2P==Nr%a!#kejWcQppZw4a7AQUue4YE4FAs3Z*Lpe(SN^HT8IU9=ER5KMT z`qDd&$O+q7Ui-4N`bQ5vMv2Mu>dP1YpF_IIM zrT*?Xh(smGrZr!+*#Iv&qy5#|f^e@?!DuVUB{25_zOwg?{f&@!Hu$9*C5uh|IsIe% zX#eIyE2t5H=dY;y3QDHI+9l|TwYKj|Mu;GGA~=f0S$fc&qM>q3@;8&i>74*{MO#Z}g3jQpcb0-F|@LtNz7$dgJ^z zmTZgiJjza8yh@@F#e{RbF9WSodXu83dnydyoj+^k1?D z-bj- z$mUs?*+vTO8 z=Ke-UoxNl7&BWayjh$oxBEn@ROJ`$ZhB6<1p)!WsAex(`J0+8z3&bkRN9ALJ;Y1dp zsO5U}5^sp!uhMU%|3|545oaOpkbI%?&5Kz>sdiOK1tM`p6?gldMgD%uro}O8O)(p= zwKhOmoqZD{?8}miC3RlAIq6;YwD> zaCv>uaxt7V|GP_YK=Or}=o4zcmTLwaIZRw?wj|Bi%-m0~M+-lASf;cg1>>0o3hP(b zpeQLDP+1vv9hc3k*&aC=A_g6IF?2}x6>2wy``L>QySkI~OgvZ-9c*LTr`k-B!XERg zt0>P2Y?n?Bm>;zJUW~b~WCpjl(u`h0XJtJn!8Y@*m#4^AR?1z{Fd?OJXM*999k;bj-PTxL#CZ_Wb*PE_9%GFd6^|4@PHqDBYWz{MIhSm@eE zKq?(OoTVX%vUm#@e!CtR(e-4xpTTgoX)hS=Pv5|}ZC;Wa$)RH|d7wppudPzrGg3@x z#T9BQ%?0Wp+x3?FEUNLreC>0BD0yrdiz42;g(zV$isJ(+yURoPW4lG6jLj`gI#WNo zp`@f}Sn1fX1}yoK0H;LDWZ9}k*~!i>kMR%NwHL$&e8uPx;q$#s==|;!MdgR5O@2nf zN%y(1-f~@*bs^>pE7jH0l9qMvwK_LgpUJ9<&7%8W;zZEj(PGnU11=%S`P6xAPnB6Z z)<$hWQnlCjBI_JFJ9%jKjJE3D=9SO~z+XY{zry7|pVd9e+psyxDB9bT2HO|C!Q`?? zt)gZjkzzH>zg^N98n17X9)9Tybx;~dbsP90y<$0(Zn9Z(BHa@o@*56g-L6whWq+>X zufNqDKXtN=X=WfGE}YSx$*hLS#No-<rlY>-JWGj-JiH`*(Tj&Lt{RRj37Yw|JNX}Pcja(MY;Dg6H zx^A1lS8W~VW@T`L7`+BFmBsh*)7uY4?36BksXsi|-MKKx97yd?p2ye(am4IV6K8VzHo=E?Sjw#J*ke9Po-xaN2soQCDW9Xw?RUIRX1&n!P1ueTTR)Mj1 ztkP)13~o0KR;44TB8Wa}ULh4P46s=73sE}~&A|q+9Lq3fX}*;ZvRo|u(niAbfBxgkxv06% z^=_#x@$0=y^9?*<(Z9vb{>zNyb2W|ulK5-{ad9@}*%Qok4hpaeodH zL6T&UW90JmV74GU{{uCrU%_B*wk6JizBaE@<{QbMBSk!h4_28;)1FpeP5`os*d)+u!7b^DIx=wCU|&&4Zkn z06RZ?EQ(O1jo2lcdu7V^vBAvJD3RR52#`3C2T~ECkhh;eN99zu_ zV-?8c+H3{9<_H_>W>>r5F;jYlI=8K9cuaf7g~^M?uBZO^xJgErD*UCrK*H ztBKFHep0(KHkwX+fCxZ%w*6HL^rh#(4huZHT%`YC!QjlwHqEi?1_-O)Oo1(q#Z$4I z2R&b#Q$t<(PP>qB1)orv^57thgo9b>XdA((PE{TY*p3b@(i`~4xwm80pKt5~F_0ZH z5qJg+0-{z&;Hap6-`uP!-{44a$MqUvu6?L2uA5P?@jYexX7p^!%vSkOZWk~JI|lf3 zg0Pu%xxZOpnNJb6P3>@?*KS_&8Mc2CyCcg1iT@=;Q>28v*)zw+BNO5fMqH8th;xw(Vs zRTv%96UcBI2eSeC7U)`@tU^-d;qSsv7JGMV*fzmKfgMIqDDMq3{~_FbVFq?j{;0-U zP1Z*vgKAo5dQs#XQBL&cx#!8&t=Ysr+|BzTG6i=?S#9blYlKGGzIfbI8~)m<07lQXA7)xXjGtbZ@UESWAotqWyzIPnO)H~cV zw%mWK;xWdvgw^4$u~tV~oEpktXFL5&h-qpgafn)7k$S)`3Ez$VPN6s{1mi^X@zL$o zcV=Zm%Ry8f4)drtSeW=JhmzihF!#&e5i|QeE>M`nf7Z)}gO|^QOf4)O2XB)3e)-kM;$)JX z{6>)QQTKC2t?A9<&>5f)btfvM$DInh=TeEwPpWr78z(gpLQ6V$Bh3R48t}4yCW$NV zIhq;0pdKcBV~-tvsf!S_!fQ7|JZV>smr9<@spnMJdJo9nep3S)uY`UfJyvM`R?#!M z^MW_DkzIFPE^_w0V(LYz)%S~F052*jR$Jfz_cT3BVVnlxq4_SNM4yc1ntc_?L1+H& zM4N6<7Fk)%C8Iz+B#T89k%(=dp4;nP{qk;pLCC!X?J4YKn}m!U1NnccM9F-CtO)34 zJ%>p4zPJDaP#|&j+To|FYZ!ecp9(;i{nQGiQYo z@x^1*6XRdqdniE*$iIC{&XvAGbI^*y!!L2nDITOtU9TwIxOG(_diu&6-i(X%uR^GqLu}ay0 zJ2cu9avk;N*stQqH%%b92rPtz$c9_gsj{ij`zn*+AeYl($NY|W*uC`1=jOMREre|PK>-2J~sodV_ue8=B+x$%Ede&fHJ4J7OTFXGuJ aRoDD|@__N=SyNfBEKk|~QgXuU#{U3dXx0$` literal 21128 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 diff --git a/img/api-secrets-page.png b/img/api-secrets-page.png deleted file mode 100644 index 77b47c866365b5a57575db8b20199c8b39f86ef8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 91976 zcmcG#1yEdF)Gdev2p&ARCO8Cl2<{Lh1b24{uEDhl?ht~7;OgN< zA803KNfD^h5uzQ)hbLyj^1@J17179d25^wii1tz%PEb&2T|a)Hd+oj$LqR>}Ns9@q zy6NsOdTOgWuRWf;6SB6z{MPJe>h?jNa*UO^$XIv0GD2croJD?+bs|b8rn1>8k14y! zB2^<%YA?_FMa?iHxN=F2O@)0NSGNBdwz#-tE6c?#Y@ibw+biwhh~}*5UVaiEL89dA zbe=7?%uBD4EvL6{-V82d{JWyBe1S;+KGO0b8HkJhx%ju~T=OZ9Q4hu2gq8lqyU|goj0{cGsPy|5^YlI=CUxm1TgCVd3WsIGa#fH{?B7;!V045kGz6Fx_^VDg{CIA zrcdQK8)4=h=T6kWYxw)7(E35l&bg-qS5I^ZH>ZscmSvh3>!bej_1;&{BkvVgFPQqg z#gYYJg#Z`VX~*8F;z3w<6u?lU@;d2nAH})_-o1GFaMe_UNo1UHm#JtbznSAr0RL}H z%E}5NGi-PDF8D_eMjT;gLqh*L8MEeq*$HwqcBFYg?O~h?>G{uie~3p4umUczfu zqiwDphip#{y`pAtxnOuc6gU32Byirk0C8OVLTStA z^wi8Yc~Daqx6%HaX7Pc3P4LVt37DL3gy1Vd&4%JbaOHm{U>LI##v0UX9Fo1@wreR0r@7+WdyG{Ut#|<_HG-<8vMJDmhgY<1S#_3f3HWN4aE6-=LuO*`M-{l z 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 diff --git a/img/credentials-and-passwords.png b/img/credentials-and-passwords.png new file mode 100644 index 0000000000000000000000000000000000000000..acb134aaec836fa9730b24e58f2e252ebcb90a4d GIT binary patch literal 73835 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%0Dr)j0{KUFGFL>bI4Kd+(_rUD@B3BY_dU5 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%0A`C1 zoKE;t9D(Ahv;#LPke)AytGLj@XpF%-UQNAACo~d2Ia!In0~mRatT!zPP`sEuGuv-R z|BKJ65h$d^UBcP@F;SFXxXL$D8MZRmIXwQfU3f<}t|YD?N*GKGuVF}5mXW$R|4z`O zwL?p#5d6izv#+RUMbHZdT0U{EdT5{_$5iqIz27%1H($zkGM-~I^M6Q$GH055 z%?8^QDjjHh`g8^+k0Sqz=c?i;drAC{vSmT3{yR+#8aNU_BaZ9Bu_=qlX~~NO2U+^RS=)NULbV5W0uG z;+0m*=v|D5k}`)ci|iN~GlP}~F7}R}e5dsKa!1Q%vgEv~_N_b8{0m15^_Jr)a!dNJ zr-=o{2Cu(kO-QxEsg!FC=#KCZY(iW7&iZ5F97IaRk7S*pq!}V%RJrKE)EhCn>JQz% zenm5u&#?>*OIvQT3$gWVQu^2}lF6UTkUuBpi%Txwge<@rt(i~SSsJN1u1)|!$|0US zQrZNty$4HfS-Beg>{^{OiJB*N{eyuWIXc3u&;90;Hgo3ou7790Nj2byWqGQNP({y8 z9WP-0Y@k4V7+JC!6xn3@`-ZP?J7|hW-qd0DLih8}R!H-mfL@5#yG%kaIy|!*P}ywy zx7gmFjZ=*ZE0LchVzCN_jO1zVEd=tZ;QVSjcS)ME;^bd%_sP=y0$TS(N#D`;WD@%V zQaKU1Jb}hJ{%YiUCE3I9U+?>Q9a*6q;|jn2`s*{E^%;o#>mtKQ`N9$Zde?K)#s42Z zh)dpOkDi$^V}BZgeq-(D7uFlf9%7O1o82pzlrZTey(c30L@lQvpx{(+n>Op8*R-Fm zUkqwY(Glt}Cw;3(z|Q*PBwha9YJxx#U$^RB)A`G$Y*sCBJ$LYcpz%8R{`C#kXP_^Y ztlsvdiFvIiYogAUL+5$pQkp$GJ7lMwPFU>-R|Z7h%>^&R_LctGL{br*9>^si(9_PO zC-07wb~9UhaJi51SDX*462|EghZGB!14d&Cf>Js0NY99WzKQ;^gz2DR=?Z_$S+cft z6UBHF`p*v=xC1T*eP6#!Gs@f=VA->G4Nf4-9UgtZC;Id6jlo|+OU)*QZMB?lI0@E2 z-j(|D8P<8BPU({9bLZDbgzEiy^v!9s(|#h)=09>l6!E7|%bl66>Jp$okYk(LUY3E& zYcFvFlIp?(INn--%Sl+sZ-_2?vGu4IMKQl=jfgUzgBHN;V4)h;1qNJLNyPHdHA5wG zbfuEYxsS)@4Lv$lfo_{zGa6)d8O8b^dGhA-qN%{g$Q2iPLg<}=K>f&lq+D=;*0aN`7W-t(9gRJl0yYDfG(j*0?XF*l{Kg1!=YZ|U|-0Z@=nGule-PmC=JBrHI(gZw70Bl z#qw$UkqIUJroDJxePn$+G)320X;f8!Zb#7gN15dC8m*Rg0W(9ai(&!a#CNu@@POg3 z%{yWHihK8~qQ|Z;XeB3|mM`3BZ@t6A;0@;kuDi_g ?%czCAmUcWt}VD5Cu_R8{( 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 z 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 EA20@!hX4Qo literal 0 HcmV?d00001 diff --git a/img/credentials-page.png b/img/credentials-page.png deleted file mode 100644 index bc3fffa0b7240bd29b98e4d53c2bfc6e911fc660..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77534 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

&W*ZZSLx&*_q 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 diff --git a/img/dashboard-1.png b/img/dashboard-1.png index ad11dd898e332db37bcb70d04c943f2987ca4953..4479914823a5b58fae54cd74e1185f8d788b104a 100644 GIT binary patch literal 136202 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 z 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