diff --git a/app/admin/sync/page.tsx b/app/admin/sync/page.tsx index 29eea57..5483f33 100644 --- a/app/admin/sync/page.tsx +++ b/app/admin/sync/page.tsx @@ -16,17 +16,20 @@ interface IntegrationCard { } const INTEGRATIONS: IntegrationCard[] = [ - { id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' }, - { id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' }, - { id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'blue' }, - { id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' }, - { id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' }, + { id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' }, + { id: 'itglue', category: 'Documentation', product: 'IT Glue', description: 'Organizations, configurations, contacts, passwords, flexible assets, documents, domains', href: '/admin/sync/itglue', logo: '/logos/itglue.ico', color: 'blue' }, + { id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' }, + { id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'orange' }, + { id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' }, + { id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' }, + { id: 'sentinelone', category: 'EDR/AV', product: 'SentinelOne', description: 'Endpoint agents, threat detections, site coverage, AV health', href: '/admin/sync/sentinelone', logo: '/logos/sentinelone.ico', color: 'purple' }, ]; const COLOR_MAP: Record = { red: { bg: 'bg-red-500/5', border: 'border-red-500/20' }, green: { bg: 'bg-green-500/5', border: 'border-green-500/20' }, blue: { bg: 'bg-blue-500/5', border: 'border-blue-500/20' }, + orange: { bg: 'bg-orange-500/5', border: 'border-orange-500/20' }, purple: { bg: 'bg-purple-500/5', border: 'border-purple-500/20' }, gray: { bg: 'bg-muted/20', border: 'border-border' }, }; @@ -48,17 +51,24 @@ export default function SyncOverviewPage() { const [autotaskSync, setAutotaskSync] = useState(null); const [loading, setLoading] = useState(true); + const [itglueSyncData, setItglueSyncData] = useState(null); + const [s1SyncData, setS1SyncData] = useState(null); + const fetchAll = async () => { try { - const [intRes, atRes] = await Promise.all([ + const [intRes, atRes, itgRes, s1Res] = await Promise.all([ fetch('/api/integrations/status'), fetch('/api/sync/last-sync'), + fetch('/api/itglue/sync'), + fetch('/api/sentinelone/sync'), ]); if (intRes.ok) setStatus(await intRes.json()); if (atRes.ok) { const d = await atRes.json(); setAutotaskSync(d.lastSync || {}); } + if (itgRes.ok) setItglueSyncData(await itgRes.json()); + if (s1Res.ok) setS1SyncData(await s1Res.json()); } catch (e) { console.error(e); } finally { @@ -110,6 +120,31 @@ export default function SyncOverviewPage() { critical: d.openAlerts?.critical ?? 0, }; } + if (id === 'itglue') { + if (!itglueSyncData) return null; + const h = itglueSyncData.history?.[0]; + const c = itglueSyncData.counts ?? {}; + return { + lastSync: h?.completed_at ?? null, + organizations: Number(c.organizations ?? 0), + configurations: Number(c.configurations ?? 0), + totalUpserted: h?.total_upserted ?? 0, + status: h?.status ?? null, + }; + } + if (id === 'sentinelone') { + if (!s1SyncData) return null; + const h = s1SyncData.history?.[0]; + const c = s1SyncData.counts ?? {}; + return { + lastSync: h?.completed_at ?? null, + status: h?.status ?? null, + sites: Number(c.sites ?? 0), + agents: Number(c.agents ?? 0), + infected: Number(c.infected ?? 0), + threats: Number(c.threats ?? 0), + }; + } if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured }; if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured }; return null; @@ -131,6 +166,16 @@ export default function SyncOverviewPage() { if (summary.openAlerts > 0) return ; return ; } + if (id === 'itglue') { + if (!summary.lastSync) return ; + if (summary.status === 'failed') return ; + return ; + } + if (id === 'sentinelone') { + if (!summary.lastSync) return ; + if (summary.infected > 0) return ; + return ; + } return ; }; @@ -243,6 +288,40 @@ export default function SyncOverviewPage() { )} )} + {intg.id === 'itglue' && summary && ( + <> +
+ Last sync + {fmtDate(summary.lastSync)} +
+
+ Organizations + {(summary as any).organizations?.toLocaleString()} +
+
+ Configurations + {(summary as any).configurations?.toLocaleString()} +
+ + )} + {intg.id === 'sentinelone' && summary && ( + <> +
+ Last sync + {fmtDate(summary.lastSync)} +
+
+ Sites / Agents + {(summary as any).sites} / {(summary as any).agents?.toLocaleString()} +
+ {(summary as any).infected > 0 && ( +
+ Infected + {(summary as any).infected} +
+ )} + + )} {(intg.id === 'auvik' || intg.id === 'addigy') && (
Status diff --git a/app/admin/sync/sentinelone/page.tsx b/app/admin/sync/sentinelone/page.tsx new file mode 100644 index 0000000..2a1aba9 --- /dev/null +++ b/app/admin/sync/sentinelone/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ArrowLeft, RefreshCw, Play, CheckCircle2, XCircle, Clock, + Shield, Monitor, AlertTriangle, Activity, +} from 'lucide-react'; + +function fmtDate(d: string | null) { + if (!d) return '—'; + return new Date(d).toLocaleString(); +} +function fmtDuration(ms: number | null) { + if (!ms) return '—'; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +} + +export default function SentinelOneSyncPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + + const fetchData = useCallback(async () => { + try { + const res = await fetch('/api/sentinelone/sync'); + if (res.ok) setData(await res.json()); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 10000); + return () => clearInterval(interval); + }, [fetchData]); + + const triggerSync = async () => { + setSyncing(true); + try { + await fetch('/api/sentinelone/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ triggeredBy: 'manual' }), + }); + setTimeout(fetchData, 2000); + } finally { + setSyncing(false); + } + }; + + const lastSync = data?.history?.[0]; + const counts = data?.counts ?? {}; + const inProgress = data?.inProgress ?? false; + + return ( +
+
+
+ + + +
+

+ + SentinelOne Sync +

+

Sites, agents, and threats synced to s1_* tables

+
+
+ +
+ + {/* Stats */} +
+ {[ + { label: 'Sites', value: counts.sites, icon: Shield, color: 'text-purple-500' }, + { label: 'Agents', value: counts.agents, icon: Monitor, color: 'text-blue-500' }, + { label: 'Active', value: counts.active_agents, icon: Activity, color: 'text-green-500' }, + { label: 'Infected', value: counts.infected, icon: AlertTriangle, color: 'text-red-500' }, + { label: 'Threats', value: counts.threats, icon: XCircle, color: 'text-orange-500' }, + ].map(({ label, value, icon: Icon, color }) => ( + + + + {label} + + + +
+ {loading ? '—' : (Number(value ?? 0)).toLocaleString()} +
+
+
+ ))} +
+ + {/* Last sync status */} + {lastSync && ( + + + Last Sync + + +
+ {lastSync.status === 'completed' + ? + : lastSync.status === 'running' + ? + : } +
+
{lastSync.status}
+
+ {fmtDate(lastSync.completed_at || lastSync.started_at)} · {fmtDuration(lastSync.duration_ms)} · {lastSync.total_upserted?.toLocaleString()} records +
+
+
+ {lastSync.entity_results && ( +
+ {(Array.isArray(lastSync.entity_results) + ? lastSync.entity_results + : JSON.parse(lastSync.entity_results) + ).map((e: any) => ( +
+ {e.entity} +
+ {e.success + ? {e.recordsUpserted.toLocaleString()} + : failed} +
+
+ ))} +
+ )} + {lastSync.error_message && ( +
{lastSync.error_message}
+ )} +
+
+ )} + + {/* History */} + + Sync History + +
+ {(data?.history ?? []).map((h: any) => ( +
+
+ {h.status === 'completed' ? + : h.status === 'running' ? + : } + {fmtDate(h.started_at)} +
+
+ {h.total_upserted?.toLocaleString() ?? 0} records + {fmtDuration(h.duration_ms)} + {h.triggered_by} +
+
+ ))} + {!loading && (data?.history ?? []).length === 0 && ( +

No sync history yet — run a sync to get started

+ )} +
+
+
+ +
+ + + + + + +
+
+ ); +} diff --git a/app/api/sentinelone/company-mappings/route.ts b/app/api/sentinelone/company-mappings/route.ts new file mode 100644 index 0000000..feee9e9 --- /dev/null +++ b/app/api/sentinelone/company-mappings/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = request.nextUrl; + const includeUnmapped = searchParams.get('includeUnmapped') === 'true'; + + const mappings = await postgresClient.query( + `SELECT m.id, m.s1_site_id, m.s1_site_name, m.company_id, m.company_name, + m.notes, m.created_at, m.updated_at, + s.state, s.active_licenses, s.health_status, s.sku + FROM s1_company_mappings m + LEFT JOIN s1_sites s ON s.id = m.s1_site_id + ORDER BY m.s1_site_name` + ); + + if (!includeUnmapped) { + return NextResponse.json({ mappings: mappings.rows }); + } + + const allSites = await postgresClient.query( + `SELECT id, name, state, active_licenses, health_status, sku FROM s1_sites ORDER BY name` + ); + + const mappedIds = new Set(mappings.rows.map((r: any) => r.s1_site_id)); + const unmapped = allSites.rows + .filter((s: any) => !mappedIds.has(s.id)) + .map((s: any) => ({ + id: null, + s1_site_id: s.id, + s1_site_name: s.name, + company_id: null, + company_name: null, + notes: null, + state: s.state, + active_licenses: s.active_licenses, + health_status: s.health_status, + sku: s.sku, + })); + + return NextResponse.json({ + mappings: [...mappings.rows, ...unmapped], + stats: { + total: allSites.rows.length, + mapped: mappings.rows.length, + unmapped: unmapped.length, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { s1SiteId, s1SiteName, companyId, companyName, notes = null } = body; + + if (!s1SiteId || !companyId) { + return NextResponse.json( + { error: 'Missing required fields: s1SiteId and companyId' }, + { status: 400 } + ); + } + + const result = await postgresClient.query( + `INSERT INTO s1_company_mappings (s1_site_id, s1_site_name, company_id, company_name, notes) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (s1_site_id) DO UPDATE SET + s1_site_name = $2, company_id = $3, company_name = $4, + notes = $5, updated_at = NOW() + RETURNING *`, + [s1SiteId, s1SiteName, companyId, companyName || null, notes] + ); + + return NextResponse.json({ success: true, mapping: result.rows[0] }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const id = request.nextUrl.searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + + const result = await postgresClient.query( + 'DELETE FROM s1_company_mappings WHERE id = $1 RETURNING *', + [id] + ); + + if (result.rowCount === 0) { + return NextResponse.json({ error: 'Mapping not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/sentinelone/coverage/route.ts b/app/api/sentinelone/coverage/route.ts new file mode 100644 index 0000000..f23e343 --- /dev/null +++ b/app/api/sentinelone/coverage/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from 'next/server'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function GET() { + try { + const result = await postgresClient.query(` + SELECT + s.id AS s1_site_id, + s.name AS s1_site_name, + s.state, + s.sku, + s.health_status, + s.active_licenses, + m.company_id, + m.company_name, + COALESCE(a.total_agents, 0) AS total_agents, + COALESCE(a.active_agents, 0) AS active_agents, + COALESCE(a.infected_agents, 0) AS infected_agents, + COALESCE(a.outdated_agents, 0) AS outdated_agents, + COALESCE(a.decommissioned_agents, 0) AS decommissioned_agents, + a.last_seen, + COALESCE(t.total_threats, 0) AS total_threats, + COALESCE(t.active_threats, 0) AS active_threats + FROM s1_sites s + LEFT JOIN s1_company_mappings m ON m.s1_site_id = s.id + LEFT JOIN ( + SELECT + site_id, + COUNT(*) AS total_agents, + COUNT(*) FILTER (WHERE is_active = true) AS active_agents, + COUNT(*) FILTER (WHERE infected = true) AS infected_agents, + COUNT(*) FILTER (WHERE is_up_to_date = false) AS outdated_agents, + COUNT(*) FILTER (WHERE is_decommissioned = true) AS decommissioned_agents, + MAX(updated_at) AS last_seen + FROM s1_agents + WHERE is_decommissioned = false + GROUP BY site_id + ) a ON a.site_id = s.id + LEFT JOIN ( + SELECT + site_id, + COUNT(*) AS total_threats, + COUNT(*) FILTER (WHERE mitigation_status NOT IN + ('mitigated','marked_as_benign','marked_as_false_positive')) AS active_threats + FROM s1_threats + GROUP BY site_id + ) t ON t.site_id = s.id + ORDER BY s.name + `); + + const rows = result.rows.map((r: any) => ({ + ...r, + total_agents: Number(r.total_agents), + active_agents: Number(r.active_agents), + infected_agents: Number(r.infected_agents), + outdated_agents: Number(r.outdated_agents), + decommissioned_agents: Number(r.decommissioned_agents), + total_threats: Number(r.total_threats), + active_threats: Number(r.active_threats), + })); + + const summary = { + totalSites: rows.length, + mappedSites: rows.filter((r: any) => r.company_id).length, + totalAgents: rows.reduce((s: number, r: any) => s + r.total_agents, 0), + infectedAgents: rows.reduce((s: number, r: any) => s + r.infected_agents, 0), + activeThreats: rows.reduce((s: number, r: any) => s + r.active_threats, 0), + outdatedAgents: rows.reduce((s: number, r: any) => s + r.outdated_agents, 0), + }; + + return NextResponse.json({ sites: rows, summary }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/api/sentinelone/sync/route.ts b/app/api/sentinelone/sync/route.ts new file mode 100644 index 0000000..f746af4 --- /dev/null +++ b/app/api/sentinelone/sync/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSentinelOneSyncService } from '@/lib/services/sentinelone-sync-service'; +import { postgresClient } from '@/lib/services/postgres-client'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const triggeredBy = body.triggeredBy || 'manual'; + + const svc = getSentinelOneSyncService(); + if (svc.isSyncInProgress()) { + return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 }); + } + + svc.fullSync(triggeredBy).catch(err => console.error('[S1Sync] Background sync error:', err)); + + return NextResponse.json({ success: true, message: 'SentinelOne sync started' }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function GET() { + try { + const [historyRes, countsRes] = await Promise.all([ + postgresClient.query( + `SELECT id, sync_type, status, triggered_by, started_at, completed_at, + duration_ms, total_upserted, error_message, entity_results + FROM s1_sync_history ORDER BY started_at DESC LIMIT 10` + ), + postgresClient.query( + `SELECT + (SELECT COUNT(*) FROM s1_sites) AS sites, + (SELECT COUNT(*) FROM s1_agents) AS agents, + (SELECT COUNT(*) FROM s1_agents WHERE infected = true) AS infected, + (SELECT COUNT(*) FROM s1_agents WHERE is_active = true) AS active_agents, + (SELECT COUNT(*) FROM s1_threats) AS threats` + ), + ]); + + const svc = getSentinelOneSyncService(); + + return NextResponse.json({ + inProgress: svc.isSyncInProgress(), + history: historyRes.rows, + counts: countsRes.rows[0], + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/app/sentinelone/coverage/page.tsx b/app/sentinelone/coverage/page.tsx new file mode 100644 index 0000000..720469d --- /dev/null +++ b/app/sentinelone/coverage/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { Shield, Monitor, AlertTriangle, RefreshCw, Search, CheckCircle2, XCircle, Link2Off } from 'lucide-react'; + +interface SiteCoverage { + s1_site_id: string; + s1_site_name: string; + state: string; + sku: string; + health_status: boolean; + active_licenses: number; + company_id: number | null; + company_name: string | null; + total_agents: number; + active_agents: number; + infected_agents: number; + outdated_agents: number; + decommissioned_agents: number; + total_threats: number; + active_threats: number; + last_seen: string | null; +} + +export default function S1CoveragePage() { + const [sites, setSites] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'issues' | 'unmapped'>('all'); + + const fetchData = async () => { + setLoading(true); + try { + const res = await fetch('/api/sentinelone/coverage'); + if (res.ok) { + const data = await res.json(); + setSites(data.sites ?? []); + setSummary(data.summary ?? null); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchData(); }, []); + + const filtered = sites.filter(s => { + const matchesSearch = !search || + s.s1_site_name.toLowerCase().includes(search.toLowerCase()) || + (s.company_name || '').toLowerCase().includes(search.toLowerCase()); + const matchesFilter = + filter === 'all' || + (filter === 'issues' && (s.infected_agents > 0 || s.active_threats > 0 || s.outdated_agents > 0)) || + (filter === 'unmapped' && !s.company_id); + return matchesSearch && matchesFilter; + }); + + return ( +
+
+
+

+ + SentinelOne Coverage +

+

AV agent coverage and threat status per site

+
+ +
+ + {/* Summary cards */} + {summary && ( +
+ {[ + { label: 'Total Sites', value: summary.totalSites, color: 'text-foreground' }, + { label: 'Mapped Sites', value: summary.mappedSites, color: 'text-blue-500' }, + { label: 'Total Agents', value: summary.totalAgents, color: 'text-foreground' }, + { label: 'Infected', value: summary.infectedAgents, color: summary.infectedAgents > 0 ? 'text-red-500' : 'text-green-500' }, + { label: 'Active Threats', value: summary.activeThreats, color: summary.activeThreats > 0 ? 'text-orange-500' : 'text-green-500' }, + { label: 'Outdated', value: summary.outdatedAgents, color: summary.outdatedAgents > 0 ? 'text-yellow-500' : 'text-green-500' }, + ].map(({ label, value, color }) => ( + + {label} +
{Number(value).toLocaleString()}
+
+ ))} +
+ )} + + {/* Filters */} +
+
+ + setSearch(e.target.value)} /> +
+ {(['all', 'issues', 'unmapped'] as const).map(f => ( + + ))} +
+ + {/* Table */} + + + + + + Site + Company + SKU + Agents + Active + Infected + Outdated + Threats + Status + + + + {loading ? ( + Loading... + ) : filtered.length === 0 ? ( + No sites found + ) : filtered.map(s => ( + 0 || s.active_threats > 0 ? 'bg-red-500/5' : ''}> + +
{s.s1_site_name}
+
{s.state}
+
+ + {s.company_name + ? {s.company_name} + : Unmapped} + + + {s.sku || '—'} + + {s.total_agents} + + {s.active_agents} + + + {s.infected_agents > 0 + ? {s.infected_agents} + : 0} + + + {s.outdated_agents > 0 + ? {s.outdated_agents} + : 0} + + + {s.active_threats > 0 + ? {s.active_threats} + : 0} + + + {s.infected_agents > 0 || s.active_threats > 0 + ? Action Needed + : s.outdated_agents > 0 + ? Outdated + : s.total_agents === 0 + ? No Agents + : + OK + } + +
+ ))} +
+
+
+
+
+ ); +} diff --git a/app/sentinelone/mappings/page.tsx b/app/sentinelone/mappings/page.tsx new file mode 100644 index 0000000..4c99b54 --- /dev/null +++ b/app/sentinelone/mappings/page.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Shield, Building2, RefreshCw, Search, Save, Trash2, CheckCircle, XCircle, Link2Off } from 'lucide-react'; +import { Company } from '@/lib/types/autotask'; + +interface S1SiteRow { + id: number | null; + s1_site_id: string; + s1_site_name: string; + company_id: number | null; + company_name: string | null; + state: string; + active_licenses: number; + health_status: boolean; + sku: string; + notes: string | null; +} + +const useToast = () => ({ + toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => { + if (variant === 'destructive') { console.error(`${title}: ${description}`); alert(`Error: ${description}`); } + else console.log(`${title}: ${description}`); + }, +}); + +export default function S1MappingsPage() { + const [sites, setSites] = useState([]); + const [companies, setCompanies] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState<'all' | 'mapped' | 'unmapped'>('all'); + const { toast } = useToast(); + + const fetchData = async () => { + setLoading(true); + try { + const [mappingsRes, companiesRes] = await Promise.all([ + fetch('/api/sentinelone/company-mappings?includeUnmapped=true'), + fetch('/api/companies'), + ]); + if (mappingsRes.ok) setSites((await mappingsRes.json()).mappings ?? []); + if (companiesRes.ok) setCompanies((await companiesRes.json()).companies ?? []); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchData(); }, []); + + const handleSync = async () => { + setSyncing(true); + try { + await fetch('/api/sentinelone/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ triggeredBy: 'manual' }), + }); + await new Promise(r => setTimeout(r, 5000)); + await fetchData(); + toast({ title: 'Done', description: 'SentinelOne synced' }); + } catch { + toast({ title: 'Error', description: 'Sync failed', variant: 'destructive' }); + } finally { + setSyncing(false); + } + }; + + const filtered = sites.filter(s => { + const matchesSearch = !search || + s.s1_site_name.toLowerCase().includes(search.toLowerCase()) || + (s.company_name || '').toLowerCase().includes(search.toLowerCase()); + const matchesFilter = + filter === 'all' || + (filter === 'mapped' && s.company_id !== null) || + (filter === 'unmapped' && s.company_id === null); + return matchesSearch && matchesFilter; + }); + + const stats = { + total: sites.length, + mapped: sites.filter(s => s.company_id !== null).length, + unmapped: sites.filter(s => s.company_id === null).length, + }; + + return ( +
+
+
+

+ + SentinelOne Site Mappings +

+

Map SentinelOne sites to Autotask companies

+
+
+ + +
+
+ + {/* Stats */} +
+ + Total Sites +
{stats.total}
+
+ + Mapped +
{stats.mapped}
+
+ + Unmapped +
{stats.unmapped}
+
+
+ + + + Site Mappings + Each SentinelOne site corresponds to a client. Map them to Autotask companies to enable coverage reporting. + + +
+
+ + setSearch(e.target.value)} /> +
+ +
+ +
+ + + + S1 Site + SKU / State + Autotask Company + Status + Actions + + + + {loading ? ( + Loading... + ) : filtered.length === 0 ? ( + No sites found + ) : filtered.map(site => ( + + ))} + +
+
+
+
+
+ ); +} + +function SiteMappingRow({ + site, companies, onSaved, +}: { + site: S1SiteRow; + companies: Company[]; + onSaved: () => void; +}) { + const [selectedId, setSelectedId] = useState(site.company_id ?? 0); + const [hasChanges, setHasChanges] = useState(false); + const [saving, setSaving] = useState(false); + const { toast } = useToast(); + + const handleChange = (val: string) => { + const id = parseInt(val); + setSelectedId(id); + setHasChanges(id !== (site.company_id ?? 0)); + }; + + const handleSave = async () => { + setSaving(true); + try { + const company = companies.find(c => c.id === selectedId); + const res = await fetch('/api/sentinelone/company-mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + s1SiteId: site.s1_site_id, + s1SiteName: site.s1_site_name, + companyId: selectedId, + companyName: company?.companyName ?? null, + }), + }); + if (!res.ok) throw new Error('Save failed'); + toast({ title: 'Saved', description: `Mapped ${site.s1_site_name}` }); + setHasChanges(false); + onSaved(); + } catch { + toast({ title: 'Error', description: 'Failed to save', variant: 'destructive' }); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!site.id) return; + setSaving(true); + try { + await fetch(`/api/sentinelone/company-mappings?id=${site.id}`, { method: 'DELETE' }); + toast({ title: 'Deleted', description: `Removed mapping for ${site.s1_site_name}` }); + onSaved(); + } finally { + setSaving(false); + } + }; + + return ( + + +
{site.s1_site_name}
+
{site.s1_site_id}
+
+ + {site.sku || '—'} +
{site.state}
+
+ + + + + {site.company_id !== null + ? Mapped + : Unmapped} + + +
+ {hasChanges && ( + + )} + {site.id && ( + + )} +
+
+
+ ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index ea61a48..1c1d6ad 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -15,6 +15,12 @@ import { Activity, HardDrive, Workflow, + GitBranch, + Sparkles, + Bell, + Zap, + Radio, + Shield, } from 'lucide-react'; import { NavigationMenu, @@ -77,6 +83,12 @@ const navigationItems: NavItem[] = [ icon: Globe, description: 'Map RMM sites to companies' }, + { + title: 'Zabbix WAN Monitor', + href: '/admin/zabbix-wan', + icon: Radio, + description: 'Sync RMM site WAN IPs to Zabbix with Autotask routing' + }, { title: 'Apple RMM Mapping (Addigy)', href: '/addigy-mappings', @@ -84,11 +96,47 @@ const navigationItems: NavItem[] = [ description: 'Map Addigy devices to companies' }, { - title: 'Workflow Engine', + title: 'Ticket Workflows', href: '/admin/workflow', icon: Workflow, description: 'Automated ticket triage and classification' }, + { + title: 'Classification Rules', + href: '/admin/workflow/classification-rules', + icon: GitBranch, + description: 'Keyword-based classification rules' + }, + { + title: 'AI Templates', + href: '/admin/workflow/ai-templates', + icon: Sparkles, + description: 'AI prompt templates for enhancement' + }, + { + title: 'Webhook Pipelines', + href: '/admin/workflow/pipelines', + icon: Zap, + description: 'Automated webhook processing workflows' + }, + { + title: 'Notification Channels', + href: '/admin/workflow/channels', + icon: Bell, + description: 'Teams, Telegram, and webhook notifications' + }, + { + title: 'IT Glue Sync', + href: '/admin/sync/itglue', + icon: Shield, + description: 'IT Glue documentation backup — organizations, configs, passwords, flexible assets' + }, + { + title: 'SentinelOne Sync', + href: '/admin/sync/sentinelone', + icon: Shield, + description: 'SentinelOne EDR — sites, agents, threats sync' + }, { title: 'Data Browser', href: '/admin/data-browser', diff --git a/docker-compose.yml b/docker-compose.yml index 2f2deb2..8a007f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -95,6 +95,23 @@ services: # Veeam VSPC Configuration VEEAM_VSPC_URL: ${VEEAM_VSPC_URL} VEEAM_VSPC_API_KEY: ${VEEAM_VSPC_API_KEY} + + # Zabbix API Configuration + ZABBIX_API_URL: ${ZABBIX_API_URL} + ZABBIX_API_TOKEN: ${ZABBIX_API_TOKEN} + + # ipinfo.io API token (optional) + IPINFO_TOKEN: ${IPINFO_TOKEN:-} + + # IT Glue Configuration + ITGLUE_API_KEY: ${ITGLUE_API_KEY} + + # Backblaze B2 Storage (S3-compatible) + B2_KEY_ID: ${B2_KEY_ID} + B2_APP_KEY: ${B2_APP_KEY} + B2_BUCKET: ${B2_BUCKET:-wulf-audits} + B2_REGION: ${B2_REGION:-us-west-002} + B2_ENDPOINT: ${B2_ENDPOINT:-s3.us-west-002.backblazeb2.com} # Webhook Configuration WEBHOOK_BASE_URL: ${WEBHOOK_BASE_URL:-https://pulse.wulfconsulting.cloud} diff --git a/lib/services/sentinelone-client.ts b/lib/services/sentinelone-client.ts new file mode 100644 index 0000000..fe10105 --- /dev/null +++ b/lib/services/sentinelone-client.ts @@ -0,0 +1,234 @@ +/** + * SentinelOne API Client + * Covers: sites, agents, threats, groups + * API version: 2.1 + */ + +export interface S1Site { + id: string; + accountId: string; + accountName: string; + name: string; + siteType: string; + state: string; + sku: string; + suite: string; + healthStatus: boolean; + activeLicenses: number; + totalLicenses: number; + unlimitedLicenses: boolean; + unlimitedExpiration: boolean; + expiration: string | null; + isDefault: boolean; + usageType: string; + externalId: string | null; + registrationToken: string | null; + description: string | null; + createdAt: string; + updatedAt: string; +} + +export interface S1Agent { + id: string; + siteId: string; + siteName: string; + accountId: string; + accountName: string; + groupId: string; + groupName: string; + computerName: string; + domain: string | null; + osType: string; + osName: string; + osRevision: string; + agentVersion: string; + machineType: string; + isActive: boolean; + isDecommissioned: boolean; + isUpToDate: boolean; + isPendingUninstall: boolean; + isUninstalled: boolean; + infected: boolean; + activeThreats: number; + networkStatus: string; + mitigationMode: string; + detectionState: string; + appsVulnerabilityStatus: string; + firewallEnabled: boolean; + externalIp: string | null; + lastActiveDate: string | null; + lastLoggedInUserName: string | null; + cpuId: string | null; + coreCount: number | null; + cpuCount: number | null; + totalMemory: number | null; + uuid: string; + externalId: string | null; + installerType: string | null; + scanStatus: string | null; + scanStartedAt: string | null; + scanFinishedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface S1Threat { + id: string; + agentDetectionInfo: { + siteId: string; + siteName: string; + accountId: string; + agentUuid: string; + }; + agentRealtimeInfo: { + agentId: string; + agentComputerName: string; + agentOsName: string; + agentVersion: string; + agentIsActive: boolean; + agentIsDecommissioned: boolean; + siteId: string; + siteName: string; + }; + threatInfo: { + threatName: string | null; + filePath: string | null; + sha256: string | null; + classification: string | null; + classificationSource: string | null; + confidenceLevel: string | null; + mitigationStatus: string | null; + analystVerdict: string | null; + incidentStatus: string | null; + detectionEngines: any[] | null; + createdAt: string; + updatedAt: string; + }; + mitigationStatus: any[]; + indicators: any[]; +} + +export interface S1Pagination { + totalItems: number; + nextCursor: string | null; +} + +export interface S1ListResponse { + data: T[]; + pagination: S1Pagination; +} + +export class SentinelOneClient { + private baseUrl: string; + private token: string; + + constructor(baseUrl?: string, token?: string) { + this.baseUrl = (baseUrl || process.env.S1_API_URL || '').replace(/\/$/, ''); + this.token = token || process.env.S1_API_TOKEN || ''; + if (!this.baseUrl || !this.token) { + throw new Error('SentinelOne: S1_API_URL and S1_API_TOKEN are required'); + } + } + + private async request(path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}${path}`); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); + } + + const res = await fetch(url.toString(), { + headers: { + Authorization: `ApiToken ${this.token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`S1 API ${path} failed (${res.status}): ${body.slice(0, 200)}`); + } + + return res.json(); + } + + private async getAllPages( + path: string, + dataKey: string | null = null, + extraParams: Record = {} + ): Promise { + const results: T[] = []; + let cursor: string | null = null; + + do { + const params: Record = { limit: 1000, ...extraParams }; + if (cursor) params.cursor = cursor; + + const resp = await this.request(path, params); + const items = dataKey ? resp.data?.[dataKey] : resp.data; + if (Array.isArray(items)) results.push(...items); + + cursor = resp.pagination?.nextCursor || null; + } while (cursor); + + return results; + } + + async testConnection(): Promise<{ ok: boolean; totalSites: number; totalAgents: number }> { + const [sites, agents] = await Promise.all([ + this.request('/web/api/v2.1/sites?limit=1&countOnly=false'), + this.request('/web/api/v2.1/agents?limit=1&countOnly=false'), + ]); + return { + ok: true, + totalSites: sites.pagination?.totalItems ?? 0, + totalAgents: agents.pagination?.totalItems ?? 0, + }; + } + + async getSites(): Promise { + return this.getAllPages('/web/api/v2.1/sites', 'sites'); + } + + async getAgents(siteId?: string): Promise { + const extra: Record = siteId ? { siteIds: siteId } : {}; + return this.getAllPages('/web/api/v2.1/agents', null, extra); + } + + async getThreats(siteId?: string): Promise { + const extra: Record = siteId ? { siteIds: siteId } : {}; + return this.getAllPages('/web/api/v2.1/threats', null, extra); + } + + async getSiteSummary(siteId: string): Promise<{ + agents: number; + activeAgents: number; + infected: number; + upToDate: number; + threats: number; + }> { + const [agentsResp, threatsResp] = await Promise.all([ + this.request('/web/api/v2.1/agents', { siteIds: siteId, countOnly: false, limit: 1 }), + this.request('/web/api/v2.1/threats', { siteIds: siteId, countOnly: false, limit: 1 }), + ]); + + const [activeResp, infectedResp, upToDateResp] = await Promise.all([ + this.request('/web/api/v2.1/agents', { siteIds: siteId, isActive: true, countOnly: true }), + this.request('/web/api/v2.1/agents', { siteIds: siteId, infected: true, countOnly: true }), + this.request('/web/api/v2.1/agents', { siteIds: siteId, isUpToDate: true, countOnly: true }), + ]); + + return { + agents: agentsResp.pagination?.totalItems ?? 0, + activeAgents: activeResp.data?.totalItems ?? 0, + infected: infectedResp.data?.totalItems ?? 0, + upToDate: upToDateResp.data?.totalItems ?? 0, + threats: threatsResp.pagination?.totalItems ?? 0, + }; + } +} + +let _client: SentinelOneClient | null = null; +export function getSentinelOneClient(): SentinelOneClient { + if (!_client) _client = new SentinelOneClient(); + return _client; +} diff --git a/lib/services/sentinelone-sync-service.ts b/lib/services/sentinelone-sync-service.ts new file mode 100644 index 0000000..7b070a8 --- /dev/null +++ b/lib/services/sentinelone-sync-service.ts @@ -0,0 +1,215 @@ +/** + * SentinelOne Sync Service + * Syncs sites, agents, and threats to s1_* PostgreSQL tables + */ + +import { postgresClient } from './postgres-client'; +import { getSentinelOneClient, S1Site, S1Agent, S1Threat } from './sentinelone-client'; + +export interface S1SyncEntityResult { + entity: string; + success: boolean; + recordsUpserted: number; + duration: number; + error?: string; +} + +export interface S1SyncResult { + syncId: number; + status: 'completed' | 'failed'; + startedAt: Date; + completedAt: Date; + duration: number; + entities: S1SyncEntityResult[]; + totalUpserted: number; + errors: string[]; +} + +export class SentinelOneSyncService { + private isSyncing = false; + + isSyncInProgress(): boolean { return this.isSyncing; } + + async fullSync(triggeredBy = 'system'): Promise { + if (this.isSyncing) throw new Error('SentinelOne sync already in progress'); + this.isSyncing = true; + + const startedAt = new Date(); + const entities: S1SyncEntityResult[] = []; + const errors: string[] = []; + + const { rows } = await postgresClient.query( + `INSERT INTO s1_sync_history (sync_type, status, triggered_by, started_at) + VALUES ('full', 'running', $1, NOW()) RETURNING id`, + [triggeredBy] + ); + const syncId = Number(rows[0].id); + + const run = async (name: string, fn: () => Promise) => { + const t = Date.now(); + try { + const count = await fn(); + entities.push({ entity: name, success: true, recordsUpserted: count, duration: Date.now() - t }); + console.log(`[S1Sync] ${name}: ${count} records`); + } catch (err: any) { + errors.push(`${name}: ${err.message}`); + entities.push({ entity: name, success: false, recordsUpserted: 0, duration: Date.now() - t, error: err.message }); + console.error(`[S1Sync] ${name} FAILED:`, err.message); + } + }; + + try { + await run('sites', () => this.syncSites()); + await run('agents', () => this.syncAgents()); + await run('threats', () => this.syncThreats()); + + const completedAt = new Date(); + const totalUpserted = entities.reduce((s, e) => s + e.recordsUpserted, 0); + const status = errors.length === 0 ? 'completed' : 'failed'; + + await postgresClient.query( + `UPDATE s1_sync_history SET status=$1, completed_at=NOW(), + duration_ms=$2, total_upserted=$3, entity_results=$4, error_message=$5 + WHERE id=$6`, + [status, completedAt.getTime() - startedAt.getTime(), totalUpserted, + JSON.stringify(entities), errors.length ? errors.join('; ') : null, syncId] + ); + + return { + syncId, status, startedAt, completedAt, + duration: completedAt.getTime() - startedAt.getTime(), + entities, totalUpserted, errors, + }; + } finally { + this.isSyncing = false; + } + } + + private async syncSites(): Promise { + const client = getSentinelOneClient(); + const sites = await client.getSites(); + let count = 0; + + for (const s of sites) { + await postgresClient.query( + `INSERT INTO s1_sites ( + id, account_id, account_name, name, site_type, state, sku, suite, + health_status, active_licenses, total_licenses, unlimited_licenses, + unlimited_expiration, expiration, is_default, usage_type, external_id, + registration_token, description, created_at, updated_at, synced_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,NOW()) + ON CONFLICT (id) DO UPDATE SET + account_name=$3, name=$4, state=$6, health_status=$9, + active_licenses=$10, total_licenses=$11, unlimited_licenses=$12, + unlimited_expiration=$13, expiration=$14, usage_type=$16, + updated_at=$21, synced_at=NOW()`, + [ + s.id, s.accountId, s.accountName, s.name, s.siteType, s.state, s.sku, s.suite, + s.healthStatus, s.activeLicenses, s.totalLicenses, s.unlimitedLicenses, + s.unlimitedExpiration, s.expiration || null, s.isDefault, s.usageType, + s.externalId || null, s.registrationToken || null, s.description || null, + s.createdAt || null, s.updatedAt || null, + ] + ); + count++; + } + return count; + } + + private async syncAgents(): Promise { + const client = getSentinelOneClient(); + const agents = await client.getAgents(); + let count = 0; + + for (const a of agents) { + await postgresClient.query( + `INSERT INTO s1_agents ( + id, site_id, site_name, account_id, account_name, group_id, group_name, + computer_name, domain, os_type, os_name, os_revision, agent_version, + machine_type, is_active, is_decommissioned, is_up_to_date, is_pending_uninstall, + is_uninstalled, infected, active_threats, network_status, mitigation_mode, + detection_state, apps_vulnerability_status, firewall_enabled, external_ip, + last_active_date, last_logged_in_user_name, cpu_id, core_count, cpu_count, + total_memory, uuid, external_id, installer_type, scan_status, + scan_started_at, scan_finished_at, created_at, updated_at, synced_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,NOW() + ) + ON CONFLICT (id) DO UPDATE SET + site_id=$2, site_name=$3, group_id=$6, group_name=$7, + is_active=$15, is_decommissioned=$16, is_up_to_date=$17, + is_pending_uninstall=$18, is_uninstalled=$19, infected=$20, + active_threats=$21, network_status=$22, mitigation_mode=$23, + detection_state=$24, apps_vulnerability_status=$25, firewall_enabled=$26, + external_ip=$27, last_active_date=$28, last_logged_in_user_name=$29, + agent_version=$13, scan_status=$37, scan_started_at=$38, scan_finished_at=$39, + updated_at=$41, synced_at=NOW()`, + [ + a.id, a.siteId, a.siteName, a.accountId, a.accountName, a.groupId, a.groupName, + a.computerName, a.domain || null, a.osType, a.osName, a.osRevision, a.agentVersion, + a.machineType, a.isActive, a.isDecommissioned, a.isUpToDate, a.isPendingUninstall, + a.isUninstalled, a.infected, a.activeThreats, a.networkStatus, a.mitigationMode, + a.detectionState, a.appsVulnerabilityStatus, a.firewallEnabled ?? null, a.externalIp || null, + a.lastActiveDate || null, a.lastLoggedInUserName || null, a.cpuId || null, + a.coreCount ?? null, a.cpuCount ?? null, a.totalMemory ?? null, a.uuid, + a.externalId || null, a.installerType || null, a.scanStatus || null, + a.scanStartedAt || null, a.scanFinishedAt || null, a.createdAt || null, a.updatedAt || null, + ] + ); + count++; + } + return count; + } + + private async syncThreats(): Promise { + const client = getSentinelOneClient(); + const threats = await client.getThreats(); + let count = 0; + + for (const t of threats) { + const ti = t.threatInfo; + const adi = t.agentDetectionInfo; + const ari = t.agentRealtimeInfo; + + await postgresClient.query( + `INSERT INTO s1_threats ( + id, site_id, site_name, account_id, agent_id, agent_computer_name, + agent_os_name, agent_version, agent_is_active, agent_is_decommissioned, + threat_name, threat_file_path, threat_file_sha256, classification, + classification_source, confidence_level, mitigation_status, mitigation_report, + analyst_verdict, incident_status, detection_engines, indicators, + created_at, updated_at, synced_at + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,NOW() + ) + ON CONFLICT (id) DO UPDATE SET + agent_is_active=$9, agent_is_decommissioned=$10, + mitigation_status=$17, mitigation_report=$18, + analyst_verdict=$19, incident_status=$20, + updated_at=$24, synced_at=NOW()`, + [ + t.id, adi.siteId, adi.siteName, adi.accountId, + ari.agentId, ari.agentComputerName, ari.agentOsName, ari.agentVersion, + ari.agentIsActive, ari.agentIsDecommissioned, + ti.threatName || null, ti.filePath || null, ti.sha256 || null, + ti.classification || null, ti.classificationSource || null, + ti.confidenceLevel || null, ti.mitigationStatus || null, + JSON.stringify(t.mitigationStatus ?? []), + ti.analystVerdict || null, ti.incidentStatus || null, + JSON.stringify(ti.detectionEngines ?? []), + JSON.stringify(t.indicators ?? []), + ti.createdAt || null, ti.updatedAt || null, + ] + ); + count++; + } + return count; + } +} + +let _instance: SentinelOneSyncService | null = null; +export function getSentinelOneSyncService(): SentinelOneSyncService { + if (!_instance) _instance = new SentinelOneSyncService(); + return _instance; +} diff --git a/migrations/038_create_sentinelone_tables.sql b/migrations/038_create_sentinelone_tables.sql new file mode 100644 index 0000000..629ee02 --- /dev/null +++ b/migrations/038_create_sentinelone_tables.sql @@ -0,0 +1,139 @@ +-- SentinelOne Tables +-- Prefixed with s1_ to identify data source + +-- Sync history +CREATE TABLE IF NOT EXISTS s1_sync_history ( + id SERIAL PRIMARY KEY, + sync_type VARCHAR(50) NOT NULL DEFAULT 'full', + status VARCHAR(20) NOT NULL DEFAULT 'running', + triggered_by VARCHAR(100) NOT NULL DEFAULT 'system', + started_at TIMESTAMP NOT NULL DEFAULT NOW(), + completed_at TIMESTAMP, + duration_ms INTEGER, + total_upserted INTEGER DEFAULT 0, + error_message TEXT, + entity_results JSONB DEFAULT '[]' +); + +-- Sites (one per client/tenant in S1) +CREATE TABLE IF NOT EXISTS s1_sites ( + id VARCHAR(50) PRIMARY KEY, + account_id VARCHAR(50), + account_name VARCHAR(255), + name VARCHAR(255), + site_type VARCHAR(50), + state VARCHAR(50), + sku VARCHAR(100), + suite VARCHAR(100), + health_status BOOLEAN, + active_licenses INTEGER DEFAULT 0, + total_licenses INTEGER DEFAULT 0, + unlimited_licenses BOOLEAN DEFAULT false, + unlimited_expiration BOOLEAN DEFAULT false, + expiration TIMESTAMP, + is_default BOOLEAN DEFAULT false, + usage_type VARCHAR(50), + external_id VARCHAR(255), + registration_token TEXT, + description TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP, + synced_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Agents (endpoints) +CREATE TABLE IF NOT EXISTS s1_agents ( + id VARCHAR(50) PRIMARY KEY, + site_id VARCHAR(50), + site_name VARCHAR(255), + account_id VARCHAR(50), + account_name VARCHAR(255), + group_id VARCHAR(50), + group_name VARCHAR(255), + computer_name VARCHAR(255), + domain VARCHAR(255), + os_type VARCHAR(50), + os_name VARCHAR(255), + os_revision VARCHAR(100), + agent_version VARCHAR(50), + machine_type VARCHAR(50), + is_active BOOLEAN DEFAULT false, + is_decommissioned BOOLEAN DEFAULT false, + is_up_to_date BOOLEAN DEFAULT false, + is_pending_uninstall BOOLEAN DEFAULT false, + is_uninstalled BOOLEAN DEFAULT false, + infected BOOLEAN DEFAULT false, + active_threats INTEGER DEFAULT 0, + network_status VARCHAR(50), + mitigation_mode VARCHAR(50), + detection_state VARCHAR(50), + apps_vulnerability_status VARCHAR(50), + firewall_enabled BOOLEAN, + external_ip VARCHAR(50), + last_active_date TIMESTAMP, + last_logged_in_user_name VARCHAR(255), + cpu_id VARCHAR(255), + core_count INTEGER, + cpu_count INTEGER, + total_memory INTEGER, + uuid VARCHAR(100), + external_id VARCHAR(255), + installer_type VARCHAR(20), + scan_status VARCHAR(50), + scan_started_at TIMESTAMP, + scan_finished_at TIMESTAMP, + created_at TIMESTAMP, + updated_at TIMESTAMP, + synced_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Threats +CREATE TABLE IF NOT EXISTS s1_threats ( + id VARCHAR(50) PRIMARY KEY, + site_id VARCHAR(50), + site_name VARCHAR(255), + account_id VARCHAR(50), + agent_id VARCHAR(50), + agent_computer_name VARCHAR(255), + agent_os_name VARCHAR(255), + agent_version VARCHAR(50), + agent_is_active BOOLEAN, + agent_is_decommissioned BOOLEAN, + threat_name VARCHAR(500), + threat_file_path TEXT, + threat_file_sha256 VARCHAR(100), + classification VARCHAR(100), + classification_source VARCHAR(100), + confidence_level VARCHAR(50), + mitigation_status VARCHAR(50), + mitigation_report JSONB, + analyst_verdict VARCHAR(50), + incident_status VARCHAR(50), + detection_engines JSONB, + indicators JSONB, + created_at TIMESTAMP, + updated_at TIMESTAMP, + synced_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Company mapping (S1 site → Autotask company) +CREATE TABLE IF NOT EXISTS s1_company_mappings ( + id SERIAL PRIMARY KEY, + s1_site_id VARCHAR(50) NOT NULL UNIQUE, + s1_site_name VARCHAR(255) NOT NULL, + company_id INTEGER NOT NULL, + company_name VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_s1_agents_site_id ON s1_agents(site_id); +CREATE INDEX IF NOT EXISTS idx_s1_agents_is_active ON s1_agents(is_active); +CREATE INDEX IF NOT EXISTS idx_s1_agents_infected ON s1_agents(infected); +CREATE INDEX IF NOT EXISTS idx_s1_threats_site_id ON s1_threats(site_id); +CREATE INDEX IF NOT EXISTS idx_s1_threats_agent_id ON s1_threats(agent_id); +CREATE INDEX IF NOT EXISTS idx_s1_threats_mitigation ON s1_threats(mitigation_status); +CREATE INDEX IF NOT EXISTS idx_s1_company_mappings_company ON s1_company_mappings(company_id); +CREATE INDEX IF NOT EXISTS idx_s1_sync_history_started ON s1_sync_history(started_at DESC);