diff --git a/llm_team_ui.py b/llm_team_ui.py index 439f10f..03e2a3f 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -726,8 +726,55 @@ async function loadThreats() { var r = await fetch('/api/admin/security'); var d = await r.json(); var ips = d.ips || []; + + // Also fetch sentinel status + var sr = await fetch('/api/admin/sentinel').catch(function(){return{json:function(){return{}}}}); + var sentinel = await sr.json(); + view.textContent = ''; + // Sentinel status card + var sentinelCard = document.createElement('div'); + sentinelCard.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid rgba(217,70,239,0.3);border-radius:2px;padding:14px;margin-bottom:16px;backdrop-filter:blur(16px)'; + var sHeader = document.createElement('div'); + sHeader.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:8px'; + var sDot = document.createElement('div'); + sDot.style.cssText = 'width:8px;height:8px;border-radius:50%;background:#d946ef;box-shadow:0 0 8px #d946ef;animation:pulse-dot 2s ease-in-out infinite'; + var sTitle = document.createElement('span'); + sTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:#d946ef;font-weight:700'; + sTitle.textContent = 'AI Sentinel — ' + (sentinel.model || '?'); + sHeader.appendChild(sDot);sHeader.appendChild(sTitle);sentinelCard.appendChild(sHeader); + var sStats = document.createElement('div'); + sStats.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;display:flex;gap:16px;margin-bottom:8px'; + var ss = sentinel.stats || {}; + sStats.textContent = 'Scans: ' + (ss.scans||0) + ' | AI Bans: ' + (ss.bans||0) + ' | Last: ' + (ss.last_run||'not yet') + ' | Interval: ' + (sentinel.interval||300) + 's'; + sentinelCard.appendChild(sStats); + if (ss.last_error) { + var sErr = document.createElement('div'); + sErr.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;border-left:2px solid #e05252;padding-left:8px'; + sErr.textContent = 'Last error: ' + ss.last_error; + sentinelCard.appendChild(sErr); + } + // Recent AI verdicts + var verdicts = sentinel.recent_verdicts || []; + if (verdicts.length) { + var vTitle = document.createElement('div'); + vTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin:10px 0 6px;opacity:0.6'; + vTitle.textContent = 'Recent AI Verdicts'; + sentinelCard.appendChild(vTitle); + verdicts.slice(0,8).forEach(function(v){ + var vLine = document.createElement('div'); + vLine.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872;padding:3px 0;border-bottom:1px solid rgba(42,45,53,0.3);display:flex;gap:8px'; + var actionColor = v.action === 'ban' ? '#e05252' : v.action === 'monitor' ? '#f59e0b' : '#7a7872'; + vLine.innerHTML = ''+esc(v.action||'?').toUpperCase()+'' + + ''+esc(v.ip||'?')+'' + + ''+esc(v.attack_type||'?')+'' + + ''+esc(v.reason||'')+''; + sentinelCard.appendChild(vLine); + }); + } + view.appendChild(sentinelCard); + // Summary stats var summary = document.createElement('div'); summary.className = 'threat-summary'; @@ -5494,6 +5541,218 @@ def run_extract(config): _save_pipeline("extract", prompt or source, steps, result_data, all_models, start) +# ─── AI SECURITY SENTINEL ───────────────────────────────────── + +SENTINEL_LOG = "/var/log/llm-team-sentinel.log" +SENTINEL_MODEL = "qwen2.5:latest" +SENTINEL_INTERVAL = 300 # 5 minutes +_sentinel_last_pos = 0 +_sentinel_results = [] # last 50 analyses +_sentinel_stats = {"scans": 0, "bans": 0, "last_run": None, "last_error": None} + +def _sentinel_log_entry(msg): + """Write to sentinel log file.""" + try: + with open(SENTINEL_LOG, "a") as f: + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") + except Exception: + pass + +def _sentinel_scan(): + """Read new security log entries and analyze with local AI.""" + global _sentinel_last_pos + import subprocess, collections + + _sentinel_stats["last_run"] = time.strftime("%Y-%m-%d %H:%M:%S") + _sentinel_stats["scans"] += 1 + + # Read new lines since last scan + try: + with open("/var/log/llm-team-security.log") as f: + f.seek(0, 2) # end of file + file_size = f.tell() + if _sentinel_last_pos > file_size: + _sentinel_last_pos = 0 # log rotated + f.seek(_sentinel_last_pos) + new_lines = f.readlines() + _sentinel_last_pos = f.tell() + except Exception as e: + _sentinel_stats["last_error"] = str(e) + return + + if not new_lines: + _sentinel_log_entry("SCAN_COMPLETE new_lines=0 action=none") + return + + # Aggregate by IP + ip_activity = collections.defaultdict(list) + for line in new_lines: + line = line.strip() + if not line: + continue + ip = None + for token in line.split(): + if token.startswith("ip="): + ip = token[3:] + break + if ip and not ip.startswith("192.168."): + ip_activity[ip].append(line) + + if not ip_activity: + _sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} external_ips=0 action=none") + return + + # Get currently banned IPs to skip + banned = set() + try: + for jail in ["llm-team-exploit", "llm-team-login"]: + result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5) + for line in result.stdout.split("\n"): + if "Banned IP list" in line: + for ip in line.split(":", 1)[1].strip().split(): + banned.add(ip.strip()) + except Exception: + pass + + # Build analysis prompt for the AI + analysis_items = [] + for ip, lines in ip_activity.items(): + if ip in banned: + continue + summary = f"IP {ip} ({len(lines)} events):\n" + for l in lines[:8]: # cap at 8 lines per IP + summary += f" {l}\n" + analysis_items.append((ip, summary, lines)) + + if not analysis_items: + _sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} all_banned_or_lan action=none") + return + + # Batch analysis prompt + prompt = ( + "You are a web application security analyst. Analyze these log entries from the last 5 minutes " + "and classify each IP. Respond with ONLY a JSON array, one object per IP:\n" + '[{"ip": "x.x.x.x", "threat": "none|low|medium|high|critical", "action": "ignore|monitor|ban", ' + '"reason": "brief reason", "attack_type": "scanner|bruteforce|exploit|bot|legitimate"}]\n\n' + "Guidelines:\n" + "- /.git/config, /wp-admin, /phpmyadmin, /xmlrpc.php, /env, /admin.php = exploit scanner → ban\n" + "- Multiple different user agents from same IP = rotating scanner → ban\n" + "- /robots.txt or /favicon.ico alone = harmless bot → ignore\n" + "- Failed logins = bruteforce if >2 attempts → ban\n" + "- Headless chrome, bot UAs doing probing = automated scanner → ban\n" + "- Single 404 on a common path = probably harmless → ignore\n\n" + "Log entries:\n\n" + ) + for ip, summary, _ in analysis_items[:15]: # max 15 IPs per scan + prompt += summary + "\n" + + # Query local AI + try: + cfg = load_config() + base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434") + resp = requests.post(f"{base}/api/generate", json={ + "model": SENTINEL_MODEL, "prompt": prompt, "stream": False, + "options": {"num_ctx": 4096, "temperature": 0.1} + }, timeout=60) + resp.raise_for_status() + ai_response = resp.json()["response"] + except Exception as e: + _sentinel_stats["last_error"] = f"AI query failed: {e}" + _sentinel_log_entry(f"AI_ERROR error={e}") + return + + # Parse AI response + try: + # Extract JSON from response (handle markdown code blocks) + text = ai_response.strip() + if "```" in text: + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + # Find the JSON array + start_idx = text.find("[") + end_idx = text.rfind("]") + 1 + if start_idx >= 0 and end_idx > start_idx: + text = text[start_idx:end_idx] + verdicts = json.loads(text) + except Exception as e: + _sentinel_stats["last_error"] = f"Parse failed: {e}" + _sentinel_log_entry(f"PARSE_ERROR response={ai_response[:200]}") + return + + # Execute actions + ban_count = 0 + for v in verdicts: + ip = v.get("ip", "") + action = v.get("action", "ignore") + threat = v.get("threat", "low") + reason = v.get("reason", "") + attack_type = v.get("attack_type", "unknown") + + result_entry = { + "ip": ip, "threat": threat, "action": action, + "reason": reason, "attack_type": attack_type, + "time": time.strftime("%Y-%m-%d %H:%M:%S") + } + _sentinel_results.append(result_entry) + if len(_sentinel_results) > 50: + _sentinel_results.pop(0) + + if action == "ban" and ip and not ip.startswith("192.168."): + try: + subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip], + capture_output=True, text=True, timeout=5) + ban_count += 1 + sec_log.warning("AI_BAN ip=%s threat=%s reason=%s attack=%s", ip, threat, reason, attack_type) + _sentinel_log_entry(f"AI_BAN ip={ip} threat={threat} reason={reason} attack_type={attack_type}") + except Exception as e: + _sentinel_log_entry(f"BAN_FAILED ip={ip} error={e}") + else: + _sentinel_log_entry(f"AI_VERDICT ip={ip} threat={threat} action={action} reason={reason} attack_type={attack_type}") + + _sentinel_stats["bans"] += ban_count + _sentinel_log_entry(f"SCAN_COMPLETE new_lines={len(new_lines)} ips_analyzed={len(analysis_items)} verdicts={len(verdicts)} bans={ban_count}") + + +def _sentinel_loop(): + """Background loop running every SENTINEL_INTERVAL seconds.""" + global _sentinel_last_pos + # Start from end of file (only analyze new entries) + try: + with open("/var/log/llm-team-security.log") as f: + f.seek(0, 2) + _sentinel_last_pos = f.tell() + except Exception: + pass + + _sentinel_log_entry("SENTINEL_START model=" + SENTINEL_MODEL + " interval=" + str(SENTINEL_INTERVAL) + "s") + while True: + time.sleep(SENTINEL_INTERVAL) + try: + _sentinel_scan() + except Exception as e: + _sentinel_stats["last_error"] = str(e) + _sentinel_log_entry(f"SENTINEL_ERROR {e}") + + +# API for sentinel status +@app.route("/api/admin/sentinel") +@admin_required +def admin_sentinel_status(): + return jsonify({ + "stats": _sentinel_stats, + "recent_verdicts": list(reversed(_sentinel_results[-20:])), + "model": SENTINEL_MODEL, + "interval": SENTINEL_INTERVAL + }) + + +# Start sentinel thread +_sentinel_thread = threading.Thread(target=_sentinel_loop, daemon=True) +_sentinel_thread.start() + + if __name__ == "__main__": print("\n LLM Team UI running at http://localhost:5000\n") + print(f" AI Sentinel active: {SENTINEL_MODEL} scanning every {SENTINEL_INTERVAL}s\n") app.run(host="127.0.0.1", port=5000, debug=False, threaded=True)