mirror of
https://github.com/mblanke/StrikePackageGPT.git
synced 2026-03-01 14:20:21 -05:00
v2.2: Network map improvements and OS filtering
- Fixed jumpy network map: nodes settle in 2 seconds and stay fixed - Added click vs drag detection for better node interaction - Made legend clickable as OS type filters (Windows, Linux, macOS, etc.) - Multiple filters can be active simultaneously (OR logic) - Added 'Clear filters' button when filters are active - Added DELETE endpoints to clear network hosts from dashboard - Fixed nmap parser to only include hosts with open ports - Nodes stay in place after dragging
This commit is contained in:
@@ -67,12 +67,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
# Multi-endpoint support: comma-separated URLs
|
# Local Ollama on host machine (use host.docker.internal on Windows/Mac)
|
||||||
- OLLAMA_ENDPOINTS=${OLLAMA_ENDPOINTS:-http://192.168.1.50:11434}
|
- OLLAMA_LOCAL_URL=${OLLAMA_LOCAL_URL:-http://host.docker.internal:11434}
|
||||||
|
# Network Ollama instances (Dell LLM box with larger models)
|
||||||
|
- OLLAMA_NETWORK_URLS=${OLLAMA_NETWORK_URLS:-http://192.168.1.50:11434}
|
||||||
# Legacy single endpoint (fallback)
|
# Legacy single endpoint (fallback)
|
||||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://192.168.1.50:11434}
|
- OLLAMA_ENDPOINTS=${OLLAMA_ENDPOINTS:-http://host.docker.internal:11434}
|
||||||
|
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434}
|
||||||
# Load balancing: round-robin, random, failover
|
# Load balancing: round-robin, random, failover
|
||||||
- LOAD_BALANCE_STRATEGY=${LOAD_BALANCE_STRATEGY:-round-robin}
|
- LOAD_BALANCE_STRATEGY=${LOAD_BALANCE_STRATEGY:-failover}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- strikepackage-net
|
- strikepackage-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -278,6 +278,70 @@ async def execute_command(request: CommandRequest):
|
|||||||
raise HTTPException(status_code=504, detail="Command execution timed out")
|
raise HTTPException(status_code=504, detail="Command execution timed out")
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/terminal")
|
||||||
|
async def websocket_terminal(websocket: WebSocket):
|
||||||
|
"""WebSocket endpoint for streaming terminal output from Kali executor"""
|
||||||
|
import websockets
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
print("Client WebSocket connected")
|
||||||
|
|
||||||
|
kali_ws = None
|
||||||
|
try:
|
||||||
|
# Wait for command from client
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
print(f"Received command request: {data}")
|
||||||
|
|
||||||
|
# Connect to kali-executor WebSocket with extended timeouts for long scans
|
||||||
|
kali_ws_url = "ws://strikepackage-kali-executor:8002/ws/execute"
|
||||||
|
print(f"Connecting to kali-executor at {kali_ws_url}")
|
||||||
|
|
||||||
|
# Set ping_interval=30s and ping_timeout=3600s (1 hour) for long-running scans
|
||||||
|
kali_ws = await websockets.connect(
|
||||||
|
kali_ws_url,
|
||||||
|
ping_interval=30,
|
||||||
|
ping_timeout=3600,
|
||||||
|
close_timeout=10
|
||||||
|
)
|
||||||
|
print("Connected to kali-executor")
|
||||||
|
|
||||||
|
# Send command to kali
|
||||||
|
await kali_ws.send(data)
|
||||||
|
print("Command sent to kali-executor")
|
||||||
|
|
||||||
|
# Stream responses back to client with keepalive
|
||||||
|
last_activity = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
async for message in kali_ws:
|
||||||
|
last_activity = asyncio.get_event_loop().time()
|
||||||
|
print(f"Received from kali: {message[:100]}...")
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
# Check if complete
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
msg_data = json.loads(message)
|
||||||
|
if msg_data.get("type") == "complete":
|
||||||
|
print("Command complete")
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print("Client disconnected")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WebSocket terminal error: {type(e).__name__}: {e}")
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
await websocket.send_text(json.dumps({"type": "error", "message": str(e)}))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if kali_ws:
|
||||||
|
await kali_ws.close()
|
||||||
|
|
||||||
|
|
||||||
# ============== Scan Management ==============
|
# ============== Scan Management ==============
|
||||||
|
|
||||||
@app.post("/api/scan")
|
@app.post("/api/scan")
|
||||||
@@ -766,6 +830,330 @@ async def get_network_hosts():
|
|||||||
return {"hosts": network_hosts}
|
return {"hosts": network_hosts}
|
||||||
|
|
||||||
|
|
||||||
|
class HostDiscoveryRequest(BaseModel):
|
||||||
|
"""Request to add discovered hosts from terminal commands"""
|
||||||
|
hosts: List[Dict[str, Any]]
|
||||||
|
source: str = "terminal" # terminal, scan, import
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/network/hosts/discover")
|
||||||
|
async def discover_hosts(request: HostDiscoveryRequest):
|
||||||
|
"""Add hosts discovered from terminal commands (e.g., nmap scans)"""
|
||||||
|
global network_hosts
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
for host in request.hosts:
|
||||||
|
if not host.get("ip"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ensure host has required fields
|
||||||
|
host.setdefault("hostname", "")
|
||||||
|
host.setdefault("mac", "")
|
||||||
|
host.setdefault("vendor", "")
|
||||||
|
host.setdefault("os_type", "")
|
||||||
|
host.setdefault("os_details", "")
|
||||||
|
host.setdefault("ports", [])
|
||||||
|
host.setdefault("source", request.source)
|
||||||
|
|
||||||
|
# Check if host already exists
|
||||||
|
existing = next((h for h in network_hosts if h["ip"] == host["ip"]), None)
|
||||||
|
if existing:
|
||||||
|
# Update existing host - merge ports
|
||||||
|
existing_ports = {(p["port"], p.get("protocol", "tcp")) for p in existing.get("ports", [])}
|
||||||
|
for port in host.get("ports", []):
|
||||||
|
port_key = (port["port"], port.get("protocol", "tcp"))
|
||||||
|
if port_key not in existing_ports:
|
||||||
|
existing["ports"].append(port)
|
||||||
|
|
||||||
|
# Update other fields if they have new info
|
||||||
|
for field in ["hostname", "mac", "vendor", "os_type", "os_details"]:
|
||||||
|
if host.get(field) and not existing.get(field):
|
||||||
|
existing[field] = host[field]
|
||||||
|
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
network_hosts.append(host)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"added": added,
|
||||||
|
"updated": updated,
|
||||||
|
"total_hosts": len(network_hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/network/hosts")
|
||||||
|
async def clear_network_hosts():
|
||||||
|
"""Clear all discovered network hosts"""
|
||||||
|
global network_hosts
|
||||||
|
count = len(network_hosts)
|
||||||
|
network_hosts = []
|
||||||
|
return {"status": "success", "cleared": count}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/network/hosts/{ip}")
|
||||||
|
async def delete_network_host(ip: str):
|
||||||
|
"""Delete a specific network host by IP"""
|
||||||
|
global network_hosts
|
||||||
|
original_count = len(network_hosts)
|
||||||
|
network_hosts = [h for h in network_hosts if h.get("ip") != ip]
|
||||||
|
|
||||||
|
if len(network_hosts) == original_count:
|
||||||
|
raise HTTPException(status_code=404, detail="Host not found")
|
||||||
|
|
||||||
|
return {"status": "success", "deleted": ip}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Explain API
|
||||||
|
# ===========================================
|
||||||
|
class ExplainRequest(BaseModel):
|
||||||
|
type: str # port, service, tool, finding
|
||||||
|
context: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/explain")
|
||||||
|
async def explain_context(request: ExplainRequest):
|
||||||
|
"""Get AI explanation for security concepts"""
|
||||||
|
try:
|
||||||
|
# Build explanation prompt
|
||||||
|
prompt = build_explain_prompt(request.type, request.context)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{HACKGPT_API_URL}/chat",
|
||||||
|
json={
|
||||||
|
"message": prompt,
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "llama3.2",
|
||||||
|
"context": "explain"
|
||||||
|
},
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
# Parse the response into structured format
|
||||||
|
return parse_explain_response(request.type, request.context, data.get("response", ""))
|
||||||
|
|
||||||
|
# Fallback to local explanation
|
||||||
|
return get_local_explanation(request.type, request.context)
|
||||||
|
except Exception as e:
|
||||||
|
return get_local_explanation(request.type, request.context)
|
||||||
|
|
||||||
|
|
||||||
|
def build_explain_prompt(type: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""Build an explanation prompt for the LLM"""
|
||||||
|
prompts = {
|
||||||
|
"port": f"Explain port {context.get('port', 'unknown')}/{context.get('protocol', 'tcp')} for penetration testing. Include: what service typically runs on it, common vulnerabilities, and recommended enumeration commands.",
|
||||||
|
"service": f"Explain the {context.get('service', 'unknown')} service for penetration testing. Include: purpose, common vulnerabilities, and exploitation techniques.",
|
||||||
|
"tool": f"Explain the {context.get('tool', 'unknown')} penetration testing tool. Include: purpose, key features, common usage, and example commands.",
|
||||||
|
"finding": f"Explain this security finding: {context.get('title', 'Unknown')}. Context: {context.get('description', 'N/A')}. Include: impact, remediation, and exploitation potential."
|
||||||
|
}
|
||||||
|
return prompts.get(type, f"Explain: {context}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_explain_response(type: str, context: Dict[str, Any], response: str) -> Dict[str, Any]:
|
||||||
|
"""Parse LLM response into structured explanation"""
|
||||||
|
return {
|
||||||
|
"title": get_explain_title(type, context),
|
||||||
|
"description": response,
|
||||||
|
"recommendations": extract_recommendations(response),
|
||||||
|
"warnings": extract_warnings(type, context),
|
||||||
|
"example": extract_example(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_explain_title(type: str, context: Dict[str, Any]) -> str:
|
||||||
|
"""Get title for explanation"""
|
||||||
|
if type == "port":
|
||||||
|
return f"Port {context.get('port', '?')}/{context.get('protocol', 'tcp')}"
|
||||||
|
elif type == "service":
|
||||||
|
return f"Service: {context.get('service', 'Unknown')}"
|
||||||
|
elif type == "tool":
|
||||||
|
return f"Tool: {context.get('tool', 'Unknown')}"
|
||||||
|
elif type == "finding":
|
||||||
|
return context.get('title', 'Security Finding')
|
||||||
|
return "Explanation"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_recommendations(response: str) -> List[str]:
|
||||||
|
"""Extract recommendations from response"""
|
||||||
|
recs = []
|
||||||
|
lines = response.split('\n')
|
||||||
|
in_recs = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if any(word in line.lower() for word in ['recommend', 'suggestion', 'should', 'try']):
|
||||||
|
in_recs = True
|
||||||
|
if in_recs and line.startswith(('-', '*', '•', '1', '2', '3')):
|
||||||
|
recs.append(line.lstrip('-*•123456789. '))
|
||||||
|
if len(recs) >= 5:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not recs:
|
||||||
|
return ["Check for known vulnerabilities", "Look for default credentials", "Document findings"]
|
||||||
|
return recs
|
||||||
|
|
||||||
|
|
||||||
|
def extract_warnings(type: str, context: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Extract or generate warnings"""
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if type == "finding" and context.get("severity") in ["critical", "high"]:
|
||||||
|
warnings.append("This is a high-severity finding - proceed with caution")
|
||||||
|
|
||||||
|
if type == "port":
|
||||||
|
port = context.get("port")
|
||||||
|
if port in [21, 23, 445]:
|
||||||
|
warnings.append("This port is commonly targeted in attacks")
|
||||||
|
if port == 3389:
|
||||||
|
warnings.append("Check for BlueKeep (CVE-2019-0708) vulnerability")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def extract_example(response: str) -> Optional[str]:
|
||||||
|
"""Extract example commands from response"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Look for code blocks
|
||||||
|
code_match = re.search(r'```(?:\w+)?\n(.*?)```', response, re.DOTALL)
|
||||||
|
if code_match:
|
||||||
|
return code_match.group(1).strip()
|
||||||
|
|
||||||
|
# Look for command-like lines
|
||||||
|
for line in response.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith(('$', '#', 'nmap', 'nikto', 'gobuster', 'sqlmap')):
|
||||||
|
return line.lstrip('$# ')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_explanation(type: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Get local fallback explanation"""
|
||||||
|
port_info = {
|
||||||
|
21: ("FTP - File Transfer Protocol", "Anonymous access, weak credentials, version exploits"),
|
||||||
|
22: ("SSH - Secure Shell", "Brute force, key-based exploits, old versions"),
|
||||||
|
23: ("Telnet", "Clear text credentials, always a finding"),
|
||||||
|
25: ("SMTP", "Open relay, user enumeration"),
|
||||||
|
53: ("DNS", "Zone transfers, cache poisoning"),
|
||||||
|
80: ("HTTP", "Web vulnerabilities, directory enum"),
|
||||||
|
443: ("HTTPS", "Same as HTTP + SSL/TLS issues"),
|
||||||
|
445: ("SMB", "EternalBlue, null sessions, share enum"),
|
||||||
|
3306: ("MySQL", "Default creds, SQL injection"),
|
||||||
|
3389: ("RDP", "BlueKeep, credential attacks"),
|
||||||
|
5432: ("PostgreSQL", "Default creds, trust auth"),
|
||||||
|
6379: ("Redis", "No auth by default, RCE possible")
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "port":
|
||||||
|
port = context.get("port")
|
||||||
|
info = port_info.get(port, ("Unknown Service", "Fingerprint and enumerate"))
|
||||||
|
return {
|
||||||
|
"title": f"Port {port}/{context.get('protocol', 'tcp')}",
|
||||||
|
"description": f"**{info[0]}**\n\n{info[1]}",
|
||||||
|
"recommendations": ["Enumerate service version", "Check for default credentials", "Search for CVEs"],
|
||||||
|
"example": f"nmap -sV -sC -p {port} TARGET"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": get_explain_title(type, context),
|
||||||
|
"description": "Information not available. Try asking in the chat.",
|
||||||
|
"recommendations": ["Use the AI chat for detailed help"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Help API
|
||||||
|
# ===========================================
|
||||||
|
class HelpRequest(BaseModel):
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/help")
|
||||||
|
async def help_chat(request: HelpRequest):
|
||||||
|
"""AI-powered help chat"""
|
||||||
|
try:
|
||||||
|
# Build help-focused prompt
|
||||||
|
prompt = f"""You are a helpful assistant for GooseStrike, an AI-powered penetration testing platform.
|
||||||
|
Answer this user question concisely and helpfully. Focus on practical guidance.
|
||||||
|
|
||||||
|
Question: {request.question}
|
||||||
|
|
||||||
|
Provide a clear, helpful response. Use markdown formatting."""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{HACKGPT_API_URL}/chat",
|
||||||
|
json={
|
||||||
|
"message": prompt,
|
||||||
|
"provider": "ollama",
|
||||||
|
"model": "llama3.2",
|
||||||
|
"context": "help"
|
||||||
|
},
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return {"answer": data.get("response", "I can help with that!")}
|
||||||
|
|
||||||
|
return {"answer": get_local_help(request.question)}
|
||||||
|
except Exception:
|
||||||
|
return {"answer": get_local_help(request.question)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_help(question: str) -> str:
|
||||||
|
"""Get local fallback help"""
|
||||||
|
q = question.lower()
|
||||||
|
|
||||||
|
if "scan" in q and "network" in q:
|
||||||
|
return """To scan a network:
|
||||||
|
|
||||||
|
1. Go to the **Recon** phase in the sidebar
|
||||||
|
2. Click **Quick Port Scan** or use the terminal
|
||||||
|
3. Example command: `nmap -sV 10.10.10.0/24`
|
||||||
|
|
||||||
|
You can also ask in the main chat for AI-powered guidance!"""
|
||||||
|
|
||||||
|
if "c2" in q or "command and control" in q:
|
||||||
|
return """The **C2 tab** provides:
|
||||||
|
|
||||||
|
- **Listeners**: Create HTTP/HTTPS/TCP listeners
|
||||||
|
- **Agents**: Manage connected reverse shells
|
||||||
|
- **Payloads**: Generate various reverse shell payloads
|
||||||
|
- **Tasks**: Queue commands for agents
|
||||||
|
|
||||||
|
Click the C2 tab to get started!"""
|
||||||
|
|
||||||
|
if "payload" in q or "shell" in q:
|
||||||
|
return """To generate payloads:
|
||||||
|
|
||||||
|
1. Go to **C2 tab** → **Payloads** panel
|
||||||
|
2. Set your LHOST (your IP) and LPORT
|
||||||
|
3. Choose target OS and format
|
||||||
|
4. Click **Generate Payload**
|
||||||
|
|
||||||
|
Quick Payloads section has one-click common shells!"""
|
||||||
|
|
||||||
|
return """I'm here to help with GooseStrike!
|
||||||
|
|
||||||
|
I can assist with:
|
||||||
|
- **Network scanning** and enumeration
|
||||||
|
- **Vulnerability assessment** techniques
|
||||||
|
- **C2 framework** usage
|
||||||
|
- **Payload generation**
|
||||||
|
- **Attack methodology** guidance
|
||||||
|
|
||||||
|
What would you like to know?"""
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1
|
|||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pydantic==2.10.2
|
pydantic==2.10.2
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
|
websockets==12.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import httpx
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@@ -101,6 +103,276 @@ def validate_command(command: str) -> tuple[bool, str]:
|
|||||||
return True, "OK"
|
return True, "OK"
|
||||||
|
|
||||||
|
|
||||||
|
# Dashboard URL for sending discovered hosts
|
||||||
|
DASHBOARD_URL = os.getenv("DASHBOARD_URL", "http://dashboard:8080")
|
||||||
|
|
||||||
|
|
||||||
|
def is_nmap_command(command: str) -> bool:
|
||||||
|
"""Check if command is an nmap scan that might discover hosts."""
|
||||||
|
parts = command.strip().split()
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
base_cmd = parts[0].split("/")[-1]
|
||||||
|
return base_cmd == "nmap" or base_cmd == "masscan"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_os_type(os_string: str) -> str:
|
||||||
|
"""Detect OS type from nmap OS string."""
|
||||||
|
if not os_string:
|
||||||
|
return ""
|
||||||
|
os_lower = os_string.lower()
|
||||||
|
|
||||||
|
if "windows" in os_lower:
|
||||||
|
return "Windows"
|
||||||
|
elif any(x in os_lower for x in ["linux", "ubuntu", "debian", "centos", "red hat"]):
|
||||||
|
return "Linux"
|
||||||
|
elif any(x in os_lower for x in ["mac os", "darwin", "apple", "ios"]):
|
||||||
|
return "macOS"
|
||||||
|
elif "cisco" in os_lower:
|
||||||
|
return "Cisco Router"
|
||||||
|
elif "juniper" in os_lower:
|
||||||
|
return "Juniper Router"
|
||||||
|
elif any(x in os_lower for x in ["fortinet", "fortigate"]):
|
||||||
|
return "Fortinet"
|
||||||
|
elif any(x in os_lower for x in ["vmware", "esxi"]):
|
||||||
|
return "VMware Server"
|
||||||
|
elif "freebsd" in os_lower:
|
||||||
|
return "FreeBSD"
|
||||||
|
elif "android" in os_lower:
|
||||||
|
return "Android"
|
||||||
|
elif any(x in os_lower for x in ["printer", "hp"]):
|
||||||
|
return "Printer"
|
||||||
|
elif "switch" in os_lower:
|
||||||
|
return "Network Switch"
|
||||||
|
elif "router" in os_lower:
|
||||||
|
return "Router"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def infer_os_from_ports(ports: List[Dict]) -> str:
|
||||||
|
"""Infer OS type from open ports."""
|
||||||
|
port_nums = {p["port"] for p in ports}
|
||||||
|
products = [p.get("product", "").lower() for p in ports]
|
||||||
|
|
||||||
|
# Windows indicators
|
||||||
|
windows_ports = {135, 139, 445, 3389, 5985, 5986}
|
||||||
|
if windows_ports & port_nums:
|
||||||
|
return "Windows"
|
||||||
|
if any("microsoft" in p or "windows" in p for p in products):
|
||||||
|
return "Windows"
|
||||||
|
|
||||||
|
# Linux indicators
|
||||||
|
if 22 in port_nums:
|
||||||
|
return "Linux"
|
||||||
|
|
||||||
|
# Network device indicators
|
||||||
|
if 161 in port_nums or 162 in port_nums:
|
||||||
|
return "Network Device"
|
||||||
|
|
||||||
|
# Printer
|
||||||
|
if 9100 in port_nums or 631 in port_nums:
|
||||||
|
return "Printer"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nmap_output(stdout: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse nmap output (XML or text) and extract discovered hosts."""
|
||||||
|
hosts = []
|
||||||
|
|
||||||
|
# Try XML parsing first (if -oX - was used or combined with other options)
|
||||||
|
if '<?xml' in stdout or '<nmaprun' in stdout:
|
||||||
|
try:
|
||||||
|
xml_start = stdout.find('<?xml')
|
||||||
|
if xml_start == -1:
|
||||||
|
xml_start = stdout.find('<nmaprun')
|
||||||
|
if xml_start != -1:
|
||||||
|
xml_output = stdout[xml_start:]
|
||||||
|
hosts = parse_nmap_xml(xml_output)
|
||||||
|
if hosts:
|
||||||
|
return hosts
|
||||||
|
except Exception as e:
|
||||||
|
print(f"XML parsing failed: {e}")
|
||||||
|
|
||||||
|
# Fallback to text parsing
|
||||||
|
hosts = parse_nmap_text(stdout)
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nmap_xml(xml_output: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse nmap XML output to extract hosts."""
|
||||||
|
hosts = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_output)
|
||||||
|
|
||||||
|
for host_elem in root.findall('.//host'):
|
||||||
|
status = host_elem.find("status")
|
||||||
|
if status is None or status.get("state") != "up":
|
||||||
|
continue
|
||||||
|
|
||||||
|
host = {
|
||||||
|
"ip": "",
|
||||||
|
"hostname": "",
|
||||||
|
"mac": "",
|
||||||
|
"vendor": "",
|
||||||
|
"os_type": "",
|
||||||
|
"os_details": "",
|
||||||
|
"ports": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get IP address
|
||||||
|
addr = host_elem.find("address[@addrtype='ipv4']")
|
||||||
|
if addr is not None:
|
||||||
|
host["ip"] = addr.get("addr", "")
|
||||||
|
|
||||||
|
# Get MAC address
|
||||||
|
mac = host_elem.find("address[@addrtype='mac']")
|
||||||
|
if mac is not None:
|
||||||
|
host["mac"] = mac.get("addr", "")
|
||||||
|
host["vendor"] = mac.get("vendor", "")
|
||||||
|
|
||||||
|
# Get hostname
|
||||||
|
hostname = host_elem.find(".//hostname")
|
||||||
|
if hostname is not None:
|
||||||
|
host["hostname"] = hostname.get("name", "")
|
||||||
|
|
||||||
|
# Get OS info
|
||||||
|
os_elem = host_elem.find(".//osmatch")
|
||||||
|
if os_elem is not None:
|
||||||
|
os_name = os_elem.get("name", "")
|
||||||
|
host["os_details"] = os_name
|
||||||
|
host["os_type"] = detect_os_type(os_name)
|
||||||
|
|
||||||
|
# Get ports
|
||||||
|
for port_elem in host_elem.findall(".//port"):
|
||||||
|
state_elem = port_elem.find("state")
|
||||||
|
port_info = {
|
||||||
|
"port": int(port_elem.get("portid", 0)),
|
||||||
|
"protocol": port_elem.get("protocol", "tcp"),
|
||||||
|
"state": state_elem.get("state", "") if state_elem is not None else "",
|
||||||
|
"service": ""
|
||||||
|
}
|
||||||
|
service = port_elem.find("service")
|
||||||
|
if service is not None:
|
||||||
|
port_info["service"] = service.get("name", "")
|
||||||
|
port_info["product"] = service.get("product", "")
|
||||||
|
port_info["version"] = service.get("version", "")
|
||||||
|
|
||||||
|
if port_info["state"] == "open":
|
||||||
|
host["ports"].append(port_info)
|
||||||
|
|
||||||
|
# Infer OS from ports if still unknown
|
||||||
|
if not host["os_type"] and host["ports"]:
|
||||||
|
host["os_type"] = infer_os_from_ports(host["ports"])
|
||||||
|
|
||||||
|
if host["ip"]:
|
||||||
|
hosts.append(host)
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
print(f"XML parse error: {e}")
|
||||||
|
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nmap_text(output: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse nmap text output as fallback.
|
||||||
|
|
||||||
|
Only returns hosts that have at least one OPEN port.
|
||||||
|
Hosts that respond to ping/ARP but have no open ports are filtered out.
|
||||||
|
"""
|
||||||
|
hosts = []
|
||||||
|
current_host = None
|
||||||
|
|
||||||
|
def save_host_if_has_open_ports(host):
|
||||||
|
"""Only save host if it has at least one open port."""
|
||||||
|
if host and host.get("ip") and host.get("ports"):
|
||||||
|
# Infer OS before saving
|
||||||
|
if not host["os_type"]:
|
||||||
|
host["os_type"] = infer_os_from_ports(host["ports"])
|
||||||
|
hosts.append(host)
|
||||||
|
|
||||||
|
for line in output.split('\n'):
|
||||||
|
# Match host line: "Nmap scan report for hostname (IP)" or "Nmap scan report for IP"
|
||||||
|
host_match = re.search(r'Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)', line)
|
||||||
|
if host_match:
|
||||||
|
# Save previous host only if it has open ports
|
||||||
|
save_host_if_has_open_ports(current_host)
|
||||||
|
|
||||||
|
current_host = {
|
||||||
|
"ip": host_match.group(2),
|
||||||
|
"hostname": host_match.group(1) or "",
|
||||||
|
"os_type": "",
|
||||||
|
"os_details": "",
|
||||||
|
"ports": [],
|
||||||
|
"mac": "",
|
||||||
|
"vendor": ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_host:
|
||||||
|
# Match MAC: "MAC Address: XX:XX:XX:XX:XX:XX (Vendor Name)"
|
||||||
|
mac_match = re.search(r'MAC Address: ([0-9A-Fa-f:]+)(?: \(([^)]+)\))?', line)
|
||||||
|
if mac_match:
|
||||||
|
current_host["mac"] = mac_match.group(1)
|
||||||
|
current_host["vendor"] = mac_match.group(2) or ""
|
||||||
|
|
||||||
|
# Match port: "80/tcp open http Apache httpd"
|
||||||
|
port_match = re.search(r'(\d+)/(tcp|udp)\s+(\w+)\s+(\S+)(?:\s+(.*))?', line)
|
||||||
|
if port_match and port_match.group(3) == "open":
|
||||||
|
port_info = {
|
||||||
|
"port": int(port_match.group(1)),
|
||||||
|
"protocol": port_match.group(2),
|
||||||
|
"state": "open",
|
||||||
|
"service": port_match.group(4),
|
||||||
|
"product": port_match.group(5) or ""
|
||||||
|
}
|
||||||
|
current_host["ports"].append(port_info)
|
||||||
|
|
||||||
|
# Match OS: "OS details: Linux 4.15 - 5.6" or "Running: Linux"
|
||||||
|
os_match = re.search(r'(?:OS details?|Running):\s*(.+)', line)
|
||||||
|
if os_match:
|
||||||
|
current_host["os_details"] = os_match.group(1)
|
||||||
|
current_host["os_type"] = detect_os_type(os_match.group(1))
|
||||||
|
|
||||||
|
# Match "Service Info: OS: Linux" style
|
||||||
|
service_os_match = re.search(r'Service Info:.*OS:\s*([^;,]+)', line)
|
||||||
|
if service_os_match and not current_host["os_type"]:
|
||||||
|
current_host["os_type"] = detect_os_type(service_os_match.group(1))
|
||||||
|
|
||||||
|
# Match "Aggressive OS guesses: Linux 5.4 (98%)" - take first high confidence
|
||||||
|
aggressive_match = re.search(r'Aggressive OS guesses:\s*([^(]+)\s*\((\d+)%\)', line)
|
||||||
|
if aggressive_match and not current_host["os_details"]:
|
||||||
|
confidence = int(aggressive_match.group(2))
|
||||||
|
if confidence >= 85:
|
||||||
|
current_host["os_details"] = aggressive_match.group(1).strip()
|
||||||
|
current_host["os_type"] = detect_os_type(aggressive_match.group(1))
|
||||||
|
|
||||||
|
# Don't forget the last host - only if it has open ports
|
||||||
|
save_host_if_has_open_ports(current_host)
|
||||||
|
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
async def send_hosts_to_dashboard(hosts: List[Dict[str, Any]]):
|
||||||
|
"""Send discovered hosts to the dashboard for network map update."""
|
||||||
|
if not hosts:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{DASHBOARD_URL}/api/network/hosts/discover",
|
||||||
|
json={"hosts": hosts, "source": "terminal"}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"Sent {len(hosts)} hosts to dashboard: added={result.get('added')}, updated={result.get('updated')}")
|
||||||
|
else:
|
||||||
|
print(f"Failed to send hosts to dashboard: {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending hosts to dashboard: {e}")
|
||||||
|
|
||||||
|
|
||||||
# Docker client
|
# Docker client
|
||||||
docker_client = None
|
docker_client = None
|
||||||
kali_container = None
|
kali_container = None
|
||||||
@@ -292,6 +564,15 @@ async def execute_command(request: CommandRequest):
|
|||||||
stdout = output[0].decode('utf-8', errors='replace') if output[0] else ""
|
stdout = output[0].decode('utf-8', errors='replace') if output[0] else ""
|
||||||
stderr = output[1].decode('utf-8', errors='replace') if output[1] else ""
|
stderr = output[1].decode('utf-8', errors='replace') if output[1] else ""
|
||||||
|
|
||||||
|
# Parse nmap output and send hosts to dashboard for network map
|
||||||
|
if is_nmap_command(request.command) and stdout:
|
||||||
|
try:
|
||||||
|
hosts = parse_nmap_output(stdout)
|
||||||
|
if hosts:
|
||||||
|
asyncio.create_task(send_hosts_to_dashboard(hosts))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing nmap output: {e}")
|
||||||
|
|
||||||
return CommandResult(
|
return CommandResult(
|
||||||
command_id=command_id,
|
command_id=command_id,
|
||||||
command=request.command,
|
command=request.command,
|
||||||
@@ -420,18 +701,92 @@ async def websocket_execute(websocket: WebSocket):
|
|||||||
workdir=working_dir
|
workdir=working_dir
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream output
|
# Collect output for nmap parsing
|
||||||
for stdout, stderr in exec_result.output:
|
full_stdout = []
|
||||||
if stdout:
|
is_nmap = is_nmap_command(command)
|
||||||
await websocket.send_json({
|
|
||||||
"type": "stdout",
|
# Stream output with keepalive for long-running commands
|
||||||
"data": stdout.decode('utf-8', errors='replace')
|
last_output_time = asyncio.get_event_loop().time()
|
||||||
})
|
output_queue = asyncio.Queue()
|
||||||
if stderr:
|
stream_complete = asyncio.Event()
|
||||||
await websocket.send_json({
|
|
||||||
"type": "stderr",
|
# Synchronous function to read from Docker stream (runs in thread)
|
||||||
"data": stderr.decode('utf-8', errors='replace')
|
def read_docker_output_sync(queue: asyncio.Queue, loop, complete_event):
|
||||||
})
|
try:
|
||||||
|
for stdout, stderr in exec_result.output:
|
||||||
|
if stdout:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
queue.put(("stdout", stdout.decode('utf-8', errors='replace'))),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
if stderr:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
queue.put(("stderr", stderr.decode('utf-8', errors='replace'))),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
queue.put(("error", str(e))),
|
||||||
|
loop
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
loop.call_soon_threadsafe(complete_event.set)
|
||||||
|
|
||||||
|
# Start reading in background thread
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
read_future = loop.run_in_executor(
|
||||||
|
executor,
|
||||||
|
read_docker_output_sync,
|
||||||
|
output_queue,
|
||||||
|
loop,
|
||||||
|
stream_complete
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send output and keepalives
|
||||||
|
keepalive_interval = 25 # seconds
|
||||||
|
while not stream_complete.is_set() or not output_queue.empty():
|
||||||
|
try:
|
||||||
|
# Wait for output with timeout for keepalive
|
||||||
|
try:
|
||||||
|
msg_type, msg_data = await asyncio.wait_for(
|
||||||
|
output_queue.get(),
|
||||||
|
timeout=keepalive_interval
|
||||||
|
)
|
||||||
|
last_output_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
if msg_type == "stdout":
|
||||||
|
if is_nmap:
|
||||||
|
full_stdout.append(msg_data)
|
||||||
|
await websocket.send_json({"type": "stdout", "data": msg_data})
|
||||||
|
elif msg_type == "stderr":
|
||||||
|
await websocket.send_json({"type": "stderr", "data": msg_data})
|
||||||
|
elif msg_type == "error":
|
||||||
|
await websocket.send_json({"type": "error", "message": msg_data})
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# No output for a while, send keepalive
|
||||||
|
elapsed = asyncio.get_event_loop().time() - last_output_time
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "keepalive",
|
||||||
|
"elapsed": int(elapsed),
|
||||||
|
"message": f"Scan in progress ({int(elapsed)}s)..."
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in output loop: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Wait for read thread to complete
|
||||||
|
await read_future
|
||||||
|
|
||||||
|
# Parse nmap output and send hosts to dashboard
|
||||||
|
if is_nmap and full_stdout:
|
||||||
|
try:
|
||||||
|
combined_output = "".join(full_stdout)
|
||||||
|
hosts = parse_nmap_output(combined_output)
|
||||||
|
if hosts:
|
||||||
|
asyncio.create_task(send_hosts_to_dashboard(hosts))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing nmap output: {e}")
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "complete",
|
"type": "complete",
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1
|
|||||||
docker==7.1.0
|
docker==7.1.0
|
||||||
pydantic==2.10.2
|
pydantic==2.10.2
|
||||||
websockets==14.1
|
websockets==14.1
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -3,15 +3,39 @@ FROM kalilinux/kali-rolling
|
|||||||
# Avoid prompts during package installation
|
# Avoid prompts during package installation
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
# Update and install ALL Kali tools
|
# Configure apt to use reliable mirrors and retry on failure
|
||||||
# Using kali-linux-everything metapackage for complete tool suite
|
RUN echo 'Acquire::Retries "3";' > /etc/apt/apt.conf.d/80-retries && \
|
||||||
|
echo 'Acquire::http::Timeout "30";' >> /etc/apt/apt.conf.d/80-retries && \
|
||||||
|
echo 'deb http://kali.download/kali kali-rolling main non-free non-free-firmware contrib' > /etc/apt/sources.list
|
||||||
|
|
||||||
|
# Update and install core Kali tools (smaller, faster, more reliable)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
kali-linux-everything \
|
nmap \
|
||||||
|
sqlmap \
|
||||||
|
hydra \
|
||||||
|
john \
|
||||||
|
tcpdump \
|
||||||
|
netcat-openbsd \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
git \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
whois \
|
||||||
|
dnsutils \
|
||||||
|
dirb \
|
||||||
|
gobuster \
|
||||||
|
ffuf \
|
||||||
|
seclists \
|
||||||
|
smbclient \
|
||||||
|
impacket-scripts \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install additional Python tools and utilities for command logging
|
# Install additional Python tools and utilities for command logging
|
||||||
RUN pip3 install --break-system-packages \
|
# Install setuptools first to fix compatibility issues with Python 3.13
|
||||||
|
RUN pip3 install --break-system-packages setuptools wheel && \
|
||||||
|
pip3 install --break-system-packages \
|
||||||
requests \
|
requests \
|
||||||
beautifulsoup4 \
|
beautifulsoup4 \
|
||||||
shodan \
|
shodan \
|
||||||
@@ -27,11 +51,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Create workspace directory
|
# Create workspace directory
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
# Copy scripts
|
# Copy scripts and fix line endings (in case of Windows CRLF)
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
COPY command_logger.sh /usr/local/bin/command_logger.sh
|
COPY command_logger.sh /usr/local/bin/command_logger.sh
|
||||||
COPY capture_wrapper.sh /usr/local/bin/capture
|
COPY capture_wrapper.sh /usr/local/bin/capture
|
||||||
RUN chmod +x /entrypoint.sh /usr/local/bin/command_logger.sh /usr/local/bin/capture
|
RUN sed -i 's/\r$//' /entrypoint.sh /usr/local/bin/command_logger.sh /usr/local/bin/capture && \
|
||||||
|
chmod +x /entrypoint.sh /usr/local/bin/command_logger.sh /usr/local/bin/capture
|
||||||
|
|
||||||
# Create command history directory
|
# Create command history directory
|
||||||
RUN mkdir -p /workspace/.command_history
|
RUN mkdir -p /workspace/.command_history
|
||||||
|
|||||||
Reference in New Issue
Block a user