diff --git a/cloud/APIs/shodan.py b/cloud/APIs/shodan.py new file mode 100644 index 0000000..ba868e3 --- /dev/null +++ b/cloud/APIs/shodan.py @@ -0,0 +1,134 @@ +import os +import requests +from typing import Dict, Any, Optional + + +class ShodanAuditor: + """Service class to handle Shodan API security audits and IP profiling.""" + + def __init__(self, api_key: Optional[str] = None): + self.api_key = api_key or os.getenv("SHODAN_API_KEY", "") + self.base_url = "https://api.shodan.io" + + def get_public_ip(self) -> Optional[str]: + """Utility method to discover the server's public IPv4 address.""" + try: + res = requests.get("https://api.ipify.org?format=json", timeout=5) + return res.json().get("ip") + except Exception: + return None + + def _query_host(self, ip: str) -> Dict[str, Any]: + """Internal helper to query Shodan's Host API endpoint.""" + if not self.api_key: + return {"error": "Missing Shodan API key"} + + url = f"{self.base_url}/shodan/host/{ip}?key={self.api_key}" + try: + response = requests.get(url, timeout=8) + if response.status_code == 404: + return {"indexed": False, "ip": ip} + if response.status_code != 200: + return {"error": f"Shodan API error HTTP {response.status_code}"} + + data = response.json() + data["indexed"] = True + return data + except requests.RequestException as e: + return {"error": str(e)} + + def audit_server_vulnerabilities(self, target_ip: Optional[str] = None) -> Dict[str, Any]: + """ + Audits the server for reported CVEs, exposed services, and SSL details. + + :param target_ip: IP to scan (defaults to server's own public IP) + """ + ip = target_ip or self.get_public_ip() + if not ip: + return {"status": "error", "message": "Unable to resolve target IP"} + + data = self._query_host(ip) + + if "error" in data: + return {"status": "error", "message": data["error"], "target_ip": ip} + + if not data.get("indexed"): + return { + "status": "PASS", + "target_ip": ip, + "message": "IP is not indexed by Shodan (low public exposure)", + "vulnerabilities": [], + "open_ports": [] + } + + # Extract CVEs + vulns = data.get("vulns", []) + + # Extract SSL/TLS info from HTTP services if available + ssl_info = [] + for service in data.get("data", []): + if "ssl" in service: + ssl_details = service["ssl"] + ssl_info.append({ + "port": service.get("port"), + "cert_expired": ssl_details.get("cert", {}).get("expired", False), + "cipher": ssl_details.get("cipher", {}).get("name"), + "version": ssl_details.get("versions") + }) + + return { + "status": "FAIL" if vulns else "PASS", + "target_ip": ip, + "hostnames": data.get("hostnames", []), + "open_ports": data.get("ports", []), + "vulnerabilities": vulns, + "vuln_count": len(vulns), + "ssl_certificates": ssl_info, + "last_shodan_update": data.get("last_update") + } + + def profile_client_ip(self, client_ip: str) -> Dict[str, Any]: + """ + Profiles a incoming client (microwave) public IP address. + Checks if the client network has exposed admin panels, Telnet, or CVEs. + + :param client_ip: Public IP of the connecting device/router + """ + # Skip local/private network IPs + if client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")): + return {"status": "skipped", "reason": "Local/Private IP"} + + data = self._query_host(client_ip) + + if "error" in data or not data.get("indexed"): + return { + "client_ip": client_ip, + "risk_level": "LOW", + "indexed": False, + "summary": "Client IP not indexed on Shodan" + } + + ports = data.get("ports", []) + vulns = data.get("vulns", []) + + # Check for high-risk exposed router/device ports + high_risk_ports = [23, 80, 8080, 8443, 7547] # Telnet, Web UI, TR-069 + flagged_ports = [p for p in ports if p in high_risk_ports] + + # Calculate basic risk level + if vulns or 23 in ports: + risk_level = "HIGH" + elif flagged_ports: + risk_level = "MEDIUM" + else: + risk_level = "LOW" + + return { + "client_ip": client_ip, + "risk_level": risk_level, + "indexed": True, + "open_ports": ports, + "flagged_router_ports": flagged_ports, + "vulnerabilities": vulns, + "org": data.get("org", "Unknown ISP") + } \ No newline at end of file diff --git a/cloud/app.py b/cloud/app.py index bc9e57b..1ee12f5 100644 --- a/cloud/app.py +++ b/cloud/app.py @@ -7,13 +7,16 @@ import sys import json from flask import Flask, request, jsonify, current_app from pymongo import MongoClient -from APIs import generate, EdamamAPI +from APIs import generate from APIs.mqtt import send_command from APIs.webex import WebexManager +from APIs.shodan import ShodanAuditor +from APIs.twilio import send_alert_sms from microwaveCookPlanner import MicrowaveCookPlanner import safety_checker import pymongo -from APIs.twilio import send_alert_sms +from apscheduler.schedulers.background import BackgroundScheduler +from jobs import job_server_cve_audit, job_client_ip_audit, job_request_telemetry sys.path.insert(0, '..') try: @@ -37,6 +40,9 @@ cooking_collection = db["cooking_parameters"] telemetry_collection = db["telemetry_data"] alert_collection = db["alert_data"] webex_tokens_collection = db["webex_tokens"] # Collection for Webex OAuth tokens +device_ip_collection = db["device_ips"] # Collection for device IPs +cve_audit_results = db["cve_audit_results"] # Collection for CVE audit results + # Webex Credentials & Configuration from Environment Variables WEBEX_CLIENT_ID = os.getenv("WEBEX_CLIENT_ID", "YOUR_WEBEX_CLIENT_ID") @@ -63,6 +69,44 @@ webex_manager = WebexManager( team_id=WEBEX_TEAM_ID, user_id=WEBEX_NINLUC_ID ) +shodan_auditor = ShodanAuditor() + +# ------------------------------------------------------------- +# Cron Scheduler Setup +# ------------------------------------------------------------- +scheduler = BackgroundScheduler(daemon=True) + +# Job 1: Server CVE audit every night at 00:00 UTC +scheduler.add_job( + func=job_server_cve_audit, + args=[shodan_auditor, db], + trigger="cron", + hour=0, + minute=0, + id="server_cve_audit_job" +) + +# Job 2: Client IP Shodan audit every 6 hours +scheduler.add_job( + func=job_client_ip_audit, + args=[shodan_auditor, db], + trigger="cron", + hour="*/6", + minute=15, + id="client_ip_audit_job" +) + +# Job 3: Request telemetry runs AFTER the client audit (e.g., every 12 hours) +scheduler.add_job( + func=job_request_telemetry, + args=[send_command], + trigger="cron", + hour="1,13", + minute=0, + id="request_telemetry_job" +) + +scheduler.start() # --------------------------------------------------------- # OAuth Routes @@ -97,6 +141,14 @@ def hello_world(): async def cooking_params(): try: data = request.get_json() + microwave_id = data.get("microwave_id", "unknown") + + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip() + device_ip_collection.insert_one({ + "microwave_id": microwave_id, + "client_ip": client_ip, + "received_at": datetime.now(timezone.utc) + }) if not data: return jsonify({"error": "Invalid or missing JSON payload"}), 400 @@ -326,6 +378,22 @@ def telemetry(): except Exception as e: return jsonify({"error": f"Database error: {str(e)}"}), 500 +# ------------------------------------------------------------- +# Server CVE & SSL Audit Endpoint +# ------------------------------------------------------------- +@app.route('/api/security/cve-audit', methods=['GET']) +def run_cve_audit(): + """Runs a vulnerability and SSL audit against this server.""" + audit_results = shodan_auditor.audit_server_vulnerabilities() + + cve_audit_results.insert_one({ + "audit_results": audit_results, + "timestamp": datetime.now(timezone.utc) + }) + + status_code = 200 if audit_results["status"] == "PASS" else 409 + return jsonify(audit_results), status_code + @app.route("/debug/telemetryrequest", methods=["GET"]) def debug_telemetryrequest(): diff --git a/cloud/jobs.py b/cloud/jobs.py new file mode 100644 index 0000000..d8bc1ad --- /dev/null +++ b/cloud/jobs.py @@ -0,0 +1,84 @@ +import os +import time +from datetime import datetime, timezone, timedelta + +# Fallback base URL since 'request.host_url' is not available in background threads +SERVER_BASE_URL = os.getenv("SERVER_BASE_URL", "https://smartwave.matthiasg.dev") + + +def job_server_cve_audit(shodan_auditor, db): + """Job 1: Audits Flask server public IP for CVEs and saves to MongoDB.""" + print("[Cron] Starting Server CVE Audit...") + + audit_results = shodan_auditor.audit_server_vulnerabilities() + + db.cve_audit_results.insert_one({ + "audit_results": audit_results, + "timestamp": datetime.now(timezone.utc) + }) + + print(f"[Cron] Server CVE Audit finished. Status: {audit_results.get('status')}") + + +def job_client_ip_audit(shodan_auditor, db): + """ + Job 2: Fetches distinct client IPs logged in the last 24h and audits them. + Deduplicates IPs and applies rate limits to protect Shodan API credits. + """ + print("[Cron] Starting Client IP Security Audit...") + + pipeline = [ + {"$group": { + "_id": "$client_ip", + "microwave_ids": {"$addToSet": "$microwave_id"}, + "last_seen": {"$max": "$received_at"} + }} + ] + + recent_clients = list(db.device_ip_collection.aggregate(pipeline)) + + for entry in recent_clients: + client_ip = entry["_id"] + + # Skip local/private IPs + if not client_ip or client_ip in ["127.0.0.1", "localhost"] or client_ip.startswith(("10.", "192.168.", "172.16.")): + continue + + profile = shodan_auditor.profile_client_ip(client_ip) + + # Upsert client profile in a separate collection + db.client_audit_results.update_one( + {"client_ip": client_ip}, + { + "$set": { + "client_ip": client_ip, + "microwave_ids": entry["microwave_ids"], + "last_seen": entry["last_seen"], + "profile": profile, + "audited_at": datetime.now(timezone.utc) + } + }, + upsert=True + ) + + # Pause 1 second to respect Shodan API rate limits (1 req/sec) + time.sleep(1) + + print(f"[Cron] Client IP Security Audit completed for {len(recent_clients)} IPs.") + + +def job_request_telemetry(send_command_func): + """Job 3: Triggers MQTT command to request telemetry from all microwaves.""" + print("[Cron] Sending MQTT Telemetry Request to cmd/all...") + + telemetry_url = f"{SERVER_BASE_URL.rstrip('/')}/telemetry" + cmd_payload = { + "action": "request_telemetry", + "endpoint": telemetry_url + } + + try: + send_command_func(topic="cmd/all", payload=cmd_payload) + print("[Cron] Telemetry request successfully published.") + except Exception as e: + print(f"[Cron Error] Failed to publish MQTT command: {str(e)}") \ No newline at end of file diff --git a/cloud/requirements.txt b/cloud/requirements.txt index 17f0a22..c642f44 100644 --- a/cloud/requirements.txt +++ b/cloud/requirements.txt @@ -6,4 +6,5 @@ requests==2.32.3 paho-mqtt>=1.6,<3 openai>=1.0.0 pydantic>=2.0.0 -twilio>=9.0.0 \ No newline at end of file +twilio>=9.0.0 +APScheduler>=3.10.4,<4.0.0 \ No newline at end of file