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 +
+ + + Settings + +
@@ -395,6 +401,114 @@ function obCopy() { if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) } } +// ── Settings ────────────────────────────────────────────────────────────── + +async function renderSettings() { + set(`
+

Settings

+

Loading…

+
`) + try { + const { fields, env_file_exists, env_file_path } = await apiGet('/api/settings/config') + renderSettingsForm(fields, env_file_exists, env_file_path) + } catch(e) { + document.getElementById('st-content').innerHTML = `

${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:

+
+
# in your project directory:
+
cp .env.example .env
+
# edit .env with your credentials, then:
+
docker-compose up -d --build
+
+
` : '' + + const fieldRows = fields.map(f => ` +
+ +
+ + ${f.secret ? `` : ''} +
+ ${f.set ? `

Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}

` : `

Not configured

`} +
`).join('') + + document.getElementById('st-content').innerHTML = ` + ${setupBanner} + +
+
+

Environment Configuration

+ ${esc(envPath)} +
+

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).

+
${fieldRows}
+
+ + +
+
+ +
+

Manual Setup

+

Prefer editing the file directly? Copy the template and fill in your credentials:

+
+
# 1. Copy the template
+
cp .env.example .env
+
# 2. Open and edit
+
nano .env
+
# 3. Rebuild & restart
+
docker-compose up -d --build
+
+
+

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.

+
+
` +} + +function toggleSecret(id) { + const el = document.getElementById(id) + const btn = el.nextElementSibling + if (el.type === 'password') { el.type = 'text'; btn.textContent = 'Hide' } + else { el.type = 'password'; btn.textContent = 'Show' } +} + +async function saveSettings() { + const updates = {} + document.querySelectorAll('[data-key]').forEach(el => { + if (el.value.trim()) updates[el.dataset.key] = el.value.trim() + }) + if (!Object.keys(updates).length) { + document.getElementById('st-msg').innerHTML = 'Nothing to save — fill in at least one field.' + return + } + setBtn('st-save', true) + try { + const r = await apiPost('/api/settings/config', { updates }) + document.getElementById('st-msg').innerHTML = + `✓ Saved ${r.saved.length} value${r.saved.length!==1?'s':''}. Restart the backend to apply: 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() } }