@pmoses-s1/sentinelone-mcp 1.0.0 → 1.2.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.
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Resolution order (highest wins):
5
5
  * 1. Environment variables
6
- * 2. COWORK_WORKSPACE/credentials.json
7
- * 3. Walk-up from cwd looking for credentials.json
8
- * 4. ~/mnt/<any-folder>/credentials.json (Cowork workspace mounts)
9
- * 5. CLAUDE_CONFIG_DIR/sentinelone/credentials.json
10
- * 6. ~/.config/sentinelone/credentials.json
6
+ * 2. S1_CREDS_FILE (explicit absolute path; recommended for team / VM deployments)
7
+ * 3. COWORK_WORKSPACE/credentials.json
8
+ * 4. Walk-up from cwd looking for credentials.json
9
+ * 5. ~/mnt/<any-folder>/credentials.json (Cowork workspace mounts)
10
+ * 6. CLAUDE_CONFIG_DIR/sentinelone/credentials.json
11
+ * 7. ~/.config/sentinelone/credentials.json
11
12
  */
12
13
 
13
14
  import { readFileSync, existsSync, readdirSync } from 'fs';
@@ -33,7 +34,18 @@ function tryLoad(dir) {
33
34
  }
34
35
 
35
36
  function discoverCredentials() {
36
- // 1. COWORK_WORKSPACE env override
37
+ // 1. S1_CREDS_FILE explicit absolute path. Useful for VM deployments and
38
+ // secret-store integrations (Vault / Doppler / 1Password / sealed-secrets)
39
+ // that render a credentials file to a known path at boot.
40
+ const credsFile = process.env.S1_CREDS_FILE;
41
+ if (credsFile && existsSync(credsFile)) {
42
+ try { return JSON.parse(readFileSync(credsFile, 'utf-8')); }
43
+ catch (e) {
44
+ process.stderr.write(`[credentials] S1_CREDS_FILE set but unreadable: ${e.message}\n`);
45
+ }
46
+ }
47
+
48
+ // 2. COWORK_WORKSPACE env override
37
49
  const ws = process.env.COWORK_WORKSPACE;
38
50
  if (ws) {
39
51
  const found = tryLoad(ws);
@@ -95,7 +107,6 @@ export function getCreds() {
95
107
  S1_CONSOLE_API_TOKEN: e('S1_CONSOLE_API_TOKEN') || e('S1_API_TOKEN'),
96
108
  S1_HEC_INGEST_URL: e('S1_HEC_INGEST_URL'),
97
109
  SDL_XDR_URL: e('SDL_XDR_URL') || e('SDL_BASE_URL'),
98
- SDL_LOG_WRITE_KEY: e('SDL_LOG_WRITE_KEY'),
99
110
  SDL_CONFIG_WRITE_KEY: e('SDL_CONFIG_WRITE_KEY'),
100
111
  SDL_CONFIG_READ_KEY: e('SDL_CONFIG_READ_KEY'),
101
112
  SDL_LOG_READ_KEY: e('SDL_LOG_READ_KEY'),
package/lib/hec.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * HEC (HTTP Event Collector) raw-log ingestion into the SentinelOne AI SIEM
3
+ * Singularity Data Lake. This is the SDL log-ingestion path and the replacement
4
+ * for the removed SDL `uploadLogs`. It is NOT UAM ingest: the `uam_*` tools post
5
+ * OCSF indicators/alerts to /v1/* on the same ingest host, but that is a separate
6
+ * API and is not connected to HEC.
7
+ *
8
+ * Source of truth: S-26.1 User Guide, "Singularity Data Lake > Data Ingestion >
9
+ * Additional Integrations > HTTP Event Collector (HEC)", p.4723-4726.
10
+ * Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
11
+ * Endpoints : /services/collector/raw (raw text — recommended for logs)
12
+ * /services/collector/event (structured JSON)
13
+ * Auth : Authorization: Bearer <S1_CONSOLE_API_TOKEN> (the same Management Console API token the other tools use)
14
+ * Scope : S1-Scope header is REQUIRED (accountId or accountId:siteId). Without it HEC returns 400 "Missing S1-Scope header".
15
+ * Parser : ?sourcetype=<parserName> query param. Other query params become fields in the UI.
16
+ * Pre-parsed: /event with ?isParsed=true indexes already-structured JSON fields directly, with no SDL parser.
17
+ * Compress : optional "Content-Encoding: gzip" (or zstd) — recommended, lowers egress cost.
18
+ * Limits : 10 MB uncompressed per request, 1000 requests/sec, 2 GB/sec per account.
19
+ */
20
+
21
+ import { gzipSync } from 'zlib';
22
+ import { getCreds } from './credentials.js';
23
+
24
+ const MAX_UNCOMPRESSED = 10 * 1024 * 1024; // 10 MB per HEC docs
25
+
26
+ function hecBase() {
27
+ const url = (getCreds().S1_HEC_INGEST_URL || '').replace(/\/+$/, '');
28
+ if (!url) {
29
+ throw new Error(
30
+ 'S1_HEC_INGEST_URL not configured. Add it to credentials.json ' +
31
+ '(e.g. "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"). ' +
32
+ 'Find the regional ingest URL at https://community.sentinelone.com/s/article/000004961'
33
+ );
34
+ }
35
+ return url;
36
+ }
37
+
38
+ function hecToken() {
39
+ const tok = getCreds().S1_CONSOLE_API_TOKEN;
40
+ if (!tok) {
41
+ throw new Error('S1_CONSOLE_API_TOKEN not configured. HEC uses the same Management Console API token as the Bearer.');
42
+ }
43
+ return tok;
44
+ }
45
+
46
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
47
+
48
+ /**
49
+ * Ingest raw logs/events into SDL via the HEC endpoint.
50
+ *
51
+ * @param {string} logContent Raw text. For /raw, newline-separated lines become separate events.
52
+ * @param {object} [opts]
53
+ * @param {string} [opts.parser] Parser name -> ?sourcetype=
54
+ * @param {object} [opts.fields] Extra {key: value} pairs -> query params, each becomes a UI field.
55
+ * Avoid HEC-reserved keys (event, time, host, source, sourcetype, index, fields):
56
+ * HEC interprets those, they are not stored as custom fields. Use `parser` (not a field) to set sourcetype. (S-26.1 HEC docs, p.4708.)
57
+ * @param {string} opts.scope REQUIRED. accountId or "accountId:siteId" -> S1-Scope header. HEC returns 400 "Missing S1-Scope header" without it.
58
+ * @param {('raw'|'event')} [opts.endpoint='raw']
59
+ * @param {boolean} [opts.compress=true] gzip the body (Content-Encoding: gzip)
60
+ * @param {boolean} [opts.isParsed=false] /event only: set ?isParsed=true to index already-structured JSON fields without an SDL parser.
61
+ * @returns {Promise<{status:number, endpoint:string, url:string, body:any}>}
62
+ */
63
+ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoint = 'raw', compress = true, isParsed = false } = {}) {
64
+ if (typeof logContent !== 'string' || logContent.length === 0) {
65
+ throw new Error('hecIngest: logContent must be a non-empty string.');
66
+ }
67
+ if (endpoint !== 'raw' && endpoint !== 'event') {
68
+ throw new Error("hecIngest: endpoint must be 'raw' or 'event'.");
69
+ }
70
+ if (!scope || typeof scope !== 'string') {
71
+ throw new Error('hecIngest: scope is required. HEC rejects requests without an S1-Scope header (400 "Missing S1-Scope header"). Pass an accountId or "accountId:siteId".');
72
+ }
73
+
74
+ const qs = new URLSearchParams();
75
+ if (parser) qs.set('sourcetype', parser);
76
+ for (const [k, v] of Object.entries(fields || {})) qs.set(k, String(v));
77
+ if (isParsed) qs.set('isParsed', 'true');
78
+ const query = qs.toString();
79
+ const url = `${hecBase()}/services/collector/${endpoint}${query ? `?${query}` : ''}`;
80
+
81
+ const rawBuf = Buffer.from(logContent, 'utf-8');
82
+ if (rawBuf.length > MAX_UNCOMPRESSED) {
83
+ throw new Error(
84
+ `hecIngest: payload is ${rawBuf.length} bytes, over the 10 MB uncompressed HEC limit. ` +
85
+ 'Split into smaller batches.'
86
+ );
87
+ }
88
+ const body = compress ? gzipSync(rawBuf) : rawBuf;
89
+
90
+ const headers = {
91
+ Authorization: `Bearer ${hecToken()}`,
92
+ 'Content-Type': 'text/plain',
93
+ };
94
+ if (compress) headers['Content-Encoding'] = 'gzip';
95
+ headers['S1-Scope'] = scope;
96
+
97
+ let delay = 1000;
98
+ let lastErr;
99
+ for (let attempt = 0; attempt <= 3; attempt++) {
100
+ let res;
101
+ try {
102
+ res = await fetch(url, { method: 'POST', headers, body });
103
+ } catch (err) {
104
+ lastErr = err;
105
+ if (attempt === 3) throw err;
106
+ await sleep(delay);
107
+ delay = Math.min(delay * 2, 8000);
108
+ continue;
109
+ }
110
+
111
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
112
+ const retryAfter = res.headers.get('Retry-After');
113
+ await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
114
+ delay = Math.min(delay * 2, 8000);
115
+ continue;
116
+ }
117
+
118
+ const text = await res.text();
119
+ let data;
120
+ try { data = JSON.parse(text); } catch { data = text; }
121
+
122
+ if (!res.ok) {
123
+ throw new Error(`HEC POST /services/collector/${endpoint} -> ${res.status}: ${JSON.stringify(data)}`);
124
+ }
125
+ return { status: res.status, endpoint, url, body: data };
126
+ }
127
+ throw lastErr;
128
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Streamable HTTP transport (MCP 2024-11-05 / 2025-06-18 compatible subset).
3
+ *
4
+ * Single endpoint:
5
+ * POST /mcp accept JSON-RPC, return JSON-RPC reply (Content-Type: application/json)
6
+ * GET /healthz return 200 OK (for load balancers / systemd readiness checks)
7
+ * Any other path / method returns 404 or 405.
8
+ *
9
+ * The server is stateless: it does not maintain MCP sessions or push
10
+ * server-initiated notifications, so the spec-allowed SSE response form
11
+ * is not used. Clients that try to GET /mcp for a server-initiated stream
12
+ * receive 405 Method Not Allowed, which spec-compliant clients tolerate.
13
+ *
14
+ * Auth: if any tokens are loaded via lib/auth.js, every POST /mcp must
15
+ * carry "Authorization: Bearer <token>". Missing/invalid token -> 401.
16
+ *
17
+ * Audit: every authenticated request logs to stderr (captured by journald
18
+ * on systemd or by Docker on container runtimes):
19
+ * timestamp | client-name | method | params-summary | response-status
20
+ *
21
+ * Zero external dependencies (uses node:http).
22
+ */
23
+
24
+ import { createServer } from 'http';
25
+ import { authenticate, isAuthConfigured, warnIfNoAuth, authSourceForLogging } from './auth.js';
26
+ import { err as makeErr } from './server-core.js';
27
+
28
+ const MAX_BODY_BYTES = 4 * 1024 * 1024; // 4 MB — well above any normal MCP call
29
+
30
+ function log(...args) {
31
+ process.stderr.write('[sentinelone-mcp] ' + args.join(' ') + '\n');
32
+ }
33
+
34
+ function audit(line) {
35
+ process.stderr.write(`[audit] ${line}\n`);
36
+ }
37
+
38
+ function summarizeParams(params) {
39
+ if (!params || typeof params !== 'object') return '';
40
+ if (params.name) return `name=${params.name}`; // tools/call
41
+ if (params.uri) return `uri=${params.uri}`; // resources/read
42
+ return '';
43
+ }
44
+
45
+ function readBody(req) {
46
+ return new Promise((resolve, reject) => {
47
+ let size = 0;
48
+ const chunks = [];
49
+ req.on('data', (chunk) => {
50
+ size += chunk.length;
51
+ if (size > MAX_BODY_BYTES) {
52
+ reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
53
+ req.destroy();
54
+ return;
55
+ }
56
+ chunks.push(chunk);
57
+ });
58
+ req.on('end', () => {
59
+ try {
60
+ const raw = Buffer.concat(chunks).toString('utf-8');
61
+ resolve(raw);
62
+ } catch (e) { reject(e); }
63
+ });
64
+ req.on('error', reject);
65
+ });
66
+ }
67
+
68
+ function sendJson(res, status, obj) {
69
+ const body = JSON.stringify(obj);
70
+ res.writeHead(status, {
71
+ 'Content-Type': 'application/json',
72
+ 'Content-Length': Buffer.byteLength(body),
73
+ 'Cache-Control': 'no-store',
74
+ });
75
+ res.end(body);
76
+ }
77
+
78
+ function sendText(res, status, text) {
79
+ res.writeHead(status, {
80
+ 'Content-Type': 'text/plain; charset=utf-8',
81
+ 'Content-Length': Buffer.byteLength(text),
82
+ 'Cache-Control': 'no-store',
83
+ });
84
+ res.end(text);
85
+ }
86
+
87
+ async function handleMcp(req, res, dispatch, clientIp) {
88
+ // Auth check (only if any tokens are configured)
89
+ let clientName = '-';
90
+ if (isAuthConfigured()) {
91
+ clientName = authenticate(req.headers['authorization']) || '';
92
+ if (!clientName) {
93
+ audit(`${new Date().toISOString()} | ${clientIp} | - | - | 401 unauthorized`);
94
+ sendJson(res, 401, makeErr(null, -32001, 'Unauthorized: missing or invalid bearer token'));
95
+ return;
96
+ }
97
+ } else {
98
+ clientName = `anon@${clientIp}`;
99
+ }
100
+
101
+ // Parse JSON body
102
+ let raw;
103
+ try {
104
+ raw = await readBody(req);
105
+ } catch (e) {
106
+ audit(`${new Date().toISOString()} | ${clientName} | - | - | 413 body-too-large`);
107
+ sendJson(res, 413, makeErr(null, -32600, e.message));
108
+ return;
109
+ }
110
+
111
+ if (!raw) {
112
+ sendJson(res, 400, makeErr(null, -32600, 'Empty body'));
113
+ return;
114
+ }
115
+
116
+ let msg;
117
+ try {
118
+ msg = JSON.parse(raw);
119
+ } catch (e) {
120
+ audit(`${new Date().toISOString()} | ${clientName} | - | - | 400 parse-error`);
121
+ sendJson(res, 400, makeErr(null, -32700, `Parse error: ${e.message}`));
122
+ return;
123
+ }
124
+
125
+ // JSON-RPC batch: out of spec for Streamable HTTP MCP; reject explicitly.
126
+ if (Array.isArray(msg)) {
127
+ sendJson(res, 400, makeErr(null, -32600, 'Batch requests are not supported'));
128
+ return;
129
+ }
130
+
131
+ const isNotification = msg.id === undefined;
132
+ const ts = new Date().toISOString();
133
+
134
+ try {
135
+ const response = await dispatch(msg.method, msg.params, msg.id);
136
+
137
+ if (isNotification) {
138
+ // Per JSON-RPC, notifications get no reply; per MCP Streamable HTTP, return 202.
139
+ audit(`${ts} | ${clientName} | ${msg.method} | ${summarizeParams(msg.params)} | 202 notification`);
140
+ res.writeHead(202);
141
+ res.end();
142
+ return;
143
+ }
144
+
145
+ if (response === null) {
146
+ sendJson(res, 200, makeErr(msg.id ?? null, -32603, 'Internal error: empty response'));
147
+ return;
148
+ }
149
+
150
+ const status = response.error ? 200 : 200; // JSON-RPC errors are 200 with error envelope
151
+ audit(`${ts} | ${clientName} | ${msg.method} | ${summarizeParams(msg.params)} | ${response.error ? `200 jsonrpc-error (${response.error.code})` : '200 ok'}`);
152
+ sendJson(res, status, response);
153
+
154
+ } catch (e) {
155
+ log('Dispatch error:', e.message, e.stack);
156
+ audit(`${ts} | ${clientName} | ${msg.method || '-'} | ${summarizeParams(msg.params)} | 500 internal-error`);
157
+ if (!isNotification) {
158
+ sendJson(res, 500, makeErr(msg.id ?? null, -32603, `Internal error: ${e.message}`));
159
+ } else {
160
+ res.writeHead(500);
161
+ res.end();
162
+ }
163
+ }
164
+ }
165
+
166
+ export async function startHttp(dispatch, { port, host, path }) {
167
+ warnIfNoAuth(host);
168
+
169
+ const server = createServer(async (req, res) => {
170
+ const clientIp = req.socket.remoteAddress || '-';
171
+
172
+ // Health check
173
+ if (req.method === 'GET' && (req.url === '/healthz' || req.url === '/health')) {
174
+ sendText(res, 200, 'ok\n');
175
+ return;
176
+ }
177
+
178
+ // MCP endpoint
179
+ if (req.url === path) {
180
+ if (req.method === 'POST') {
181
+ await handleMcp(req, res, dispatch, clientIp);
182
+ return;
183
+ }
184
+ if (req.method === 'GET' || req.method === 'DELETE') {
185
+ // Spec-allowed but not implemented (no server-push, no sessions).
186
+ res.writeHead(405, { 'Allow': 'POST', 'Content-Type': 'text/plain' });
187
+ res.end('Method not allowed; this server accepts POST only.\n');
188
+ return;
189
+ }
190
+ res.writeHead(405, { 'Allow': 'POST', 'Content-Type': 'text/plain' });
191
+ res.end('Method not allowed\n');
192
+ return;
193
+ }
194
+
195
+ sendText(res, 404, 'Not found. The MCP endpoint is ' + path + '.\n');
196
+ });
197
+
198
+ server.on('error', (e) => {
199
+ log(`HTTP server error: ${e.message}`);
200
+ if (e.code === 'EADDRINUSE') {
201
+ log(`Port ${port} is already in use. Pick a different --port.`);
202
+ process.exit(1);
203
+ }
204
+ });
205
+
206
+ await new Promise((resolve) => server.listen(port, host, resolve));
207
+ const authMode = isAuthConfigured() ? authSourceForLogging() : 'NONE (warn)';
208
+ log(`Transport: streamableHttp listening on http://${host}:${port}${path} (auth: ${authMode})`);
209
+
210
+ // Graceful shutdown
211
+ process.on('SIGINT', () => {
212
+ log('SIGINT received, draining HTTP server...');
213
+ server.close(() => process.exit(0));
214
+ setTimeout(() => process.exit(0), 5000).unref();
215
+ });
216
+ process.on('SIGTERM', () => {
217
+ log('SIGTERM received, draining HTTP server...');
218
+ server.close(() => process.exit(0));
219
+ setTimeout(() => process.exit(0), 5000).unref();
220
+ });
221
+
222
+ return server;
223
+ }
package/lib/s1.js CHANGED
@@ -248,7 +248,7 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
248
248
  }
249
249
 
250
250
  // ─── Purple AI ────────────────────────────────────────────────────────────────
251
- // Reverse-engineered from live network traffic on usea1-purple.sentinelone.net.
251
+ // Reverse-engineered from live network traffic on usea1-acme.sentinelone.net.
252
252
  //
253
253
  // IMPORTANT: purpleLaunchQuery is a GraphQL QUERY (not mutation).
254
254
  // Variable wrapper is `request` (type PurpleLaunchQueryRequest), NOT `input`.
package/lib/sdl.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * putFile → config_write_key
6
6
  * getFile / listFiles → config_write_key || config_read_key || console_api_token
7
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
8
  *
10
9
  * All SDL endpoints live at SDL_XDR_URL (e.g. https://xdr.us1.sentinelone.net).
11
10
  * The Authorization header is: Bearer <key>
@@ -30,7 +29,6 @@ function pickKey(chain) {
30
29
  // Confirmed: SDL_CONFIG_WRITE_KEY does NOT grant "View logs" permission on /api/query.
31
30
  // SDL_LOG_READ_KEY must be first in chain for V1 query to succeed.
32
31
  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
32
  };
35
33
  const candidates = chains[chain] || chains.config_read;
36
34
  const key = candidates.find(k => k);
@@ -117,36 +115,6 @@ export async function deleteFile(path, expectedVersion) {
117
115
  return sdlFetch('POST', '/api/putFile', { body, chain: 'config_write' });
118
116
  }
119
117
 
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
118
  // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
151
119
  // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
152
120
  // Use for schema discovery; use LRQ for hunting.