@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/sdl.js ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * SentinelOne Singularity Data Lake (SDL) API client.
3
+ *
4
+ * Auth: the console API token, sent as `Authorization: Bearer <token>`.
5
+ * It covers every SDL operation, config read, config write, and log read.
6
+ *
7
+ * All SDL endpoints live under `<console>/sdl`, derived from S1_CONSOLE_URL.
8
+ */
9
+
10
+ import { getCreds } from './credentials.js';
11
+
12
+ // ─── helpers ──────────────────────────────────────────────────────────────────
13
+
14
+ function sdlBase() {
15
+ const url = (getCreds().S1_CONSOLE_URL || '').replace(/\/+$/, '');
16
+ if (!url) throw new Error('S1_CONSOLE_URL not configured. Drop credentials.json into your project folder.');
17
+ return `${url}/sdl`;
18
+ }
19
+
20
+ /** The SDL credential: the console API token, used for every operation. */
21
+ export function sdlToken() {
22
+ const token = getCreds().S1_CONSOLE_API_TOKEN;
23
+ if (!token) {
24
+ throw new Error('S1_CONSOLE_API_TOKEN not configured. Drop credentials.json into your project folder.');
25
+ }
26
+ return token;
27
+ }
28
+
29
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
30
+
31
+ function retryAfterMs(res, fallback) {
32
+ // Retry-After may be seconds OR an HTTP date; parseInt on a date yields NaN
33
+ // and sleep(NaN) fires immediately (no backoff). Validate and cap.
34
+ const raw = res.headers.get('Retry-After');
35
+ const secs = Number(raw);
36
+ if (raw && Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, 30000);
37
+ return fallback;
38
+ }
39
+
40
+ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, contentType = 'application/json' } = {}, retries = 3) {
41
+ const url = `${sdlBase()}${path}`;
42
+ const headers = {
43
+ Authorization: `Bearer ${sdlToken()}`,
44
+ 'Content-Type': contentType,
45
+ ...extraHeaders,
46
+ };
47
+
48
+ let delay = 500;
49
+ for (let attempt = 0; attempt <= retries; attempt++) {
50
+ let res;
51
+ try {
52
+ res = await fetch(url, {
53
+ method,
54
+ headers,
55
+ body: rawBody !== null ? rawBody : (body !== undefined ? JSON.stringify(body) : undefined),
56
+ });
57
+ } catch (err) {
58
+ if (attempt === retries) throw err;
59
+ await sleep(delay);
60
+ delay = Math.min(delay * 2, 8000);
61
+ continue;
62
+ }
63
+
64
+ if ((res.status === 429 || res.status >= 500) && attempt < retries) {
65
+ await sleep(retryAfterMs(res, delay));
66
+ delay = Math.min(delay * 2, 8000);
67
+ continue;
68
+ }
69
+
70
+ const text = await res.text();
71
+ let data;
72
+ try { data = JSON.parse(text); } catch { data = text; }
73
+
74
+ if (!res.ok) {
75
+ const msg = typeof data === 'object' ? JSON.stringify(data) : text;
76
+ throw new Error(`SDL API ${method} ${path} → ${res.status}: ${msg}`);
77
+ }
78
+ return data;
79
+ }
80
+ throw new Error(`SDL API ${method} ${path}: request failed after retries`);
81
+ }
82
+
83
+ // ─── Config file operations ───────────────────────────────────────────────────
84
+
85
+ /** POST /api/listFiles: list every configuration file path on the SDL tenant. */
86
+ export async function listFiles() {
87
+ return sdlFetch('POST', '/api/listFiles', { body: {} });
88
+ }
89
+
90
+ /** POST /api/getFile: read a configuration file by path.
91
+ * Returns { path, content, version, ...status }. */
92
+ export async function getFile(path) {
93
+ return sdlFetch('POST', '/api/getFile', {
94
+ body: { path, prettyprint: true },
95
+ });
96
+ }
97
+
98
+ /** POST /api/putFile: create or update a configuration file.
99
+ * Pass expectedVersion (from a prior getFile) to enable optimistic locking. */
100
+ export async function putFile(path, content, expectedVersion) {
101
+ const body = { path, content };
102
+ if (expectedVersion !== undefined && expectedVersion !== null) {
103
+ body.expectedVersion = expectedVersion;
104
+ }
105
+ return sdlFetch('POST', '/api/putFile', { body });
106
+ }
107
+
108
+ /** POST /api/putFile with deleteFile:true deletes a config file. */
109
+ export async function deleteFile(path, expectedVersion) {
110
+ const body = { path, deleteFile: true };
111
+ if (expectedVersion !== undefined) body.expectedVersion = expectedVersion;
112
+ return sdlFetch('POST', '/api/putFile', { body });
113
+ }
114
+
115
+ // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
116
+ // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
117
+ // Use for schema discovery; use LRQ for hunting.
118
+
119
+ /** POST /api/query: retrieve raw event JSON for schema discovery.
120
+ * Returns { matches: [{ timestamp, message, attributes }] }. */
121
+ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
122
+ const body = {
123
+ queryType: 'log',
124
+ filter,
125
+ maxCount,
126
+ startTime,
127
+ };
128
+ if (endTime) body.endTime = endTime;
129
+ return sdlFetch('POST', '/api/query', { body });
130
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * MCP server core: transport-agnostic.
3
+ *
4
+ * Exports:
5
+ * - dispatch(method, params, id): JSON-RPC method dispatcher
6
+ * - SERVER_INFO, PROTOCOL_VERSION: server identity
7
+ * - TOOL_DEFS: for diagnostics / introspection
8
+ * - ALL_TOOLS: for tests
9
+ *
10
+ * Both stdio-transport and http-transport import dispatch() and feed it
11
+ * parsed JSON-RPC envelopes. They are responsible for serialization,
12
+ * framing, and any transport-specific concerns (auth, sessions, headers).
13
+ */
14
+
15
+ import { readFileSync, existsSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ import { tools as pqTools } from '../tools/powerquery.js';
20
+ import { tools as mgmtTools } from '../tools/mgmt-console.js';
21
+ import { tools as sdlTools } from '../tools/sdl-api.js';
22
+ import { tools as haTools } from '../tools/hyperautomation.js';
23
+ import { tools as uamIngestTools } from '../tools/uam-ingest.js';
24
+ import { getCreds, hasS1Creds, hasSdlCreds } from './credentials.js';
25
+ import { hasHecCreds } from './uam-ingest.js';
26
+
27
+ const __dir = dirname(fileURLToPath(import.meta.url));
28
+
29
+ // ─── SOC context (CLAUDE.md) ──────────────────────────────────────────────────
30
+
31
+ function loadSocContext() {
32
+ const candidates = [
33
+ process.env.S1_CLAUDE_MD_PATH,
34
+ process.cwd() ? join(process.cwd(), 'CLAUDE.md') : null,
35
+ join(__dir, '..', '..', 'CLAUDE.md'), // claude-skills/CLAUDE.md (git clone)
36
+ join(__dir, '..', '..', '..', 'CLAUDE.md'),
37
+ join(__dir, '..', 'CLAUDE.md'),
38
+ ].filter(Boolean);
39
+ for (const p of candidates) {
40
+ if (existsSync(p)) {
41
+ try { return readFileSync(p, 'utf-8'); } catch { /* skip */ }
42
+ }
43
+ }
44
+ return '# SentinelOne SOC Analyst Context\n\n_CLAUDE.md not found. Place it in your Cowork project folder, or set S1_CLAUDE_MD_PATH to an absolute path._';
45
+ }
46
+
47
+ const SOC_CONTEXT = loadSocContext();
48
+
49
+ // ─── Tool registry ────────────────────────────────────────────────────────────
50
+
51
+ export const ALL_TOOLS = [...pqTools, ...mgmtTools, ...sdlTools, ...haTools, ...uamIngestTools];
52
+
53
+ export const TOOL_DEFS = ALL_TOOLS.map(t => ({
54
+ name: t.name,
55
+ description: t.description,
56
+ inputSchema: t.inputSchema,
57
+ }));
58
+
59
+ const HANDLERS = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t.handler]));
60
+
61
+ // ─── Resources ────────────────────────────────────────────────────────────────
62
+
63
+ const RESOURCES = [
64
+ {
65
+ uri: 'sentinelone://soc-context',
66
+ name: 'SOC Analyst Operating Instructions',
67
+ description: 'CLAUDE.md: Principal SOC Analyst operating instructions including investigation workflow, evidence discipline, anomaly detection playbook, MITRE ATT&CK mapping, and tool usage priorities.',
68
+ mimeType: 'text/markdown',
69
+ },
70
+ {
71
+ uri: 'sentinelone://credentials-status',
72
+ name: 'Credential Configuration Status',
73
+ description: 'Reports which credentials are configured and which API surfaces are available.',
74
+ mimeType: 'application/json',
75
+ },
76
+ ];
77
+
78
+ // ─── Prompts ──────────────────────────────────────────────────────────────────
79
+
80
+ const PROMPTS = [
81
+ {
82
+ name: 'soc_analyst',
83
+ description: 'Load the Principal SOC Analyst system context from CLAUDE.md. Call at the start of every security investigation session to prime the operating instructions, evidence discipline rules, investigation workflow, and tool usage priorities.',
84
+ arguments: [],
85
+ },
86
+ {
87
+ name: 'session_init',
88
+ description: 'Structured session initialization prompt. Triggers mandatory data-source enumeration, alert triage, and schema discovery in parallel, mirroring the standard engagement workflow from the SOC playbook.',
89
+ arguments: [],
90
+ },
91
+ ];
92
+
93
+ // ─── MCP envelope helpers ─────────────────────────────────────────────────────
94
+
95
+ export const SERVER_INFO = {
96
+ name: 's1-secops-mcp-server',
97
+ version: '1.3.0',
98
+ };
99
+
100
+ export const PROTOCOL_VERSION = '2024-11-05';
101
+
102
+ export function ok(id, result) {
103
+ return { jsonrpc: '2.0', id, result };
104
+ }
105
+
106
+ export function err(id, code, message, data) {
107
+ return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } };
108
+ }
109
+
110
+ function log(...args) {
111
+ process.stderr.write('[s1-secops-mcp] ' + args.join(' ') + '\n');
112
+ }
113
+
114
+ // ─── dispatch ────────────────────────────────────────────────────────────────
115
+
116
+ export async function dispatch(method, params, id) {
117
+ switch (method) {
118
+
119
+ case 'initialize': {
120
+ return ok(id, {
121
+ protocolVersion: PROTOCOL_VERSION,
122
+ capabilities: {
123
+ resources: { subscribe: false, listChanged: false },
124
+ tools: { listChanged: false },
125
+ prompts: { listChanged: false },
126
+ },
127
+ serverInfo: SERVER_INFO,
128
+ instructions: 'SentinelOne MCP server providing PowerQuery, Mgmt Console API, SDL API, and Hyperautomation tools. Load the "soc_analyst" prompt at session start for full operating context.',
129
+ });
130
+ }
131
+
132
+ case 'ping': {
133
+ return ok(id, {});
134
+ }
135
+
136
+ case 'resources/list': {
137
+ return ok(id, { resources: RESOURCES });
138
+ }
139
+
140
+ case 'resources/read': {
141
+ const uri = params?.uri;
142
+ if (uri === 'sentinelone://soc-context') {
143
+ return ok(id, {
144
+ contents: [{ uri, mimeType: 'text/markdown', text: SOC_CONTEXT }],
145
+ });
146
+ }
147
+ if (uri === 'sentinelone://credentials-status') {
148
+ const c = getCreds();
149
+ const status = {
150
+ s1MgmtApi: {
151
+ configured: hasS1Creds(),
152
+ consoleUrl: c.S1_CONSOLE_URL ? c.S1_CONSOLE_URL.replace(/https?:\/\//, '').split('.')[0] + '...' : 'NOT SET',
153
+ tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
154
+ },
155
+ sdlApi: {
156
+ configured: hasSdlCreds(),
157
+ tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
158
+ },
159
+ uamIngestApi: {
160
+ configured: hasHecCreds(),
161
+ hecUrl: c.S1_HEC_INGEST_URL || 'NOT SET (add S1_HEC_INGEST_URL to credentials.json)',
162
+ tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
163
+ },
164
+ };
165
+ return ok(id, {
166
+ contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(status, null, 2) }],
167
+ });
168
+ }
169
+ return err(id, -32002, `Resource not found: ${uri}`);
170
+ }
171
+
172
+ case 'prompts/list': {
173
+ return ok(id, { prompts: PROMPTS });
174
+ }
175
+
176
+ case 'prompts/get': {
177
+ const name = params?.name;
178
+
179
+ if (name === 'soc_analyst') {
180
+ return ok(id, {
181
+ description: 'Principal SOC Analyst operating instructions from CLAUDE.md',
182
+ messages: [
183
+ {
184
+ role: 'user',
185
+ content: {
186
+ type: 'text',
187
+ text: `You are operating as a Principal SOC Analyst. Load and follow the instructions below precisely.\n\n${SOC_CONTEXT}`,
188
+ },
189
+ },
190
+ ],
191
+ });
192
+ }
193
+
194
+ if (name === 'session_init') {
195
+ return ok(id, {
196
+ description: 'Structured session initialization',
197
+ messages: [
198
+ {
199
+ role: 'user',
200
+ content: {
201
+ type: 'text',
202
+ text: `Begin a new SOC analyst session. Follow this initialization sequence:
203
+
204
+ 1. Call \`powerquery_enumerate_sources\` to discover active SDL data sources (MANDATORY, never assume sources from prior sessions).
205
+ 2. In parallel, call \`uam_list_alerts\` with status="NEW" to pull untriaged active alerts. Valid status values are "NEW", "IN_PROGRESS", or "RESOLVED" (there is no "OPEN" status; it silently returns 0 results). Omit the status argument to pull the most recent alerts across all states.
206
+ 3. For each discovered data source not already in the schema registry, plan schema discovery via \`powerquery_schema_discover\`.
207
+ 4. Report: (a) active data sources list, (b) untriaged (NEW) alert count and top 5 by severity, (c) which sources need schema discovery.
208
+
209
+ Apply the SOC analyst context from the soc_analyst prompt throughout.`,
210
+ },
211
+ },
212
+ ],
213
+ });
214
+ }
215
+
216
+ return err(id, -32002, `Prompt not found: ${name}`);
217
+ }
218
+
219
+ case 'tools/list': {
220
+ return ok(id, { tools: TOOL_DEFS });
221
+ }
222
+
223
+ case 'tools/call': {
224
+ const toolName = params?.name;
225
+ const args = params?.arguments || {};
226
+
227
+ if (!toolName) {
228
+ return err(id, -32602, 'Missing tool name');
229
+ }
230
+
231
+ const handler = HANDLERS[toolName];
232
+ if (!handler) {
233
+ return err(id, -32602, `Tool not found: ${toolName}`);
234
+ }
235
+
236
+ try {
237
+ const output = await handler(args);
238
+ const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
239
+ return ok(id, {
240
+ content: [{ type: 'text', text }],
241
+ isError: false,
242
+ });
243
+ } catch (e) {
244
+ log(`Tool error [${toolName}]:`, e.message);
245
+ return ok(id, {
246
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
247
+ isError: true,
248
+ });
249
+ }
250
+ }
251
+
252
+ case 'notifications/initialized':
253
+ case 'initialized':
254
+ return null;
255
+
256
+ default: {
257
+ if (id !== undefined) {
258
+ return err(id, -32601, `Method not found: ${method}`);
259
+ }
260
+ return null;
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * stdio transport: reads JSON-RPC messages from stdin (one per line),
3
+ * dispatches via the provided dispatcher, writes responses to stdout.
4
+ *
5
+ * This is the default transport and the one used by Claude Desktop,
6
+ * Claude Code, Claude Cowork, and any other client launched via the
7
+ * `npx` / `node index.js` invocation pattern.
8
+ */
9
+
10
+ import { createInterface } from 'readline';
11
+ import { err as makeErr } from './server-core.js';
12
+
13
+ function log(...args) {
14
+ process.stderr.write('[s1-secops-mcp] ' + args.join(' ') + '\n');
15
+ }
16
+
17
+ function send(obj) {
18
+ process.stdout.write(JSON.stringify(obj) + '\n');
19
+ }
20
+
21
+ export async function startStdio(dispatch) {
22
+ log('Transport: stdio');
23
+
24
+ const rl = createInterface({ input: process.stdin, terminal: false });
25
+
26
+ let inFlight = 0;
27
+ let stdinClosed = false;
28
+
29
+ function maybeExit() {
30
+ if (stdinClosed && inFlight === 0) {
31
+ log('All requests complete, exiting.');
32
+ process.exit(0);
33
+ }
34
+ }
35
+
36
+ rl.on('line', async (line) => {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) return;
39
+
40
+ let msg;
41
+ try {
42
+ msg = JSON.parse(trimmed);
43
+ } catch (e) {
44
+ send(makeErr(null, -32700, `Parse error: ${e.message}`));
45
+ return;
46
+ }
47
+
48
+ const isNotification = msg.id === undefined;
49
+
50
+ inFlight++;
51
+ try {
52
+ const response = await dispatch(msg.method, msg.params, msg.id);
53
+ if (response !== null && !isNotification) {
54
+ send(response);
55
+ }
56
+ } catch (e) {
57
+ log('Unhandled dispatch error:', e.message, e.stack);
58
+ if (!isNotification) {
59
+ send(makeErr(msg.id ?? null, -32603, `Internal error: ${e.message}`));
60
+ }
61
+ } finally {
62
+ inFlight--;
63
+ maybeExit();
64
+ }
65
+ });
66
+
67
+ rl.on('close', () => {
68
+ log('stdin closed, waiting for in-flight requests...');
69
+ stdinClosed = true;
70
+ maybeExit();
71
+ });
72
+
73
+ process.on('SIGINT', () => {
74
+ log('SIGINT received, exiting.');
75
+ process.exit(0);
76
+ });
77
+ }