@pmoses-s1/sentinelone-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/s1.js ADDED
@@ -0,0 +1,767 @@
1
+ /**
2
+ * SentinelOne client — Mgmt Console REST API, LRQ PowerQuery, Purple AI, UAM GraphQL.
3
+ *
4
+ * Auth patterns:
5
+ * Mgmt REST API → Authorization: ApiToken <jwt>
6
+ * LRQ → Authorization: Bearer <jwt> (same token, different prefix)
7
+ * Purple AI → Authorization: ApiToken <jwt> (POST /web/api/v2.1/graphql)
8
+ * UAM GraphQL → Authorization: ApiToken <jwt> (POST /web/api/v2.1/unifiedalerts/graphql)
9
+ */
10
+
11
+ import { getCreds } from './credentials.js';
12
+
13
+ // ─── helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ function base() {
16
+ const url = getCreds().S1_CONSOLE_URL.replace(/\/+$/, '');
17
+ if (!url) throw new Error('S1_CONSOLE_URL not configured. Drop credentials.json into your project folder.');
18
+ return url;
19
+ }
20
+
21
+ function jwt() {
22
+ const tok = getCreds().S1_CONSOLE_API_TOKEN;
23
+ if (!tok) throw new Error('S1_CONSOLE_API_TOKEN not configured. Drop credentials.json into your project folder.');
24
+ return tok;
25
+ }
26
+
27
+ async function doFetch(url, opts, retries = 3) {
28
+ let delay = 500;
29
+ for (let attempt = 0; attempt <= retries; attempt++) {
30
+ let res;
31
+ try {
32
+ res = await fetch(url, opts);
33
+ } catch (err) {
34
+ if (attempt === retries) throw err;
35
+ await sleep(delay);
36
+ delay = Math.min(delay * 2, 8000);
37
+ continue;
38
+ }
39
+
40
+ // Retry on 429 / 5xx
41
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
42
+ const retryAfter = res.headers.get('Retry-After');
43
+ const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
44
+ await sleep(wait);
45
+ delay = Math.min(delay * 2, 8000);
46
+ continue;
47
+ }
48
+
49
+ const text = await res.text();
50
+ let data;
51
+ try { data = JSON.parse(text); } catch { data = text; }
52
+
53
+ if (!res.ok) {
54
+ const msg = typeof data === 'object' ? (data?.errors?.[0]?.detail || data?.errors?.[0]?.message || JSON.stringify(data)) : text;
55
+ throw new Error(`S1 API ${opts.method || 'GET'} ${url} → ${res.status}: ${msg}`);
56
+ }
57
+ return data;
58
+ }
59
+ }
60
+
61
+ function sleep(ms) {
62
+ return new Promise(r => setTimeout(r, ms));
63
+ }
64
+
65
+ // ─── Mgmt REST API ────────────────────────────────────────────────────────────
66
+
67
+ /** GET /web/api/v2.1/<path> */
68
+ export async function apiGet(path, params = {}) {
69
+ let url = `${base()}${path}`;
70
+ const qs = new URLSearchParams(
71
+ Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined && v !== null))
72
+ ).toString();
73
+ if (qs) url += '?' + qs;
74
+ return doFetch(url, {
75
+ method: 'GET',
76
+ headers: {
77
+ Authorization: `ApiToken ${jwt()}`,
78
+ 'Content-Type': 'application/json',
79
+ },
80
+ });
81
+ }
82
+
83
+ /** POST /web/api/v2.1/<path> */
84
+ export async function apiPost(path, body = {}) {
85
+ return doFetch(`${base()}${path}`, {
86
+ method: 'POST',
87
+ headers: {
88
+ Authorization: `ApiToken ${jwt()}`,
89
+ 'Content-Type': 'application/json',
90
+ },
91
+ body: JSON.stringify(body),
92
+ });
93
+ }
94
+
95
+ /** PUT /web/api/v2.1/<path> */
96
+ export async function apiPut(path, body = {}) {
97
+ return doFetch(`${base()}${path}`, {
98
+ method: 'PUT',
99
+ headers: {
100
+ Authorization: `ApiToken ${jwt()}`,
101
+ 'Content-Type': 'application/json',
102
+ },
103
+ body: JSON.stringify(body),
104
+ });
105
+ }
106
+
107
+ /** DELETE /web/api/v2.1/<path> */
108
+ export async function apiDelete(path, body = {}) {
109
+ return doFetch(`${base()}${path}`, {
110
+ method: 'DELETE',
111
+ headers: {
112
+ Authorization: `ApiToken ${jwt()}`,
113
+ 'Content-Type': 'application/json',
114
+ },
115
+ body: JSON.stringify(body),
116
+ });
117
+ }
118
+
119
+ /** PATCH /web/api/v2.1/<path> */
120
+ export async function apiPatch(path, body = {}) {
121
+ return doFetch(`${base()}${path}`, {
122
+ method: 'PATCH',
123
+ headers: {
124
+ Authorization: `ApiToken ${jwt()}`,
125
+ 'Content-Type': 'application/json',
126
+ },
127
+ body: JSON.stringify(body),
128
+ });
129
+ }
130
+
131
+ // ─── LRQ PowerQuery ───────────────────────────────────────────────────────────
132
+ // POST <console>/sdl/v2/api/queries with Bearer auth (same JWT, different prefix)
133
+ // Must echo X-Dataset-Query-Forward-Tag on every subsequent GET/DELETE.
134
+ // Poll every 1s; query expires 30s after last poll. Always cancel after use.
135
+
136
+ /** Run a full LRQ PowerQuery lifecycle. Returns { columns, rows, rowCount, matchCount }. */
137
+ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000 } = {}) {
138
+ const b = base();
139
+ const tok = jwt();
140
+
141
+ // Resolve time range
142
+ if (!startTime || !endTime) {
143
+ const now = new Date();
144
+ endTime = now.toISOString().replace(/\.\d+Z$/, 'Z');
145
+ startTime = new Date(now - hours * 3600 * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
146
+ }
147
+
148
+ const launchUrl = `${b}/sdl/v2/api/queries`;
149
+ const launchBody = {
150
+ queryType: 'PQ',
151
+ tenant: true,
152
+ startTime,
153
+ endTime,
154
+ queryPriority: 'HIGH',
155
+ pq: { query, resultType: 'TABLE' },
156
+ };
157
+
158
+ // Launch
159
+ const launchRes = await fetch(launchUrl, {
160
+ method: 'POST',
161
+ headers: {
162
+ Authorization: `Bearer ${tok}`,
163
+ 'Content-Type': 'application/json',
164
+ },
165
+ body: JSON.stringify(launchBody),
166
+ });
167
+
168
+ if (!launchRes.ok) {
169
+ const body = await launchRes.text();
170
+ throw new Error(`LRQ launch failed (${launchRes.status}): ${body}`);
171
+ }
172
+
173
+ const forwardTag = launchRes.headers.get('X-Dataset-Query-Forward-Tag');
174
+ const launched = await launchRes.json();
175
+ const queryId = launched.id;
176
+ if (!queryId) throw new Error(`LRQ launch returned no id: ${JSON.stringify(launched)}`);
177
+
178
+ const pollHeaders = {
179
+ Authorization: `Bearer ${tok}`,
180
+ 'Content-Type': 'application/json',
181
+ ...(forwardTag ? { 'X-Dataset-Query-Forward-Tag': forwardTag } : {}),
182
+ };
183
+
184
+ // Poll until done (30s expiry, poll every 1s)
185
+ let lastStepSeen = 0;
186
+ let result = null;
187
+ const deadline = Date.now() + 5 * 60 * 1000; // 5 min hard timeout
188
+
189
+ try {
190
+ while (Date.now() < deadline) {
191
+ await sleep(1000);
192
+ const pollUrl = `${b}/sdl/v2/api/queries/${queryId}?lastStepSeen=${lastStepSeen}`;
193
+ let pollRes;
194
+ try {
195
+ pollRes = await fetch(pollUrl, { method: 'GET', headers: pollHeaders });
196
+ } catch (err) {
197
+ // Transient network error; keep polling
198
+ continue;
199
+ }
200
+
201
+ if (!pollRes.ok) {
202
+ const body = await pollRes.text();
203
+ throw new Error(`LRQ poll failed (${pollRes.status}): ${body}`);
204
+ }
205
+
206
+ const state = await pollRes.json();
207
+ lastStepSeen = state.stepsCompleted ?? lastStepSeen;
208
+
209
+ const done = state.stepsTotal > 0 && state.stepsCompleted >= state.stepsTotal;
210
+ if (done) {
211
+ result = state;
212
+ break;
213
+ }
214
+ }
215
+ } finally {
216
+ // Always cancel to release quota
217
+ try {
218
+ await fetch(`${b}/sdl/v2/api/queries/${queryId}`, {
219
+ method: 'DELETE',
220
+ headers: pollHeaders,
221
+ });
222
+ } catch { /* best effort */ }
223
+ }
224
+
225
+ if (!result) throw new Error('LRQ timed out after 5 minutes');
226
+
227
+ const data = result.data || {};
228
+ const columns = data.columns || [];
229
+ const rawRows = data.values || [];
230
+
231
+ // Cap rows
232
+ // Confirmed: LRQ API returns columns as descriptor objects {name, cellType, ...}, not strings.
233
+ // Must use col.name (not col itself) as the row key — col.toString() produces "[object Object]".
234
+ const rows = rawRows.slice(0, maxRows).map(r => {
235
+ const obj = {};
236
+ columns.forEach((col, i) => { obj[col.name ?? col] = r[i]; });
237
+ return obj;
238
+ });
239
+
240
+ return {
241
+ columns,
242
+ rows,
243
+ rowCount: rows.length,
244
+ totalRows: rawRows.length,
245
+ matchCount: result.matchCount ?? null,
246
+ queryId,
247
+ };
248
+ }
249
+
250
+ // ─── Purple AI ────────────────────────────────────────────────────────────────
251
+ // Reverse-engineered from live network traffic on usea1-purple.sentinelone.net.
252
+ //
253
+ // IMPORTANT: purpleLaunchQuery is a GraphQL QUERY (not mutation).
254
+ // Variable wrapper is `request` (type PurpleLaunchQueryRequest), NOT `input`.
255
+ // The prior implementation used mutation + PurpleLaunchQueryInput — both wrong,
256
+ // causing HTTP 400 "invalid query" at the middleware layer.
257
+ //
258
+ // Endpoints:
259
+ // Purple AI LLM → POST /web/api/v2.1/graphql (ApiToken auth)
260
+ // SDL/History → POST <base>/sdl/v2/graphql (Bearer auth, same token)
261
+
262
+ function randomHex(len = 32) {
263
+ return Array.from({ length: len }, () => Math.floor(Math.random() * 16).toString(16)).join('');
264
+ }
265
+
266
+ /**
267
+ * Run a Purple AI natural-language query.
268
+ *
269
+ * Flow:
270
+ * 1. purpleLaunchQuery (contentType=NATURAL_LANGUAGE) → PowerQuery string
271
+ * 2. Return the generated PowerQuery + token for downstream SDL execution.
272
+ *
273
+ * Returns { powerQuery, viewSelector, timeRange, token, status, resultType }
274
+ */
275
+ export async function purpleAiQuery(userInput, { viewSelector = 'EDR', hours = 24 } = {}) {
276
+ const now = Date.now();
277
+ const startMs = now - hours * 3600 * 1000;
278
+ const conversationId = randomHex(32);
279
+ const feedItemId = randomHex(32);
280
+ const msgId = randomHex(32);
281
+ const consoleUrl = `${base()}/`;
282
+
283
+ // Confirmed correct shape from live API validation (2026-05-03):
284
+ // - operation type is `query` not `mutation`
285
+ // - variable name is `request` not `input`
286
+ // - type is `PurpleLaunchQueryRequest` not `PurpleLaunchQueryInput`
287
+ // - inputContent MUST appear at the top level of `request` (confirmed: HTTP 400
288
+ // "missing input value at $request.inputContent" when omitted)
289
+ // - PurpleUserDetailsRequest schema (confirmed from live validation error):
290
+ // { accountId: ID!, teamToken: ID!, sessionId, emailAddress, userAgent, buildDate, buildHash }
291
+ // Does NOT have siteIds or groupIds.
292
+ const inputContentPayload = {
293
+ userInput,
294
+ viewSelector,
295
+ displayedTimeRange: { start: startMs, end: now },
296
+ resultsPq: null,
297
+ powerQueryForResults: null,
298
+ additionalFieldsForPq: null,
299
+ contextId: null,
300
+ userDetails: null,
301
+ };
302
+
303
+ const gqlBody = {
304
+ operationName: 'purpleLaunchQuery',
305
+ variables: {
306
+ request: {
307
+ isAsync: false,
308
+ contentType: 'NATURAL_LANGUAGE',
309
+ consoleDetails: {
310
+ baseUrl: consoleUrl,
311
+ version: 'S-26.1.3#69',
312
+ },
313
+ // Top-level inputContent required by PurpleLaunchQueryRequest schema.
314
+ inputContent: inputContentPayload,
315
+ conversation: {
316
+ id: conversationId,
317
+ messages: [
318
+ {
319
+ inputMessage: {
320
+ id: msgId,
321
+ feedItemId,
322
+ conversationId,
323
+ createdAt: new Date(now).toISOString(),
324
+ messageType: 'INPUT',
325
+ contentType: 'NATURAL_LANGUAGE',
326
+ inputContent: inputContentPayload,
327
+ },
328
+ },
329
+ ],
330
+ },
331
+ },
332
+ },
333
+ query: `
334
+ query purpleLaunchQuery($request: PurpleLaunchQueryRequest!) {
335
+ purpleLaunchQuery(request: $request) {
336
+ token
337
+ resultType
338
+ status { state error { errorType errorDetail origin } }
339
+ stepsCompleted
340
+ result {
341
+ message
342
+ summary
343
+ maskedMetadata
344
+ powerQuery {
345
+ query
346
+ viewSelector
347
+ timeRange { start end }
348
+ }
349
+ suggestedQuestions {
350
+ question
351
+ powerQuery
352
+ viewSelector
353
+ timeRange { start end }
354
+ }
355
+ }
356
+ }
357
+ }
358
+ `,
359
+ };
360
+
361
+ const data = await apiPost('/web/api/v2.1/graphql', gqlBody);
362
+
363
+ if (data.errors?.length) {
364
+ throw new Error(`Purple AI GraphQL error: ${data.errors[0].message}`);
365
+ }
366
+
367
+ const plq = data?.data?.purpleLaunchQuery || {};
368
+ const state = plq?.status?.state;
369
+ if (state && state !== 'COMPLETED') {
370
+ const err = plq?.status?.error || {};
371
+ const detail = err.errorDetail || '';
372
+ const origin = err.origin || '';
373
+ // AsimovError from LaunchQueryManager = LLM workspace layer rejected the request.
374
+ // Root cause: purpleLaunchQuery NATURAL_LANGUAGE requires a browser-session teamToken
375
+ // (obtained from /sdl/v2/graphql) that service-account API tokens never have.
376
+ // purpleAlertSummary (ALERT_ENTRY) does not have this limitation and works fine.
377
+ if (detail.includes('AsimovError') || origin === 'LaunchQueryManager') {
378
+ throw new Error(
379
+ 'Purple AI NL query failed: the LLM workspace layer (LaunchQueryManager) rejected this ' +
380
+ 'service-account request. purpleLaunchQuery NATURAL_LANGUAGE requires a browser-session ' +
381
+ 'teamToken that API-token service accounts do not have. Use purple_ai_alert_summary ' +
382
+ '(ALERT_ENTRY) instead, or run the query interactively in the Purple AI browser console.'
383
+ );
384
+ }
385
+ throw new Error(`Purple AI state: ${state}${detail ? ` (${origin}: ${detail})` : ''}`);
386
+ }
387
+
388
+ const r = plq.result || {};
389
+ return {
390
+ token: plq.token || null,
391
+ resultType: plq.resultType || null,
392
+ status: plq.status || null,
393
+ powerQuery: r.powerQuery?.query || null,
394
+ viewSelector: r.powerQuery?.viewSelector || viewSelector,
395
+ timeRange: r.powerQuery?.timeRange || { start: startMs, end: now },
396
+ summary: r.summary || null,
397
+ message: r.message || null,
398
+ suggestedQuestions: r.suggestedQuestions || [],
399
+ maskedMetadata: r.maskedMetadata || null,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Get a Purple AI natural-language summary for a specific UAM alert.
405
+ *
406
+ * Calls purpleAlertSummary (separate operation from purpleLaunchQuery).
407
+ * The inputAlert must be the OCSF-serialised alert JSON string.
408
+ * Returns { token, summary }
409
+ */
410
+ export async function purpleAlertSummary(alertOcsfJson, { userDetails = null } = {}) {
411
+ const consoleUrl = `${base()}/`;
412
+
413
+ const gqlBody = {
414
+ operationName: 'AlertSummary',
415
+ variables: {
416
+ request: {
417
+ isAsync: false,
418
+ contentType: 'ALERT_ENTRY',
419
+ inputAlert: typeof alertOcsfJson === 'string' ? alertOcsfJson : JSON.stringify(alertOcsfJson),
420
+ userDetails: userDetails || {
421
+ teamToken: '',
422
+ accountId: '',
423
+ userAgent: 'sentinelone-mcp/1.0',
424
+ buildDate: new Date().toISOString(),
425
+ buildHash: '',
426
+ emailAddress: '',
427
+ },
428
+ consoleDetails: {
429
+ baseUrl: consoleUrl,
430
+ version: 'S-26.1.3#69',
431
+ },
432
+ },
433
+ },
434
+ query: `
435
+ query AlertSummary($request: PurpleAlertSummaryRequest!) {
436
+ purpleAlertSummary(request: $request) {
437
+ token
438
+ result { summary }
439
+ }
440
+ }
441
+ `,
442
+ };
443
+
444
+ const data = await apiPost('/web/api/v2.1/graphql', gqlBody);
445
+ if (data.errors?.length) throw new Error(`Purple AI AlertSummary error: ${data.errors[0].message}`);
446
+
447
+ const pas = data?.data?.purpleAlertSummary || {};
448
+ return {
449
+ token: pas.token || null,
450
+ summary: pas.result?.summary || null,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Trigger and poll Purple AI auto-investigation on a UAM alert.
456
+ *
457
+ * Step 1: alertTriggerActions with id="S1/aiInvestigation/run"
458
+ * Step 2: poll aiInvestigations until status=COMPLETED or FAILED
459
+ *
460
+ * Returns { verdict, markdown, investigationSteps[], alertId }
461
+ */
462
+ export async function purpleAiInvestigate(alertId, { scopeId, scopeType = 'ACCOUNT', timeoutMs = 5 * 60 * 1000 } = {}) {
463
+ // Resolve account scope from user profile if not provided
464
+ let resolvedScopeId = scopeId;
465
+ if (!resolvedScopeId) {
466
+ const userInfo = await doFetch(`${base()}/web/api/v2.1/user`, {
467
+ method: 'GET',
468
+ headers: { Authorization: `ApiToken ${jwt()}`, 'Content-Type': 'application/json' },
469
+ });
470
+ resolvedScopeId = userInfo?.data?.scopeRoles?.[0]?.id || userInfo?.data?.id;
471
+ if (!resolvedScopeId) throw new Error('Could not resolve account scopeId for aiInvestigation. Pass scopeId explicitly.');
472
+ }
473
+
474
+ const scope = { scopeIds: [resolvedScopeId], scopeType };
475
+
476
+ // Step 1: Trigger investigation
477
+ const triggerBody = {
478
+ operationName: 'AlertTriggerActions',
479
+ variables: {
480
+ scope,
481
+ filter: { or: [{ and: [{ fieldId: 'id', stringEqual: { value: alertId } }] }] },
482
+ viewType: 'ALL',
483
+ actions: [{
484
+ id: 'S1/aiInvestigation/run',
485
+ payload: {
486
+ aiInvestigation: {
487
+ buildDate: new Date().toISOString(),
488
+ buildHash: '',
489
+ consoleVersion: 'S-26.1.3#69',
490
+ scope,
491
+ tenantId: resolvedScopeId,
492
+ userAgent: 'sentinelone-mcp/1.0',
493
+ sessionId: randomHex(32),
494
+ userTime: new Date().toISOString(),
495
+ },
496
+ },
497
+ }],
498
+ },
499
+ query: `
500
+ mutation AlertTriggerActions($scope: ScopeSelectorInput, $filter: OrFilterSelectionInput, $actions: [TriggerActionInput!]!, $viewType: ViewType) {
501
+ alertTriggerActions(filter: $filter scope: $scope actions: $actions viewType: $viewType) {
502
+ ... on ActionsTriggered {
503
+ actions { actionId skip { id } failure { id errorMessage errorType } success { id } }
504
+ }
505
+ ... on TriggerActionsError {
506
+ errors { errorMessage }
507
+ }
508
+ }
509
+ }
510
+ `,
511
+ };
512
+
513
+ const triggerRes = await uamGraphql(triggerBody.query, triggerBody.variables, 'AlertTriggerActions');
514
+ const triggerData = triggerRes?.alertTriggerActions;
515
+ if (triggerData?.errors?.length) {
516
+ throw new Error(`aiInvestigation trigger error: ${triggerData.errors[0].errorMessage}`);
517
+ }
518
+ const triggered = triggerData?.actions?.[0];
519
+ if (triggered?.failure?.length) {
520
+ const f = triggered.failure[0];
521
+ // SERVICE_ERROR without errorMessage = LLM-layer rejection (same root cause as
522
+ // purpleLaunchQuery AsimovError). aiInvestigation/run requires a browser-session
523
+ // workspace; API-token service accounts are rejected at the service layer.
524
+ if (f.errorType === 'SERVICE_ERROR' && !f.errorMessage) {
525
+ throw new Error(
526
+ 'aiInvestigation SERVICE_ERROR: the AI investigation service rejected this service-account ' +
527
+ 'request. Like purpleLaunchQuery, this feature requires an active browser-session workspace. ' +
528
+ 'Trigger the investigation interactively from the Purple AI card in the alert detail panel.'
529
+ );
530
+ }
531
+ throw new Error(`aiInvestigation trigger failure: ${f.errorMessage || f.errorType}`);
532
+ }
533
+
534
+ // Step 2: Poll GetAlertAiInvestigations
535
+ const pollQuery = `
536
+ query GetAlertAiInvestigations($alertIds: [ID!]!, $scope: ScopeSelectorInput) {
537
+ aiInvestigations(alertIds: $alertIds, scope: $scope) {
538
+ alertId status purpleAiStatus investigationStep verdict timestamp result restrictionReason
539
+ }
540
+ }
541
+ `;
542
+
543
+ const deadline = Date.now() + timeoutMs;
544
+ const steps = [];
545
+ let lastStep = null;
546
+
547
+ while (Date.now() < deadline) {
548
+ await sleep(4000);
549
+ const pollRes = await uamGraphql(pollQuery, { alertIds: [alertId], scope }, 'GetAlertAiInvestigations');
550
+ const inv = pollRes?.aiInvestigations?.[0];
551
+ if (!inv) continue;
552
+
553
+ const step = inv.investigationStep;
554
+ if (step && step !== lastStep) {
555
+ steps.push(step);
556
+ lastStep = step;
557
+ }
558
+
559
+ if (inv.status === 'COMPLETED') {
560
+ return {
561
+ alertId: inv.alertId,
562
+ verdict: inv.verdict,
563
+ markdown: inv.result,
564
+ investigationSteps: steps,
565
+ timestamp: inv.timestamp,
566
+ };
567
+ }
568
+ if (inv.status === 'FAILED') {
569
+ throw new Error(`aiInvestigation FAILED: ${inv.restrictionReason || 'unknown reason'}`);
570
+ }
571
+ }
572
+
573
+ throw new Error(`aiInvestigation timed out after ${timeoutMs / 1000}s`);
574
+ }
575
+
576
+ // ─── UAM GraphQL ─────────────────────────────────────────────────────────────
577
+
578
+ /** Execute a raw UAM GraphQL operation. */
579
+ export async function uamGraphql(query, variables = {}, operationName) {
580
+ const body = { query, variables };
581
+ if (operationName) body.operationName = operationName;
582
+ const data = await apiPost('/web/api/v2.1/unifiedalerts/graphql', body);
583
+ if (data.errors?.length) {
584
+ throw new Error(`UAM GraphQL error: ${data.errors[0].message}`);
585
+ }
586
+ return data.data;
587
+ }
588
+
589
+ /**
590
+ * List UAM alerts using the correct `filters: [FilterInput!]` schema.
591
+ *
592
+ * IMPORTANT: The `alerts` query takes `filters: [FilterInput!]` (flat AND-joined list).
593
+ * Do NOT pass `filter: String` or `OrFilterSelectionInput` — those belong to mutations only.
594
+ *
595
+ * Each FilterInput is: { fieldId, <comparator>: <value> }
596
+ * Valid comparators (confirmed via introspection):
597
+ * stringEqual, stringIn, booleanEqual, booleanIn,
598
+ * intEqual, intIn, intRange,
599
+ * longEqual, longIn, longRange,
600
+ * dateTimeRange, match (fulltext)
601
+ * For dates: dateTimeRange: { start: <epoch_ms>, end: <epoch_ms> }
602
+ * NOT dateRange, NOT date_range, NOT { from, to }
603
+ *
604
+ * Purple MCP bug: its search_alerts sends date_range (snake_case) → UAM rejects.
605
+ * Use this function instead for time-scoped searches.
606
+ */
607
+ export async function uamListAlerts({
608
+ first = 20,
609
+ after = null,
610
+ viewType = 'ALL',
611
+ // Convenience: status / severity / detectionProduct strings → auto-built FilterInputs
612
+ status = null, // e.g. 'OPEN', 'IN_PROGRESS'
613
+ severity = null, // e.g. 'CRITICAL', 'HIGH'
614
+ detectionProduct = null, // e.g. 'EDR', 'STAR'
615
+ searchText = null, // fullText search across all fields
616
+ // Time range — specify either ISO strings OR epoch ms; both become dateRange { from, to }
617
+ startTime = null, // ISO string "2026-05-03T07:32:00Z" or epoch ms number
618
+ endTime = null, // ISO string or epoch ms; defaults to now when startTime is set
619
+ // Raw FilterInput list — overrides all convenience params above when provided
620
+ filters = null,
621
+ } = {}) {
622
+
623
+ // Build filters array
624
+ let builtFilters = filters;
625
+ if (!builtFilters) {
626
+ builtFilters = [];
627
+
628
+ if (status) {
629
+ builtFilters.push({ fieldId: 'status', stringEqual: { value: status } });
630
+ }
631
+ if (severity) {
632
+ builtFilters.push({ fieldId: 'severity', stringEqual: { value: severity } });
633
+ }
634
+ if (detectionProduct) {
635
+ builtFilters.push({ fieldId: 'detectionProduct', stringEqual: { value: detectionProduct } });
636
+ }
637
+ if (searchText) {
638
+ builtFilters.push({ fieldId: '*', match: { value: [searchText] } });
639
+ }
640
+ if (startTime !== null) {
641
+ // Convert ISO string to epoch ms if needed
642
+ const fromMs = typeof startTime === 'number' ? startTime : new Date(startTime).getTime();
643
+ const toMs = endTime
644
+ ? (typeof endTime === 'number' ? endTime : new Date(endTime).getTime())
645
+ : Date.now();
646
+ // Correct FilterInput field: dateTimeRange { start, end } — NOT dateRange, NOT date_range
647
+ builtFilters.push({ fieldId: 'detectedAt', dateTimeRange: { start: fromMs, end: toMs } });
648
+ }
649
+ }
650
+
651
+ const variables = {
652
+ first,
653
+ ...(after ? { after } : {}),
654
+ ...(builtFilters.length ? { filters: builtFilters } : {}),
655
+ viewType,
656
+ };
657
+
658
+ const query = `
659
+ query ListAlerts($first: Int, $after: String, $filters: [FilterInput!], $viewType: ViewType) {
660
+ alerts(first: $first, after: $after, filters: $filters, viewType: $viewType) {
661
+ pageInfo { hasNextPage endCursor }
662
+ totalCount
663
+ edges {
664
+ node {
665
+ id
666
+ severity
667
+ status
668
+ createdAt
669
+ updatedAt
670
+ detectedAt
671
+ name
672
+ description
673
+ externalId
674
+ storylineId
675
+ noteExists
676
+ confidenceLevel
677
+ primaryIndicatorType
678
+ assignee { fullName email }
679
+ }
680
+ }
681
+ }
682
+ }
683
+ `;
684
+ const data = await uamGraphql(query, variables);
685
+ const edges = data?.alerts?.edges || [];
686
+ return {
687
+ alerts: edges.map(e => e.node),
688
+ totalCount: data?.alerts?.totalCount ?? null,
689
+ pageInfo: data?.alerts?.pageInfo || {},
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Get a single UAM alert with notes.
695
+ * Fetches alert detail and notes in parallel (history is a separate paginated connection).
696
+ * Confirmed field list via __type introspection on UnifiedAlertDetail and AlertNote.
697
+ */
698
+ export async function uamGetAlert(alertId) {
699
+ const [alertData, notesData] = await Promise.all([
700
+ uamGraphql(`
701
+ query GetAlert($id: ID!) {
702
+ alert(id: $id) {
703
+ id severity status createdAt updatedAt detectedAt
704
+ name description externalId storylineId noteExists
705
+ confidenceLevel primaryIndicatorType analystVerdict result
706
+ assignee { fullName email }
707
+ detectionSource { product vendor }
708
+ }
709
+ }
710
+ `, { id: alertId }),
711
+ uamGraphql(`
712
+ query GetAlertNotes($id: ID!) {
713
+ alertNotes(alertId: $id) {
714
+ data { id text type createdAt updatedAt author { fullName email } }
715
+ }
716
+ }
717
+ `, { id: alertId }),
718
+ ]);
719
+ const alert = alertData?.alert || null;
720
+ if (alert) {
721
+ alert.notes = notesData?.alertNotes?.data || [];
722
+ }
723
+ return alert;
724
+ }
725
+
726
+ /**
727
+ * Add an analyst note to a UAM alert.
728
+ * Confirmed mutation signature: addAlertNote(alertId: ID!, text: String!, type: ContentType)
729
+ * Returns AlertNotesListResponse.data (all notes for the alert after adding).
730
+ */
731
+ export async function uamAddNote(alertId, noteText) {
732
+ const query = `
733
+ mutation AddNote($alertId: ID!, $text: String!) {
734
+ addAlertNote(alertId: $alertId, text: $text, type: PLAIN_TEXT) {
735
+ data { id text type createdAt updatedAt author { fullName email } }
736
+ }
737
+ }
738
+ `;
739
+ const data = await uamGraphql(query, { alertId, text: noteText });
740
+ const notes = data?.addAlertNote?.data || [];
741
+ // Return the most recently added note (last in the list)
742
+ return notes.length > 0 ? notes[notes.length - 1] : null;
743
+ }
744
+
745
+ /**
746
+ * Update the status of a UAM alert via alertTriggerActions.
747
+ * Valid status values (confirmed via Status enum introspection): NEW | IN_PROGRESS | RESOLVED
748
+ * Note: FALSE_POSITIVE is not a status — it is an analystVerdict value.
749
+ * To mark false positive: use uam_set_analyst_verdict with a FALSE_POSITIVE_* value instead.
750
+ */
751
+ export async function uamSetStatus(alertId, status) {
752
+ const query = `
753
+ mutation SetStatus($filter: OrFilterSelectionInput, $actions: [TriggerActionInput!]) {
754
+ alertTriggerActions(filter: $filter, actions: $actions) {
755
+ __typename
756
+ }
757
+ }
758
+ `;
759
+ const variables = {
760
+ filter: {
761
+ or: [{ and: [{ fieldId: 'id', stringEqual: { value: alertId } }] }],
762
+ },
763
+ actions: [{ id: 'S1/alert/statusUpdate', payload: { status: { value: status } } }],
764
+ };
765
+ const data = await uamGraphql(query, variables);
766
+ return data?.alertTriggerActions || null;
767
+ }