Initial commit: SIEM Toolkit for SentinelOne

Dockerized SecOps toolkit with:
- Coverage Map: STAR rule vs SDL parser field coverage analysis
- Ingest Dashboard: PowerQuery-powered event volume and source breakdown
- Onboarding Assistant: AI-guided log source onboarding with Claude
- Parser management via SDL MCP integration

Stack: FastAPI + PostgreSQL backend, nginx-served HTML frontend, Docker Compose.
PowerQuery runs via Scalyr XDR API (SDL_XDR_URL + SDL_LOG_READ_KEY).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mick
2026-05-19 11:39:26 -04:00
commit c182d837ee
42 changed files with 2273 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
.next
+4
View File
@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
+421
View File
@@ -0,0 +1,421 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIEM Toolkit</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-950 text-gray-100 h-screen flex overflow-hidden font-sans">
<aside class="w-56 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div class="p-4 border-b border-gray-800">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded bg-purple-600 flex items-center justify-center text-xs font-bold text-white">S1</div>
<span class="font-semibold text-sm text-white">SIEM Toolkit</span>
</div>
<p class="text-xs text-gray-500 mt-1">demo.sentinelone.net</p>
</div>
<nav class="flex-1 p-3 space-y-1">
<a href="#/" data-page="home" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Overview</a>
<a href="#/coverage" data-page="coverage" class="nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer">Parser Coverage</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>
</nav>
</aside>
<main class="flex-1 overflow-auto" id="main"></main>
<script>
const API = 'http://localhost:8001'
// ── Utilities ──────────────────────────────────────────────────────────────
async function apiFetch(path, opts = {}) {
const res = await fetch(API + path, opts)
if (!res.ok) throw new Error(await res.text())
return res.json()
}
const apiGet = path => apiFetch(path)
const apiPost = (path, body) => apiFetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) })
const apiForm = (path, form) => apiFetch(path, { method:'POST', body:form })
function esc(s) { return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function statCard(label, value, color = 'text-gray-200') {
return `<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 text-center">
<div class="text-2xl font-bold ${color}">${esc(value)}</div>
<div class="text-xs text-gray-500 mt-1">${esc(label)}</div>
</div>`
}
function errBox(msg) {
return msg ? `<div class="p-3 bg-red-900/40 border border-red-700 rounded-lg text-sm text-red-300 mb-4">${esc(msg)}</div>` : ''
}
function setBtn(id, loading, label) {
const b = document.getElementById(id)
if (!b) return
b.disabled = loading
b.textContent = loading ? 'Loading…' : label
}
// ── Simple SVG bar chart ───────────────────────────────────────────────────
function barChart(rows, labelKey, valueKey) {
if (!rows?.length) return '<p class="text-gray-600 text-sm h-32 flex items-center justify-center">No data</p>'
const max = Math.max(...rows.map(r => r[valueKey] || 0), 1)
const W = 680, H = 140, padL = 10, padB = 24
const bw = Math.max(4, Math.floor((W - padL) / rows.length) - 3)
const bars = rows.map((r, i) => {
const bh = Math.floor(((r[valueKey] || 0) / max) * (H - padB - 4))
const x = padL + i * (bw + 3)
const y = H - padB - bh
const lbl = esc(String(r[labelKey] || '').slice(0, 10))
return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="#7c3aed" rx="2"/>
<text x="${x + bw/2}" y="${H - 4}" text-anchor="middle" fill="#6b7280" font-size="8">${lbl}</text>`
}).join('')
return `<svg viewBox="0 0 ${W} ${H}" class="w-full">${bars}</svg>`
}
// ── Home ──────────────────────────────────────────────────────────────────
function renderHome() {
set(`<div class="p-8 max-w-5xl">
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">SIEM Engineering Toolkit</h1>
<p class="text-gray-400 mt-1">SentinelOne AI-SIEM · demo.sentinelone.net</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
${homeCard('#/coverage','Parser Coverage Map','Cross-reference SDL parser fields against STAR and Sigma rule fields. Surface parsed-but-unused fields as reduction candidates.','Open Coverage Map','from-purple-700 to-purple-900')}
${homeCard('#/ingest','Ingest Dashboard','Visualize event volume by source and type. Project monthly GB costs and simulate exclusion filters before applying them.','Open Dashboard','from-blue-700 to-blue-900')}
${homeCard('#/onboarding','Onboarding Accelerator','Step-by-step guide for onboarding a new log source using Claude Code directly — no API key required.','View Guide','from-emerald-700 to-emerald-900')}
</div>
</div>`)
}
function homeCard(href, title, desc, cta, grad) {
return `<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 flex flex-col gap-4">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br ${grad}"></div>
<div><h2 class="font-semibold text-white">${esc(title)}</h2>
<p class="text-sm text-gray-400 mt-1 leading-relaxed">${esc(desc)}</p></div>
<a href="${href}" class="mt-auto text-sm text-purple-400 hover:text-purple-300 font-medium">${esc(cta)} →</a>
</div>`
}
// ── Coverage ──────────────────────────────────────────────────────────────
let cvFilter = 'all', cvData = null
function renderCoverage() {
set(`<div class="p-8 max-w-6xl">
<div class="flex items-start justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-white">Parser Coverage Map</h1>
<p class="text-sm text-gray-400 mt-1">Cross-reference SDL parser fields against STAR / Sigma rule fields</p>
</div>
<div class="flex gap-2 flex-wrap justify-end">
<button id="btn-star" onclick="loadStar()" class="px-3 py-1.5 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white">Load STAR Rules</button>
<button id="btn-sdl-parsers" onclick="loadSDLParsers()" class="px-3 py-1.5 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white">Load SDL Parsers</button>
<button onclick="document.getElementById('f-sigma').click()" class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg text-white">Upload Sigma Rules</button>
<button onclick="document.getElementById('f-parser').click()" class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg text-white">Upload Parser</button>
<button onclick="cvReset()" class="px-3 py-1.5 text-sm bg-red-900/60 hover:bg-red-800 rounded-lg text-red-300">Reset</button>
</div>
</div>
<input type="file" id="f-sigma" accept=".yml,.yaml" multiple class="hidden" onchange="cvUploadSigma(this.files)">
<input type="file" id="f-parser" accept=".json" class="hidden" onchange="cvUploadParser(this.files[0])">
<div id="cv-err"></div>
<div id="cv-stats"></div>
<div id="cv-filters" class="flex gap-2 mb-4 hidden"></div>
<div id="cv-table"></div>
</div>`)
cvLoad()
}
async function loadSDLParsers() {
setBtn('btn-sdl-parsers', true)
document.getElementById('cv-err').innerHTML = ''
try {
const res = await apiPost('/api/coverage/load-parsers-from-sdl', {})
if (res.errors?.length) {
document.getElementById('cv-err').innerHTML = errBox(`${res.errors.length} parser(s) failed to load: ${res.errors.map(e=>e.parser).join(', ')}`)
}
cvLoad()
} catch(e) {
document.getElementById('cv-err').innerHTML = errBox(e.message)
} finally {
setBtn('btn-sdl-parsers', false, 'Load SDL Parsers')
}
}
async function loadStar() {
setBtn('btn-star', true)
document.getElementById('cv-err').innerHTML = ''
try { await apiPost('/api/coverage/load-star-rules', {}); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
finally { setBtn('btn-star', false, 'Load STAR Rules') }
}
async function cvUploadSigma(files) {
const form = new FormData()
Array.from(files).forEach(f => form.append('files', f))
try { await apiForm('/api/coverage/upload-sigma', form); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
}
async function cvUploadParser(file) {
const form = new FormData(); form.append('file', file)
try { await apiForm('/api/coverage/upload-parser', form); cvLoad() }
catch(e) { document.getElementById('cv-err').innerHTML = errBox(e.message) }
}
async function cvReset() {
await apiGet('/api/coverage/reset'); cvData = null; cvLoad()
}
async function cvLoad() {
try {
cvData = await apiGet('/api/coverage/map')
const s = cvData.summary
document.getElementById('cv-stats').innerHTML = `
<div class="grid grid-cols-5 gap-3 mb-6">
${statCard('Parser Fields', s.total_parser_fields)}
${statCard('Rule Fields', s.total_rule_fields)}
${statCard('Covered', s.covered, 'text-emerald-400')}
${statCard('Parsed Unused', s.parsed_but_unused, 'text-yellow-400')}
${statCard('Missing Parser', s.rules_missing_parser, 'text-red-400')}
</div>`
const filtersEl = document.getElementById('cv-filters')
filtersEl.classList.remove('hidden')
filtersEl.innerHTML = [['all','All'],['covered','Covered'],['unused','Parsed Unused'],['missing_parser','Missing Parser']]
.map(([f,l]) => `<button onclick="cvSetFilter('${f}')" id="cvf-${f}"
class="px-3 py-1 text-xs rounded-full border border-gray-700 text-gray-400 hover:border-gray-500">${l}</button>`).join('')
cvSetFilter(cvFilter)
} catch {
document.getElementById('cv-table').innerHTML = '<p class="text-gray-600 text-sm">Load STAR rules or upload parsers to begin.</p>'
}
}
function suggestParser(field, dataSources) {
if (dataSources && dataSources.length) {
return 'Parser needed for: ' + dataSources.join(', ')
}
// Fallback if no dataSource.name found in rule queries
const f = field.toLowerCase()
if (f.startsWith('wineventlog')) return 'Windows Event Log (WEL) parser'
if (f.startsWith('event.')) return 'Event normalisation parser'
if (f.includes('dns')) return 'DNS log parser'
if (f.includes('process')) return 'Endpoint process parser'
return 'Custom parser needed'
}
function cvSetFilter(f) {
cvFilter = f
document.querySelectorAll('[id^="cvf-"]').forEach(b => {
const on = b.id === `cvf-${f}`
b.className = `px-3 py-1 text-xs rounded-full border transition-colors ${on ? 'bg-purple-700 border-purple-600 text-white' : 'border-gray-700 text-gray-400 hover:border-gray-500'}`
})
if (!cvData) return
const LABELS = { covered:'Covered', unused:'Parsed Unused', missing_parser:'Missing Parser' }
const STYLES = { covered:'bg-emerald-900/50 text-emerald-300 border-emerald-700', unused:'bg-yellow-900/50 text-yellow-300 border-yellow-700', missing_parser:'bg-red-900/50 text-red-300 border-red-700' }
const fields = Object.entries(cvData.fields).filter(([,d]) => f === 'all' || d.status === f)
const showSuggest = f === 'missing_parser' || f === 'all'
document.getElementById('cv-table').innerHTML = fields.length === 0
? '<p class="text-gray-600 text-sm">No fields match this filter.</p>'
: `<div class="overflow-x-auto"><table class="w-full text-sm">
<thead><tr class="text-left text-gray-500 border-b border-gray-800">
<th class="pb-2 pr-4 font-medium">Field</th>
<th class="pb-2 pr-4 font-medium">Status</th>
<th class="pb-2 pr-4 font-medium">Parser / Suggestion</th>
<th class="pb-2 font-medium">Blocked rules</th>
</tr></thead>
<tbody>${fields.map(([field, d]) => `
<tr class="border-b border-gray-800/50 hover:bg-gray-900/30">
<td class="py-2 pr-4 font-mono text-xs text-gray-200">${esc(field)}</td>
<td class="py-2 pr-4"><span class="px-2 py-0.5 rounded text-xs border ${STYLES[d.status]||''}">${LABELS[d.status]||d.status}</span></td>
<td class="py-2 pr-4 text-xs ${d.status === 'missing_parser' ? 'text-amber-400 italic' : 'text-gray-400'}">
${d.status === 'missing_parser' ? '⚠ ' + esc(suggestParser(field, d.data_sources)) : esc(d.parser_name || '—')}
</td>
<td class="py-2 text-xs text-gray-400">${d.rules?.length ? d.rules.map(r=>esc(r.rule)).join(', ') : '—'}</td>
</tr>`).join('')}
</tbody></table></div>`
}
// ── Ingest ────────────────────────────────────────────────────────────────
let igDays = 7
function renderIngest() {
set(`<div class="p-8 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-white">Ingest Dashboard</h1>
<p class="text-sm text-gray-400 mt-1">Event volume · cost projection · filter simulator</p>
</div>
<div class="flex gap-2">
${[7,14,30].map(d=>`<button onclick="igSetDays(${d})" id="ig-d${d}"
class="px-3 py-1.5 text-xs rounded-lg border border-gray-700 text-gray-400 hover:border-gray-500">${d}d</button>`).join('')}
</div>
</div>
<div id="ig-err"></div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<h2 class="text-sm font-medium text-gray-300 mb-4">Daily Event Volume</h2>
<div id="ig-chart"><p class="text-gray-600 text-sm">Loading…</p></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<h2 class="text-sm font-medium text-gray-300 mb-4">Top Sources</h2>
<div id="ig-sources"><p class="text-gray-600 text-sm">Loading…</p></div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h2 class="text-sm font-medium text-gray-300 mb-1">Filter Simulator</h2>
<p class="text-xs text-gray-500 mb-4">Estimate events and GB eliminated by dropping a source + event type combination.</p>
<div class="flex gap-3 flex-wrap mb-4">
<input id="ig-src" placeholder="Source name (optional)"
class="flex-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600">
<input id="ig-evt" placeholder="Event type (optional)"
class="flex-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600">
<button onclick="igSimulate()" id="btn-sim"
class="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 rounded-lg text-white">Simulate</button>
</div>
<div id="ig-sim-result"></div>
</div>
</div>`)
igSetDays(igDays)
}
function igSetDays(d) {
igDays = d
;[7,14,30].forEach(n => {
const b = document.getElementById(`ig-d${n}`)
if (b) b.className = `px-3 py-1.5 text-xs rounded-lg border transition-colors ${n===d ? 'bg-purple-700 border-purple-600 text-white' : 'border-gray-700 text-gray-400 hover:border-gray-500'}`
})
igLoad()
}
async function igLoad() {
try {
const daily = await apiGet(`/api/ingest/daily-volume?days=${igDays}`)
document.getElementById('ig-chart').innerHTML = barChart(daily, 'date', 'events')
} catch(e) {
document.getElementById('ig-chart').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
try {
const { data = [] } = await apiGet(`/api/ingest/top-sources?days=${igDays}`)
const rows = data.map(r => {
const name = r['dataSource.name'] || r.name || 'unknown'
const evts = r.events || 0
return `<tr class="border-b border-gray-800/50">
<td class="py-2 font-mono text-xs text-gray-200">${esc(name)}</td>
<td class="py-2 text-right text-gray-300">${evts.toLocaleString()}</td>
<td class="py-2 text-right text-gray-400">${(evts/1e6*0.5).toFixed(3)}</td>
</tr>`
})
document.getElementById('ig-sources').innerHTML = rows.length ? `
<table class="w-full text-sm">
<thead><tr class="text-left text-gray-500 border-b border-gray-800">
<th class="pb-2 font-medium">Source</th>
<th class="pb-2 text-right font-medium">Events</th>
<th class="pb-2 text-right font-medium">Est. GB</th>
</tr></thead>
<tbody>${rows.join('')}</tbody>
</table>` : `<p class="text-gray-500 text-sm">No data — check that S1_BASE_URL points to your SDL-enabled tenant.</p>`
} catch(e) {
document.getElementById('ig-sources').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
}
}
async function igSimulate() {
setBtn('btn-sim', true)
try {
const r = await apiPost('/api/ingest/simulate-filter', {
source: document.getElementById('ig-src').value,
event_type: document.getElementById('ig-evt').value,
days: igDays, gb_per_million_events: 0.5
})
document.getElementById('ig-sim-result').innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
${statCard('Matched Events', (r.matched_events||0).toLocaleString(), 'text-purple-300')}
${statCard(`Est. GB (${igDays}d)`, r.estimated_gb_period||0, 'text-purple-300')}
${statCard('Proj. Monthly Events', (r.projected_monthly_events||0).toLocaleString(), 'text-purple-300')}
${statCard('Proj. Monthly GB', r.projected_monthly_gb||0, 'text-purple-300')}
</div>`
} catch(e) {
document.getElementById('ig-sim-result').innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
} finally { setBtn('btn-sim', false, 'Simulate') }
}
// ── Onboarding ────────────────────────────────────────────────────────────
const PROMPT = `Onboard this log source for SentinelOne SDL. Please generate:
1. An SDL parser skeleton in augmented-JSON format (/logParsers/ format)
2. Field mappings from raw fields to the SDL common schema
3. 23 starter STAR detection rules for common threats from this source type
4. 5 parser test assertions (input line → expected field → expected value)
Log source: [describe source, e.g. "Palo Alto PAN-OS firewall"]
Raw log sample:
[paste your log lines here]`
function renderOnboarding() {
set(`<div class="p-8 max-w-3xl">
<div class="mb-8">
<h1 class="text-xl font-bold text-white">Onboarding Accelerator</h1>
<p class="text-sm text-gray-400 mt-1">Use Claude Code directly — no API key required</p>
</div>
<div class="space-y-4 mb-8">
${obStep('1. Grab a log sample','Copy 1050 representative lines from the new log source. Include edge cases — errors, different event types, varying field presence.')}
${obStep('2. Paste into Claude Code','Open Claude Code and say "Onboard this log source for SentinelOne SDL" then paste the sample. Mention the source type if known.')}
${obStep('3. Get your artefacts','Claude returns an SDL parser (augmented-JSON), field mappings to the SDL schema, starter STAR detection rules, and parser test assertions.')}
${obStep('4. Deploy','Drop the parser JSON into your /logParsers/ path. Paste the STAR rules into the AI-SIEM rule editor. Run the test assertions to validate extraction.')}
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div class="px-4 py-2 border-b border-gray-800 flex items-center justify-between">
<span class="text-xs font-medium text-gray-400">Prompt template</span>
<button onclick="obCopy()" id="btn-copy" class="px-2 py-1 text-xs text-gray-400 hover:text-gray-200">Copy</button>
</div>
<pre class="p-4 text-xs text-gray-300 font-mono leading-relaxed whitespace-pre-wrap">${esc(PROMPT)}</pre>
</div>
</div>`)
}
function obStep(title, desc) {
return `<div class="flex gap-4 bg-gray-900 border border-gray-800 rounded-xl p-4">
<div class="w-8 h-8 shrink-0 rounded-lg bg-purple-900/60 flex items-center justify-center text-purple-300 text-xs mt-0.5">→</div>
<div>
<div class="text-sm font-medium text-white">${esc(title)}</div>
<div class="text-sm text-gray-400 mt-1">${esc(desc)}</div>
</div>
</div>`
}
function obCopy() {
navigator.clipboard.writeText(PROMPT)
const b = document.getElementById('btn-copy')
if (b) { b.textContent = 'Copied!'; setTimeout(() => b.textContent = 'Copy', 1500) }
}
// ── Router ────────────────────────────────────────────────────────────────
function set(html) { document.getElementById('main').innerHTML = html }
function updateNav(page) {
document.querySelectorAll('.nav-link').forEach(el => {
const on = el.dataset.page === page
el.className = `nav-link flex items-center px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors ${on ? 'bg-purple-700 text-white' : 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'}`
})
}
function route() {
const h = location.hash || '#/'
if (h === '#/coverage') { updateNav('coverage'); renderCoverage() }
else if (h === '#/ingest') { updateNav('ingest'); renderIngest() }
else if (h === '#/onboarding') { updateNav('onboarding'); renderOnboarding() }
else { updateNav('home'); renderHome() }
}
window.addEventListener('hashchange', route)
route()
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
}
module.exports = nextConfig
+9
View File
@@ -0,0 +1,9 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html /index.html;
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "siem-toolkit",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1",
"recharts": "2.12.7",
"@tanstack/react-query": "5.56.2",
"axios": "1.7.7",
"clsx": "2.1.1",
"lucide-react": "0.441.0"
},
"devDependencies": {
"@types/node": "22.5.4",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"autoprefixer": "10.4.20",
"postcss": "8.4.45",
"tailwindcss": "3.4.11",
"typescript": "5.6.2"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+232
View File
@@ -0,0 +1,232 @@
'use client'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import clsx from 'clsx'
type FieldDetail = {
in_parser: boolean
parser_name: string | null
rule_count: number
rules: { rule: string; type: string }[]
status: 'covered' | 'unused' | 'missing_parser'
}
type CoverageMap = {
summary: {
total_parser_fields: number
total_rule_fields: number
covered: number
parsed_but_unused: number
rules_missing_parser: number
}
fields: Record<string, FieldDetail>
}
const STATUS_STYLE = {
covered: 'bg-emerald-900/50 text-emerald-300 border-emerald-700',
unused: 'bg-yellow-900/50 text-yellow-300 border-yellow-700',
missing_parser: 'bg-red-900/50 text-red-300 border-red-700',
}
const STATUS_LABEL = {
covered: 'Covered',
unused: 'Unused (reduce candidate)',
missing_parser: 'Missing parser',
}
export default function CoveragePage() {
const qc = useQueryClient()
const sigmaRef = useRef<HTMLInputElement>(null)
const parserRef = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<'all' | 'covered' | 'unused' | 'missing_parser'>('all')
const [err, setErr] = useState('')
const { data, isLoading } = useQuery<CoverageMap>({
queryKey: ['coverage-map'],
queryFn: () => api.get('/api/coverage/map'),
})
const loadStar = useMutation({
mutationFn: () => api.post('/api/coverage/load-star-rules', {}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['coverage-map'] }),
onError: (e: Error) => setErr(e.message),
})
const uploadSigma = useMutation({
mutationFn: async (files: FileList) => {
const form = new FormData()
Array.from(files).forEach((f) => form.append('files', f))
return api.postForm('/api/coverage/upload-sigma', form)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['coverage-map'] }),
onError: (e: Error) => setErr(e.message),
})
const uploadParser = useMutation({
mutationFn: async (file: File) => {
const form = new FormData()
form.append('file', file)
return api.postForm('/api/coverage/upload-parser', form)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['coverage-map'] }),
onError: (e: Error) => setErr(e.message),
})
const reset = useMutation({
mutationFn: () => api.get('/api/coverage/reset'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['coverage-map'] }),
})
const fields = data
? Object.entries(data.fields).filter(
([, d]) => filter === 'all' || d.status === filter
)
: []
const busy = loadStar.isPending || uploadSigma.isPending || uploadParser.isPending
return (
<div className="p-8 max-w-6xl">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-white">Parser Coverage Map</h1>
<p className="text-sm text-gray-400 mt-1">
Cross-reference SDL parser fields against STAR / Sigma rule fields
</p>
</div>
<div className="flex gap-2 flex-wrap justify-end">
<button
onClick={() => loadStar.mutate()}
disabled={busy}
className="px-3 py-1.5 text-sm bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg text-white"
>
{loadStar.isPending ? 'Loading…' : 'Load STAR Rules'}
</button>
<button
onClick={() => sigmaRef.current?.click()}
disabled={busy}
className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 disabled:opacity-50 rounded-lg text-white"
>
Upload Sigma Rules
</button>
<button
onClick={() => parserRef.current?.click()}
disabled={busy}
className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 disabled:opacity-50 rounded-lg text-white"
>
Upload Parser
</button>
<button
onClick={() => reset.mutate()}
disabled={busy}
className="px-3 py-1.5 text-sm bg-red-900/60 hover:bg-red-800 disabled:opacity-50 rounded-lg text-red-300"
>
Reset
</button>
</div>
</div>
<input
ref={sigmaRef}
type="file"
accept=".yml,.yaml"
multiple
className="hidden"
onChange={(e) => e.target.files && uploadSigma.mutate(e.target.files)}
/>
<input
ref={parserRef}
type="file"
accept=".json"
className="hidden"
onChange={(e) => e.target.files?.[0] && uploadParser.mutate(e.target.files[0])}
/>
{err && (
<div className="mb-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-sm text-red-300">
{err}
</div>
)}
{data && (
<div className="grid grid-cols-5 gap-3 mb-6">
{[
{ label: 'Parser Fields', value: data.summary.total_parser_fields, color: 'text-gray-200' },
{ label: 'Rule Fields', value: data.summary.total_rule_fields, color: 'text-gray-200' },
{ label: 'Covered', value: data.summary.covered, color: 'text-emerald-400' },
{ label: 'Parsed Unused', value: data.summary.parsed_but_unused, color: 'text-yellow-400' },
{ label: 'Missing Parser', value: data.summary.rules_missing_parser, color: 'text-red-400' },
].map(({ label, value, color }) => (
<div key={label} className="bg-gray-900 border border-gray-800 rounded-lg p-4 text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1">{label}</div>
</div>
))}
</div>
)}
<div className="flex gap-2 mb-4">
{(['all', 'covered', 'unused', 'missing_parser'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={clsx(
'px-3 py-1 text-xs rounded-full border transition-colors',
filter === f
? 'bg-purple-700 border-purple-600 text-white'
: 'border-gray-700 text-gray-400 hover:border-gray-500'
)}
>
{f === 'all' ? 'All' : STATUS_LABEL[f]}
</button>
))}
</div>
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : fields.length === 0 ? (
<div className="text-gray-600 text-sm">
{data ? 'No fields match this filter.' : 'Load STAR rules or upload parsers to begin.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="pb-2 pr-4 font-medium">Field</th>
<th className="pb-2 pr-4 font-medium">Status</th>
<th className="pb-2 pr-4 font-medium">Parser</th>
<th className="pb-2 font-medium">Rules using it</th>
</tr>
</thead>
<tbody>
{fields.map(([field, detail]) => (
<tr key={field} className="border-b border-gray-800/50 hover:bg-gray-900/30">
<td className="py-2 pr-4 font-mono text-xs text-gray-200">{field}</td>
<td className="py-2 pr-4">
<span
className={clsx(
'px-2 py-0.5 rounded text-xs border',
STATUS_STYLE[detail.status]
)}
>
{STATUS_LABEL[detail.status]}
</span>
</td>
<td className="py-2 pr-4 text-xs text-gray-400">{detail.parser_name ?? '—'}</td>
<td className="py-2 text-xs text-gray-400">
{detail.rule_count > 0
? detail.rules.map((r) => r.rule).join(', ')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts'
import { api } from '@/lib/api'
type SourceRow = { 'src.name': string; events: number }
type DayRow = { date: string; events: number }
export default function IngestPage() {
const [days, setDays] = useState(7)
const [simSource, setSimSource] = useState('')
const [simEventType, setSimEventType] = useState('')
const [simResult, setSimResult] = useState<Record<string, unknown> | null>(null)
const [simErr, setSimErr] = useState('')
const sources = useQuery<{ data: SourceRow[] }>({
queryKey: ['top-sources', days],
queryFn: () => api.get(`/api/ingest/top-sources?days=${days}`),
})
const daily = useQuery<DayRow[]>({
queryKey: ['daily-volume', days],
queryFn: () => api.get(`/api/ingest/daily-volume?days=${days}`),
})
const simulate = useMutation({
mutationFn: () =>
api.post<Record<string, unknown>>('/api/ingest/simulate-filter', {
source: simSource,
event_type: simEventType,
days,
gb_per_million_events: 0.5,
}),
onSuccess: (data) => { setSimResult(data); setSimErr('') },
onError: (e: Error) => setSimErr(e.message),
})
const chartData = (sources.data?.data ?? []).slice(0, 15).map((r) => ({
name: r['src.name'] ?? 'unknown',
events: r.events ?? 0,
}))
return (
<div className="p-8 max-w-6xl">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-white">Ingest Dashboard</h1>
<p className="text-sm text-gray-400 mt-1">Event volume · cost projection · filter simulator</p>
</div>
<div className="flex gap-2">
{[7, 14, 30].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
days === d
? 'bg-purple-700 border-purple-600 text-white'
: 'border-gray-700 text-gray-400 hover:border-gray-500'
}`}
>
{d}d
</button>
))}
</div>
</div>
{/* Daily volume chart */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<h2 className="text-sm font-medium text-gray-300 mb-4">Daily Event Volume</h2>
{daily.isLoading ? (
<div className="text-gray-600 text-sm h-32 flex items-center">Loading</div>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={daily.data ?? []}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', fontSize: 12 }}
labelStyle={{ color: '#d1d5db' }}
/>
<Bar dataKey="events" fill="#7c3aed" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Top sources table */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 mb-5">
<h2 className="text-sm font-medium text-gray-300 mb-4">Top Sources last {days}d</h2>
{sources.isLoading ? (
<div className="text-gray-600 text-sm">Loading</div>
) : sources.isError ? (
<div className="text-red-400 text-sm">{String(sources.error)}</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="pb-2 font-medium">Source</th>
<th className="pb-2 font-medium text-right">Events</th>
<th className="pb-2 font-medium text-right">Est. GB</th>
</tr>
</thead>
<tbody>
{chartData.map((row) => (
<tr key={row.name} className="border-b border-gray-800/50">
<td className="py-2 font-mono text-xs text-gray-200">{row.name}</td>
<td className="py-2 text-right text-gray-300">{row.events.toLocaleString()}</td>
<td className="py-2 text-right text-gray-400">
{(row.events / 1_000_000 * 0.5).toFixed(3)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Filter simulator */}
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5">
<h2 className="text-sm font-medium text-gray-300 mb-4">Filter Simulator</h2>
<p className="text-xs text-gray-500 mb-4">
Estimate events and GB eliminated by dropping a source + event type combination.
</p>
<div className="flex gap-3 flex-wrap mb-4">
<input
value={simSource}
onChange={(e) => setSimSource(e.target.value)}
placeholder="Source name (optional)"
className="flex-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600"
/>
<input
value={simEventType}
onChange={(e) => setSimEventType(e.target.value)}
placeholder="Event type (optional)"
className="flex-1 min-w-48 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-purple-600"
/>
<button
onClick={() => simulate.mutate()}
disabled={simulate.isPending || (!simSource && !simEventType)}
className="px-4 py-2 text-sm bg-purple-700 hover:bg-purple-600 disabled:opacity-50 rounded-lg text-white"
>
{simulate.isPending ? 'Running…' : 'Simulate'}
</button>
</div>
{simErr && <p className="text-red-400 text-sm">{simErr}</p>}
{simResult && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Matched Events', value: String(simResult.matched_events ?? 0) },
{ label: `Est. GB (${days}d)`, value: String(simResult.estimated_gb_period ?? 0) },
{ label: 'Projected Monthly Events', value: String(simResult.projected_monthly_events ?? 0) },
{ label: 'Projected Monthly GB', value: String(simResult.projected_monthly_gb ?? 0) },
].map(({ label, value }) => (
<div key={label} className="bg-gray-800 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-purple-300">{value}</div>
<div className="text-xs text-gray-500 mt-1">{label}</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import './globals.css'
import Sidebar from '@/components/Sidebar'
import QueryProvider from '@/components/QueryProvider'
export const metadata: Metadata = {
title: 'SIEM Toolkit',
description: 'SentinelOne AI-SIEM Engineering Toolkit',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="font-sans bg-gray-950 text-gray-100 h-screen flex overflow-hidden">
<QueryProvider>
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</QueryProvider>
</body>
</html>
)
}
@@ -0,0 +1,21 @@
'use client'
import { useState } from 'react'
import { Copy, Check } from 'lucide-react'
export default function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
{copied ? <Check size={12} className="text-emerald-400" /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Zap, MessageSquare, FileText, Code2 } from 'lucide-react'
const STEPS = [
{
icon: FileText,
title: '1. Grab a log sample',
desc: 'Copy 1050 representative lines from the new log source. Include edge cases — errors, different event types, varying field presence.',
},
{
icon: MessageSquare,
title: '2. Paste into Claude Code',
desc: 'Open Claude Code and say: "Onboard this log source for SentinelOne SDL" then paste the sample. Mention the source type if known (e.g. "Palo Alto firewall").',
},
{
icon: Code2,
title: '3. Get your artefacts',
desc: 'Claude returns an SDL parser (augmented-JSON), field mappings to the SDL schema, starter STAR detection rules, and parser test assertions.',
},
{
icon: Zap,
title: '4. Deploy',
desc: 'Drop the parser JSON into your /logParsers/ path. Paste the STAR rules into the AI-SIEM rule editor. Run the test assertions to validate extraction.',
},
]
const PROMPT = `Onboard this log source for SentinelOne SDL. Please generate:
1. An SDL parser skeleton in augmented-JSON format (/logParsers/ format)
2. Field mappings from raw fields to the SDL common schema
3. 23 starter STAR detection rules for common threats from this source type
4. 5 parser test assertions (input line expected field expected value)
Log source: [describe source, e.g. "Palo Alto PAN-OS firewall"]
Raw log sample:
[paste your log lines here]`
export default function OnboardingPage() {
return (
<div className="p-8 max-w-3xl">
<div className="mb-8">
<h1 className="text-xl font-bold text-white">Onboarding Accelerator</h1>
<p className="text-sm text-gray-400 mt-1">
Use Claude Code directly no API key required
</p>
</div>
<div className="space-y-4 mb-8">
{STEPS.map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex gap-4 bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="w-8 h-8 shrink-0 rounded-lg bg-purple-900/60 flex items-center justify-center mt-0.5">
<Icon size={15} className="text-purple-300" />
</div>
<div>
<div className="text-sm font-medium text-white">{title}</div>
<div className="text-sm text-gray-400 mt-1">{desc}</div>
</div>
</div>
))}
</div>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-2 border-b border-gray-800 flex items-center justify-between">
<span className="text-xs font-medium text-gray-400">Copy this prompt template</span>
<CopyButton text={PROMPT} />
</div>
<pre className="p-4 text-xs text-gray-300 font-mono leading-relaxed whitespace-pre-wrap">{PROMPT}</pre>
</div>
</div>
)
}
function CopyButton({ text }: { text: string }) {
'use client'
return <_CopyButton text={text} />
}
// Split to keep the page a server component with one small client island
import _CopyButton from './_CopyButton'
+59
View File
@@ -0,0 +1,59 @@
import { Shield, BarChart2, Zap } from 'lucide-react'
import Link from 'next/link'
const CARDS = [
{
href: '/coverage',
icon: Shield,
title: 'Parser Coverage Map',
desc: 'Cross-reference SDL parser output fields against STAR and Sigma rule fields. Surface parsed-but-unused fields as reduction candidates.',
cta: 'Open Coverage Map',
color: 'from-purple-700 to-purple-900',
},
{
href: '/ingest',
icon: BarChart2,
title: 'Ingest Dashboard',
desc: 'Visualize event volume by source and type. Project monthly GB costs and simulate the impact of exclusion filters before applying them.',
cta: 'Open Dashboard',
color: 'from-blue-700 to-blue-900',
},
{
href: '/onboarding',
icon: Zap,
title: 'Onboarding Accelerator',
desc: 'Step-by-step guide for onboarding a new log source using Claude Code directly — no API key required.',
cta: 'View Onboarding Guide',
color: 'from-emerald-700 to-emerald-900',
},
]
export default function Home() {
return (
<div className="p-8 max-w-5xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">SIEM Engineering Toolkit</h1>
<p className="text-gray-400 mt-1">SentinelOne AI-SIEM · demo.sentinelone.net</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{CARDS.map(({ href, icon: Icon, title, desc, cta, color }) => (
<div key={href} className="bg-gray-900 border border-gray-800 rounded-xl p-6 flex flex-col gap-4">
<div className={`w-10 h-10 rounded-lg bg-gradient-to-br ${color} flex items-center justify-center`}>
<Icon size={20} className="text-white" />
</div>
<div>
<h2 className="font-semibold text-white">{title}</h2>
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{desc}</p>
</div>
<Link
href={href}
className="mt-auto text-sm text-purple-400 hover:text-purple-300 font-medium"
>
{cta}
</Link>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,9 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export default function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: 1 } } }))
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Shield, BarChart2, Zap, Home } from 'lucide-react'
import clsx from 'clsx'
const NAV = [
{ href: '/', label: 'Overview', icon: Home },
{ href: '/coverage', label: 'Parser Coverage', icon: Shield },
{ href: '/ingest', label: 'Ingest Dashboard', icon: BarChart2 },
{ href: '/onboarding', label: 'Onboarding', icon: Zap },
]
export default function Sidebar() {
const path = usePathname()
return (
<aside className="w-56 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="p-4 border-b border-gray-800">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-purple-600 flex items-center justify-center text-xs font-bold">S1</div>
<span className="font-semibold text-sm text-white">SIEM Toolkit</span>
</div>
<p className="text-xs text-gray-500 mt-1">demo.sentinelone.net</p>
</div>
<nav className="flex-1 p-3 space-y-1">
{NAV.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={clsx(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors',
path === href
? 'bg-purple-700 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
)}
>
<Icon size={15} />
{label}
</Link>
))}
</nav>
</aside>
)
}
+22
View File
@@ -0,0 +1,22 @@
const BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'
export async function apiFetch<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, init)
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res.json() as Promise<T>
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
post: <T>(path: string, body: unknown) =>
apiFetch<T>(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
postForm: <T>(path: string, form: FormData) =>
apiFetch<T>(path, { method: 'POST', body: form }),
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: '#7c3aed',
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}