7 Commits

Author SHA1 Message Date
bfb52f098c Add devcontainer compose, dependabot config, and ignore nested repo 2025-12-29 10:20:11 -05:00
e459266e9c Persist dashboard projects data and tighten nmap host filter 2025-12-29 10:16:17 -05:00
af31caeacf Add Vite React component bundling, SSE process streaming, preferences persistence, WebSocket terminal proxy, local Ollama integration
- Enable local Ollama service in compose with llm-router dependency
- Add SSE /stream/processes endpoint in kali-executor for live process updates
- Add WebSocket /ws/execute for real-time terminal command streaming
- Implement preferences persistence (provider/model) via dashboard backend
- Create Vite build pipeline for React components (VoiceControls, NetworkMap, GuidedWizard)
- Update dashboard Dockerfile with Node builder stage for component bundling
- Wire dashboard template to mount components and subscribe to SSE/WebSocket streams
- Add preferences load/save hooks in UI to persist LLM provider/model selection
2025-12-28 21:29:59 -05:00
b971482bbd Dashboard: integrate Cytoscape network map view toggle and mount, add terminal Pause/Scroll Lock/Copy, elapsed time and exit status badges 2025-12-28 21:24:00 -05:00
17f8a332db v3.0: Project management, credentials, notes, exploit suggestions, recon pipelines
Major features:
- Project-based data organization (hosts, scans, credentials, notes saved per project)
- Credential manager with full CRUD operations
- Project notes with categories (recon, exploitation, post-exploit, loot)
- Exploit suggestion engine based on discovered services/versions
- Automated recon pipelines (quick, standard, full, stealth modes)
- searchsploit integration for CVE lookups
- MSF module launcher from host details panel

UI additions:
- Project selector in header with create/details panels
- Credentials tab with table view
- Notes tab with card layout
- Exploit suggestions in network map host details
- Recon Pipeline modal with progress tracking
2025-12-09 23:07:39 -05:00
b1250aa452 v2.3: Full Kali toolkit, improved scanning accuracy
- Install kali-linux-everything metapackage (600+ tools)
- Add --disable-arp-ping to prevent false positives from proxy ARP
- Add MAC address verification for host discovery
- Improve OS detection with scoring system (handles Linux+Samba correctly)
- Fix .21 showing as Windows when it's Linux with xrdp
2025-12-08 13:14:38 -05:00
8b51ba9108 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
2025-12-08 10:17:06 -05:00
17 changed files with 6398 additions and 99 deletions

View File

@@ -0,0 +1,26 @@
version: '3.8'
services:
# Update this to the name of the service you want to work with in your docker-compose.yml file
dashboard:
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
# array). The sample below assumes your primary file is in the root of your project.
#
# build:
# context: .
# dockerfile: .devcontainer/Dockerfile
volumes:
# Update this to wherever you want VS Code to mount the folder of your project
- ..:/workspaces:cached
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
# cap_add:
# - SYS_PTRACE
# security_opt:
# - seccomp:unconfined
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

3
.gitignore vendored
View File

@@ -32,3 +32,6 @@ data/
# Temporary files
*.tmp
*.temp
# Nested repo
StrikePackageGPT/

View File

@@ -11,6 +11,8 @@ services:
- HACKGPT_API_URL=http://strikepackage-hackgpt-api:8001
- LLM_ROUTER_URL=http://strikepackage-llm-router:8000
- KALI_EXECUTOR_URL=http://strikepackage-kali-executor:8002
volumes:
- ./data/dashboard:/app/data
depends_on:
- hackgpt-api
- llm-router
@@ -67,15 +69,22 @@ services:
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
# Multi-endpoint support: comma-separated URLs
- OLLAMA_ENDPOINTS=${OLLAMA_ENDPOINTS:-http://192.168.1.50:11434}
# Prefer local Ollama container for self-contained setup
- OLLAMA_LOCAL_URL=${OLLAMA_LOCAL_URL:-http://strikepackage-ollama: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)
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://192.168.1.50:11434}
- OLLAMA_ENDPOINTS=${OLLAMA_ENDPOINTS:-http://strikepackage-ollama:11434}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://strikepackage-ollama:11434}
# 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:
- strikepackage-net
restart: unless-stopped
depends_on:
- ollama
# Kali Linux - Security tools container
kali:
@@ -95,26 +104,25 @@ services:
- NET_RAW
restart: unless-stopped
# Ollama - Local LLM (disabled - using Dell LLM box at 192.168.1.50)
# Uncomment to use local Ollama instead
# ollama:
# image: ollama/ollama:latest
# container_name: strikepackage-ollama
# ports:
# - "11434:11434"
# volumes:
# - ollama-models:/root/.ollama
# networks:
# - strikepackage-net
# restart: unless-stopped
# # Uncomment for GPU support:
# # deploy:
# # resources:
# # reservations:
# # devices:
# # - driver: nvidia
# # count: all
# # capabilities: [gpu]
# Ollama - Local LLM
ollama:
image: ollama/ollama:latest
container_name: strikepackage-ollama
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
networks:
- strikepackage-net
restart: unless-stopped
# GPU support (optional): uncomment if using NVIDIA GPU
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
networks:
strikepackage-net:

View File

@@ -1,3 +1,14 @@
FROM node:20-slim AS builder
WORKDIR /build
# Copy package files and JSX components
COPY package.json vite.config.js ./
COPY components/ ./components/
# Install dependencies and build
RUN npm install && npm run build
FROM python:3.12-slim
WORKDIR /app
@@ -11,6 +22,9 @@ COPY app/ ./app/
COPY templates/ ./templates/
COPY static/ ./static/
# Copy built components from builder stage
COPY --from=builder /build/static/dist/ ./static/dist/
# Expose port
EXPOSE 8080

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react';
const WIZARD_TYPES = {
first_time_setup: {
title: 'Welcome to GooseStrike',
steps: [
{ id: 'intro', title: 'Introduction', icon: '👋' },
{ id: 'phases', title: 'Methodology', icon: '📋' },
{ id: 'tools', title: 'Security Tools', icon: '🛠️' },
{ id: 'start', title: 'Get Started', icon: '🚀' },
],
},
run_scan: {
title: 'Run Security Scan',
steps: [
{ id: 'target', title: 'Target Selection', icon: '🎯' },
{ id: 'scan-type', title: 'Scan Type', icon: '🔍' },
{ id: 'options', title: 'Options', icon: '⚙️' },
{ id: 'execute', title: 'Execute', icon: '▶️' },
],
},
create_operation: {
title: 'Create Security Operation',
steps: [
{ id: 'details', title: 'Operation Details', icon: '📝' },
{ id: 'scope', title: 'Target Scope', icon: '🎯' },
{ id: 'methodology', title: 'Methodology', icon: '📋' },
{ id: 'review', title: 'Review', icon: '✅' },
],
},
};
const GuidedWizard = ({ type = 'first_time_setup', onComplete = () => {}, onCancel = () => {} }) => {
const wizard = WIZARD_TYPES[type] || WIZARD_TYPES.first_time_setup;
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({});
const handleNext = () => {
if (currentStep < wizard.steps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
onComplete(formData);
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleInputChange = (key, value) => {
setFormData({ ...formData, [key]: value });
};
const progress = ((currentStep + 1) / wizard.steps.length) * 100;
return (
<div className="guided-wizard fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-sp-dark rounded-lg border border-sp-grey-mid w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-6 border-b border-sp-grey-mid">
<h2 className="text-2xl font-bold text-sp-white">{wizard.title}</h2>
<div className="mt-4 flex gap-2">
{wizard.steps.map((step, idx) => (
<div
key={step.id}
className={`wizard-step flex-1 p-2 rounded text-center border transition ${
idx === currentStep
? 'border-sp-red bg-sp-red bg-opacity-10'
: idx < currentStep
? 'border-green-500 bg-green-500 bg-opacity-10'
: 'border-sp-grey-mid'
}`}
>
<div className="text-xl">{step.icon}</div>
<div className="text-xs text-sp-white-muted mt-1">{step.title}</div>
</div>
))}
</div>
<div className="mt-3 h-1 bg-sp-grey-mid rounded overflow-hidden">
<div
className="wizard-progress h-full bg-sp-red transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Body */}
<div className="flex-1 p-6 overflow-y-auto">
<div className="text-sp-white">
{/* Render step content based on wizard type and current step */}
{type === 'first_time_setup' && currentStep === 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Welcome to GooseStrike! 🍁</h3>
<p className="text-sp-white-muted mb-4">
GooseStrike is an AI-powered penetration testing platform that follows industry-standard
methodologies to help you identify security vulnerabilities.
</p>
<ul className="list-disc list-inside text-sp-white-muted space-y-2">
<li>AI-assisted security analysis with local or cloud LLMs</li>
<li>600+ integrated Kali Linux security tools</li>
<li>Voice control for hands-free operation</li>
<li>Interactive network visualization</li>
<li>Comprehensive reporting and documentation</li>
</ul>
</div>
)}
{type === 'run_scan' && currentStep === 0 && (
<div>
<h3 className="text-xl font-bold mb-4">Select Target</h3>
<label className="block mb-2 text-sm text-sp-white-muted">Target IP or hostname</label>
<input
type="text"
className="w-full bg-sp-grey border border-sp-grey-mid rounded px-3 py-2 text-sp-white"
placeholder="192.168.1.100 or example.com"
value={formData.target || ''}
onChange={(e) => handleInputChange('target', e.target.value)}
/>
</div>
)}
{/* Add more step content as needed */}
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-sp-grey-mid flex justify-between">
<button
onClick={onCancel}
className="px-4 py-2 bg-sp-grey hover:bg-sp-grey-light rounded text-sp-white transition"
>
Cancel
</button>
<div className="flex gap-2">
{currentStep > 0 && (
<button
onClick={handleBack}
className="px-4 py-2 bg-sp-grey hover:bg-sp-grey-light rounded text-sp-white transition"
>
Back
</button>
)}
<button
onClick={handleNext}
className="px-4 py-2 bg-sp-red hover:bg-sp-red-dark rounded text-sp-white transition"
>
{currentStep === wizard.steps.length - 1 ? 'Complete' : 'Next →'}
</button>
</div>
</div>
</div>
</div>
);
};
export default GuidedWizard;

View File

@@ -0,0 +1,110 @@
import React, { useEffect, useRef } from 'react';
import cytoscape from 'cytoscape';
const NetworkMap = ({ hosts = [], onHostSelect = () => {} }) => {
const containerRef = useRef(null);
const cyRef = useRef(null);
useEffect(() => {
if (!containerRef.current || hosts.length === 0) return;
// Build Cytoscape elements from hosts
const elements = [];
hosts.forEach((host) => {
elements.push({
data: {
id: host.ip,
label: host.hostname || host.ip,
type: host.device_type || 'unknown',
os: host.os || 'unknown',
ports: host.ports || [],
},
classes: host.device_type || 'unknown',
});
// Add edges for network relationships (simple example: connect all to a central gateway)
if (host.ip !== '192.168.1.1') {
elements.push({
data: {
id: `edge-${host.ip}`,
source: '192.168.1.1',
target: host.ip,
},
});
}
});
// Initialize Cytoscape
cyRef.current = cytoscape({
container: containerRef.current,
elements,
style: [
{
selector: 'node',
style: {
'background-color': '#dc2626',
label: 'data(label)',
color: '#e5e5e5',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '10px',
width: 40,
height: 40,
},
},
{
selector: 'node.router',
style: {
'background-color': '#3b82f6',
shape: 'diamond',
},
},
{
selector: 'node.server',
style: {
'background-color': '#22c55e',
shape: 'rectangle',
},
},
{
selector: 'edge',
style: {
width: 2,
'line-color': '#3a3a3a',
'target-arrow-color': '#3a3a3a',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
},
},
],
layout: {
name: 'cose',
animate: true,
animationDuration: 500,
nodeDimensionsIncludeLabels: true,
},
});
// Handle node clicks
cyRef.current.on('tap', 'node', (evt) => {
const node = evt.target;
onHostSelect(node.data());
});
return () => {
if (cyRef.current) {
cyRef.current.destroy();
}
};
}, [hosts, onHostSelect]);
return (
<div
ref={containerRef}
className="network-map-container w-full h-full min-h-[500px] rounded border border-sp-grey-mid"
/>
);
};
export default NetworkMap;

View File

@@ -0,0 +1,124 @@
import React, { useState, useRef, useEffect } from 'react';
const VoiceControls = ({ onCommand = () => {}, apiUrl = '/api/voice' }) => {
const [state, setState] = useState('idle'); // idle, listening, processing
const [transcript, setTranscript] = useState('');
const [hotkey, setHotkey] = useState('`'); // backtick
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const hotkeyPressedRef = useRef(false);
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === hotkey && !hotkeyPressedRef.current && state === 'idle') {
hotkeyPressedRef.current = true;
startRecording();
}
};
const handleKeyUp = (e) => {
if (e.key === hotkey && hotkeyPressedRef.current) {
hotkeyPressedRef.current = false;
stopRecording();
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [state, hotkey]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorderRef.current = new MediaRecorder(stream);
audioChunksRef.current = [];
mediaRecorderRef.current.ondataavailable = (event) => {
audioChunksRef.current.push(event.data);
};
mediaRecorderRef.current.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/wav' });
await sendToTranscribe(audioBlob);
stream.getTracks().forEach((track) => track.stop());
};
mediaRecorderRef.current.start();
setState('listening');
setTranscript('Listening...');
} catch (error) {
console.error('Microphone access denied:', error);
setTranscript('Microphone access denied');
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && state === 'listening') {
mediaRecorderRef.current.stop();
setState('processing');
setTranscript('Processing...');
}
};
const sendToTranscribe = async (audioBlob) => {
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
const response = await fetch(`${apiUrl}/transcribe`, {
method: 'POST',
body: formData,
});
const result = await response.json();
setTranscript(result.text || 'No speech detected');
setState('idle');
if (result.text) {
onCommand(result.text);
}
} catch (error) {
console.error('Transcription failed:', error);
setTranscript('Transcription failed');
setState('idle');
}
};
return (
<div className="voice-controls p-4 bg-sp-grey rounded border border-sp-grey-mid">
<div className="flex items-center gap-3">
<button
className={`voice-btn w-12 h-12 rounded-full flex items-center justify-center text-2xl transition ${
state === 'listening'
? 'bg-sp-red animate-pulse'
: state === 'processing'
? 'bg-yellow-500'
: 'bg-sp-grey-mid hover:bg-sp-red'
}`}
onMouseDown={startRecording}
onMouseUp={stopRecording}
disabled={state === 'processing'}
>
{state === 'listening' ? '🎙️' : state === 'processing' ? '⏳' : '🎤'}
</button>
<div className="flex-1">
<div className="text-sm text-sp-white-muted">
{state === 'idle' && `Press & hold ${hotkey} or click to speak`}
{state === 'listening' && 'Release to stop recording'}
{state === 'processing' && 'Processing audio...'}
</div>
{transcript && (
<div className="text-sm text-sp-white mt-1 font-mono">{transcript}</div>
)}
</div>
</div>
</div>
);
};
export default VoiceControls;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import VoiceControls from './VoiceControls';
import NetworkMap from './NetworkMap';
import GuidedWizard from './GuidedWizard';
// Export components for external mounting
window.GooseStrikeComponents = {
VoiceControls,
NetworkMap,
GuidedWizard,
mount: {
voiceControls: (containerId, props = {}) => {
const container = document.getElementById(containerId);
if (container) {
const root = createRoot(container);
root.render(<VoiceControls {...props} />);
return root;
}
},
networkMap: (containerId, props = {}) => {
const container = document.getElementById(containerId);
if (container) {
const root = createRoot(container);
root.render(<NetworkMap {...props} />);
return root;
}
},
guidedWizard: (containerId, props = {}) => {
const container = document.getElementById(containerId);
if (container) {
const root = createRoot(container);
root.render(<GuidedWizard {...props} />);
return root;
}
},
},
};
export { VoiceControls, NetworkMap, GuidedWizard };

View File

@@ -0,0 +1,19 @@
{
"name": "goosestrike-dashboard",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"cytoscape": "^3.28.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.10"
}
}

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1
httpx==0.28.1
pydantic==2.10.2
jinja2==3.1.4
websockets==12.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'static/dist',
emptyOutDir: true,
rollupOptions: {
input: {
components: path.resolve(__dirname, 'components/index.jsx'),
},
output: {
entryFileNames: 'components.js',
chunkFileNames: 'components-[name].js',
assetFileNames: 'components-[name].[ext]',
},
},
},
});

View File

@@ -4,6 +4,7 @@ Executes commands in the Kali container via Docker SDK.
"""
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
import docker
@@ -13,6 +14,8 @@ import os
import uuid
import json
import re
import httpx
import xml.etree.ElementTree as ET
from datetime import datetime
from contextlib import asynccontextmanager
@@ -101,6 +104,307 @@ def validate_command(command: str) -> tuple[bool, str]:
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.
Uses a scoring system to handle hosts running multiple services
(e.g., Linux with Samba looks like Windows on port 445).
"""
port_nums = {p["port"] for p in ports}
services = {p.get("service", "").lower() for p in ports}
products = [p.get("product", "").lower() for p in ports]
# Score-based detection to handle mixed indicators
linux_score = 0
windows_score = 0
# Strong Linux indicators
if 22 in port_nums: # SSH is strongly Linux/Unix
linux_score += 3
if any("openssh" in p or "linux" in p for p in products):
linux_score += 5
if any("apache" in p or "nginx" in p for p in products):
linux_score += 2
# Strong Windows indicators
if 135 in port_nums: # MSRPC is Windows-only
windows_score += 5
if 3389 in port_nums: # RDP is Windows
windows_score += 3
if 5985 in port_nums or 5986 in port_nums: # WinRM is Windows-only
windows_score += 5
if any("microsoft" in p or "windows" in p for p in products):
windows_score += 5
# Weak indicators (could be either)
if 445 in port_nums: # SMB - could be Samba on Linux or Windows
windows_score += 1 # Slight Windows bias but not definitive
if 139 in port_nums: # NetBIOS - same as above
windows_score += 1
# Decide based on score
if linux_score > windows_score:
return "Linux"
if windows_score > linux_score:
return "Windows"
# 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"])
# Only include hosts with at least one OPEN port
# This prevents false positives from proxy ARP responses
# where routers respond for all IPs even if device is offline
if host["ip"] and host["ports"]:
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.
Filters out false positives from router proxy ARP (where all IPs appear "up").
"""
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 = None
kali_container = None
@@ -253,6 +557,23 @@ def _run_command_sync(container, command, working_dir):
workdir=working_dir
)
@app.get("/stream/processes")
async def stream_running_processes():
"""Server-Sent Events stream of running security processes.
Emits JSON events with current process list every 5 seconds.
"""
async def event_generator():
while True:
try:
data = await get_running_processes()
yield f"data: {json.dumps(data)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
await asyncio.sleep(5)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/execute", response_model=CommandResult)
async def execute_command(request: CommandRequest):
"""Execute a command in the Kali container."""
@@ -292,6 +613,15 @@ async def execute_command(request: CommandRequest):
stdout = output[0].decode('utf-8', errors='replace') if output[0] 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(
command_id=command_id,
command=request.command,
@@ -309,6 +639,52 @@ async def execute_command(request: CommandRequest):
except Exception as e:
raise HTTPException(status_code=500, detail=f"Execution error: {str(e)}")
@app.websocket("/ws/execute/{command_id}")
async def websocket_execute(websocket: WebSocket, command_id: str):
"""WebSocket endpoint for streaming command output in real-time."""
await websocket.accept()
if command_id not in running_commands:
await websocket.send_json({"error": "Command not found"})
await websocket.close()
return
cmd_info = running_commands[command_id]
try:
# Stream output as it becomes available
last_stdout_len = 0
last_stderr_len = 0
while cmd_info["status"] == "running":
current_stdout = cmd_info.get("stdout", "")
current_stderr = cmd_info.get("stderr", "")
# Send new stdout
if len(current_stdout) > last_stdout_len:
new_stdout = current_stdout[last_stdout_len:]
await websocket.send_json({"type": "stdout", "data": new_stdout})
last_stdout_len = len(current_stdout)
# Send new stderr
if len(current_stderr) > last_stderr_len:
new_stderr = current_stderr[last_stderr_len:]
await websocket.send_json({"type": "stderr", "data": new_stderr})
last_stderr_len = len(current_stderr)
await asyncio.sleep(0.5)
# Send final status
await websocket.send_json({
"type": "complete",
"status": cmd_info["status"],
"exit_code": cmd_info.get("exit_code"),
"duration": cmd_info.get("duration_seconds"),
})
except WebSocketDisconnect:
pass
finally:
await websocket.close()
@app.post("/execute/async")
async def execute_command_async(request: CommandRequest):
@@ -420,18 +796,92 @@ async def websocket_execute(websocket: WebSocket):
workdir=working_dir
)
# Stream output
for stdout, stderr in exec_result.output:
if stdout:
await websocket.send_json({
"type": "stdout",
"data": stdout.decode('utf-8', errors='replace')
})
if stderr:
await websocket.send_json({
"type": "stderr",
"data": stderr.decode('utf-8', errors='replace')
})
# Collect output for nmap parsing
full_stdout = []
is_nmap = is_nmap_command(command)
# Stream output with keepalive for long-running commands
last_output_time = asyncio.get_event_loop().time()
output_queue = asyncio.Queue()
stream_complete = asyncio.Event()
# Synchronous function to read from Docker stream (runs in thread)
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({
"type": "complete",

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1
docker==7.1.0
pydantic==2.10.2
websockets==14.1
httpx==0.28.1

View File

@@ -3,15 +3,24 @@ FROM kalilinux/kali-rolling
# Avoid prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Update and install ALL Kali tools
# Using kali-linux-everything metapackage for complete tool suite
RUN apt-get update && apt-get install -y --no-install-recommends \
kali-linux-everything \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Configure apt to use reliable mirrors and retry on failure
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
# Install kali-linux-everything metapackage (600+ tools, ~15GB)
# This includes: nmap, metasploit, burpsuite, wireshark, aircrack-ng,
# hashcat, john, hydra, sqlmap, nikto, wpscan, responder, crackmapexec,
# enum4linux, gobuster, dirb, wfuzz, masscan, and hundreds more
RUN apt-get update && \
apt-get install -y kali-linux-everything && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 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 \
beautifulsoup4 \
shodan \
@@ -27,11 +36,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Create workspace directory
WORKDIR /workspace
# Copy scripts
# Copy scripts and fix line endings (in case of Windows CRLF)
COPY entrypoint.sh /entrypoint.sh
COPY command_logger.sh /usr/local/bin/command_logger.sh
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
RUN mkdir -p /workspace/.command_history