@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/sdl.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * SentinelOne Singularity Data Lake (SDL) API client.
3
+ *
4
+ * Auth routing (mirrors the Python SDLClient behavior):
5
+ * putFile → config_write_key
6
+ * getFile / listFiles → config_write_key || config_read_key || console_api_token
7
+ * V1 query methods → config_write_key || config_read_key || log_read_key || console_api_token
8
+ * uploadLogs → log_write_key (console token NOT accepted here)
9
+ *
10
+ * All SDL endpoints live at SDL_XDR_URL (e.g. https://xdr.us1.sentinelone.net).
11
+ * The Authorization header is: Bearer <key>
12
+ * For the console JWT used with SDL, the same Bearer prefix applies.
13
+ */
14
+
15
+ import { getCreds } from './credentials.js';
16
+
17
+ // ─── helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ function xdrBase() {
20
+ const url = getCreds().SDL_XDR_URL.replace(/\/+$/, '');
21
+ if (!url) throw new Error('SDL_XDR_URL not configured. Drop credentials.json into your project folder.');
22
+ return url;
23
+ }
24
+
25
+ function pickKey(chain) {
26
+ const c = getCreds();
27
+ const chains = {
28
+ config_write: [c.SDL_CONFIG_WRITE_KEY],
29
+ config_read: [c.SDL_CONFIG_WRITE_KEY, c.SDL_CONFIG_READ_KEY, c.S1_CONSOLE_API_TOKEN],
30
+ // Confirmed: SDL_CONFIG_WRITE_KEY does NOT grant "View logs" permission on /api/query.
31
+ // SDL_LOG_READ_KEY must be first in chain for V1 query to succeed.
32
+ log_read: [c.SDL_LOG_READ_KEY, c.SDL_CONFIG_READ_KEY, c.SDL_CONFIG_WRITE_KEY, c.S1_CONSOLE_API_TOKEN],
33
+ log_write_strict: [c.SDL_LOG_WRITE_KEY], // console token NOT accepted
34
+ };
35
+ const candidates = chains[chain] || chains.config_read;
36
+ const key = candidates.find(k => k);
37
+ if (!key) throw new Error(`No SDL credential available for chain "${chain}". Drop credentials.json into your project folder.`);
38
+ return key;
39
+ }
40
+
41
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
42
+
43
+ async function sdlFetch(method, path, { body, chain = 'config_read', extraHeaders = {}, rawBody = null, contentType = 'application/json' } = {}, retries = 3) {
44
+ const url = `${xdrBase()}${path}`;
45
+ const token = pickKey(chain);
46
+ const headers = {
47
+ Authorization: `Bearer ${token}`,
48
+ 'Content-Type': contentType,
49
+ ...extraHeaders,
50
+ };
51
+
52
+ let delay = 500;
53
+ for (let attempt = 0; attempt <= retries; attempt++) {
54
+ let res;
55
+ try {
56
+ res = await fetch(url, {
57
+ method,
58
+ headers,
59
+ body: rawBody !== null ? rawBody : (body !== undefined ? JSON.stringify(body) : undefined),
60
+ });
61
+ } catch (err) {
62
+ if (attempt === retries) throw err;
63
+ await sleep(delay);
64
+ delay = Math.min(delay * 2, 8000);
65
+ continue;
66
+ }
67
+
68
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
69
+ const retryAfter = res.headers.get('Retry-After');
70
+ await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
71
+ delay = Math.min(delay * 2, 8000);
72
+ continue;
73
+ }
74
+
75
+ const text = await res.text();
76
+ let data;
77
+ try { data = JSON.parse(text); } catch { data = text; }
78
+
79
+ if (!res.ok) {
80
+ const msg = typeof data === 'object' ? JSON.stringify(data) : text;
81
+ throw new Error(`SDL API ${method} ${path} → ${res.status}: ${msg}`);
82
+ }
83
+ return data;
84
+ }
85
+ }
86
+
87
+ // ─── Config file operations ───────────────────────────────────────────────────
88
+
89
+ /** POST /api/listFiles — list every configuration file path on the SDL tenant. */
90
+ export async function listFiles() {
91
+ return sdlFetch('POST', '/api/listFiles', { body: {}, chain: 'config_read' });
92
+ }
93
+
94
+ /** POST /api/getFile — read a configuration file by path.
95
+ * Returns { path, content, version, ...status }. */
96
+ export async function getFile(path) {
97
+ return sdlFetch('POST', '/api/getFile', {
98
+ body: { path, prettyprint: true },
99
+ chain: 'config_read',
100
+ });
101
+ }
102
+
103
+ /** POST /api/putFile — create or update a configuration file.
104
+ * Pass expectedVersion (from a prior getFile) to enable optimistic locking. */
105
+ export async function putFile(path, content, expectedVersion) {
106
+ const body = { path, content };
107
+ if (expectedVersion !== undefined && expectedVersion !== null) {
108
+ body.expectedVersion = expectedVersion;
109
+ }
110
+ return sdlFetch('POST', '/api/putFile', { body, chain: 'config_write' });
111
+ }
112
+
113
+ /** POST /api/putFile with deleteFile:true — delete a config file. */
114
+ export async function deleteFile(path, expectedVersion) {
115
+ const body = { path, deleteFile: true };
116
+ if (expectedVersion !== undefined) body.expectedVersion = expectedVersion;
117
+ return sdlFetch('POST', '/api/putFile', { body, chain: 'config_write' });
118
+ }
119
+
120
+ // ─── Log ingestion ────────────────────────────────────────────────────────────
121
+
122
+ /** POST /api/uploadLogs — upload raw text log lines (newline-separated events). */
123
+ export async function uploadLogs(logContent, { parser, serverHost, logfile } = {}) {
124
+ const extraHeaders = {};
125
+ if (parser) extraHeaders['parser'] = parser;
126
+ if (serverHost) extraHeaders['server-host'] = serverHost;
127
+ if (logfile) extraHeaders['logfile'] = logfile;
128
+
129
+ const raw = typeof logContent === 'string' ? Buffer.from(logContent, 'utf-8') : logContent;
130
+ return sdlFetch('POST', '/api/uploadLogs', {
131
+ chain: 'log_write_strict',
132
+ rawBody: raw,
133
+ contentType: 'text/plain',
134
+ extraHeaders,
135
+ });
136
+ }
137
+
138
+ /** POST /api/addEvents — ingest structured events (JSON). */
139
+ export async function addEvents(events, session) {
140
+ const body = {
141
+ session: session || `mcp-${Date.now()}`,
142
+ events: events.map(e => ({
143
+ ts: e.ts || BigInt(Date.now()) * 1_000_000n,
144
+ attrs: e.attrs || e,
145
+ })),
146
+ };
147
+ return sdlFetch('POST', '/api/addEvents', { body, chain: 'log_write_strict' });
148
+ }
149
+
150
+ // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
151
+ // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
152
+ // Use for schema discovery; use LRQ for hunting.
153
+
154
+ /** POST /api/query — retrieve raw event JSON for schema discovery.
155
+ * Returns { matches: [{ timestamp, message, attributes }] }. */
156
+ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
157
+ const body = {
158
+ queryType: 'log',
159
+ filter,
160
+ maxCount,
161
+ startTime,
162
+ };
163
+ if (endTime) body.endTime = endTime;
164
+ return sdlFetch('POST', '/api/query', { body, chain: 'log_read' });
165
+ }
@@ -0,0 +1,438 @@
1
+ /**
2
+ * UAM Alert Interface client — pushes OCSF indicators and SecurityAlerts
3
+ * INTO Unified Alert Management via the SentinelOne HEC ingest host.
4
+ *
5
+ * This is a SEPARATE API surface from the Mgmt Console:
6
+ * Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
7
+ * Auth : Authorization: Bearer <jwt> (NOT "ApiToken" — endpoint rejects ApiToken)
8
+ * Body : concatenated JSON, gzip-compressed, Content-Encoding: gzip
9
+ * Scope : S1-Scope: <accountId>[:<siteId>[:<groupId>]] (mandatory)
10
+ *
11
+ * Endpoints:
12
+ * POST /v1/indicators — OCSF behavioral indicators (batch: N per call)
13
+ * POST /v1/alerts — OCSF SecurityAlert (ONE per call — see below)
14
+ *
15
+ * Critical constraints (empirically confirmed on your-tenant 2026-04-22):
16
+ * - ONE alert per POST /v1/alerts. Multi-alert bodies return HTTP 202 but the
17
+ * stitcher silently drops all but one. Loop callers for multiple alerts.
18
+ * - Sleep ~3s between POST /v1/indicators and POST /v1/alerts. If the alert
19
+ * lands before the indicator's metadata.uid is registered the stitcher silently
20
+ * drops the alert (still HTTP 202). ingestAlert() enforces the sleep.
21
+ * - file.hashes MUST be OCSF Fingerprint array [{algorithm_id, algorithm, value}],
22
+ * NOT a plain dict. Dict form causes silent drop even on HTTP 202.
23
+ * - finding_info.related_events[] entries MUST carry class_uid, type_uid,
24
+ * category_uid, activity_id, severity_id, time, message, and observables[]
25
+ * each with both type and typeName alongside type_id/name/value.
26
+ */
27
+
28
+ import { gzipSync } from 'zlib';
29
+ import { randomUUID } from 'crypto';
30
+ import { getCreds } from './credentials.js';
31
+
32
+ // ─── helpers ──────────────────────────────────────────────────────────────────
33
+
34
+ function hecBase() {
35
+ const url = (getCreds().S1_HEC_INGEST_URL || '').replace(/\/+$/, '');
36
+ if (!url) {
37
+ throw new Error(
38
+ 'S1_HEC_INGEST_URL not configured. Add it to credentials.json ' +
39
+ '(e.g. "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"). ' +
40
+ 'Find the correct URL for your region at: ' +
41
+ 'https://community.sentinelone.com/s/article/000004961'
42
+ );
43
+ }
44
+ return url;
45
+ }
46
+
47
+ function bearerJwt() {
48
+ const tok = getCreds().S1_CONSOLE_API_TOKEN;
49
+ if (!tok) throw new Error('S1_CONSOLE_API_TOKEN not configured.');
50
+ return tok;
51
+ }
52
+
53
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
54
+
55
+ /**
56
+ * POST one or more OCSF objects to a HEC ingest endpoint.
57
+ * Body is concatenated JSON (newline-separated), gzip-compressed.
58
+ * Auth is Bearer (not ApiToken).
59
+ */
60
+ async function hecPost(path, payloads, scope, retries = 3) {
61
+ const url = `${hecBase()}${path}`;
62
+ const items = Array.isArray(payloads) ? payloads : [payloads];
63
+ const body = items.map(p => JSON.stringify(p)).join('\n');
64
+ const compressed = gzipSync(Buffer.from(body, 'utf-8'));
65
+
66
+ let delay = 1000;
67
+ let lastErr;
68
+ for (let attempt = 0; attempt <= retries; attempt++) {
69
+ let res;
70
+ try {
71
+ res = await fetch(url, {
72
+ method: 'POST',
73
+ headers: {
74
+ Authorization: `Bearer ${bearerJwt()}`,
75
+ 'Content-Type': 'application/json',
76
+ 'Content-Encoding': 'gzip',
77
+ 'S1-Scope': scope,
78
+ },
79
+ body: compressed,
80
+ });
81
+ } catch (err) {
82
+ lastErr = err;
83
+ if (attempt === retries) throw err;
84
+ await sleep(delay);
85
+ delay = Math.min(delay * 2, 8000);
86
+ continue;
87
+ }
88
+
89
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
90
+ const retryAfter = res.headers.get('Retry-After');
91
+ await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
92
+ delay = Math.min(delay * 2, 8000);
93
+ continue;
94
+ }
95
+
96
+ const text = await res.text();
97
+ let data;
98
+ try { data = JSON.parse(text); } catch { data = text; }
99
+
100
+ if (!res.ok) {
101
+ throw new Error(`HEC POST ${path} -> ${res.status}: ${JSON.stringify(data)}`);
102
+ }
103
+ return { status: res.status, body: data };
104
+ }
105
+ throw lastErr;
106
+ }
107
+
108
+ // ─── OCSF payload builders ────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Build an OCSF FileSystem Activity indicator (class_uid 1001).
112
+ *
113
+ * Shape matches the confirmed-working Python build_file_indicator() in
114
+ * sentinelone-mgmt-console-api/scripts/uam_alert_interface.py (tested
115
+ * on usea1-purple 2026-04-22). Key points:
116
+ * - metadata.version "1.6.0-dev" (not "1.6.0")
117
+ * - metadata.extensions array (not "extension" singular)
118
+ * - metadata.product omits vendor_name (just name)
119
+ * - type_uid set directly on the indicator (class_uid*100 + activity_id)
120
+ * - device carries name + hostname + type_id:1
121
+ * - actor.user carries type:"System" + type_id:3
122
+ * - attack_surface_id:1 (singular, at top level)
123
+ * - severity_id:2 (not 3)
124
+ * - file.hashes MUST be Fingerprint array [{algorithm_id,algorithm,value}]
125
+ *
126
+ * Returns a complete indicator object ready to POST to /v1/indicators.
127
+ */
128
+ export function buildFileIndicator({
129
+ indicatorUid,
130
+ filename = 'test-payload.exe',
131
+ sha256,
132
+ hostname = 'mcp-test-host',
133
+ deviceUid,
134
+ userUid,
135
+ nowMs,
136
+ } = {}) {
137
+ const ts = nowMs || Date.now();
138
+ const iUid = indicatorUid || randomUUID();
139
+ const dUid = deviceUid || randomUUID();
140
+ const uUid = userUid || randomUUID();
141
+ const sha = sha256 || '0'.repeat(64);
142
+ const activityId = 1;
143
+ const classUid = 1001;
144
+
145
+ return {
146
+ message: `File ${filename} action_${activityId}`,
147
+ time: ts,
148
+ device: {
149
+ uid: dUid,
150
+ name: hostname,
151
+ hostname,
152
+ type_id: 1,
153
+ },
154
+ metadata: {
155
+ version: '1.6.0-dev',
156
+ product: { name: 'smoke-product' },
157
+ extensions: [{ name: 's1', uid: '998', version: '0.1.0' }],
158
+ profiles: ['s1/security_indicator'],
159
+ uid: iUid,
160
+ },
161
+ type_uid: classUid * 100 + activityId,
162
+ activity_id: activityId,
163
+ class_uid: classUid,
164
+ category_uid: 1,
165
+ observables: [
166
+ { type_id: 7, type: 'File Name', typeName: 'File Name', name: 'file.name', value: filename },
167
+ { type_id: 1, type: 'Hostname', typeName: 'Hostname', name: 'device.hostname', value: hostname },
168
+ { type_id: 8, type: 'Hash', typeName: 'Hash', name: 'file.hashes.sha256', value: sha },
169
+ ],
170
+ actor: {
171
+ user: {
172
+ name: 'smoke-user',
173
+ type: 'System',
174
+ uid: uUid,
175
+ type_id: 3,
176
+ },
177
+ },
178
+ severity_id: 2,
179
+ attack_surface_id: 1,
180
+ // OCSF Fingerprint array — dict form causes silent stitcher drop even on HTTP 202
181
+ file: {
182
+ name: filename,
183
+ type_id: 1,
184
+ hashes: [{ algorithm_id: 3, algorithm: 'SHA-256', value: sha }],
185
+ },
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Build an OCSF SecurityAlert (class_uid 2002) referencing one indicator.
191
+ *
192
+ * Returns a complete alert object ready to POST to /v1/alerts (one at a time).
193
+ *
194
+ * @param {boolean} [inline=false]
195
+ * false (default): related_events[] contains only the reference fields (uid, class_uid,
196
+ * type_uid, etc.) and observables. The stitcher resolves the full indicator from a
197
+ * prior /v1/indicators POST via metadata.uid. Use with ingestAlert() (two-call flow).
198
+ * true: related_events[] embeds the full indicator context (file, device, actor) inline.
199
+ * No separate /v1/indicators POST is required — everything ships in one /v1/alerts call.
200
+ * Use with ingestAlertInline() (single-call flow).
201
+ */
202
+ export function buildSecurityAlert({
203
+ alertUid,
204
+ indicator,
205
+ title = 'MCP Test Alert',
206
+ description = 'Synthetic test alert created by sentinelone-mcp uam_ingest_alert.',
207
+ detectionProduct = 'smoke-product',
208
+ detectionVendor = 'smoke-vendor',
209
+ inline = false,
210
+ nowMs,
211
+ } = {}) {
212
+ const ts = nowMs || Date.now();
213
+ const uid = indicator.metadata.uid;
214
+
215
+ // related_events[] entry — shape matches Python build_alert_referencing().
216
+ // type_uid comes from the indicator's own type_uid field (set by buildFileIndicator).
217
+ // inline=true embeds file/device/actor so the alert is fully self-contained;
218
+ // inline=false is reference-only and relies on the stitcher resolving metadata.uid.
219
+ const relatedEvent = {
220
+ message: indicator.message || '',
221
+ time: ts,
222
+ uid,
223
+ severity_id: indicator.severity_id || 2,
224
+ observables: (indicator.observables || []).map(o => ({
225
+ ...o,
226
+ typeName: o.typeName || o.type,
227
+ })),
228
+ class_uid: indicator.class_uid,
229
+ type_uid: indicator.type_uid,
230
+ category_uid: indicator.category_uid,
231
+ activity_id: indicator.activity_id || 1,
232
+ ...(inline ? {
233
+ file: indicator.file,
234
+ device: indicator.device,
235
+ actor: indicator.actor,
236
+ } : {}),
237
+ };
238
+
239
+ const aUid = alertUid || randomUUID();
240
+ const dev = indicator.device || {};
241
+
242
+ return {
243
+ finding_info: {
244
+ uid: aUid,
245
+ title,
246
+ desc: description,
247
+ related_events: [relatedEvent],
248
+ },
249
+ // Single resources[] entry keyed on first indicator's device.
250
+ // type_id:1 + type:"host" matches the Python reference implementation.
251
+ resources: [{
252
+ uid: dev.uid || 'unknown',
253
+ name: dev.hostname || dev.name || 'unknown',
254
+ type_id: 1,
255
+ type: 'host',
256
+ }],
257
+ category_uid: 2,
258
+ category_name: 'Findings',
259
+ // S1-specific extension class — NOT the generic OCSF 2002.
260
+ // Using 2002 causes silent drop; 99602001 is what the stitcher expects.
261
+ class_uid: 99602001,
262
+ class_name: 'S1 Security Alert',
263
+ type_uid: 9960200101,
264
+ type_name: 'S1 Security Alert: Create',
265
+ activity_id: 1,
266
+ metadata: {
267
+ version: '1.6.0-dev',
268
+ extension: { name: 's1', uid: '998', version: '0.1.0' },
269
+ product: { name: detectionProduct, vendor_name: detectionVendor },
270
+ logged_time: ts,
271
+ modified_time: ts,
272
+ },
273
+ time: ts,
274
+ attack_surface_ids: [1],
275
+ severity_id: 2,
276
+ state_id: 1,
277
+ s1_classification_id: 1,
278
+ };
279
+ }
280
+
281
+ // ─── High-level end-to-end helpers ────────────────────────────────────────────
282
+
283
+ /**
284
+ * Create a synthetic test alert in UAM end-to-end.
285
+ *
286
+ * Builds an OCSF FileSystem Activity indicator and a SecurityAlert,
287
+ * POSTs them to the HEC ingest host with the required 3s sleep in between,
288
+ * and returns the UIDs and HTTP responses.
289
+ *
290
+ * The alert typically surfaces in UAM within 30-60s. Search by title or
291
+ * poll uam_list_alerts.
292
+ *
293
+ * @param {object} opts
294
+ * @param {string} opts.scope accountId or "accountId:siteId" (mandatory)
295
+ * @param {string} [opts.title] Alert name shown in UAM (default: "MCP Test Alert")
296
+ * @param {string} [opts.description] Alert description
297
+ * @param {string} [opts.hostname] Hostname for the indicator device
298
+ * @param {string} [opts.filename] Filename for the FileSystem indicator
299
+ * @param {string} [opts.sha256] SHA-256 hash (64 hex chars); random if omitted
300
+ * @param {number} [opts.sleepMs=3000] Sleep between indicator POST and alert POST
301
+ */
302
+ export async function ingestAlert({
303
+ scope,
304
+ title = 'MCP Test Alert',
305
+ description = 'Synthetic test alert created by sentinelone-mcp uam_ingest_alert.',
306
+ hostname = 'mcp-test-host',
307
+ filename = 'test-payload.exe',
308
+ sha256,
309
+ sleepMs = 3000,
310
+ } = {}) {
311
+ if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
312
+
313
+ const nowMs = Date.now();
314
+ const indicatorUid = randomUUID();
315
+ const alertUid = randomUUID();
316
+
317
+ const indicator = buildFileIndicator({
318
+ indicatorUid,
319
+ filename,
320
+ sha256,
321
+ hostname,
322
+ nowMs,
323
+ });
324
+
325
+ const indicatorResp = await hecPost('/v1/indicators', [indicator], scope);
326
+
327
+ // Wait for the stitcher to register the indicator uid before posting the alert.
328
+ // Reducing below ~2s has been observed to cause silent drops on loaded tenants.
329
+ await sleep(sleepMs);
330
+
331
+ const alert = buildSecurityAlert({
332
+ alertUid,
333
+ indicator,
334
+ title,
335
+ description,
336
+ nowMs,
337
+ });
338
+
339
+ const alertResp = await hecPost('/v1/alerts', alert, scope);
340
+
341
+ return {
342
+ indicator_uid: indicatorUid,
343
+ alert_uid: alertUid,
344
+ indicator_response: indicatorResp,
345
+ alert_response: alertResp,
346
+ next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Create a synthetic test alert in UAM in a single /v1/alerts POST.
352
+ *
353
+ * Differs from ingestAlert() in two ways:
354
+ * - No separate /v1/indicators POST (no HEC indicator call at all).
355
+ * - No sleep — the indicator data is embedded inline inside the alert's
356
+ * finding_info.related_events[] entry (file, device, actor fields included),
357
+ * so the stitcher does not need to resolve a uid from a prior indicator POST.
358
+ *
359
+ * Trade-off: the alert's Indicators tab in UAM may show less detail than in the
360
+ * two-call flow (stitcher reconciliation vs inline embedding). Use two-call mode
361
+ * when deep indicator stitching is required; use inline mode for rapid testing or
362
+ * when a single round-trip is preferred.
363
+ */
364
+ export async function ingestAlertInline({
365
+ scope,
366
+ title = 'MCP Test Alert',
367
+ description = 'Synthetic test alert created by sentinelone-mcp uam_ingest_alert (inline mode).',
368
+ hostname = 'mcp-test-host',
369
+ filename = 'test-payload.exe',
370
+ sha256,
371
+ } = {}) {
372
+ if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
373
+
374
+ const nowMs = Date.now();
375
+ const indicatorUid = randomUUID();
376
+ const alertUid = randomUUID();
377
+
378
+ const indicator = buildFileIndicator({
379
+ indicatorUid,
380
+ filename,
381
+ sha256,
382
+ hostname,
383
+ nowMs,
384
+ });
385
+
386
+ const alert = buildSecurityAlert({
387
+ alertUid,
388
+ indicator,
389
+ title,
390
+ description,
391
+ inline: true,
392
+ nowMs,
393
+ });
394
+
395
+ const alertResp = await hecPost('/v1/alerts', alert, scope);
396
+
397
+ return {
398
+ indicator_uid: indicatorUid,
399
+ alert_uid: alertUid,
400
+ alert_response: alertResp,
401
+ mode: 'inline',
402
+ next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
403
+ };
404
+ }
405
+
406
+ // ─── Low-level raw-payload helpers ────────────────────────────────────────────
407
+
408
+ /**
409
+ * POST raw OCSF indicators to /v1/indicators.
410
+ * Caller is responsible for correct OCSF shape.
411
+ */
412
+ export async function postIndicators({ scope, indicators }) {
413
+ if (!scope) throw new Error('scope is required.');
414
+ const items = Array.isArray(indicators) ? indicators : [indicators];
415
+ return hecPost('/v1/indicators', items, scope);
416
+ }
417
+
418
+ /**
419
+ * POST a single raw OCSF SecurityAlert to /v1/alerts.
420
+ * ONE alert per call — the stitcher silently drops all but one in multi-alert POSTs.
421
+ */
422
+ export async function postAlert({ scope, alert }) {
423
+ if (!scope) throw new Error('scope is required.');
424
+ if (Array.isArray(alert)) {
425
+ throw new Error(
426
+ 'postAlert() accepts a single alert object, not an array. ' +
427
+ 'The HEC stitcher silently drops all but one alert in multi-alert POSTs. ' +
428
+ 'Loop this call for multiple alerts.'
429
+ );
430
+ }
431
+ return hecPost('/v1/alerts', alert, scope);
432
+ }
433
+
434
+ /** True if HEC ingest credentials are configured. */
435
+ export function hasHecCreds() {
436
+ const c = getCreds();
437
+ return !!(c.S1_HEC_INGEST_URL && c.S1_CONSOLE_API_TOKEN);
438
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@pmoses-s1/sentinelone-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "sentinelone-mcp": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "lib/",
13
+ "tools/",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "start": "node index.js",
18
+ "dev": "node --watch index.js"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/pmoses-s1/claude-skills.git",
26
+ "directory": "sentinelone-mcp"
27
+ },
28
+ "homepage": "https://github.com/pmoses-s1/claude-skills/tree/main/sentinelone-mcp#readme",
29
+ "bugs": "https://github.com/pmoses-s1/claude-skills/issues",
30
+ "keywords": [
31
+ "mcp",
32
+ "sentinelone",
33
+ "model-context-protocol",
34
+ "soc",
35
+ "powerquery",
36
+ "sdl",
37
+ "singularity-data-lake",
38
+ "claude"
39
+ ],
40
+ "license": "MIT",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }