mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 20:37:12 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Zap, MessageSquare, FileText, Code2 } from 'lucide-react'
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: '1. Grab a log sample',
|
||||
desc: 'Copy 10–50 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. 2–3 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'
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
Reference in New Issue
Block a user