wulf-pulse/app/api/sentinelone/coverage/route.ts
lorentz ed6c4a8b65 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
2026-02-27 05:31:31 -05:00

76 lines
3.1 KiB
TypeScript

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 });
}
}