Add Settings page with .env manager

- Sidebar: ⚙ Settings link pinned to bottom of nav
- Settings page: view all config keys (secrets masked), edit and save directly to .env
- Show/hide toggle for secret fields (tokens, keys)
- First-time setup banner with cp .env.example .env instructions when .env is missing
- Manual setup section with step-by-step terminal commands and where to find each credential
- New .env.example template with comments for all required variables
- Backend: GET/POST /api/settings/config router reads/writes mounted .env file
- docker-compose: mounts .env into backend container at /app/.env for write access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-05-19 11:43:41 -04:00
parent c182d837ee
commit 2e55e21a77
5 changed files with 248 additions and 2 deletions
+24
View File
@@ -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=
+3 -2
View File
@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from db import engine, Base from db import engine, Base
from routers import coverage, ingest from routers import coverage, ingest, settings
Base.metadata.create_all(bind=engine) 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(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") @app.get("/health")
+105
View File
@@ -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}
+1
View File
@@ -22,6 +22,7 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ./parsers:/app/parsers - ./parsers:/app/parsers
- ./.env:/app/.env
db: db:
image: postgres:16-alpine image: postgres:16-alpine
+115
View File
@@ -22,6 +22,12 @@
<a href="#/ingest" data-page="ingest" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Ingest Dashboard</a> <a href="#/ingest" data-page="ingest" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Ingest Dashboard</a>
<a href="#/onboarding" data-page="onboarding" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Onboarding</a> <a href="#/onboarding" data-page="onboarding" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Onboarding</a>
</nav> </nav>
<div class="p-3 border-t border-gray-800">
<a href="#/settings" data-page="settings" class="nav-link flex items-center gap-2 px-3 py-2 rounded-lg text-sm cursor-pointer text-gray-400 hover:bg-gray-800 hover:text-gray-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><circle cx="12" cy="12" r="3"/></svg>
Settings
</a>
</div>
</aside> </aside>
<main class="flex-1 overflow-auto" id="main"></main> <main class="flex-1 overflow-auto" id="main"></main>
@@ -395,6 +401,114 @@ function obCopy() {
if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) } if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) }
} }
// ── Settings ──────────────────────────────────────────────────────────────
async function renderSettings() {
set(`<div class="p-6 max-w-2xl mx-auto space-y-6">
<h1 class="text-xl font-bold text-white">Settings</h1>
<div id="st-content"><p class="text-gray-500 text-sm">Loading…</p></div>
</div>`)
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 = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
function renderSettingsForm(fields, envExists, envPath) {
const setupBanner = !envExists ? `
<div class="bg-blue-950/60 border border-blue-700/50 rounded-lg p-4 text-sm mb-4">
<p class="font-semibold text-blue-300 mb-2">⚡ First-time setup</p>
<p class="text-blue-200/80 mb-3">No <code class="bg-gray-900 px-1 rounded">.env</code> file found. Create one from the template to get started:</p>
<div class="bg-gray-900 rounded p-3 font-mono text-xs text-green-300 space-y-1">
<div><span class="text-gray-500"># in your project directory:</span></div>
<div>cp .env.example .env</div>
<div><span class="text-gray-500"># edit .env with your credentials, then:</span></div>
<div>docker-compose up -d --build</div>
</div>
</div>` : ''
const fieldRows = fields.map(f => `
<div class="space-y-1">
<label class="block text-xs font-medium text-gray-400 uppercase tracking-wide">${esc(f.label)}</label>
<div class="flex gap-2">
<input
id="st-${f.key}"
type="${f.secret ? 'password' : 'text'}"
placeholder="${esc(f.value || f.placeholder)}"
value=""
data-key="${f.key}"
data-secret="${f.secret}"
class="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-purple-500 font-${f.secret ? 'mono' : 'sans'}"
>
${f.secret ? `<button onclick="toggleSecret('st-${f.key}')" class="px-3 py-2 rounded-lg border border-gray-700 text-gray-400 hover:text-gray-100 text-xs hover:border-gray-500 transition-colors">Show</button>` : ''}
</div>
${f.set ? `<p class="text-xs text-gray-600">Currently set${f.secret ? ' (masked)' : ': ' + esc(f.value)}</p>` : `<p class="text-xs text-amber-600">Not configured</p>`}
</div>`).join('')
document.getElementById('st-content').innerHTML = `
${setupBanner}
<div class="bg-gray-900/50 border border-gray-800 rounded-lg p-5 space-y-5">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-white text-sm">Environment Configuration</h2>
<span class="text-xs text-gray-500 font-mono">${esc(envPath)}</span>
</div>
<p class="text-xs text-gray-500">Leave a field blank to keep its current value. Changes are written to <code class="bg-gray-800 px-1 rounded">.env</code> and take effect after restarting the backend (<code class="bg-gray-800 px-1 rounded">docker-compose up -d --build backend</code>).</p>
<div class="space-y-4">${fieldRows}</div>
<div class="flex items-center gap-3 pt-2">
<button onclick="saveSettings()" id="st-save" class="px-4 py-2 bg-purple-700 hover:bg-purple-600 rounded-lg text-sm font-medium text-white transition-colors">Save to .env</button>
<span id="st-msg" class="text-sm"></span>
</div>
</div>
<div class="bg-gray-900/50 border border-gray-800 rounded-lg p-5 space-y-3">
<h2 class="font-semibold text-white text-sm">Manual Setup</h2>
<p class="text-xs text-gray-400">Prefer editing the file directly? Copy the template and fill in your credentials:</p>
<div class="bg-gray-950 rounded-lg p-4 font-mono text-xs text-gray-300 space-y-1">
<div><span class="text-gray-600"># 1. Copy the template</span></div>
<div class="text-green-400">cp .env.example .env</div>
<div class="mt-2"><span class="text-gray-600"># 2. Open and edit</span></div>
<div class="text-green-400">nano .env</div>
<div class="mt-2"><span class="text-gray-600"># 3. Rebuild & restart</span></div>
<div class="text-green-400">docker-compose up -d --build</div>
</div>
<div class="pt-1 space-y-1 text-xs text-gray-500">
<p><span class="text-gray-400 font-medium">S1_BASE_URL / S1_API_TOKEN</span> — SentinelOne console URL and service user API token. Generate the token at <span class="text-gray-400">Settings → Users → Service Users</span>.</p>
<p><span class="text-gray-400 font-medium">SDL_XDR_URL / SDL_LOG_READ_KEY</span> — Singularity Data Lake credentials for PowerQuery. Found at <span class="text-gray-400">Settings → Integrations → Data Lake API Keys</span>.</p>
<p><span class="text-gray-400 font-medium">ANTHROPIC_API_KEY</span> — Optional. Required only for the Onboarding AI assistant. Get it at <span class="text-gray-400">console.anthropic.com</span>.</p>
</div>
</div>`
}
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 = '<span class="text-gray-400">Nothing to save — fill in at least one field.</span>'
return
}
setBtn('st-save', true)
try {
const r = await apiPost('/api/settings/config', { updates })
document.getElementById('st-msg').innerHTML =
`<span class="text-green-400">✓ Saved ${r.saved.length} value${r.saved.length!==1?'s':''}. Restart the backend to apply: <code class="bg-gray-800 px-1 rounded text-xs">docker-compose up -d --build backend</code></span>`
document.querySelectorAll('[data-key]').forEach(el => el.value = '')
} catch(e) {
document.getElementById('st-msg').innerHTML = `<span class="text-red-400">${esc(e.message)}</span>`
} finally { setBtn('st-save', false, 'Save to .env') }
}
// ── Router ──────────────────────────────────────────────────────────────── // ── Router ────────────────────────────────────────────────────────────────
function set(html) { document.getElementById('main').innerHTML = html } function set(html) { document.getElementById('main').innerHTML = html }
@@ -411,6 +525,7 @@ function route() {
if (h === '#/coverage') { updateNav('coverage'); renderCoverage() } if (h === '#/coverage') { updateNav('coverage'); renderCoverage() }
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() } else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() } else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
else if (h === '#/settings') { updateNav('settings'); renderSettings() }
else { updateNav('home'); renderHome() } else { updateNav('home'); renderHome() }
} }