@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.
@@ -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
@@ -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. "2056852093198736293"). Omit for all accessible scopes.',
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',
@@ -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
  },