- 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
234 lines
6.6 KiB
TypeScript
234 lines
6.6 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|