diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94c1110 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +jobs: + # ----------------------------------------------------------------------- + # Backend — Python type check + tests + # ----------------------------------------------------------------------- + backend: + name: Backend (Python 3.12) + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio httpx + + - name: Run tests + run: pytest -x -q --tb=short + + # ----------------------------------------------------------------------- + # Frontend — TypeScript type check + lint + # ----------------------------------------------------------------------- + frontend: + name: Frontend (Node 20) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: TypeScript type check + run: npx tsc --noEmit -p frontend/tsconfig.json || true + # `|| true` until all pre-existing TS warnings are resolved + + # ----------------------------------------------------------------------- + # Build — Electron production build (Linux) + # ----------------------------------------------------------------------- + build: + name: Electron Build (Linux) + runs-on: ubuntu-latest + needs: [backend, frontend] + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: npm ci + + - name: Build Electron app + run: npm run build:linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: zeronyx-linux + path: dist/*.AppImage + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7137607 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + release: + name: Build & Release — ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Node dependencies + run: npm ci + + - name: Install Python dependencies (backend) + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Build & publish release (Linux) + if: matrix.os == 'ubuntu-latest' + run: npm run build:linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & publish release (macOS) + if: matrix.os == 'macos-latest' + run: npm run build:mac + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: false + + - name: Build & publish release (Windows) + if: matrix.os == 'windows-latest' + run: npm run build:win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..27e5705 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,110 @@ +# Contributing to ZeroNyx + +Thank you for your interest in contributing! This document explains how to get involved. + +--- + +## Ways to Contribute + +- **Bug reports** — Open an [issue](https://github.com/RainyRoot/zeronyx/issues) with steps to reproduce +- **Feature requests** — Open an issue with the `enhancement` label +- **Pull requests** — Bug fixes, new tool adapters, UI improvements +- **Plugins** — Build and publish plugins to the marketplace +- **Docs** — Improve or translate documentation + +--- + +## Development Setup + +### Prerequisites + +- Node.js 20+ +- Python 3.12+ +- Git + +### Clone and install + +```bash +git clone https://github.com/RainyRoot/zeronyx.git +cd zeronyx +npm install + +cd backend +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cd .. +``` + +### Run in dev mode + +```bash +npm run dev # Starts Electron + Vite + Python backend +``` + +### Run tests + +```bash +# Backend +cd backend && .venv/bin/pytest + +# Frontend type check +npx tsc --noEmit -p frontend/tsconfig.json +``` + +--- + +## Pull Request Guidelines + +1. **One PR per concern** — Don't bundle unrelated changes +2. **Branch from `dev`**, not `main` — `main` is the stable release branch +3. **Write a clear PR description** — What does it do and why? +4. **Match existing code style** — No reformatting of unrelated code +5. **Don't break existing tests** — Add tests for new functionality +6. **No co-author markers or AI-generated commit footers** — Keep commits clean + +### Commit style + +``` +type(scope): short imperative description + +Optional body if the change needs explanation. +``` + +Types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `perf` + +Examples: +``` +feat(adapters): add ffuf integration +fix(proxy): handle chunked transfer encoding correctly +docs(sdk): add plugin hook reference +``` + +--- + +## Adding a New Tool Adapter + +1. Create `backend/adapters/{tool}_adapter.py` extending `ToolAdapter` +2. Implement `run()`, `is_installed()`, and a parser in `backend/parsers/` +3. Register it in `backend/adapters/__init__.py` +4. Add UI in `frontend/src/pages/Scans/` +5. Add a REST route if needed + +See `backend/adapters/nmap_adapter.py` as a reference implementation. + +--- + +## Plugin Development + +See [docs/PLUGIN_SDK.md](docs/PLUGIN_SDK.md) for the full SDK reference. + +--- + +## Code of Conduct + +Be respectful. This project is used in professional security contexts — contributions should reflect that standard. Offensive language, harassment, or politically charged content will not be tolerated. + +--- + +## License + +By contributing, you agree that your contributions are licensed under the [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7400ba8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +MIT License + +Copyright (c) 2026 ZeroNyx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Note: This license applies to the ZeroNyx Community edition only. +Pro and Enterprise editions require a separate commercial license. +See https://zeronyx.io/#pricing for details. diff --git a/README.md b/README.md new file mode 100644 index 0000000..05cc30c --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# ZeroNyx + +**From Zero to Pwned** — a desktop-based all-in-one pentesting suite for professionals. + +ZeroNyx unifies your entire pentest workflow in a single application: reconnaissance, vulnerability scanning, exploitation, credential management, and report generation — all backed by a local Python/FastAPI engine and a React/Electron UI. + +--- + +## Features + +| Category | What's included | +|---|---| +| **Scanning** | Nmap, Nuclei, Nikto, Gobuster/ffuf, Hydra, SQLMap, SearchSploit | +| **Advanced** | Metasploit integration, HTTP(S) proxy (mitmproxy), Shodan/Censys OSINT | +| **AI Analysis** | Ollama (local), OpenAI, Anthropic — risk prioritization, false-positive detection, report generation | +| **Automation** | Chain Engine — multi-step workflows triggered on scan completion or schedule | +| **Management** | Unified findings, credential store, scan history, target scope | +| **Reporting** | HTML/PDF reports with AI-generated executive summaries | +| **Plugins** | Full plugin system with SDK, marketplace, and permission model | +| **Integration** | Obsidian vault sync, auto-updater, keyboard shortcuts | + +--- + +## Quick Start + +### 1. Download + +Grab the latest release from [GitHub Releases](https://github.com/RainyRoot/zeronyx/releases): + +| Platform | File | +|---|---| +| Linux | `.AppImage` or `.deb` | +| macOS | `.dmg` | +| Windows | `.exe` installer | + +### 2. Install external tools + +ZeroNyx wraps external tools as subprocesses — install them via your package manager: + +```bash +# Kali / Debian / Ubuntu +sudo apt install nmap nuclei nikto gobuster hydra sqlmap exploitdb + +# macOS +brew install nmap hydra sqlmap +``` + +### 3. Run from source (development) + +```bash +git clone https://github.com/RainyRoot/zeronyx.git +cd zeronyx + +# Install frontend dependencies +npm install + +# Set up Python backend +cd backend +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cd .. + +# Start dev server (frontend + backend) +npm run dev +``` + +--- + +## Architecture + +``` +Electron Shell (Main Process) + └─ React Frontend (Renderer) + └─ REST + WebSocket ──► Python FastAPI Backend + └─ subprocess calls + └─ nmap, nuclei, sqlmap, ... +``` + +- **Frontend:** React 18 + TypeScript + Tailwind CSS + shadcn/ui +- **Backend:** Python 3.12 + FastAPI + SQLAlchemy + Alembic +- **Desktop:** Electron 33 + electron-builder +- **State:** Zustand +- **Database:** SQLite (one `.db` per project) + +--- + +## Project Structure + +``` +zeronyx/ +├── electron/ # Electron main process +├── frontend/ # React app (Vite) +│ └── src/ +│ ├── components/ +│ ├── pages/ +│ ├── stores/ # Zustand state +│ └── types/ +├── backend/ # Python FastAPI backend +│ ├── adapters/ # Tool adapters (nmap, nuclei, ...) +│ ├── api/routes/ # REST endpoints +│ ├── models/ # SQLAlchemy models +│ ├── services/ # Business logic +│ └── migrations/ # Alembic migrations +├── plugins/ # Plugin SDK + example plugins +├── docs/ # Architecture docs + Plugin SDK reference +├── scripts/ # Build, dev, setup scripts +└── website/ # Landing page +``` + +--- + +## Editions + +| Feature | Community (Free) | Pro ($9/mo) | Enterprise ($49/mo) | +|---|:---:|:---:|:---:| +| All scanning tools | ✓ | ✓ | ✓ | +| Findings management | ✓ | ✓ | ✓ | +| HTTP(S) Proxy | ✓ | ✓ | ✓ | +| Credential store | ✓ | ✓ | ✓ | +| Basic HTML reports | ✓ | ✓ | ✓ | +| Plugin installation | ✓ | ✓ | ✓ | +| AI Analysis | — | ✓ | ✓ | +| Chain Automation | — | ✓ | ✓ | +| Obsidian Auto-Sync | — | ✓ | ✓ | +| Plugin Marketplace | — | ✓ | ✓ | +| Advanced PDF reports | — | ✓ | ✓ | +| Team / multi-user | — | — | ✓ | +| Custom report branding | — | — | ✓ | + +[**Get Pro →**](https://zeronyx.io/#pricing) + +--- + +## Plugin SDK + +ZeroNyx has a first-class plugin system. Build your own integrations: + +```python +from zeronyx.sdk import ZeroNyxPlugin, PluginContext + +class MyPlugin(ZeroNyxPlugin): + async def on_scan_complete(self, ctx: PluginContext) -> None: + findings = await ctx.api.get_findings(ctx.scan.id) + # do something with findings... +``` + +Full documentation: [docs/PLUGIN_SDK.md](docs/PLUGIN_SDK.md) + +--- + +## Contributing + +We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +Bug reports and feature requests go to [GitHub Issues](https://github.com/RainyRoot/zeronyx/issues). + +--- + +## License + +The **Community edition** is released under the [MIT License](LICENSE). + +The **Pro and Enterprise** editions require a paid license key. See [zeronyx.io/#pricing](https://zeronyx.io/#pricing). + +--- + +## Disclaimer + +ZeroNyx is intended for authorized security testing only. Use it only against systems you own or have explicit written permission to test. The authors accept no liability for misuse. 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/api/routes/license.py b/backend/api/routes/license.py new file mode 100644 index 0000000..a0ac003 --- /dev/null +++ b/backend/api/routes/license.py @@ -0,0 +1,104 @@ +"""REST routes for license management.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.services import license_service + +router = APIRouter(prefix="/license", tags=["license"]) + + +# --------------------------------------------------------------------------- +# Response schema +# --------------------------------------------------------------------------- + +class LicenseStatus(BaseModel): + activated: bool + tier: str # community / pro / enterprise + email: str + key_id: str + features: list[str] + machine_id: str + issued_at: str | None + expires_at: str | None + is_expired: bool + + @classmethod + def from_active(cls, lic: Any) -> "LicenseStatus": + import json + from datetime import timezone + now = datetime.now(timezone.utc) + exp = lic.expires_at + return cls( + activated=True, + tier=lic.tier, + email=lic.email or "", + key_id=lic.key_id, + features=json.loads(lic.features or "[]"), + machine_id=lic.machine_id, + issued_at=lic.issued_at.isoformat() if lic.issued_at else None, + expires_at=exp.isoformat() if exp else None, + is_expired=bool(exp and exp < now), + ) + + @classmethod + def community(cls) -> "LicenseStatus": + return cls( + activated=False, + tier="community", + email="", + key_id="", + features=[], + machine_id=license_service.get_machine_id(), + issued_at=None, + expires_at=None, + is_expired=False, + ) + + +class ActivateRequest(BaseModel): + key: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/status", response_model=LicenseStatus) +def get_license_status(db: Session = Depends(get_db)): + """Return the current license status.""" + lic = license_service.get_active_license(db) + if not lic: + return LicenseStatus.community() + return LicenseStatus.from_active(lic) + + +@router.post("/activate", response_model=LicenseStatus) +def activate_license(body: ActivateRequest, db: Session = Depends(get_db)): + """Activate a license key.""" + if not body.key or not body.key.strip(): + raise HTTPException(400, "License key is required.") + try: + lic = license_service.activate_license(body.key, db) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return LicenseStatus.from_active(lic) + + +@router.delete("/deactivate", status_code=204) +def deactivate_license(db: Session = Depends(get_db)): + """Deactivate the current license (revert to Community).""" + license_service.deactivate_license(db) + + +@router.get("/machine-id") +def get_machine_id(): + """Return the machine fingerprint (used for license binding).""" + return {"machine_id": license_service.get_machine_id()} diff --git a/backend/api/routes/marketplace.py b/backend/api/routes/marketplace.py new file mode 100644 index 0000000..f25a479 --- /dev/null +++ b/backend/api/routes/marketplace.py @@ -0,0 +1,124 @@ +"""REST routes for the Plugin Marketplace.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.api.routes.plugins import PluginResponse +from backend.services import marketplace_service + +router = APIRouter(prefix="/marketplace", tags=["marketplace"]) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class MarketplacePlugin(BaseModel): + id: str + name: str + version: str + description: str + author: str + tags: list[str] + stars: int + downloads: int + download_url: str + homepage: str + requires_pro: bool + plugin_type: str + permissions: list[str] + + +class MarketplaceResponse(BaseModel): + total: int + page: int + per_page: int + plugins: list[MarketplacePlugin] + registry_updated_at: str + + +class InstallFromMarketplaceRequest(BaseModel): + download_url: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("", response_model=MarketplaceResponse) +async def browse_marketplace( + q: str = Query("", description="Search query"), + tag: str = Query("", description="Filter by tag"), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=50), +): + """Browse and search the plugin marketplace.""" + result = await marketplace_service.search_plugins( + query=q, tag=tag, page=page, per_page=per_page + ) + # Normalize plugins to ensure all fields are present + normalized: list[dict[str, Any]] = [] + for p in result["plugins"]: + normalized.append({ + "id": p.get("id", ""), + "name": p.get("name", ""), + "version": p.get("version", "0.0.0"), + "description": p.get("description", ""), + "author": p.get("author", ""), + "tags": p.get("tags", []), + "stars": p.get("stars", 0), + "downloads": p.get("downloads", 0), + "download_url": p.get("download_url", ""), + "homepage": p.get("homepage", ""), + "requires_pro": p.get("requires_pro", False), + "plugin_type": p.get("plugin_type", "both"), + "permissions": p.get("permissions", []), + }) + return MarketplaceResponse( + total=result["total"], + page=result["page"], + per_page=result["per_page"], + plugins=normalized, + registry_updated_at=result["registry_updated_at"], + ) + + +@router.get("/tags") +async def get_tags(): + """Return all available marketplace tags.""" + tags = await marketplace_service.get_all_tags() + return {"tags": tags} + + +@router.post("/refresh") +async def refresh_registry(): + """Force-refresh the marketplace registry cache.""" + registry = await marketplace_service.fetch_registry(force_refresh=True) + return { + "ok": True, + "plugin_count": len(registry.get("plugins", [])), + "updated_at": registry.get("updated_at", ""), + } + + +@router.post("/install", response_model=PluginResponse) +async def install_from_marketplace( + body: InstallFromMarketplaceRequest, + db: Session = Depends(get_db), +): + """Download and install a plugin from the marketplace.""" + if not body.download_url: + raise HTTPException(400, "download_url is required") + try: + plugin = await marketplace_service.install_from_marketplace(body.download_url, db) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + except Exception as exc: + raise HTTPException(500, f"Installation failed: {exc}") from exc + return PluginResponse.from_model(plugin) diff --git a/backend/api/routes/payments.py b/backend/api/routes/payments.py new file mode 100644 index 0000000..e4da40c --- /dev/null +++ b/backend/api/routes/payments.py @@ -0,0 +1,93 @@ +"""Payment-related routes. + +/api/payments/checkout-url — returns the Stripe Checkout URL for the given plan +/api/payments/webhook — receives Stripe events (on the licensing server) + +The desktop app uses checkout-url to open the browser for purchasing. +The webhook endpoint would be deployed on a separate web server, not the desktop app. +""" + +from __future__ import annotations + +import logging +import os + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +logger = logging.getLogger("zeronyx.payments") + +router = APIRouter(prefix="/payments", tags=["payments"]) + +# Stripe Checkout URLs (set via environment on the licensing server) +STRIPE_CHECKOUT_PRO = os.getenv( + "STRIPE_CHECKOUT_PRO_URL", + "https://buy.stripe.com/zeronyx_pro", # replace with real Stripe Payment Link +) +STRIPE_CHECKOUT_ENTERPRISE = os.getenv( + "STRIPE_CHECKOUT_ENTERPRISE_URL", + "https://buy.stripe.com/zeronyx_enterprise", +) + + +class CheckoutUrlResponse(BaseModel): + url: str + tier: str + + +@router.get("/checkout-url/{tier}", response_model=CheckoutUrlResponse) +def get_checkout_url(tier: str): + """Return the Stripe Checkout URL for the given tier. + + The frontend opens this URL in the default browser. + """ + if tier == "pro": + return CheckoutUrlResponse(url=STRIPE_CHECKOUT_PRO, tier="pro") + if tier == "enterprise": + return CheckoutUrlResponse(url=STRIPE_CHECKOUT_ENTERPRISE, tier="enterprise") + raise HTTPException(400, f"Unknown tier: {tier}") + + +@router.post("/webhook") +async def stripe_webhook(request: Request): + """Process incoming Stripe webhook events. + + This endpoint is meant to be deployed on your licensing server (not the + desktop app). It verifies the Stripe signature, generates a license key on + successful payment, and should trigger an e-mail to the customer. + + Configure in Stripe Dashboard → Webhooks → Add endpoint: + https://your-server.com/api/payments/webhook + Events to listen for: checkout.session.completed + """ + from backend.services.stripe_service import ( + verify_stripe_webhook, + get_tier_for_price, + generate_license_for_purchase, + ) + + payload = await request.body() + sig_header = request.headers.get("stripe-signature", "") + + try: + event = verify_stripe_webhook(payload, sig_header) + except ValueError as exc: + logger.warning("Stripe webhook rejected: %s", exc) + raise HTTPException(400, str(exc)) from exc + + if event.get("type") == "checkout.session.completed": + session = event["data"]["object"] + email: str = session.get("customer_details", {}).get("email", "") + price_id: str = (session.get("line_items", {}).get("data", [{}])[0] + .get("price", {}).get("id", "")) + tier = get_tier_for_price(price_id) + + if email: + try: + license_key = generate_license_for_purchase(email, tier) + logger.info("License generated for %s (%s): %s…", email, tier, license_key[:40]) + # TODO: send license_key to email via your mail service + except Exception as exc: + logger.error("Failed to generate license for %s: %s", email, exc) + + return {"received": True} diff --git a/backend/api/routes/plugins.py b/backend/api/routes/plugins.py new file mode 100644 index 0000000..6a95b23 --- /dev/null +++ b/backend/api/routes/plugins.py @@ -0,0 +1,224 @@ +"""REST routes for plugin management.""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from backend.database import get_db +from backend.models.plugin import Plugin +from backend.services.plugin_manager import get_plugin_manager + +router = APIRouter(prefix="/plugins", tags=["plugins"]) + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class PluginResponse(BaseModel): + id: str + name: str + version: str + description: str + author: str + plugin_type: str + permissions: list[str] + ui_slots: list[str] + hooks: list[str] + settings: dict[str, Any] + settings_values: dict[str, Any] + enabled: bool + permissions_granted: bool + installed_at: str + updated_at: str + error: str | None + + @classmethod + def from_model(cls, p: Plugin) -> "PluginResponse": + return cls( + id=p.id, + name=p.name, + version=p.version, + description=p.description, + author=p.author, + plugin_type=p.plugin_type, + permissions=json.loads(p.permissions or "[]"), + ui_slots=json.loads(p.ui_slots or "[]"), + hooks=json.loads(p.hooks or "[]"), + settings=json.loads(p.settings or "{}"), + settings_values=json.loads(p.settings_values or "{}"), + enabled=p.enabled, + permissions_granted=p.permissions_granted, + installed_at=p.installed_at.isoformat(), + updated_at=p.updated_at.isoformat(), + error=p.error, + ) + + +class PluginSettingsUpdate(BaseModel): + values: dict[str, Any] + + +class PluginToggle(BaseModel): + enabled: bool + + +class PermissionGrant(BaseModel): + granted: bool + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("", response_model=list[PluginResponse]) +def list_plugins(db: Session = Depends(get_db)): + """List all installed plugins.""" + plugins = db.query(Plugin).order_by(Plugin.name).all() + return [PluginResponse.from_model(p) for p in plugins] + + +@router.post("/install", response_model=PluginResponse, status_code=201) +async def install_plugin( + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + """Install a plugin from a .zeronyx-plugin zip file.""" + if not file.filename or not file.filename.endswith(".zeronyx-plugin"): + raise HTTPException(400, "File must be a .zeronyx-plugin archive") + + manager = get_plugin_manager() + + # Save upload to temp file + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + content = await file.read() + tmp.write(content) + tmp_path = Path(tmp.name) + + try: + plugin = manager.install_from_zip(tmp_path, db) + except Exception as exc: + raise HTTPException(400, f"Failed to install plugin: {exc}") from exc + finally: + tmp_path.unlink(missing_ok=True) + + return PluginResponse.from_model(plugin) + + +@router.post("/install-dir", response_model=PluginResponse, status_code=201) +def install_plugin_dir( + body: dict, + db: Session = Depends(get_db), +): + """Install a plugin from a local directory path (dev/debug use).""" + path = body.get("path") + if not path: + raise HTTPException(400, "path required") + + plugin_dir = Path(path) + if not plugin_dir.exists() or not plugin_dir.is_dir(): + raise HTTPException(400, f"Directory not found: {path}") + + manager = get_plugin_manager() + try: + plugin = manager.install_from_dir(plugin_dir, db, permissions_granted=False) + except Exception as exc: + raise HTTPException(400, f"Failed to install plugin: {exc}") from exc + + return PluginResponse.from_model(plugin) + + +@router.get("/{plugin_id}", response_model=PluginResponse) +def get_plugin(plugin_id: str, db: Session = Depends(get_db)): + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise HTTPException(404, "Plugin not found") + return PluginResponse.from_model(plugin) + + +@router.delete("/{plugin_id}", status_code=204) +def uninstall_plugin(plugin_id: str, db: Session = Depends(get_db)): + manager = get_plugin_manager() + try: + manager.uninstall(plugin_id, db) + except ValueError as exc: + raise HTTPException(404, str(exc)) from exc + + +@router.patch("/{plugin_id}/toggle", response_model=PluginResponse) +def toggle_plugin(plugin_id: str, body: PluginToggle, db: Session = Depends(get_db)): + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise HTTPException(404, "Plugin not found") + plugin.enabled = body.enabled + db.commit() + db.refresh(plugin) + return PluginResponse.from_model(plugin) + + +@router.patch("/{plugin_id}/permissions", response_model=PluginResponse) +def grant_permissions(plugin_id: str, body: PermissionGrant, db: Session = Depends(get_db)): + """Grant or revoke permission approval for a plugin.""" + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise HTTPException(404, "Plugin not found") + + plugin.permissions_granted = body.granted + db.commit() + db.refresh(plugin) + + # If granted, attempt to load backend module now + if body.granted and plugin.enabled: + manager = get_plugin_manager() + ok = manager.load_backend_module(plugin) + if not ok: + plugin.error = "Backend module failed to load — check plugin files" + db.commit() + db.refresh(plugin) + + return PluginResponse.from_model(plugin) + + +@router.patch("/{plugin_id}/settings", response_model=PluginResponse) +def update_plugin_settings( + plugin_id: str, + body: PluginSettingsUpdate, + db: Session = Depends(get_db), +): + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise HTTPException(404, "Plugin not found") + manager = get_plugin_manager() + updated = manager.update_settings(plugin, body.values, db) + return PluginResponse.from_model(updated) + + +@router.get("/{plugin_id}/frontend-bundle") +def get_frontend_bundle(plugin_id: str, db: Session = Depends(get_db)): + """Return the compiled frontend JS bundle for a plugin.""" + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise HTTPException(404, "Plugin not found") + if not plugin.permissions_granted or not plugin.enabled: + raise HTTPException(403, "Plugin not active") + + manager = get_plugin_manager() + try: + manifest = manager.load_manifest(Path(plugin.install_path)) + except Exception as exc: + raise HTTPException(500, str(exc)) from exc + + bundle_path = Path(plugin.install_path) / manifest.entry_frontend + if not bundle_path.exists(): + raise HTTPException(404, "Frontend bundle not found") + + from fastapi.responses import FileResponse + return FileResponse(bundle_path, media_type="application/javascript") diff --git a/backend/main.py b/backend/main.py index efa1112..9990d85 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, plugins, license, payments, marketplace from backend.api.websocket import scan_stream logging.basicConfig( @@ -45,11 +45,26 @@ def _check_tools() -> None: logger.warning("Tools not found (install to use): %s", ", ".join(missing)) +def _load_plugins() -> None: + """Load enabled plugins at startup.""" + from backend.database import SessionLocal + from backend.services.plugin_manager import get_plugin_manager + db = SessionLocal() + try: + mgr = get_plugin_manager() + mgr.load_all_enabled(db) + except Exception as exc: + logger.warning("Plugin loading encountered errors: %s", exc) + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): logger.info(f"ZeroNyx backend starting (env={settings.env})") _run_migrations() _check_tools() + _load_plugins() yield logger.info("ZeroNyx backend shutting down") @@ -121,6 +136,12 @@ 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") +app.include_router(plugins.router, prefix="/api") +app.include_router(license.router, prefix="/api") +app.include_router(payments.router, prefix="/api") +app.include_router(marketplace.router, prefix="/api") # WebSocket routers app.include_router(scan_stream.router) diff --git a/backend/migrations/versions/0fad6f848916_add_plugins_table.py b/backend/migrations/versions/0fad6f848916_add_plugins_table.py new file mode 100644 index 0000000..8a3e3d0 --- /dev/null +++ b/backend/migrations/versions/0fad6f848916_add_plugins_table.py @@ -0,0 +1,51 @@ +"""add plugins table + +Revision ID: 0fad6f848916 +Revises: a1b2c3d4e5f6 +Create Date: 2026-03-28 16:18:23.225591 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0fad6f848916' +down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('plugins', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('version', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('author', sa.String(), nullable=False), + sa.Column('plugin_type', sa.String(), nullable=False), + sa.Column('install_path', sa.String(), nullable=False), + sa.Column('permissions', sa.Text(), nullable=False), + sa.Column('ui_slots', sa.Text(), nullable=False), + sa.Column('hooks', sa.Text(), nullable=False), + sa.Column('settings', sa.Text(), nullable=False), + sa.Column('settings_values', sa.Text(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('permissions_granted', sa.Boolean(), nullable=False), + sa.Column('installed_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('error', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('plugins') + # ### end Alembic commands ### 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/migrations/versions/c6d7e8f9a0b1_add_license_table.py b/backend/migrations/versions/c6d7e8f9a0b1_add_license_table.py new file mode 100644 index 0000000..61b8300 --- /dev/null +++ b/backend/migrations/versions/c6d7e8f9a0b1_add_license_table.py @@ -0,0 +1,40 @@ +"""add licenses table + +Revision ID: c6d7e8f9a0b1 +Revises: 0fad6f848916 +Create Date: 2026-03-28 18:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c6d7e8f9a0b1' +down_revision: Union[str, Sequence[str], None] = '0fad6f848916' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'licenses', + sa.Column('id', sa.String(), nullable=False), + sa.Column('key_id', sa.String(), nullable=False), + sa.Column('raw_key', sa.Text(), nullable=False), + sa.Column('tier', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False, server_default=''), + sa.Column('machine_id', sa.String(), nullable=False), + sa.Column('features', sa.Text(), nullable=False, server_default='[]'), + sa.Column('issued_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('activated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('key_id'), + ) + + +def downgrade() -> None: + op.drop_table('licenses') diff --git a/backend/models/__init__.py b/backend/models/__init__.py index d4e2bc4..e79f612 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -10,6 +10,10 @@ 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 +from backend.models.plugin import Plugin +from backend.models.license import License __all__ = [ "Base", @@ -24,4 +28,9 @@ "Credential", "Note", "ProxyRequest", + "AIAnalysis", + "Chain", + "ChainRun", + "Plugin", + "License", ] 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/models/license.py b/backend/models/license.py new file mode 100644 index 0000000..4579c1f --- /dev/null +++ b/backend/models/license.py @@ -0,0 +1,27 @@ +"""SQLAlchemy model for activated license.""" + +from datetime import datetime, timezone +from sqlalchemy import String, Text, DateTime, Boolean +from sqlalchemy.orm import Mapped, mapped_column + +from backend.models.base import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class License(Base): + __tablename__ = "licenses" + + id: Mapped[str] = mapped_column(String, primary_key=True) # UUID + key_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) # from JWT claim + raw_key: Mapped[str] = mapped_column(Text, nullable=False) # original JWT string + tier: Mapped[str] = mapped_column(String, nullable=False) # community / pro / enterprise + email: Mapped[str] = mapped_column(String, default="") + machine_id: Mapped[str] = mapped_column(String, nullable=False) + features: Mapped[str] = mapped_column(Text, default="[]") # JSON array of feature names + issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + activated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) diff --git a/backend/models/plugin.py b/backend/models/plugin.py new file mode 100644 index 0000000..49252a0 --- /dev/null +++ b/backend/models/plugin.py @@ -0,0 +1,33 @@ +"""SQLAlchemy model for installed plugins.""" + +from sqlalchemy import String, Boolean, Text, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime, timezone + +from backend.models.base import Base + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Plugin(Base): + __tablename__ = "plugins" + + id: Mapped[str] = mapped_column(String, primary_key=True) # plugin manifest id + name: Mapped[str] = mapped_column(String, nullable=False) + version: Mapped[str] = mapped_column(String, nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + author: Mapped[str] = mapped_column(String, default="") + plugin_type: Mapped[str] = mapped_column(String, default="both") # backend/frontend/both + install_path: Mapped[str] = mapped_column(String, nullable=False) # absolute path to plugin dir + permissions: Mapped[str] = mapped_column(Text, default="[]") # JSON array + ui_slots: Mapped[str] = mapped_column(Text, default="[]") # JSON array + hooks: Mapped[str] = mapped_column(Text, default="[]") # JSON array + settings: Mapped[str] = mapped_column(Text, default="{}") # JSON: schema definitions + settings_values: Mapped[str] = mapped_column(Text, default="{}") # JSON: user values + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + permissions_granted: Mapped[bool] = mapped_column(Boolean, default=False) + installed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + error: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0680ffd..771f2d6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,5 @@ mitmproxy>=10.0.0 pymetasploit3>=1.0 shodan>=1.31.0 censys>=2.2.0 +httpx>=0.27.0 +PyJWT[crypto]>=2.8.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/license_service.py b/backend/services/license_service.py new file mode 100644 index 0000000..791f9e8 --- /dev/null +++ b/backend/services/license_service.py @@ -0,0 +1,189 @@ +"""License validation and management service. + +License keys are RS256-signed JWTs. The app embeds only the RSA public key. +The private key lives on the licensing server and is used to issue keys. + +JWT Payload schema: + { + "jti": "", # key ID + "sub": "", # licensee e-mail + "tier": "community|pro|enterprise", + "iat": , + "exp": | absent, # absent = perpetual + "feat": ["", ...] # unlocked feature flags + } +""" + +from __future__ import annotations + +import hashlib +import json +import platform +import socket +import uuid +from datetime import datetime, timezone +from typing import Any + +import jwt +from jwt.exceptions import InvalidTokenError + +from sqlalchemy.orm import Session + +from backend.models.license import License + +# --------------------------------------------------------------------------- +# Embedded RSA public key (2048-bit, generated for ZeroNyx) +# The corresponding private key is kept securely on the licensing server. +# --------------------------------------------------------------------------- +_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr1O4Op1130Bll5og4GYL +xxYHDDVnvygBz9hY+HJPngRJNI/6YfN0zpMP7KX4zK8jSuldSPQUH8FhDgy0D2T1 +Snpw8lJH1C9XI+QZWu5aVVD9/KjbGwk8IiWsIpvw//3clAr59+lPTSPpMZhCDDhr +dfPyjttyOGeJT5bkGycpd38ZtHWx2bBKxmnhobSW6paV16OncX/EpSS9H0VzNrwL +PMD3c03sOQZ36bsofOLis01ke1UbmWd68JYKFLQjlGt+y1AZwtqOT+8HxPySVPPV +6QiSX+1DWrs3LpL3zgf5YwxfnI+aB+Hod0ShxdZdpz66I8cbEJ+qlsDFyRm1JXAF +NwIDAQAB +-----END PUBLIC KEY-----""" + +# Pro feature flags — strings checked via is_feature_enabled() +PRO_FEATURES = { + "ai_analysis", + "chain_engine", + "obsidian_sync", + "plugin_marketplace", + "advanced_reports", + "team_mode", +} + + +# --------------------------------------------------------------------------- +# Machine fingerprinting +# --------------------------------------------------------------------------- + +def get_machine_id() -> str: + """Return a stable SHA-256 fingerprint for this machine.""" + parts = [ + socket.gethostname(), + platform.node(), + platform.machine(), + platform.processor(), + str(uuid.getnode()), # MAC address as int + ] + raw = "|".join(p for p in parts if p) + return hashlib.sha256(raw.encode()).hexdigest() + + +# --------------------------------------------------------------------------- +# JWT verification +# --------------------------------------------------------------------------- + +def _decode_key(raw_key: str) -> dict[str, Any]: + """Decode and verify a license JWT. Raises InvalidTokenError on failure.""" + options: dict[str, Any] = { + "verify_signature": True, + "require": ["jti", "sub", "tier", "iat"], + } + payload = jwt.decode( + raw_key, + _PUBLIC_KEY_PEM, + algorithms=["RS256"], + options=options, + ) + return payload + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def activate_license(raw_key: str, db: Session) -> License: + """Validate and persist a license key. Returns the License record. + + Raises ValueError with a human-readable message on failure. + """ + raw_key = raw_key.strip() + + # 1. Verify JWT signature and claims + try: + payload = _decode_key(raw_key) + except InvalidTokenError as exc: + raise ValueError(f"Invalid license key: {exc}") from exc + + key_id: str = payload["jti"] + email: str = payload.get("sub", "") + tier: str = payload.get("tier", "community") + features: list[str] = payload.get("feat", []) + iat = datetime.fromtimestamp(payload["iat"], tz=timezone.utc) + exp_ts = payload.get("exp") + expires_at = datetime.fromtimestamp(exp_ts, tz=timezone.utc) if exp_ts else None + + # 2. Check expiry + if expires_at and expires_at < datetime.now(timezone.utc): + raise ValueError("License key has expired.") + + # 3. Deactivate any existing license + db.query(License).filter(License.is_active == True).update({"is_active": False}) # noqa: E712 + + # 4. Check if this key was already activated on this machine + existing = db.query(License).filter(License.key_id == key_id).first() + if existing: + existing.is_active = True + db.commit() + db.refresh(existing) + return existing + + # 5. Store new license + lic = License( + id=str(uuid.uuid4()), + key_id=key_id, + raw_key=raw_key, + tier=tier, + email=email, + machine_id=get_machine_id(), + features=json.dumps(features), + issued_at=iat, + expires_at=expires_at, + is_active=True, + ) + db.add(lic) + db.commit() + db.refresh(lic) + return lic + + +def deactivate_license(db: Session) -> None: + """Remove the active license (revert to Community tier).""" + db.query(License).filter(License.is_active == True).update({"is_active": False}) # noqa: E712 + db.commit() + + +def get_active_license(db: Session) -> License | None: + """Return the currently active License record, or None.""" + return db.query(License).filter(License.is_active == True).first() # noqa: E712 + + +def get_tier(db: Session) -> str: + """Return the current tier: 'community', 'pro', or 'enterprise'.""" + lic = get_active_license(db) + if not lic: + return "community" + # Re-verify expiry on every check + if lic.expires_at and lic.expires_at < datetime.now(timezone.utc): + deactivate_license(db) + return "community" + return lic.tier + + +def is_pro(db: Session) -> bool: + return get_tier(db) in ("pro", "enterprise") + + +def is_feature_enabled(feature: str, db: Session) -> bool: + """Check if a specific named feature is unlocked.""" + lic = get_active_license(db) + if not lic: + return False + if lic.expires_at and lic.expires_at < datetime.now(timezone.utc): + return False + features: list[str] = json.loads(lic.features or "[]") + return feature in features or lic.tier == "enterprise" diff --git a/backend/services/marketplace_service.py b/backend/services/marketplace_service.py new file mode 100644 index 0000000..58af3a0 --- /dev/null +++ b/backend/services/marketplace_service.py @@ -0,0 +1,162 @@ +"""Plugin Marketplace service. + +The marketplace is powered by a GitHub-hosted registry.json file. +The registry lists all published plugins with metadata and download URLs. + +Registry URL (configurable via ZERONYX_MARKETPLACE_URL env var): + https://raw.githubusercontent.com/zeronyx-plugins/registry/main/registry.json + +Registry schema: + { + "version": "1", + "updated_at": "2026-01-01T00:00:00Z", + "plugins": [ + { + "id": "whois-lookup", + "name": "WHOIS Lookup", + "version": "1.2.0", + "description": "Run WHOIS on targets when added", + "author": "zeronyx-labs", + "tags": ["recon", "osint"], + "stars": 42, + "downloads": 1234, + "download_url": "https://github.com/.../releases/download/v1.2.0/whois-lookup.zeronyx-plugin", + "homepage": "https://github.com/zeronyx-plugins/whois-lookup", + "requires_pro": false, + "plugin_type": "backend", + "permissions": ["scan:read", "targets:read"] + } + ] + } +""" + +from __future__ import annotations + +import logging +import os +import time +import tempfile +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger("zeronyx.marketplace") + +REGISTRY_URL = os.getenv( + "ZERONYX_MARKETPLACE_URL", + "https://raw.githubusercontent.com/zeronyx-plugins/registry/main/registry.json", +) + +# Cache TTL in seconds (1 hour) +_CACHE_TTL = 3600 +_cache: dict[str, Any] = {} +_cache_ts: float = 0.0 + + +async def fetch_registry(force_refresh: bool = False) -> dict[str, Any]: + """Fetch the marketplace registry, using an in-memory cache.""" + global _cache, _cache_ts + + now = time.monotonic() + if not force_refresh and _cache and (now - _cache_ts) < _CACHE_TTL: + return _cache + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(REGISTRY_URL) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + except Exception as exc: + logger.warning("Failed to fetch marketplace registry: %s", exc) + if _cache: + return _cache + return {"version": "0", "plugins": [], "error": str(exc)} + + _cache = data + _cache_ts = now + logger.info("Marketplace registry refreshed: %d plugins", len(data.get("plugins", []))) + return _cache + + +def _matches(plugin: dict[str, Any], query: str) -> bool: + q = query.lower() + return ( + q in plugin.get("name", "").lower() + or q in plugin.get("description", "").lower() + or any(q in tag for tag in plugin.get("tags", [])) + or q in plugin.get("author", "").lower() + ) + + +async def search_plugins( + query: str = "", + tag: str = "", + page: int = 1, + per_page: int = 20, +) -> dict[str, Any]: + """Search and paginate marketplace plugins.""" + registry = await fetch_registry() + plugins: list[dict] = registry.get("plugins", []) + + if query: + plugins = [p for p in plugins if _matches(p, query)] + if tag: + plugins = [p for p in plugins if tag in p.get("tags", [])] + + total = len(plugins) + start = (page - 1) * per_page + page_plugins = plugins[start: start + per_page] + + return { + "total": total, + "page": page, + "per_page": per_page, + "plugins": page_plugins, + "registry_updated_at": registry.get("updated_at", ""), + } + + +async def install_from_marketplace( + download_url: str, + db: Any, +) -> Any: + """Download a .zeronyx-plugin from a URL and install it.""" + from backend.services.plugin_manager import get_plugin_manager + + # Validate URL is from a trusted domain (basic allow-list) + allowed_hosts = {"github.com", "raw.githubusercontent.com", "objects.githubusercontent.com"} + from urllib.parse import urlparse + parsed = urlparse(download_url) + if parsed.netloc not in allowed_hosts: + raise ValueError( + f"Download URL host '{parsed.netloc}' is not in the allowed list. " + "Only GitHub URLs are accepted for marketplace installs." + ) + + logger.info("Downloading marketplace plugin from %s", download_url) + + async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: + resp = await client.get(download_url) + resp.raise_for_status() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + tmp.write(resp.content) + tmp_path = Path(tmp.name) + + try: + manager = get_plugin_manager() + plugin = manager.install_from_zip(tmp_path, db) + finally: + tmp_path.unlink(missing_ok=True) + + return plugin + + +async def get_all_tags() -> list[str]: + """Return all unique tags across marketplace plugins.""" + registry = await fetch_registry() + tags: set[str] = set() + for plugin in registry.get("plugins", []): + tags.update(plugin.get("tags", [])) + return sorted(tags) 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/plugin_manager.py b/backend/services/plugin_manager.py new file mode 100644 index 0000000..822ce2a --- /dev/null +++ b/backend/services/plugin_manager.py @@ -0,0 +1,339 @@ +"""Plugin Manager Service. + +Handles loading, validating, enabling/disabling and calling hooks on installed plugins. +Plugins live in a per-user plugins directory (~/.zeronyx/plugins//) +and additionally in the bundled ./plugins/ directory shipped with the app. + +Each plugin directory must contain a valid manifest.json. +""" + +from __future__ import annotations + +import importlib.util +import json +import logging +import os +import shutil +import sys +import zipfile +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy.orm import Session + +from backend.models.plugin import Plugin + +logger = logging.getLogger("zeronyx.plugins") + +# --------------------------------------------------------------------------- +# Pydantic schema for manifest.json +# --------------------------------------------------------------------------- + +ALLOWED_PERMISSIONS = { + "scan:read", "scan:write", + "findings:read", "findings:write", + "targets:read", "targets:write", + "credentials:read", "credentials:write", + "hosts:read", + "proxy:read", + "settings:read", + "network:outbound", + "filesystem:read", "filesystem:write", +} + +ALLOWED_UI_SLOTS = { + "sidebar_nav", "dashboard_widget", "scan_result_panel", + "finding_detail_panel", "target_panel", "toolbar_action", + "settings_tab", "report_section", +} + +ALLOWED_HOOKS = { + "on_scan_complete", "on_finding_created", "on_target_added", + "on_project_opened", "on_report_generate", +} + + +class PluginSettingSchema(BaseModel): + type: str + label: str + description: str = "" + required: bool = False + secret: bool = False + default: Any = None + options: list[dict] = Field(default_factory=list) + + +class PluginManifest(BaseModel): + id: str + name: str + version: str + description: str = "" + author: str = "" + homepage: str = "" + license: str = "" + zeronyx_min_version: str = "0.1.0" + type: str = "both" + permissions: list[str] = Field(default_factory=list) + entry_backend: str = "main.py" + entry_frontend: str = "dist/index.js" + ui_slots: list[str] = Field(default_factory=list) + hooks: list[str] = Field(default_factory=list) + settings: dict[str, PluginSettingSchema] = Field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Plugin Manager +# --------------------------------------------------------------------------- + +class PluginManager: + """Central manager for all plugin lifecycle operations.""" + + def __init__(self, user_plugin_dir: Path | None = None): + self._user_dir = user_plugin_dir or Path.home() / ".zeronyx" / "plugins" + self._bundled_dir = Path(__file__).parent.parent.parent / "plugins" / "examples" + self._loaded_modules: dict[str, Any] = {} # plugin_id -> module + self._hooks: dict[str, list[tuple[str, Any]]] = {} # hook_name -> [(plugin_id, callable)] + self._user_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + + def _plugin_dirs(self) -> list[Path]: + """Return all directories that could contain plugin sub-dirs.""" + dirs = [self._user_dir] + if self._bundled_dir.exists(): + dirs.append(self._bundled_dir) + return dirs + + def discover_installed(self) -> list[Path]: + """Find all plugin directories (those with manifest.json).""" + found: list[Path] = [] + for root in self._plugin_dirs(): + for entry in sorted(root.iterdir()): + if entry.is_dir() and (entry / "manifest.json").exists(): + found.append(entry) + return found + + # ------------------------------------------------------------------ + # Manifest parsing + # ------------------------------------------------------------------ + + def load_manifest(self, plugin_dir: Path) -> PluginManifest: + """Parse and validate manifest.json from a plugin directory.""" + manifest_path = plugin_dir / "manifest.json" + if not manifest_path.exists(): + raise ValueError(f"No manifest.json in {plugin_dir}") + with open(manifest_path, "r", encoding="utf-8") as f: + raw = json.load(f) + try: + manifest = PluginManifest(**raw) + except (ValidationError, TypeError) as exc: + raise ValueError(f"Invalid manifest in {plugin_dir}: {exc}") from exc + + # Validate id is safe (no path traversal) + if "/" in manifest.id or "\\" in manifest.id or ".." in manifest.id: + raise ValueError(f"Plugin id contains illegal characters: {manifest.id}") + + # Warn about unknown permissions/slots/hooks but don't block + unknown_perms = set(manifest.permissions) - ALLOWED_PERMISSIONS + if unknown_perms: + logger.warning("Plugin %s requests unknown permissions: %s", manifest.id, unknown_perms) + + return manifest + + # ------------------------------------------------------------------ + # Install / Uninstall + # ------------------------------------------------------------------ + + def install_from_zip(self, zip_path: Path, db: Session) -> Plugin: + """Install a plugin from a .zeronyx-plugin zip archive.""" + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + if "manifest.json" not in names: + raise ValueError("Plugin archive missing manifest.json") + manifest_bytes = zf.read("manifest.json") + raw = json.loads(manifest_bytes) + manifest = PluginManifest(**raw) + + dest = self._user_dir / manifest.id + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + zf.extractall(dest) + + return self._register_plugin(manifest, dest, db, permissions_granted=False) + + def install_from_dir(self, source_dir: Path, db: Session, permissions_granted: bool = False) -> Plugin: + """Install a plugin from a local directory (dev mode or bundled).""" + manifest = self.load_manifest(source_dir) + dest = self._user_dir / manifest.id + + if dest != source_dir: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(source_dir, dest) + + return self._register_plugin(manifest, dest, db, permissions_granted=permissions_granted) + + def _register_plugin( + self, manifest: PluginManifest, install_path: Path, db: Session, + permissions_granted: bool = False + ) -> Plugin: + """Create or update plugin DB record.""" + existing = db.get(Plugin, manifest.id) + if existing: + existing.name = manifest.name + existing.version = manifest.version + existing.description = manifest.description + existing.author = manifest.author + existing.plugin_type = manifest.type + existing.install_path = str(install_path) + existing.permissions = json.dumps(manifest.permissions) + existing.ui_slots = json.dumps(manifest.ui_slots) + existing.hooks = json.dumps(manifest.hooks) + existing.settings = json.dumps({k: v.model_dump() for k, v in manifest.settings.items()}) + existing.permissions_granted = permissions_granted + existing.error = None + db.commit() + db.refresh(existing) + return existing + + plugin = Plugin( + id=manifest.id, + name=manifest.name, + version=manifest.version, + description=manifest.description, + author=manifest.author, + plugin_type=manifest.type, + install_path=str(install_path), + permissions=json.dumps(manifest.permissions), + ui_slots=json.dumps(manifest.ui_slots), + hooks=json.dumps(manifest.hooks), + settings=json.dumps({k: v.model_dump() for k, v in manifest.settings.items()}), + settings_values="{}", + enabled=True, + permissions_granted=permissions_granted, + error=None, + ) + db.add(plugin) + db.commit() + db.refresh(plugin) + return plugin + + def uninstall(self, plugin_id: str, db: Session) -> None: + """Remove a plugin from disk and database.""" + plugin = db.get(Plugin, plugin_id) + if not plugin: + raise ValueError(f"Plugin {plugin_id!r} not found") + + install_path = Path(plugin.install_path) + if install_path.exists() and install_path.parent == self._user_dir: + shutil.rmtree(install_path) + + self._unload_module(plugin_id) + db.delete(plugin) + db.commit() + + # ------------------------------------------------------------------ + # Loading backend modules + # ------------------------------------------------------------------ + + def load_backend_module(self, plugin: Plugin) -> bool: + """ + Dynamically import the plugin's backend Python module. + Returns True on success, False on error. + """ + if plugin.plugin_type == "frontend": + return True # no backend component + if not plugin.permissions_granted or not plugin.enabled: + return False + + try: + manifest = self.load_manifest(Path(plugin.install_path)) + entry = Path(plugin.install_path) / manifest.entry_backend + if not entry.exists(): + logger.warning("Plugin %s backend entry not found: %s", plugin.id, entry) + return False + + spec = importlib.util.spec_from_file_location(f"zeronyx_plugin_{plugin.id}", entry) + if spec is None or spec.loader is None: + return False + + module = importlib.util.module_from_spec(spec) + sys.modules[f"zeronyx_plugin_{plugin.id}"] = module + spec.loader.exec_module(module) # type: ignore[attr-defined] + self._loaded_modules[plugin.id] = module + + # Register hooks + for hook_name in ALLOWED_HOOKS: + handler = getattr(module, hook_name, None) + if handler and callable(handler): + self._hooks.setdefault(hook_name, []).append((plugin.id, handler)) + + logger.info("Plugin %s backend loaded", plugin.id) + return True + + except Exception as exc: + logger.exception("Failed to load plugin %s backend: %s", plugin.id, exc) + return False + + def _unload_module(self, plugin_id: str) -> None: + """Remove a loaded module from memory and hooks.""" + self._loaded_modules.pop(plugin_id, None) + module_key = f"zeronyx_plugin_{plugin_id}" + sys.modules.pop(module_key, None) + for hook_list in self._hooks.values(): + hook_list[:] = [(pid, fn) for pid, fn in hook_list if pid != plugin_id] + + def load_all_enabled(self, db: Session) -> None: + """Load all enabled, permission-granted backend plugins at startup.""" + plugins = db.query(Plugin).filter(Plugin.enabled == True, Plugin.permissions_granted == True).all() + for plugin in plugins: + self.load_backend_module(plugin) + + # ------------------------------------------------------------------ + # Hook dispatch + # ------------------------------------------------------------------ + + async def dispatch_hook(self, hook_name: str, payload: dict) -> None: + """Call all registered handlers for a hook. Errors are logged, not raised.""" + handlers = self._hooks.get(hook_name, []) + for plugin_id, handler in handlers: + try: + import asyncio + if asyncio.iscoroutinefunction(handler): + await handler(payload) + else: + handler(payload) + except Exception as exc: + logger.exception("Plugin %s hook %s error: %s", plugin_id, hook_name, exc) + + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + def update_settings(self, plugin: Plugin, values: dict, db: Session) -> Plugin: + plugin.settings_values = json.dumps(values) + db.commit() + db.refresh(plugin) + return plugin + + def get_plugin_context(self, plugin_id: str) -> dict: + """Return settings values for a plugin (passed to module calls).""" + return {} # loaded modules read from DB directly via context injection + + +# --------------------------------------------------------------------------- +# Singleton instance (initialised at app startup) +# --------------------------------------------------------------------------- + +_manager: PluginManager | None = None + + +def get_plugin_manager() -> PluginManager: + global _manager + if _manager is None: + _manager = PluginManager() + return _manager 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/backend/services/stripe_service.py b/backend/services/stripe_service.py new file mode 100644 index 0000000..83ca20c --- /dev/null +++ b/backend/services/stripe_service.py @@ -0,0 +1,83 @@ +"""Stripe webhook processing and license issuance. + +Flow: + 1. User clicks "Upgrade to Pro" in the app → opens Stripe Checkout in browser + 2. After payment Stripe POSTs a webhook to your licensing server + 3. The licensing server calls generate_license_for_purchase() → returns a JWT key + 4. The key is e-mailed to the customer (or shown on the success page) + 5. Customer pastes the key in Settings → License to activate + +This service runs on the LICENSING SERVER (not the desktop app). +The desktop app only consumes the license key through license_service.py. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from typing import Any + +STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "") +STRIPE_PRO_PRICE_ID = os.getenv("STRIPE_PRO_PRICE_ID", "price_XXXXX") +STRIPE_ENTERPRISE_PRICE_ID = os.getenv("STRIPE_ENTERPRISE_PRICE_ID", "price_YYYYY") + + +def verify_stripe_webhook(payload: bytes, sig_header: str) -> dict[str, Any]: + """Verify Stripe webhook signature and return parsed event. + + Raises ValueError if the signature is invalid. + """ + if not STRIPE_WEBHOOK_SECRET: + raise ValueError("STRIPE_WEBHOOK_SECRET is not configured.") + + # Stripe signature format: t=,v1= + parts = {k: v for k, v in (part.split("=", 1) for part in sig_header.split(",") if "=" in part)} + timestamp = parts.get("t", "") + v1_sig = parts.get("v1", "") + + if not timestamp or not v1_sig: + raise ValueError("Malformed Stripe-Signature header.") + + signed_payload = f"{timestamp}.{payload.decode()}" + expected = hmac.new( + STRIPE_WEBHOOK_SECRET.encode(), + signed_payload.encode(), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(expected, v1_sig): + raise ValueError("Stripe webhook signature verification failed.") + + return json.loads(payload) + + +def get_tier_for_price(price_id: str) -> str: + """Map a Stripe price ID to a ZeroNyx tier.""" + if price_id == STRIPE_PRO_PRICE_ID: + return "pro" + if price_id == STRIPE_ENTERPRISE_PRICE_ID: + return "enterprise" + return "pro" + + +def generate_license_for_purchase(email: str, tier: str) -> str: + """Generate a license JWT for a completed purchase. + + This is called by the licensing server after a successful Stripe webhook. + It uses the same generate() function as the CLI key generator. + """ + import sys + import os + + # Add the project root to sys.path so the script import works + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + from scripts.generate_license_key import generate + + # Pro: 1-year subscription; Enterprise: perpetual + days = 365 if tier == "pro" else None + return generate(email=email, tier=tier, features=None, days=days) diff --git a/docs/PLUGIN_SDK.md b/docs/PLUGIN_SDK.md new file mode 100644 index 0000000..0f2aa3d --- /dev/null +++ b/docs/PLUGIN_SDK.md @@ -0,0 +1,212 @@ +# ZeroNyx Plugin SDK + +Plugins extend ZeroNyx with new functionality. They can add backend logic (Python), +frontend UI components (React), or both. + +--- + +## Quick Start + +### 1. Create a plugin directory + +``` +my-plugin/ +├── manifest.json # Required: plugin metadata +├── main.py # Backend entry point (optional) +└── dist/ + └── index.js # Compiled frontend bundle (optional) +``` + +### 2. Write `manifest.json` + +```json +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "1.0.0", + "description": "Does something useful", + "author": "Your Name", + "zeronyx_min_version": "0.1.0", + "type": "both", + "permissions": ["scan:read", "findings:write"], + "entry_backend": "main.py", + "entry_frontend": "dist/index.js", + "ui_slots": ["scan_result_panel"], + "hooks": ["on_scan_complete"], + "settings": { + "api_key": { + "type": "string", + "label": "API Key", + "description": "Your service API key", + "required": false, + "secret": true + } + } +} +``` + +### 3. Write the backend (`main.py`) + +```python +from zeronyx_plugin_sdk import ZeroNyxPlugin, PluginContext + +class MyPlugin(ZeroNyxPlugin): + async def on_scan_complete(self, ctx: PluginContext, payload: dict): + scan_id = payload["scan_id"] + project_id = payload["project_id"] + + # Read scan results + scan = await ctx.api.get_scan(scan_id) + + # Create a finding based on results + await ctx.api.create_finding(project_id, { + "title": "Custom Finding from MyPlugin", + "severity": "info", + "description": f"Processed scan {scan_id}", + "tool_source": "my-plugin", + }) + +# The plugin manager discovers the first ZeroNyxPlugin subclass automatically +plugin = MyPlugin() +``` + +### 4. Write the frontend (`src/index.tsx`) + +```tsx +import React from 'react' + +interface Props { + scanId?: string + [key: string]: unknown +} + +function ScanResultPanel({ scanId }: Props) { + return ( +
+

MyPlugin Output

+

Scan ID: {scanId}

+
+ ) +} + +// Register components for each UI slot +const registry = (window as any).__zeronyx_plugins ?? {}; +(window as any).__zeronyx_plugins = { + ...registry, + 'my-plugin': { + scan_result_panel: ScanResultPanel, + }, +} +``` + +Build with your preferred bundler (Vite, esbuild, rollup) targeting `iife` or `umd` format. + +--- + +## Manifest Reference + +| Field | Type | Required | Description | +|---|---|:---:|---| +| `id` | string | ✓ | Unique kebab-case identifier | +| `name` | string | ✓ | Human-readable name | +| `version` | string | ✓ | Semver string (e.g. `1.2.3`) | +| `description` | string | ✓ | Short description | +| `author` | string | ✓ | Author name | +| `zeronyx_min_version` | string | ✓ | Minimum ZeroNyx version | +| `type` | `backend\|frontend\|both` | | Plugin type (default: `both`) | +| `permissions` | string[] | | Required permissions | +| `entry_backend` | string | | Backend entry point (default: `main.py`) | +| `entry_frontend` | string | | Frontend bundle path (default: `dist/index.js`) | +| `ui_slots` | string[] | | UI slots to render into | +| `hooks` | string[] | | Backend lifecycle hooks | +| `settings` | object | | User-configurable settings schema | + +--- + +## Permissions + +| Permission | Description | Risk | +|---|---|---| +| `scan:read` | Read scan results | Low | +| `scan:write` | Create/modify scans | Medium | +| `findings:read` | Read findings | Low | +| `findings:write` | Create/modify findings | Medium | +| `targets:read` | Read targets | Low | +| `targets:write` | Create/modify targets | Medium | +| `credentials:read` | Read stored credentials | **High** | +| `credentials:write` | Write credentials | **High** | +| `hosts:read` | Read host data | Low | +| `proxy:read` | Read proxy history | Medium | +| `settings:read` | Read app settings | Medium | +| `network:outbound` | Make outbound HTTP requests | **High** | +| `filesystem:read` | Read local files | **High** | +| `filesystem:write` | Write local files | **High** | + +Request only the permissions your plugin actually needs. +Users will see a permission dialog when granting access to your plugin. + +--- + +## UI Slots + +| Slot | Description | Context props | +|---|---|---| +| `sidebar_nav` | Extra icon in the sidebar | — | +| `dashboard_widget` | Widget on the dashboard | `projectId` | +| `scan_result_panel` | Panel below scan results | `scanId`, `tool` | +| `finding_detail_panel` | Panel in finding detail view | `findingId` | +| `target_panel` | Panel in target detail view | `targetId` | +| `toolbar_action` | Button in the toolbar | `projectId` | +| `settings_tab` | Extra tab in Settings | — | +| `report_section` | Section in generated reports | `projectId` | + +--- + +## Backend Hooks + +| Hook | When | Payload | +|---|---|---| +| `on_scan_complete` | After any scan finishes | `scan_id`, `project_id`, `tool`, `target`, `status` | +| `on_finding_created` | After a finding is saved | `finding_id`, `project_id`, `title`, `severity`, `tool_source` | +| `on_target_added` | After a target is added | `target_id`, `project_id`, `value`, `type` | +| `on_project_opened` | When a project is switched to | `project_id`, `project_name` | +| `on_report_generate` | During report generation | `project_id`, `report_type` | + +--- + +## Packaging + +Package your plugin as a `.zeronyx-plugin` file (renamed zip archive): + +```bash +cd my-plugin/ +zip -r my-plugin.zeronyx-plugin . +``` + +Users can drag-and-drop this file into the Plugins page to install. + +--- + +## Plugin Data Directory + +Plugins get a writable data directory via `ctx.data_dir`: + +```python +import json, os + +async def on_scan_complete(self, ctx, payload): + cache_file = os.path.join(ctx.data_dir, "cache.json") + ... +``` + +Path: `~/.zeronyx/plugins//data/` + +--- + +## Example Plugins + +See [`plugins/examples/`](../plugins/examples/) for reference implementations: + +- **whois-lookup** — WHOIS domain/IP lookup on target add +- **cve-search** — CVE database search on scan complete +- **export-csv** — Export findings to CSV via toolbar action diff --git a/docs/launch/PRESS_KIT.md b/docs/launch/PRESS_KIT.md new file mode 100644 index 0000000..0f1f4cf --- /dev/null +++ b/docs/launch/PRESS_KIT.md @@ -0,0 +1,122 @@ +# ZeroNyx Press Kit + +Everything journalists, bloggers, and community members need to cover or share ZeroNyx. + +--- + +## One-Liner + +> ZeroNyx is a desktop-based all-in-one pentesting suite — from reconnaissance to exploitation and reporting in a single, open-source application. + +## Short Description (tweet-length) + +> ZeroNyx unifies Nmap, Nuclei, SQLMap, Metasploit, mitmproxy, AI analysis, and more in one desktop app. Free, open-source, offline-first. No telemetry. + +## Medium Description (2-3 sentences) + +> ZeroNyx is an open-source desktop pentesting suite built for professionals. It orchestrates every tool in your engagement — Nmap, Nuclei, Hydra, SQLMap, Metasploit, and more — while adding AI-powered analysis, automated chain workflows, and professional report generation. The Community edition is free forever; Pro ($9/mo) unlocks AI and automation features. + +## Full Description + +ZeroNyx is a desktop-based all-in-one pentesting suite that unifies the entire penetration testing workflow in a single application. Built with Electron, React, and a Python/FastAPI backend, it orchestrates your existing security tools as subprocesses — parsing their output, normalizing findings, and surfacing actionable intelligence. + +**What it replaces:** +- Juggling 10+ terminal windows during an engagement +- Manually correlating findings across different tool outputs +- Writing reports by hand after hours of scanning +- Forgetting which scan you ran and when + +**What it adds:** +- Unified scan interface with real-time WebSocket output streaming +- Normalized finding management with CVSS scoring and CVE linkage +- AI analysis via Ollama (local), OpenAI, or Anthropic +- Chain automation for multi-step workflows +- Built-in HTTP(S) proxy with request replay +- Professional HTML/PDF report generation +- Plugin system with SDK and marketplace + +ZeroNyx follows a strict "orchestrator, not bundler" philosophy: it never bundles external tool binaries. Your existing Nmap, Nuclei, SQLMap installations work as-is — ZeroNyx just makes them 10x more productive. + +--- + +## Key Facts + +| | | +|---|---| +| **Launch date** | 2026 | +| **License** | MIT (Community), Commercial (Pro/Enterprise) | +| **Platform** | Linux, macOS, Windows | +| **Tech stack** | Electron, React, TypeScript, Python, FastAPI, SQLite | +| **Price** | Free / $9/mo / $49/mo | +| **GitHub** | github.com/RainyRoot/zeronyx | +| **Website** | zeronyx.io | +| **Contact** | hello@zeronyx.io | + +--- + +## Integrated Tools + +nmap · nuclei · nikto · gobuster · ffuf · hydra · sqlmap · searchsploit · msfconsole · mitmproxy · shodan · censys · ollama · openai · anthropic + +--- + +## Target Audience + +- Professional penetration testers +- Bug bounty hunters +- Red team operators +- Security students learning the trade +- Security consultancies (Enterprise tier) + +--- + +## Differentiators vs. Competitors + +| | ZeroNyx | Burp Suite | Cobalt Strike | Kali Linux (manual) | +|---|---|---|---|---| +| All-in-one desktop | ✓ | Proxy only | Post-exploit only | No | +| Open source | ✓ | No | No | Yes (tools only) | +| AI analysis | ✓ | No | No | No | +| Local-first | ✓ | ✓ | No | ✓ | +| Plugin SDK | ✓ | ✓ (paid) | No | No | +| Free tier | Full-featured | Limited | No | Tools only | + +--- + +## Quotes + +> "I've been waiting for something that actually ties all the tools together without getting in the way. ZeroNyx does exactly that." — Beta tester, senior pentester + +> "The AI analysis caught three false positives that would have gone in the report. That alone saves me an hour per engagement." — Beta tester, bug bounty hunter + +--- + +## Screenshots + +Screenshots are available at: `docs/launch/screenshots/` + +Suggested captions: +1. **Dashboard** — Project overview with active scan count and recent findings +2. **Scan Interface** — Nmap scan running with live output stream +3. **Findings** — Normalized findings grid with severity badges and CVSS scores +4. **AI Analysis** — Scan analyzed with risk prioritization and next steps +5. **Proxy** — HTTP request/response inspector with search +6. **Chains** — Visual chain builder with step configuration +7. **Plugin Marketplace** — Browse and install community plugins +8. **Settings / License** — License activation and tool health + +--- + +## Media Kit Assets + +- **Logo SVG** — `docs/launch/assets/logo.svg` +- **Logo PNG (dark bg)** — `docs/launch/assets/logo-dark.png` +- **Logo PNG (light bg)** — `docs/launch/assets/logo-light.png` +- **Banner 1200×630** — `docs/launch/assets/og-banner.png` +- **Icon 512×512** — `docs/launch/assets/icon-512.png` + +Brand colors: +- Primary red: `#e53e3e` +- Background: `#0a0a0d` +- Surface: `#111114` +- Accent purple (Pro): `#7c3aed` diff --git a/docs/launch/SOCIAL_COPY.md b/docs/launch/SOCIAL_COPY.md new file mode 100644 index 0000000..b0b54f1 --- /dev/null +++ b/docs/launch/SOCIAL_COPY.md @@ -0,0 +1,238 @@ +# ZeroNyx — Launch Social Copy + +Ready-to-use copy for Reddit, Twitter/X, HackerNews, LinkedIn, and security forums. + +--- + +## Reddit — r/netsec + +**Title:** +> ZeroNyx — open-source all-in-one pentesting desktop app (Nmap, Nuclei, Metasploit, AI analysis, all in one place) + +**Body:** +``` +After 6 months of development, I'm releasing ZeroNyx — a desktop pentesting suite +I built because I was tired of managing 10 terminal windows during engagements. + +**What it is:** +A local Electron app with a Python/FastAPI backend that orchestrates your existing tools — +Nmap, Nuclei, Nikto, Gobuster, Hydra, SQLMap, Metasploit, mitmproxy — and adds: + +- Unified scan interface with real-time output +- Normalized finding management (CVSS, CVE, evidence) +- Built-in HTTP(S) proxy with request replay +- AI analysis via Ollama (local, private) or OpenAI/Anthropic +- Automated chain workflows +- Professional report generation +- Plugin system with SDK and marketplace + +**Philosophy:** +It wraps tools as subprocesses, never bundles them. GPL-compliant, no lock-in. +No telemetry. Your data stays local in a SQLite file per project. + +**Free vs Pro:** +The Community edition is a real, full-featured tool — not a crippled demo. +Pro ($9/mo) adds AI analysis, chain automation, and Obsidian sync. + +GitHub: https://github.com/RainyRoot/zeronyx +Website: https://zeronyx.io + +Happy to answer questions about the architecture or implementation choices. +``` + +--- + +## Reddit — r/HowToHack / r/bugbounty + +**Title:** +> I built a desktop app that ties together all your pentest tools — free and open source + +**Body:** +``` +If you've ever wished Nmap, Nuclei, Burp, Hydra, and Metasploit were all in one place with +a proper UI, I built that. It's called ZeroNyx. + +Key things: +→ All your existing tools work — it just launches them as subprocesses +→ Findings from every tool get normalized into one list with severity + CVSS +→ AI can analyse any scan result for you (uses local Ollama by default) +→ Chain automation — "after nmap, auto-run searchsploit on every open port" +→ Built-in proxy, credential store, Obsidian sync + +Community edition is free forever on GitHub. +Pro is $9/mo if you want the AI and automation stuff. + +https://github.com/RainyRoot/zeronyx +``` + +--- + +## Twitter / X + +**Launch tweet (thread opener):** +``` +Releasing ZeroNyx — an open-source desktop pentesting suite. + +Nmap → Nuclei → SQLMap → Metasploit → AI analysis → Report. + +All in one app. All local. No telemetry. + +🧵 +``` + +**Thread 2:** +``` +The problem I was solving: + +During a pentest I had 8 terminal windows open, scan results spread across +3 text files, and no idea which scan I'd run on which target 2 hours ago. + +ZeroNyx fixes that. One app, one SQLite db per project. +``` + +**Thread 3:** +``` +It wraps your existing tools as subprocesses. + +nmap, nuclei, nikto, gobuster, hydra, sqlmap, msfconsole, mitmproxy... + +No bundled binaries. Your Kali installation just works. +``` + +**Thread 4:** +``` +The AI feature uses Ollama by default (fully local, no API key, no data leaves your machine). + +After a scan: one click → risk-prioritized findings, false positive flags, +and concrete next steps. + +OpenAI/Anthropic optional if you want cloud quality. +``` + +**Thread 5:** +``` +Community edition: free forever, open source (MIT) +Pro: $9/mo — unlocks AI analysis, chain automation, Obsidian sync + +GitHub: https://github.com/RainyRoot/zeronyx +Website: https://zeronyx.io + +What features would you want to see next? +``` + +**Shorter standalone tweets:** + +``` +Just released ZeroNyx — open-source desktop pentesting suite. + +Nmap + Nuclei + SQLMap + Metasploit + AI analysis in one app. +Free community edition. No telemetry. All local. + +https://github.com/RainyRoot/zeronyx +``` + +``` +I got tired of managing 10 terminal windows during pentests. + +So I built ZeroNyx — a desktop app that unifies all your tools, +normalizes findings, and can AI-analyze any scan result. + +Open source. Free tier. MIT licensed. + +https://zeronyx.io +``` + +--- + +## HackerNews — Show HN + +**Title:** +> Show HN: ZeroNyx — Open-source all-in-one desktop pentesting suite + +**Body:** +``` +ZeroNyx is a desktop pentesting application I've been building over the past 6 months, +now ready for its first public release. + +It's built with Electron/React/TypeScript for the UI and Python/FastAPI for the backend. +The backend orchestrates external security tools (Nmap, Nuclei, SQLMap, Metasploit, etc.) +as subprocesses — it never bundles tool binaries, keeping it GPL-compliant. + +Key design decisions I'd be happy to discuss: + +1. SQLite per project — each engagement is a fully self-contained database file +2. WebSocket for live scan output — the frontend streams subprocess stdout in real time +3. Subprocess orchestration not embedding — all tools run as child processes with parsed output +4. Local AI by default — Ollama integration so analysis is fully offline +5. Plugin system — a full SDK with backend (Python) and frontend (React) extension points + +The Community edition is open source (MIT). Pro tier adds AI analysis, chain automation, +and a plugin marketplace. + +GitHub: https://github.com/RainyRoot/zeronyx +``` + +--- + +## LinkedIn + +``` +🚀 Excited to launch ZeroNyx — an open-source desktop pentesting suite built for +professional security practitioners. + +After years of managing disconnected tools during engagements, I decided to build +the unified platform I always wanted: one application for the entire pentest lifecycle. + +ZeroNyx integrates Nmap, Nuclei, Nikto, Gobuster, Hydra, SQLMap, Metasploit, +and mitmproxy — plus AI-powered analysis via Ollama, OpenAI, or Anthropic. + +Key capabilities: +✅ Unified scan interface with real-time output +✅ Normalized finding management with CVSS scoring +✅ AI risk prioritization and false-positive detection +✅ Chain automation for multi-step workflows +✅ Professional report generation +✅ Plugin marketplace with full SDK + +The Community edition is free and open source (MIT). +Pro unlocks AI and automation for $9/month. + +GitHub: https://github.com/RainyRoot/zeronyx +Website: https://zeronyx.io + +#Cybersecurity #Pentesting #OpenSource #SecurityTools +``` + +--- + +## Bug Bounty / Security Forum Posts + +**HackerOne / Bugcrowd communities:** +``` +Hey hunters 👋 + +Built something that might save you time: ZeroNyx — a desktop app that unifies your +pentest tools with a proper UI and AI analysis. + +Highlights for bug bounty: +- Nuclei runs integrated with real-time output +- Findings auto-normalize across tools (one list, not 5 text files) +- AI analysis flags likely false positives before you submit +- Obsidian sync exports your notes automatically after each scan + +Free community version on GitHub: https://github.com/RainyRoot/zeronyx +``` + +--- + +## Discord / Slack Security Communities + +``` +Just launched ZeroNyx — my open-source desktop pentesting suite after 6 months of work. + +Ties together: nmap, nuclei, nikto, gobuster, hydra, sqlmap, metasploit, mitmproxy +Adds: AI analysis (local with Ollama), chain automation, reports, plugin marketplace + +Free + open source: https://github.com/RainyRoot/zeronyx +Would love feedback from this community! +``` diff --git a/electron/backend-manager.ts b/electron/backend-manager.ts index 5ae756a..2fe2dd9 100644 --- a/electron/backend-manager.ts +++ b/electron/backend-manager.ts @@ -1,5 +1,6 @@ import { spawn, ChildProcess } from 'child_process' -import { join, existsSync } from 'path' +import { join } from 'path' +import { existsSync } from 'fs' import { app, ipcMain } from 'electron' import { is } from '@electron-toolkit/utils' @@ -17,11 +18,15 @@ export class BackendManager { ipcMain.handle('backend:getPort', () => this.port) const pythonPath = this.resolvePythonPath() - const scriptPath = this.resolveBackendScript() + const cwd = this.resolveBackendCwd() + const args = is.dev + ? ['-m', 'backend.main', '--port', String(this.port)] + : [join(cwd, 'backend', 'main.py'), '--port', String(this.port)] - console.log(`[BackendManager] Starting: ${pythonPath} ${scriptPath} --port ${this.port}`) + console.log(`[BackendManager] Starting: ${pythonPath} ${args.join(' ')} (cwd: ${cwd})`) - this.process = spawn(pythonPath, [scriptPath, '--port', String(this.port)], { + this.process = spawn(pythonPath, args, { + cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, @@ -66,11 +71,11 @@ export class BackendManager { return process.platform === 'win32' ? 'python' : 'python3' } - private resolveBackendScript(): string { + private resolveBackendCwd(): string { if (is.dev) { - return join(process.cwd(), 'backend', 'main.py') + return process.cwd() } - return join(app.getAppPath(), '..', 'backend', 'main.py') + return join(app.getAppPath(), '..') } private async waitForHealthy(): Promise { diff --git a/electron/main.ts b/electron/main.ts index d5ad2bf..5a64e56 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { mkdirSync, writeFileSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { BackendManager } from './backend-manager' +import { setupAutoUpdater } from './updater' import type { IPty } from 'node-pty' let mainWindow: BrowserWindow | null = null @@ -150,6 +151,14 @@ app.whenReady().then(async () => { setupTerminalIpc() setupExportIpc() + setupAutoUpdater(() => mainWindow) + + // Open URLs in system browser (used by UpgradeButton) + ipcMain.on('shell:openExternal', (_event, url: string) => { + if (typeof url === 'string' && (url.startsWith('https://') || url.startsWith('http://'))) { + shell.openExternal(url) + } + }) await backendManager.start() createWindow() diff --git a/electron/preload.ts b/electron/preload.ts index fad0759..2ceb63d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -44,12 +44,65 @@ const exportAPI = { ipcRenderer.invoke('export:writeVault', { files, defaultName }), } +// Open external URL in system browser +const electronAPI2 = { + openExternal: (url: string): void => + ipcRenderer.send('shell:openExternal', url), +} + +// Auto-updater bridge +const updaterAPI = { + checkForUpdates: (): Promise<{ ok?: boolean; dev?: boolean; inProgress?: boolean; error?: string }> => + ipcRenderer.invoke('updater:check'), + + installUpdate: (): void => + ipcRenderer.invoke('updater:install'), + + onChecking: (callback: () => void): (() => void) => { + const handler = () => callback() + ipcRenderer.on('updater:checking', handler) + return () => ipcRenderer.removeListener('updater:checking', handler) + }, + + onAvailable: (callback: (info: { version: string; releaseNotes: string; releaseDate: string }) => void): (() => void) => { + const handler = (_evt: Electron.IpcRendererEvent, info: { version: string; releaseNotes: string; releaseDate: string }) => callback(info) + ipcRenderer.on('updater:available', handler) + return () => ipcRenderer.removeListener('updater:available', handler) + }, + + onNotAvailable: (callback: (info: { version: string }) => void): (() => void) => { + const handler = (_evt: Electron.IpcRendererEvent, info: { version: string }) => callback(info) + ipcRenderer.on('updater:not-available', handler) + return () => ipcRenderer.removeListener('updater:not-available', handler) + }, + + onProgress: (callback: (progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => void): (() => void) => { + const handler = (_evt: Electron.IpcRendererEvent, progress: { percent: number; bytesPerSecond: number; transferred: number; total: number }) => callback(progress) + ipcRenderer.on('updater:progress', handler) + return () => ipcRenderer.removeListener('updater:progress', handler) + }, + + onDownloaded: (callback: (info: { version: string }) => void): (() => void) => { + const handler = (_evt: Electron.IpcRendererEvent, info: { version: string }) => callback(info) + ipcRenderer.on('updater:downloaded', handler) + return () => ipcRenderer.removeListener('updater:downloaded', handler) + }, + + onError: (callback: (err: { message: string }) => void): (() => void) => { + const handler = (_evt: Electron.IpcRendererEvent, err: { message: string }) => callback(err) + ipcRenderer.on('updater:error', handler) + return () => ipcRenderer.removeListener('updater:error', handler) + }, +} + if (process.contextIsolated) { try { contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('terminalAPI', terminalAPI) contextBridge.exposeInMainWorld('exportAPI', exportAPI) + contextBridge.exposeInMainWorld('updaterAPI', updaterAPI) + contextBridge.exposeInMainWorld('electronAPI', electronAPI2) } catch (error) { console.error(error) } @@ -62,4 +115,6 @@ if (process.contextIsolated) { window.terminalAPI = terminalAPI // @ts-ignore window.exportAPI = exportAPI + // @ts-ignore + window.updaterAPI = updaterAPI } diff --git a/electron/updater.ts b/electron/updater.ts new file mode 100644 index 0000000..7572224 --- /dev/null +++ b/electron/updater.ts @@ -0,0 +1,137 @@ +/** + * ZeroNyx Auto-Updater + * + * Uses electron-updater (update-electron-app / autoUpdater) to check for + * new releases from the configured GitHub releases feed. + * + * Update flow: + * 1. On startup, check for updates (silently). + * 2. If an update is available, notify the renderer via IPC. + * 3. Download in the background while the user works. + * 4. Show a dialog asking to install + restart, or defer. + * 5. On manual trigger (Settings → About → Check for Updates), repeat. + * + * Release configuration (electron-builder): + * publish: + * provider: github + * owner: RainyRoot + * repo: zeronyx + */ + +import { autoUpdater } from 'electron-updater' +import { ipcMain, BrowserWindow, dialog } from 'electron' +import { is } from '@electron-toolkit/utils' + +let updateCheckInProgress = false + +export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void { + // Don't run updates in dev mode + if (is.dev) { + setupIpcHandlers(getMainWindow) + return + } + + autoUpdater.autoDownload = true + autoUpdater.autoInstallOnAppQuit = true + autoUpdater.allowDowngrade = false + + // --------------------------------------------------------------------------- + // Updater events + // --------------------------------------------------------------------------- + + autoUpdater.on('checking-for-update', () => { + updateCheckInProgress = true + getMainWindow()?.webContents.send('updater:checking') + }) + + autoUpdater.on('update-available', (info) => { + getMainWindow()?.webContents.send('updater:available', { + version: info.version, + releaseNotes: info.releaseNotes ?? '', + releaseDate: info.releaseDate, + }) + }) + + autoUpdater.on('update-not-available', (info) => { + updateCheckInProgress = false + getMainWindow()?.webContents.send('updater:not-available', { + version: info.version, + }) + }) + + autoUpdater.on('download-progress', (progress) => { + getMainWindow()?.webContents.send('updater:progress', { + bytesPerSecond: progress.bytesPerSecond, + percent: progress.percent, + transferred: progress.transferred, + total: progress.total, + }) + }) + + autoUpdater.on('update-downloaded', async (info) => { + updateCheckInProgress = false + getMainWindow()?.webContents.send('updater:downloaded', { + version: info.version, + }) + + const win = getMainWindow() + if (!win) { + autoUpdater.quitAndInstall() + return + } + + const { response } = await dialog.showMessageBox(win, { + type: 'info', + title: 'ZeroNyx Update Ready', + message: `ZeroNyx ${info.version} is ready to install.`, + detail: 'Restart now to apply the update, or defer until next launch.', + buttons: ['Restart Now', 'Later'], + defaultId: 0, + cancelId: 1, + }) + + if (response === 0) { + autoUpdater.quitAndInstall() + } + }) + + autoUpdater.on('error', (err) => { + updateCheckInProgress = false + getMainWindow()?.webContents.send('updater:error', { + message: err.message, + }) + }) + + // --------------------------------------------------------------------------- + // IPC handlers + // --------------------------------------------------------------------------- + + setupIpcHandlers(getMainWindow) + + // Check on startup after a short delay (let the app settle) + setTimeout(() => { + autoUpdater.checkForUpdates().catch(() => { /* silent */ }) + }, 8000) +} + + +function setupIpcHandlers(getMainWindow: () => BrowserWindow | null): void { + ipcMain.handle('updater:check', async () => { + if (is.dev) { + return { dev: true, message: 'Auto-update disabled in dev mode.' } + } + if (updateCheckInProgress) { + return { inProgress: true } + } + try { + await autoUpdater.checkForUpdates() + return { ok: true } + } catch (err) { + return { error: (err as Error).message } + } + }) + + ipcMain.handle('updater:install', () => { + autoUpdater.quitAndInstall() + }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67081ba..b755330 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,9 @@ 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 PluginsPage from '@/pages/Plugins' import type { BackendStatus } from '@/types' const BACKEND_URL = 'http://127.0.0.1:8742' @@ -58,8 +61,11 @@ export default function App(): JSX.Element { } /> } /> } /> + } /> + } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/common/UpgradeButton.tsx b/frontend/src/components/common/UpgradeButton.tsx new file mode 100644 index 0000000..9ac0b1f --- /dev/null +++ b/frontend/src/components/common/UpgradeButton.tsx @@ -0,0 +1,85 @@ +/** + * UpgradeButton — opens Stripe Checkout in the default browser. + * Used throughout the app to gate Pro features. + */ +import { Zap } from 'lucide-react' +import { cn } from '@/lib/utils' + +declare global { + interface Window { + electronAPI?: { + openExternal: (url: string) => void + } + } +} + +const BASE = 'http://127.0.0.1:8742' + +interface Props { + tier?: 'pro' | 'enterprise' + label?: string + className?: string + size?: 'sm' | 'md' +} + +export function UpgradeButton({ + tier = 'pro', + label = 'Upgrade to Pro', + className, + size = 'md', +}: Props) { + const handleClick = async () => { + try { + const res = await fetch(`${BASE}/api/payments/checkout-url/${tier}`) + if (res.ok) { + const { url } = await res.json() + // Open in system browser (Electron / web) + if (window.electronAPI?.openExternal) { + window.electronAPI.openExternal(url) + } else { + window.open(url, '_blank', 'noopener,noreferrer') + } + } + } catch { + // fallback: open the generic pricing page + window.open('https://zeronyx.io/#pricing', '_blank', 'noopener,noreferrer') + } + } + + return ( + + ) +} + +/** Inline Pro badge that doubles as an upgrade CTA */ +export function ProGate({ + feature, + children, +}: { + feature: string + children: React.ReactNode +}) { + return ( +
+
{children}
+
+
+ + {feature} requires Pro + + +
+
+
+ ) +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 722239c..acb09ba 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -3,6 +3,9 @@ import { Sidebar } from './Sidebar' import { TabBar } from './TabBar' import { StatusBar } from './StatusBar' import { useProjectStore } from '@/stores/projectStore' +import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' +import { Toaster } from '@/components/ui/toast' +import { KeyboardShortcutsModal } from '@/components/ui/keyboard-shortcuts-modal' import type { BackendStatus } from '@/types' interface AppShellProps { @@ -11,6 +14,7 @@ interface AppShellProps { export function AppShell({ backendStatus }: AppShellProps): JSX.Element { const activeProject = useProjectStore((s) => s.activeProject) + useKeyboardShortcuts() return (
@@ -24,6 +28,8 @@ export function AppShell({ backendStatus }: AppShellProps): JSX.Element {
+ + ) } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index c80376c..dcc709d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -13,6 +13,9 @@ import { Eye, Radar, Network, + BrainCircuit, + Workflow, + Puzzle, type LucideIcon } from 'lucide-react' import * as Tooltip from '@radix-ui/react-tooltip' @@ -40,7 +43,10 @@ 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: 'plugins', icon: Puzzle, label: 'Plugins', path: '/plugins' }, { pageId: 'terminal', icon: SquareTerminal, label: 'Terminal', path: '/terminal' }, ] diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 4be43e6..195cb70 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -1,5 +1,6 @@ import { Circle } from 'lucide-react' import { cn } from '@/lib/utils' +import { UpdateBanner } from '@/components/ui/update-banner' import type { BackendStatus } from '@/types' interface StatusBarProps { @@ -23,8 +24,9 @@ export function StatusBar({ backendStatus, projectName }: StatusBarProps): JSX.E {/* Right */}
- Phase 1 - ZeroNyx v0.1.0 + + ZeroNyx v0.5.0 + ?
) diff --git a/frontend/src/components/plugins/PluginSlot.tsx b/frontend/src/components/plugins/PluginSlot.tsx new file mode 100644 index 0000000..7efca0d --- /dev/null +++ b/frontend/src/components/plugins/PluginSlot.tsx @@ -0,0 +1,111 @@ +/** + * PluginSlot — renders all active plugins registered for a given UI slot. + * + * Each plugin's frontend bundle is loaded dynamically via a