@pmoses-s1/s1-secops-mcp 1.3.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,610 @@
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
+ /**
28
+ * Build a validated absolute URL for an S1 Mgmt API call.
29
+ *
30
+ * SECURITY: `path` frequently originates from an LLM tool call
31
+ * (s1_api_get/post/put/delete/patch) and must never be able to change the
32
+ * request authority. Bare string concatenation (`${base()}${path}`) let a
33
+ * path like "@evil.example/x", ".evil.example/x", or "//evil.example/x"
34
+ * rewrite the host and send the tenant ApiToken to an attacker-chosen origin.
35
+ * We therefore (1) require a leading single "/" and (2) pin the resolved
36
+ * origin to the configured console. Any deviation throws before doFetch runs.
37
+ */
38
+ function safeUrl(path) {
39
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
40
+ throw new Error(
41
+ `S1 API path must be a string starting with a single "/" (got: ${JSON.stringify(path)?.slice(0, 80)})`
42
+ );
43
+ }
44
+ const origin = new URL(base()).origin;
45
+ const u = new URL(path, origin);
46
+ if (u.origin !== origin) {
47
+ throw new Error(`S1 API path may not change the request origin (resolved to ${u.origin})`);
48
+ }
49
+ return u;
50
+ }
51
+
52
+ async function doFetch(url, opts, retries = 3, { allowRetry = null } = {}) {
53
+ // Status-based retry is restricted to idempotent methods (GET/HEAD) unless the
54
+ // caller opts in: a 5xx received after the server committed a write would
55
+ // otherwise be re-POSTed (duplicate rules/notes/ingestion). Fixed 2026-07-29,
56
+ // mirroring the same fix in scripts/s1_client.py.
57
+ const method = (opts.method || 'GET').toUpperCase();
58
+ const methodRetryable = allowRetry !== null ? allowRetry : (method === 'GET' || method === 'HEAD');
59
+ let delay = 500;
60
+ for (let attempt = 0; attempt <= retries; attempt++) {
61
+ let res;
62
+ try {
63
+ res = await fetch(url, opts);
64
+ } catch (err) {
65
+ if (attempt === retries) throw err;
66
+ await sleep(delay);
67
+ delay = Math.min(delay * 2, 8000);
68
+ continue;
69
+ }
70
+
71
+ // Retry on 429 / 5xx (idempotent methods, or explicit opt-in, only)
72
+ if ((res.status === 429 || res.status >= 500) && attempt < retries && methodRetryable) {
73
+ // Retry-After may be missing or an HTTP date. Number(null) is 0, so a
74
+ // missing header must not be treated as "wait 0ms": only honor the
75
+ // header when the raw value is present and parses to a finite number.
76
+ const raRaw = res.headers.get('Retry-After');
77
+ const ra = Number(raRaw);
78
+ const wait = raRaw && Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay;
79
+ await sleep(wait);
80
+ delay = Math.min(delay * 2, 8000);
81
+ continue;
82
+ }
83
+
84
+ const text = await res.text();
85
+ let data;
86
+ try { data = JSON.parse(text); } catch { data = text; }
87
+
88
+ if (!res.ok) {
89
+ const msg = typeof data === 'object' ? (data?.errors?.[0]?.detail || data?.errors?.[0]?.message || JSON.stringify(data)) : text;
90
+ throw new Error(`S1 API ${opts.method || 'GET'} ${url} → ${res.status}: ${msg}`);
91
+ }
92
+ return data;
93
+ }
94
+ }
95
+
96
+ function sleep(ms) {
97
+ return new Promise(r => setTimeout(r, ms));
98
+ }
99
+
100
+ // ─── Mgmt REST API ────────────────────────────────────────────────────────────
101
+
102
+ /** GET /web/api/v2.1/<path> */
103
+ export async function apiGet(path, params = {}) {
104
+ const u = safeUrl(path);
105
+ for (const [k, v] of Object.entries(params)) {
106
+ if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
107
+ }
108
+ return doFetch(u.toString(), {
109
+ method: 'GET',
110
+ headers: {
111
+ Authorization: `ApiToken ${jwt()}`,
112
+ 'Content-Type': 'application/json',
113
+ },
114
+ });
115
+ }
116
+
117
+ /** POST /web/api/v2.1/<path>.
118
+ * Pass { allowRetry: true } ONLY for read-only POSTs (GraphQL queries, Purple AI
119
+ * launches, validate endpoints); mutating POSTs must not auto-retry on 5xx. */
120
+ export async function apiPost(path, body = {}, { allowRetry = false } = {}) {
121
+ return doFetch(safeUrl(path).toString(), {
122
+ method: 'POST',
123
+ headers: {
124
+ Authorization: `ApiToken ${jwt()}`,
125
+ 'Content-Type': 'application/json',
126
+ },
127
+ body: JSON.stringify(body),
128
+ }, 3, { allowRetry });
129
+ }
130
+
131
+ /** PUT /web/api/v2.1/<path> */
132
+ export async function apiPut(path, body = {}) {
133
+ return doFetch(safeUrl(path).toString(), {
134
+ method: 'PUT',
135
+ headers: {
136
+ Authorization: `ApiToken ${jwt()}`,
137
+ 'Content-Type': 'application/json',
138
+ },
139
+ body: JSON.stringify(body),
140
+ });
141
+ }
142
+
143
+ /** DELETE /web/api/v2.1/<path> */
144
+ export async function apiDelete(path, body = {}) {
145
+ return doFetch(safeUrl(path).toString(), {
146
+ method: 'DELETE',
147
+ headers: {
148
+ Authorization: `ApiToken ${jwt()}`,
149
+ 'Content-Type': 'application/json',
150
+ },
151
+ body: JSON.stringify(body),
152
+ });
153
+ }
154
+
155
+ /** PATCH /web/api/v2.1/<path> */
156
+ export async function apiPatch(path, body = {}) {
157
+ return doFetch(safeUrl(path).toString(), {
158
+ method: 'PATCH',
159
+ headers: {
160
+ Authorization: `ApiToken ${jwt()}`,
161
+ 'Content-Type': 'application/json',
162
+ },
163
+ body: JSON.stringify(body),
164
+ });
165
+ }
166
+
167
+ // ─── LRQ PowerQuery ───────────────────────────────────────────────────────────
168
+ // POST <console>/sdl/v2/api/queries with Bearer auth (same JWT, different prefix)
169
+ // Must echo X-Dataset-Query-Forward-Tag on every subsequent GET/DELETE.
170
+ // Poll every 1s; query expires 30s after last poll. Always cancel after use.
171
+
172
+ /** Resolve the LRQ time window. Each bound defaults INDEPENDENTLY, per the tool schema.
173
+ * Bug fixed 2026-07-29: the old `if (!startTime || !endTime)` overwrote BOTH bounds
174
+ * whenever either was missing, so a call with only startTime silently ran over the
175
+ * last `hours` instead of the requested window (plausible-but-wrong results).
176
+ * Demonstrated live: startTime-only for a 7.4-day window returned 12,880 events
177
+ * (== the 24h control, 12,875) vs 73,099 for the true pinned window. */
178
+ export function resolveLrqWindow({ startTime, endTime, hours = 24 } = {}) {
179
+ const iso = (d) => d.toISOString().replace(/\.\d+Z$/, 'Z');
180
+ if (!endTime) endTime = iso(new Date());
181
+ if (!startTime) startTime = iso(new Date(new Date(endTime) - hours * 3600 * 1000));
182
+ return { startTime, endTime };
183
+ }
184
+
185
+ /** matchCount lives inside the data block on current engines; top-level is a legacy
186
+ * fallback. Fixed 2026-07-29: reading only result.matchCount returned null on every
187
+ * live call, breaking the 0-rows-vs-0-matches triage. */
188
+ export function pickMatchCount(result) {
189
+ const d = (result && result.data) || {};
190
+ return d.matchCount ?? (result && result.matchCount) ?? null;
191
+ }
192
+
193
+ /** Run a full LRQ PowerQuery lifecycle. Returns { columns, rows, rowCount, matchCount }. */
194
+ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000 } = {}) {
195
+ const b = base();
196
+ const tok = jwt();
197
+
198
+ ({ startTime, endTime } = resolveLrqWindow({ startTime, endTime, hours }));
199
+
200
+ const launchUrl = `${b}/sdl/v2/api/queries`;
201
+ const launchBody = {
202
+ queryType: 'PQ',
203
+ tenant: true,
204
+ startTime,
205
+ endTime,
206
+ queryPriority: 'HIGH',
207
+ pq: { query, resultType: 'TABLE' },
208
+ };
209
+
210
+ // Launch
211
+ const launchRes = await fetch(launchUrl, {
212
+ method: 'POST',
213
+ headers: {
214
+ Authorization: `Bearer ${tok}`,
215
+ 'Content-Type': 'application/json',
216
+ },
217
+ body: JSON.stringify(launchBody),
218
+ });
219
+
220
+ if (!launchRes.ok) {
221
+ const body = await launchRes.text();
222
+ throw new Error(`LRQ launch failed (${launchRes.status}): ${body}`);
223
+ }
224
+
225
+ const forwardTag = launchRes.headers.get('X-Dataset-Query-Forward-Tag');
226
+ const launched = await launchRes.json();
227
+ const queryId = launched.id;
228
+ if (!queryId) throw new Error(`LRQ launch returned no id: ${JSON.stringify(launched)}`);
229
+
230
+ const pollHeaders = {
231
+ Authorization: `Bearer ${tok}`,
232
+ 'Content-Type': 'application/json',
233
+ ...(forwardTag ? { 'X-Dataset-Query-Forward-Tag': forwardTag } : {}),
234
+ };
235
+
236
+ // Poll until done (30s expiry, poll every 1s)
237
+ let lastStepSeen = 0;
238
+ let result = null;
239
+ let pollDelay = 1000;
240
+ const deadline = Date.now() + 5 * 60 * 1000; // 5 min hard timeout
241
+
242
+ try {
243
+ while (Date.now() < deadline) {
244
+ await sleep(pollDelay);
245
+ const pollUrl = `${b}/sdl/v2/api/queries/${queryId}?lastStepSeen=${lastStepSeen}`;
246
+ let pollRes;
247
+ try {
248
+ pollRes = await fetch(pollUrl, { method: 'GET', headers: pollHeaders });
249
+ } catch (err) {
250
+ // Transient network error; keep polling
251
+ continue;
252
+ }
253
+
254
+ if (!pollRes.ok) {
255
+ const body = await pollRes.text().catch(() => '');
256
+ // A transient 429/5xx on a single poll must not cancel a running
257
+ // query: keep polling (doubling the interval up to 5s, still well
258
+ // under the 30s poll-expiry window) until the 5-minute deadline.
259
+ // Other 4xx responses are permanent and remain fatal.
260
+ if (pollRes.status === 429 || pollRes.status >= 500) {
261
+ pollDelay = Math.min(pollDelay * 2, 5000);
262
+ continue;
263
+ }
264
+ throw new Error(`LRQ poll failed (${pollRes.status}): ${body}`);
265
+ }
266
+ pollDelay = 1000; // healthy poll: restore the normal interval
267
+
268
+ const state = await pollRes.json();
269
+ lastStepSeen = state.stepsCompleted ?? lastStepSeen;
270
+
271
+ const done = state.stepsTotal > 0 && state.stepsCompleted >= state.stepsTotal;
272
+ if (done) {
273
+ result = state;
274
+ break;
275
+ }
276
+ }
277
+ } finally {
278
+ // Always cancel to release quota
279
+ try {
280
+ await fetch(`${b}/sdl/v2/api/queries/${queryId}`, {
281
+ method: 'DELETE',
282
+ headers: pollHeaders,
283
+ });
284
+ } catch { /* best effort */ }
285
+ }
286
+
287
+ if (!result) throw new Error('LRQ timed out after 5 minutes');
288
+
289
+ const data = result.data || {};
290
+ const columns = data.columns || [];
291
+ const rawRows = data.values || [];
292
+
293
+ // Cap rows
294
+ // Confirmed: LRQ API returns columns as descriptor objects {name, cellType, ...}, not strings.
295
+ // Must use col.name (not col itself) as the row key, col.toString() produces "[object Object]".
296
+ const rows = rawRows.slice(0, maxRows).map(r => {
297
+ const obj = {};
298
+ columns.forEach((col, i) => { obj[col.name ?? col] = r[i]; });
299
+ return obj;
300
+ });
301
+
302
+ return {
303
+ columns,
304
+ rows,
305
+ rowCount: rows.length,
306
+ totalRows: rawRows.length,
307
+ matchCount: pickMatchCount(result),
308
+ queryId,
309
+ };
310
+ }
311
+
312
+ // ─── Purple AI ────────────────────────────────────────────────────────────────
313
+ // Reverse-engineered from live network traffic on usea1-acme.sentinelone.net.
314
+ //
315
+ // Endpoints:
316
+ // Purple AI LLM → POST /web/api/v2.1/graphql (ApiToken auth)
317
+ // SDL/History → POST <base>/sdl/v2/graphql (Bearer auth, same token)
318
+ //
319
+ // The dead exports purpleAiQuery and purpleAiInvestigate were deleted
320
+ // 2026-07-31. Their MCP tools were removed 2026-05-03: purpleLaunchQuery
321
+ // NATURAL_LANGUAGE and aiInvestigation/run both require a browser-session
322
+ // teamToken that service-account API tokens never obtain (AsimovError /
323
+ // SERVICE_ERROR). purpleAlertSummary (ALERT_ENTRY) has no such limitation.
324
+
325
+ /**
326
+ * Get a Purple AI natural-language summary for a specific UAM alert.
327
+ *
328
+ * Calls purpleAlertSummary (separate operation from purpleLaunchQuery).
329
+ * The inputAlert must be the OCSF-serialised alert JSON string.
330
+ * Returns { token, summary }
331
+ */
332
+ export async function purpleAlertSummary(alertOcsfJson, { userDetails = null } = {}) {
333
+ const consoleUrl = `${base()}/`;
334
+
335
+ const gqlBody = {
336
+ operationName: 'AlertSummary',
337
+ variables: {
338
+ request: {
339
+ isAsync: false,
340
+ contentType: 'ALERT_ENTRY',
341
+ inputAlert: typeof alertOcsfJson === 'string' ? alertOcsfJson : JSON.stringify(alertOcsfJson),
342
+ userDetails: userDetails || {
343
+ teamToken: '',
344
+ accountId: '',
345
+ userAgent: 's1-secops-mcp/1.0',
346
+ buildDate: new Date().toISOString(),
347
+ buildHash: '',
348
+ emailAddress: '',
349
+ },
350
+ consoleDetails: {
351
+ baseUrl: consoleUrl,
352
+ version: 'S-26.1.3#69',
353
+ },
354
+ },
355
+ },
356
+ query: `
357
+ query AlertSummary($request: PurpleAlertSummaryRequest!) {
358
+ purpleAlertSummary(request: $request) {
359
+ token
360
+ result { summary }
361
+ }
362
+ }
363
+ `,
364
+ };
365
+
366
+ const data = await apiPost('/web/api/v2.1/graphql', gqlBody, { allowRetry: true }); // read-only summary
367
+ if (data.errors?.length) throw new Error(`Purple AI AlertSummary error: ${data.errors[0].message}`);
368
+
369
+ const pas = data?.data?.purpleAlertSummary || {};
370
+ return {
371
+ token: pas.token || null,
372
+ summary: pas.result?.summary || null,
373
+ };
374
+ }
375
+
376
+ // ─── UAM GraphQL ─────────────────────────────────────────────────────────────
377
+
378
+ /** Execute a raw UAM GraphQL operation. */
379
+ export async function uamGraphql(query, variables = {}, operationName, { readOnly = false } = {}) {
380
+ const body = { query, variables };
381
+ if (operationName) body.operationName = operationName;
382
+ // readOnly=true (list/get queries) re-enables 429/5xx retry, which is safe
383
+ // for GraphQL reads; mutations (addNote, setStatus) must not auto-retry.
384
+ const data = await apiPost('/web/api/v2.1/unifiedalerts/graphql', body, { allowRetry: readOnly });
385
+ if (data.errors?.length) {
386
+ throw new Error(`UAM GraphQL error: ${data.errors[0].message}`);
387
+ }
388
+ return data.data;
389
+ }
390
+
391
+ /**
392
+ * List UAM alerts using the correct `filters: [FilterInput!]` schema.
393
+ *
394
+ * IMPORTANT: The `alerts` query takes `filters: [FilterInput!]` (flat AND-joined list).
395
+ * Do NOT pass `filter: String` or `OrFilterSelectionInput`: those belong to mutations only.
396
+ *
397
+ * Each FilterInput is: { fieldId, <comparator>: <value> }
398
+ * Valid comparators (confirmed via introspection):
399
+ * stringEqual, stringIn, booleanEqual, booleanIn,
400
+ * intEqual, intIn, intRange,
401
+ * longEqual, longIn, longRange,
402
+ * dateTimeRange, match (fulltext)
403
+ * For dates: dateTimeRange: { start: <epoch_ms>, end: <epoch_ms> }
404
+ * NOT dateRange, NOT date_range, NOT { from, to }
405
+ *
406
+ * Purple MCP bug: its search_alerts sends date_range (snake_case) → UAM rejects.
407
+ * Use this function instead for time-scoped searches.
408
+ */
409
+ export async function uamListAlerts({
410
+ first = 20,
411
+ after = null,
412
+ viewType = 'ALL',
413
+ // Convenience: status / severity / detectionProduct strings → auto-built FilterInputs
414
+ status = null, // e.g. 'OPEN', 'IN_PROGRESS'
415
+ severity = null, // e.g. 'CRITICAL', 'HIGH'
416
+ detectionProduct = null, // e.g. 'EDR', 'STAR'
417
+ searchText = null, // fullText search across all fields
418
+ // Time range: specify either ISO strings OR epoch ms; both become dateRange { from, to }
419
+ startTime = null, // ISO string "2026-05-03T07:32:00Z" or epoch ms number
420
+ endTime = null, // ISO string or epoch ms; defaults to now when startTime is set
421
+ // Raw FilterInput list: overrides all convenience params above when provided
422
+ filters = null,
423
+ } = {}) {
424
+
425
+ // Build filters array
426
+ let builtFilters = filters;
427
+ if (!builtFilters) {
428
+ builtFilters = [];
429
+
430
+ if (status) {
431
+ builtFilters.push({ fieldId: 'status', stringEqual: { value: status } });
432
+ }
433
+ if (severity) {
434
+ builtFilters.push({ fieldId: 'severity', stringEqual: { value: severity } });
435
+ }
436
+ if (detectionProduct) {
437
+ builtFilters.push({ fieldId: 'detectionProduct', stringEqual: { value: detectionProduct } });
438
+ }
439
+ if (searchText) {
440
+ builtFilters.push({ fieldId: '*', match: { value: [searchText] } });
441
+ }
442
+ if (startTime !== null) {
443
+ // Convert ISO string to epoch ms if needed
444
+ const fromMs = typeof startTime === 'number' ? startTime : new Date(startTime).getTime();
445
+ const toMs = endTime
446
+ ? (typeof endTime === 'number' ? endTime : new Date(endTime).getTime())
447
+ : Date.now();
448
+ // Correct FilterInput field: dateTimeRange { start, end }, NOT dateRange, NOT date_range
449
+ builtFilters.push({ fieldId: 'detectedAt', dateTimeRange: { start: fromMs, end: toMs } });
450
+ }
451
+ }
452
+
453
+ const variables = {
454
+ first,
455
+ ...(after ? { after } : {}),
456
+ ...(builtFilters.length ? { filters: builtFilters } : {}),
457
+ viewType,
458
+ };
459
+
460
+ const query = `
461
+ query ListAlerts($first: Int, $after: String, $filters: [FilterInput!], $viewType: ViewType) {
462
+ alerts(first: $first, after: $after, filters: $filters, viewType: $viewType) {
463
+ pageInfo { hasNextPage endCursor }
464
+ totalCount
465
+ edges {
466
+ node {
467
+ id
468
+ severity
469
+ status
470
+ createdAt
471
+ updatedAt
472
+ detectedAt
473
+ name
474
+ description
475
+ externalId
476
+ storylineId
477
+ noteExists
478
+ confidenceLevel
479
+ primaryIndicatorType
480
+ assignee { fullName email }
481
+ }
482
+ }
483
+ }
484
+ }
485
+ `;
486
+ const data = await uamGraphql(query, variables, undefined, { readOnly: true });
487
+ const edges = data?.alerts?.edges || [];
488
+ return {
489
+ alerts: edges.map(e => e.node),
490
+ totalCount: data?.alerts?.totalCount ?? null,
491
+ pageInfo: data?.alerts?.pageInfo || {},
492
+ };
493
+ }
494
+
495
+ /**
496
+ * Get a single UAM alert with notes.
497
+ * Fetches alert detail and notes in parallel (history is a separate paginated connection).
498
+ * Confirmed field list via __type introspection on UnifiedAlertDetail and AlertNote.
499
+ */
500
+ export async function uamGetAlert(alertId) {
501
+ const [alertData, notesData] = await Promise.all([
502
+ uamGraphql(`
503
+ query GetAlert($id: ID!) {
504
+ alert(id: $id) {
505
+ id severity status createdAt updatedAt detectedAt
506
+ name description externalId storylineId noteExists
507
+ confidenceLevel primaryIndicatorType analystVerdict result
508
+ assignee { fullName email }
509
+ detectionSource { product vendor }
510
+ }
511
+ }
512
+ `, { id: alertId }),
513
+ uamGraphql(`
514
+ query GetAlertNotes($id: ID!) {
515
+ alertNotes(alertId: $id) {
516
+ data { id text type createdAt updatedAt author { fullName email } }
517
+ }
518
+ }
519
+ `, { id: alertId }),
520
+ ]);
521
+ const alert = alertData?.alert || null;
522
+ if (alert) {
523
+ alert.notes = notesData?.alertNotes?.data || [];
524
+ }
525
+ return alert;
526
+ }
527
+
528
+ /**
529
+ * Add an analyst note to a UAM alert.
530
+ * Confirmed mutation signature: addAlertNote(alertId: ID!, text: String!, type: ContentType)
531
+ * Returns AlertNotesListResponse.data (all notes for the alert after adding).
532
+ */
533
+ export async function uamAddNote(alertId, noteText) {
534
+ const query = `
535
+ mutation AddNote($alertId: ID!, $text: String!) {
536
+ addAlertNote(alertId: $alertId, text: $text, type: PLAIN_TEXT) {
537
+ data { id text type createdAt updatedAt author { fullName email } }
538
+ }
539
+ }
540
+ `;
541
+ const data = await uamGraphql(query, { alertId, text: noteText });
542
+ const notes = data?.addAlertNote?.data || [];
543
+ // Fixed 2026-07-29: do not assume list ordering (newest-last was unverified).
544
+ // Prefer the note whose text matches what we just posted; tiebreak/fallback on
545
+ // the newest createdAt.
546
+ const pool = notes.filter(n => n?.text === noteText);
547
+ const candidates = pool.length ? pool : notes;
548
+ return candidates.reduce((best, n) => {
549
+ if (!best) return n;
550
+ return new Date(n?.createdAt || 0) >= new Date(best?.createdAt || 0) ? n : best;
551
+ }, null);
552
+ }
553
+
554
+ /**
555
+ * Update the status of a UAM alert via alertTriggerActions.
556
+ * Valid status values (confirmed via Status enum introspection): NEW | IN_PROGRESS | RESOLVED
557
+ * Note: FALSE_POSITIVE is not a status; it is an analystVerdict value.
558
+ * To mark false positive: there is no dedicated tool. POST the raw
559
+ * alertTriggerActions mutation with the S1/alert/analystVerdictUpdate action
560
+ * via s1_api_post to /web/api/v2.1/unifiedalerts/graphql.
561
+ *
562
+ * The mutation result is verified: a __typename-only selection previously
563
+ * reported success even when the backend skipped or failed the action
564
+ * (observed live: status stayed unchanged). Fixed 2026-07-31.
565
+ */
566
+ export async function uamSetStatus(alertId, status) {
567
+ const query = `
568
+ mutation SetStatus($filter: OrFilterSelectionInput, $actions: [TriggerActionInput!]) {
569
+ alertTriggerActions(filter: $filter, actions: $actions) {
570
+ ... on ActionsTriggered {
571
+ actions { actionId skip { id } failure { id errorMessage errorType } success { id } }
572
+ }
573
+ ... on TriggerActionsError {
574
+ errors { errorMessage }
575
+ }
576
+ }
577
+ }
578
+ `;
579
+ const variables = {
580
+ filter: {
581
+ or: [{ and: [{ fieldId: 'id', stringEqual: { value: alertId } }] }],
582
+ },
583
+ actions: [{ id: 'S1/alert/statusUpdate', payload: { status: { value: status } } }],
584
+ };
585
+ const data = await uamGraphql(query, variables);
586
+ const result = data?.alertTriggerActions || null;
587
+ if (result?.errors?.length) {
588
+ throw new Error(`uamSetStatus trigger error: ${result.errors[0].errorMessage}`);
589
+ }
590
+ const action = result?.actions?.[0];
591
+ if (!action) {
592
+ // Empty actions array: the backend applied nothing (e.g. the filter matched
593
+ // no alert). Same silent-success class as skip-without-success; fail loudly.
594
+ throw new Error(
595
+ `uamSetStatus applied no action for alert ${alertId}: the backend returned an empty actions list. ` +
596
+ 'Verify the alert id, then re-check with uam_get_alert.'
597
+ );
598
+ }
599
+ if (action.failure?.length) {
600
+ const f = action.failure[0];
601
+ throw new Error(`uamSetStatus failed for alert ${alertId}: ${f.errorMessage || f.errorType || 'unknown error'}`);
602
+ }
603
+ if (!(action.success?.length) && action.skip?.length) {
604
+ throw new Error(
605
+ `uamSetStatus skipped for alert ${alertId}: the backend did not apply the status update. ` +
606
+ 'Verify the alert id and that the transition is valid, then re-check with uam_get_alert.'
607
+ );
608
+ }
609
+ return result;
610
+ }