@pmoses-s1/sentinelone-mcp 1.1.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 1.1.0 — 2026-05-28
3
+ ## 1.2.0 — 2026-06-11
4
+
5
+ ### Added
6
+ - **`hec_ingest` tool** — raw-log/event ingestion into the Singularity Data Lake via the HEC (HTTP Event Collector) endpoint (`/services/collector/raw` and `/services/collector/event`). Supports `parser` (-> `?sourcetype=`), custom `fields` (query params), **required** `scope` (S1-Scope header), gzip compression, and `isParsed` (-> `?isParsed=true`, indexes already-structured JSON with no SDL parser). Replaces the removed `sdl_upload_logs`. Validated live across the full HEC matrix (both endpoints, gzip on/off, parser field extraction, multi-line, batched, reserved-field handling, scope enforcement, isParsed). Grounded in the S-26.1 HEC docs (p.4723-4726).
7
+
8
+ ### Removed
9
+ - **`sdl_upload_logs` tool** plus the underlying SDL `uploadLogs`/`addEvents` library functions and `SDL_LOG_WRITE_KEY` plumbing. SDL raw-log ingestion moves to the HEC path (`hec_ingest`). The `sentinelone-sdl-api` skill is now query + configuration only; the `sentinelone-sdl-log-parser` validation loop uses HEC ingest.
10
+
11
+ ### Changed
12
+ - Tool count unchanged at 26 (removed `sdl_upload_logs`, added `hec_ingest`).
13
+ - Skill docs corrected: scheduled detection rules bind the Target Asset via `entityMappings` ("Entity column mapping"); the full scheduled-rule option set (UI <-> API) is catalogued in `sentinelone-powerquery/references/detection-rules.md`.
14
+
15
+
16
+ ## 1.1.0 — 2026-05-28 (rebuilt 2026-05-31)
17
+
18
+ ### Fixed (rebuild)
19
+ - **`s1_api_get` now auto-injects `isLegacy=false` for `/cloud-detection/rules` listings.** Without `isLegacy=false` the S1 API silently omits `queryType="scheduled"` PowerQuery rules from the response — no error, no warning, the response just lies by omission. The handler now guards against this when the caller forgets, and the tool description loudly flags the requirement. This eliminates the "I see zero scheduled detections" failure mode that was producing wrong verdicts when listing Custom Detection rules. Same `1.1.0` version per the rebuild request.
4
20
 
5
21
  ### Added
6
22
  - **Streamable HTTP transport.** New `--transport http` mode (default stays `stdio`). Single-endpoint POST `/mcp` per the MCP 2024-11-05 spec, plus `/healthz` for load balancer probes. Implementation is pure `node:http`, no new dependencies.
package/README.md CHANGED
@@ -31,7 +31,7 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
31
31
  | SDL API | `sdl_get_file` | sentinelone-sdl-api / sdl-dashboard / sdl-log-parser |
32
32
  | SDL API | `sdl_list_files` | sentinelone-sdl-api / sdl-dashboard / sdl-log-parser |
33
33
  | SDL API | `sdl_put_file` | sentinelone-sdl-api / sdl-dashboard / sdl-log-parser |
34
- | SDL API | `sdl_upload_logs` | sentinelone-sdl-api / sdl-log-parser |
34
+ | SDL API | `hec_ingest` | sentinelone-sdl-api / sdl-log-parser |
35
35
  | Hyperautomation | `ha_archive_workflow` | sentinelone-hyperautomation |
36
36
  | Hyperautomation | `ha_export_workflow` | sentinelone-hyperautomation |
37
37
  | Hyperautomation | `ha_get_workflow` | sentinelone-hyperautomation |
@@ -52,9 +52,11 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
52
52
 
53
53
  ## Quick install
54
54
 
55
- Two paths, pick one:
55
+ Three paths, pick the one that matches your setup:
56
56
 
57
- ### Easiest: `npx` via Claude Desktop / Claude Code / Cowork
57
+ ### A. Local single-user via `npx` (Claude Desktop / Claude Code / Cowork)
58
+
59
+ MCP runs as a subprocess on your machine, talking SentinelOne APIs directly. Credentials live in the Claude config `env` block.
58
60
 
59
61
  Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
60
62
 
@@ -70,7 +72,6 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
70
72
  "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
71
73
  "SDL_XDR_URL": "https://xdr.us1.sentinelone.net",
72
74
  "SDL_LOG_READ_KEY": "...",
73
- "SDL_LOG_WRITE_KEY": "...",
74
75
  "SDL_CONFIG_READ_KEY": "...",
75
76
  "SDL_CONFIG_WRITE_KEY": "..."
76
77
  }
@@ -81,21 +82,55 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
81
82
 
82
83
  Restart Claude Desktop. `npx -y` caches the package on first launch.
83
84
 
84
- ### Reproducible: install script
85
+ ### B. Reproducible: install script
85
86
 
86
87
  ```bash
87
88
  curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/install.sh | bash
88
89
  ```
89
90
 
90
- This sets up a per-user npm prefix if needed, installs the package, drops a credentials skeleton at `~/.config/sentinelone/credentials.json` (mode 0600), and prints the wiring instructions for Claude Desktop.
91
+ Sets up a per-user npm prefix if needed, installs the package, drops a credentials skeleton at `~/.config/sentinelone/credentials.json` (mode 0600), and prints the wiring instructions for Claude Desktop.
91
92
 
92
93
  For VM deployments, the same script in `--server` mode does everything (system user, systemd unit, initial bearer token, service start). See [deploy/README.md](./deploy/README.md).
93
94
 
95
+ ### C. Claude Desktop connecting to a team VM (stdio bridge)
96
+
97
+ When the MCP is running as a shared service on a Linux VM (deploy topology C in [deploy/README.md](./deploy/README.md)) and you're connecting from Claude Desktop, you need a small stdio↔HTTPS shim because Claude Desktop's stable build doesn't accept `type: "http"` configs. (Claude Cowork and Claude Code do; see "[Calling the HTTP endpoint directly](#calling-the-http-endpoint-directly)" for the native `type: "http"` form.)
98
+
99
+ The bridge is a 40-line zero-dependency Node script shipped at [`deploy/bridge/sentinelone-mcp-bridge.mjs`](./deploy/bridge/sentinelone-mcp-bridge.mjs).
100
+
101
+ Each team member installs the script once:
102
+
103
+ ```bash
104
+ mkdir -p ~/.local/bin
105
+ curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/bridge/sentinelone-mcp-bridge.mjs \
106
+ -o ~/.local/bin/sentinelone-mcp-bridge.mjs
107
+ chmod +x ~/.local/bin/sentinelone-mcp-bridge.mjs
108
+ ```
109
+
110
+ Then adds this block to `claude_desktop_config.json`:
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "sentinelone-mcp": {
116
+ "command": "node",
117
+ "args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
118
+ "env": {
119
+ "MCP_URL": "https://mcp.example.internal:8764/mcp",
120
+ "MCP_BEARER": "<your personal bearer token>"
121
+ }
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ Cmd+Q and reopen Claude Desktop. SentinelOne credentials live on the VM in `/etc/sentinelone-mcp/credentials.json` — only the bearer token sits in each user's local Claude config. Full setup + smoke-test instructions at [`deploy/bridge/README.md`](./deploy/bridge/README.md).
128
+
94
129
  ## Credentials
95
130
 
96
131
  `S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, and UAM tools (16 of the 26).
97
132
 
98
- `S1_HEC_INGEST_URL` is **required** for the three UAM Ingest tools (`uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert`). Without it those tools error at call time, the rest still work.
133
+ `S1_HEC_INGEST_URL` is **required** for the three UAM Ingest tools (`uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert`) and for `hec_ingest`. Without it those tools error at call time; the rest still work.
99
134
 
100
135
  `SDL_*` keys gate the SDL tools as follows:
101
136
 
@@ -103,10 +138,9 @@ For VM deployments, the same script in `--server` mode does everything (system u
103
138
  |----------|-------------|--------------|
104
139
  | `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery tools |
105
140
  | `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM tools |
106
- | `S1_HEC_INGEST_URL` | HEC ingest host, e.g. `https://ingest.us1.sentinelone.net` | `uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert` |
141
+ | `S1_HEC_INGEST_URL` | HEC ingest host, e.g. `https://ingest.us1.sentinelone.net` | `uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert`, `hec_ingest` |
107
142
  | `SDL_XDR_URL` | SDL tenant URL, e.g. `https://xdr.us1.sentinelone.net` | All `sdl_*` tools and `powerquery_schema_discover` |
108
143
  | `SDL_LOG_READ_KEY` | SDL Log Read key | SDL query operations |
109
- | `SDL_LOG_WRITE_KEY` | SDL Log Write key (console JWT NOT accepted by this endpoint) | `sdl_upload_logs` |
110
144
  | `SDL_CONFIG_READ_KEY` | SDL Config Read key | `sdl_list_files`, `sdl_get_file` |
111
145
  | `SDL_CONFIG_WRITE_KEY` | SDL Config Write key | `sdl_put_file`, `sdl_delete_file` |
112
146
 
@@ -182,6 +216,221 @@ Every authenticated HTTP request emits a structured stderr line that systemd cap
182
216
  [audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
183
217
  ```
184
218
 
219
+ ## Calling the HTTP endpoint directly
220
+
221
+ You don't need an MCP client library. The HTTP transport is plain JSON-RPC 2.0 over `POST`, with bearer auth in the `Authorization` header. Any HTTP client works — `curl`, Python `requests`, Node `fetch`, Go `net/http`, etc. This is how you'd integrate from a custom script, a CI job, or a non-MCP tool that just needs to call SentinelOne via the same wrapped surface.
222
+
223
+ > **Ready-made check:** [`scripts/smoke-test-http.sh`](./scripts/smoke-test-http.sh) runs the six contract checks documented below (healthz, initialize, tools/list, tools/call, bad-bearer 401, unknown-method JSON-RPC error) and prints PASS/FAIL. Run as `MCP_HOST=<host:port> MCP_BEARER=<token> bash sentinelone-mcp/scripts/smoke-test-http.sh`. Good for new-team-member onboarding and post-rotation validation.
224
+
225
+ ### Endpoint contract
226
+
227
+ | Item | Value |
228
+ |---|---|
229
+ | Method | `POST` |
230
+ | URL | `https://<host>:<port>/mcp` (path is `/mcp` by default; configurable with `--path`) |
231
+ | `Content-Type` | `application/json` |
232
+ | `Authorization` | `Bearer <token>` (one of the tokens in `MCP_BEARER_TOKENS_FILE`) |
233
+ | Body | JSON-RPC 2.0 envelope |
234
+ | Response | JSON-RPC 2.0 envelope (`result` on success, `error` on failure) |
235
+
236
+ Health probe (no auth, no JSON): `GET /healthz` returns `200 ok`.
237
+
238
+ ### Initialize, then list tools, then call one (curl)
239
+
240
+ ```bash
241
+ HOST=mcp.s1.internal
242
+ TOKEN='your-bearer-token-here'
243
+
244
+ # 1. initialize (required first call per spec; advertises protocol version and capabilities)
245
+ curl -s -X POST "https://$HOST/mcp" \
246
+ -H "Authorization: Bearer $TOKEN" \
247
+ -H "Content-Type: application/json" \
248
+ -d '{
249
+ "jsonrpc": "2.0",
250
+ "id": 1,
251
+ "method": "initialize",
252
+ "params": {
253
+ "protocolVersion": "2024-11-05",
254
+ "capabilities": {},
255
+ "clientInfo": { "name": "my-script", "version": "1.0" }
256
+ }
257
+ }'
258
+ # -> {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{...},"serverInfo":{...}}}
259
+
260
+ # 2. list every tool the server exposes
261
+ curl -s -X POST "https://$HOST/mcp" \
262
+ -H "Authorization: Bearer $TOKEN" \
263
+ -H "Content-Type: application/json" \
264
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
265
+ | jq '.result.tools | length'
266
+ # -> 26
267
+
268
+ # 3. call a tool (here: list custom detection rules with the mandatory isLegacy=false)
269
+ curl -s -X POST "https://$HOST/mcp" \
270
+ -H "Authorization: Bearer $TOKEN" \
271
+ -H "Content-Type: application/json" \
272
+ -d '{
273
+ "jsonrpc": "2.0",
274
+ "id": 3,
275
+ "method": "tools/call",
276
+ "params": {
277
+ "name": "s1_api_get",
278
+ "arguments": {
279
+ "path": "/web/api/v2.1/cloud-detection/rules",
280
+ "params": { "isLegacy": false, "limit": 50 }
281
+ }
282
+ }
283
+ }' \
284
+ | jq '.result.content[0].text | fromjson | .pagination.totalItems'
285
+ ```
286
+
287
+ ### Python (requests)
288
+
289
+ ```python
290
+ import json
291
+ import requests
292
+
293
+ URL = "https://mcp.s1.internal/mcp"
294
+ TOKEN = "your-bearer-token-here"
295
+ HEADERS = {
296
+ "Authorization": f"Bearer {TOKEN}",
297
+ "Content-Type": "application/json",
298
+ }
299
+
300
+ def rpc(method, params=None, id=1):
301
+ body = {"jsonrpc": "2.0", "id": id, "method": method}
302
+ if params is not None:
303
+ body["params"] = params
304
+ r = requests.post(URL, headers=HEADERS, json=body, timeout=30)
305
+ r.raise_for_status()
306
+ return r.json()
307
+
308
+ # initialize once per session
309
+ rpc("initialize", {
310
+ "protocolVersion": "2024-11-05",
311
+ "capabilities": {},
312
+ "clientInfo": {"name": "python-client", "version": "1.0"},
313
+ }, id=0)
314
+
315
+ # list tools
316
+ tools = rpc("tools/list", id=1)["result"]["tools"]
317
+ print(f"{len(tools)} tools available")
318
+
319
+ # call a tool
320
+ resp = rpc("tools/call", {
321
+ "name": "powerquery_run",
322
+ "arguments": {
323
+ "query": "dataSource.name=* | group count=count() by dataSource.name | sort -count | limit 10",
324
+ "hours": 24,
325
+ },
326
+ }, id=2)
327
+
328
+ # Tool results live in result.content[0].text as a JSON string.
329
+ payload = json.loads(resp["result"]["content"][0]["text"])
330
+ print(json.dumps(payload, indent=2))
331
+ ```
332
+
333
+ ### Node (built-in fetch, Node 18+)
334
+
335
+ ```javascript
336
+ const URL = 'https://mcp.s1.internal/mcp';
337
+ const TOKEN = process.env.MCP_BEARER;
338
+
339
+ async function rpc(method, params, id = 1) {
340
+ const body = { jsonrpc: '2.0', id, method };
341
+ if (params !== undefined) body.params = params;
342
+ const res = await fetch(URL, {
343
+ method: 'POST',
344
+ headers: {
345
+ 'Authorization': `Bearer ${TOKEN}`,
346
+ 'Content-Type': 'application/json',
347
+ },
348
+ body: JSON.stringify(body),
349
+ });
350
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
351
+ return res.json();
352
+ }
353
+
354
+ await rpc('initialize', {
355
+ protocolVersion: '2024-11-05',
356
+ capabilities: {},
357
+ clientInfo: { name: 'node-client', version: '1.0' },
358
+ }, 0);
359
+
360
+ const { result } = await rpc('tools/list', null, 1);
361
+ console.log(`${result.tools.length} tools available`);
362
+
363
+ const call = await rpc('tools/call', {
364
+ name: 'uam_list_alerts',
365
+ arguments: { first: 20, status: 'NEW' },
366
+ }, 2);
367
+ console.log(JSON.parse(call.result.content[0].text));
368
+ ```
369
+
370
+ ### JSON-RPC envelope shapes
371
+
372
+ **Success response:**
373
+
374
+ ```json
375
+ {
376
+ "jsonrpc": "2.0",
377
+ "id": 2,
378
+ "result": { ... method-specific payload ... }
379
+ }
380
+ ```
381
+
382
+ **Error response:**
383
+
384
+ ```json
385
+ {
386
+ "jsonrpc": "2.0",
387
+ "id": 2,
388
+ "error": {
389
+ "code": -32602,
390
+ "message": "Tool not found: bad_tool_name"
391
+ }
392
+ }
393
+ ```
394
+
395
+ **Notifications** (one-way messages with no `id`, e.g. `notifications/initialized`):
396
+
397
+ ```bash
398
+ curl -i -s -X POST "https://$HOST/mcp" \
399
+ -H "Authorization: Bearer $TOKEN" \
400
+ -H "Content-Type: application/json" \
401
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
402
+ # -> HTTP/2 202 (no body, per JSON-RPC spec)
403
+ ```
404
+
405
+ ### Error codes you'll actually see
406
+
407
+ | HTTP | JSON-RPC code | Meaning |
408
+ |---|---|---|
409
+ | 200 | (none, has `result`) | Success |
410
+ | 200 | `-32601` | Method not found (e.g. typo in method name) |
411
+ | 200 | `-32602` | Invalid params (tool not found, missing required arg) |
412
+ | 200 | `-32603` | Tool handler threw — upstream S1 API error usually |
413
+ | 400 | `-32700` | Parse error (malformed JSON body) |
414
+ | 400 | `-32600` | Invalid request (e.g. JSON-RPC batch — not supported) |
415
+ | 401 | `-32001` | Missing or invalid bearer token |
416
+ | 405 | (none) | Wrong HTTP method on `/mcp` (only POST is accepted) |
417
+ | 413 | `-32600` | Body exceeds 4 MB |
418
+
419
+ ### Tool inputs and outputs
420
+
421
+ Every tool's input schema is documented in the `tools/list` response (look at the `inputSchema` JSON Schema on each tool). The response shape is always:
422
+
423
+ ```json
424
+ {
425
+ "content": [
426
+ { "type": "text", "text": "<JSON-encoded result>" }
427
+ ],
428
+ "isError": false
429
+ }
430
+ ```
431
+
432
+ Parse `content[0].text` as JSON to get the actual data the tool returned. Tool-level errors set `isError: true` and put the error text in the same field.
433
+
185
434
  ## CLI reference
186
435
 
187
436
  ```
@@ -208,7 +457,7 @@ sentinelone-mcp/
208
457
  auth.js Bearer token allowlist with SIGHUP reload
209
458
  credentials.js S1 + SDL credential resolution
210
459
  s1.js Mgmt REST + LRQ PowerQuery + Purple AI + UAM GraphQL
211
- sdl.js SDL config files + V1 query + uploadLogs
460
+ sdl.js SDL config files + V1 query
212
461
  uam-ingest.js HEC alert/indicator ingestion
213
462
  tools/
214
463
  powerquery.js PowerQuery enumerate/run/schema-discover
@@ -236,7 +485,6 @@ sentinelone-mcp/
236
485
  | UAM GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
237
486
  | UAM HEC ingest | `Authorization: Bearer <jwt>` | `S1_CONSOLE_API_TOKEN` |
238
487
  | SDL config ops | `Authorization: Bearer <key>` | `SDL_CONFIG_WRITE_KEY` or console JWT |
239
- | SDL uploadLogs | `Authorization: Bearer <key>` | `SDL_LOG_WRITE_KEY` only (console JWT rejected) |
240
488
 
241
489
  ## Testing
242
490
 
package/deploy/README.md CHANGED
@@ -28,7 +28,6 @@ Then edit `~/.config/sentinelone/credentials.json` with your real values:
28
28
  "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
29
29
  "SDL_XDR_URL": "https://xdr.us1.sentinelone.net",
30
30
  "SDL_LOG_READ_KEY": "...",
31
- "SDL_LOG_WRITE_KEY": "...",
32
31
  "SDL_CONFIG_READ_KEY": "...",
33
32
  "SDL_CONFIG_WRITE_KEY": "..."
34
33
  }
@@ -256,6 +255,96 @@ curl -s http://127.0.0.1:8765/healthz # behind the proxy
256
255
  curl -s https://mcp.s1.internal/healthz # in front of the proxy
257
256
  ```
258
257
 
258
+ ## Connecting Claude Desktop to a remote MCP
259
+
260
+ Claude Desktop's `claude_desktop_config.json` only accepts stdio-based MCP servers in current stable builds; the `type: "http"` form gets rejected with "not valid MCP server configuration" on load. To connect Claude Desktop to your VM's HTTPS endpoint, use the bridge script shipped in this repo at [`bridge/sentinelone-mcp-bridge.mjs`](./bridge/sentinelone-mcp-bridge.mjs) — a 40-line zero-dependency Node script that translates Claude Desktop's stdio into POST requests against the MCP HTTP endpoint.
261
+
262
+ Each team member drops the script anywhere on their machine (typically `~/.local/bin/sentinelone-mcp-bridge.mjs`) and points Claude Desktop at it:
263
+
264
+ ```json
265
+ {
266
+ "mcpServers": {
267
+ "sentinelone-mcp": {
268
+ "command": "node",
269
+ "args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
270
+ "env": {
271
+ "MCP_URL": "https://mcp.s1.internal/mcp",
272
+ "MCP_BEARER": "<your personal bearer token>"
273
+ }
274
+ }
275
+ }
276
+ }
277
+ ```
278
+
279
+ Then Cmd+Q and reopen Claude Desktop. See [`bridge/README.md`](./bridge/README.md) for install, smoke-test, and troubleshooting steps. Claude Cowork users can keep using the native `type: "http"` config (it supports remote HTTP MCPs in current builds) — only Claude Desktop needs the bridge.
280
+
281
+ ## AWS-specific gotchas
282
+
283
+ Five things that bit during real deployment to an EC2 instance. None are blockers, but knowing them up front saves hours.
284
+
285
+ ### EC2 public DNS is unstable without an Elastic IP
286
+
287
+ Stopping and starting an instance assigns a new public IPv4 address and a new public DNS name (`ec2-<new-ip>.<region>.compute.amazonaws.com`). Every ACME-issued cert, every `claude_desktop_config.json`, and every Caddyfile that referenced the old hostname breaks. Allocate an Elastic IP in EC2 → Elastic IPs → Allocate → Associate before issuing certs. Free while attached to a running instance.
288
+
289
+ The instance's `*.compute.internal` DNS name (e.g. `ip-172-31-7-227.ap-southeast-2.compute.internal`) is the VPC-internal name and is **not** reachable from outside the VPC. It can't be used for ACME validation or for clients on the public internet.
290
+
291
+ ### Let's Encrypt refuses `*.amazonaws.com` by policy
292
+
293
+ If you try to issue a cert for the EC2 public DNS, LE returns:
294
+
295
+ ```
296
+ HTTP 400 urn:ietf:params:acme:error:rejectedIdentifier
297
+ The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
298
+ ```
299
+
300
+ Caddy auto-falls back to **ZeroSSL** (also free, also publicly trusted, no policy block on `amazonaws.com`). Use the email-shorthand form `tls <email>` and Caddy handles the fallback transparently. The right end state is a cert with `issuer=ZeroSSL ECC DV SSL CA 2` — verify with:
301
+
302
+ ```bash
303
+ echo | openssl s_client -connect $HOST:8764 -servername $HOST 2>/dev/null \
304
+ | grep -E "^(issuer=|verify return code)"
305
+ ```
306
+
307
+ For long-term peace of mind, use a real domain instead (Route 53 A record pointing at the Elastic IP) — both LE and ZeroSSL issue without restriction and the hostname survives instance replacement.
308
+
309
+ ### Caddyfile: don't mix `tls` shorthand with `issuer acme` block
310
+
311
+ ```caddyfile
312
+ # WRONG — Caddy errors: "cannot mix issuer subdirective with other issuer-specific subdirectives"
313
+ tls prithvi@example.com {
314
+ issuer acme {
315
+ disable_http_challenge
316
+ }
317
+ }
318
+ ```
319
+
320
+ The shorthand `tls <email>` implicitly configures an ACME issuer. Combining it with an explicit `issuer acme { ... }` block conflicts. Pick one form:
321
+
322
+ ```caddyfile
323
+ # Form 1: shorthand (requires port 80 open for HTTP-01)
324
+ tls prithvi@example.com
325
+
326
+ # Form 2: explicit block (gives you knobs like disable_http_challenge)
327
+ tls {
328
+ issuer acme {
329
+ email prithvi@example.com
330
+ disable_http_challenge
331
+ }
332
+ }
333
+ ```
334
+
335
+ ### `tls internal` produces a Caddy CA cert, not a public one
336
+
337
+ If you see ACME succeed in milliseconds rather than ~10-30 seconds, look at the cert: it was likely issued by Caddy's local CA, not by an external ACME server. The give-aways are an instant log line and `no OCSP server specified in certificate` warnings (public CAs always embed OCSP URLs). `tls internal` is fine for private-network deployments with cert distribution to clients, but doesn't help when you want public trust.
338
+
339
+ ### systemd hardening that breaks Node V8 JIT
340
+
341
+ The hardened service file we ship omits two systemd directives that would otherwise be useful:
342
+
343
+ - `MemoryDenyWriteExecute=true`
344
+ - `LockPersonality=true`
345
+
346
+ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them causes the service to silently SIGTRAP at startup with `Result: core-dump` and ~5 MB peak memory — no useful log output. If you customize the unit, leave both off.
347
+
259
348
  ## Troubleshooting
260
349
 
261
350
  | Symptom | Likely cause | Fix |
@@ -0,0 +1,93 @@
1
+ # Claude Desktop bridge for the remote MCP
2
+
3
+ A small stdio↔HTTPS proxy so Claude Desktop can talk to a team-shared `sentinelone-mcp` server running on a VM. Each team member runs the bridge on their own machine; the bridge sends bearer-authed POSTs to the shared MCP endpoint.
4
+
5
+ ## Why this exists
6
+
7
+ Claude Desktop's `claude_desktop_config.json` only accepts stdio-based MCP servers in current stable builds. Adding a remote server via `type: "http"` gets rejected with "not valid MCP server configuration". The bridge wraps the remote HTTPS endpoint as a local stdio process, which Claude Desktop accepts.
8
+
9
+ Claude Cowork and Claude Code don't need this — both support `type: "http"` natively.
10
+
11
+ ## What's in the box
12
+
13
+ - [`sentinelone-mcp-bridge.mjs`](./sentinelone-mcp-bridge.mjs) — the script. 40 lines, zero external dependencies. Requires Node.js 18+ (uses the built-in `fetch`).
14
+
15
+ ## Install (per team member, one-time)
16
+
17
+ ```bash
18
+ # Download the script
19
+ mkdir -p ~/.local/bin
20
+ curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/bridge/sentinelone-mcp-bridge.mjs \
21
+ -o ~/.local/bin/sentinelone-mcp-bridge.mjs
22
+ chmod +x ~/.local/bin/sentinelone-mcp-bridge.mjs
23
+
24
+ # Confirm Node is on PATH
25
+ node --version # must be 18.0.0 or newer
26
+ ```
27
+
28
+ ## Configure Claude Desktop
29
+
30
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Add the `sentinelone-mcp` block:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "sentinelone-mcp": {
36
+ "command": "node",
37
+ "args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
38
+ "env": {
39
+ "MCP_URL": "https://mcp.s1.internal/mcp",
40
+ "MCP_BEARER": "<your personal bearer token>"
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ `MCP_URL` is whatever URL your team's admin gave you (it should end in `/mcp`). `MCP_BEARER` is your personal bearer token from `/etc/sentinelone-mcp/bearer-tokens.json` on the VM.
48
+
49
+ Quit Claude Desktop fully (Cmd+Q on macOS, not just close the window) and reopen. The 26 tools should appear in the tools list.
50
+
51
+ ## Smoke test (without Claude Desktop)
52
+
53
+ ```bash
54
+ export MCP_URL='https://mcp.s1.internal/mcp'
55
+ export MCP_BEARER='<your token>'
56
+
57
+ # initialize round trip
58
+ echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli","version":"1"}}}' \
59
+ | node ~/.local/bin/sentinelone-mcp-bridge.mjs
60
+
61
+ # tools/list (should return 26)
62
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
63
+ | node ~/.local/bin/sentinelone-mcp-bridge.mjs \
64
+ | python3 -c 'import sys,json; print(len(json.load(sys.stdin)["result"]["tools"]), "tools")'
65
+ ```
66
+
67
+ ## Troubleshooting
68
+
69
+ | Symptom | Likely cause | Fix |
70
+ |---|---|---|
71
+ | Claude Desktop log: `MCP error -32001: Request timed out` | `MCP_URL` unreachable or wrong path | `curl -sS $MCP_URL` from the same machine. Confirm the URL ends with `/mcp`. |
72
+ | `fetch error: ... UNABLE_TO_GET_ISSUER_CERT_LOCALLY` | Server is using a private CA (e.g. `tls internal`); Node doesn't read the system keychain | Use a publicly-trusted cert on the server (Let's Encrypt or ZeroSSL). See [../README.md#aws-specific-gotchas](../README.md#aws-specific-gotchas). |
73
+ | `bridge fetch error: ... ENOTFOUND` | DNS doesn't resolve `MCP_URL` host | Verify with `nslookup` or `dig`. If using an AWS public DNS, it may have changed; re-check the EC2 console. |
74
+ | 401 from upstream in the log | Wrong / revoked bearer token | Ask the admin for a fresh token; replace `MCP_BEARER`. |
75
+ | Bridge starts but Claude Desktop times out | Node version too old | `node --version` — need 18+. Built-in fetch was added in 18. |
76
+
77
+ ## How it works
78
+
79
+ ```
80
+ +----------------+ stdio JSON-RPC +--------+ HTTPS POST /mcp +------+
81
+ | Claude Desktop | <------------------------------> | bridge | <---------------------------> | VM |
82
+ +----------------+ +--------+ Bearer auth, JSON in/out +------+
83
+ ```
84
+
85
+ The bridge reads one JSON-RPC message per line from stdin, POSTs it to `MCP_URL` with the `Authorization: Bearer <token>` header, and writes the JSON-RPC reply to stdout. JSON-RPC notifications (messages with no `id`) get no reply, matching the spec. Errors get translated to a JSON-RPC error envelope so Claude Desktop sees something useful instead of a hung process.
86
+
87
+ There is no session state, no buffering, and no SDK dependency — it's just stdin → fetch → stdout.
88
+
89
+ ## Security notes
90
+
91
+ - The bearer token is stored in plaintext in `claude_desktop_config.json`. Treat that file as a secret on each laptop.
92
+ - Rotate bearer tokens by editing `/etc/sentinelone-mcp/bearer-tokens.json` on the VM (see [../README.md#day-2-operations](../README.md#day-2-operations)) and reissuing the new value to each team member.
93
+ - TLS verification uses Node's bundled CA store. For privately-issued certs, set `NODE_EXTRA_CA_CERTS=/path/to/root.pem` in the `env` block. Better: use a publicly-trusted cert on the server so no client-side trust is needed.
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tiny stdio -> HTTPS bridge for the SentinelOne MCP server running on a VM.
4
+ *
5
+ * Why this exists:
6
+ * Claude Desktop's stable claude_desktop_config.json only accepts stdio-based
7
+ * MCP servers; the type:"http" form is rejected. This bridge translates
8
+ * Claude Desktop's stdio JSON-RPC into POSTs against the MCP HTTP endpoint,
9
+ * so a single shared HTTPS MCP can serve a whole team's Claude Desktops.
10
+ *
11
+ * Required environment:
12
+ * MCP_URL full HTTPS endpoint, e.g. https://mcp.s1.internal/mcp
13
+ * MCP_BEARER bearer token (no "Bearer " prefix; the script adds it)
14
+ *
15
+ * Optional:
16
+ * none. Zero external dependencies. Requires Node.js 18+ for built-in fetch.
17
+ *
18
+ * Configure Claude Desktop:
19
+ * {
20
+ * "mcpServers": {
21
+ * "sentinelone-mcp": {
22
+ * "command": "node",
23
+ * "args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
24
+ * "env": {
25
+ * "MCP_URL": "https://mcp.s1.internal/mcp",
26
+ * "MCP_BEARER": "<your bearer token>"
27
+ * }
28
+ * }
29
+ * }
30
+ * }
31
+ *
32
+ * Smoke test:
33
+ * MCP_URL=... MCP_BEARER=... bash -c '
34
+ * echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}" \
35
+ * | node sentinelone-mcp-bridge.mjs'
36
+ * # -> JSON-RPC response with 26 tools in result.tools[]
37
+ */
38
+
39
+ import { createInterface } from 'node:readline';
40
+
41
+ const URL = process.env.MCP_URL || (() => { throw new Error('MCP_URL not set'); })();
42
+ const BEARER = process.env.MCP_BEARER || (() => { throw new Error('MCP_BEARER not set'); })();
43
+
44
+ const log = (...a) => process.stderr.write('[bridge] ' + a.join(' ') + '\n');
45
+
46
+ log('starting; target', URL);
47
+
48
+ const rl = createInterface({ input: process.stdin, terminal: false });
49
+
50
+ let inFlight = 0;
51
+ let stdinClosed = false;
52
+ function maybeExit() {
53
+ if (stdinClosed && inFlight === 0) {
54
+ log('all requests complete, exiting');
55
+ process.exit(0);
56
+ }
57
+ }
58
+
59
+ rl.on('line', async (line) => {
60
+ const raw = line.trim();
61
+ if (!raw) return;
62
+
63
+ let msg;
64
+ try { msg = JSON.parse(raw); }
65
+ catch (e) { log('bad json from stdin:', e.message); return; }
66
+
67
+ const isNotification = msg.id === undefined;
68
+
69
+ inFlight++;
70
+ try {
71
+ const res = await fetch(URL, {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json',
75
+ 'Authorization': `Bearer ${BEARER}`,
76
+ 'Accept': 'application/json',
77
+ },
78
+ body: raw,
79
+ });
80
+
81
+ if (isNotification) return;
82
+
83
+ const text = await res.text();
84
+ if (!res.ok) log(`HTTP ${res.status} from upstream: ${text.slice(0, 200)}`);
85
+ process.stdout.write(text.trimEnd() + '\n');
86
+ } catch (e) {
87
+ const cause = e.cause ? ` cause=${e.cause.code || e.cause.message || JSON.stringify(e.cause)}` : '';
88
+ log('fetch error:', e.message, cause);
89
+ if (!isNotification) {
90
+ process.stdout.write(JSON.stringify({
91
+ jsonrpc: '2.0',
92
+ id: msg.id ?? null,
93
+ error: { code: -32603, message: `bridge fetch error: ${e.message}${cause}` },
94
+ }) + '\n');
95
+ }
96
+ } finally {
97
+ inFlight--;
98
+ maybeExit();
99
+ }
100
+ });
101
+
102
+ rl.on('close', () => { log('stdin closed, draining...'); stdinClosed = true; maybeExit(); });
103
+ process.on('SIGINT', () => process.exit(0));
104
+ process.on('SIGTERM', () => process.exit(0));
@@ -9,6 +9,17 @@
9
9
  # tls /path/to/cert.pem /path/to/key.pem
10
10
  # Bring your own cert from an internal PKI.
11
11
  #
12
+ # AWS users: Let's Encrypt refuses to issue for *.amazonaws.com hostnames by
13
+ # policy. When you use the `tls <email>` shorthand, Caddy automatically falls
14
+ # back to ZeroSSL (also free, also publicly trusted, no policy block). The
15
+ # end-state cert should have `issuer=ZeroSSL ECC DV SSL CA 2`. Port 80 must
16
+ # be open in the SG for HTTP-01 to complete.
17
+ #
18
+ # DO NOT mix `tls <email>` with an `issuer acme { ... }` block — Caddy errors
19
+ # with "cannot mix issuer subdirective with other issuer-specific subdirectives".
20
+ # Pick one form: the email shorthand on its own line, OR a full `tls { ... }`
21
+ # block with the email moved inside the `issuer acme` stanza. Not both.
22
+ #
12
23
  # Why Caddy and not nginx? Caddy auto-renews certs, has zero config for SSE
13
24
  # streaming (flush_interval handled), and is one binary with no system Python
14
25
  # or Lua dependency. Equally valid alternative: nginx with the snippet at the
package/deploy/install.sh CHANGED
@@ -141,7 +141,6 @@ if [[ ! -f "$CRED_PATH" ]]; then
141
141
  "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
142
142
  "SDL_XDR_URL": "https://xdr.us1.sentinelone.net",
143
143
  "SDL_LOG_READ_KEY": "",
144
- "SDL_LOG_WRITE_KEY": "",
145
144
  "SDL_CONFIG_READ_KEY": "",
146
145
  "SDL_CONFIG_WRITE_KEY": ""
147
146
  }
@@ -29,6 +29,9 @@ Restart=on-failure
29
29
  RestartSec=5
30
30
 
31
31
  # Hardening
32
+ # NOTE: MemoryDenyWriteExecute=true and LockPersonality=true are incompatible
33
+ # with Node.js V8 JIT (W+X mappings). Including them causes a silent SIGTRAP
34
+ # at startup with no useful log output. Leave them off for Node services.
32
35
  NoNewPrivileges=true
33
36
  PrivateTmp=true
34
37
  ProtectSystem=strict
@@ -39,8 +42,6 @@ ProtectControlGroups=true
39
42
  RestrictNamespaces=true
40
43
  RestrictRealtime=true
41
44
  RestrictSUIDSGID=true
42
- LockPersonality=true
43
- MemoryDenyWriteExecute=true
44
45
  SystemCallArchitectures=native
45
46
  ReadWritePaths=
46
47
 
package/index.js CHANGED
@@ -111,7 +111,6 @@ ENVIRONMENT
111
111
  uam_post_indicators, uam_post_alert.
112
112
  SDL_XDR_URL SDL tenant URL.
113
113
  SDL_LOG_READ_KEY SDL Log Read key.
114
- SDL_LOG_WRITE_KEY SDL Log Write key. Required for sdl_upload_logs.
115
114
  SDL_CONFIG_READ_KEY SDL Config Read key.
116
115
  SDL_CONFIG_WRITE_KEY SDL Config Write key. Required for sdl_put_file.
117
116
  S1_CREDS_FILE Explicit path to a credentials.json file.
@@ -107,7 +107,6 @@ export function getCreds() {
107
107
  S1_CONSOLE_API_TOKEN: e('S1_CONSOLE_API_TOKEN') || e('S1_API_TOKEN'),
108
108
  S1_HEC_INGEST_URL: e('S1_HEC_INGEST_URL'),
109
109
  SDL_XDR_URL: e('SDL_XDR_URL') || e('SDL_BASE_URL'),
110
- SDL_LOG_WRITE_KEY: e('SDL_LOG_WRITE_KEY'),
111
110
  SDL_CONFIG_WRITE_KEY: e('SDL_CONFIG_WRITE_KEY'),
112
111
  SDL_CONFIG_READ_KEY: e('SDL_CONFIG_READ_KEY'),
113
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
+ }
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.
@@ -156,7 +156,6 @@ export async function dispatch(method, params, id) {
156
156
  configured: hasSdlCreds(),
157
157
  xdrUrl: c.SDL_XDR_URL || 'NOT SET',
158
158
  configWriteKey: !!c.SDL_CONFIG_WRITE_KEY,
159
- logWriteKey: !!c.SDL_LOG_WRITE_KEY,
160
159
  },
161
160
  uamIngestApi: {
162
161
  configured: hasHecCreds(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmoses-s1/sentinelone-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context. Stdio or Streamable HTTP transport with per-user bearer auth for team deployments.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -44,7 +44,7 @@ const TOOL_SKILL = {
44
44
  sdl_get_file: 'sentinelone-sdl-api / sdl-dashboard / sdl-log-parser',
45
45
  sdl_put_file: 'sentinelone-sdl-api / sdl-dashboard / sdl-log-parser',
46
46
  sdl_delete_file: 'sentinelone-sdl-api',
47
- sdl_upload_logs: 'sentinelone-sdl-api / sdl-log-parser',
47
+ hec_ingest: 'sentinelone-sdl-api / sdl-log-parser',
48
48
  // Hyperautomation
49
49
  ha_list_workflows: 'sentinelone-hyperautomation',
50
50
  ha_get_workflow: 'sentinelone-hyperautomation',
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-test-http.sh -- generic HTTP smoke test for sentinelone-mcp.
4
+ #
5
+ # Exercises the public contract end-to-end:
6
+ # 1. healthz returns 200 (no auth)
7
+ # 2. initialize returns the expected protocol version and server info
8
+ # 3. tools/list returns 26 tools
9
+ # 4. tools/call s1_api_get works (uses /agents/count as a cheap probe)
10
+ # 5. bad bearer returns HTTP 401
11
+ # 6. unknown method returns JSON-RPC error -32601 inside a 200 envelope
12
+ #
13
+ # Useful for new team members verifying their setup, or for re-checking the
14
+ # deployment after a config change, a cert rotation, or an MCP version bump.
15
+ #
16
+ # Run as:
17
+ # MCP_HOST=mcp.s1.internal:8764 \
18
+ # MCP_BEARER=your-bearer-token \
19
+ # bash sentinelone-mcp/scripts/smoke-test-http.sh
20
+ #
21
+ # Or set defaults at the top of the script and run with no args.
22
+
23
+ set -uo pipefail
24
+
25
+ HOST="${MCP_HOST:-}"
26
+ TOKEN="${MCP_BEARER:-}"
27
+
28
+ if [[ -z "$HOST" || -z "$TOKEN" ]]; then
29
+ cat >&2 <<EOF
30
+ Usage:
31
+ MCP_HOST=<host:port> MCP_BEARER=<token> bash $0
32
+
33
+ Both env vars are required.
34
+ EOF
35
+ exit 2
36
+ fi
37
+
38
+ for tool in curl jq; do
39
+ if ! command -v "$tool" >/dev/null 2>&1; then
40
+ echo "Missing dependency: $tool" >&2
41
+ exit 3
42
+ fi
43
+ done
44
+
45
+ URL="https://$HOST/mcp"
46
+ AUTH="Authorization: Bearer $TOKEN"
47
+ JSON="Content-Type: application/json"
48
+
49
+ FAILED=0
50
+ fail() { echo " FAIL: $*" >&2; FAILED=$((FAILED+1)); }
51
+ pass() { echo " PASS: $*"; }
52
+
53
+ echo "=== 1. healthz (no auth) ==="
54
+ HEALTHZ_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$HOST/healthz")
55
+ [[ "$HEALTHZ_CODE" == "200" ]] && pass "healthz returned 200" || fail "healthz returned $HEALTHZ_CODE (expected 200)"
56
+
57
+ echo
58
+ echo "=== 2. initialize ==="
59
+ INIT_BODY=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
60
+ "jsonrpc": "2.0",
61
+ "id": 1,
62
+ "method": "initialize",
63
+ "params": {
64
+ "protocolVersion": "2024-11-05",
65
+ "capabilities": {},
66
+ "clientInfo": { "name": "smoke-test", "version": "1" }
67
+ }
68
+ }')
69
+ PROTO=$(echo "$INIT_BODY" | jq -r '.result.protocolVersion // "missing"')
70
+ NAME=$( echo "$INIT_BODY" | jq -r '.result.serverInfo.name // "missing"')
71
+ VER=$( echo "$INIT_BODY" | jq -r '.result.serverInfo.version // "missing"')
72
+ [[ "$PROTO" == "2024-11-05" ]] && pass "protocolVersion=$PROTO" || fail "protocolVersion=$PROTO"
73
+ [[ "$NAME" == "sentinelone-mcp-server" ]] && pass "serverInfo.name=$NAME" || fail "serverInfo.name=$NAME"
74
+ [[ "$VER" != "missing" ]] && pass "serverInfo.version=$VER" || fail "serverInfo.version missing"
75
+
76
+ echo
77
+ echo "=== 3. tools/list count ==="
78
+ TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
79
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
80
+ | jq '.result.tools | length')
81
+ [[ "$TOOLS_COUNT" == "26" ]] && pass "tools/list returned 26 tools" || fail "tools/list returned $TOOLS_COUNT"
82
+
83
+ echo
84
+ echo "=== 4. tools/call s1_api_get on /agents/count ==="
85
+ AGENTS_TOTAL=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
86
+ "jsonrpc": "2.0",
87
+ "id": 3,
88
+ "method": "tools/call",
89
+ "params": {
90
+ "name": "s1_api_get",
91
+ "arguments": { "path": "/web/api/v2.1/agents/count" }
92
+ }
93
+ }' | jq -r '.result.content[0].text' | jq -r '.data.total // "missing"')
94
+ if [[ "$AGENTS_TOTAL" =~ ^[0-9]+$ ]]; then
95
+ pass "s1_api_get returned $AGENTS_TOTAL agents"
96
+ else
97
+ fail "s1_api_get returned data.total=$AGENTS_TOTAL"
98
+ fi
99
+
100
+ echo
101
+ echo "=== 5. bad bearer (expect HTTP 401) ==="
102
+ BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$URL" \
103
+ -H "Authorization: Bearer wrong-token-of-sufficient-length-1234567890" -H "$JSON" \
104
+ -d '{"jsonrpc":"2.0","id":99,"method":"tools/list"}')
105
+ [[ "$BAD_CODE" == "401" ]] && pass "bad bearer rejected with 401" || fail "bad bearer returned $BAD_CODE (expected 401)"
106
+
107
+ echo
108
+ echo "=== 6. method not found (expect -32601) ==="
109
+ ERR_CODE=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
110
+ -d '{"jsonrpc":"2.0","id":4,"method":"does/not/exist"}' \
111
+ | jq -r '.error.code // "missing"')
112
+ [[ "$ERR_CODE" == "-32601" ]] && pass "unknown method returned -32601" || fail "unknown method returned code=$ERR_CODE"
113
+
114
+ echo
115
+ echo "=== Summary ==="
116
+ if [[ "$FAILED" -eq 0 ]]; then
117
+ echo "All checks passed."
118
+ exit 0
119
+ else
120
+ echo "$FAILED check(s) failed."
121
+ exit 1
122
+ fi
@@ -21,11 +21,35 @@
21
21
 
22
22
  import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus } from '../lib/s1.js';
23
23
 
24
+ /**
25
+ * Defensive normalization for GET /cloud-detection/rules calls.
26
+ *
27
+ * Without `isLegacy=false`, the S1 API silently omits queryType="scheduled"
28
+ * PowerQuery rules from the response — no error, no warning, the response
29
+ * just lies by omission. Promoting "empty response" to "tenant has zero
30
+ * scheduled detections" without isLegacy=false is the failure mode this
31
+ * guard exists to prevent. Exported for unit testing.
32
+ */
33
+ export function normalizeS1ApiGetParams(path, params) {
34
+ const p = { ...(params || {}) };
35
+ if (
36
+ typeof path === 'string' &&
37
+ /\/cloud-detection\/rules(\/|\?|$)/.test(path) &&
38
+ p.isLegacy === undefined &&
39
+ p.is_legacy === undefined
40
+ ) {
41
+ p.isLegacy = false;
42
+ }
43
+ return p;
44
+ }
45
+
24
46
  export const tools = [
25
47
  // ─── s1_api_get ───────────────────────────────────────────────────────────
26
48
  {
27
49
  name: 's1_api_get',
28
- description: `Generic GET request to the SentinelOne Management Console REST API (v2.1). Use for ALL read operations: listing, counting, and exporting. The S1 API uses GET for every read — listing, counting, and exporting are always GET, never POST. The path should start with /web/api/v2.1/. Returns raw JSON response. For paginated endpoints, use the cursor or skip/limit params. Count examples: path="/web/api/v2.1/agents/count" returns {"data":{"total":N}}; path="/web/api/v2.1/threats" params={"countOnly":true} returns pagination.totalItems. Export example: path="/web/api/v2.1/threats/export" (no extra params). Get agents by IDs: path="/web/api/v2.1/agents" params={"ids":"<id1>,<id2>"} (comma-separated query param).`,
50
+ description: `Generic GET request to the SentinelOne Management Console REST API (v2.1). Use for ALL read operations: listing, counting, and exporting. The S1 API uses GET for every read — listing, counting, and exporting are always GET, never POST. The path should start with /web/api/v2.1/. Returns raw JSON response. For paginated endpoints, use the cursor or skip/limit params. Count examples: path="/web/api/v2.1/agents/count" returns {"data":{"total":N}}; path="/web/api/v2.1/threats" params={"countOnly":true} returns pagination.totalItems. Export example: path="/web/api/v2.1/threats/export" (no extra params). Get agents by IDs: path="/web/api/v2.1/agents" params={"ids":"<id1>,<id2>"} (comma-separated query param).
51
+
52
+ ⚠️ CLOUD-DETECTION RULES — MANDATORY isLegacy=false: For ANY GET on /cloud-detection/rules (listing, name search, queryType filter, scope filter) you MUST pass params.isLegacy=false. Without it the API silently omits queryType="scheduled" PowerQuery rules and returns only events-type rules — there is no error, no warning, the response just lies by omission. This handler auto-injects isLegacy=false when it sees a /cloud-detection/rules path and the caller forgot it, but always pass it explicitly so it shows up in audit logs. Promoting "empty response" to "tenant has zero scheduled detections" without isLegacy=false is the failure mode this guard exists to prevent.`,
29
53
  inputSchema: {
30
54
  type: 'object',
31
55
  properties: {
@@ -35,14 +59,17 @@ export const tools = [
35
59
  },
36
60
  params: {
37
61
  type: 'object',
38
- description: 'Query string parameters as key-value pairs, e.g. {"limit": 20, "sortBy": "createdAt"}.',
62
+ description: 'Query string parameters as key-value pairs, e.g. {"limit": 20, "sortBy": "createdAt"}. For /cloud-detection/rules listings ALWAYS include {"isLegacy": false}; the handler auto-injects it as a safety net but explicit is better.',
39
63
  additionalProperties: true,
40
64
  },
41
65
  },
42
66
  required: ['path'],
43
67
  },
44
68
  async handler({ path, params = {} }) {
45
- const result = await apiGet(path, params);
69
+ // Safety net: /cloud-detection/rules silently hides scheduled
70
+ // PowerQuery rules unless isLegacy=false is passed.
71
+ const normalized = normalizeS1ApiGetParams(path, params);
72
+ const result = await apiGet(path, normalized);
46
73
  return JSON.stringify(result, null, 2);
47
74
  },
48
75
  },
package/tools/sdl-api.js CHANGED
@@ -6,10 +6,11 @@
6
6
  * sdl_get_file Get file content and version (parsers, dashboards, alerts, lookups)
7
7
  * sdl_put_file Deploy or update a config file (with optimistic locking)
8
8
  * sdl_delete_file Delete a config file
9
- * sdl_upload_logs Upload raw log events to SDL (requires Log Write key)
9
+ * hec_ingest Ingest raw logs/events into SDL via the HEC endpoint (replaces uploadLogs)
10
10
  */
11
11
 
12
- import { listFiles, getFile, putFile, deleteFile, uploadLogs } from '../lib/sdl.js';
12
+ import { listFiles, getFile, putFile, deleteFile } from '../lib/sdl.js';
13
+ import { hecIngest } from '../lib/hec.js';
13
14
 
14
15
  export const tools = [
15
16
  // ─── sdl_list_files ───────────────────────────────────────────────────────
@@ -99,34 +100,25 @@ export const tools = [
99
100
  },
100
101
  },
101
102
 
102
- // ─── sdl_upload_logs ──────────────────────────────────────────────────────
103
+ // ─── hec_ingest ─────────────────────────────────────────────────────────────
103
104
  {
104
- name: 'sdl_upload_logs',
105
- description: `Upload raw log events to SDL via the uploadLogs endpoint (plain text, newline-separated). Used for ingesting custom telemetry, testing parsers, and one-off log imports. Requires an SDL Log Write Access key (SDL_LOG_WRITE_KEY) the console JWT is NOT accepted for this endpoint. Max 6 MB per request, 10 GB per day. Pair with a parser at logfile= to apply field extraction.`,
105
+ name: 'hec_ingest',
106
+ description: `Ingest raw logs/events into the SentinelOne AI SIEM Singularity Data Lake via the HEC (HTTP Event Collector) endpoint. Applies a named parser via ?sourcetype and lands the data in the Data Lake for Event Search, PowerQuery, and detection rules. Replaces the removed sdl_upload_logs. NOT UAM ingest (the uam_* tools post OCSF indicators/alerts to /v1/* on the same host but a separate API). Per S-26.1 HEC docs: POST {S1_HEC_INGEST_URL}/services/collector/raw, Authorization: Bearer <S1_CONSOLE_API_TOKEN>, query params become fields, gzip recommended, 10 MB uncompressed per request.`,
106
107
  inputSchema: {
107
108
  type: 'object',
108
109
  properties: {
109
- logContent: {
110
- type: 'string',
111
- description: 'Raw log text, newline-separated. Each line becomes a separate SDL event.',
112
- },
113
- parser: {
114
- type: 'string',
115
- description: 'Parser name to apply to the uploaded events (matches the "parser" header). Omit to use the default parser.',
116
- },
117
- logfile: {
118
- type: 'string',
119
- description: 'Logical logfile identifier sent as the "logfile" header, e.g. "myapp/access.log". Used by parsers to route events.',
120
- },
121
- serverHost: {
122
- type: 'string',
123
- description: 'Source host name, sent as the "server-host" header.',
124
- },
110
+ logContent: { type: 'string', description: 'Raw log text. For the /raw endpoint, newline-separated lines become separate events.' },
111
+ parser: { type: 'string', description: 'Parser name, sent as the ?sourcetype= query param. Omit to skip parsing (structured JSON on /event auto-parses).' },
112
+ fields: { type: 'object', description: 'Extra key-value pairs sent as query params; each key becomes a field in the UI, e.g. {"server":"dev","region":"ap1"}. Avoid HEC-reserved names (event, time, host, source, sourcetype, index, fields) as keys; use the parser arg to set sourcetype.' },
113
+ scope: { type: 'string', description: 'REQUIRED. accountId or "accountId:siteId" sent as the S1-Scope header; HEC rejects requests without it (400 "Missing S1-Scope header").' },
114
+ endpoint: { type: 'string', enum: ['raw','event'], description: "HEC endpoint: 'raw' (default, raw text) or 'event' (structured JSON)." },
115
+ compress: { type: 'boolean', description: 'gzip the body (Content-Encoding: gzip). Default true.' },
116
+ isParsed: { type: 'boolean', description: 'For /event with structured JSON: set ?isParsed=true so SDL indexes the JSON fields directly, with no SDL parser. Confirmed working.' },
125
117
  },
126
- required: ['logContent'],
118
+ required: ['logContent', 'scope'],
127
119
  },
128
- async handler({ logContent, parser, logfile, serverHost }) {
129
- const result = await uploadLogs(logContent, { parser, logfile, serverHost });
120
+ async handler({ logContent, parser, fields, scope, endpoint, compress, isParsed }) {
121
+ const result = await hecIngest(logContent, { parser, fields, scope, endpoint, compress, isParsed });
130
122
  return JSON.stringify(result, null, 2);
131
123
  },
132
124
  },