From 31b04a2ec86842686c434e65c0488dcc64d8003b Mon Sep 17 00:00:00 2001 From: rainyroot Date: Sat, 28 Mar 2026 16:13:49 +0100 Subject: [PATCH 01/20] =?UTF-8?q?feat(4.1-4.9):=20Phase=204=20=E2=80=94=20?= =?UTF-8?q?AI=20&=20Automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI Service (4.1, 4.9) - Provider abstraction: Ollama (local), OpenAI, Anthropic via httpx - Data sanitization: IPs/hostnames replaced with [IP-N]/[HOST-N] tokens before cloud API calls, restored after — zero PII leakage - ai_analyses DB model + Alembic migration (ai_analyses, chains, chain_runs) - GET/PATCH /api/ai/settings — provider, model, API key, sanitize toggle - POST /api/ai/test — connection smoke test - POST /api/ai/analyse — scan | finding | host | project context types - GET /api/ai/analyses — stored analysis history per project AI Features (4.2, 4.3, 4.4) - Scans page: AI Analyse panel on completed scans - Findings page: Analyse Finding + False Positive Check buttons in detail panel - AI Page (/ai): Settings / Analyse / History tabs AI Report Generation (4.7) - Reports page: AI Report Generation card — executive summary + tech details - POST /api/ai/analyse with context_type=project generates full pentest report Chain Engine (4.5, 4.6) - Sequential workflow runner: scan → scan → notify steps - chains + chain_runs DB models - POST/GET/PATCH/DELETE /api/chains - POST /api/chains/{id}/run — background execution with step-level results - GET /api/chains/templates — 4 built-in chains: Quick Recon (Nmap → Gobuster) Full Web Audit (Nikto → Nuclei → SQLMap) Network Sweep (Nmap → SearchSploit) Credential Attack (Hydra SSH + FTP) - POST /api/chains/from-template — instantiate chain from template - Chains Page (/chains): Templates tab + My Chains tab with run history Obsidian Auto-Sync (4.8) - ObsidianSyncService writes Markdown directly to vault directory on disk - Hook in ScanService._persist_result — auto-syncs after each scan if enabled - POST /api/projects/{id}/export/obsidian/sync-to-disk — manual full sync - Settings: obsidian_vault_path + obsidian_auto_sync toggle (with PRO badge) Frontend - New /ai and /chains routes + sidebar nav items (BrainCircuit, Workflow icons) - AIAnalysis, AISettings, Chain, ChainRun types added to types/index.ts - PageId extended with 'ai' | 'chains' --- backend/=10.0.0 | 3 + backend/api/routes/ai.py | 382 +++++++++++ backend/api/routes/app_settings.py | 23 + backend/api/routes/chains.py | 272 ++++++++ backend/api/routes/export.py | 44 ++ backend/main.py | 4 +- .../versions/a1b2c3d4e5f6_phase4_ai_chains.py | 94 +++ backend/models/__init__.py | 5 + backend/models/ai_analysis.py | 34 + backend/models/chain.py | 57 ++ backend/requirements.txt | 1 + backend/services/ai_service.py | 385 +++++++++++ backend/services/chain_service.py | 256 ++++++++ backend/services/obsidian_sync_service.py | 277 ++++++++ backend/services/scan_service.py | 12 + frontend/src/App.tsx | 4 + frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/pages/AI/index.tsx | 602 ++++++++++++++++++ frontend/src/pages/Chains/index.tsx | 458 +++++++++++++ frontend/src/pages/Findings/index.tsx | 73 ++- frontend/src/pages/Reports/index.tsx | 135 +++- frontend/src/pages/Scans/index.tsx | 81 +++ frontend/src/pages/Settings/index.tsx | 47 +- frontend/src/types/index.ts | 72 +++ 24 files changed, 3318 insertions(+), 7 deletions(-) create mode 100644 backend/=10.0.0 create mode 100644 backend/api/routes/ai.py create mode 100644 backend/api/routes/chains.py create mode 100644 backend/migrations/versions/a1b2c3d4e5f6_phase4_ai_chains.py create mode 100644 backend/models/ai_analysis.py create mode 100644 backend/models/chain.py create mode 100644 backend/services/ai_service.py create mode 100644 backend/services/chain_service.py create mode 100644 backend/services/obsidian_sync_service.py create mode 100644 frontend/src/pages/AI/index.tsx create mode 100644 frontend/src/pages/Chains/index.tsx diff --git a/backend/=10.0.0 b/backend/=10.0.0 new file mode 100644 index 0000000..dc551d0 --- /dev/null +++ b/backend/=10.0.0 @@ -0,0 +1,3 @@ + +[notice] A new release of pip is available: 25.3 -> 26.0.1 +[notice] To update, run: /home/rainyroot/VSCProjects/Zeronyx/backend/.venv/bin/python3 -m pip install --upgrade pip diff --git a/backend/api/routes/ai.py b/backend/api/routes/ai.py new file mode 100644 index 0000000..3ebb7b7 --- /dev/null +++ b/backend/api/routes/ai.py @@ -0,0 +1,382 @@ +"""AI Analysis endpoints — Phase 4.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models.ai_analysis import AIAnalysis +from backend.models.base import new_uuid +from backend.models.finding import Finding +from backend.models.host import Host +from backend.models.port import Port +from backend.models.scan import Scan, ScanResult +from backend.models.project import Project +from backend.services.ai_service import AIService +from backend.api.routes.app_settings import _load_user_settings + +logger = logging.getLogger("zeronyx.ai") + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_ai_service() -> AIService: + settings = _load_user_settings() + ai_cfg = settings.get("ai", {}) + return AIService(ai_cfg) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +PromptType = Literal["analyse", "false_positive", "exploits", "report"] + + +class AnalyseRequest(BaseModel): + project_id: str + context_type: Literal["scan", "finding", "host", "project"] + context_id: str | None = None + prompt_type: PromptType = "analyse" + + +class AnalysisResponse(BaseModel): + id: str + project_id: str + context_type: str + context_id: str | None + provider: str | None + model: str | None + prompt_type: str | None + response: str | None + tokens_used: int | None + sanitized: bool + created_at: str + + +class AISettingsSchema(BaseModel): + provider: str = "ollama" + ollama_url: str = "http://localhost:11434" + ollama_model: str = "llama3.2" + openai_api_key: str = "" + openai_model: str = "gpt-4o" + anthropic_api_key: str = "" + anthropic_model: str = "claude-opus-4-6" + sanitize_before_cloud: bool = True + enabled: bool = True + + +# --------------------------------------------------------------------------- +# AI Settings endpoints +# --------------------------------------------------------------------------- + +@router.get("/settings", response_model=AISettingsSchema) +def get_ai_settings(): + """Return AI provider settings.""" + s = _load_user_settings() + ai = s.get("ai", {}) + return AISettingsSchema( + provider=ai.get("provider", "ollama"), + ollama_url=ai.get("ollama_url", "http://localhost:11434"), + ollama_model=ai.get("ollama_model", "llama3.2"), + openai_api_key=ai.get("openai_api_key", ""), + openai_model=ai.get("openai_model", "gpt-4o"), + anthropic_api_key=ai.get("anthropic_api_key", ""), + anthropic_model=ai.get("anthropic_model", "claude-opus-4-6"), + sanitize_before_cloud=ai.get("sanitize_before_cloud", True), + enabled=ai.get("enabled", True), + ) + + +@router.patch("/settings", response_model=AISettingsSchema) +def update_ai_settings(payload: AISettingsSchema): + """Update AI provider settings.""" + from backend.api.routes.app_settings import _save_user_settings + s = _load_user_settings() + s["ai"] = payload.model_dump() + _save_user_settings(s) + return payload + + +# --------------------------------------------------------------------------- +# Connection test +# --------------------------------------------------------------------------- + +class TestConnectionResponse(BaseModel): + success: bool + provider: str + model: str + message: str + + +@router.post("/test", response_model=TestConnectionResponse) +async def test_ai_connection(): + """Ping the configured AI provider with a minimal request.""" + svc = _get_ai_service() + try: + resp, _tokens, _san = await svc.analyse_scan({ + "tool": "test", + "target": "test", + "findings": [], + "hosts": [], + "ports": [], + }) + return TestConnectionResponse( + success=True, + provider=svc.provider, + model=svc.get_model_name(), + message=f"Connected. Response preview: {resp[:80]}...", + ) + except Exception as exc: + return TestConnectionResponse( + success=False, + provider=svc.provider, + model=svc.get_model_name(), + message=str(exc), + ) + + +# --------------------------------------------------------------------------- +# Core analysis endpoint +# --------------------------------------------------------------------------- + +def _row_to_response(row: AIAnalysis) -> AnalysisResponse: + return AnalysisResponse( + id=row.id, + project_id=row.project_id, + context_type=row.context_type, + context_id=row.context_id, + provider=row.provider, + model=row.model, + prompt_type=row.prompt_type, + response=row.response, + tokens_used=row.tokens_used, + sanitized=row.sanitized, + created_at=row.created_at.isoformat() if hasattr(row.created_at, "isoformat") else str(row.created_at), + ) + + +@router.post("/analyse", response_model=AnalysisResponse) +async def run_analysis(payload: AnalyseRequest, db: Session = Depends(get_db)): + """Run an AI analysis. + + The endpoint builds the context from the DB (scan data, finding, host), + calls the configured AI provider, persists the result, and returns it. + """ + svc = _get_ai_service() + + # ---- Verify project exists ---- + project = db.get(Project, payload.project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # ---- Build context data from DB ---- + ai_settings = _load_user_settings().get("ai", {}) + if not ai_settings.get("enabled", True): + raise HTTPException(status_code=400, detail="AI is disabled in settings") + + try: + if payload.context_type == "scan": + response, tokens, sanitized = await _analyse_scan(svc, payload.context_id, db) + elif payload.context_type == "finding": + response, tokens, sanitized = await _analyse_finding( + svc, payload.context_id, payload.prompt_type, db + ) + elif payload.context_type == "host": + response, tokens, sanitized = await _analyse_host(svc, payload.context_id, db) + elif payload.context_type == "project": + response, tokens, sanitized = await _analyse_project(svc, payload.project_id, db) + else: + raise HTTPException(status_code=400, detail=f"Unknown context_type: {payload.context_type}") + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) + + # ---- Persist ---- + row = AIAnalysis( + id=new_uuid(), + project_id=payload.project_id, + context_type=payload.context_type, + context_id=payload.context_id, + provider=svc.provider, + model=svc.get_model_name(), + prompt_type=payload.prompt_type, + response=response, + tokens_used=tokens, + sanitized=sanitized, + ) + db.add(row) + db.commit() + db.refresh(row) + return _row_to_response(row) + + +# --------------------------------------------------------------------------- +# Context builders +# --------------------------------------------------------------------------- + +async def _analyse_scan(svc: AIService, scan_id: str | None, db: Session): + if not scan_id: + raise HTTPException(status_code=400, detail="context_id (scan_id) required") + scan = db.get(Scan, scan_id) + if not scan: + raise HTTPException(status_code=404, detail="Scan not found") + + # Gather ports & findings linked to this scan + findings = db.query(Finding).filter(Finding.scan_id == scan_id).all() + ports = db.query(Port).filter(Port.scan_id == scan_id).all() + hosts = db.query(Host).filter(Host.project_id == scan.project_id).all() + + scan_data = { + "tool": scan.tool, + "target": scan.target.value if scan.target else "", + "findings": [ + {"title": f.title, "severity": f.severity, "cve": f.cve, "description": f.description} + for f in findings + ], + "hosts": [ + {"ip": h.ip, "hostname": h.hostname, "os": h.os} + for h in hosts[:30] + ], + "ports": [ + {"number": p.number, "protocol": p.protocol, "service": p.service, "version": p.version} + for p in ports[:50] + ], + } + return await svc.analyse_scan(scan_data) + + +async def _analyse_finding(svc: AIService, finding_id: str | None, prompt_type: str, db: Session): + if not finding_id: + raise HTTPException(status_code=400, detail="context_id (finding_id) required") + finding = db.get(Finding, finding_id) + if not finding: + raise HTTPException(status_code=404, detail="Finding not found") + + finding_data = { + "title": finding.title, + "severity": finding.severity, + "cve": finding.cve, + "description": finding.description, + "tool_source": finding.tool_source, + "remediation": finding.remediation, + } + + if prompt_type == "false_positive": + return await svc.analyse_finding(finding_data) + return await svc.analyse_finding(finding_data) + + +async def _analyse_host(svc: AIService, host_id: str | None, db: Session): + if not host_id: + raise HTTPException(status_code=400, detail="context_id (host_id) required") + host = db.get(Host, host_id) + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + ports = db.query(Port).filter(Port.host_id == host_id).all() + findings = db.query(Finding).filter(Finding.host_id == host_id).all() + + host_data = { + "ip": host.ip, + "os": host.os, + "ports": [ + {"number": p.number, "protocol": p.protocol, "service": p.service, "version": p.version} + for p in ports + ], + "findings": [ + {"title": f.title, "severity": f.severity, "cve": f.cve} + for f in findings + ], + } + return await svc.suggest_exploits(host_data) + + +async def _analyse_project(svc: AIService, project_id: str, db: Session): + project = db.get(Project, project_id) + hosts = db.query(Host).filter(Host.project_id == project_id).all() + findings = db.query(Finding).filter(Finding.project_id == project_id).all() + + sev_counts: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + for f in findings: + sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1 + + # Top findings by severity + sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + top = sorted(findings, key=lambda f: sev_order.get(f.severity, 5))[:20] + + # Build host IP lookup + host_map = {h.id: h.ip for h in hosts} + + project_data = { + "name": project.name if project else project_id, + "total_findings": len(findings), + "critical": sev_counts["critical"], + "high": sev_counts["high"], + "medium": sev_counts["medium"], + "low": sev_counts["low"], + "hosts": [{"ip": h.ip} for h in hosts], + "top_findings": [ + { + "title": f.title, + "severity": f.severity, + "host": host_map.get(f.host_id or "", "?"), + "description": (f.description or "")[:200], + } + for f in top + ], + } + return await svc.generate_report(project_data) + + +# --------------------------------------------------------------------------- +# List stored analyses +# --------------------------------------------------------------------------- + +@router.get("/analyses", response_model=list[AnalysisResponse]) +def list_analyses( + project_id: str, + context_id: str | None = None, + context_type: str | None = None, + limit: int = 50, + db: Session = Depends(get_db), +): + """Return stored AI analyses for a project.""" + q = db.query(AIAnalysis).filter(AIAnalysis.project_id == project_id) + if context_id: + q = q.filter(AIAnalysis.context_id == context_id) + if context_type: + q = q.filter(AIAnalysis.context_type == context_type) + rows = q.order_by(AIAnalysis.created_at.desc()).limit(limit).all() + return [_row_to_response(r) for r in rows] + + +@router.get("/analyses/{analysis_id}", response_model=AnalysisResponse) +def get_analysis(analysis_id: str, db: Session = Depends(get_db)): + row = db.get(AIAnalysis, analysis_id) + if not row: + raise HTTPException(status_code=404, detail="Analysis not found") + return _row_to_response(row) + + +@router.delete("/analyses/{analysis_id}", status_code=204) +def delete_analysis(analysis_id: str, db: Session = Depends(get_db)): + row = db.get(AIAnalysis, analysis_id) + if not row: + raise HTTPException(status_code=404, detail="Analysis not found") + db.delete(row) + db.commit() diff --git a/backend/api/routes/app_settings.py b/backend/api/routes/app_settings.py index 3a77ab1..527b5fa 100644 --- a/backend/api/routes/app_settings.py +++ b/backend/api/routes/app_settings.py @@ -28,6 +28,21 @@ "tool_paths": {}, # tool_name → custom binary path "scan_timeout": 600, # seconds "data_dir": str(settings.data_dir), + # AI provider settings + "ai": { + "provider": "ollama", + "ollama_url": "http://localhost:11434", + "ollama_model": "llama3.2", + "openai_api_key": "", + "openai_model": "gpt-4o", + "anthropic_api_key": "", + "anthropic_model": "claude-opus-4-6", + "sanitize_before_cloud": True, + "enabled": True, + }, + # Obsidian auto-sync settings + "obsidian_vault_path": "", + "obsidian_auto_sync": False, } @@ -63,12 +78,16 @@ class UserSettingsResponse(BaseModel): data_dir: str version: str = "0.1.0" env: str + obsidian_vault_path: str = "" + obsidian_auto_sync: bool = False class UserSettingsPatch(BaseModel): theme: str | None = None tool_paths: dict[str, str] | None = None scan_timeout: int | None = None + obsidian_vault_path: str | None = None + obsidian_auto_sync: bool | None = None class ToolHealthEntry(BaseModel): @@ -92,6 +111,8 @@ def get_settings(): scan_timeout=data["scan_timeout"], data_dir=data["data_dir"], env=settings.env, + obsidian_vault_path=data.get("obsidian_vault_path", ""), + obsidian_auto_sync=data.get("obsidian_auto_sync", False), ) @@ -115,6 +136,8 @@ def update_settings(payload: UserSettingsPatch): scan_timeout=data["scan_timeout"], data_dir=data["data_dir"], env=settings.env, + obsidian_vault_path=data.get("obsidian_vault_path", ""), + obsidian_auto_sync=data.get("obsidian_auto_sync", False), ) diff --git a/backend/api/routes/chains.py b/backend/api/routes/chains.py new file mode 100644 index 0000000..5a64a1d --- /dev/null +++ b/backend/api/routes/chains.py @@ -0,0 +1,272 @@ +"""Chain Engine REST API — Phase 4.5/4.6.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models.base import new_uuid +from backend.models.chain import Chain, ChainRun +from backend.services.chain_service import ChainEngine, STANDARD_CHAINS + +logger = logging.getLogger("zeronyx.chains") + +router = APIRouter(prefix="/chains", tags=["chains"]) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class ChainStepSchema(BaseModel): + id: str + type: str = "scan" + tool: str | None = None + label: str | None = None + config: dict[str, Any] = {} + depends_on: str | None = None + condition: str | None = None + continue_on_error: bool = False + + +class ChainCreate(BaseModel): + project_id: str + name: str + description: str | None = None + steps: list[ChainStepSchema] = [] + trigger_on: str = "manual" + + +class ChainUpdate(BaseModel): + name: str | None = None + description: str | None = None + steps: list[ChainStepSchema] | None = None + trigger_on: str | None = None + enabled: bool | None = None + + +class ChainResponse(BaseModel): + id: str + project_id: str + name: str + description: str | None + steps: list[dict] + trigger_on: str + enabled: bool + last_run: str | None + last_status: str | None + created_at: str + + +class ChainRunResponse(BaseModel): + id: str + chain_id: str + project_id: str + status: str + step_results: dict[str, Any] + error: str | None + started_at: str | None + finished_at: str | None + created_at: str + + +class RunChainRequest(BaseModel): + target_id: str | None = None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _chain_to_resp(c: Chain) -> ChainResponse: + return ChainResponse( + id=c.id, + project_id=c.project_id, + name=c.name, + description=c.description, + steps=json.loads(c.steps or "[]"), + trigger_on=c.trigger_on, + enabled=c.enabled, + last_run=c.last_run, + last_status=c.last_status, + created_at=c.created_at.isoformat() if hasattr(c.created_at, "isoformat") else str(c.created_at), + ) + + +def _run_to_resp(r: ChainRun) -> ChainRunResponse: + return ChainRunResponse( + id=r.id, + chain_id=r.chain_id, + project_id=r.project_id, + status=r.status, + step_results=json.loads(r.step_results or "{}"), + error=r.error, + started_at=r.started_at, + finished_at=r.finished_at, + created_at=r.created_at.isoformat() if hasattr(r.created_at, "isoformat") else str(r.created_at), + ) + + +# --------------------------------------------------------------------------- +# Standard chains endpoint (read-only templates) +# --------------------------------------------------------------------------- + +@router.get("/templates") +def list_templates(): + """Return built-in standard chain templates.""" + return {"templates": STANDARD_CHAINS} + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +@router.get("", response_model=list[ChainResponse]) +def list_chains(project_id: str, db: Session = Depends(get_db)): + rows = db.query(Chain).filter(Chain.project_id == project_id).order_by(Chain.created_at).all() + return [_chain_to_resp(r) for r in rows] + + +@router.post("", response_model=ChainResponse, status_code=201) +def create_chain(payload: ChainCreate, db: Session = Depends(get_db)): + chain = Chain( + id=new_uuid(), + project_id=payload.project_id, + name=payload.name, + description=payload.description, + steps=json.dumps([s.model_dump() for s in payload.steps]), + trigger_on=payload.trigger_on, + enabled=True, + ) + db.add(chain) + db.commit() + db.refresh(chain) + return _chain_to_resp(chain) + + +@router.post("/from-template", response_model=ChainResponse, status_code=201) +def create_from_template(project_id: str, template_name: str, db: Session = Depends(get_db)): + """Create a chain from a standard template.""" + tmpl = next((t for t in STANDARD_CHAINS if t["name"] == template_name), None) + if not tmpl: + raise HTTPException(status_code=404, detail=f"Template '{template_name}' not found") + + chain = Chain( + id=new_uuid(), + project_id=project_id, + name=tmpl["name"], + description=tmpl.get("description"), + steps=json.dumps(tmpl.get("steps", [])), + trigger_on=tmpl.get("trigger_on", "manual"), + enabled=True, + ) + db.add(chain) + db.commit() + db.refresh(chain) + return _chain_to_resp(chain) + + +@router.get("/{chain_id}", response_model=ChainResponse) +def get_chain(chain_id: str, db: Session = Depends(get_db)): + chain = db.get(Chain, chain_id) + if not chain: + raise HTTPException(status_code=404, detail="Chain not found") + return _chain_to_resp(chain) + + +@router.patch("/{chain_id}", response_model=ChainResponse) +def update_chain(chain_id: str, payload: ChainUpdate, db: Session = Depends(get_db)): + chain = db.get(Chain, chain_id) + if not chain: + raise HTTPException(status_code=404, detail="Chain not found") + + if payload.name is not None: + chain.name = payload.name + if payload.description is not None: + chain.description = payload.description + if payload.steps is not None: + chain.steps = json.dumps([s.model_dump() for s in payload.steps]) + if payload.trigger_on is not None: + chain.trigger_on = payload.trigger_on + if payload.enabled is not None: + chain.enabled = payload.enabled + + db.commit() + db.refresh(chain) + return _chain_to_resp(chain) + + +@router.delete("/{chain_id}", status_code=204) +def delete_chain(chain_id: str, db: Session = Depends(get_db)): + chain = db.get(Chain, chain_id) + if not chain: + raise HTTPException(status_code=404, detail="Chain not found") + db.delete(chain) + db.commit() + + +# --------------------------------------------------------------------------- +# Run a chain +# --------------------------------------------------------------------------- + +@router.post("/{chain_id}/run", response_model=ChainRunResponse, status_code=202) +async def run_chain( + chain_id: str, + payload: RunChainRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Trigger a chain execution (runs in background).""" + chain = db.get(Chain, chain_id) + if not chain: + raise HTTPException(status_code=404, detail="Chain not found") + + run_id = new_uuid() + # Create the run record immediately + run = ChainRun( + id=run_id, + chain_id=chain_id, + project_id=chain.project_id, + status="pending", + step_results="{}", + ) + db.add(run) + db.commit() + + async def _bg(): + engine = ChainEngine(db) + await engine.run(chain_id, chain.project_id, payload.target_id, run_id=run_id) + + background_tasks.add_task(_bg) + db.refresh(run) + return _run_to_resp(run) + + +# --------------------------------------------------------------------------- +# Chain run history +# --------------------------------------------------------------------------- + +@router.get("/{chain_id}/runs", response_model=list[ChainRunResponse]) +def list_chain_runs(chain_id: str, limit: int = 20, db: Session = Depends(get_db)): + rows = ( + db.query(ChainRun) + .filter(ChainRun.chain_id == chain_id) + .order_by(ChainRun.created_at.desc()) + .limit(limit) + .all() + ) + return [_run_to_resp(r) for r in rows] + + +@router.get("/{chain_id}/runs/{run_id}", response_model=ChainRunResponse) +def get_chain_run(chain_id: str, run_id: str, db: Session = Depends(get_db)): + run = db.get(ChainRun, run_id) + if not run or run.chain_id != chain_id: + raise HTTPException(status_code=404, detail="Run not found") + return _run_to_resp(run) diff --git a/backend/api/routes/export.py b/backend/api/routes/export.py index aeba77c..3d15098 100644 --- a/backend/api/routes/export.py +++ b/backend/api/routes/export.py @@ -345,3 +345,47 @@ def export_obsidian(project_id: str, db: Session = Depends(get_db)): file_count=len(files), files=files, ) + + +# --------------------------------------------------------------------------- +# Auto-Sync to disk endpoint (4.8) +# --------------------------------------------------------------------------- + +class SyncToDiskRequest(BaseModel): + vault_path: str | None = None # override; falls back to user settings + + +class SyncToDiskResponse(BaseModel): + vault_path: str + written: int + errors: int + + +@router.post("/{project_id}/export/obsidian/sync-to-disk", response_model=SyncToDiskResponse) +def sync_obsidian_to_disk( + project_id: str, + payload: SyncToDiskRequest, + db: Session = Depends(get_db), +): + """Write Obsidian Markdown notes directly into a vault directory on disk.""" + from backend.api.routes.app_settings import _load_user_settings + from backend.services.obsidian_sync_service import ObsidianSyncService + + # Resolve vault path: request body overrides settings + user_settings = _load_user_settings() + vault_path = payload.vault_path or user_settings.get("obsidian_vault_path", "") + if not vault_path: + raise HTTPException( + status_code=400, + detail="No vault path configured. Set obsidian_vault_path in settings or pass vault_path in the request body.", + ) + + svc = ObsidianSyncService(vault_path) + try: + result = svc.sync_project(project_id, db) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=f"Cannot write to vault: {exc}") + + return SyncToDiskResponse(vault_path=vault_path, **result) diff --git a/backend/main.py b/backend/main.py index efa1112..ffb5204 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from backend.config import settings from backend import models # noqa: F401 — registers all models with Base.metadata -from backend.api.routes import projects, targets, scans, findings, app_settings, export, credentials, proxy, metasploit, shodan, censys, hosts +from backend.api.routes import projects, targets, scans, findings, app_settings, export, credentials, proxy, metasploit, shodan, censys, hosts, ai, chains from backend.api.websocket import scan_stream logging.basicConfig( @@ -121,6 +121,8 @@ async def health(): app.include_router(shodan.router, prefix="/api") app.include_router(censys.router, prefix="/api") app.include_router(hosts.router, prefix="/api") +app.include_router(ai.router, prefix="/api") +app.include_router(chains.router, prefix="/api") # WebSocket routers app.include_router(scan_stream.router) diff --git a/backend/migrations/versions/a1b2c3d4e5f6_phase4_ai_chains.py b/backend/migrations/versions/a1b2c3d4e5f6_phase4_ai_chains.py new file mode 100644 index 0000000..57a6ca3 --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f6_phase4_ai_chains.py @@ -0,0 +1,94 @@ +"""Phase 4: add ai_analyses, chains, chain_runs tables + +Revision ID: a1b2c3d4e5f6 +Revises: f4a91c3b5e72 +Create Date: 2026-03-28 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = 'f4a91c3b5e72' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- ai_analyses --- + op.create_table( + 'ai_analyses', + sa.Column('id', sa.String(), nullable=False), + sa.Column('project_id', sa.String(), nullable=False), + sa.Column('context_type', sa.String(length=32), nullable=False), + sa.Column('context_id', sa.String(), nullable=True), + sa.Column('provider', sa.String(length=32), nullable=True), + sa.Column('model', sa.String(length=128), nullable=True), + sa.Column('prompt_type', sa.String(length=64), nullable=True), + sa.Column('response', sa.Text(), nullable=True), + sa.Column('tokens_used', sa.Integer(), nullable=True), + sa.Column('sanitized', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('ai_analyses', schema=None) as batch_op: + batch_op.create_index('ix_ai_analyses_project_id', ['project_id'], unique=False) + batch_op.create_index('ix_ai_analyses_context_id', ['context_id'], unique=False) + + # --- chains --- + op.create_table( + 'chains', + sa.Column('id', sa.String(), nullable=False), + sa.Column('project_id', sa.String(), nullable=False), + sa.Column('name', sa.String(length=256), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('steps', sa.Text(), nullable=False, server_default='[]'), + sa.Column('trigger_on', sa.String(length=32), nullable=False, server_default='manual'), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('last_run', sa.String(length=64), nullable=True), + sa.Column('last_status', sa.String(length=32), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('chains', schema=None) as batch_op: + batch_op.create_index('ix_chains_project_id', ['project_id'], unique=False) + + # --- chain_runs --- + op.create_table( + 'chain_runs', + sa.Column('id', sa.String(), nullable=False), + sa.Column('chain_id', sa.String(), nullable=False), + sa.Column('project_id', sa.String(), nullable=False), + sa.Column('status', sa.String(length=32), nullable=False, server_default='running'), + sa.Column('step_results', sa.Text(), nullable=False, server_default='{}'), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('started_at', sa.String(length=64), nullable=True), + sa.Column('finished_at', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('chain_runs', schema=None) as batch_op: + batch_op.create_index('ix_chain_runs_chain_id', ['chain_id'], unique=False) + batch_op.create_index('ix_chain_runs_project_id', ['project_id'], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table('chain_runs', schema=None) as batch_op: + batch_op.drop_index('ix_chain_runs_project_id') + batch_op.drop_index('ix_chain_runs_chain_id') + op.drop_table('chain_runs') + + with op.batch_alter_table('chains', schema=None) as batch_op: + batch_op.drop_index('ix_chains_project_id') + op.drop_table('chains') + + with op.batch_alter_table('ai_analyses', schema=None) as batch_op: + batch_op.drop_index('ix_ai_analyses_context_id') + batch_op.drop_index('ix_ai_analyses_project_id') + op.drop_table('ai_analyses') diff --git a/backend/models/__init__.py b/backend/models/__init__.py index d4e2bc4..fc7db3b 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -10,6 +10,8 @@ from backend.models.credential import Credential from backend.models.note import Note from backend.models.proxy_request import ProxyRequest +from backend.models.ai_analysis import AIAnalysis +from backend.models.chain import Chain, ChainRun __all__ = [ "Base", @@ -24,4 +26,7 @@ "Credential", "Note", "ProxyRequest", + "AIAnalysis", + "Chain", + "ChainRun", ] diff --git a/backend/models/ai_analysis.py b/backend/models/ai_analysis.py new file mode 100644 index 0000000..1cdc44b --- /dev/null +++ b/backend/models/ai_analysis.py @@ -0,0 +1,34 @@ +from sqlalchemy import Boolean, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import Base, TimestampMixin, new_uuid + + +class AIAnalysis(Base, TimestampMixin): + """Stored result of an AI analysis request.""" + + __tablename__ = "ai_analyses" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + + # What was analysed: scan | finding | project | report + context_type: Mapped[str] = mapped_column(String(32), nullable=False) + # ID of the scan / finding / project + context_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True) + + # Which AI was used + provider: Mapped[str | None] = mapped_column(String(32), nullable=True) # ollama | openai | anthropic + model: Mapped[str | None] = mapped_column(String(128), nullable=True) + + # Type of analysis performed + prompt_type: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) # analyse | false_positive | exploits | report + + # The generated response (Markdown) + response: Mapped[str | None] = mapped_column(Text, nullable=True) + tokens_used: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Whether PII/IPs were anonymised before sending to the cloud + sanitized: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/backend/models/chain.py b/backend/models/chain.py new file mode 100644 index 0000000..c089a34 --- /dev/null +++ b/backend/models/chain.py @@ -0,0 +1,57 @@ +from sqlalchemy import Boolean, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import Base, TimestampMixin, new_uuid + + +class Chain(Base, TimestampMixin): + """An automated workflow definition (Pro feature). + + A chain is a sequence of steps executed in order. Each step is stored as + JSON inside ``steps``:: + + [ + {"id": "...", "type": "scan", "tool": "nmap", "config": {...}}, + {"id": "...", "type": "scan", "tool": "gobuster", "config": {...}, "depends_on": "prev_result"}, + {"id": "...", "type": "notify", "message": "Chain complete"}, + ] + """ + + __tablename__ = "chains" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid) + project_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(256), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + # JSON array of ChainStep dicts + steps: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + + # manual | on_scan_complete | scheduled + trigger_on: Mapped[str] = mapped_column(String(32), default="manual", nullable=False) + + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Filled after each run + last_run: Mapped[str | None] = mapped_column(String(64), nullable=True) + last_status: Mapped[str | None] = mapped_column(String(32), nullable=True) # success | failed | running + + +class ChainRun(Base, TimestampMixin): + """Execution record for a single chain invocation.""" + + __tablename__ = "chain_runs" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=new_uuid) + chain_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + project_id: Mapped[str] = mapped_column(String, nullable=False, index=True) + + # running | completed | failed | cancelled + status: Mapped[str] = mapped_column(String(32), default="running", nullable=False) + + # JSON: per-step results {step_id: {status, output, scan_id?}} + step_results: Mapped[str] = mapped_column(Text, default="{}", nullable=False) + + error: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[str | None] = mapped_column(String(64), nullable=True) + finished_at: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0680ffd..9017929 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ mitmproxy>=10.0.0 pymetasploit3>=1.0 shodan>=1.31.0 censys>=2.2.0 +httpx>=0.27.0 diff --git a/backend/services/ai_service.py b/backend/services/ai_service.py new file mode 100644 index 0000000..1339ab7 --- /dev/null +++ b/backend/services/ai_service.py @@ -0,0 +1,385 @@ +"""AI Service +============ +Provider-agnostic AI analysis for ZeroNyx. + +Supported providers +------------------- +* **ollama** — Local inference via Ollama HTTP API (default, no keys needed). +* **openai** — OpenAI Chat Completions API (requires api_key). +* **anthropic** — Anthropic Messages API (requires api_key). + +Data Sanitization (4.9) +----------------------- +When the user has ``sanitize_before_cloud`` enabled, any data sent to +cloud providers (openai / anthropic) has IPs, hostnames, and common PII +replaced with stable tokens (``[HOST-1]``, ``[IP-1]``, etc.) before +transmission. The reverse mapping is stored per-call so the raw response +can optionally be un-sanitized for display. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import httpx + +logger = logging.getLogger("zeronyx.ai_service") + +# --------------------------------------------------------------------------- +# IP / hostname sanitisation helpers (4.9) +# --------------------------------------------------------------------------- + +_IP_RE = re.compile( + r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b" +) +_HOSTNAME_RE = re.compile( + r"\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b" +) + + +def sanitize_text(text: str) -> tuple[str, dict[str, str]]: + """Replace IPs and hostnames with stable tokens. + + Returns ``(sanitized_text, reverse_map)`` where ``reverse_map`` maps + each token back to its original value. + """ + reverse: dict[str, str] = {} + token_map: dict[str, str] = {} # original → token + counter = {"ip": 0, "host": 0} + + def _replace_ip(m: re.Match) -> str: + orig = m.group(0) + if orig not in token_map: + counter["ip"] += 1 + token = f"[IP-{counter['ip']}]" + token_map[orig] = token + reverse[token] = orig + return token_map[orig] + + def _replace_host(m: re.Match) -> str: + orig = m.group(0) + if orig in token_map: + return token_map[orig] + counter["host"] += 1 + token = f"[HOST-{counter['host']}]" + token_map[orig] = token + reverse[token] = orig + return token + + text = _IP_RE.sub(_replace_ip, text) + text = _HOSTNAME_RE.sub(_replace_host, text) + return text, reverse + + +def desanitize_text(text: str, reverse_map: dict[str, str]) -> str: + """Restore original IPs / hostnames in AI response text.""" + for token, original in reverse_map.items(): + text = text.replace(token, original) + return text + + +# --------------------------------------------------------------------------- +# Prompt templates +# --------------------------------------------------------------------------- + +_SYSTEM_SECURITY = ( + "You are an expert penetration tester and security analyst. " + "Respond concisely and technically. Use Markdown for formatting. " + "Focus on actionable findings." +) + + +def _build_scan_prompt(scan_data: dict) -> str: + tool = scan_data.get("tool", "unknown") + target = scan_data.get("target", "unknown") + findings = scan_data.get("findings", []) + hosts = scan_data.get("hosts", []) + ports = scan_data.get("ports", []) + + lines = [ + f"## Scan Analysis Request", + f"**Tool:** {tool} **Target:** {target}", + "", + f"### Discovered Hosts ({len(hosts)})", + ] + for h in hosts[:20]: + lines.append(f"- {h.get('ip','?')} ({h.get('hostname','')}) OS: {h.get('os','unknown')}") + + lines += ["", f"### Open Ports ({len(ports)})"] + for p in ports[:30]: + lines.append(f"- {p.get('number','?')}/{p.get('protocol','tcp')} — {p.get('service','?')} {p.get('version','')}") + + lines += ["", f"### Findings ({len(findings)})"] + for f in findings[:20]: + lines.append( + f"- [{f.get('severity','?').upper()}] {f.get('title','?')} " + f"(CVE: {f.get('cve') or 'n/a'})" + ) + + lines += [ + "", + "---", + "Please provide:", + "1. **Summary** — What is the attack surface?", + "2. **Key Risks** — Top 3 critical issues to address first.", + "3. **Recommended Next Steps** — Which tools / techniques to use next.", + "4. **Quick Wins** — Low-effort, high-impact actions.", + ] + return "\n".join(lines) + + +def _build_finding_prompt(finding: dict) -> str: + return ( + f"## Finding Evaluation\n\n" + f"**Title:** {finding.get('title','?')}\n" + f"**Severity:** {finding.get('severity','?')}\n" + f"**Tool Source:** {finding.get('tool_source','?')}\n" + f"**CVE:** {finding.get('cve') or 'n/a'}\n" + f"**Description:**\n{finding.get('description','n/a')}\n\n" + "---\n" + "Evaluate this finding:\n" + "1. **Verdict** — Is this likely a true positive, false positive, or needs manual verification?\n" + "2. **Reasoning** — Explain your confidence level.\n" + "3. **Remediation** — Concise fix recommendation.\n" + "4. **References** — Relevant CVEs, CWEs, or documentation links." + ) + + +def _build_exploits_prompt(host_data: dict) -> str: + ip = host_data.get("ip", "?") + os = host_data.get("os", "unknown") + ports = host_data.get("ports", []) + findings = host_data.get("findings", []) + + port_lines = "\n".join( + f"- {p.get('number','?')}/{p.get('protocol','tcp')} {p.get('service','?')} {p.get('version','')}" + for p in ports[:20] + ) + finding_lines = "\n".join( + f"- [{f.get('severity','?').upper()}] {f.get('title','?')} (CVE: {f.get('cve') or 'n/a'})" + for f in findings[:15] + ) + + return ( + f"## Exploit & Attack Path Recommendations\n\n" + f"**Host:** {ip} **OS:** {os}\n\n" + f"### Services\n{port_lines or 'None'}\n\n" + f"### Known Findings\n{finding_lines or 'None'}\n\n" + "---\n" + "Provide:\n" + "1. **Attack Paths** — Realistic exploitation chains for this host.\n" + "2. **Suggested Tools** — Which tools to use (searchsploit, metasploit, sqlmap, etc.).\n" + "3. **CVE Candidates** — Likely CVEs based on service versions.\n" + "4. **Post-Exploitation** — If access is gained, what to do next (privilege escalation, lateral movement).\n" + ) + + +def _build_report_prompt(project_data: dict) -> str: + name = project_data.get("name", "Unnamed Project") + total_findings = project_data.get("total_findings", 0) + critical = project_data.get("critical", 0) + high = project_data.get("high", 0) + medium = project_data.get("medium", 0) + low = project_data.get("low", 0) + hosts = project_data.get("hosts", []) + top_findings = project_data.get("top_findings", []) + + finding_lines = "\n".join( + f"- [{f.get('severity','?').upper()}] {f.get('title','?')} on {f.get('host','?')} — {f.get('description','')[:120]}" + for f in top_findings[:20] + ) + + return ( + f"## Penetration Test Report Generation\n\n" + f"**Project:** {name}\n" + f"**Scope:** {len(hosts)} hosts\n" + f"**Findings:** {total_findings} total — " + f"{critical} Critical, {high} High, {medium} Medium, {low} Low\n\n" + f"### Top Findings\n{finding_lines or 'No findings recorded.'}\n\n" + "---\n" + "Generate a professional penetration test report with:\n" + "1. **Executive Summary** (non-technical, 2-3 paragraphs for management)\n" + "2. **Risk Rating** — Overall risk posture (Critical/High/Medium/Low)\n" + "3. **Scope & Methodology** (brief)\n" + "4. **Key Findings** — Table: Finding | Severity | Affected Host | Status\n" + "5. **Technical Details** — Top 5 findings with full description and remediation\n" + "6. **Remediation Roadmap** — Prioritised action plan\n" + "7. **Conclusion**\n" + ) + + +# --------------------------------------------------------------------------- +# Provider implementations +# --------------------------------------------------------------------------- + +async def _call_ollama(prompt: str, system: str, settings: dict) -> tuple[str, int]: + url = settings.get("ollama_url", "http://localhost:11434") + model = settings.get("ollama_model", "llama3.2") + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "stream": False, + } + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post(f"{url}/api/chat", json=payload) + r.raise_for_status() + data = r.json() + content = data.get("message", {}).get("content", "") + tokens = ( + data.get("prompt_eval_count", 0) + data.get("eval_count", 0) + ) + return content, tokens + + +async def _call_openai(prompt: str, system: str, settings: dict) -> tuple[str, int]: + api_key = settings.get("openai_api_key", "") + model = settings.get("openai_model", "gpt-4o") + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 2048, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + "https://api.openai.com/v1/chat/completions", + json=payload, + headers=headers, + ) + r.raise_for_status() + data = r.json() + content = data["choices"][0]["message"]["content"] + tokens = data.get("usage", {}).get("total_tokens", 0) + return content, tokens + + +async def _call_anthropic(prompt: str, system: str, settings: dict) -> tuple[str, int]: + api_key = settings.get("anthropic_api_key", "") + model = settings.get("anthropic_model", "claude-opus-4-6") + payload = { + "model": model, + "max_tokens": 2048, + "system": system, + "messages": [{"role": "user", "content": prompt}], + } + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + } + async with httpx.AsyncClient(timeout=120.0) as client: + r = await client.post( + "https://api.anthropic.com/v1/messages", + json=payload, + headers=headers, + ) + r.raise_for_status() + data = r.json() + content = data["content"][0]["text"] + tokens = data.get("usage", {}).get("input_tokens", 0) + data.get("usage", {}).get("output_tokens", 0) + return content, tokens + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +class AIService: + """Stateless service — instantiate per-request, pass ai_settings dict.""" + + def __init__(self, ai_settings: dict) -> None: + self.cfg = ai_settings + + @property + def provider(self) -> str: + return self.cfg.get("provider", "ollama") + + @property + def _sanitize(self) -> bool: + return self.cfg.get("sanitize_before_cloud", True) and self.provider in ("openai", "anthropic") + + async def _call(self, prompt: str) -> tuple[str, int, bool, dict]: + """Send prompt to configured provider. + + Returns ``(response_text, tokens_used, was_sanitized, reverse_map)``. + """ + reverse_map: dict[str, str] = {} + sanitized = False + + if self._sanitize: + prompt, reverse_map = sanitize_text(prompt) + sanitized = True + + prov = self.provider + try: + if prov == "ollama": + resp, tokens = await _call_ollama(prompt, _SYSTEM_SECURITY, self.cfg) + elif prov == "openai": + resp, tokens = await _call_openai(prompt, _SYSTEM_SECURITY, self.cfg) + elif prov == "anthropic": + resp, tokens = await _call_anthropic(prompt, _SYSTEM_SECURITY, self.cfg) + else: + raise ValueError(f"Unknown AI provider: {prov}") + except httpx.HTTPStatusError as exc: + logger.error("AI provider %s returned HTTP %s: %s", prov, exc.response.status_code, exc.response.text) + raise + except httpx.ConnectError: + raise RuntimeError( + f"Cannot connect to {prov}. " + + ("Make sure Ollama is running." if prov == "ollama" else "Check your API key / network.") + ) + + if sanitized and reverse_map: + resp = desanitize_text(resp, reverse_map) + + return resp, tokens, sanitized, reverse_map + + # ------------------------------------------------------------------ + # High-level methods + # ------------------------------------------------------------------ + + async def analyse_scan(self, scan_data: dict) -> tuple[str, int, bool]: + prompt = _build_scan_prompt(scan_data) + resp, tokens, sanitized, _ = await self._call(prompt) + return resp, tokens, sanitized + + async def analyse_finding(self, finding: dict) -> tuple[str, int, bool]: + prompt = _build_finding_prompt(finding) + resp, tokens, sanitized, _ = await self._call(prompt) + return resp, tokens, sanitized + + async def suggest_exploits(self, host_data: dict) -> tuple[str, int, bool]: + prompt = _build_exploits_prompt(host_data) + resp, tokens, sanitized, _ = await self._call(prompt) + return resp, tokens, sanitized + + async def generate_report(self, project_data: dict) -> tuple[str, int, bool]: + prompt = _build_report_prompt(project_data) + resp, tokens, sanitized, _ = await self._call(prompt) + return resp, tokens, sanitized + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def get_model_name(self) -> str: + prov = self.provider + if prov == "ollama": + return self.cfg.get("ollama_model", "llama3.2") + if prov == "openai": + return self.cfg.get("openai_model", "gpt-4o") + if prov == "anthropic": + return self.cfg.get("anthropic_model", "claude-opus-4-6") + return "unknown" diff --git a/backend/services/chain_service.py b/backend/services/chain_service.py new file mode 100644 index 0000000..a521fb7 --- /dev/null +++ b/backend/services/chain_service.py @@ -0,0 +1,256 @@ +"""Chain Engine — Phase 4.5/4.6 + +A Chain is a sequential workflow of steps that the engine executes +one-by-one. Each step can: + +* ``scan`` — start a scan with a given tool + config +* ``wait`` — wait for a previous scan step to complete +* ``notify`` — log a message (future: Slack/webhook) + +Standard chains (4.6) are defined at the bottom of this module. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from backend.models.base import new_uuid +from backend.models.chain import Chain, ChainRun +from backend.models.scan import Scan + +logger = logging.getLogger("zeronyx.chain_engine") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +# --------------------------------------------------------------------------- +# Standard Chain Definitions (4.6) +# --------------------------------------------------------------------------- + +STANDARD_CHAINS: list[dict] = [ + { + "name": "Quick Recon", + "description": "Fast network sweep → port scan → web directory brute-force on any HTTP ports found.", + "trigger_on": "manual", + "steps": [ + { + "id": "nmap_quick", + "type": "scan", + "tool": "nmap", + "label": "Quick Nmap Sweep", + "config": {"flags": "-T4 -F --open"}, + }, + { + "id": "gobuster_web", + "type": "scan", + "tool": "gobuster", + "label": "Directory Brute-Force", + "config": {"mode": "dir", "wordlist": "/usr/share/wordlists/dirb/common.txt"}, + "depends_on": "nmap_quick", + "condition": "has_http_port", + }, + ], + }, + { + "name": "Full Web Audit", + "description": "Comprehensive web application audit: Nikto → Nuclei → SQLMap on forms.", + "trigger_on": "manual", + "steps": [ + { + "id": "nikto_scan", + "type": "scan", + "tool": "nikto", + "label": "Nikto Web Scan", + "config": {}, + }, + { + "id": "nuclei_scan", + "type": "scan", + "tool": "nuclei", + "label": "Nuclei CVE Scan", + "config": {"severity": "critical,high,medium"}, + }, + { + "id": "sqlmap_scan", + "type": "scan", + "tool": "sqlmap", + "label": "SQLMap Injection Test", + "config": {"level": 1, "risk": 1}, + "depends_on": "nikto_scan", + }, + ], + }, + { + "name": "Network Sweep", + "description": "Full network enumeration: Nmap all ports → SearchSploit auto-lookup → Shodan enrichment.", + "trigger_on": "manual", + "steps": [ + { + "id": "nmap_full", + "type": "scan", + "tool": "nmap", + "label": "Full Port Scan", + "config": {"flags": "-p- -T4 -sV"}, + }, + { + "id": "searchsploit_lookup", + "type": "scan", + "tool": "searchsploit", + "label": "Exploit Lookup", + "depends_on": "nmap_full", + "config": {}, + }, + ], + }, + { + "name": "Credential Attack", + "description": "SSH + FTP brute-force with Hydra on discovered hosts.", + "trigger_on": "manual", + "steps": [ + { + "id": "hydra_ssh", + "type": "scan", + "tool": "hydra", + "label": "Hydra SSH Brute-Force", + "config": {"service": "ssh"}, + }, + { + "id": "hydra_ftp", + "type": "scan", + "tool": "hydra", + "label": "Hydra FTP Brute-Force", + "config": {"service": "ftp"}, + }, + ], + }, +] + + +# --------------------------------------------------------------------------- +# Chain Runner +# --------------------------------------------------------------------------- + +class ChainEngine: + """Executes a chain run step-by-step.""" + + def __init__(self, db: Session) -> None: + self.db = db + + async def run( + self, + chain_id: str, + project_id: str, + target_id: str | None, + run_id: str | None = None, + ) -> str: + """Execute chain, return the ChainRun ID.""" + chain = self.db.get(Chain, chain_id) + if not chain: + raise ValueError(f"Chain {chain_id} not found") + if not chain.enabled: + raise ValueError("Chain is disabled") + + run_id = run_id or new_uuid() + steps: list[dict] = json.loads(chain.steps or "[]") + + # Create run record + run = ChainRun( + id=run_id, + chain_id=chain_id, + project_id=project_id, + status="running", + step_results="{}", + started_at=_now_iso(), + ) + self.db.add(run) + chain.last_run = _now_iso() + chain.last_status = "running" + self.db.commit() + + # Execute steps sequentially + step_results: dict[str, Any] = {} + try: + for step in steps: + step_id = step.get("id", new_uuid()) + step_type = step.get("type", "scan") + + logger.info("Chain %s — executing step %s (%s)", chain_id, step_id, step_type) + result = await self._execute_step(step, project_id, target_id, step_results) + step_results[step_id] = result + + # Persist progress + run.step_results = json.dumps(step_results) + self.db.commit() + + if result.get("status") == "failed" and not step.get("continue_on_error"): + raise RuntimeError(f"Step {step_id} failed: {result.get('error')}") + + run.status = "completed" + chain.last_status = "success" + except Exception as exc: + run.status = "failed" + run.error = str(exc) + chain.last_status = "failed" + logger.error("Chain %s run %s failed: %s", chain_id, run_id, exc) + finally: + run.finished_at = _now_iso() + self.db.commit() + + return run_id + + async def _execute_step( + self, + step: dict, + project_id: str, + target_id: str | None, + previous_results: dict[str, Any], + ) -> dict[str, Any]: + step_type = step.get("type", "scan") + + if step_type == "scan": + return await self._run_scan_step(step, project_id, target_id) + elif step_type == "notify": + msg = step.get("message", "Chain step completed") + logger.info("Chain notify: %s", msg) + return {"status": "completed", "message": msg} + else: + return {"status": "skipped", "reason": f"Unknown step type: {step_type}"} + + async def _run_scan_step( + self, + step: dict, + project_id: str, + target_id: str | None, + ) -> dict[str, Any]: + from backend.services.scan_service import ScanService + + tool = step.get("tool", "nmap") + config = dict(step.get("config", {})) + + # Create scan record + scan = Scan( + id=new_uuid(), + project_id=project_id, + target_id=target_id, + tool=tool, + profile=step.get("label"), + config=json.dumps(config), + status="pending", + ) + self.db.add(scan) + self.db.commit() + + try: + service = ScanService(self.db) + await service.run(scan.id, tool, config) + return {"status": "completed", "scan_id": scan.id} + except Exception as exc: + return {"status": "failed", "scan_id": scan.id, "error": str(exc)} diff --git a/backend/services/obsidian_sync_service.py b/backend/services/obsidian_sync_service.py new file mode 100644 index 0000000..2b4c1aa --- /dev/null +++ b/backend/services/obsidian_sync_service.py @@ -0,0 +1,277 @@ +"""Obsidian Auto-Sync Service — Phase 4.8 + +Writes project data as Markdown files directly into a local Obsidian vault. + +Triggered after scans complete (via ScanService hook) and on demand via the +``POST /projects/{id}/export/obsidian/sync-to-disk`` endpoint. +""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +logger = logging.getLogger("zeronyx.obsidian_sync") + +_UNSAFE_RE = re.compile(r'[\\/:*?"<>|]') + + +def _safe_name(value: str, max_len: int = 60) -> str: + cleaned = _UNSAFE_RE.sub("_", value).strip() + return cleaned[:max_len] if cleaned else "unnamed" + + +def _short_id(id_: str) -> str: + return id_[:8] + + +def _fmt_dt(dt: Any) -> str: + if dt is None: + return "—" + return str(dt)[:19].replace("T", " ") + + +_SEVERITY_EMOJI = { + "critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "⚪", +} +_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} + + +class ObsidianSyncService: + """Writes project data into an Obsidian vault directory.""" + + def __init__(self, vault_path: str | Path) -> None: + self.vault = Path(vault_path) + + def _write(self, relative_path: str, content: str) -> None: + """Write a file to the vault, creating parent dirs as needed.""" + target = self.vault / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + logger.debug("Obsidian sync — wrote %s", relative_path) + + def sync_project(self, project_id: str, db: Session) -> dict[str, int]: + """Full sync: write all notes for the project. + + Returns a dict with counts: ``{"written": N, "errors": M}``. + """ + from backend.models.finding import Finding + from backend.models.project import Project + from backend.models.scan import Scan, ScanResult + from backend.models.target import Target + + project = db.get(Project, project_id) + if not project: + raise ValueError(f"Project {project_id} not found") + + targets = db.query(Target).filter(Target.project_id == project_id).all() + scans = db.query(Scan).filter(Scan.project_id == project_id).all() + findings = db.query(Finding).filter(Finding.project_id == project_id).all() + + # Results lookup + results_map: dict[str, ScanResult] = {} + for scan in scans: + result = db.query(ScanResult).filter(ScanResult.scan_id == scan.id).first() + if result: + results_map[scan.id] = result + + findings_by_scan: dict[str, list] = {} + for f in findings: + if f.scan_id: + findings_by_scan.setdefault(f.scan_id, []).append(f) + + written = 0 + errors = 0 + project_dir = _safe_name(project.name) + + # Index + try: + self._write(f"{project_dir}/Index.md", self._build_index(project, targets, scans, findings)) + written += 1 + except Exception as exc: + logger.error("Obsidian sync index error: %s", exc) + errors += 1 + + # Targets + for t in targets: + try: + note = self._build_target_note(t, scans) + self._write(f"{project_dir}/Targets/{_safe_name(t.value)}.md", note) + written += 1 + except Exception as exc: + logger.error("Obsidian sync target %s error: %s", t.id, exc) + errors += 1 + + # Scans + for s in scans: + try: + result = results_map.get(s.id) + scan_findings = findings_by_scan.get(s.id, []) + note = self._build_scan_note(s, result, scan_findings) + tool_upper = s.tool.upper() + fname = f"{_short_id(s.id)} {tool_upper}" + self._write(f"{project_dir}/Scans/{_safe_name(fname)}.md", note) + written += 1 + except Exception as exc: + logger.error("Obsidian sync scan %s error: %s", s.id, exc) + errors += 1 + + # Findings + for f in findings: + try: + note = self._build_finding_note(f) + sev_dir = f.severity.capitalize() + fname = _safe_name(f.title) + self._write(f"{project_dir}/Findings/{sev_dir}/{fname}.md", note) + written += 1 + except Exception as exc: + logger.error("Obsidian sync finding %s error: %s", f.id, exc) + errors += 1 + + logger.info( + "Obsidian sync complete for project %s — %d written, %d errors", + project_id, written, errors, + ) + return {"written": written, "errors": errors} + + def sync_scan(self, scan_id: str, db: Session) -> bool: + """Write / update a single scan note and refresh the index.""" + from backend.models.finding import Finding + from backend.models.project import Project + from backend.models.scan import Scan, ScanResult + + scan = db.get(Scan, scan_id) + if not scan: + return False + + project = db.get(Project, scan.project_id) + project_dir = _safe_name(project.name) if project else scan.project_id + + result = db.query(ScanResult).filter(ScanResult.scan_id == scan_id).first() + findings = db.query(Finding).filter(Finding.scan_id == scan_id).all() + + note = self._build_scan_note(scan, result, findings) + tool_upper = scan.tool.upper() + fname = f"{_short_id(scan.id)} {tool_upper}" + try: + self._write(f"{project_dir}/Scans/{_safe_name(fname)}.md", note) + return True + except Exception as exc: + logger.error("Obsidian sync scan %s failed: %s", scan_id, exc) + return False + + # ------------------------------------------------------------------ + # Note builders (reuse export.py logic but local) + # ------------------------------------------------------------------ + + def _build_index(self, project: Any, targets: list, scans: list, findings: list) -> str: + sev_counts: dict[str, int] = {} + for f in findings: + sev_counts[f.severity] = sev_counts.get(f.severity, 0) + 1 + + lines = [ + f"# {project.name}", + "", + f"> **Status:** {project.status} ", + f"> **Synced:** auto-synced by ZeroNyx", + "", + ] + if project.description: + lines += [project.description, ""] + lines += [ + "## Stats", + "", + "| Metric | Value |", + "|---|---|", + f"| Targets | {len(targets)} |", + f"| Scans | {len(scans)} |", + f"| Findings | {len(findings)} |", + ] + for sev in ("critical", "high", "medium", "low", "info"): + if sev in sev_counts: + lines.append(f"| {_SEVERITY_EMOJI[sev]} {sev.capitalize()} | {sev_counts[sev]} |") + lines.append("") + if targets: + lines += ["## Targets", ""] + for t in targets: + lines.append(f"- [[Targets/{_safe_name(t.value)}]]") + lines.append("") + if scans: + lines += ["## Scans", ""] + for s in sorted(scans, key=lambda x: x.created_at, reverse=True): + fname = f"{_short_id(s.id)} {s.tool.upper()}" + lines.append(f"- [[Scans/{_safe_name(fname)}]] — {s.status}") + lines.append("") + if findings: + lines += ["## Findings", ""] + for f in sorted(findings, key=lambda x: _SEVERITY_ORDER.get(x.severity, 99)): + fname = _safe_name(f.title) + sev_dir = f.severity.capitalize() + lines.append(f"- {_SEVERITY_EMOJI.get(f.severity,'')} [[Findings/{sev_dir}/{fname}]]") + lines.append("") + return "\n".join(lines) + + def _build_target_note(self, target: Any, scans: list) -> str: + related = [s for s in scans if s.target_id == target.id] + lines = [ + f"# {target.value}", + "", + f"| Field | Value |", + "|---|---|", + f"| Type | {target.type} |", + f"| Added | {_fmt_dt(target.created_at)} |", + "", + ] + if target.notes: + lines += ["## Notes", "", target.notes, ""] + if related: + lines += ["## Scans", ""] + for s in sorted(related, key=lambda x: x.created_at, reverse=True): + fname = f"{_short_id(s.id)} {s.tool.upper()}" + lines.append(f"- [[Scans/{_safe_name(fname)}]] — {s.status}") + lines.append("") + return "\n".join(lines) + + def _build_scan_note(self, scan: Any, result: Any, findings: list) -> str: + lines = [ + f"# {scan.tool.upper()} — {_short_id(scan.id)}", + "", + "| Field | Value |", + "|---|---|", + f"| Tool | {scan.tool} |", + f"| Status | {scan.status} |", + f"| Started | {_fmt_dt(scan.started_at)} |", + f"| Finished | {_fmt_dt(scan.finished_at)} |", + "", + ] + if findings: + lines += ["## Findings", ""] + for f in sorted(findings, key=lambda x: _SEVERITY_ORDER.get(x.severity, 99)): + sev_dir = f.severity.capitalize() + fname = _safe_name(f.title) + lines.append(f"- {_SEVERITY_EMOJI.get(f.severity,'')} [[Findings/{sev_dir}/{fname}]] — {f.severity.upper()}") + lines.append("") + return "\n".join(lines) + + def _build_finding_note(self, finding: Any) -> str: + lines = [ + f"# {finding.title}", + "", + "| Field | Value |", + "|---|---|", + f"| Severity | {finding.severity.upper()} |", + f"| Status | {finding.status} |", + f"| Tool | {finding.tool_source or '—'} |", + f"| CVE | {finding.cve or '—'} |", + "", + ] + if finding.description: + lines += ["## Description", "", finding.description, ""] + if finding.remediation: + lines += ["## Remediation", "", finding.remediation, ""] + return "\n".join(lines) diff --git a/backend/services/scan_service.py b/backend/services/scan_service.py index 0ae7cbb..45ee650 100644 --- a/backend/services/scan_service.py +++ b/backend/services/scan_service.py @@ -207,6 +207,18 @@ async def _persist_result(self, scan: Scan, result: ToolResult) -> None: scan.id, len(result.hosts), len(result.ports), len(result.findings), len(result.credentials), ) + # Obsidian auto-sync hook (4.8) + try: + from backend.api.routes.app_settings import _load_user_settings + user_settings = _load_user_settings() + vault_path = user_settings.get("obsidian_vault_path", "") + if user_settings.get("obsidian_auto_sync") and vault_path: + from backend.services.obsidian_sync_service import ObsidianSyncService + ObsidianSyncService(vault_path).sync_scan(scan.id, self.db) + logger.info("[scan:%s] Obsidian auto-sync complete", scan.id) + except Exception as exc: + logger.warning("[scan:%s] Obsidian auto-sync skipped: %s", scan.id, exc) + def _upsert_host(self, project_id: str, data: dict) -> Host: ip = data.get("ip", "") host = ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67081ba..d446d38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ import { SQLMapPage } from '@/pages/SQLMap' import { ShodanPage } from '@/pages/Shodan' import { CensysPage } from '@/pages/Censys' import { HostsPage } from '@/pages/Hosts' +import { AIPage } from '@/pages/AI' +import { ChainsPage } from '@/pages/Chains' import type { BackendStatus } from '@/types' const BACKEND_URL = 'http://127.0.0.1:8742' @@ -58,6 +60,8 @@ export default function App(): JSX.Element { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c80376c..8ca240d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,6 +13,8 @@ import { Eye, Radar, Network, + BrainCircuit, + Workflow, type LucideIcon } from 'lucide-react' import * as Tooltip from '@radix-ui/react-tooltip' @@ -40,6 +42,8 @@ const NAV_ITEMS: NavItem[] = [ { pageId: 'shodan', icon: Eye, label: 'Shodan', path: '/shodan' }, { pageId: 'censys', icon: Radar, label: 'Censys', path: '/censys' }, { pageId: 'hosts', icon: Network, label: 'Hosts', path: '/hosts' }, + { pageId: 'ai', icon: BrainCircuit, label: 'AI Analysis', path: '/ai' }, + { pageId: 'chains', icon: Workflow, label: 'Chains', path: '/chains' }, { pageId: 'reports', icon: FileBarChart, label: 'Reports', path: '/reports' }, { pageId: 'terminal', icon: SquareTerminal, label: 'Terminal', path: '/terminal' }, ] diff --git a/frontend/src/pages/AI/index.tsx b/frontend/src/pages/AI/index.tsx new file mode 100644 index 0000000..8fba8a6 --- /dev/null +++ b/frontend/src/pages/AI/index.tsx @@ -0,0 +1,602 @@ +/** + * AI Analysis Page — Phase 4 + * + * Tabs: + * - Settings : Provider configuration + connection test (4.1) + * - Analyse : Run analysis (scan / finding / host / project) (4.2–4.4, 4.7) + * - History : Browse stored AI analyses + */ + +import { useCallback, useEffect, useState } from 'react' +import { + BrainCircuit, Sparkles, History, Settings2, + CheckCircle2, XCircle, Loader2, + RefreshCw, Copy, Trash2, ShieldCheck, Zap, + FileText, AlertTriangle, Eye, +} from 'lucide-react' +import { useProjectStore } from '@/stores/projectStore' +import { cn } from '@/lib/utils' +import type { AIAnalysis, AISettings } from '@/types' + +const BASE = 'http://127.0.0.1:8742' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type Tab = 'settings' | 'analyse' | 'history' +type AnalyseContextType = 'scan' | 'finding' | 'host' | 'project' +type PromptType = 'analyse' | 'false_positive' | 'exploits' | 'report' + +interface AnalyseOption { + context_type: AnalyseContextType + prompt_type: PromptType + label: string + description: string + icon: JSX.Element +} + +const ANALYSE_OPTIONS: AnalyseOption[] = [ + { + context_type: 'scan', + prompt_type: 'analyse', + label: 'Analyse Scan', + description: 'Attack surface summary, key risks, recommended next steps.', + icon: , + }, + { + context_type: 'finding', + prompt_type: 'false_positive', + label: 'False Positive Check', + description: 'Evaluate a finding for true/false positive with confidence reasoning.', + icon: , + }, + { + context_type: 'host', + prompt_type: 'exploits', + label: 'Suggest Exploits', + description: 'Attack paths and CVE candidates for a specific host.', + icon: , + }, + { + context_type: 'project', + prompt_type: 'report', + label: 'Generate Report', + description: 'Full pentest report — executive summary + technical findings.', + icon: , + }, +] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const PROVIDER_LABELS: Record = { + ollama: 'Ollama (Local)', + openai: 'OpenAI', + anthropic: 'Anthropic / Claude', +} + +function MarkdownText({ text }: { text: string }): JSX.Element { + // Very lightweight Markdown renderer — just headings + bold + lists + const lines = text.split('\n') + return ( +
+ {lines.map((line, i) => { + if (line.startsWith('### ')) + return

{line.slice(4)}

+ if (line.startsWith('## ')) + return

{line.slice(3)}

+ if (line.startsWith('# ')) + return

{line.slice(2)}

+ if (line.startsWith('- ') || line.startsWith('* ')) + return
{line.slice(2)}
+ if (line.startsWith('---')) + return
+ if (line === '') + return
+ return

{line}

+ })} +
+ ) +} + +// --------------------------------------------------------------------------- +// Settings Tab +// --------------------------------------------------------------------------- + +function SettingsTab(): JSX.Element { + const [cfg, setCfg] = useState({ + provider: 'ollama', + ollama_url: 'http://localhost:11434', + ollama_model: 'llama3.2', + openai_api_key: '', + openai_model: 'gpt-4o', + anthropic_api_key: '', + anthropic_model: 'claude-opus-4-6', + sanitize_before_cloud: true, + enabled: true, + }) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + + useEffect(() => { + fetch(`${BASE}/api/ai/settings`) + .then(r => r.json()) + .then(setCfg) + .catch(() => {}) + }, []) + + const handleSave = async () => { + setSaving(true) + setSaved(false) + try { + const r = await fetch(`${BASE}/api/ai/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cfg), + }) + if (r.ok) { + const data = await r.json() + setCfg(data) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } + } finally { + setSaving(false) + } + } + + const handleTest = async () => { + setTesting(true) + setTestResult(null) + try { + const r = await fetch(`${BASE}/api/ai/test`, { method: 'POST' }) + const data = await r.json() + setTestResult({ success: data.success, message: data.message }) + } catch (e) { + setTestResult({ success: false, message: String(e) }) + } finally { + setTesting(false) + } + } + + const F = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ +
{children}
+
+ ) + + const Input = ({ value, onChange, placeholder, type = 'text' }: { + value: string; onChange: (v: string) => void; placeholder?: string; type?: string + }) => ( + onChange(e.target.value)} + placeholder={placeholder} + className="w-full bg-[#16161a] border border-[#2a2a32] rounded-lg px-3 py-1.5 text-xs text-gray-200 font-mono focus:outline-none focus:border-red-500/50 placeholder-gray-700 transition-colors" + /> + ) + + return ( +
+ {/* Enabled */} +
+ setCfg(p => ({ ...p, enabled: e.target.checked }))} + className="accent-red-500" /> + + Disable to suppress all AI prompts +
+ + {/* Provider */} +
+

Provider

+ + + + + {/* Ollama */} + {cfg.provider === 'ollama' && <> + setCfg(p => ({ ...p, ollama_url: v }))} placeholder="http://localhost:11434" /> + setCfg(p => ({ ...p, ollama_model: v }))} placeholder="llama3.2" /> + } + + {/* OpenAI */} + {cfg.provider === 'openai' && <> + setCfg(p => ({ ...p, openai_api_key: v }))} placeholder="sk-..." /> + setCfg(p => ({ ...p, openai_model: v }))} placeholder="gpt-4o" /> + } + + {/* Anthropic */} + {cfg.provider === 'anthropic' && <> + setCfg(p => ({ ...p, anthropic_api_key: v }))} placeholder="sk-ant-..." /> + setCfg(p => ({ ...p, anthropic_model: v }))} placeholder="claude-opus-4-6" /> + } +
+ + {/* Privacy */} + {cfg.provider !== 'ollama' && ( +
+

Privacy

+
+ setCfg(p => ({ ...p, sanitize_before_cloud: e.target.checked }))} + className="accent-red-500 mt-0.5" + /> +
+ +

+ Replace IPs and hostnames with tokens (e.g. [IP-1], [HOST-2]) before sending data to {cfg.provider}. Restored in the response. +

+
+
+
+ )} + + {/* Test + Save */} +
+ + + {saved && Saved} +
+ + {testResult && ( +
+ {testResult.success ? : } + {testResult.message} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Analyse Tab (4.2 – 4.4, 4.7) +// --------------------------------------------------------------------------- + +function AnalyseTab(): JSX.Element { + const { activeProject } = useProjectStore() + const [selected, setSelected] = useState(ANALYSE_OPTIONS[0]) + const [contextId, setContextId] = useState('') + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const needsContextId = selected.context_type !== 'project' + + const handleRun = async () => { + if (!activeProject) { setError('Select a project first'); return } + if (needsContextId && !contextId.trim()) { setError('Enter the context ID'); return } + + setLoading(true) + setError(null) + setResult(null) + + try { + const r = await fetch(`${BASE}/api/ai/analyse`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: activeProject.id, + context_type: selected.context_type, + context_id: needsContextId ? contextId.trim() : activeProject.id, + prompt_type: selected.prompt_type, + }), + }) + if (!r.ok) { + const data = await r.json() + throw new Error(data.detail ?? 'AI analysis failed') + } + setResult(await r.json()) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + const copyToClipboard = () => { + if (result?.response) navigator.clipboard.writeText(result.response) + } + + return ( +
+ {/* Left: options */} +
+

Choose analysis type:

+ {ANALYSE_OPTIONS.map((opt) => ( + + ))} +
+ + {/* Right: input + result */} +
+ {!activeProject && ( +
+ + No project selected. Open a project from the Dashboard. +
+ )} + + {needsContextId && ( +
+ + setContextId(e.target.value)} + placeholder={`Paste the ${selected.context_type} UUID here`} + className="w-full bg-[#16161a] border border-[#2a2a32] rounded-lg px-3 py-1.5 text-xs font-mono text-gray-200 focus:outline-none focus:border-red-500/50 placeholder-gray-700 transition-colors" + /> +

+ Find the ID in the {selected.context_type === 'scan' ? 'Scan History' : 'Findings'} page. +

+
+ )} + + {selected.context_type === 'project' && activeProject && ( +
+ Analysing project: {activeProject.name} +
+ )} + + + + {error && ( +
+ + {error} +
+ )} + + {result && ( +
+
+
+ + {selected.label} + + {result.provider} / {result.model} + {result.tokens_used ? ` · ${result.tokens_used} tokens` : ''} + {result.sanitized ? ' · sanitized' : ''} + +
+ +
+
+ +
+
+ )} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// History Tab +// --------------------------------------------------------------------------- + +function HistoryTab(): JSX.Element { + const { activeProject } = useProjectStore() + const [analyses, setAnalyses] = useState([]) + const [selected, setSelected] = useState(null) + const [loading, setLoading] = useState(false) + + const fetchHistory = useCallback(async () => { + if (!activeProject) return + setLoading(true) + try { + const r = await fetch(`${BASE}/api/ai/analyses?project_id=${activeProject.id}`) + if (r.ok) setAnalyses(await r.json()) + } finally { + setLoading(false) + } + }, [activeProject]) + + useEffect(() => { fetchHistory() }, [fetchHistory]) + + const handleDelete = async (id: string) => { + await fetch(`${BASE}/api/ai/analyses/${id}`, { method: 'DELETE' }) + setAnalyses(p => p.filter(a => a.id !== id)) + if (selected?.id === id) setSelected(null) + } + + const PROMPT_LABELS: Record = { + analyse: 'Scan Analysis', + false_positive: 'False Positive Check', + exploits: 'Exploit Suggestions', + report: 'Report', + } + + return ( +
+ {/* List */} +
+
+ {analyses.length} analyses + +
+
+ {analyses.length === 0 && ( +

No analyses yet.

+ )} + {analyses.map((a) => ( +
setSelected(a)} + className={cn( + 'group flex items-start gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors', + selected?.id === a.id + ? 'bg-red-500/10 border-red-500/30' + : 'bg-[#1a1a1f] border-[#2a2a32] hover:border-gray-500', + )} + > + +
+
+ {PROMPT_LABELS[a.prompt_type ?? ''] ?? a.prompt_type ?? 'Analysis'} +
+
+ {a.context_type} · {a.provider}/{a.model} +
+
{a.created_at.slice(0, 16)}
+
+ +
+ ))} +
+
+ + {/* Detail */} +
+ {!selected ? ( +
+ +

Select an analysis to view

+
+ ) : ( +
+
+ + + {PROMPT_LABELS[selected.prompt_type ?? ''] ?? selected.prompt_type} + + + {selected.provider}/{selected.model} + {selected.tokens_used ? ` · ${selected.tokens_used} tokens` : ''} + {selected.sanitized ? ' · sanitized' : ''} + +
+
+ +
+
+ )} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export function AIPage(): JSX.Element { + const [tab, setTab] = useState('settings') + + const TABS: { id: Tab; label: string; icon: JSX.Element }[] = [ + { id: 'settings', label: 'Settings', icon: }, + { id: 'analyse', label: 'Analyse', icon: }, + { id: 'history', label: 'History', icon: }, + ] + + return ( +
+ {/* Header */} +
+ +

AI Analysis

+ PRO +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Content */} +
+ {tab === 'settings' && } + {tab === 'analyse' && } + {tab === 'history' && } +
+
+ ) +} diff --git a/frontend/src/pages/Chains/index.tsx b/frontend/src/pages/Chains/index.tsx new file mode 100644 index 0000000..bd2d297 --- /dev/null +++ b/frontend/src/pages/Chains/index.tsx @@ -0,0 +1,458 @@ +/** + * Chains Page — Phase 4.5/4.6 + * + * Automated workflow engine. Two tabs: + * - Templates : Built-in standard chains — click to instantiate (4.6) + * - My Chains : Create, edit, run custom chains (4.5) + */ + +import { useCallback, useEffect, useState } from 'react' +import { + Workflow, Play, Plus, Trash2, RefreshCw, CheckCircle2, + XCircle, Loader2, ChevronRight, Clock, AlertTriangle, + Zap, Globe, Network, Server, Database, +} from 'lucide-react' +import { useProjectStore } from '@/stores/projectStore' +import { cn } from '@/lib/utils' +import type { Chain, ChainRun } from '@/types' + +const BASE = 'http://127.0.0.1:8742' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface Template { + name: string + description: string + trigger_on: string + steps: Array<{ id: string; type: string; tool?: string; label?: string; config: Record }> +} + +type Tab = 'templates' | 'chains' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TOOL_ICONS: Record = { + nmap: , + gobuster: , + nikto: , + nuclei: , + hydra: , + sqlmap: , + searchsploit: , +} + +const STATUS_STYLE: Record = { + success: 'text-green-400', + completed: 'text-green-400', + failed: 'text-red-400', + running: 'text-yellow-400', + pending: 'text-gray-500', +} + +function StatusDot({ status }: { status: string }): JSX.Element { + return ( + + {status} + + ) +} + +// --------------------------------------------------------------------------- +// Templates Tab +// --------------------------------------------------------------------------- + +function TemplatesTab({ onInstantiate }: { onInstantiate: () => void }): JSX.Element { + const { activeProject } = useProjectStore() + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [instantiating, setInstantiating] = useState(null) + const [done, setDone] = useState(null) + + useEffect(() => { + fetch(`${BASE}/api/chains/templates`) + .then(r => r.json()) + .then(d => setTemplates(d.templates ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + const handleInstantiate = async (name: string) => { + if (!activeProject) return + setInstantiating(name) + try { + const r = await fetch( + `${BASE}/api/chains/from-template?project_id=${activeProject.id}&template_name=${encodeURIComponent(name)}`, + { method: 'POST' }, + ) + if (r.ok) { + setDone(name) + setTimeout(() => setDone(null), 3000) + onInstantiate() + } + } finally { + setInstantiating(null) + } + } + + if (loading) return

Loading templates…

+ + return ( +
+ {!activeProject && ( +
+ + Select a project first to add a chain. +
+ )} + + {templates.map((tmpl) => ( +
+
+
+
+ + {tmpl.name} +
+

{tmpl.description}

+
+ +
+ + {/* Steps */} +
+ {tmpl.steps.map((step, i) => ( +
+
+ {TOOL_ICONS[step.tool ?? ''] ?? } + {step.label ?? step.tool ?? step.type} +
+ {i < tmpl.steps.length - 1 && ( + + )} +
+ ))} +
+
+ ))} +
+ ) +} + +// --------------------------------------------------------------------------- +// My Chains Tab +// --------------------------------------------------------------------------- + +function ChainsTab(): JSX.Element { + const { activeProject } = useProjectStore() + const [chains, setChains] = useState([]) + const [selected, setSelected] = useState(null) + const [runs, setRuns] = useState([]) + const [running, setRunning] = useState(null) + const [loading, setLoading] = useState(false) + + const fetchChains = useCallback(async () => { + if (!activeProject) return + setLoading(true) + try { + const r = await fetch(`${BASE}/api/chains?project_id=${activeProject.id}`) + if (r.ok) setChains(await r.json()) + } finally { + setLoading(false) + } + }, [activeProject]) + + const fetchRuns = useCallback(async (chainId: string) => { + const r = await fetch(`${BASE}/api/chains/${chainId}/runs`) + if (r.ok) setRuns(await r.json()) + }, []) + + useEffect(() => { fetchChains() }, [fetchChains]) + + const handleSelect = async (chain: Chain) => { + setSelected(chain) + setRuns([]) + await fetchRuns(chain.id) + } + + const handleRun = async (chain: Chain) => { + setRunning(chain.id) + try { + const r = await fetch(`${BASE}/api/chains/${chain.id}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + if (r.ok) { + setTimeout(() => { + fetchRuns(chain.id) + setRunning(null) + }, 1000) + } + } catch { + setRunning(null) + } + } + + const handleDelete = async (id: string) => { + await fetch(`${BASE}/api/chains/${id}`, { method: 'DELETE' }) + setChains(p => p.filter(c => c.id !== id)) + if (selected?.id === id) setSelected(null) + } + + const toggleEnabled = async (chain: Chain) => { + const r = await fetch(`${BASE}/api/chains/${chain.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !chain.enabled }), + }) + if (r.ok) { + const updated = await r.json() + setChains(p => p.map(c => c.id === updated.id ? updated : c)) + } + } + + if (!activeProject) { + return ( +
+ + Select a project first. +
+ ) + } + + return ( +
+ {/* Chain list */} +
+
+ {chains.length} chains + +
+ +
+ {chains.length === 0 && ( +

+ No chains yet. Add one from Templates. +

+ )} + {chains.map((c) => ( +
handleSelect(c)} + className={cn( + 'group flex items-start gap-2 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors', + selected?.id === c.id + ? 'bg-red-500/10 border-red-500/30' + : 'bg-[#1a1a1f] border-[#2a2a32] hover:border-gray-500', + )} + > + +
+
{c.name}
+
+ {c.steps.length} steps + {c.last_status && } +
+
+
+ + +
+
+ ))} +
+
+ + {/* Chain detail */} +
+ {!selected ? ( +
+ +

Select a chain

+
+ ) : ( + <> + {/* Chain header */} +
+
+
+
+

{selected.name}

+ + {selected.enabled ? 'enabled' : 'disabled'} + +
+ {selected.description && ( +

{selected.description}

+ )} +
+
+ + +
+
+ + {/* Steps */} +
+ {selected.steps.map((step, i) => ( +
+
+ {TOOL_ICONS[step.tool ?? ''] ?? } + {step.label ?? step.tool ?? step.type} +
+ {i < selected.steps.length - 1 && ( + + )} +
+ ))} +
+
+ + {/* Run history */} +
+

Run History

+ {runs.length === 0 ? ( +

No runs yet.

+ ) : ( +
+ {runs.map((run) => ( +
+
+
+ {run.status === 'completed' ? : + run.status === 'failed' ? : + run.status === 'running' ? : + } + + {run.id.slice(0, 8)} +
+ {run.started_at?.slice(0, 16)} +
+ {run.error && ( +

{run.error}

+ )} + {Object.keys(run.step_results).length > 0 && ( +
+ {Object.entries(run.step_results).map(([stepId, result]: [string, unknown]) => { + const r = result as { status?: string; scan_id?: string } + return ( + + {stepId}: {r.status} + + ) + })} +
+ )} +
+ ))} +
+ )} +
+ + )} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Main Page +// --------------------------------------------------------------------------- + +export function ChainsPage(): JSX.Element { + const [tab, setTab] = useState('templates') + + const TABS: { id: Tab; label: string }[] = [ + { id: 'templates', label: 'Templates' }, + { id: 'chains', label: 'My Chains' }, + ] + + return ( +
+ {/* Header */} +
+ +

Chain Automation

+ PRO +
+ + {/* Tabs */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* Content */} +
+ {tab === 'templates' && setTab('chains')} />} + {tab === 'chains' && } +
+
+ ) +} diff --git a/frontend/src/pages/Findings/index.tsx b/frontend/src/pages/Findings/index.tsx index 42c49e7..a6ec8ef 100644 --- a/frontend/src/pages/Findings/index.tsx +++ b/frontend/src/pages/Findings/index.tsx @@ -3,7 +3,7 @@ import { ShieldAlert, Plus, Trash2, X, Search, Filter, AlertTriangle, AlertCircle, Info, CheckCircle, ChevronRight, Edit3, Check, KeyRound, Download, - ShieldCheck, Shield, Copy, + ShieldCheck, Shield, Copy, BrainCircuit, Loader2, } from 'lucide-react' import { useFindingStore } from '@/stores/findingStore' import { useCredentialStore } from '@/stores/credentialStore' @@ -214,9 +214,42 @@ function AddFindingModal({ // Finding detail panel // --------------------------------------------------------------------------- +const BASE_API = 'http://127.0.0.1:8742' + function DetailPanel({ finding, onClose }: { finding: Finding; onClose: () => void }): JSX.Element { const { updateFinding, deleteFinding, fetchStats } = useFindingStore() const [editingStatus, setEditingStatus] = useState(false) + const [aiLoading, setAiLoading] = useState(null) // 'analyse' | 'fp' + const [aiResult, setAiResult] = useState<{ type: string; text: string } | null>(null) + const [aiError, setAiError] = useState(null) + + const runAI = async (promptType: 'analyse' | 'false_positive') => { + setAiLoading(promptType) + setAiResult(null) + setAiError(null) + try { + const r = await fetch(`${BASE_API}/api/ai/analyse`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: finding.project_id, + context_type: 'finding', + context_id: finding.id, + prompt_type: promptType, + }), + }) + if (!r.ok) { + const data = await r.json() + throw new Error(data.detail ?? 'AI failed') + } + const data = await r.json() + setAiResult({ type: promptType, text: data.response ?? '' }) + } catch (e) { + setAiError((e as Error).message) + } finally { + setAiLoading(null) + } + } const handleStatusChange = async (status: FindingStatus) => { await updateFinding(finding.id, { status }) @@ -340,6 +373,44 @@ function DetailPanel({ finding, onClose }: { finding: Finding; onClose: () => vo

{finding.remediation}

)} + + {/* AI Actions (4.2 / 4.4) */} +
+
+ + AI Analysis + PRO +
+
+ + +
+ {aiError && ( +

{aiError}

+ )} + {aiResult && ( +
+
+ {aiResult.type === 'false_positive' ? 'False Positive Check' : 'Analysis'} +
+

{aiResult.text}

+
+ )} +
) diff --git a/frontend/src/pages/Reports/index.tsx b/frontend/src/pages/Reports/index.tsx index 0ed27cf..8e3912a 100644 --- a/frontend/src/pages/Reports/index.tsx +++ b/frontend/src/pages/Reports/index.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' -import { BookOpen, CheckCircle2, XCircle, Loader2, FolderOpen } from 'lucide-react' +import { BookOpen, CheckCircle2, XCircle, Loader2, FolderOpen, BrainCircuit, Copy } from 'lucide-react' import { useProjectStore } from '@/stores/projectStore' import { exportApi } from '@/services/api' import { cn } from '@/lib/utils' +const BASE_API = 'http://127.0.0.1:8742' + type ExportState = 'idle' | 'fetching' | 'writing' | 'done' | 'error' interface ExportResult { @@ -12,6 +14,126 @@ interface ExportResult { error?: string } +// --------------------------------------------------------------------------- +// AI Report Generation component (4.7) +// --------------------------------------------------------------------------- + +function AIReportCard(): JSX.Element { + const { projects, activeProject } = useProjectStore() + const [selectedId, setSelectedId] = useState(activeProject?.id ?? '') + const [loading, setLoading] = useState(false) + const [report, setReport] = useState(null) + const [error, setError] = useState(null) + + const handleGenerate = async () => { + if (!selectedId) return + setLoading(true) + setError(null) + setReport(null) + try { + const r = await fetch(`${BASE_API}/api/ai/analyse`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: selectedId, + context_type: 'project', + context_id: selectedId, + prompt_type: 'report', + }), + }) + if (!r.ok) { + const data = await r.json() + throw new Error(data.detail ?? 'Report generation failed') + } + const data = await r.json() + setReport(data.response ?? '') + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+ {/* Header */} +
+
+ +
+
+
+

AI Report Generation

+ PRO +
+

+ Generate an executive summary + technical pentest report using AI. +

+
+
+ +
+ {/* Project selector */} +
+ + +
+ + {error && ( +
+ +

{error}

+
+ )} + + {report && ( +
+ +
+
{report}
+
+
+ )} + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Reports Page +// --------------------------------------------------------------------------- + export function ReportsPage(): JSX.Element { const { projects, activeProject } = useProjectStore() const [selectedId, setSelectedId] = useState(activeProject?.id ?? '') @@ -59,9 +181,14 @@ export function ReportsPage(): JSX.Element { const isRunning = exportState === 'fetching' || exportState === 'writing' return ( -
-

Reports

-

Export engagement data as structured markdown notes.

+
+
+

Reports

+

Export engagement data and generate AI-powered reports.

+
+ + {/* AI Report Generation Card */} + {/* Obsidian Export Card */}
diff --git a/frontend/src/pages/Scans/index.tsx b/frontend/src/pages/Scans/index.tsx index 4d35394..e5650fe 100644 --- a/frontend/src/pages/Scans/index.tsx +++ b/frontend/src/pages/Scans/index.tsx @@ -3,7 +3,10 @@ import { Play, Square, Trash2, Terminal, Server, LayoutList, ChevronDown, AlertCircle, CheckCircle2, Clock, Loader2, Crosshair, Network, Globe, Search, FolderOpen, Zap, + BrainCircuit, Copy, } from 'lucide-react' + +const BASE_API = 'http://127.0.0.1:8742' import { useProjectStore } from '@/stores/projectStore' import { useTargetStore } from '@/stores/targetStore' import { useScanStore } from '@/stores/scanStore' @@ -2428,6 +2431,11 @@ export function ScansPage(): JSX.Element { {activeScan.tool === 'searchsploit' && results.parsed && ( )} + {/* AI Scan Analysis (4.2) */} + )}
@@ -2436,6 +2444,79 @@ export function ScansPage(): JSX.Element { ) } +// --------------------------------------------------------------------------- +// AI Scan Analysis Panel (4.2) +// --------------------------------------------------------------------------- + +function AIScanAnalysisPanel({ scanId, projectId }: { scanId: string; projectId: string }): JSX.Element { + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + + const handleAnalyse = async () => { + setLoading(true) + setError(null) + setResult(null) + setOpen(true) + try { + const r = await fetch(`${BASE_API}/api/ai/analyse`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project_id: projectId, + context_type: 'scan', + context_id: scanId, + prompt_type: 'analyse', + }), + }) + if (!r.ok) { + const data = await r.json() + throw new Error(data.detail ?? 'AI failed') + } + const data = await r.json() + setResult(data.response ?? '') + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ + AI Analysis + PRO +
+
+ {result && ( + + )} + +
+
+ {error &&

{error}

} + {open && result && ( +
+
{result}
+
+ )} +
+ ) +} + // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx index 5259eff..ace1339 100644 --- a/frontend/src/pages/Settings/index.tsx +++ b/frontend/src/pages/Settings/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback } from 'react' import { CheckCircle2, XCircle, RefreshCw, Save, AlertCircle, - Terminal, Folder, Clock, Info, + Terminal, Folder, Clock, Info, BrainCircuit, BookOpen, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -18,6 +18,8 @@ interface UserSettings { data_dir: string version: string env: string + obsidian_vault_path?: string + obsidian_auto_sync?: boolean } interface ToolHealth { @@ -48,6 +50,8 @@ export function SettingsPage(): JSX.Element { // Editable form state const [scanTimeout, setScanTimeout] = useState(600) const [toolPaths, setToolPaths] = useState>({}) + const [obsidianVaultPath, setObsidianVaultPath] = useState('') + const [obsidianAutoSync, setObsidianAutoSync] = useState(false) const fetchSettings = useCallback(async () => { try { @@ -57,6 +61,8 @@ export function SettingsPage(): JSX.Element { setSettings(data) setScanTimeout(data.scan_timeout) setToolPaths(data.tool_paths ?? {}) + setObsidianVaultPath(data.obsidian_vault_path ?? '') + setObsidianAutoSync(data.obsidian_auto_sync ?? false) } } catch { /* ignore */ } }, []) @@ -89,6 +95,8 @@ export function SettingsPage(): JSX.Element { body: JSON.stringify({ scan_timeout: scanTimeout, tool_paths: toolPaths, + obsidian_vault_path: obsidianVaultPath, + obsidian_auto_sync: obsidianAutoSync, }), }) if (!res.ok) { @@ -204,6 +212,43 @@ export function SettingsPage(): JSX.Element {
+ {/* ---- Obsidian Auto-Sync ----------------------------------- */} +
} title="Obsidian Auto-Sync"> +

+ Automatically write scan notes as Markdown into an Obsidian vault after each scan completes. +

+
+
+ + setObsidianVaultPath(e.target.value)} + placeholder="/path/to/your/ObsidianVault" + className="flex-1 bg-[#16161a] border border-[#2a2a32] rounded-lg px-3 py-1.5 text-xs font-mono text-gray-200 placeholder-gray-700 focus:outline-none focus:border-red-500/50 transition-colors" + /> +
+ +
+
+ + {/* ---- AI Link ----------------------------------------------- */} +
} title="AI Settings"> +

+ Configure AI provider (Ollama, OpenAI, Anthropic) and API keys in the{' '} + AI Analysis page. +

+
+ {/* ---- Save -------------------------------------------------- */}