diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6d662..b446f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to CyberAI are documented here. +## [0.5.0] - 2026-06-18 +### Differentiated Platform — Week 4 +Week 4 gives CyberAI its unique edge: out-of-band-driven exploitation, a +Web3 audit track, an MCP server, report self-validation, bug-bounty scope +import, and a web dashboard. + +### Added +- OOB-driven exploitation: phantom-grid v2.0 client (token-flow), payload + library v2 (7 categories), `OOBWorkflow` + `ExploitAgentOOB` correlating + injected payloads against live callbacks. +- Nuclei exploit engine: subprocess wrapper with JSONL parsing, searchsploit + integration (graceful), CVE→OOB heuristic for JNDI/SSRF templates. +- Web3 track: standalone `SmartContractAgent`, Slither wrapper, Immunefi + severity classifier (per-check table + impact×confidence fallback). +- MCP server: official `mcp` SDK, recon + intel tools exposed as MCP tools + with JSON Schema and graceful dispatch (Claude Desktop / Cursor docs). +- LLM-as-Judge: `judge_report` cross-checks report claims against KB + evidence, `JudgeVerdict`, feedback-driven retry, per-finding confidence. +- Bug-bounty scope import: HackerOne / Bugcrowd JSON → in/out scope with + exclusion-aware matching (`!host` overrides allow-wildcards). +- Web dashboard: FastAPI backend reading sessions from disk, SSE live phase + progress, single-file htmx + alpinejs UI (no build step). + +### Changed +- Web backend migrated from dead Flask stubs to FastAPI; sessions are now + read from disk (single source of truth shared with `cyberai replay`). + ## [0.4.0] - 2026-06-12 ### Accelerated & Observable — Week 3 diff --git a/cyberai/version.py b/cyberai/version.py index 7d768c4..d6f7234 100644 --- a/cyberai/version.py +++ b/cyberai/version.py @@ -1,3 +1,3 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" __author__ = "evkir" __description__ = "CyberAI — AI-native multi-agent pentest platform" diff --git a/cyberai/web/app.py b/cyberai/web/app.py index 8106875..609f0ef 100644 --- a/cyberai/web/app.py +++ b/cyberai/web/app.py @@ -1,40 +1,55 @@ """ -CyberAI Flask API server. -REST interface for starting scans, querying sessions, serving reports. +CyberAI FastAPI server. + +REST + SSE interface for listing scan sessions and serving reports. +Sessions are read from disk (config.output_dir / session_.json), +the same artifacts the CLI `replay` command consumes. """ -from flask import Flask, jsonify -from cyberai.web.routes.session import session_bp -from cyberai.web.routes.report import report_bp +from __future__ import annotations + import logging +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse + +from cyberai.core.config import CyberAIConfig +from cyberai.web.routes.report import router as report_router +from cyberai.web.routes.session import router as session_router logger = logging.getLogger("cyberai.web") +_TEMPLATES = Path(__file__).parent / "templates" -def create_app() -> Flask: - app = Flask(__name__) - app.config["JSON_SORT_KEYS"] = False - # Register blueprints - app.register_blueprint(session_bp, url_prefix="/api") - app.register_blueprint(report_bp, url_prefix="/api") +def create_app(config: CyberAIConfig | None = None) -> FastAPI: + """Build the FastAPI app. Pass a config to override the sessions dir.""" + app = FastAPI(title="CyberAI API", version="0.5.0") + app.state.config = config or CyberAIConfig() - @app.get("/health") - def health(): - return jsonify({"status": "ok", "service": "CyberAI API"}) + app.include_router(session_router, prefix="/api") + app.include_router(report_router, prefix="/api") - @app.errorhandler(404) - def not_found(e): - return jsonify({"error": "not found"}), 404 + @app.get("/health") + def health() -> dict: + return {"status": "ok", "service": "CyberAI API"} - @app.errorhandler(500) - def server_error(e): - return jsonify({"error": "internal server error"}), 500 + @app.get("/", response_class=HTMLResponse) + def dashboard() -> str: + index = _TEMPLATES / "dashboard.html" + if not index.exists(): + return "

CyberAI

dashboard.html missing

" + return index.read_text() - logger.info("CyberAI API server created") + logger.info("CyberAI FastAPI app created") return app +app = create_app() + + if __name__ == "__main__": - app = create_app() - app.run(host="127.0.0.1", port=8888, debug=False) + import uvicorn + + uvicorn.run("cyberai.web.app:app", host="127.0.0.1", port=8888, reload=False) diff --git a/cyberai/web/routes/report.py b/cyberai/web/routes/report.py index 5ba1474..d613c2e 100644 --- a/cyberai/web/routes/report.py +++ b/cyberai/web/routes/report.py @@ -1,54 +1,65 @@ """ -/api/report — serve generated markdown/JSON reports. +/api/sessions/{id}/report — serve a session's generated markdown report. + +The report path is resolved from the session's knowledge base +(report.markdown_path), written by ReportAgent. Falls back to a 404-shaped +error dict when a session has no report (e.g. dry-run scans). """ -from flask import Blueprint, jsonify, send_file, abort +from __future__ import annotations + +import json from pathlib import Path -import os - -report_bp = Blueprint("report", __name__) - -REPORTS_DIR = Path(os.getenv("CYBERAI_REPORTS_DIR", "reports/")) - - -@report_bp.get("/report") -def list_reports(): - """GET /api/report — list available report files.""" - if not REPORTS_DIR.exists(): - return jsonify({"reports": [], "count": 0}) - - files = [ - { - "filename": f.name, - "size_bytes": f.stat().st_size, - "modified": f.stat().st_mtime, - } - for f in REPORTS_DIR.iterdir() - if f.suffix in (".md", ".json", ".html", ".pdf") - ] - files.sort(key=lambda x: x["modified"], reverse=True) - return jsonify({"reports": files, "count": len(files)}) - - -@report_bp.get("/report/") -def get_report(filename: str): - """ - GET /api/report/ - Serves the report file. Sanitizes path to prevent traversal. - """ - # Path traversal guard - safe_name = Path(filename).name - report_path = REPORTS_DIR / safe_name - - if not report_path.exists(): - abort(404) - - suffix = report_path.suffix.lower() - mime_map = { - ".md": "text/markdown", - ".json": "application/json", - ".html": "text/html", - ".pdf": "application/pdf", - } - mimetype = mime_map.get(suffix, "application/octet-stream") - return send_file(report_path, mimetype=mimetype) + +from fastapi import APIRouter, Request +from fastapi.responses import PlainTextResponse + +router = APIRouter() + + +def _sessions_dir(request: Request) -> Path: + return Path(request.app.state.config.output_dir) + + +def _report_path_for(session: dict) -> str | None: + kb = session.get("kb") + if isinstance(kb, dict): + val = kb.get("report.markdown_path") + if isinstance(val, dict): # kb entries may wrap value+meta + val = val.get("value") + if isinstance(val, str): + return val + return None + + +@router.get("/sessions/{session_id}/report", response_class=PlainTextResponse) +def get_report(session_id: str, request: Request): + """Return the markdown report body for a session, or an error dict.""" + out = _sessions_dir(request) + safe = Path(session_id).name + spath = out / f"session_{safe}.json" + if not spath.exists(): + return PlainTextResponse( + json.dumps({"error": "session not found"}), + status_code=404, + media_type="application/json", + ) + session = json.loads(spath.read_text()) + + md = _report_path_for(session) + # Guard traversal: report must live inside the sessions dir. + if md: + rp = Path(md) + if rp.name == rp.as_posix().split("/")[-1] and rp.exists(): + try: + rp.resolve().relative_to(out.resolve()) + except ValueError: + md = None + else: + return PlainTextResponse(rp.read_text(), media_type="text/markdown") + + return PlainTextResponse( + json.dumps({"error": "no report for session", "session_id": session_id}), + status_code=404, + media_type="application/json", + ) diff --git a/cyberai/web/routes/session.py b/cyberai/web/routes/session.py index f274873..f0a9239 100644 --- a/cyberai/web/routes/session.py +++ b/cyberai/web/routes/session.py @@ -1,112 +1,102 @@ """ -/api/session — start scans, query session state. -""" - -from flask import Blueprint, request, jsonify -from dataclasses import dataclass, field, asdict -from typing import Dict, Optional -import uuid -import time -import threading - -session_bp = Blueprint("session", __name__) - -# In-memory session store (replaced by DB in production) -_sessions: Dict[str, dict] = {} -_lock = threading.Lock() - +/api/sessions — list and inspect scan sessions read from disk. -@dataclass -class SessionRecord: - session_id: str - target: str - status: str = "pending" # pending | running | done | error - created_at: float = field(default_factory=time.time) - completed_at: Optional[float] = None - result: dict = field(default_factory=dict) - error: Optional[str] = None +Sessions live as session_.json in config.output_dir, written by the +CLI scan flow (cyberai.cli.replay.save_session). This router never mutates +them; it is a read-only window for the dashboard. +""" +from __future__ import annotations -@session_bp.post("/session") -def create_session(): - """ - POST /api/session - Body: {"target": "10.10.10.1"} - Returns: {"session_id": "...", "status": "pending"} - """ - data = request.get_json(silent=True) or {} - target = data.get("target", "").strip() +import asyncio +import json +from pathlib import Path - if not target: - return jsonify({"error": "target is required"}), 400 +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse - session_id = str(uuid.uuid4()) - record = SessionRecord(session_id=session_id, target=target) +router = APIRouter() - with _lock: - _sessions[session_id] = asdict(record) +_SESSION_GLOB = "session_*.json" - # Fire pipeline in background thread - thread = threading.Thread( - target=_run_pipeline, - args=(session_id, target), - daemon=True, - ) - thread.start() - return jsonify( - { - "session_id": session_id, - "target": target, - "status": "pending", - } - ), 202 +def _sessions_dir(request: Request) -> Path: + return Path(request.app.state.config.output_dir) -@session_bp.get("/session/") -def get_session(session_id: str): - """ - GET /api/session/ - Returns session status and result when complete. +def _load(path: Path) -> dict | None: + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + +def _summary(data: dict) -> dict: + """Compact view for the list endpoint.""" + return { + "session_id": data.get("session_id"), + "target": data.get("target"), + "state": data.get("state"), + "created_at": data.get("created_at"), + "ended_at": data.get("ended_at"), + "findings": len(data.get("findings") or []), + } + + +@router.get("/sessions") +def list_sessions(request: Request) -> dict: + """List all sessions on disk, newest first.""" + out = _sessions_dir(request) + items: list[dict] = [] + if out.exists(): + for path in out.glob(_SESSION_GLOB): + data = _load(path) + if data: + items.append(_summary(data)) + items.sort(key=lambda s: s.get("created_at") or "", reverse=True) + return {"sessions": items, "count": len(items)} + + +@router.get("/sessions/{session_id}") +def get_session(session_id: str, request: Request) -> dict: + """Full session JSON for one id, or a 404-shaped error dict.""" + safe = Path(session_id).name + path = _sessions_dir(request) / f"session_{safe}.json" + data = _load(path) if path.exists() else None + if data is None: + return {"error": "session not found", "session_id": session_id} + return data + + +@router.get("/sessions/{session_id}/stream") +async def stream_session(session_id: str, request: Request) -> StreamingResponse: """ - with _lock: - record = _sessions.get(session_id) - - if not record: - return jsonify({"error": "session not found"}), 404 - - return jsonify(record) - - -@session_bp.get("/session") -def list_sessions(): - """GET /api/session — list all sessions""" - with _lock: - sessions = list(_sessions.values()) - return jsonify({"sessions": sessions, "count": len(sessions)}) + SSE: poll the session file and emit phase deltas until terminal state. - -def _run_pipeline(session_id: str, target: str): - """Background worker: runs async pipeline, updates session record.""" - import asyncio - from cyberai.core.pipeline import AsyncPipeline - - _update(session_id, status="running") - try: - pipeline = AsyncPipeline() - result = asyncio.run(pipeline.run(target)) - _update( - session_id, - status="done" if result.success else "error", - result=result.recon, - completed_at=time.time(), - error=result.error, - ) - except Exception as e: - _update(session_id, status="error", error=str(e), completed_at=time.time()) - - -def _update(session_id: str, **kwargs): - with _lock: - if session_id in _sessions: - _sessions[session_id].update(kwargs) + Emits `event: phase` for each newly-seen phase and `event: done` when + the session reaches a terminal state or after a bounded number of polls. + """ + safe = Path(session_id).name + path = _sessions_dir(request) / f"session_{safe}.json" + + async def gen(): + seen: set[str] = set() + terminal = {"completed", "failed", "error"} + for _ in range(120): + if await request.is_disconnected(): + return + data = _load(path) if path.exists() else None + if data: + for ph in data.get("phases") or []: + name = ph.get("phase") or ph.get("name") + if name and name not in seen: + seen.add(name) + yield f"event: phase\ndata: {json.dumps(ph)}\n\n" + state = data.get("state") + if state and state.lower() in terminal: + yield f"event: done\ndata: {json.dumps({'state': state})}\n\n" + return + await asyncio.sleep(0.5) + yield 'event: done\ndata: {"state": "timeout"}\n\n' + + return StreamingResponse(gen(), media_type="text/event-stream") diff --git a/cyberai/web/templates/dashboard.html b/cyberai/web/templates/dashboard.html index 4be78fa..dfc93ba 100644 --- a/cyberai/web/templates/dashboard.html +++ b/cyberai/web/templates/dashboard.html @@ -1,185 +1,104 @@ - - - CyberAI — Session Monitor + + + CyberAI Dashboard + + - - -
-

🤖 CyberAI — Session Monitor

- Real-time scan session tracking -
- -
- - - -
- - - - - - - - - - - - - - -
Session IDTargetStatusStartedDuration
No sessions yet
- -
Auto-refresh every 5s
- - - + + diff --git a/docs/journal/week-4.md b/docs/journal/week-4.md new file mode 100644 index 0000000..2bf6301 --- /dev/null +++ b/docs/journal/week-4.md @@ -0,0 +1,52 @@ +# Week 4 — Differentiation + +**Период:** дни 22-28. **Итог:** платформа получила уникальные фичи — +OOB-driven exploitation, Web3-трек, MCP-сервер, LLM-as-Judge, bug-bounty +scope import и web-дашборд. 395 тестов зелёные (было 319). v0.5.0. + +## Что сделано +- **OOB exploitation** (день 22): phantom-grid v2.0 client (token-flow, + `/api/tokens`), payload-библиотека v2 (7 категорий), `OOBWorkflow` + + `ExploitAgentOOB` — pick CVE → payload → inject → poll grid → LLM-вывод. +- **Nuclei engine** (день 23): subprocess-обёртка с JSONL-парсером, + searchsploit (graceful), CVE→OOB-эвристика для JNDI/SSRF-темплейтов. +- **Web3 agent** (день 24): `SmartContractAgent` standalone, Slither-обёртка, + Immunefi severity classifier (per-check таблица + impact×confidence + fallback). TheDAO-fixture → reentrancy → Critical. +- **MCP server** (день 25): официальный mcp SDK 1.27.2, recon+intel tools + как MCP-tools с JSON Schema, graceful dispatch, docs для Claude Desktop. +- **LLM-as-Judge** (день 26): `judge_report` сверяет claims отчёта с KB, + `JudgeVerdict` (pydantic), retry с фидбеком при score=2.31.0,<3", "networkx>=3.2.1,<4", "colorama>=0.4.6,<1", + "fastapi>=0.110,<1", + "uvicorn>=0.29,<1", ] [project.optional-dependencies] diff --git a/tests/unit/test_web_api.py b/tests/unit/test_web_api.py new file mode 100644 index 0000000..933b929 --- /dev/null +++ b/tests/unit/test_web_api.py @@ -0,0 +1,95 @@ +"""Unit tests for the FastAPI dashboard backend (day 28).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyberai.core.config import CyberAIConfig +from cyberai.web.app import create_app + + +def _write_session(out: Path, sid: str, *, state: str = "completed", md: str | None = None) -> None: + data = { + "session_id": sid, + "target": "example.com", + "state": state, + "created_at": "2026-06-18T10:00:00+00:00", + "ended_at": "2026-06-18T10:00:05+00:00", + "findings": [{"id": "F1"}], + "phases": [ + {"phase": "recon", "success": True, "data": {}}, + {"phase": "report", "success": True, "data": {}}, + ], + "kb": {"report.markdown_path": str(out / f"report_{sid}.md")} if md else {}, + } + (out / f"session_{sid}.json").write_text(json.dumps(data)) + if md: + (out / f"report_{sid}.md").write_text(md) + + +@pytest.fixture +def client(tmp_path): + cfg = CyberAIConfig() + cfg.output_dir = tmp_path + return TestClient(create_app(cfg)) + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_dashboard_served(client): + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + + +def test_list_sessions_empty(client): + r = client.get("/api/sessions") + assert r.status_code == 200 + assert r.json() == {"sessions": [], "count": 0} + + +def test_list_and_get_session(client, tmp_path): + _write_session(tmp_path, "aaaa1111") + r = client.get("/api/sessions") + body = r.json() + assert body["count"] == 1 + assert body["sessions"][0]["session_id"] == "aaaa1111" + assert body["sessions"][0]["findings"] == 1 + + r2 = client.get("/api/sessions/aaaa1111") + assert r2.json()["target"] == "example.com" + + +def test_get_session_missing(client): + r = client.get("/api/sessions/nope") + assert "error" in r.json() + + +def test_report_present(client, tmp_path): + _write_session(tmp_path, "bbbb2222", md="# Report\nfindings here") + r = client.get("/api/sessions/bbbb2222/report") + assert r.status_code == 200 + assert "# Report" in r.text + + +def test_report_absent_for_dryrun(client, tmp_path): + _write_session(tmp_path, "cccc3333") # no md + r = client.get("/api/sessions/cccc3333/report") + assert r.status_code == 404 + + +def test_sse_stream_terminal(client, tmp_path): + _write_session(tmp_path, "dddd4444", state="completed") + with client.stream("GET", "/api/sessions/dddd4444/stream") as r: + assert r.status_code == 200 + chunks = "".join(r.iter_text()) + assert "event: phase" in chunks + assert "event: done" in chunks