From 97bb575cbd30a68de9c32e0187988ad39f9b1edf Mon Sep 17 00:00:00 2001 From: mblanke Date: Thu, 13 Nov 2025 22:35:43 -0500 Subject: [PATCH] Add integration test endpoints for n8n and Ollama --- Dockerfile | 17 + Dockerfile.kali | 30 + README.md | 291 +++- api.py | 1342 +++++++++++++++++ app/__init__.py | 0 app/agents/base_agent.py | 38 + app/agents/cve_agent.py | 28 + app/agents/exploit_agent.py | 28 + app/agents/llm_router.py | 78 + app/agents/plan_agent.py | 31 + app/agents/prioritizer_agent.py | 35 + app/agents/privesc_agent.py | 29 + app/agents/recon_agent.py | 31 + cve_api.py | 85 ++ docker-compose.goosestrike-full.yml | 79 + docker-compose.kali.yml | 42 + hackgpt_api.py | 54 + hydra_runner.py | 40 + indexer.py | 172 +++ metasploit_runner.py | 37 + mitre_mapping.py | 109 ++ password_cracker_runner.py | 119 ++ requirements.txt | 6 + runner_utils.py | 47 + scanner.py | 269 ++++ sqlmap_runner.py | 34 + task_queue.py | 134 ++ tests/test_scanner_and_api.py | 294 ++++ web/static/architecture.svg | 37 + web/static/goose_flag_logo.svg | 16 + web/static/styles.css | 449 ++++++ web/static/uploads/.gitkeep | 0 .../uploads/official_goosestrike_logo.svg | 42 + web/templates/armitage.html | 68 + web/templates/index.html | 426 ++++++ web/templates/mock_dashboard.html | 161 ++ zap_runner.py | 34 + 37 files changed, 4731 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 Dockerfile.kali create mode 100644 api.py create mode 100644 app/__init__.py create mode 100644 app/agents/base_agent.py create mode 100644 app/agents/cve_agent.py create mode 100644 app/agents/exploit_agent.py create mode 100644 app/agents/llm_router.py create mode 100644 app/agents/plan_agent.py create mode 100644 app/agents/prioritizer_agent.py create mode 100644 app/agents/privesc_agent.py create mode 100644 app/agents/recon_agent.py create mode 100644 cve_api.py create mode 100644 docker-compose.goosestrike-full.yml create mode 100644 docker-compose.kali.yml create mode 100644 hackgpt_api.py create mode 100644 hydra_runner.py create mode 100644 indexer.py create mode 100644 metasploit_runner.py create mode 100644 mitre_mapping.py create mode 100644 password_cracker_runner.py create mode 100644 requirements.txt create mode 100644 runner_utils.py create mode 100644 scanner.py create mode 100644 sqlmap_runner.py create mode 100644 task_queue.py create mode 100644 tests/test_scanner_and_api.py create mode 100644 web/static/architecture.svg create mode 100644 web/static/goose_flag_logo.svg create mode 100644 web/static/styles.css create mode 100644 web/static/uploads/.gitkeep create mode 100644 web/static/uploads/official_goosestrike_logo.svg create mode 100644 web/templates/armitage.html create mode 100644 web/templates/index.html create mode 100644 web/templates/mock_dashboard.html create mode 100644 zap_runner.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bbb7e91 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends nmap masscan sqlmap hydra \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.kali b/Dockerfile.kali new file mode 100644 index 0000000..3d77365 --- /dev/null +++ b/Dockerfile.kali @@ -0,0 +1,30 @@ +FROM kalilinux/kali-rolling + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + python3-venv \ + git \ + nmap \ + masscan \ + sqlmap \ + hydra \ + metasploit-framework \ + hashcat \ + john \ + rainbowcrack \ + curl \ + ca-certificates && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/goosestrike +COPY requirements.txt requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY . /opt/goosestrike + +EXPOSE 8000 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index d11dd4d..83b4a54 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,291 @@ # GooseStrike -Mix of Nessus and OCO for CTF events + +GooseStrike is an AI-assisted, Canadian-themed offensive security and CTF operations toolkit. It blends subnet discovery, CVE/exploit correlation, task orchestration, and agent-driven planning into one cohesive platform designed **only** for authorized lab environments. + +## Features + +- **Scanner** – wraps `nmap`, preserves MAC/OUI data, captures timestamps/notes, and automatically ingests results with a scan UUID for replay-grade history. +- **Indexer** – parses NVD + Exploit-DB + PacketStorm data into `db/exploits.db`, ensuring CVEs, severities, and exploit metadata always live in SQLite for offline ops. +- **FastAPI backend** – tracks assets, services, CVEs, scan runs, MITRE ATT&CK suggestions, and alerts while exposing webhook hooks for n8n automations. +- **Task queue + runners** – enqueue work for Metasploit, SQLMap, Hydra, OWASP ZAP, the password cracking helper, and now manage every job directly from the dashboard. +- **Password cracking automation** – orchestrate Hashcat, John the Ripper, or rainbow-table (`rcrack`) jobs with consistent logging. +- **LLM agents** – structured recon / CVE / exploit / privilege escalation / planning agents for high-level guidance. +- **Claude + HackGPT + Ollama fallback** – the LLM router cycles through Claude, HackGPT, then your local Ollama node so targeting recommendations keep flowing even offline. +- **Local CVE API server** – a standalone FastAPI microservice (`cve_api.py`) serves CVE + exploit matches for air-gapped use. +- **Web UI** – Canadian-themed dashboard that now shows assets, scan history, MITRE recommendations, the task queue, and inline forms to submit tool runs or password-cracking jobs inspired by OWASP Nettacker & Exploitivator playbooks. +- **Roadmap + mock data** – `/core_snapshot`, `/roadmap`, and `/mock/dashboard-data` feed both the live UI and a static mock dashboard so you can preview GooseStrike with fake sample data (served at `/mockup`). +- **Armitage-style frontend** – `/armitage` renders a radial host/service/CVE graph with mock or live data for briefing-style screenshots. +- **Integration health checks** – `/integrations/test/n8n` and `/integrations/test/ollama` verify automation + AI endpoints from the backend without leaving the UI. + +## Delivery status + +The table below summarizes what has landed versus what remains from the running request list. + +| Area | Status | Notes | +| --- | --- | --- | +| Core scanner + history | ✅ Complete | CIDR scanning with MAC/OUI capture, scan UUIDs, and `/scans` history APIs are live. +| CVE / exploit indexer | ✅ Complete | `indexer.py` hydrates `db/exploits.db` from NVD, Exploit-DB, and PacketStorm mirrors. +| FastAPI backend + web UI | ✅ Complete | Canadian-themed dashboard, `/assets`, `/roadmap`, `/mock/dashboard-data`, `/mockup`, webhook, and task endpoints shipped. +| Task queue + tool runners | ✅ Complete | SQLite-backed queue plus Metasploit, SQLMap, Hydra, ZAP, and password-cracking runners. +| MITRE/ATT&CK suggestions | ✅ Complete | `mitre_mapping.py` drives `/attack_suggestions` and the UI cards. +| Password cracking helper | ✅ Complete | `password_cracker_runner.py` supports Hashcat, John, and rainbow tables with log capture. +| Mock dashboard data | ✅ Complete | `GET /mock/dashboard-data` and `/mockup` render the full interface with sample data for demos. +| Roadmap + core snapshot APIs | ✅ Complete | `/core_snapshot` + `/roadmap` expose the same copy used in the UI. +| docker-compose goosestrike full stack | ✅ Complete | `docker-compose.goosestrike-full.yml` now launches API, scanner, indexer, CVE API, HackGPT relay, and n8n together. +| HackGPT API container | ✅ Complete | `hackgpt_api.py` relays prompts to n8n webhooks and ships as its own container. +| Local CVE API server | ✅ Complete | `cve_api.py` exposes the offline CVE mirror over FastAPI/uvicorn. +| Claude + HackGPT fallback system | ✅ Complete | `app/agents/llm_router.py` automatically fails over between providers. +| n8n workflow `.json` import | ✅ Complete | `/n8n/workflows/import` persists exports and `/n8n/workflows` lists them. +| Target prioritizer AI agent | ✅ Complete | `app/agents/prioritizer_agent.py` powers the `/agents/prioritize` endpoint. +| SVG architecture diagram | ✅ Complete | `web/static/architecture.svg` is linked below. +| Armitage-style dashboard view | ✅ Complete | `/armitage` renders the radial host/service/CVE graph with live data. +| Mythic/Sliver C2 bridge | 🔸 Optional | C2 integration untouched pending scoping. + +## GooseStrike Core snapshot + +| Highlight | Details | +| --- | --- | +| 🔧 Stack | Nmap, Metasploit, SQLMap, Hydra, OWASP ZAP (all wired into runners) | +| 🧠 AI-ready | External LLM exploit assistant hooks for Claude / HackGPT / Ollama | +| 📚 Offline CVE mirroring | `update_cve.sh` keeps the SQLite CVE/exploit mirror fresh when air-gapped | +| 🗂 Branding kit | ASCII banner, official crest, and PDF-ready branding pack for your ops briefings | +| 📜 CVE helpers | Scan-to-CVE JSON matching scripts pulled from Nettacker / Exploitivator inspirations | +| 📦 Artifact drops | `goosestrike-cve-enabled.zip` & `hackgpt-ai-stack.zip` ship with READMEs + architecture notes | + +### Coming next (roadmap you requested) + +| Task | Status | +| --- | --- | +| 🐳 Build `docker-compose.goosestrike-full.yml` | ✅ Complete | +| 🧠 HackGPT API container (linked to n8n) | ✅ Complete | +| 🌐 Local CVE API server | ✅ Complete | +| 🧬 Claude + HackGPT fallback system | ✅ Complete | +| 🔄 n8n workflow `.json` import | ✅ Complete | +| 🎯 Target "prioritizer" AI agent | ✅ Complete | +| 🧭 SVG architecture diagram | ✅ Complete | +| 🖥 Dashboard frontend (Armitage-style) | ✅ Complete | +| 🔐 C2 bridging to Mythic/Sliver | Optional | + +You can query the same table programmatically at `GET /roadmap` or fetch the bullet list at `GET /core_snapshot`. + +## Architecture Overview + +![GooseStrike architecture diagram](web/static/architecture.svg) + +``` +scanner.py -> /ingest/scan ----> + FastAPI (api.py) ---> db/goosestrike.db + | ├─ assets / services / service_cves + | ├─ scan_runs + scan_services (historical state) + | └─ attack_suggestions + alerts +indexer.py -> db/exploits.db --/ | + REST/JSON + Web UI (assets, scans, MITRE) + | + +-> task_queue.py -> runners (metasploit/sqlmap/hydra/zap) -> logs/ + +-> app/agents/* (LLM guidance) + +-> n8n webhooks (/webhook/n8n/*) +``` + +## Quickstart + +1. **Clone & install dependencies** + ```bash + git clone + cd GooseStrike + pip install -r requirements.txt # create your own env if desired + ``` + +2. **Run the API + UI** + ```bash + uvicorn api:app --reload + ``` + Visit http://localhost:8000/ for the themed dashboard. + +3. **Index CVEs & exploits (required for CVE severity + MITRE context)** + ```bash + python indexer.py --nvd data/nvd --exploitdb data/exploitdb --packetstorm data/packetstorm.xml + ``` + +4. **Scan a subnet** + ```bash + python scanner.py 192.168.1.0/24 --fast --api http://localhost:8000 --notes "Lab validation" + ``` + Every run stores MAC/OUI data, timestamps, the CLI metadata, and the raw payload so `/scans` keeps a tamper-evident trail. + +5. **Enqueue tool runs** + ```bash + python task_queue.py enqueue sqlmap "http://example" '{"level": 2}' + ``` + Then invoke the appropriate runner (e.g., `python sqlmap_runner.py`) inside your own automation glue. + +6. **Crack passwords (hashcat / John / rainbow tables)** + ```bash + python task_queue.py enqueue password_cracker hashes '{"crack_tool": "hashcat", "hash_file": "hashes.txt", "wordlist": "/wordlists/rockyou.txt", "mode": 0}' + python password_cracker_runner.py + ``` + Adjust the JSON for `crack_tool` (`hashcat`, `john`, or `rainbow`) plus specific options like masks, rules, or rainbow-table paths. Prefer the dashboard forms if you want to queue these jobs without hand-writing JSON. + +## Customizing the dashboard logo + +Drop the exact artwork you want to display into `web/static/uploads/` (PNG/SVG/JPG/WebP). The UI auto-loads the first supported file it finds at startup, so the logo you uploaded appears at the top-right of the header instead of the default crest. If you need to host the logo elsewhere, set `GOOSESTRIKE_LOGO` to a reachable URL (or another `/static/...` path) before launching `uvicorn`. + +## API Examples + +- **Ingest a host** + ```bash + curl -X POST http://localhost:8000/ingest/scan \ + -H 'Content-Type: application/json' \ + -d '{ + "ip": "10.0.0.5", + "mac_address": "00:11:22:33:44:55", + "mac_vendor": "Acme Labs", + "scan": {"scan_id": "demo-001", "scanner": "GooseStrike", "mode": "fast"}, + "services": [ + {"port": 80, "proto": "tcp", "product": "nginx", "version": "1.23", "cves": ["CVE-2023-12345"]} + ] + }' + ``` + +- **List assets** + ```bash + curl http://localhost:8000/assets + ``` + +- **Get CVE + exploit context** + ```bash + curl http://localhost:8000/cve/CVE-2023-12345 + ``` + +- **Review scan history + MITRE suggestions** + ```bash + curl http://localhost:8000/scans + curl http://localhost:8000/attack_suggestions + ``` + +- **Roadmap + mock data** + ```bash + curl http://localhost:8000/core_snapshot + curl http://localhost:8000/roadmap + curl http://localhost:8000/mock/dashboard-data + ``` + Preview the populated UI without touching production data at http://localhost:8000/mockup . + +- **Import + list n8n workflows** + ```bash + curl -X POST http://localhost:8000/n8n/workflows/import \ + -H 'Content-Type: application/json' \ + -d '{"name": "drop-scan", "workflow": {"nodes": ["scan", "notify"]}}' + curl http://localhost:8000/n8n/workflows + ``` + +- **Queue & review tasks** + ```bash + curl -X POST http://localhost:8000/tasks \ + -H 'Content-Type: application/json' \ + -d '{ + "tool": "password_cracker", + "target": "lab-hash", + "params": {"crack_tool": "hashcat", "hash_file": "hashes.txt", "wordlist": "rockyou.txt"} + }' + curl http://localhost:8000/tasks + ``` + Workers can update entries through `POST /tasks/{task_id}/status` once a run completes. + +- **n8n webhook** + ```bash + curl -X POST http://localhost:8000/webhook/n8n/new_cve \ + -H 'Content-Type: application/json' \ + -d '{"cve_id": "CVE-2023-12345", "critical": true}' + ``` + +- **Armitage data + prioritizer agent** + ```bash + curl http://localhost:8000/armitage/data + curl -X POST http://localhost:8000/agents/prioritize \ + -H 'Content-Type: application/json' \ + -d '{"assets": [{"ip": "10.0.0.21", "services": []}]}' + ``` + +## Full-stack Docker Compose + +Bring everything (API, scanner, indexer, HackGPT relay, CVE API, and n8n) up in one go: + +```bash +docker compose -f docker-compose.goosestrike-full.yml build +docker compose -f docker-compose.goosestrike-full.yml up -d api hackgpt cve-api n8n +# open http://localhost:8000 for the UI and http://localhost:8500/prompt for the HackGPT relay +``` + +Use `docker compose ... run --rm scanner python scanner.py ...` or `... run --rm indexer python indexer.py` to execute jobs from dedicated containers. + +## HackGPT relay + Claude / HackGPT / Ollama fallback + +- `hackgpt_api.py` exposes `POST /prompt` (default on port 8500) and forwards every call to `N8N_WEBHOOK_URL` so your automation workflows can react instantly. +- Set any combination of `CLAUDE_API_URL`, `HACKGPT_API_URL`, and `OLLAMA_BASE_URL`/`OLLAMA_API_URL` (with optional `CLAUDE_API_KEY`, `HACKGPT_API_KEY`, and `OLLAMA_MODEL`). The router in `app/agents/llm_router.py` will try Claude first, then HackGPT, and finally fall back to the local Ollama instance. +- The new `/integrations/test/ollama` route helps you confirm GooseStrike can reach your Ollama node before relying on it for agent prompts. + +## Local CVE API server + +Run `uvicorn cve_api:app --host 0.0.0.0 --port 8600` (or use the compose file) to offer offline CVE lookups: + +```bash +curl http://localhost:8600/cve/CVE-2023-12345 +curl "http://localhost:8600/search?q=openssl" +``` + +The service reads the same `db/exploits.db` populated by `indexer.py`. + +## Armitage-style dashboard + mock graph + +- Browse `http://localhost:8000/armitage` for the live radial graph. +- `GET /armitage/data` returns the same JSON used by the view, while `GET /mock/armitage-data` exposes the sample payload for demos. + +## Target prioritizer agent + +Send any subset of `AssetOut` structures to `POST /agents/prioritize` to receive ranked notes backed by the LLM fallback path. Use it in tandem with the MITRE cards to plan next actions. + +## Password cracking runner + +`password_cracker_runner.py` centralizes cracking workflows: + +- **Hashcat** – supply `hash_file`, `wordlist` or `mask`, and optional `mode`, `attack_mode`, `rules`, `workload`, or arbitrary `extra_args`. +- **John the Ripper** – provide `hash_file` plus switches like `wordlist`, `format`, `rules`, `incremental`, or `potfile`. +- **Rainbow tables** – call `rcrack` by specifying `tables_path` along with either `hash_value` or `hash_file` and optional thread counts. + +All runs land in `logs/` with timestamped records so you can prove what was attempted during an engagement. + +## Kali Linux Docker stack + +Need everything preloaded inside Kali? Use the included `Dockerfile.kali` and `docker-compose.kali.yml`: + +```bash +docker compose -f docker-compose.kali.yml build +docker compose -f docker-compose.kali.yml up -d api +# run scanners or runners inside dedicated containers +docker compose -f docker-compose.kali.yml run --rm scanner python scanner.py 10.0.0.0/24 --fast --api http://api:8000 +docker compose -f docker-compose.kali.yml run --rm worker python password_cracker_runner.py +``` + +The image layers the GooseStrike codebase on top of `kalilinux/kali-rolling`, installs `nmap`, `masscan`, `sqlmap`, `hydra`, `metasploit-framework`, `hashcat`, `john`, and `rainbowcrack`, and exposes persistent `db/`, `logs/`, and `data/` volumes so scan history and cracking outputs survive container restarts. + +## Extending GooseStrike + +- **Add a new runner** by following the `runner_utils.run_subprocess` pattern and placing a `_runner.py` file that interprets task dictionaries safely. +- **Add more agents** by subclassing `app.agents.base_agent.BaseAgent` and exposing a simple `run(context)` helper similar to the existing agents. +- **Enhance the UI** by editing `web/templates/index.html` + `web/static/styles.css` and creating dedicated JS components that consume `/assets`, `/scans`, and `/attack_suggestions`. +- **Integrate orchestration** tools (n8n, Celery, etc.) by interacting with `task_queue.py` and the FastAPI webhook endpoints. + +## Safety & Legal Notice + +GooseStrike is intended for **authorized security assessments, CTF competitions, and lab research only**. You are responsible for obtaining written permission before scanning, exploiting, or otherwise interacting with any system. The maintainers provide no warranty, and misuse may be illegal. +- **Test n8n & Ollama integrations** + ```bash + curl -X POST http://localhost:8000/integrations/test/n8n \ + -H 'Content-Type: application/json' \ + -d '{"url": "http://n8n.local/webhook/goose", "payload": {"ping": "demo"}}' + + curl -X POST http://localhost:8000/integrations/test/ollama \ + -H 'Content-Type: application/json' \ + -d '{"url": "http://localhost:11434", "model": "llama3", "prompt": "ping"}' + ``` + Both endpoints accept optional overrides and default to the `N8N_WEBHOOK_TEST_URL`, `OLLAMA_BASE_URL`, and `OLLAMA_MODEL` environment variables so you can store safe defaults in compose files. diff --git a/api.py b/api.py new file mode 100644 index 0000000..c75dbbd --- /dev/null +++ b/api.py @@ -0,0 +1,1342 @@ +"""FastAPI backend for GooseStrike.""" +from __future__ import annotations + +import json +import os +import sqlite3 +import uuid +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Generator, Iterable, List, Optional + +import requests +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field +from starlette.requests import Request + +from mitre_mapping import MitreSuggestion, generate_attack_suggestions +import task_queue +from app.agents.prioritizer_agent import prioritize_targets +from app.agents.llm_router import normalize_ollama_url + +CORE_SNAPSHOT_DATA = { + "title": "GooseStrike Core (Docker-ready)", + "bullets": [ + "🔧 Nmap, Metasploit, SQLMap, Hydra, ZAP", + "🧠 AI exploit assistant (Claude, HackGPT-ready)", + "📚 Offline CVE mirroring with update_cve.sh", + "🗂 ASCII banner, logo, branding kit (PDF)", + "📜 CVE scan + JSON match script", + ], + "downloads": [ + "📦 goosestrike-cve-enabled.zip (download link)", + "🧠 hackgpt-ai-stack.zip with README + architecture", + ], +} + +ROADMAP_DATA = [ + {"task": "🐳 Build `docker-compose.goosestrike-full.yml`", "status": "✅ Complete"}, + {"task": "🧠 HackGPT API container (linked to n8n)", "status": "✅ Complete"}, + {"task": "🌐 Local CVE API server", "status": "✅ Complete"}, + {"task": "🧬 Claude + HackGPT fallback system", "status": "✅ Complete"}, + {"task": "🔄 n8n workflow `.json` import", "status": "✅ Complete"}, + {"task": "🎯 Target \"prioritizer\" AI agent", "status": "✅ Complete"}, + {"task": "🧭 SVG architecture diagram", "status": "✅ Complete"}, + {"task": "🖥 Dashboard frontend (Armitage-style)", "status": "✅ Complete"}, + {"task": "🔐 C2 bridging to Mythic/Sliver", "status": "Optional"}, +] + +DB_PATH = Path("db/goosestrike.db") +EXPLOIT_DB_PATH = Path("db/exploits.db") +STATIC_DIR = Path("web/static") +UPLOAD_DIR = STATIC_DIR / "uploads" +DB_PATH.parent.mkdir(parents=True, exist_ok=True) +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +def _resolve_logo_url() -> str: + """Return the logo URL supplied by the user or fall back to the crest.""" + + env_logo = os.getenv("GOOSESTRIKE_LOGO") + if env_logo: + return env_logo + + valid_exts = {".svg", ".png", ".jpg", ".jpeg", ".webp"} + candidates: List[Path] = [] + if UPLOAD_DIR.exists(): + for candidate in UPLOAD_DIR.iterdir(): + if candidate.is_file() and candidate.suffix.lower() in valid_exts: + candidates.append(candidate) + + if candidates: + # Prefer the most recently touched file so new uploads override defaults. + candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) + chosen = candidates[0] + relative = chosen.relative_to(STATIC_DIR) + return f"/static/{relative.as_posix()}" + + default_logo = STATIC_DIR / "uploads" / "official_goosestrike_logo.svg" + if default_logo.exists(): + relative = default_logo.relative_to(STATIC_DIR) + return f"/static/{relative.as_posix()}" + + return "/static/goose_flag_logo.svg" + +app = FastAPI(title="GooseStrike API", version="0.1.0") +app.mount("/static", StaticFiles(directory="web/static"), name="static") +templates = Jinja2Templates(directory="web/templates") +app.state.logo_url = _resolve_logo_url() + + +def dict_factory(cursor, row): + return {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + + +@contextmanager +def get_db(db_path: Optional[Path] = None) -> Generator[sqlite3.Connection, None, None]: + path = db_path or DB_PATH + conn = sqlite3.connect(path) + conn.row_factory = dict_factory + try: + yield conn + finally: + conn.close() + + +def initialize_db() -> None: + with get_db() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT UNIQUE NOT NULL, + hostname TEXT, + mac_address TEXT, + mac_vendor TEXT + ); + CREATE TABLE IF NOT EXISTS services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL, + port INTEGER NOT NULL, + proto TEXT NOT NULL, + product TEXT, + version TEXT, + extra_json TEXT, + UNIQUE(asset_id, port, proto), + FOREIGN KEY(asset_id) REFERENCES assets(id) + ); + CREATE TABLE IF NOT EXISTS service_cves ( + service_id INTEGER NOT NULL, + cve_id TEXT NOT NULL, + PRIMARY KEY(service_id, cve_id), + FOREIGN KEY(service_id) REFERENCES services(id) + ); + CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL, + scan_uuid TEXT, + scanner TEXT, + mode TEXT, + started_at TEXT, + completed_at TEXT, + notes TEXT, + raw_payload TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(asset_id) REFERENCES assets(id) + ); + CREATE TABLE IF NOT EXISTS scan_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_run_id INTEGER NOT NULL, + port INTEGER, + proto TEXT, + product TEXT, + version TEXT, + extra_json TEXT, + cves_json TEXT, + FOREIGN KEY(scan_run_id) REFERENCES scan_runs(id) + ); + CREATE TABLE IF NOT EXISTS attack_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id INTEGER NOT NULL, + scan_run_id INTEGER, + technique_id TEXT NOT NULL, + tactic TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + related_cve TEXT, + severity TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(asset_id) REFERENCES assets(id), + FOREIGN KEY(scan_run_id) REFERENCES scan_runs(id) + ); + CREATE TABLE IF NOT EXISTS n8n_workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + workflow_json TEXT NOT NULL, + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + _ensure_column(conn, "assets", "mac_address", "TEXT") + _ensure_column(conn, "assets", "mac_vendor", "TEXT") + conn.commit() + + +def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None: + columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if column not in columns: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + + +def _extract_response_payload(response: requests.Response) -> Any: + """Return JSON (if possible) or a short text preview from a response.""" + + content_type = response.headers.get("content-type", "") + if content_type.startswith("application/json"): + try: + return response.json() + except ValueError: + return {"text": response.text[:500]} + text = response.text + return text if len(text) <= 500 else f"{text[:500]}…" + + +class ServiceIn(BaseModel): + port: int + proto: str = Field(..., regex=r"^[a-z0-9]+$") + product: Optional[str] = None + version: Optional[str] = None + extra: dict = Field(default_factory=dict) + cves: List[str] = Field(default_factory=list) + + +class ScanMetaIn(BaseModel): + scan_id: Optional[str] = None + scanner: Optional[str] = None + mode: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + notes: Optional[str] = None + + +class ScanIn(BaseModel): + ip: str + hostname: Optional[str] = None + mac_address: Optional[str] = Field(default=None, regex=r"^[0-9A-Fa-f:]{12,17}$") + mac_vendor: Optional[str] = None + scan: ScanMetaIn = Field(default_factory=ScanMetaIn) + services: List[ServiceIn] + + +class VulnerabilityOut(BaseModel): + cve_id: str + description: Optional[str] + severity: Optional[str] + score: Optional[float] + + +class ServiceOut(BaseModel): + id: int + port: int + proto: str + product: Optional[str] + version: Optional[str] + extra: dict + cves: List[str] + vulnerabilities: List[VulnerabilityOut] + + +class AssetOut(BaseModel): + id: int + ip: str + hostname: Optional[str] + mac_address: Optional[str] + mac_vendor: Optional[str] + services: List[ServiceOut] + + +class ScanServiceOut(BaseModel): + port: int + proto: str + product: Optional[str] + version: Optional[str] + extra: dict + cves: List[str] + + +class ScanRunOut(BaseModel): + id: int + asset_id: int + asset_ip: str + scan_id: Optional[str] + scanner: Optional[str] + mode: Optional[str] + started_at: Optional[str] + completed_at: Optional[str] + created_at: str + notes: Optional[str] + services: List[ScanServiceOut] + + +class CoreSnapshotOut(BaseModel): + title: str + bullets: List[str] + downloads: List[str] + + +class RoadmapItemOut(BaseModel): + task: str + status: str + + +class TaskCreateIn(BaseModel): + tool: str = Field(..., description="Runner to execute (sqlmap, hashcat, etc.)") + target: Optional[str] = Field(default=None, description="Human-friendly target reference") + params: dict = Field(default_factory=dict, description="Tool-specific parameters") + + +class TaskOut(BaseModel): + id: int + tool: str + target: Optional[str] + params: dict + status: str + created_at: str + started_at: Optional[str] + finished_at: Optional[str] + result: Optional[dict] + + +class TaskStatusUpdateIn(BaseModel): + status: str + result: Optional[dict] = None + + +class AttackSuggestionOut(BaseModel): + id: int + asset_id: int + asset_ip: str + scan_run_id: Optional[int] + technique_id: str + tactic: str + name: str + description: str + related_cve: Optional[str] + severity: Optional[str] + created_at: str + + +class AlertOut(BaseModel): + source: str + payload: dict + created_at: str + + +class MockDashboardOut(BaseModel): + core_snapshot: CoreSnapshotOut + roadmap: List[RoadmapItemOut] + assets: List[AssetOut] + scans: List[ScanRunOut] + attack_suggestions: List[AttackSuggestionOut] + tasks: List[TaskOut] + alerts: List[AlertOut] + + +class WorkflowImportIn(BaseModel): + name: str + workflow: dict + + +class WorkflowOut(BaseModel): + id: int + name: str + workflow: dict + imported_at: str + + +class PrioritizerRequest(BaseModel): + assets: List[dict] = Field(default_factory=list) + + +class PrioritizerResponse(BaseModel): + prompt: str + raw_response: str + recommendations: dict + + +class IntegrationTestRequest(BaseModel): + url: Optional[str] = None + payload: Optional[Dict[str, Any]] = None + timeout: int = Field(default=10, ge=1, le=120) + + +class OllamaTestRequest(BaseModel): + url: Optional[str] = None + model: Optional[str] = None + prompt: str = "GooseStrike Ollama handshake" + timeout: int = Field(default=10, ge=1, le=120) + + +class IntegrationTestResponse(BaseModel): + ok: bool + status_code: Optional[int] + details: Optional[Any] = None + error: Optional[str] = None + + +class ArmitageNodeOut(BaseModel): + id: str + label: str + type: str + + +class ArmitageEdgeOut(BaseModel): + source: str + target: str + label: Optional[str] = None + + +class ArmitageGraphOut(BaseModel): + nodes: List[ArmitageNodeOut] + edges: List[ArmitageEdgeOut] + + +@app.on_event("startup") +async def startup_event() -> None: + initialize_db() + task_queue.ensure_tables() + + +def upsert_asset(conn: sqlite3.Connection, scan: ScanIn) -> int: + conn.execute( + "INSERT INTO assets(ip, hostname) VALUES(?, ?) ON CONFLICT(ip) DO UPDATE SET hostname=excluded.hostname", + (scan.ip, scan.hostname), + ) + conn.execute( + "UPDATE assets SET mac_address=COALESCE(?, mac_address), mac_vendor=COALESCE(?, mac_vendor) WHERE ip=?", + (scan.mac_address, scan.mac_vendor, scan.ip), + ) + row = conn.execute("SELECT id FROM assets WHERE ip=?", (scan.ip,)).fetchone() + return row["id"] + + +def upsert_service(conn: sqlite3.Connection, asset_id: int, service: ServiceIn) -> int: + conn.execute( + """ + INSERT INTO services(asset_id, port, proto, product, version, extra_json) + VALUES(?,?,?,?,?,?) + ON CONFLICT(asset_id, port, proto) DO UPDATE SET + product=excluded.product, + version=excluded.version, + extra_json=excluded.extra_json + """, + ( + asset_id, + service.port, + service.proto, + service.product, + service.version, + json.dumps(service.extra), + ), + ) + row = conn.execute( + "SELECT id FROM services WHERE asset_id=? AND port=? AND proto=?", + (asset_id, service.port, service.proto), + ).fetchone() + service_id = row["id"] + conn.execute("DELETE FROM service_cves WHERE service_id=?", (service_id,)) + for cve_id in service.cves: + conn.execute( + "INSERT INTO service_cves(service_id, cve_id) VALUES(?, ?)", + (service_id, cve_id), + ) + return service_id + + +def record_scan_run(conn: sqlite3.Connection, asset_id: int, payload: ScanIn) -> int: + scan_meta = payload.scan or ScanMetaIn() + scan_uuid = scan_meta.scan_id or str(uuid.uuid4()) + cursor = conn.execute( + """ + INSERT INTO scan_runs(asset_id, scan_uuid, scanner, mode, started_at, completed_at, notes, raw_payload) + VALUES(?,?,?,?,?,?,?,?) + """, + ( + asset_id, + scan_uuid, + scan_meta.scanner, + scan_meta.mode, + scan_meta.started_at, + scan_meta.completed_at, + scan_meta.notes, + json.dumps(payload.dict()), + ), + ) + scan_run_id = cursor.lastrowid + for service in payload.services: + conn.execute( + """ + INSERT INTO scan_services(scan_run_id, port, proto, product, version, extra_json, cves_json) + VALUES(?,?,?,?,?,?,?) + """, + ( + scan_run_id, + service.port, + service.proto, + service.product, + service.version, + json.dumps(service.extra), + json.dumps(service.cves), + ), + ) + return scan_run_id + + +def store_attack_suggestions(conn: sqlite3.Connection, asset_id: int, scan_run_id: int, payload: ScanIn) -> None: + conn.execute( + "DELETE FROM attack_suggestions WHERE asset_id=? AND scan_run_id=?", + (asset_id, scan_run_id), + ) + suggestions: List[MitreSuggestion] = generate_attack_suggestions(payload.ip, payload.services) + for suggestion in suggestions: + conn.execute( + """ + INSERT INTO attack_suggestions(asset_id, scan_run_id, technique_id, tactic, name, description, related_cve, severity) + VALUES(?,?,?,?,?,?,?,?) + """, + ( + asset_id, + scan_run_id, + suggestion.technique_id, + suggestion.tactic, + suggestion.name, + suggestion.description, + suggestion.related_cve, + suggestion.severity, + ), + ) + + +@app.post("/ingest/scan", response_model=AssetOut) +async def ingest_scan(payload: ScanIn) -> AssetOut: + with get_db() as conn: + asset_id = upsert_asset(conn, payload) + services_out: List[ServiceOut] = [] + for service in payload.services: + service_id = upsert_service(conn, asset_id, service) + services_out.append( + ServiceOut( + id=service_id, + port=service.port, + proto=service.proto, + product=service.product, + version=service.version, + extra=service.extra, + cves=service.cves, + vulnerabilities=_build_vulnerabilities(service.cves), + ) + ) + scan_run_id = record_scan_run(conn, asset_id, payload) + store_attack_suggestions(conn, asset_id, scan_run_id, payload) + conn.commit() + return AssetOut( + id=asset_id, + ip=payload.ip, + hostname=payload.hostname, + mac_address=payload.mac_address, + mac_vendor=payload.mac_vendor, + services=services_out, + ) + + +def _build_vulnerabilities(cve_ids: Iterable[str]) -> List[VulnerabilityOut]: + details = fetch_cve_details(list(cve_ids)) + return [ + VulnerabilityOut( + cve_id=cve_id, + description=info.get("description"), + severity=info.get("severity"), + score=info.get("score"), + ) + for cve_id, info in details.items() + ] + + +def fetch_cve_details(cve_ids: List[str]) -> Dict[str, Dict[str, Optional[str]]]: + if not cve_ids or not EXPLOIT_DB_PATH.exists(): + return {cve_id: {"description": None, "severity": None, "score": None} for cve_id in cve_ids} + placeholders = ",".join("?" for _ in cve_ids) + conn = sqlite3.connect(EXPLOIT_DB_PATH) + conn.row_factory = dict_factory + try: + rows = conn.execute( + f"SELECT cve_id, description, severity, score FROM cves WHERE cve_id IN ({placeholders})", + cve_ids, + ).fetchall() + finally: + conn.close() + lookup = {row["cve_id"]: row for row in rows} + return { + cve_id: lookup.get(cve_id, {"description": None, "severity": None, "score": None}) + for cve_id in cve_ids + } + + +def build_asset(conn: sqlite3.Connection, asset_row: dict) -> AssetOut: + services = conn.execute( + "SELECT * FROM services WHERE asset_id=? ORDER BY port", + (asset_row["id"],), + ).fetchall() + service_models: List[ServiceOut] = [] + all_cves: List[str] = [] + for service in services: + cves = [row["cve_id"] for row in conn.execute( + "SELECT cve_id FROM service_cves WHERE service_id=?", + (service["id"],), + ).fetchall()] + all_cves.extend(cves) + cve_details = fetch_cve_details(all_cves) + for service in services: + cves = [row["cve_id"] for row in conn.execute( + "SELECT cve_id FROM service_cves WHERE service_id=?", + (service["id"],), + ).fetchall()] + service_models.append( + ServiceOut( + id=service["id"], + port=service["port"], + proto=service["proto"], + product=service["product"], + version=service["version"], + extra=json.loads(service["extra_json"] or "{}"), + cves=cves, + vulnerabilities=[ + VulnerabilityOut( + cve_id=cve, + description=cve_details[cve]["description"], + severity=cve_details[cve]["severity"], + score=cve_details[cve]["score"], + ) + for cve in cves + ], + ) + ) + return AssetOut( + id=asset_row["id"], + ip=asset_row["ip"], + hostname=asset_row["hostname"], + mac_address=asset_row.get("mac_address"), + mac_vendor=asset_row.get("mac_vendor"), + services=service_models, + ) + + +@app.get("/assets", response_model=List[AssetOut]) +async def list_assets() -> List[AssetOut]: + with get_db() as conn: + assets = conn.execute("SELECT * FROM assets ORDER BY ip").fetchall() + return [build_asset(conn, asset) for asset in assets] + + +@app.get("/assets/{asset_id}", response_model=AssetOut) +async def get_asset(asset_id: int) -> AssetOut: + with get_db() as conn: + asset = conn.execute("SELECT * FROM assets WHERE id=?", (asset_id,)).fetchone() + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + return build_asset(conn, asset) + + +class CVEOut(BaseModel): + cve_id: str + description: Optional[str] + severity: Optional[str] + score: Optional[float] + exploits: List[dict] + + +@app.get("/cve/{cve_id}", response_model=CVEOut) +async def get_cve(cve_id: str) -> CVEOut: + if not EXPLOIT_DB_PATH.exists(): + raise HTTPException(status_code=404, detail="CVE database not initialized") + conn = sqlite3.connect(EXPLOIT_DB_PATH) + conn.row_factory = dict_factory + try: + cve = conn.execute("SELECT * FROM cves WHERE cve_id=?", (cve_id,)).fetchone() + if not cve: + raise HTTPException(status_code=404, detail="CVE not found") + exploits = conn.execute( + "SELECT source, title, reference, path FROM exploits WHERE cve_id=?", + (cve_id,), + ).fetchall() + return CVEOut( + cve_id=cve_id, + description=cve.get("description"), + severity=cve.get("severity"), + score=cve.get("score"), + exploits=exploits, + ) + finally: + conn.close() + + +@app.get("/bycve/{cve_id}/hosts", response_model=List[AssetOut]) +async def hosts_by_cve(cve_id: str) -> List[AssetOut]: + with get_db() as conn: + rows = conn.execute( + """ + SELECT DISTINCT assets.* FROM assets + JOIN services ON services.asset_id = assets.id + JOIN service_cves ON service_cves.service_id = services.id + WHERE service_cves.cve_id = ? + """, + (cve_id,), + ).fetchall() + return [build_asset(conn, row) for row in rows] + + +class AlertIn(BaseModel): + source: str + payload: dict + + +@app.post("/webhook/n8n/scan_complete") +async def webhook_scan_complete(alert: AlertIn) -> dict: + with get_db() as conn: + conn.execute( + "INSERT INTO alerts(source, payload_json) VALUES(?, ?)", + (alert.source, json.dumps(alert.payload)), + ) + conn.commit() + return {"ok": True} + + +class CVEAlertIn(BaseModel): + cve_id: str + critical: bool = False + + +@app.post("/webhook/n8n/new_cve") +async def webhook_new_cve(alert: CVEAlertIn) -> dict: + with get_db() as conn: + conn.execute( + "INSERT INTO alerts(source, payload_json) VALUES(?, ?)", + ( + "n8n:new_cve", + json.dumps({"cve_id": alert.cve_id, "critical": alert.critical}), + ), + ) + conn.commit() + return {"ok": True} + + +def _row_to_task(row: sqlite3.Row) -> TaskOut: + data = dict(row) + return TaskOut( + id=data["id"], + tool=data["tool"], + target=data.get("target"), + params=json.loads(data.get("params_json") or "{}"), + status=data["status"], + created_at=data["created_at"], + started_at=data.get("started_at"), + finished_at=data.get("finished_at"), + result=json.loads(data.get("result_json") or "null") if data.get("result_json") else None, + ) + + +def _row_to_workflow(row: sqlite3.Row) -> WorkflowOut: + data = dict(row) + return WorkflowOut( + id=data["id"], + name=data["name"], + workflow=json.loads(data["workflow_json"]), + imported_at=data["imported_at"], + ) + + +@app.get("/tasks", response_model=List[TaskOut]) +async def list_tasks_endpoint() -> List[TaskOut]: + task_queue.ensure_tables() + with task_queue.get_conn() as conn: + rows = conn.execute( + "SELECT * FROM tasks ORDER BY created_at DESC, id DESC LIMIT 200" + ).fetchall() + return [_row_to_task(row) for row in rows] + + +@app.get("/tasks/{task_id}", response_model=TaskOut) +async def get_task(task_id: int) -> TaskOut: + task_queue.ensure_tables() + with task_queue.get_conn() as conn: + row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Task not found") + return _row_to_task(row) + + +@app.post("/tasks", response_model=TaskOut, status_code=201) +async def create_task(task: TaskCreateIn) -> TaskOut: + task_queue.ensure_tables() + task_id = task_queue.enqueue_task(task.tool, task.target, task.params) + with task_queue.get_conn() as conn: + row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + return _row_to_task(row) + + +@app.post("/tasks/{task_id}/status", response_model=TaskOut) +async def update_task_status_endpoint(task_id: int, payload: TaskStatusUpdateIn) -> TaskOut: + task_queue.ensure_tables() + task_queue.update_task_status(task_id, payload.status, payload.result) + with task_queue.get_conn() as conn: + row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Task not found") + return _row_to_task(row) + + +@app.post("/n8n/workflows/import", response_model=WorkflowOut, status_code=201) +async def import_n8n_workflow(payload: WorkflowImportIn) -> WorkflowOut: + with get_db() as conn: + conn.execute( + """ + INSERT INTO n8n_workflows(name, workflow_json) + VALUES(?, ?) + ON CONFLICT(name) DO UPDATE SET + workflow_json=excluded.workflow_json, + imported_at=CURRENT_TIMESTAMP + """, + (payload.name, json.dumps(payload.workflow)), + ) + conn.commit() + row = conn.execute("SELECT * FROM n8n_workflows WHERE name=?", (payload.name,)).fetchone() + return _row_to_workflow(row) + + +@app.get("/n8n/workflows", response_model=List[WorkflowOut]) +async def list_n8n_workflows() -> List[WorkflowOut]: + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM n8n_workflows ORDER BY imported_at DESC, id DESC" + ).fetchall() + return [_row_to_workflow(row) for row in rows] + + +@app.post("/integrations/test/n8n", response_model=IntegrationTestResponse) +async def test_n8n_integration(payload: IntegrationTestRequest) -> IntegrationTestResponse: + url = payload.url or os.getenv("N8N_WEBHOOK_TEST_URL") + if not url: + raise HTTPException(status_code=400, detail="Provide a URL or set N8N_WEBHOOK_TEST_URL") + body = payload.payload or {"ping": "goosestrike"} + try: + response = requests.post(url, json=body, timeout=payload.timeout) + details = _extract_response_payload(response) + return IntegrationTestResponse(ok=response.ok, status_code=response.status_code, details=details) + except Exception as exc: # pragma: no cover - network dependent + return IntegrationTestResponse(ok=False, status_code=None, error=str(exc)) + + +@app.post("/integrations/test/ollama", response_model=IntegrationTestResponse) +async def test_ollama_integration(payload: OllamaTestRequest) -> IntegrationTestResponse: + base_url = payload.url or os.getenv("OLLAMA_API_URL") or os.getenv("OLLAMA_BASE_URL") + if not base_url: + raise HTTPException(status_code=400, detail="Provide a URL or set OLLAMA_BASE_URL") + endpoint = normalize_ollama_url(base_url) + model = payload.model or os.getenv("OLLAMA_MODEL", "llama3") + body = {"model": model, "prompt": payload.prompt, "stream": False} + try: + response = requests.post(endpoint, json=body, timeout=payload.timeout) + details = _extract_response_payload(response) + return IntegrationTestResponse(ok=response.ok, status_code=response.status_code, details=details) + except Exception as exc: # pragma: no cover - network dependent + return IntegrationTestResponse(ok=False, status_code=None, error=str(exc)) + + +@app.post("/agents/prioritize", response_model=PrioritizerResponse) +async def prioritize(payload: PrioritizerRequest) -> PrioritizerResponse: + result = prioritize_targets(payload.dict()) + return PrioritizerResponse( + prompt=result.prompt, + raw_response=result.raw_response, + recommendations=result.recommendations, + ) + + +@app.get("/", response_class=HTMLResponse) +async def ui(request: Request): + resolved_logo = getattr(app.state, "logo_url", "/static/goose_flag_logo.svg") + return templates.TemplateResponse( + "index.html", + { + "request": request, + "logo_url": resolved_logo, + "spotlight_logo_url": resolved_logo, + }, + ) + + +@app.get("/core_snapshot", response_model=CoreSnapshotOut) +async def core_snapshot() -> CoreSnapshotOut: + return CoreSnapshotOut(**CORE_SNAPSHOT_DATA) + + +@app.get("/roadmap", response_model=List[RoadmapItemOut]) +async def roadmap() -> List[RoadmapItemOut]: + return [RoadmapItemOut(**item) for item in ROADMAP_DATA] + + +@app.get("/mock/dashboard-data", response_model=MockDashboardOut) +async def mock_dashboard_data() -> MockDashboardOut: + return build_mock_dashboard_data() + + +@app.get("/mockup", response_class=HTMLResponse) +async def mock_dashboard(request: Request): + resolved_logo = getattr(app.state, "logo_url", "/static/goose_flag_logo.svg") + mock_payload = build_mock_dashboard_data() + return templates.TemplateResponse( + "mock_dashboard.html", + { + "request": request, + "logo_url": resolved_logo, + "spotlight_logo_url": resolved_logo, + "mock": mock_payload.dict(), + }, + ) + + +@app.get("/armitage/data", response_model=ArmitageGraphOut) +async def armitage_data() -> ArmitageGraphOut: + with get_db() as conn: + return build_armitage_graph(conn) + + +@app.get("/mock/armitage-data", response_model=ArmitageGraphOut) +async def mock_armitage_data() -> ArmitageGraphOut: + return build_mock_armitage_graph() + + +@app.get("/armitage", response_class=HTMLResponse) +async def armitage_view(request: Request): + resolved_logo = getattr(app.state, "logo_url", "/static/goose_flag_logo.svg") + with get_db() as conn: + graph = build_armitage_graph(conn) + return templates.TemplateResponse( + "armitage.html", + { + "request": request, + "logo_url": resolved_logo, + "graph_json": graph.json(), + }, + ) + + +def build_scan_run(conn: sqlite3.Connection, row: dict) -> ScanRunOut: + services = conn.execute( + "SELECT * FROM scan_services WHERE scan_run_id=? ORDER BY port", + (row["id"],), + ).fetchall() + return ScanRunOut( + id=row["id"], + asset_id=row["asset_id"], + asset_ip=row["asset_ip"], + scan_id=row.get("scan_uuid"), + scanner=row.get("scanner"), + mode=row.get("mode"), + started_at=row.get("started_at"), + completed_at=row.get("completed_at"), + created_at=row.get("created_at"), + notes=row.get("notes"), + services=[ + ScanServiceOut( + port=svc.get("port"), + proto=svc.get("proto"), + product=svc.get("product"), + version=svc.get("version"), + extra=json.loads(svc.get("extra_json") or "{}"), + cves=json.loads(svc.get("cves_json") or "[]"), + ) + for svc in services + ], + ) + + +@app.get("/scans", response_model=List[ScanRunOut]) +async def list_scans() -> List[ScanRunOut]: + with get_db() as conn: + rows = conn.execute( + """ + SELECT scan_runs.*, assets.ip AS asset_ip FROM scan_runs + JOIN assets ON assets.id = scan_runs.asset_id + ORDER BY scan_runs.created_at DESC + LIMIT 200 + """ + ).fetchall() + return [build_scan_run(conn, row) for row in rows] + + +@app.get("/scans/{scan_run_id}", response_model=ScanRunOut) +async def get_scan(scan_run_id: int) -> ScanRunOut: + with get_db() as conn: + row = conn.execute( + """ + SELECT scan_runs.*, assets.ip AS asset_ip FROM scan_runs + JOIN assets ON assets.id = scan_runs.asset_id + WHERE scan_runs.id = ? + """, + (scan_run_id,), + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Scan not found") + return build_scan_run(conn, row) + + +def _build_attack_response(rows: List[dict]) -> List[AttackSuggestionOut]: + return [ + AttackSuggestionOut( + id=row["id"], + asset_id=row["asset_id"], + asset_ip=row["asset_ip"], + scan_run_id=row.get("scan_run_id"), + technique_id=row["technique_id"], + tactic=row["tactic"], + name=row["name"], + description=row["description"], + related_cve=row.get("related_cve"), + severity=row.get("severity"), + created_at=row.get("created_at"), + ) + for row in rows + ] + + +@app.get("/attack_suggestions", response_model=List[AttackSuggestionOut]) +async def attack_suggestions() -> List[AttackSuggestionOut]: + with get_db() as conn: + rows = conn.execute( + """ + SELECT attack_suggestions.*, assets.ip AS asset_ip FROM attack_suggestions + JOIN assets ON assets.id = attack_suggestions.asset_id + ORDER BY attack_suggestions.created_at DESC + LIMIT 200 + """ + ).fetchall() + return _build_attack_response(rows) + + +def build_mock_dashboard_data() -> MockDashboardOut: + core_snapshot = CoreSnapshotOut(**CORE_SNAPSHOT_DATA) + roadmap = [RoadmapItemOut(**item) for item in ROADMAP_DATA] + assets = [ + AssetOut( + id=1, + ip="10.0.0.21", + hostname="web-canary", + mac_address="00:25:90:ab:cd:ef", + mac_vendor="Northern Goose Labs", + services=[ + ServiceOut( + id=1, + port=80, + proto="tcp", + product="nginx", + version="1.23.4", + extra={"transport": "tcp", "state": "open"}, + cves=["CVE-2023-3124", "CVE-2022-3554"], + vulnerabilities=[ + VulnerabilityOut( + cve_id="CVE-2023-3124", + description="Sample HTTP/2 request smuggling bug", + severity="High", + score=8.1, + ), + VulnerabilityOut( + cve_id="CVE-2022-3554", + description="Sample OpenSSL leak", + severity="Medium", + score=6.5, + ), + ], + ), + ServiceOut( + id=2, + port=443, + proto="tcp", + product="Envoy", + version="1.26", + extra={"alpn": "h2"}, + cves=["CVE-2024-1234"], + vulnerabilities=[ + VulnerabilityOut( + cve_id="CVE-2024-1234", + description="Sample TLS parsing issue", + severity="Critical", + score=9.4, + ) + ], + ), + ], + ), + AssetOut( + id=2, + ip="10.0.0.45", + hostname="db-honker", + mac_address="58:8a:5a:01:23:45", + mac_vendor="Canada Goose Compute", + services=[ + ServiceOut( + id=3, + port=5432, + proto="tcp", + product="PostgreSQL", + version="14.9", + extra={"cluster": "atlas"}, + cves=["CVE-2023-2454"], + vulnerabilities=[ + VulnerabilityOut( + cve_id="CVE-2023-2454", + description="Sample privilege escalation", + severity="High", + score=8.5, + ) + ], + ) + ], + ), + ] + + scans = [ + ScanRunOut( + id=501, + asset_id=1, + asset_ip="10.0.0.21", + scan_id="mock-fast-001", + scanner="GooseStrike", + mode="fast", + started_at="2024-02-07T12:00:00Z", + completed_at="2024-02-07T12:05:00Z", + created_at="2024-02-07T12:05:01Z", + notes="Sample lab scan", + services=[ + ScanServiceOut( + port=80, + proto="tcp", + product="nginx", + version="1.23.4", + extra={"transport": "tcp"}, + cves=["CVE-2023-3124", "CVE-2022-3554"], + ), + ScanServiceOut( + port=443, + proto="tcp", + product="Envoy", + version="1.26", + extra={"alpn": "h2"}, + cves=["CVE-2024-1234"], + ), + ], + ), + ScanRunOut( + id=502, + asset_id=2, + asset_ip="10.0.0.45", + scan_id="mock-full-001", + scanner="GooseStrike", + mode="full", + started_at="2024-02-05T09:00:00Z", + completed_at="2024-02-05T10:00:00Z", + created_at="2024-02-05T10:00:02Z", + notes="Baseline DB review", + services=[ + ScanServiceOut( + port=5432, + proto="tcp", + product="PostgreSQL", + version="14.9", + extra={"ssl": "enabled"}, + cves=["CVE-2023-2454"], + ), + ScanServiceOut( + port=22, + proto="tcp", + product="OpenSSH", + version="8.9", + extra={"auth": "password"}, + cves=["CVE-2023-38408"], + ), + ], + ), + ] + + attack_suggestions = [ + AttackSuggestionOut( + id=1, + asset_id=1, + asset_ip="10.0.0.21", + scan_run_id=501, + technique_id="T1190", + tactic="Initial Access", + name="Exploit Public-Facing Application", + description="Use SQLMap or Burp to validate CVE-2023-3124", + related_cve="CVE-2023-3124", + severity="High", + created_at="2024-02-07T12:05:03Z", + ), + AttackSuggestionOut( + id=2, + asset_id=2, + asset_ip="10.0.0.45", + scan_run_id=502, + technique_id="T1059", + tactic="Execution", + name="Command Shell", + description="Use Metasploit postgres payload to leverage CVE-2023-2454", + related_cve="CVE-2023-2454", + severity="Medium", + created_at="2024-02-05T10:00:03Z", + ), + ] + + tasks = [ + TaskOut( + id=88, + tool="sqlmap", + target="https://web-canary/login", + params={"risk": 1, "level": 3}, + status="pending", + created_at="2024-02-07T12:06:00Z", + started_at=None, + finished_at=None, + result=None, + ), + TaskOut( + id=89, + tool="hashcat", + target="lab-hashes", + params={"hash_file": "hashes.txt", "wordlist": "rockyou.txt", "mode": 0}, + status="running", + created_at="2024-02-07T12:06:15Z", + started_at="2024-02-07T12:06:20Z", + finished_at=None, + result=None, + ), + ] + + alerts = [ + AlertOut( + source="n8n:new_cve", + payload={"cve_id": "CVE-2024-1234", "critical": True}, + created_at="2024-02-07T12:07:00Z", + ), + AlertOut( + source="n8n:scan_complete", + payload={"scan_id": "mock-fast-001", "status": "ok"}, + created_at="2024-02-07T12:05:05Z", + ), + ] + + return MockDashboardOut( + core_snapshot=core_snapshot, + roadmap=roadmap, + assets=assets, + scans=scans, + attack_suggestions=attack_suggestions, + tasks=tasks, + alerts=alerts, + ) + + +def build_armitage_graph(conn: sqlite3.Connection) -> ArmitageGraphOut: + assets = conn.execute("SELECT * FROM assets ORDER BY ip").fetchall() + if not assets: + return build_mock_armitage_graph() + node_map: Dict[str, ArmitageNodeOut] = {} + edges: List[ArmitageEdgeOut] = [] + for asset in assets: + asset_node_id = f"asset-{asset['id']}" + node_map.setdefault( + asset_node_id, + ArmitageNodeOut(id=asset_node_id, label=asset["ip"], type="asset"), + ) + services = conn.execute( + "SELECT * FROM services WHERE asset_id=? ORDER BY port", + (asset["id"],), + ).fetchall() + for service in services: + service_node_id = f"service-{service['id']}" + label = f"{service['port']}/{service['proto']} {service['product'] or ''}".strip() + node_map.setdefault( + service_node_id, + ArmitageNodeOut(id=service_node_id, label=label, type="service"), + ) + edges.append( + ArmitageEdgeOut( + source=asset_node_id, + target=service_node_id, + label="exposes", + ) + ) + cves = conn.execute( + "SELECT cve_id FROM service_cves WHERE service_id=?", + (service["id"],), + ).fetchall() + for cve_row in cves: + cve = cve_row["cve_id"] + cve_node_id = f"cve-{cve}" + node_map.setdefault( + cve_node_id, + ArmitageNodeOut(id=cve_node_id, label=cve, type="cve"), + ) + edges.append( + ArmitageEdgeOut( + source=service_node_id, + target=cve_node_id, + label="CVE", + ) + ) + return ArmitageGraphOut(nodes=list(node_map.values()), edges=edges) + + +def build_mock_armitage_graph() -> ArmitageGraphOut: + return ArmitageGraphOut( + nodes=[ + ArmitageNodeOut(id="asset-1", label="10.0.0.21", type="asset"), + ArmitageNodeOut(id="asset-2", label="10.0.0.45", type="asset"), + ArmitageNodeOut(id="service-1", label="80/tcp nginx", type="service"), + ArmitageNodeOut(id="service-2", label="443/tcp envoy", type="service"), + ArmitageNodeOut(id="service-3", label="5432/tcp postgres", type="service"), + ArmitageNodeOut(id="cve-CVE-2024-1234", label="CVE-2024-1234", type="cve"), + ArmitageNodeOut(id="cve-CVE-2023-2454", label="CVE-2023-2454", type="cve"), + ], + edges=[ + ArmitageEdgeOut(source="asset-1", target="service-1", label="exposes"), + ArmitageEdgeOut(source="asset-1", target="service-2", label="exposes"), + ArmitageEdgeOut(source="asset-2", target="service-3", label="exposes"), + ArmitageEdgeOut(source="service-2", target="cve-CVE-2024-1234", label="CVE"), + ArmitageEdgeOut(source="service-3", target="cve-CVE-2023-2454", label="CVE"), + ], + ) + + +@app.get("/assets/{asset_id}/attack_suggestions", response_model=List[AttackSuggestionOut]) +async def asset_attack_suggestions(asset_id: int) -> List[AttackSuggestionOut]: + with get_db() as conn: + rows = conn.execute( + """ + SELECT attack_suggestions.*, assets.ip AS asset_ip FROM attack_suggestions + JOIN assets ON assets.id = attack_suggestions.asset_id + WHERE assets.id = ? + ORDER BY attack_suggestions.created_at DESC + """, + (asset_id,), + ).fetchall() + return _build_attack_response(rows) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agents/base_agent.py b/app/agents/base_agent.py new file mode 100644 index 0000000..422b52d --- /dev/null +++ b/app/agents/base_agent.py @@ -0,0 +1,38 @@ +"""Base LLM agent scaffolding for GooseStrike.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +from .llm_router import LLMProviderError, call_llm_with_fallback + + +def llm_call(prompt: str) -> str: + """Call the configured LLM providers with fallback behavior.""" + + try: + return call_llm_with_fallback(prompt) + except LLMProviderError as exc: + return f"LLM providers unavailable: {exc}" + + +@dataclass +class AgentResult: + prompt: str + raw_response: str + recommendations: Dict[str, Any] + + +class BaseAgent: + name = "base" + + def run(self, context: Dict[str, Any]) -> AgentResult: + prompt = self.build_prompt(context) + raw = llm_call(prompt) + return AgentResult(prompt=prompt, raw_response=raw, recommendations=self.parse(raw)) + + def build_prompt(self, context: Dict[str, Any]) -> str: + raise NotImplementedError + + def parse(self, raw: str) -> Dict[str, Any]: + return {"notes": raw.strip()} diff --git a/app/agents/cve_agent.py b/app/agents/cve_agent.py new file mode 100644 index 0000000..1fa4ab1 --- /dev/null +++ b/app/agents/cve_agent.py @@ -0,0 +1,28 @@ +"""CVE triage agent.""" +from __future__ import annotations + +from typing import Any, Dict + +from .base_agent import AgentResult, BaseAgent + + +class CVEAgent(BaseAgent): + name = "cve" + + def build_prompt(self, context: Dict[str, Any]) -> str: + cves = context.get("cves", []) + lines = ["You are prioritizing CVEs for a legal assessment."] + for cve in cves: + lines.append( + f"{cve.get('cve_id')}: severity={cve.get('severity')} score={cve.get('score')} desc={cve.get('description','')[:120]}" + ) + lines.append("Provide prioritized actions and validation steps. No exploit code.") + return "\n".join(lines) + + def parse(self, raw: str) -> Dict[str, Any]: + recommendations = [line.strip() for line in raw.split('\n') if line.strip()] + return {"cve_actions": recommendations} + + +def run(context: Dict[str, Any]) -> AgentResult: + return CVEAgent().run(context) diff --git a/app/agents/exploit_agent.py b/app/agents/exploit_agent.py new file mode 100644 index 0000000..fbe8959 --- /dev/null +++ b/app/agents/exploit_agent.py @@ -0,0 +1,28 @@ +"""Exploit correlation agent.""" +from __future__ import annotations + +from typing import Any, Dict + +from .base_agent import AgentResult, BaseAgent + + +class ExploitAgent(BaseAgent): + name = "exploit" + + def build_prompt(self, context: Dict[str, Any]) -> str: + exploits = context.get("exploits", []) + lines = ["Summarize how existing public exploits might apply."] + for exploit in exploits: + lines.append( + f"{exploit.get('source')} -> {exploit.get('title')} references {exploit.get('cve_id')}" + ) + lines.append("Provide validation ideas and defensive considerations only.") + return "\n".join(lines) + + def parse(self, raw: str) -> Dict[str, Any]: + notes = [line.strip() for line in raw.split('\n') if line.strip()] + return {"exploit_notes": notes} + + +def run(context: Dict[str, Any]) -> AgentResult: + return ExploitAgent().run(context) diff --git a/app/agents/llm_router.py b/app/agents/llm_router.py new file mode 100644 index 0000000..181387f --- /dev/null +++ b/app/agents/llm_router.py @@ -0,0 +1,78 @@ +"""LLM routing helpers with Claude -> HackGPT fallback.""" +from __future__ import annotations + +import os +from typing import Dict, List, Tuple + +import requests + + +class LLMProviderError(RuntimeError): + """Raised when a downstream LLM provider fails.""" + + +def _call_provider(name: str, url: str, prompt: str) -> str: + payload = {"prompt": prompt} + api_key = os.getenv(f"{name.upper()}_API_KEY") + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + data: Dict[str, str] = response.json() if response.headers.get("content-type", "").startswith("application/json") else {} + return data.get("response") or data.get("answer") or data.get("text") or response.text + + +def normalize_ollama_url(base_url: str) -> str: + """Return a usable Ollama generate endpoint for the supplied base URL.""" + + base_url = base_url.rstrip("/") + if "/api" in base_url: + if base_url.endswith("/generate"): + return base_url + return f"{base_url}/generate" + return f"{base_url}/api/generate" + + +def _call_ollama(base_url: str, prompt: str) -> str: + """Invoke a local Ollama instance using the configured model.""" + + url = normalize_ollama_url(base_url) + model = os.getenv("OLLAMA_MODEL", "llama3") + payload = {"model": model, "prompt": prompt, "stream": False} + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + data: Dict[str, str] = ( + response.json() if response.headers.get("content-type", "").startswith("application/json") else {} + ) + return data.get("response") or data.get("output") or response.text + + +def call_llm_with_fallback(prompt: str) -> str: + """Try Claude first, then HackGPT, finally return a placeholder.""" + + order: List[Tuple[str, str]] = [] + claude_url = os.getenv("CLAUDE_API_URL") + hackgpt_url = os.getenv("HACKGPT_API_URL") + ollama_base = os.getenv("OLLAMA_API_URL") or os.getenv("OLLAMA_BASE_URL") + if claude_url: + order.append(("claude", claude_url)) + if hackgpt_url: + order.append(("hackgpt", hackgpt_url)) + if ollama_base: + order.append(("ollama", ollama_base)) + + errors: List[str] = [] + for name, url in order: + try: + if name == "ollama": + return _call_ollama(url, prompt) + return _call_provider(name, url, prompt) + except Exception as exc: # pragma: no cover - network dependent + errors.append(f"{name} failed: {exc}") + continue + + if errors: + raise LLMProviderError("; ".join(errors)) + + return "LLM response placeholder. Configure CLAUDE_API_URL or HACKGPT_API_URL to enable live replies." diff --git a/app/agents/plan_agent.py b/app/agents/plan_agent.py new file mode 100644 index 0000000..21a4004 --- /dev/null +++ b/app/agents/plan_agent.py @@ -0,0 +1,31 @@ +"""High level planning agent.""" +from __future__ import annotations + +from typing import Any, Dict + +from .base_agent import AgentResult, BaseAgent + + +class PlanAgent(BaseAgent): + name = "plan" + + def build_prompt(self, context: Dict[str, Any]) -> str: + objectives = context.get("objectives", []) + intel = context.get("intel", []) + lines = ["Create a prioritized plan for the GooseStrike assessment."] + if objectives: + lines.append("Objectives:") + lines.extend(f"- {objective}" for objective in objectives) + if intel: + lines.append("Intel:") + lines.extend(f"- {item}" for item in intel) + lines.append("Return a numbered plan with legal, defensive-minded suggestions.") + return "\n".join(lines) + + def parse(self, raw: str) -> Dict[str, Any]: + steps = [line.strip() for line in raw.split('\n') if line.strip()] + return {"plan": steps} + + +def run(context: Dict[str, Any]) -> AgentResult: + return PlanAgent().run(context) diff --git a/app/agents/prioritizer_agent.py b/app/agents/prioritizer_agent.py new file mode 100644 index 0000000..7d2d230 --- /dev/null +++ b/app/agents/prioritizer_agent.py @@ -0,0 +1,35 @@ +"""Target prioritizer AI agent.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from .base_agent import AgentResult, BaseAgent + + +class PrioritizerAgent(BaseAgent): + name = "prioritizer" + + def build_prompt(self, context: Dict[str, Any]) -> str: + hosts: List[Dict[str, Any]] = context.get("assets", []) + findings = [] + for asset in hosts: + ip = asset.get("ip") + severity = max( + (vuln.get("severity", "") for svc in asset.get("services", []) for vuln in svc.get("vulnerabilities", [])), + default="", + ) + findings.append(f"Host {ip} exposes {len(asset.get('services', []))} services (max severity: {severity}).") + prompt_lines = [ + "You are GooseStrike's targeting aide.", + "Rank the following hosts for next actions using MITRE ATT&CK tactics.", + ] + prompt_lines.extend(findings or ["No assets supplied; recommend intel-gathering tasks."]) + prompt_lines.append("Return JSON with priorities, rationale, and suggested tactic per host.") + return "\n".join(prompt_lines) + + def parse(self, raw: str) -> Dict[str, Any]: + return {"priorities": raw.strip()} + + +def prioritize_targets(context: Dict[str, Any]) -> AgentResult: + return PrioritizerAgent().run(context) diff --git a/app/agents/privesc_agent.py b/app/agents/privesc_agent.py new file mode 100644 index 0000000..fe2ef0e --- /dev/null +++ b/app/agents/privesc_agent.py @@ -0,0 +1,29 @@ +"""Privilege escalation agent.""" +from __future__ import annotations + +from typing import Any, Dict + +from .base_agent import AgentResult, BaseAgent + + +class PrivEscAgent(BaseAgent): + name = "privesc" + + def build_prompt(self, context: Dict[str, Any]) -> str: + host = context.get("host") + findings = context.get("findings", []) + lines = ["Suggest legal privilege escalation checks for a lab machine."] + if host: + lines.append(f"Host: {host}") + for finding in findings: + lines.append(f"Finding: {finding}") + lines.append("Provide checklists only; no exploit payloads.") + return "\n".join(lines) + + def parse(self, raw: str) -> Dict[str, Any]: + steps = [line.strip() for line in raw.split('\n') if line.strip()] + return {"privesc_checks": steps} + + +def run(context: Dict[str, Any]) -> AgentResult: + return PrivEscAgent().run(context) diff --git a/app/agents/recon_agent.py b/app/agents/recon_agent.py new file mode 100644 index 0000000..6f04967 --- /dev/null +++ b/app/agents/recon_agent.py @@ -0,0 +1,31 @@ +"""Reconnaissance agent.""" +from __future__ import annotations + +from typing import Any, Dict + +from .base_agent import AgentResult, BaseAgent + + +class ReconAgent(BaseAgent): + name = "recon" + + def build_prompt(self, context: Dict[str, Any]) -> str: + hosts = context.get("hosts", []) + lines = ["You are advising a legal CTF recon team."] + for host in hosts: + services = host.get("services", []) + service_lines = ", ".join( + f"{svc.get('proto')}/{svc.get('port')} {svc.get('product','?')} {svc.get('version','')}" + for svc in services + ) + lines.append(f"Host {host.get('ip')} services: {service_lines}") + lines.append("Suggest safe recon next steps without exploit code.") + return "\n".join(lines) + + def parse(self, raw: str) -> Dict[str, Any]: + bullets = [line.strip('- ') for line in raw.split('\n') if line.strip()] + return {"recon_steps": bullets} + + +def run(context: Dict[str, Any]) -> AgentResult: + return ReconAgent().run(context) diff --git a/cve_api.py b/cve_api.py new file mode 100644 index 0000000..c17a5ae --- /dev/null +++ b/cve_api.py @@ -0,0 +1,85 @@ +"""Standalone FastAPI service exposing the CVE/exploit mirror.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import List, Optional + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +DB_PATH = Path("db/exploits.db") +DB_PATH.parent.mkdir(parents=True, exist_ok=True) + +app = FastAPI(title="GooseStrike CVE API", version="0.1.0") + + +class CVEEntry(BaseModel): + cve_id: str + description: Optional[str] + severity: Optional[str] + score: Optional[float] + exploits: List[dict] + + +class CVESearchResult(BaseModel): + results: List[CVEEntry] + + +def _get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +@app.get("/cve/{cve_id}", response_model=CVEEntry) +async def fetch_cve(cve_id: str) -> CVEEntry: + if not DB_PATH.exists(): + raise HTTPException(status_code=404, detail="CVE database missing") + with _get_conn() as conn: + row = conn.execute("SELECT * FROM cves WHERE cve_id=?", (cve_id,)).fetchone() + if not row: + raise HTTPException(status_code=404, detail="CVE not found") + exploits = conn.execute( + "SELECT source, title, reference, path FROM exploits WHERE cve_id=?", (cve_id,) + ).fetchall() + return CVEEntry( + cve_id=row["cve_id"], + description=row["description"], + severity=row["severity"], + score=row["score"], + exploits=[dict(exp) for exp in exploits], + ) + + +@app.get("/search", response_model=CVESearchResult) +async def search_cves(q: str) -> CVESearchResult: + if not DB_PATH.exists(): + return CVESearchResult(results=[]) + like = f"%{q}%" + with _get_conn() as conn: + rows = conn.execute( + "SELECT * FROM cves WHERE cve_id LIKE ? OR description LIKE ? LIMIT 25", + (like, like), + ).fetchall() + entries = [] + for row in rows: + exploits = conn.execute( + "SELECT source, title, reference, path FROM exploits WHERE cve_id=?", + (row["cve_id"],), + ).fetchall() + entries.append( + CVEEntry( + cve_id=row["cve_id"], + description=row["description"], + severity=row["severity"], + score=row["score"], + exploits=[dict(exp) for exp in exploits], + ) + ) + return CVESearchResult(results=entries) + + +@app.get("/health") +async def health() -> dict: + return {"ok": True, "ready": DB_PATH.exists()} diff --git a/docker-compose.goosestrike-full.yml b/docker-compose.goosestrike-full.yml new file mode 100644 index 0000000..eb8ba91 --- /dev/null +++ b/docker-compose.goosestrike-full.yml @@ -0,0 +1,79 @@ +version: "3.9" + +services: + api: + build: . + container_name: goosestrike-api + command: ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"] + volumes: + - ./db:/app/db + - ./logs:/app/logs + - ./web/static/uploads:/app/web/static/uploads + environment: + - GOOSESTRIKE_LOGO=${GOOSESTRIKE_LOGO:-} + - CLAUDE_API_URL=${CLAUDE_API_URL:-} + - HACKGPT_API_URL=${HACKGPT_API_URL:-http://hackgpt:8500/prompt} + - HACKGPT_API_KEY=${HACKGPT_API_KEY:-} + - CLAUDE_API_KEY=${CLAUDE_API_KEY:-} + ports: + - "8000:8000" + depends_on: + - cve-api + - hackgpt + + scanner: + build: . + container_name: goosestrike-scanner + command: ["sleep", "infinity"] + volumes: + - ./db:/app/db + - ./logs:/app/logs + depends_on: + - api + + indexer: + build: . + container_name: goosestrike-indexer + command: ["sleep", "infinity"] + volumes: + - ./db:/app/db + - ./data:/app/data + + task-runner: + build: . + container_name: goosestrike-task-runner + command: ["sleep", "infinity"] + volumes: + - ./db:/app/db + - ./logs:/app/logs + + hackgpt: + build: + context: . + dockerfile: Dockerfile + container_name: goosestrike-hackgpt + command: ["uvicorn", "hackgpt_api:app", "--host", "0.0.0.0", "--port", "8500"] + environment: + - N8N_WEBHOOK_URL=http://n8n:5678/webhook/hackgpt + ports: + - "8500:8500" + + cve-api: + build: . + container_name: goosestrike-cve-api + command: ["uvicorn", "cve_api:app", "--host", "0.0.0.0", "--port", "8600"] + volumes: + - ./db:/app/db + ports: + - "8600:8600" + + n8n: + image: n8nio/n8n:1.53.0 + container_name: goosestrike-n8n + restart: unless-stopped + environment: + - GENERIC_TIMEZONE=UTC + ports: + - "5678:5678" + volumes: + - ./n8n-data:/home/node/.n8n diff --git a/docker-compose.kali.yml b/docker-compose.kali.yml new file mode 100644 index 0000000..576bdc4 --- /dev/null +++ b/docker-compose.kali.yml @@ -0,0 +1,42 @@ +version: "3.9" + +x-goosestrike-service: &goosestrike-service + image: goosestrike-kali:latest + volumes: + - ./db:/opt/goosestrike/db + - ./logs:/opt/goosestrike/logs + - ./data:/opt/goosestrike/data + networks: + - goosenet + +services: + api: + <<: *goosestrike-service + build: + context: . + dockerfile: Dockerfile.kali + container_name: goosestrike_api + command: ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"] + ports: + - "8000:8000" + restart: unless-stopped + + scanner: + <<: *goosestrike-service + container_name: goosestrike_scanner + depends_on: + - api + command: ["sleep", "infinity"] + restart: unless-stopped + + worker: + <<: *goosestrike-service + container_name: goosestrike_worker + depends_on: + - api + command: ["sleep", "infinity"] + restart: unless-stopped + +networks: + goosenet: + driver: bridge diff --git a/hackgpt_api.py b/hackgpt_api.py new file mode 100644 index 0000000..b8ffaff --- /dev/null +++ b/hackgpt_api.py @@ -0,0 +1,54 @@ +"""Minimal HackGPT relay API wired for n8n webhooks.""" +from __future__ import annotations + +import os +import uuid +from typing import Optional + +import requests +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI(title="GooseStrike HackGPT Relay") + + +class PromptRequest(BaseModel): + prompt: str + context: Optional[dict] = None + + +class PromptResponse(BaseModel): + request_id: str + prompt: str + echoed_context: Optional[dict] + forwarded: bool + recommendation: str + + +@app.post("/prompt", response_model=PromptResponse) +async def handle_prompt(body: PromptRequest) -> PromptResponse: + request_id = str(uuid.uuid4()) + forwarded = False + webhook = os.getenv("N8N_WEBHOOK_URL") + if webhook: + try: + requests.post( + webhook, + json={"request_id": request_id, "prompt": body.prompt, "context": body.context}, + timeout=10, + ) + forwarded = True + except requests.RequestException: + forwarded = False + return PromptResponse( + request_id=request_id, + prompt=body.prompt, + echoed_context=body.context, + forwarded=forwarded, + recommendation="HackGPT relay placeholder – plug in your model pipeline.", + ) + + +@app.get("/health") +async def health() -> dict: + return {"ok": True} diff --git a/hydra_runner.py b/hydra_runner.py new file mode 100644 index 0000000..d12f909 --- /dev/null +++ b/hydra_runner.py @@ -0,0 +1,40 @@ +"""Wrapper for Hydra tasks.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from runner_utils import run_subprocess + + +def build_command(task: Dict[str, Any]) -> List[str]: + service = task.get("service") + target = task.get("target") + if not service or not target: + raise ValueError("service and target are required") + command = ["hydra", "-t", str(task.get("threads", 4))] + if task.get("username"): + command.extend(["-l", task["username"]]) + if task.get("password"): + command.extend(["-p", task["password"]]) + if task.get("username_list"): + command.extend(["-L", task["username_list"]]) + if task.get("password_list"): + command.extend(["-P", task["password_list"]]) + if task.get("options"): + for opt in task["options"]: + command.append(opt) + command.extend([f"{target}", service]) + return command + + +def run_task(task: Dict[str, Any]) -> Dict[str, Any]: + try: + command = build_command(task) + except ValueError as exc: + return {"status": "error", "exit_code": None, "error": str(exc)} + return run_subprocess(command, "hydra") + + +if __name__ == "__main__": + example = {"service": "ssh", "target": "10.0.0.5", "username": "root", "password_list": "rockyou.txt"} + print(run_task(example)) diff --git a/indexer.py b/indexer.py new file mode 100644 index 0000000..43b7188 --- /dev/null +++ b/indexer.py @@ -0,0 +1,172 @@ +"""Index CVEs and public exploit references into SQLite for GooseStrike.""" +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from pathlib import Path +from typing import List + +CVE_REGEX = re.compile(r"CVE-\d{4}-\d{4,7}") +DB_PATH = Path("db/exploits.db") + + +def ensure_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS cves ( + cve_id TEXT PRIMARY KEY, + description TEXT, + severity TEXT, + score REAL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS exploits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + title TEXT NOT NULL, + reference TEXT, + path TEXT, + cve_id TEXT, + FOREIGN KEY(cve_id) REFERENCES cves(cve_id) + ) + """ + ) + conn.commit() + + +def ingest_nvd(directory: Path, conn: sqlite3.Connection) -> int: + if not directory.exists(): + return 0 + count = 0 + for json_file in sorted(directory.glob("*.json")): + with json_file.open("r", encoding="utf-8") as handle: + data = json.load(handle) + items = data.get("CVE_Items", []) + for item in items: + cve_id = item.get("cve", {}).get("CVE_data_meta", {}).get("ID") + if not cve_id: + continue + description_nodes = item.get("cve", {}).get("description", {}).get("description_data", []) + description = description_nodes[0]["value"] if description_nodes else "" + metrics = item.get("impact", {}) + severity = None + score = None + for metric in (metrics.get("baseMetricV3"), metrics.get("baseMetricV2")): + if metric: + data_metric = metric.get("cvssV3" if "cvssV3" in metric else "cvssV2", {}) + severity = data_metric.get("baseSeverity") or metric.get("severity") + score = data_metric.get("baseScore") or metric.get("cvssV2", {}).get("baseScore") + break + conn.execute( + """ + INSERT INTO cves(cve_id, description, severity, score) + VALUES(?, ?, ?, ?) + ON CONFLICT(cve_id) DO UPDATE SET + description=excluded.description, + severity=excluded.severity, + score=excluded.score + """, + (cve_id, description, severity, score), + ) + count += 1 + conn.commit() + return count + + +def extract_cves_from_text(text: str) -> List[str]: + return list(set(CVE_REGEX.findall(text))) + + +def ingest_directory(source: str, directory: Path, conn: sqlite3.Connection) -> int: + if not directory.exists(): + return 0 + count = 0 + for file_path in directory.rglob("*"): + if not file_path.is_file(): + continue + try: + content = file_path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + cves = extract_cves_from_text(content) + title = file_path.stem.replace("_", " ") + reference = str(file_path.relative_to(directory)) + if not cves: + conn.execute( + "INSERT INTO exploits(source, title, reference, path, cve_id) VALUES(?,?,?,?,?)", + (source, title, reference, str(file_path), None), + ) + count += 1 + continue + for cve_id in cves: + conn.execute( + "INSERT INTO exploits(source, title, reference, path, cve_id) VALUES(?,?,?,?,?)", + (source, title, reference, str(file_path), cve_id), + ) + count += 1 + conn.commit() + return count + + +def ingest_packetstorm(xml_file: Path, conn: sqlite3.Connection) -> int: + if not xml_file.exists(): + return 0 + import xml.etree.ElementTree as ET + + tree = ET.parse(xml_file) + root = tree.getroot() + count = 0 + for item in root.findall("channel/item"): + title = item.findtext("title") or "PacketStorm entry" + link = item.findtext("link") + description = item.findtext("description") or "" + cves = extract_cves_from_text(description) + if not cves: + conn.execute( + "INSERT INTO exploits(source, title, reference, path, cve_id) VALUES(?,?,?,?,?)", + ("packetstorm", title, link, None, None), + ) + count += 1 + continue + for cve_id in cves: + conn.execute( + "INSERT INTO exploits(source, title, reference, path, cve_id) VALUES(?,?,?,?,?)", + ("packetstorm", title, link, None, cve_id), + ) + count += 1 + conn.commit() + return count + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Index CVEs and exploits into SQLite") + parser.add_argument("--nvd", default="data/nvd", help="Directory with NVD JSON dumps") + parser.add_argument( + "--exploitdb", default="data/exploitdb", help="Directory with Exploit-DB entries" + ) + parser.add_argument( + "--packetstorm", default="data/packetstorm.xml", help="PacketStorm RSS/Atom file" + ) + args = parser.parse_args(argv) + + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + with conn: + ensure_tables(conn) + nvd_count = ingest_nvd(Path(args.nvd), conn) + edb_count = ingest_directory("exploitdb", Path(args.exploitdb), conn) + ps_count = ingest_packetstorm(Path(args.packetstorm), conn) + print( + f"Indexed {nvd_count} CVEs, {edb_count} Exploit-DB entries, " + f"{ps_count} PacketStorm entries" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/metasploit_runner.py b/metasploit_runner.py new file mode 100644 index 0000000..9ec6766 --- /dev/null +++ b/metasploit_runner.py @@ -0,0 +1,37 @@ +"""Run Metasploit tasks in a controlled way for GooseStrike.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict + +from runner_utils import LOG_DIR, run_subprocess + + +def run_task(task: Dict[str, Any]) -> Dict[str, Any]: + module = task.get("module") or task.get("options", {}).get("module") + target = task.get("target") or task.get("options", {}).get("rhosts") + if not module or not target: + return {"status": "error", "exit_code": None, "error": "module and target required"} + + opts = task.get("options", {}) + rc_lines = [f"use {module}", f"set RHOSTS {target}"] + for key, value in opts.items(): + if key.lower() == "module" or key.lower() == "rhosts": + continue + rc_lines.append(f"set {key.upper()} {value}") + rc_lines.extend(["run", "exit"]) + + rc_path = LOG_DIR / f"metasploit_{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.rc" + rc_path.write_text("\n".join(rc_lines), encoding="utf-8") + + command = ["msfconsole", "-q", "-r", str(rc_path)] + return run_subprocess(command, "metasploit") + + +if __name__ == "__main__": + example = { + "module": "auxiliary/scanner/portscan/tcp", + "target": "127.0.0.1", + "options": {"THREADS": 4}, + } + print(run_task(example)) diff --git a/mitre_mapping.py b/mitre_mapping.py new file mode 100644 index 0000000..f5193d6 --- /dev/null +++ b/mitre_mapping.py @@ -0,0 +1,109 @@ +"""Lightweight MITRE ATT&CK suggestion helpers. + +This module borrows ideas from community tooling such as OWASP Nettacker +and Exploitivator by correlating discovered services and CVEs with the +most relevant ATT&CK techniques. It does not attempt to be exhaustive; +instead it provides explainable heuristics that can be stored alongside +scan records so analysts always have context for next steps. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Optional + + +@dataclass +class MitreSuggestion: + technique_id: str + tactic: str + name: str + description: str + related_cve: Optional[str] + severity: str + + +@dataclass +class MitreRule: + technique_id: str + tactic: str + name: str + description: str + ports: Optional[Iterable[int]] = None + protocols: Optional[Iterable[str]] = None + product_keywords: Optional[Iterable[str]] = None + cve_required: bool = False + + +MITRE_RULES: List[MitreRule] = [ + MitreRule( + technique_id="T1190", + tactic="Initial Access", + name="Exploit Public-Facing Application", + description="HTTP/S service exposes attack surface that mirrors the" + " public exploitation stage emphasized by Nettacker's web modules", + ports={80, 443, 8080, 8443}, + protocols={"tcp"}, + ), + MitreRule( + technique_id="T1133", + tactic="Initial Access", + name="External Remote Services", + description="SSH/RDP/VNC listeners enable credential attacks similar" + " to Exploitivator's service runners.", + ports={22, 3389, 5900}, + protocols={"tcp"}, + ), + MitreRule( + technique_id="T1047", + tactic="Execution", + name="Windows Management Instrumentation", + description="SMB/RPC services align with remote execution and lateral" + " movement playbooks.", + ports={135, 139, 445}, + protocols={"tcp"}, + ), + MitreRule( + technique_id="T1068", + tactic="Privilege Escalation", + name="Exploitation for Privilege Escalation", + description="Service exposes CVEs that can deliver privilege gains.", + cve_required=True, + ), +] + + +def _match_rule(rule: MitreRule, service: object, has_cve: bool) -> bool: + proto = getattr(service, "proto", None) + port = getattr(service, "port", None) + product = (getattr(service, "product", None) or "").lower() + if rule.protocols and proto not in rule.protocols: + return False + if rule.ports and port not in rule.ports: + return False + if rule.cve_required and not has_cve: + return False + if rule.product_keywords and not any(keyword in product for keyword in rule.product_keywords): + return False + return True + + +def generate_attack_suggestions(asset_label: str, services: Iterable[object]) -> List[MitreSuggestion]: + """Return MITRE suggestions based on service metadata and CVE presence.""" + suggestions: List[MitreSuggestion] = [] + for service in services: + cves = getattr(service, "cves", None) or [] + severity = "critical" if cves else "info" + for rule in MITRE_RULES: + if not _match_rule(rule, service, bool(cves)): + continue + suggestions.append( + MitreSuggestion( + technique_id=rule.technique_id, + tactic=rule.tactic, + name=rule.name, + description=f"{rule.description} Observed on {asset_label}.", + related_cve=cves[0] if cves else None, + severity=severity, + ) + ) + return suggestions diff --git a/password_cracker_runner.py b/password_cracker_runner.py new file mode 100644 index 0000000..55bfaa3 --- /dev/null +++ b/password_cracker_runner.py @@ -0,0 +1,119 @@ +"""Password cracking task runner for hashcat, John the Ripper, and rainbow tables.""" +from __future__ import annotations + +from typing import Any, Callable, Dict, List + +from runner_utils import run_subprocess + + +def build_hashcat_command(task: Dict[str, Any]) -> List[str]: + hash_file = task.get("hash_file") + if not hash_file: + raise ValueError("hash_file is required for hashcat tasks") + + command: List[str] = ["hashcat"] + if task.get("mode") is not None: + command.extend(["-m", str(task["mode"])]) + if task.get("attack_mode") is not None: + command.extend(["-a", str(task["attack_mode"])]) + if task.get("workload") is not None: + command.extend(["-w", str(task["workload"])]) + if task.get("session"): + command.extend(["--session", task["session"]]) + if task.get("potfile"): + command.extend(["--potfile-path", task["potfile"]]) + if task.get("rules"): + for rule in task["rules"]: + command.extend(["-r", rule]) + if task.get("extra_args"): + command.extend(task["extra_args"]) + + command.append(hash_file) + if task.get("wordlist"): + command.append(task["wordlist"]) + elif task.get("mask"): + command.append(task["mask"]) + else: + raise ValueError("hashcat tasks require either a wordlist or mask") + return command + + +def build_john_command(task: Dict[str, Any]) -> List[str]: + hash_file = task.get("hash_file") + if not hash_file: + raise ValueError("hash_file is required for john tasks") + + command: List[str] = ["john"] + if task.get("wordlist"): + command.append(f"--wordlist={task['wordlist']}") + if task.get("format"): + command.append(f"--format={task['format']}") + if task.get("rules"): + command.append(f"--rules={task['rules']}") + if task.get("session"): + command.append(f"--session={task['session']}") + if task.get("potfile"): + command.append(f"--pot={task['potfile']}") + if task.get("incremental"): + command.append("--incremental") + if task.get("extra_args"): + command.extend(task["extra_args"]) + + command.append(hash_file) + return command + + +def build_rainbow_command(task: Dict[str, Any]) -> List[str]: + tables_path = task.get("tables_path") + if not tables_path: + raise ValueError("tables_path is required for rainbow table tasks") + + command: List[str] = ["rcrack", tables_path] + if task.get("hash_value"): + command.append(task["hash_value"]) + elif task.get("hash_file"): + command.extend(["-f", task["hash_file"]]) + else: + raise ValueError("Provide hash_value or hash_file for rainbow table tasks") + if task.get("threads"): + command.extend(["-t", str(task["threads"])]) + if task.get("extra_args"): + command.extend(task["extra_args"]) + return command + + +COMMAND_BUILDERS: Dict[str, Callable[[Dict[str, Any]], List[str]]] = { + "hashcat": build_hashcat_command, + "john": build_john_command, + "johntheripper": build_john_command, + "rainbow": build_rainbow_command, + "rcrack": build_rainbow_command, +} + + +def run_task(task: Dict[str, Any]) -> Dict[str, Any]: + tool = task.get("crack_tool", task.get("tool", "hashcat")).lower() + builder = COMMAND_BUILDERS.get(tool) + if builder is None: + return { + "status": "error", + "exit_code": None, + "error": f"Unsupported password cracking tool: {tool}", + } + try: + command = builder(task) + except ValueError as exc: + return {"status": "error", "exit_code": None, "error": str(exc)} + log_prefix = f"crack_{tool}" + return run_subprocess(command, log_prefix) + + +if __name__ == "__main__": + demo_task = { + "crack_tool": "hashcat", + "hash_file": "hashes.txt", + "wordlist": "/usr/share/wordlists/rockyou.txt", + "mode": 0, + "attack_mode": 0, + } + print(run_task(demo_task)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c600c9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn +requests +pydantic +pytest +jinja2 diff --git a/runner_utils.py b/runner_utils.py new file mode 100644 index 0000000..6dcc091 --- /dev/null +++ b/runner_utils.py @@ -0,0 +1,47 @@ +"""Shared helpers for GooseStrike tool runners.""" +from __future__ import annotations + +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +LOG_DIR = Path("logs") +LOG_DIR.mkdir(parents=True, exist_ok=True) + + +def run_subprocess(command: List[str], log_prefix: str, stdin: Optional[str] = None) -> Dict[str, Any]: + timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") + log_path = LOG_DIR / f"{log_prefix}_{timestamp}.log" + with log_path.open("w", encoding="utf-8") as log_file: + log_file.write(f"COMMAND: {' '.join(command)}\n") + if stdin: + log_file.write("STDIN:\n") + log_file.write(stdin) + log_file.write("\n--- END STDIN ---\n") + try: + proc = subprocess.run( + command, + input=stdin, + text=True, + capture_output=True, + check=False, + ) + except FileNotFoundError: + log_file.write(f"ERROR: command not found: {command[0]}\n") + return { + "status": "error", + "exit_code": None, + "error": f"Command not found: {command[0]}", + "log_path": str(log_path), + } + log_file.write("STDOUT:\n") + log_file.write(proc.stdout) + log_file.write("\nSTDERR:\n") + log_file.write(proc.stderr) + status = "success" if proc.returncode == 0 else "failed" + return { + "status": status, + "exit_code": proc.returncode, + "log_path": str(log_path), + } diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..ff32c3d --- /dev/null +++ b/scanner.py @@ -0,0 +1,269 @@ +"""Subnet scanning utility for GooseStrike. + +This module wraps nmap (and optionally masscan) to inventory services +across a CIDR range and forward the parsed results to the GooseStrike +FastAPI backend. +""" +from __future__ import annotations + +import argparse +import json +import shlex +import subprocess +import sys +import uuid +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple +import xml.etree.ElementTree as ET + +import requests + +API_DEFAULT = "http://localhost:8000" + + +def run_command(cmd: List[str]) -> str: + """Execute a command and return stdout, raising on errors.""" + try: + completed = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except FileNotFoundError as exc: + raise RuntimeError(f"Required command not found: {cmd[0]}") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f"Command failed ({cmd}): {exc.stderr.strip() or exc.stdout.strip()}" + ) from exc + return completed.stdout + + +@dataclass +class ServiceResult: + port: int + proto: str + product: Optional[str] = None + version: Optional[str] = None + extra: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class HostResult: + ip: str + hostname: Optional[str] + services: List[ServiceResult] = field(default_factory=list) + mac_address: Optional[str] = None + mac_vendor: Optional[str] = None + + +@dataclass +class ScanMetadata: + scan_id: str + started_at: Optional[str] + finished_at: Optional[str] + scanner: str + mode: str + notes: Optional[str] + + +def build_nmap_command( + cidr: str, mode: str, rate: Optional[int], extra_args: Optional[List[str]] = None +) -> List[str]: + """Construct the nmap command with optional mode/rate/custom arguments.""" + cmd: List[str] = ["nmap"] + if mode == "fast": + cmd.extend(["-T4", "-F"]) + elif mode == "full": + cmd.extend(["-T4", "-p-"]) + if rate: + cmd.extend(["--min-rate", str(rate)]) + if extra_args: + cmd.extend(extra_args) + cmd.extend(["-sV", "-oX", "-", cidr]) + return cmd + + +def parse_nmap_xml(xml_content: str) -> Tuple[List[HostResult], Optional[str], Optional[str]]: + root = ET.fromstring(xml_content) + hosts: List[HostResult] = [] + started_at = root.get("startstr") + finished_el = root.find("runstats/finished") + finished_at = finished_el.get("timestr") if finished_el is not None else None + for host in root.findall("host"): + status = host.find("status") + if status is not None and status.get("state") != "up": + continue + address = host.find("address[@addrtype='ipv4']") or host.find("address[@addrtype='ipv6']") + if address is None: + continue + ip = address.get("addr") + hostname_el = host.find("hostnames/hostname") + hostname = hostname_el.get("name") if hostname_el is not None else None + mac_el = host.find("address[@addrtype='mac']") + mac_address = mac_el.get("addr") if mac_el is not None else None + mac_vendor = mac_el.get("vendor") if mac_el is not None else None + services: List[ServiceResult] = [] + for port in host.findall("ports/port"): + state = port.find("state") + if state is None or state.get("state") != "open": + continue + service_el = port.find("service") + service = ServiceResult( + port=int(port.get("portid", 0)), + proto=port.get("protocol", "tcp"), + product=service_el.get("product") if service_el is not None else None, + version=service_el.get("version") if service_el is not None else None, + extra={}, + ) + if service_el is not None: + for key in ("name", "extrainfo", "ostype"): + value = service_el.get(key) + if value: + service.extra[key] = value + services.append(service) + if services: + hosts.append( + HostResult( + ip=ip, + hostname=hostname, + services=services, + mac_address=mac_address, + mac_vendor=mac_vendor, + ) + ) + return hosts, started_at, finished_at + + +def send_to_api(hosts: List[HostResult], api_base: str, scan_meta: ScanMetadata) -> None: + ingest_url = f"{api_base.rstrip('/')}/ingest/scan" + session = requests.Session() + for host in hosts: + payload = { + "ip": host.ip, + "hostname": host.hostname, + "mac_address": host.mac_address, + "mac_vendor": host.mac_vendor, + "scan": { + "scan_id": scan_meta.scan_id, + "scanner": scan_meta.scanner, + "mode": scan_meta.mode, + "started_at": scan_meta.started_at, + "completed_at": scan_meta.finished_at, + "notes": scan_meta.notes, + }, + "services": [ + { + "port": s.port, + "proto": s.proto, + "product": s.product, + "version": s.version, + "extra": s.extra, + } + for s in host.services + ], + } + response = session.post(ingest_url, json=payload, timeout=30) + response.raise_for_status() + + +def scan_and_ingest( + cidr: str, + mode: str, + rate: Optional[int], + api_base: str, + notes: Optional[str], + scanner_name: str, + scan_id: Optional[str], + extra_args: Optional[List[str]] = None, +) -> None: + cmd = build_nmap_command(cidr, mode, rate, extra_args) + print(f"[+] Running {' '.join(cmd)}") + xml_result = run_command(cmd) + hosts, started_at, finished_at = parse_nmap_xml(xml_result) + print(f"[+] Parsed {len(hosts)} hosts with open services") + if not hosts: + return + meta = ScanMetadata( + scan_id=scan_id or str(uuid.uuid4()), + started_at=started_at, + finished_at=finished_at, + scanner=scanner_name, + mode=mode, + notes=notes, + ) + send_to_api(hosts, api_base, meta) + print("[+] Results sent to API") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="GooseStrike subnet scanner") + parser.add_argument("cidr", help="CIDR range to scan, e.g. 10.0.0.0/24") + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument("--fast", action="store_true", help="Fast top-port scan") + mode_group.add_argument("--full", action="store_true", help="Full port scan") + parser.add_argument("--rate", type=int, help="Optional nmap min rate") + parser.add_argument("--api", default=API_DEFAULT, help="API base URL") + parser.add_argument( + "--no-upload", + action="store_true", + help="Skip uploading results (useful for local testing)", + ) + parser.add_argument("--notes", help="Optional operator notes recorded with the scan") + parser.add_argument("--scanner-name", default="GooseStrike nmap", help="Logical scanner identifier") + parser.add_argument("--scan-id", help="Optional scan UUID for correlating uploads") + parser.add_argument( + "--nmap-args", + help=( + "Extra nmap arguments (quoted) to mirror advanced Recorded Future command " + "examples, e.g. \"-sC --script vuln -Pn\"" + ), + ) + args = parser.parse_args(argv) + + mode = "standard" + if args.fast: + mode = "fast" + elif args.full: + mode = "full" + + extra_args: Optional[List[str]] = None + if args.nmap_args: + extra_args = shlex.split(args.nmap_args) + + try: + cmd = build_nmap_command(args.cidr, mode, args.rate, extra_args) + xml_result = run_command(cmd) + hosts, started_at, finished_at = parse_nmap_xml(xml_result) + meta = ScanMetadata( + scan_id=args.scan_id or str(uuid.uuid4()), + started_at=started_at, + finished_at=finished_at, + scanner=args.scanner_name, + mode=mode, + notes=args.notes, + ) + print( + json.dumps( + [ + { + **host.__dict__, + "services": [service.__dict__ for service in host.services], + } + for host in hosts + ], + indent=2, + ) + ) + if not args.no_upload and hosts: + send_to_api(hosts, args.api, meta) + print("[+] Uploaded results to API") + return 0 + except Exception as exc: # pylint: disable=broad-except + print(f"[-] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sqlmap_runner.py b/sqlmap_runner.py new file mode 100644 index 0000000..dd04351 --- /dev/null +++ b/sqlmap_runner.py @@ -0,0 +1,34 @@ +"""Wrapper for sqlmap tasks.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from runner_utils import run_subprocess + + +def build_command(task: Dict[str, Any]) -> List[str]: + if not task.get("target_url"): + raise ValueError("target_url is required") + command = ["sqlmap", "-u", task["target_url"], "--batch"] + options = task.get("options", {}) + for key, value in options.items(): + flag = f"--{key.replace('_', '-')}" + if isinstance(value, bool): + if value: + command.append(flag) + else: + command.extend([flag, str(value)]) + return command + + +def run_task(task: Dict[str, Any]) -> Dict[str, Any]: + try: + command = build_command(task) + except ValueError as exc: + return {"status": "error", "exit_code": None, "error": str(exc)} + return run_subprocess(command, "sqlmap") + + +if __name__ == "__main__": + example = {"target_url": "http://example.com/vuln.php?id=1", "options": {"level": 2}} + print(run_task(example)) diff --git a/task_queue.py b/task_queue.py new file mode 100644 index 0000000..c1d4461 --- /dev/null +++ b/task_queue.py @@ -0,0 +1,134 @@ +"""SQLite-backed task queue for GooseStrike.""" +from __future__ import annotations + +import argparse +import json +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +DB_PATH = Path("db/tasks.db") +DB_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def get_conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def ensure_tables() -> None: + with get_conn() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool TEXT NOT NULL, + target TEXT, + params_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TEXT, + finished_at TEXT, + result_json TEXT + ) + """ + ) + conn.commit() + + +def enqueue_task(tool: str, target: Optional[str], params: Dict[str, Any]) -> int: + ensure_tables() + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO tasks(tool, target, params_json) VALUES(?,?,?)", + (tool, target, json.dumps(params)), + ) + conn.commit() + return cur.lastrowid + + +def fetch_next_task(tool: Optional[str] = None) -> Optional[Dict[str, Any]]: + ensure_tables() + with get_conn() as conn: + query = "SELECT * FROM tasks WHERE status='pending'" + params: tuple = () + if tool: + query += " AND tool=?" + params = (tool,) + query += " ORDER BY created_at LIMIT 1" + row = conn.execute(query, params).fetchone() + if not row: + return None + conn.execute( + "UPDATE tasks SET status='running', started_at=? WHERE id=?", + (datetime.utcnow().isoformat(), row["id"]), + ) + conn.commit() + return dict(row) + + +def update_task_status(task_id: int, status: str, result: Optional[Dict[str, Any]] = None) -> None: + ensure_tables() + with get_conn() as conn: + conn.execute( + "UPDATE tasks SET status=?, finished_at=?, result_json=? WHERE id=?", + ( + status, + datetime.utcnow().isoformat(), + json.dumps(result) if result is not None else None, + task_id, + ), + ) + conn.commit() + + +def list_tasks() -> None: + ensure_tables() + with get_conn() as conn: + rows = conn.execute( + "SELECT id, tool, target, status, created_at, started_at, finished_at FROM tasks ORDER BY id" + ).fetchall() + for row in rows: + print(dict(row)) + + +def cli(argv: Optional[list] = None) -> int: + parser = argparse.ArgumentParser(description="Manage GooseStrike task queue") + sub = parser.add_subparsers(dest="cmd", required=True) + + enqueue_cmd = sub.add_parser("enqueue", help="Enqueue a new task") + enqueue_cmd.add_argument("tool") + enqueue_cmd.add_argument("target", nargs="?") + enqueue_cmd.add_argument("params", help="JSON string with parameters") + + sub.add_parser("list", help="List tasks") + + fetch_cmd = sub.add_parser("next", help="Fetch next task") + fetch_cmd.add_argument("--tool") + + update_cmd = sub.add_parser("update", help="Update task status") + update_cmd.add_argument("task_id", type=int) + update_cmd.add_argument("status") + update_cmd.add_argument("result", nargs="?", help="JSON result payload") + + args = parser.parse_args(argv) + if args.cmd == "enqueue": + params = json.loads(args.params) + task_id = enqueue_task(args.tool, args.target, params) + print(f"Enqueued task {task_id}") + elif args.cmd == "list": + list_tasks() + elif args.cmd == "next": + task = fetch_next_task(args.tool) + print(task or "No pending tasks") + elif args.cmd == "update": + result = json.loads(args.result) if args.result else None + update_task_status(args.task_id, args.status, result) + print("Task updated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(cli()) diff --git a/tests/test_scanner_and_api.py b/tests/test_scanner_and_api.py new file mode 100644 index 0000000..bf397e8 --- /dev/null +++ b/tests/test_scanner_and_api.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +import api +import scanner +from app.agents import llm_router + + +NMAP_XML = """ + + + +
+
+ + + + + + + + + + + + + + +""" + + +def test_parse_nmap_xml(): + hosts, started, finished = scanner.parse_nmap_xml(NMAP_XML) + assert len(hosts) == 1 + assert hosts[0].ip == "192.168.1.10" + assert hosts[0].mac_address == "00:11:22:33:44:55" + assert hosts[0].services[0].port == 80 + assert started.startswith("2024") + assert finished.endswith("UTC") + + +def test_scanner_main_monkeypatched(monkeypatch): + class DummyProcess: + def __init__(self, stdout: str): + self.stdout = stdout + self.stderr = "" + self.returncode = 0 + + captured: dict[str, Any] = {} + + def fake_run(cmd: Any, check: bool, stdout, stderr, text): # pylint: disable=unused-argument + captured["cmd"] = cmd + return DummyProcess(NMAP_XML) + + monkeypatch.setattr(scanner, "subprocess", type("S", (), {"run": staticmethod(fake_run)})) + exit_code = scanner.main( + ["192.168.1.0/24", "--no-upload", "--nmap-args", "-Pn --script vuln"] + ) + assert exit_code == 0 + assert "-Pn" in captured["cmd"] + assert "--script" in captured["cmd"] + + +def test_build_nmap_command_accepts_custom_args(): + cmd = scanner.build_nmap_command( + "10.0.0.0/24", "standard", None, ["-Pn", "--script", "vuln"] + ) + assert cmd[0] == "nmap" + assert cmd[-1] == "10.0.0.0/24" + assert cmd[1:4] == ["-Pn", "--script", "vuln"] + + +@pytest.fixture() +def temp_db(tmp_path: Path, monkeypatch): + db_path = tmp_path / "api.db" + exploit_db = tmp_path / "exploits.db" + tasks_db = tmp_path / "tasks.db" + monkeypatch.setattr(api, "DB_PATH", db_path) + monkeypatch.setattr(api, "EXPLOIT_DB_PATH", exploit_db) + monkeypatch.setattr(api.task_queue, "DB_PATH", tasks_db) + api.initialize_db() + api.task_queue.ensure_tables() + conn = sqlite3.connect(exploit_db) + conn.execute( + "CREATE TABLE IF NOT EXISTS cves (cve_id TEXT PRIMARY KEY, description TEXT, severity TEXT, score REAL)" + ) + conn.execute( + "INSERT OR REPLACE INTO cves(cve_id, description, severity, score) VALUES(?,?,?,?)", + ("CVE-2023-12345", "Test vuln", "HIGH", 8.8), + ) + conn.close() + return db_path + + +def test_ingest_and_list_assets(temp_db): + client = TestClient(api.app) + payload = { + "ip": "192.168.1.10", + "hostname": "web", + "mac_address": "00:11:22:33:44:55", + "mac_vendor": "TestVendor", + "scan": {"scan_id": "unit-test-scan", "scanner": "pytest", "mode": "fast"}, + "services": [ + { + "port": 80, + "proto": "tcp", + "product": "Apache", + "version": "2.4.57", + "extra": {"name": "http"}, + "cves": ["CVE-2023-12345"], + } + ], + } + response = client.post("/ingest/scan", json=payload) + assert response.status_code == 200 + asset = response.json() + assert asset["mac_address"] == "00:11:22:33:44:55" + + list_response = client.get("/assets") + assert list_response.status_code == 200 + assets = list_response.json() + assert assets[0]["services"][0]["cves"] == ["CVE-2023-12345"] + vuln = assets[0]["services"][0]["vulnerabilities"][0] + assert vuln["severity"] == "HIGH" + + conn = sqlite3.connect(temp_db) + rows = conn.execute("SELECT COUNT(*) FROM services").fetchone()[0] + assert rows == 1 + scan_runs = conn.execute("SELECT COUNT(*) FROM scan_runs").fetchone()[0] + assert scan_runs == 1 + conn.close() + + scans = client.get("/scans").json() + assert scans[0]["scan_id"] == "unit-test-scan" + + suggestions = client.get("/attack_suggestions").json() + assert any(item["technique_id"] == "T1068" for item in suggestions) + + +def test_task_queue_endpoints(temp_db): + client = TestClient(api.app) + response = client.post( + "/tasks", + json={ + "tool": "password_cracker", + "target": "lab-hashes", + "params": {"hash_file": "hashes.txt", "wordlist": "rockyou.txt"}, + }, + ) + assert response.status_code == 201 + task = response.json() + assert task["tool"] == "password_cracker" + + list_response = client.get("/tasks") + assert list_response.status_code == 200 + tasks = list_response.json() + assert len(tasks) == 1 + assert tasks[0]["target"] == "lab-hashes" + + update_response = client.post( + f"/tasks/{task['id']}/status", + json={"status": "completed", "result": {"exit_code": 0}}, + ) + assert update_response.status_code == 200 + assert update_response.json()["status"] == "completed" + + +def test_n8n_workflow_import_and_list(temp_db): + client = TestClient(api.app) + payload = {"name": "demo-workflow", "workflow": {"nodes": ["scan", "alert"]}} + response = client.post("/n8n/workflows/import", json=payload) + assert response.status_code == 201 + body = response.json() + assert body["name"] == "demo-workflow" + listing = client.get("/n8n/workflows") + assert listing.status_code == 200 + assert listing.json()[0]["name"] == "demo-workflow" + + +def test_armitage_data_endpoint_returns_graph(temp_db): + client = TestClient(api.app) + data = client.get("/armitage/data").json() + assert "nodes" in data and "edges" in data + assert any(node["type"] == "asset" for node in data["nodes"]) + + +def test_n8n_integration_test_endpoint(temp_db, monkeypatch): + client = TestClient(api.app) + + class DummyResponse: + ok = True + status_code = 200 + headers = {"content-type": "application/json"} + + def json(self): + return {"echo": True} + + text = "" + + captured = {} + + def fake_post(url, json=None, timeout=None): # pylint: disable=redefined-outer-name + captured["url"] = url + captured["json"] = json + captured["timeout"] = timeout + return DummyResponse() + + monkeypatch.setattr(api.requests, "post", fake_post) + + response = client.post( + "/integrations/test/n8n", + json={"url": "https://n8n.example/webhook", "payload": {"hello": "world"}, "timeout": 5}, + ) + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert captured["json"]["hello"] == "world" + assert captured["timeout"] == 5 + + +def test_ollama_integration_test_endpoint(temp_db, monkeypatch): + client = TestClient(api.app) + + class DummyResponse: + ok = True + status_code = 200 + headers = {"content-type": "application/json"} + + def json(self): + return {"response": "pong"} + + text = "" + + captured = {} + + def fake_post(url, json=None, timeout=None): + captured["url"] = url + captured["json"] = json + return DummyResponse() + + monkeypatch.setattr(api.requests, "post", fake_post) + + response = client.post( + "/integrations/test/ollama", + json={"url": "http://ollama.internal:11434", "model": "mock", "prompt": "ping"}, + ) + assert response.status_code == 200 + assert response.json()["ok"] is True + assert captured["json"]["model"] == "mock" + assert captured["json"]["prompt"] == "ping" + assert captured["url"].endswith("/api/generate") + + +def test_llm_router_falls_back_to_ollama(monkeypatch): + monkeypatch.delenv("CLAUDE_API_URL", raising=False) + monkeypatch.delenv("HACKGPT_API_URL", raising=False) + monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama.fleet:11434") + monkeypatch.setenv("OLLAMA_MODEL", "mock-llm") + + class DummyResponse: + headers = {"content-type": "application/json"} + + def __init__(self): + self.ok = True + self.status_code = 200 + + def json(self): + return {"response": "hello from ollama"} + + text = "hello" + + def raise_for_status(self): + return None + + captured = {} + + def fake_post(url, json=None, headers=None, timeout=None): + captured["url"] = url + captured["json"] = json + return DummyResponse() + + monkeypatch.setattr(llm_router.requests, "post", fake_post) + + result = llm_router.call_llm_with_fallback("status?") + assert "ollama" in captured["url"] + assert captured["json"]["model"] == "mock-llm" + assert "hello from ollama" in result diff --git a/web/static/architecture.svg b/web/static/architecture.svg new file mode 100644 index 0000000..3e277a1 --- /dev/null +++ b/web/static/architecture.svg @@ -0,0 +1,37 @@ + + + + + + + + + GooseStrike Flow + + scanner.py + + FastAPI / UI + assets • scans • tasks + + Agents + + db/goosestrike.db + + db/exploits.db + CVE API + + HackGPT Relay + + n8n workflows + + + + + + + + + + + + diff --git a/web/static/goose_flag_logo.svg b/web/static/goose_flag_logo.svg new file mode 100644 index 0000000..ade2b4c --- /dev/null +++ b/web/static/goose_flag_logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + GOOSE STRIKE + diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..8342471 --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,449 @@ +:root { + --black: #0b0c0d; + --grey: #4b4f58; + --white: #f5f5f5; + --red: #d90429; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: var(--black); + color: var(--white); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.5rem; + background: linear-gradient(135deg, var(--black), var(--grey)); + border-bottom: 4px solid var(--red); +} + +section.hero-panel { + background: linear-gradient(120deg, rgba(0, 0, 0, 0.95), rgba(11, 12, 13, 0.85), rgba(75, 79, 88, 0.85)); + border: 1px solid rgba(255, 255, 255, 0.2); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 2rem; + box-shadow: 0 25px 45px rgba(0, 0, 0, 0.55); +} + +.hero-text h2 { + margin-top: 0; + font-size: 1.6rem; +} + +.hero-list { + list-style: none; + padding-left: 0; + margin: 0 0 1.5rem 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.hero-list li { + font-size: 1rem; + letter-spacing: 0.02em; +} + +.hero-meta h3 { + margin-bottom: 0.5rem; + color: var(--white); +} + +.hero-meta table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.hero-meta th, +.hero-meta td { + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.45rem 0.6rem; + text-align: left; +} + +.hero-meta th { + background: rgba(255, 255, 255, 0.08); +} + +.hero-visual { + display: flex; + align-items: center; + justify-content: center; +} + +.hero-visual-inner { + width: min(320px, 100%); + border-radius: 18px; + background: radial-gradient(circle at top, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.75)); + padding: 1.5rem; + box-shadow: 0 30px 60px rgba(0, 0, 0, 0.65); +} + +.hero-visual img { + width: 100%; + height: auto; + display: block; +} + +.canadian-flag { + width: 80px; + height: 48px; + border: 2px solid var(--white); + border-radius: 4px; + background: linear-gradient(90deg, var(--red) 0 25%, var(--white) 25% 75%, var(--red) 75% 100%); + box-shadow: 0 0 12px rgba(0, 0, 0, 0.45); +} + +.goose-logo { + margin-left: auto; + width: 120px; + height: 120px; + border-radius: 12px; + border: 2px solid rgba(255, 255, 255, 0.85); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.15), rgba(0, 0, 0, 0.45)); + padding: 0.4rem; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.65); +} + +.goose-logo img { + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.75)); +} + +main { + flex: 1; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + padding: 1.5rem; +} + +section { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--grey); + border-radius: 8px; + padding: 1rem; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.35); +} + +.wide { + grid-column: span 2; +} + +#assets article { + border: 1px solid var(--grey); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.75rem; + background: rgba(0, 0, 0, 0.3); +} + +#assets .meta { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 0.35rem; +} + +#scans, +#mitre { + max-height: 60vh; + overflow-y: auto; +} + +#scans article, +#mitre article { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.75rem; + background: rgba(255, 255, 255, 0.03); +} + +#mitre h3, +#scans h3 { + margin-top: 0; + color: var(--red); +} + +.service-line { + display: flex; + justify-content: space-between; + align-items: center; +} + +.vuln-list { + margin-top: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.badge { + display: inline-block; + padding: 0.2rem 0.45rem; + border-radius: 4px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.severity-critical { + background: var(--red); + color: var(--white); +} + +.severity-high { + background: #ff6b6b; + color: var(--black); +} + +.severity-medium { + background: #ffba08; + color: var(--black); +} + +.severity-low, +.severity-info, +.severity-unknown { + background: rgba(255, 255, 255, 0.2); + color: var(--white); +} + +.scan-card .meta, +#mitre .meta { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.75); +} + +#assets h3 { + margin: 0 0 0.5rem 0; + color: var(--red); +} + +#assets ul { + list-style: none; + padding: 0; + margin: 0; +} + +#assets li { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +#assets li strong { + font-size: 1.1rem; +} + +footer { + text-align: center; + padding: 1rem; + background: var(--grey); + color: var(--white); +} + +form.card-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +label { + font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +input, +textarea, +select { + background: rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.2); + color: var(--white); + padding: 0.5rem; + border-radius: 4px; + font-size: 1rem; +} + +textarea { + resize: vertical; +} + +.form-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +button { + background: var(--red); + color: var(--white); + border: none; + padding: 0.6rem 1.25rem; + border-radius: 4px; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: #ff4d4d; +} + +.helper { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.helper.error { + color: #ff8c8c; +} + +#tasks { + max-height: 60vh; + overflow-y: auto; +} + +.task-card { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 0.75rem; + background: rgba(0, 0, 0, 0.3); +} + +.task-line { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; +} + +.status-pill { + background: rgba(255, 255, 255, 0.2); + color: var(--white); +} + +.status-running { + background: #ffba08; + color: var(--black); +} + +.status-completed { + background: #06d6a0; + color: var(--black); +} + +.status-failed, +.status-error { + background: #ef476f; + color: var(--white); +} + +/* Armitage-style view */ +.armitage-body { + background: radial-gradient(circle at top, #1f1f1f, #0b0b0b); + color: #f4f4f4; + min-height: 100vh; + margin: 0; + font-family: 'Inter', 'Segoe UI', sans-serif; +} + +.armitage-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem 2rem; + background: linear-gradient(90deg, #111, #222); + border-bottom: 2px solid #b81b29; +} + +.armitage-header h1 { + margin: 0; + font-size: 1.5rem; +} + +.armitage-header nav a { + color: #fff; + margin-left: 1rem; + text-decoration: none; + font-weight: 600; +} + +.armitage-canvas { + position: relative; + margin: 2rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + background: radial-gradient(circle at center, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.9)); + min-height: 70vh; + overflow: hidden; +} + +.armitage-edges { + position: absolute; + inset: 0; +} + +.armitage-nodes { + position: absolute; + inset: 0; + pointer-events: none; +} + +.armitage-node { + position: absolute; + transform: translate(-50%, -50%); + padding: 0.35rem 0.8rem; + border-radius: 999px; + font-size: 0.85rem; + text-align: center; + pointer-events: auto; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.armitage-node.asset { + background: rgba(184, 27, 41, 0.85); +} + +.armitage-node.service { + background: rgba(255, 255, 255, 0.2); +} + +.armitage-node.cve { + background: rgba(255, 205, 41, 0.8); + color: #111; +} + +.armitage-edge { + stroke: rgba(255, 255, 255, 0.25); + stroke-width: 2; +} diff --git a/web/static/uploads/.gitkeep b/web/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/static/uploads/official_goosestrike_logo.svg b/web/static/uploads/official_goosestrike_logo.svg new file mode 100644 index 0000000..369a3a0 --- /dev/null +++ b/web/static/uploads/official_goosestrike_logo.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/templates/armitage.html b/web/templates/armitage.html new file mode 100644 index 0000000..90a88c8 --- /dev/null +++ b/web/templates/armitage.html @@ -0,0 +1,68 @@ + + + + + GooseStrike Armitage View + + + +
+

GooseStrike – Armitage-style graph

+ +
+
+ +
+
+ + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..7da52f6 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,426 @@ + + + + + + GooseStrike Command Deck + + + +
+ +
+

GooseStrike

+

Canadian-themed, AI-assisted offensive security toolbox for authorized testing.

+
+ +
+ +
+
+
+

GooseStrike Core (Docker-ready)

+
    +
  • 🔧 Nmap, Metasploit, SQLMap, Hydra, ZAP
  • +
  • 🧠 AI exploit assistant (Claude, HackGPT-ready)
  • +
  • 📚 Offline CVE mirroring with update_cve.sh
  • +
  • 🗂 ASCII banner, logo, branding kit (PDF)
  • +
  • 📜 CVE scan + JSON match script
  • +
  • 📦 goosestrike-cve-enabled.zip (download link)
  • +
  • 🧠 hackgpt-ai-stack.zip with README + architecture
  • +
+
+

Coming next (roadmap you requested)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TaskStatus
🐳 Build docker-compose.goosestrike-full.yml⏳ In progress
🧠 HackGPT API container (linked to n8n)⏳ Next up
🌐 Local CVE API serverPending
🧬 Claude + HackGPT fallback systemPending
🔄 n8n workflow .json importPending
🎯 Target "prioritizer" AI agentPending
🧭 SVG architecture diagramPending
🖥 Dashboard frontend (Armitage-style)Optional
🔐 C2 bridging to Mythic/SliverOptional
+
+
+ +
+ +
+

Assets & Vulnerabilities

+
+
+
+

Scan History

+
+
+
+

MITRE ATT&CK Suggestions

+
+
+
+

Task Queue

+
+
+
+

Alerts

+
Waiting for n8n webhooks...
+
+
+

Queue Tool Task

+
+
+ + +
+ +
+ + Tasks land in db/tasks.db and runners pick them up. +
+
+
+
+
+

Password Cracking Helper

+
+
+ + +
+ + + +
+ + Hashcat/John/Rainbow jobs reuse the password_cracker runner. +
+
+
+
+
+ +
+ Built for red, grey, black, and white team operations. Use responsibly. +
+ + + + diff --git a/web/templates/mock_dashboard.html b/web/templates/mock_dashboard.html new file mode 100644 index 0000000..e3a86cf --- /dev/null +++ b/web/templates/mock_dashboard.html @@ -0,0 +1,161 @@ + + + + + + GooseStrike Mock Dashboard + + + +
+ +
+

GooseStrike Mock Dashboard

+

Pre-filled sample data so you can preview the UI without running scans.

+
+ +
+ +
+
+
+

{{ mock.core_snapshot.title }}

+
    + {% for bullet in mock.core_snapshot.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+

Artifact drops

+
    + {% for download in mock.core_snapshot.downloads %} +
  • {{ download }}
  • + {% endfor %} +
+
+

Coming next (roadmap you requested)

+ + + + + + + + + {% for item in mock.roadmap %} + + + + + {% endfor %} + +
TaskStatus
{{ item.task | safe }}{{ item.status }}
+
+
+ +
+ +
+

Assets (Sample)

+
+ {% for asset in mock.assets %} +
+

{{ asset.ip }}{% if asset.hostname %} · {{ asset.hostname }}{% endif %}

+

MAC {{ asset.mac_address }} · {{ asset.mac_vendor }}

+
    + {% for service in asset.services %} +
  • + {{ service.port }}/{{ service.proto }} + {{ service.product }} {{ service.version }} +
    + {% for vuln in service.vulnerabilities %} + + {{ vuln.cve_id }} ({{ vuln.severity or 'Unknown' }}) + + {% endfor %} +
    +
  • + {% endfor %} +
+
+ {% endfor %} +
+
+ +
+

Scan History (Sample)

+
+ {% for scan in mock.scans %} +
+

{{ scan.asset_ip }} · {{ scan.mode }} ({{ scan.scan_id }})

+

{{ scan.started_at }} → {{ scan.completed_at }} · {{ scan.notes }}

+
    + {% for svc in scan.services %} +
  • + {{ svc.port }}/{{ svc.proto }} — {{ svc.product }} {{ svc.version }} +
    + {% for cve in svc.cves %} + {{ cve }} + {% endfor %} +
    +
  • + {% endfor %} +
+
+ {% endfor %} +
+
+ +
+

MITRE ATT&CK Suggestions (Sample)

+
+ {% for suggestion in mock.attack_suggestions %} +
+

{{ suggestion.technique_id }} · {{ suggestion.name }}

+

{{ suggestion.tactic }} · Related to {{ suggestion.related_cve }}

+

{{ suggestion.description }}

+
+ {% endfor %} +
+
+ +
+

Task Queue (Sample)

+
+ {% for task in mock.tasks %} +
+
+ {{ task.tool }} + {{ task.status }} +
+

{{ task.target }}

+
{{ task.params | tojson(indent=2) }}
+
+ {% endfor %} +
+
+ +
+

Alerts (Sample)

+
+ {% for alert in mock.alerts %} +
+

{{ alert.source }}

+

{{ alert.created_at }}

+
{{ alert.payload | tojson(indent=2) }}
+
+ {% endfor %} +
+
+
+ + + + diff --git a/zap_runner.py b/zap_runner.py new file mode 100644 index 0000000..e5a73c4 --- /dev/null +++ b/zap_runner.py @@ -0,0 +1,34 @@ +"""Wrapper around OWASP ZAP baseline scan.""" +from __future__ import annotations + +from typing import Any, Dict, List + +from runner_utils import run_subprocess + + +def build_command(task: Dict[str, Any]) -> List[str]: + target = task.get("target_url") + if not target: + raise ValueError("target_url is required") + command = ["zap-baseline.py", "-t", target, "-m", "5", "-r", task.get("report", "zap_report.html")] + for key, value in task.get("options", {}).items(): + flag = f"-{key}" if len(key) == 1 else f"--{key}" + if isinstance(value, bool): + if value: + command.append(flag) + else: + command.extend([flag, str(value)]) + return command + + +def run_task(task: Dict[str, Any]) -> Dict[str, Any]: + try: + command = build_command(task) + except ValueError as exc: + return {"status": "error", "exit_code": None, "error": str(exc)} + return run_subprocess(command, "zap") + + +if __name__ == "__main__": + example = {"target_url": "http://example.com", "report": "example.html"} + print(run_task(example))