wulf-pulse/lib/services/sentinelone-sync-service.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

215 lines
8.8 KiB
TypeScript

/**
* 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;
}