mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 12:33:51 +00:00
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:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user