diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a2893d --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# ───────────────────────────────────────────────────────────────────────────── +# SIEM Toolkit — Environment Configuration +# ───────────────────────────────────────────────────────────────────────────── +# 1. Copy this file: cp .env.example .env +# 2. Fill in values below (see comments for where to find each one) +# 3. Start the app: docker-compose up -d --build +# ───────────────────────────────────────────────────────────────────────────── + +# SentinelOne Management Console +# ─ URL: your console (e.g. https://demo.sentinelone.net) +# ─ Token: Settings → Users → Service Users → generate API token +S1_BASE_URL=https://demo.sentinelone.net +S1_API_TOKEN= + +# Singularity Data Lake (SDL) — PowerQuery credentials +# ─ Console: Settings → Integrations → Data Lake API Keys +# ─ XDR URL: shown on the API Keys page (e.g. https://xdr.us1.sentinelone.net) +# ─ Log Read Key: copy the "Log Read" key from that page +SDL_XDR_URL=https://xdr.us1.sentinelone.net +SDL_LOG_READ_KEY= + +# Anthropic (for Onboarding Accelerator AI features) +# ─ https://console.anthropic.com/settings/api-keys +ANTHROPIC_API_KEY= diff --git a/backend/main.py b/backend/main.py index 851aa51..5e2d532 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from db import engine, Base -from routers import coverage, ingest +from routers import coverage, ingest, settings Base.metadata.create_all(bind=engine) @@ -16,7 +16,8 @@ app.add_middleware( ) app.include_router(coverage.router, prefix="/api/coverage", tags=["Coverage"]) -app.include_router(ingest.router, prefix="/api/ingest", tags=["Ingest"]) +app.include_router(ingest.router, prefix="/api/ingest", tags=["Ingest"]) +app.include_router(settings.router, prefix="/api/settings", tags=["Settings"]) @app.get("/health") diff --git a/backend/routers/settings.py b/backend/routers/settings.py new file mode 100644 index 0000000..8d78542 --- /dev/null +++ b/backend/routers/settings.py @@ -0,0 +1,105 @@ +import os +import re +from pathlib import Path +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +router = APIRouter() + +ENV_FILE = Path(os.environ.get("ENV_FILE_PATH", "/app/.env")) + +# Fields we expose in the UI — order matters for display +FIELDS = [ + {"key": "S1_BASE_URL", "label": "Console URL", "secret": False, "placeholder": "https://demo.sentinelone.net"}, + {"key": "S1_API_TOKEN", "label": "Console API Token", "secret": True, "placeholder": "eyJ..."}, + {"key": "SDL_XDR_URL", "label": "SDL XDR URL", "secret": False, "placeholder": "https://xdr.us1.sentinelone.net"}, + {"key": "SDL_LOG_READ_KEY", "label": "SDL Log Read Key", "secret": True, "placeholder": "1DnK0Y4e..."}, + {"key": "ANTHROPIC_API_KEY", "label": "Anthropic API Key", "secret": True, "placeholder": "sk-ant-..."}, +] + +FIELD_KEYS = {f["key"] for f in FIELDS} + + +def _read_env() -> dict[str, str]: + """Read .env file into a dict.""" + vals: dict[str, str] = {} + if ENV_FILE.exists(): + for line in ENV_FILE.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, _, v = line.partition("=") + vals[k.strip()] = v.strip() + return vals + + +def _write_env(updates: dict[str, str]) -> None: + """Write updates into .env, preserving comments and unknown keys.""" + existing_lines: list[str] = [] + if ENV_FILE.exists(): + existing_lines = ENV_FILE.read_text().splitlines() + + written: set[str] = set() + new_lines: list[str] = [] + + for line in existing_lines: + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + k, _, _ = stripped.partition("=") + k = k.strip() + if k in updates: + new_lines.append(f"{k}={updates[k]}") + written.add(k) + continue + new_lines.append(line) + + # Append any new keys not already in the file + for k, v in updates.items(): + if k not in written: + new_lines.append(f"{k}={v}") + + ENV_FILE.write_text("\n".join(new_lines) + "\n") + + +@router.get("/config") +async def get_config(): + """Return current config values. Secrets are masked.""" + env_vals = _read_env() + result = [] + for f in FIELDS: + key = f["key"] + # Prefer live env var, fall back to .env file value + raw = os.environ.get(key, env_vals.get(key, "")) + if f["secret"] and raw: + # Show first 6 + last 4 chars, mask middle + masked = raw[:6] + "•" * max(4, len(raw) - 10) + raw[-4:] if len(raw) > 10 else "••••••••" + else: + masked = raw + result.append({ + "key": key, + "label": f["label"], + "secret": f["secret"], + "placeholder": f["placeholder"], + "value": masked, + "set": bool(raw), + }) + env_file_exists = ENV_FILE.exists() + return {"fields": result, "env_file_exists": env_file_exists, "env_file_path": str(ENV_FILE)} + + +class ConfigUpdate(BaseModel): + updates: dict[str, str] + + +@router.post("/config") +async def save_config(body: ConfigUpdate): + """Save config values to .env file. Only known keys accepted.""" + bad = [k for k in body.updates if k not in FIELD_KEYS] + if bad: + raise HTTPException(400, f"Unknown keys: {bad}") + if not ENV_FILE.parent.exists(): + raise HTTPException(503, f"Cannot write to {ENV_FILE} — check Docker volume mount") + try: + _write_env(body.updates) + except Exception as e: + raise HTTPException(500, f"Failed to write .env: {e}") + return {"saved": list(body.updates.keys()), "restart_required": True} diff --git a/docker-compose.yml b/docker-compose.yml index 3c396e0..7b13dcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: condition: service_healthy volumes: - ./parsers:/app/parsers + - ./.env:/app/.env db: image: postgres:16-alpine diff --git a/frontend/index.html b/frontend/index.html index 696775d..684922d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,6 +22,12 @@ Ingest Dashboard Onboarding +
Loading…
${esc(e.message)}
` + } +} + +function renderSettingsForm(fields, envExists, envPath) { + const setupBanner = !envExists ? ` +⚡ First-time setup
+No .env file found. Create one from the template to get started:
Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}
` : `Not configured
`} +Leave a field blank to keep its current value. Changes are written to .env and take effect after restarting the backend (docker-compose up -d --build backend).
Prefer editing the file directly? Copy the template and fill in your credentials:
+S1_BASE_URL / S1_API_TOKEN — SentinelOne console URL and service user API token. Generate the token at Settings → Users → Service Users.
+SDL_XDR_URL / SDL_LOG_READ_KEY — Singularity Data Lake credentials for PowerQuery. Found at Settings → Integrations → Data Lake API Keys.
+ANTHROPIC_API_KEY — Optional. Required only for the Onboarding AI assistant. Get it at console.anthropic.com.
+docker-compose up -d --build backend`
+ document.querySelectorAll('[data-key]').forEach(el => el.value = '')
+ } catch(e) {
+ document.getElementById('st-msg').innerHTML = `${esc(e.message)}`
+ } finally { setBtn('st-save', false, 'Save to .env') }
+}
+
// ── Router ────────────────────────────────────────────────────────────────
function set(html) { document.getElementById('main').innerHTML = html }
@@ -411,6 +525,7 @@ function route() {
if (h === '#/coverage') { updateNav('coverage'); renderCoverage() }
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
+ else if (h === '#/settings') { updateNav('settings'); renderSettings() }
else { updateNav('home'); renderHome() }
}