feat: Add SentinelOne integration
- Add SentinelOne API client (lib/services/sentinelone-client.ts) - Paginated fetching for sites, agents, threats - JWT token auth via S1_API_URL / S1_API_TOKEN env vars - Add SentinelOne sync service (lib/services/sentinelone-sync-service.ts) - Full sync: sites, agents, threats into s1_* tables - Sync history tracking with per-entity results - Add DB migration 038: s1_sites, s1_agents, s1_threats, s1_company_mappings, s1_sync_history tables - Add API routes: - POST/GET /api/sentinelone/sync - GET/POST/DELETE /api/sentinelone/company-mappings - GET /api/sentinelone/coverage (fixed Cartesian product bug) - Add UI pages: - /admin/sync/sentinelone — sync admin with history + stats - /sentinelone/coverage — AV coverage report per site - /sentinelone/mappings — map S1 sites to Autotask companies - Wire SentinelOne into admin sync overview card grid - Add SentinelOne Sync to app navigation - Fix docker-compose: remove explicit S1 env var entries that were overwriting env_file values with empty strings
This commit is contained in:
parent
d7c3dc7168
commit
ed6c4a8b65
12 changed files with 1637 additions and 7 deletions
|
|
@ -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<string, { bg: string; border: string }> = {
|
||||
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<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [itglueSyncData, setItglueSyncData] = useState<any>(null);
|
||||
const [s1SyncData, setS1SyncData] = useState<any>(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 <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
if (id === 'itglue') {
|
||||
if (!summary.lastSync) return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
if (summary.status === 'failed') return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
if (id === 'sentinelone') {
|
||||
if (!summary.lastSync) return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
if (summary.infected > 0) return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
}
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
};
|
||||
|
||||
|
|
@ -243,6 +288,40 @@ export default function SyncOverviewPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{intg.id === 'itglue' && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Last sync</span>
|
||||
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Organizations</span>
|
||||
<span className="font-medium text-foreground">{(summary as any).organizations?.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Configurations</span>
|
||||
<span className="font-medium text-foreground">{(summary as any).configurations?.toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{intg.id === 'sentinelone' && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>Last sync</span>
|
||||
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Sites / Agents</span>
|
||||
<span className="font-medium text-foreground">{(summary as any).sites} / {(summary as any).agents?.toLocaleString()}</span>
|
||||
</div>
|
||||
{(summary as any).infected > 0 && (
|
||||
<div className="flex justify-between text-red-600">
|
||||
<span>Infected</span>
|
||||
<span className="font-medium">{(summary as any).infected}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(intg.id === 'auvik' || intg.id === 'addigy') && (
|
||||
<div className="flex justify-between">
|
||||
<span>Status</span>
|
||||
|
|
|
|||
188
app/admin/sync/sentinelone/page.tsx
Normal file
188
app/admin/sync/sentinelone/page.tsx
Normal file
|
|
@ -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<any>(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 (
|
||||
<div className="container mx-auto py-8 space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/sync">
|
||||
<Button variant="ghost" size="sm"><ArrowLeft className="w-4 h-4 mr-1" />Back</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Shield className="w-6 h-6 text-purple-500" />
|
||||
SentinelOne Sync
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Sites, agents, and threats synced to s1_* tables</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={triggerSync} disabled={syncing || inProgress}>
|
||||
{syncing || inProgress
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
|
||||
: <><Play className="w-4 h-4 mr-2" />Sync Now</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Icon className={`w-3 h-3 ${color}`} />{label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? '—' : (Number(value ?? 0)).toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Last sync status */}
|
||||
{lastSync && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Last Sync</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{lastSync.status === 'completed'
|
||||
? <CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
: lastSync.status === 'running'
|
||||
? <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
: <XCircle className="w-5 h-5 text-red-500" />}
|
||||
<div>
|
||||
<div className="font-medium capitalize">{lastSync.status}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{fmtDate(lastSync.completed_at || lastSync.started_at)} · {fmtDuration(lastSync.duration_ms)} · {lastSync.total_upserted?.toLocaleString()} records
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{lastSync.entity_results && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Array.isArray(lastSync.entity_results)
|
||||
? lastSync.entity_results
|
||||
: JSON.parse(lastSync.entity_results)
|
||||
).map((e: any) => (
|
||||
<div key={e.entity} className="flex items-center justify-between text-sm border rounded p-2">
|
||||
<span className="capitalize">{e.entity}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{e.success
|
||||
? <Badge variant="secondary">{e.recordsUpserted.toLocaleString()}</Badge>
|
||||
: <Badge variant="destructive">failed</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{lastSync.error_message && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded p-2">{lastSync.error_message}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">Sync History</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{(data?.history ?? []).map((h: any) => (
|
||||
<div key={h.id} className="flex items-center justify-between text-sm border-b pb-2 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{h.status === 'completed' ? <CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
: h.status === 'running' ? <RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
: <XCircle className="w-4 h-4 text-red-500" />}
|
||||
<span className="text-muted-foreground">{fmtDate(h.started_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<span>{h.total_upserted?.toLocaleString() ?? 0} records</span>
|
||||
<span>{fmtDuration(h.duration_ms)}</span>
|
||||
<Badge variant="outline" className="text-xs">{h.triggered_by}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && (data?.history ?? []).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No sync history yet — run a sync to get started</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href="/sentinelone/coverage">
|
||||
<Button variant="outline"><Shield className="w-4 h-4 mr-2" />Coverage Report</Button>
|
||||
</Link>
|
||||
<Link href="/sentinelone/mappings">
|
||||
<Button variant="outline"><Monitor className="w-4 h-4 mr-2" />Company Mappings</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
app/api/sentinelone/company-mappings/route.ts
Normal file
104
app/api/sentinelone/company-mappings/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
76
app/api/sentinelone/coverage/route.ts
Normal file
76
app/api/sentinelone/coverage/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
53
app/api/sentinelone/sync/route.ts
Normal file
53
app/api/sentinelone/sync/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
187
app/sentinelone/coverage/page.tsx
Normal file
187
app/sentinelone/coverage/page.tsx
Normal file
|
|
@ -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<SiteCoverage[]>([]);
|
||||
const [summary, setSummary] = useState<any>(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 (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-purple-600" />
|
||||
SentinelOne Coverage
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">AV agent coverage and threat status per site</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="pb-2"><CardTitle className="text-xs text-muted-foreground">{label}</CardTitle></CardHeader>
|
||||
<CardContent><div className={`text-2xl font-bold ${color}`}>{Number(value).toLocaleString()}</div></CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder="Search sites or companies..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{(['all', 'issues', 'unmapped'] as const).map(f => (
|
||||
<Button key={f} variant={filter === f ? 'default' : 'outline'} size="sm" onClick={() => setFilter(f)}>
|
||||
{f === 'all' ? 'All' : f === 'issues' ? 'Has Issues' : 'Unmapped'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Site</TableHead>
|
||||
<TableHead>Company</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead className="text-right">Agents</TableHead>
|
||||
<TableHead className="text-right">Active</TableHead>
|
||||
<TableHead className="text-right">Infected</TableHead>
|
||||
<TableHead className="text-right">Outdated</TableHead>
|
||||
<TableHead className="text-right">Threats</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground">Loading...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground">No sites found</TableCell></TableRow>
|
||||
) : filtered.map(s => (
|
||||
<TableRow key={s.s1_site_id} className={s.infected_agents > 0 || s.active_threats > 0 ? 'bg-red-500/5' : ''}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{s.s1_site_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{s.state}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.company_name
|
||||
? <span>{s.company_name}</span>
|
||||
: <span className="text-muted-foreground flex items-center gap-1"><Link2Off className="w-3 h-3" />Unmapped</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{s.sku || '—'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{s.total_agents}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className={s.active_agents < s.total_agents ? 'text-yellow-500' : ''}>{s.active_agents}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{s.infected_agents > 0
|
||||
? <span className="text-red-500 font-bold">{s.infected_agents}</span>
|
||||
: <span className="text-muted-foreground">0</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{s.outdated_agents > 0
|
||||
? <span className="text-yellow-500">{s.outdated_agents}</span>
|
||||
: <span className="text-muted-foreground">0</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{s.active_threats > 0
|
||||
? <span className="text-orange-500 font-bold">{s.active_threats}</span>
|
||||
: <span className="text-muted-foreground">0</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{s.infected_agents > 0 || s.active_threats > 0
|
||||
? <Badge variant="destructive" className="text-xs">Action Needed</Badge>
|
||||
: s.outdated_agents > 0
|
||||
? <Badge className="text-xs bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">Outdated</Badge>
|
||||
: s.total_agents === 0
|
||||
? <Badge variant="secondary" className="text-xs">No Agents</Badge>
|
||||
: <Badge className="text-xs bg-green-500/20 text-green-700 dark:text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />OK
|
||||
</Badge>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
app/sentinelone/mappings/page.tsx
Normal file
290
app/sentinelone/mappings/page.tsx
Normal file
|
|
@ -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<S1SiteRow[]>([]);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
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 (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-purple-600" />
|
||||
SentinelOne Site Mappings
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">Map SentinelOne sites to Autotask companies</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSync} variant="outline" size="sm" disabled={syncing}>
|
||||
{syncing
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
|
||||
: <><RefreshCw className="w-4 h-4 mr-2" />Sync S1</>}
|
||||
</Button>
|
||||
<Button onClick={fetchData} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground">Total Sites</CardTitle></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold">{stats.total}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground flex items-center gap-2"><CheckCircle className="w-4 h-4 text-green-600" />Mapped</CardTitle></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold text-green-600">{stats.mapped}</div></CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground flex items-center gap-2"><XCircle className="w-4 h-4 text-orange-600" />Unmapped</CardTitle></CardHeader>
|
||||
<CardContent><div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div></CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Site Mappings</CardTitle>
|
||||
<CardDescription>Each SentinelOne site corresponds to a client. Map them to Autotask companies to enable coverage reporting.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input className="pl-9" placeholder="Search sites or companies..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={filter} onValueChange={(v: any) => setFilter(v)}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Sites</SelectItem>
|
||||
<SelectItem value="mapped">Mapped</SelectItem>
|
||||
<SelectItem value="unmapped">Unmapped</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>S1 Site</TableHead>
|
||||
<TableHead>SKU / State</TableHead>
|
||||
<TableHead>Autotask Company</TableHead>
|
||||
<TableHead className="w-[100px]">Status</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">Loading...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No sites found</TableCell></TableRow>
|
||||
) : filtered.map(site => (
|
||||
<SiteMappingRow
|
||||
key={site.s1_site_id}
|
||||
site={site}
|
||||
companies={companies}
|
||||
onSaved={fetchData}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteMappingRow({
|
||||
site, companies, onSaved,
|
||||
}: {
|
||||
site: S1SiteRow;
|
||||
companies: Company[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState<number>(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 (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="font-medium">{site.s1_site_name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{site.s1_site_id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{site.sku || '—'}</Badge>
|
||||
<div className="text-xs text-muted-foreground mt-1">{site.state}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={selectedId.toString()} onValueChange={handleChange} disabled={saving}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{selectedId === 0 ? 'No mapping' : companies.find(c => c.id === selectedId)?.companyName ?? 'Select...'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">No mapping</SelectItem>
|
||||
{companies.sort((a, b) => a.companyName.localeCompare(b.companyName)).map(c => (
|
||||
<SelectItem key={c.id} value={c.id.toString()}>{c.companyName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{site.company_id !== null
|
||||
? <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 text-xs"><CheckCircle className="w-3 h-3 mr-1" />Mapped</Badge>
|
||||
: <Badge variant="secondary" className="text-xs"><Link2Off className="w-3 h-3 mr-1" />Unmapped</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{hasChanges && (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || selectedId === 0}>
|
||||
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <><Save className="w-3 h-3 mr-1" />Save</>}
|
||||
</Button>
|
||||
)}
|
||||
{site.id && (
|
||||
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -96,6 +96,23 @@ services:
|
|||
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}
|
||||
AUTOTASK_WEBHOOK_SECRET: ${AUTOTASK_WEBHOOK_SECRET}
|
||||
|
|
|
|||
234
lib/services/sentinelone-client.ts
Normal file
234
lib/services/sentinelone-client.ts
Normal file
|
|
@ -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<T> {
|
||||
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<T>(path: string, params: Record<string, string | number | boolean> = {}): Promise<T> {
|
||||
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<T>(
|
||||
path: string,
|
||||
dataKey: string | null = null,
|
||||
extraParams: Record<string, string | number | boolean> = {}
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
let cursor: string | null = null;
|
||||
|
||||
do {
|
||||
const params: Record<string, string | number | boolean> = { limit: 1000, ...extraParams };
|
||||
if (cursor) params.cursor = cursor;
|
||||
|
||||
const resp = await this.request<any>(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<any>('/web/api/v2.1/sites?limit=1&countOnly=false'),
|
||||
this.request<any>('/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<S1Site[]> {
|
||||
return this.getAllPages<S1Site>('/web/api/v2.1/sites', 'sites');
|
||||
}
|
||||
|
||||
async getAgents(siteId?: string): Promise<S1Agent[]> {
|
||||
const extra: Record<string, string | number | boolean> = siteId ? { siteIds: siteId } : {};
|
||||
return this.getAllPages<S1Agent>('/web/api/v2.1/agents', null, extra);
|
||||
}
|
||||
|
||||
async getThreats(siteId?: string): Promise<S1Threat[]> {
|
||||
const extra: Record<string, string | number | boolean> = siteId ? { siteIds: siteId } : {};
|
||||
return this.getAllPages<S1Threat>('/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<any>('/web/api/v2.1/agents', { siteIds: siteId, countOnly: false, limit: 1 }),
|
||||
this.request<any>('/web/api/v2.1/threats', { siteIds: siteId, countOnly: false, limit: 1 }),
|
||||
]);
|
||||
|
||||
const [activeResp, infectedResp, upToDateResp] = await Promise.all([
|
||||
this.request<any>('/web/api/v2.1/agents', { siteIds: siteId, isActive: true, countOnly: true }),
|
||||
this.request<any>('/web/api/v2.1/agents', { siteIds: siteId, infected: true, countOnly: true }),
|
||||
this.request<any>('/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;
|
||||
}
|
||||
215
lib/services/sentinelone-sync-service.ts
Normal file
215
lib/services/sentinelone-sync-service.ts
Normal file
|
|
@ -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<S1SyncResult> {
|
||||
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<number>) => {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
139
migrations/038_create_sentinelone_tables.sql
Normal file
139
migrations/038_create_sentinelone_tables.sql
Normal file
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue