@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.
- package/CHANGELOG.md +60 -0
- package/README.md +422 -102
- package/deploy/README.md +366 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +104 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +264 -0
- package/deploy/systemd/sentinelone-mcp.service +58 -0
- package/index.js +135 -312
- package/lib/auth.js +161 -0
- package/lib/credentials.js +18 -7
- package/lib/hec.js +128 -0
- package/lib/http-transport.js +223 -0
- package/lib/s1.js +1 -1
- package/lib/sdl.js +0 -32
- package/lib/server-core.js +264 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +1 -1
- package/package.json +10 -4
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +122 -0
- package/scripts/test-mac.sh +179 -0
- package/tools/hyperautomation.js +1 -1
- package/tools/mgmt-console.js +30 -3
- package/tools/sdl-api.js +16 -24
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Mac validation script for sentinelone-mcp v1.1.0.
|
|
4
|
+
#
|
|
5
|
+
# Runs the same test matrix as Linux:
|
|
6
|
+
# - syntax-check every .js/.mjs file
|
|
7
|
+
# - npm test (smoke + stdio + HTTP suites, 22 assertions)
|
|
8
|
+
# - regen:readme --check (no doc drift)
|
|
9
|
+
# - sanity-run the server in stdio and HTTP modes
|
|
10
|
+
#
|
|
11
|
+
# Run this from the sentinelone-mcp/ directory on your Mac:
|
|
12
|
+
#
|
|
13
|
+
# cd ~/path/to/claude-skills/sentinelone-mcp
|
|
14
|
+
# bash scripts/test-mac.sh
|
|
15
|
+
#
|
|
16
|
+
# All checks should pass with green PASS markers. Any FAIL line should be
|
|
17
|
+
# reported back so the issue can be diagnosed.
|
|
18
|
+
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
PASS_COUNT=0
|
|
22
|
+
FAIL_COUNT=0
|
|
23
|
+
|
|
24
|
+
green() { printf '\033[32m%s\033[0m' "$*"; }
|
|
25
|
+
red() { printf '\033[31m%s\033[0m' "$*"; }
|
|
26
|
+
bold() { printf '\033[1m%s\033[0m' "$*"; }
|
|
27
|
+
|
|
28
|
+
pass() { printf ' %s %s\n' "$(green PASS)" "$1"; PASS_COUNT=$((PASS_COUNT+1)); }
|
|
29
|
+
fail() { printf ' %s %s\n %s\n' "$(red FAIL)" "$1" "${2:-}"; FAIL_COUNT=$((FAIL_COUNT+1)); }
|
|
30
|
+
step() { printf '\n%s\n' "$(bold "$1")"; }
|
|
31
|
+
|
|
32
|
+
# ─── 1. Environment ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
step "1. Environment"
|
|
35
|
+
if [[ "$(uname -s)" != "Darwin" ]]; then
|
|
36
|
+
echo " WARNING: this script targets macOS but you're on $(uname -s). Continuing anyway."
|
|
37
|
+
fi
|
|
38
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
39
|
+
fail "node not on PATH" "Install Node 18+: brew install node@20"
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
NODE_MAJOR="$(node --version | sed 's/^v\([0-9]*\).*/\1/')"
|
|
43
|
+
if [[ "$NODE_MAJOR" -lt 18 ]]; then
|
|
44
|
+
fail "Node $(node --version) is too old" "Need Node 18+"
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
pass "Node $(node --version) on $(uname -srm)"
|
|
48
|
+
|
|
49
|
+
if [[ ! -f "index.js" ]]; then
|
|
50
|
+
fail "Not in the sentinelone-mcp directory" "cd to .../claude-skills/sentinelone-mcp first"
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
pass "running from sentinelone-mcp directory"
|
|
54
|
+
|
|
55
|
+
# ─── 2. Syntax check ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
step "2. Syntax check"
|
|
58
|
+
for f in index.js lib/*.js tests/*.mjs scripts/*.mjs; do
|
|
59
|
+
[[ -f "$f" ]] || continue
|
|
60
|
+
if node --check "$f" 2>/dev/null; then
|
|
61
|
+
pass "$f"
|
|
62
|
+
else
|
|
63
|
+
out="$(node --check "$f" 2>&1)"
|
|
64
|
+
fail "$f" "$out"
|
|
65
|
+
fi
|
|
66
|
+
done
|
|
67
|
+
|
|
68
|
+
# ─── 3. npm test (22 assertions across smoke + stdio + HTTP) ─────────────────
|
|
69
|
+
|
|
70
|
+
step "3. Test suite (npm test)"
|
|
71
|
+
# Node 18-22 default to the TAP reporter, Node 23+ default to spec. Both
|
|
72
|
+
# reporters print the same passing assertions but with different summary
|
|
73
|
+
# lines. We check exit code first (authoritative) and then count assertions.
|
|
74
|
+
TEST_LOG="/tmp/mcp-test-mac.npm-test.log"
|
|
75
|
+
if npm test >"$TEST_LOG" 2>&1; then
|
|
76
|
+
# Count passing assertions across both reporter formats:
|
|
77
|
+
# TAP: "ok 1 - <name>"
|
|
78
|
+
# spec: "✔ <name>" (U+2714 HEAVY CHECK MARK)
|
|
79
|
+
TAP_OKS="$(grep -cE '^ok [0-9]+' "$TEST_LOG" || true)"
|
|
80
|
+
SPEC_OKS="$(grep -cE '^[[:space:]]*✔' "$TEST_LOG" || true)"
|
|
81
|
+
TOTAL=$((TAP_OKS + SPEC_OKS))
|
|
82
|
+
if [[ "$TOTAL" -ge 22 ]]; then
|
|
83
|
+
pass "$TOTAL passing assertions (npm test exit 0)"
|
|
84
|
+
else
|
|
85
|
+
fail "npm test exited 0 but only $TOTAL passing lines found" "$(tail -30 "$TEST_LOG")"
|
|
86
|
+
fi
|
|
87
|
+
else
|
|
88
|
+
fail "npm test exited non-zero" "$(tail -30 "$TEST_LOG")"
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# ─── 4. README/code drift check ───────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
step "4. README/code drift check"
|
|
94
|
+
if npm run regen:readme -- --check >/dev/null 2>&1; then
|
|
95
|
+
pass "README tools table in sync with ALL_TOOLS"
|
|
96
|
+
else
|
|
97
|
+
fail "README tools table is stale" "Run: npm run regen:readme"
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
# ─── 5. CLI flag sanity ───────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
step "5. CLI flag sanity"
|
|
103
|
+
if [[ "$(node index.js --version 2>/dev/null)" == "1.1.0" ]]; then
|
|
104
|
+
pass "--version returns 1.1.0"
|
|
105
|
+
else
|
|
106
|
+
fail "--version did not return 1.1.0" "Got: $(node index.js --version 2>&1)"
|
|
107
|
+
fi
|
|
108
|
+
if node index.js --help 2>&1 | grep -q "sentinelone-mcp 1.1.0"; then
|
|
109
|
+
pass "--help renders"
|
|
110
|
+
else
|
|
111
|
+
fail "--help broken" "$(node index.js --help 2>&1 | head -5)"
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
# ─── 6. stdio round-trip ──────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
step "6. stdio round-trip"
|
|
117
|
+
STDIO_REPLY="$(printf '%s\n%s\n' \
|
|
118
|
+
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"mac-test","version":"1"}}}' \
|
|
119
|
+
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
|
|
120
|
+
| node index.js 2>/dev/null)"
|
|
121
|
+
|
|
122
|
+
TOOL_COUNT="$(echo "$STDIO_REPLY" | tail -n 1 | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
|
|
123
|
+
if [[ "$TOOL_COUNT" == "26" ]]; then
|
|
124
|
+
pass "stdio tools/list returned 26 tools"
|
|
125
|
+
else
|
|
126
|
+
fail "stdio tools/list returned $TOOL_COUNT (expected 26)" "$STDIO_REPLY"
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
# ─── 7. HTTP transport round-trip ─────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
step "7. HTTP transport round-trip"
|
|
132
|
+
PORT=$((10000 + RANDOM % 1000))
|
|
133
|
+
node index.js --transport http --port "$PORT" --host 127.0.0.1 >/tmp/mcp-test-mac.log 2>&1 &
|
|
134
|
+
SERVER_PID=$!
|
|
135
|
+
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
|
|
136
|
+
|
|
137
|
+
# Wait for healthz
|
|
138
|
+
for i in $(seq 1 30); do
|
|
139
|
+
if curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
|
|
140
|
+
pass "HTTP server up on port $PORT"
|
|
141
|
+
break
|
|
142
|
+
fi
|
|
143
|
+
sleep 0.2
|
|
144
|
+
if [[ "$i" -eq 30 ]]; then
|
|
145
|
+
fail "HTTP server did not start within 6s" "$(cat /tmp/mcp-test-mac.log)"
|
|
146
|
+
exit 1
|
|
147
|
+
fi
|
|
148
|
+
done
|
|
149
|
+
|
|
150
|
+
HTTP_REPLY="$(curl -sf -X POST "http://127.0.0.1:$PORT/mcp" \
|
|
151
|
+
-H 'Content-Type: application/json' \
|
|
152
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')"
|
|
153
|
+
HTTP_COUNT="$(echo "$HTTP_REPLY" | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
|
|
154
|
+
if [[ "$HTTP_COUNT" == "26" ]]; then
|
|
155
|
+
pass "HTTP tools/list returned 26 tools"
|
|
156
|
+
else
|
|
157
|
+
fail "HTTP tools/list returned $HTTP_COUNT" "$HTTP_REPLY"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# Auth: with no token loaded the server should accept unauthenticated requests
|
|
161
|
+
# (single-user local-only mode). Confirmed by the 26-tools response above.
|
|
162
|
+
pass "no-auth mode allows requests (single-user local)"
|
|
163
|
+
|
|
164
|
+
kill $SERVER_PID 2>/dev/null || true
|
|
165
|
+
trap - EXIT
|
|
166
|
+
|
|
167
|
+
# ─── Summary ──────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
step "Summary"
|
|
170
|
+
printf " Passed: %s\n" "$(green "$PASS_COUNT")"
|
|
171
|
+
if [[ "$FAIL_COUNT" -gt 0 ]]; then
|
|
172
|
+
printf " Failed: %s\n\n" "$(red "$FAIL_COUNT")"
|
|
173
|
+
echo "If any FAILs above, paste them so the issue can be diagnosed."
|
|
174
|
+
exit 1
|
|
175
|
+
else
|
|
176
|
+
printf " Failed: %s\n\n" "$FAIL_COUNT"
|
|
177
|
+
echo "$(green 'All checks passed on macOS.')"
|
|
178
|
+
exit 0
|
|
179
|
+
fi
|
package/tools/hyperautomation.js
CHANGED
|
@@ -50,7 +50,7 @@ export const tools = [
|
|
|
50
50
|
},
|
|
51
51
|
siteIds: {
|
|
52
52
|
type: 'string',
|
|
53
|
-
description: 'Comma-separated site IDs to scope results to (e.g. "
|
|
53
|
+
description: 'Comma-separated site IDs to scope results to (e.g. "<site-id-1>,<site-id-2>"). Omit for all accessible scopes.',
|
|
54
54
|
},
|
|
55
55
|
sortBy: {
|
|
56
56
|
type: 'string',
|
package/tools/mgmt-console.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
// ───
|
|
103
|
+
// ─── hec_ingest ─────────────────────────────────────────────────────────────
|
|
103
104
|
{
|
|
104
|
-
name: '
|
|
105
|
-
description: `
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
},
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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,
|
|
129
|
-
const result = await
|
|
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
|
},
|