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:
2025-12-08 10:17:06 -05:00
parent 91b4697403
commit 8b51ba9108
7 changed files with 2568 additions and 63 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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