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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue