@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/CHANGELOG.md +119 -0
- package/README.md +519 -0
- package/deploy/README.md +370 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +122 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +280 -0
- package/deploy/systemd/s1-secops-mcp.service +59 -0
- package/index.js +171 -0
- package/lib/auth.js +161 -0
- package/lib/credentials.js +124 -0
- package/lib/hec.js +144 -0
- package/lib/http-transport.js +289 -0
- package/lib/s1.js +610 -0
- package/lib/sdl.js +130 -0
- package/lib/server-core.js +263 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +444 -0
- package/package.json +50 -0
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +125 -0
- package/scripts/test-mac.sh +187 -0
- package/tools/hyperautomation.js +284 -0
- package/tools/mgmt-console.js +344 -0
- package/tools/powerquery.js +129 -0
- package/tools/sdl-api.js +125 -0
- package/tools/uam-ingest.js +128 -0
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
|
+
}
|