@pmoses-s1/s1-secops-mcp 1.3.1 → 1.3.2
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 +43 -0
- package/README.md +7 -6
- package/deploy/README.md +2 -2
- package/lib/credentials.js +0 -1
- package/lib/sdl.js +206 -25
- package/lib/server-core.js +1 -1
- package/package.json +2 -2
- package/tools/sdl-api.js +60 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.2 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
Config-file operations move from the legacy REST endpoints to GraphQL. Tool count unchanged at
|
|
6
|
+
26, and all four tool names are unchanged, so no caller needs to change.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **`sdl_list_files` no longer returns an incomplete listing.** The REST `/sdl/api/listFiles`
|
|
11
|
+
endpoint omits every udoId-addressed dashboard. Measured live on `usea1-purple`: REST returned
|
|
12
|
+
1,914 paths against `configFiles`' 2,264, a 350-file gap consisting entirely of `/dashboards/`
|
|
13
|
+
files that carry a `udoId`. REST `getFile` on any of them returns `success/noSuchFile`. The
|
|
14
|
+
practical impact was a false negative: a dashboard that existed in the console was reported as
|
|
15
|
+
not found. All four config-file tools now run on `POST /sdl/v2/graphql`.
|
|
16
|
+
- **`sdl_list_files` description no longer claims to return "all" files.** It did not, and the
|
|
17
|
+
claim was load-bearing: an agent reading it had no reason to look further after an empty result.
|
|
18
|
+
- **`sdl_get_file` / `sdl_put_file` / `sdl_delete_file` can now address dashboards.** New `udoId`
|
|
19
|
+
parameter. The console's Configuration Files grid displays a dashboard as
|
|
20
|
+
`/dashboards/id/<udoId>/<name>`; that string is not a path, and reading it as one returns
|
|
21
|
+
`no file exists at path`.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`lib/sdl.js`: `configFiles`, `configFile`, `putConfigFile`, `deleteConfigFile`** over
|
|
26
|
+
`POST /sdl/v2/graphql`. GraphQL reports failure as HTTP 200 with an `errors` array, so the
|
|
27
|
+
wrapper raises on that array rather than trusting the status code.
|
|
28
|
+
- **Duplicate guardrail on dashboard writes.** `addConfigFile(name:)` updates in place for a
|
|
29
|
+
name-addressed file but creates a duplicate for a dashboard (both verified live). `sdl_put_file`
|
|
30
|
+
now refuses a name-addressed write to an *existing* dashboard and names the `udoId`s already
|
|
31
|
+
holding it, while still allowing the initial create, which has no `udoId` yet. The tenant this
|
|
32
|
+
was found on already carries 256 surplus dashboard copies from this behaviour, including 152 of
|
|
33
|
+
`/dashboards/AI Usage`.
|
|
34
|
+
- **`pathPrefix` filter on `sdl_list_files`**, so callers can scope to `/dashboards/` or
|
|
35
|
+
`/logParsers/` without pulling the full listing into context.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
|
|
39
|
+
- `udoId` is assigned by namespace, verified live: only `/dashboards/` files get one. `/lookups/`,
|
|
40
|
+
`/datatables/`, `/logParsers/` and `/automaticLookups` are name-addressed with `udoId` null.
|
|
41
|
+
- `expectedVersion` is honoured on `udoId`-addressed writes and ignored on name-addressed ones.
|
|
42
|
+
- A `deleteConfigFile` returning `null` with no `errors` array is success, not failure.
|
|
43
|
+
- The scoped SDL keys (`SDL_CONFIG_READ_KEY` and friends) are retired; the console API token
|
|
44
|
+
covers every SDL operation.
|
|
45
|
+
|
|
3
46
|
## 1.2.4 - 2026-07-31
|
|
4
47
|
|
|
5
48
|
Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
|
|
|
65
65
|
"mcpServers": {
|
|
66
66
|
"s1-secops-mcp": {
|
|
67
67
|
"command": "npx",
|
|
68
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
68
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.2"],
|
|
69
69
|
"env": {
|
|
70
70
|
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
|
|
71
71
|
"S1_CONSOLE_API_TOKEN": "eyJ...",
|
|
@@ -126,16 +126,16 @@ Cmd+Q and reopen Claude Desktop. SentinelOne credentials live on the VM in `/etc
|
|
|
126
126
|
|
|
127
127
|
Credential keys, where to get each one, and the two token types are documented canonically in **[docs/credentials.md](../docs/credentials.md)**. This section adds the MCP-server-specific detail: which tools each key gates, and the server's full credential-resolution order.
|
|
128
128
|
|
|
129
|
-
`S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, and
|
|
129
|
+
`S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, UAM, Hyperautomation, and SDL config-file tools (22 of the 26).
|
|
130
130
|
|
|
131
|
-
`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
|
|
131
|
+
`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`, the only four tools that need it. Without it those tools error at call time; the rest still work.
|
|
132
132
|
|
|
133
|
-
`
|
|
133
|
+
The SDL config-file tools (`sdl_list_files`, `sdl_get_file`, `sdl_put_file`, `sdl_delete_file`) are authorised by `S1_CONSOLE_API_TOKEN` against `POST <console>/sdl/v2/graphql`. The scoped SDL keys (`SDL_CONFIG_READ_KEY`, `SDL_CONFIG_WRITE_KEY`, `SDL_LOG_READ_KEY`, `SDL_LOG_WRITE_KEY`, `SDL_XDR_URL`) are retired and are no longer read.
|
|
134
134
|
|
|
135
135
|
| Variable | Description | Required for |
|
|
136
136
|
|----------|-------------|--------------|
|
|
137
|
-
| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery tools |
|
|
138
|
-
| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM tools |
|
|
137
|
+
| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery + SDL tools |
|
|
138
|
+
| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM + SDL config-file tools |
|
|
139
139
|
| `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` |
|
|
140
140
|
|
|
141
141
|
### Credential resolution order (highest priority wins)
|
|
@@ -485,6 +485,7 @@ s1-secops-mcp/
|
|
|
485
485
|
| Purple AI GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
486
486
|
| UAM GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
487
487
|
| UAM HEC ingest | `Authorization: Bearer <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
488
|
+
| SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer <jwt>`, do not send `s1-scope` (returns 403) | `S1_CONSOLE_API_TOKEN` |
|
|
488
489
|
|
|
489
490
|
## Testing
|
|
490
491
|
|
package/deploy/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Or, equivalently, by package name without the install:
|
|
|
56
56
|
"mcpServers": {
|
|
57
57
|
"s1-secops-mcp": {
|
|
58
58
|
"command": "npx",
|
|
59
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
59
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.2"]
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
}
|
|
@@ -363,7 +363,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
|
|
|
363
363
|
|
|
364
364
|
These are supported but not first-class:
|
|
365
365
|
|
|
366
|
-
- **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.
|
|
366
|
+
- **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.2` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
|
|
367
367
|
|
|
368
368
|
- **External bridge (`supergateway`, `mcp-proxy`).** Pre-1.1.0 deployments used these to wrap the stdio-only server. They still work; this server's native HTTP mode is functionally equivalent and removes the extra process. Prefer native unless you have a specific reason.
|
|
369
369
|
|
package/lib/credentials.js
CHANGED
package/lib/sdl.js
CHANGED
|
@@ -37,14 +37,44 @@ function retryAfterMs(res, fallback) {
|
|
|
37
37
|
return fallback;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Origin-pin the SDL request path, mirroring safeUrl() in lib/s1.js.
|
|
42
|
+
*
|
|
43
|
+
* Every current caller passes a literal, so this is defence in depth: the next
|
|
44
|
+
* caller to thread a tool-supplied path through sdlFetch would otherwise be
|
|
45
|
+
* able to rewrite the authority ("//evil.example/x", "@evil.example/x") and
|
|
46
|
+
* send the tenant bearer token to an attacker-chosen origin.
|
|
47
|
+
*/
|
|
48
|
+
function safeSdlUrl(path) {
|
|
49
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`SDL API path must be a string starting with a single "/" (got: ${JSON.stringify(path)?.slice(0, 80)})`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const origin = new URL(sdlBase()).origin;
|
|
55
|
+
const u = new URL(sdlBase() + path, origin);
|
|
56
|
+
if (u.origin !== origin) {
|
|
57
|
+
throw new Error(`SDL API path may not change the request origin (resolved to ${u.origin})`);
|
|
58
|
+
}
|
|
59
|
+
return u.toString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, contentType = 'application/json', allowRetry = null } = {}, retries = 3) {
|
|
63
|
+
const url = safeSdlUrl(path);
|
|
42
64
|
const headers = {
|
|
43
65
|
Authorization: `Bearer ${sdlToken()}`,
|
|
44
66
|
'Content-Type': contentType,
|
|
45
67
|
...extraHeaders,
|
|
46
68
|
};
|
|
47
69
|
|
|
70
|
+
// Status-based retry is restricted to idempotent methods, mirroring lib/s1.js.
|
|
71
|
+
// A 5xx received *after* the server committed a write would otherwise be
|
|
72
|
+
// re-sent, and a re-sent addConfigFile(name:) against /dashboards/ creates a
|
|
73
|
+
// duplicate. Callers opt in with allowRetry:true for read-only POSTs
|
|
74
|
+
// (GraphQL queries). Network-layer rejections are still always retried:
|
|
75
|
+
// those mean the request may never have reached the server at all.
|
|
76
|
+
const methodRetryable = allowRetry !== null ? allowRetry : (method === 'GET' || method === 'HEAD');
|
|
77
|
+
|
|
48
78
|
let delay = 500;
|
|
49
79
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
50
80
|
let res;
|
|
@@ -61,7 +91,7 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
|
|
|
61
91
|
continue;
|
|
62
92
|
}
|
|
63
93
|
|
|
64
|
-
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
94
|
+
if (methodRetryable && (res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
65
95
|
await sleep(retryAfterMs(res, delay));
|
|
66
96
|
delay = Math.min(delay * 2, 8000);
|
|
67
97
|
continue;
|
|
@@ -80,38 +110,189 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
|
|
|
80
110
|
throw new Error(`SDL API ${method} ${path}: request failed after retries`);
|
|
81
111
|
}
|
|
82
112
|
|
|
83
|
-
// ─── Config file operations
|
|
113
|
+
// ─── Config file operations (GraphQL, canonical) ──────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// `POST /sdl/v2/graphql` is the canonical config-file surface. It is a strict
|
|
116
|
+
// superset of the legacy REST `/api/*File` endpoints:
|
|
117
|
+
//
|
|
118
|
+
// * REST listFiles omits every udoId-addressed dashboard. Measured on a live
|
|
119
|
+
// tenant: REST returned 1,914 paths, GraphQL configFiles returned 2,264.
|
|
120
|
+
// The 350-file gap is entirely /dashboards/ files that carry a udoId, and
|
|
121
|
+
// REST getFile on any of them returns `success/noSuchFile`.
|
|
122
|
+
// * The console's Configuration Files grid renders a udoId dashboard as the
|
|
123
|
+
// display string `/dashboards/id/<udoId>/<name>`. That is NOT a path. Reading
|
|
124
|
+
// it as one returns "no file exists at path". Address it by udoId; its real
|
|
125
|
+
// name is `/dashboards/<name>`.
|
|
126
|
+
//
|
|
127
|
+
// udoId assignment is by namespace, verified live: only `/dashboards/` files get
|
|
128
|
+
// a udoId. `/lookups/`, `/datatables/`, `/logParsers/` and `/automaticLookups`
|
|
129
|
+
// all come back with udoId `null` and are addressed by name.
|
|
130
|
+
//
|
|
131
|
+
// Write rule, verified live: addConfigFile(name:) UPDATES IN PLACE for a
|
|
132
|
+
// name-addressed file, but CREATES A DUPLICATE for a dashboard. Always write a
|
|
133
|
+
// dashboard by udoId. Skipping that rule is how one tenant accumulated 152
|
|
134
|
+
// copies of `/dashboards/AI Usage`.
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array.
|
|
138
|
+
*
|
|
139
|
+
* GraphQL reports failure as HTTP 200 with an `errors` array, so the status
|
|
140
|
+
* code alone never proves success. Three things this must not do, each of which
|
|
141
|
+
* would resurface the exact false-negative class this module exists to remove:
|
|
142
|
+
*
|
|
143
|
+
* 1. Accept a non-JSON 200. sdlFetch falls back to raw text when JSON.parse
|
|
144
|
+
* fails, so a proxy interstitial or WAF page arrives as a string. Left
|
|
145
|
+
* unchecked, `payload.errors` is undefined and every caller returns its
|
|
146
|
+
* empty default: "no files", "not found", "deleted".
|
|
147
|
+
* 2. Require `errors` to be an array. A bare `{errors: {...}}` object would
|
|
148
|
+
* slip through an Array.isArray gate.
|
|
149
|
+
* 3. Return a payload carrying neither `data` nor `errors`.
|
|
150
|
+
*
|
|
151
|
+
* `readOnly` opts into status-based retry; only pass it for queries.
|
|
152
|
+
*/
|
|
153
|
+
async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
|
|
154
|
+
const body = { query };
|
|
155
|
+
if (variables) body.variables = variables;
|
|
156
|
+
const payload = await sdlFetch(
|
|
157
|
+
'POST',
|
|
158
|
+
`/v2/graphql?opname=${encodeURIComponent(opname)}`,
|
|
159
|
+
{ body, allowRetry: readOnly }
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`SDL GraphQL ${opname}: expected a JSON object, got ${typeof payload}. ` +
|
|
165
|
+
'This usually means a proxy or auth interstitial answered instead of the API. ' +
|
|
166
|
+
`First 200 chars: ${String(payload).slice(0, 200)}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (payload.errors) {
|
|
170
|
+
const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors];
|
|
171
|
+
const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId;
|
|
172
|
+
const msg = errs[0]?.message || 'unknown GraphQL error';
|
|
173
|
+
throw new Error(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
|
|
174
|
+
}
|
|
175
|
+
if (!('data' in payload)) {
|
|
176
|
+
throw new Error(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
|
|
177
|
+
}
|
|
178
|
+
return payload.data;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** udoIds are 16 digits, within ~1.4x of Number.MAX_SAFE_INTEGER. A caller that
|
|
182
|
+
* sends one as a JSON number has already lost precision before we stringify. */
|
|
183
|
+
function assertSafeUdoId(udoId) {
|
|
184
|
+
if (typeof udoId === 'number' && !Number.isSafeInteger(udoId)) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`udoId ${udoId} exceeds the JS safe-integer range and has already lost precision. Pass it as a string.`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return String(udoId);
|
|
190
|
+
}
|
|
84
191
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
192
|
+
const CONFIG_FIELDS = 'udoId name readOnly version';
|
|
193
|
+
|
|
194
|
+
/** Every config file on the tenant, including udoId-addressed dashboards. */
|
|
195
|
+
export async function configFiles() {
|
|
196
|
+
const data = await sdlGraphql(
|
|
197
|
+
'getConfigurationFiles',
|
|
198
|
+
`query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
|
|
199
|
+
undefined,
|
|
200
|
+
{ readOnly: true }
|
|
201
|
+
);
|
|
202
|
+
return data?.configFiles ?? [];
|
|
88
203
|
}
|
|
89
204
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
205
|
+
/** Read one config file by name (plain files) or udoId (dashboards). */
|
|
206
|
+
export async function configFile({ name, udoId }) {
|
|
207
|
+
if (!name && !udoId) throw new Error('configFile requires either name or udoId');
|
|
208
|
+
const data = udoId
|
|
209
|
+
? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: assertSafeUdoId(udoId) }, { readOnly: true })
|
|
210
|
+
: await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true });
|
|
211
|
+
return data?.configFile ?? null;
|
|
96
212
|
}
|
|
97
213
|
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Create or update a config file.
|
|
216
|
+
* - udoId given → updates that file in place; pass expectedVersion to lock.
|
|
217
|
+
* - name given → updates in place for plain files, but CREATES A DUPLICATE
|
|
218
|
+
* for /dashboards/. Never write a dashboard by name.
|
|
219
|
+
*/
|
|
220
|
+
export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
221
|
+
if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
|
|
222
|
+
// Creating a dashboard must go by name (no udoId exists yet); only an
|
|
223
|
+
// *existing* dashboard is at risk of being duplicated by a name-addressed
|
|
224
|
+
// write. So refuse only when a file of that name already exists.
|
|
225
|
+
if (!udoId && String(name).startsWith('/dashboards/')) {
|
|
226
|
+
const all = await configFiles();
|
|
227
|
+
// An empty listing means the check could not run, not that the name is
|
|
228
|
+
// free. Failing open here would silently disable the guard.
|
|
229
|
+
if (!all.length) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Refusing to write "${name}" by name: the configFiles listing came back empty, so the ` +
|
|
232
|
+
'duplicate check could not run. Retry, or pass an explicit udoId.'
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const key = String(name).trim().toLowerCase();
|
|
236
|
+
const existing = all.filter(f => String(f.name || '').trim().toLowerCase() === key);
|
|
237
|
+
if (existing.length) {
|
|
238
|
+
const ids = existing.map(f => f.udoId).filter(Boolean).join(', ');
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Refusing to write "${name}" by name: ${existing.length} dashboard(s) already use that name, ` +
|
|
241
|
+
'and a name-addressed write to /dashboards/ creates another duplicate rather than updating. ' +
|
|
242
|
+
`Pass one of these udoIds instead: ${ids || '(none, file has no udoId)'}.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
104
245
|
}
|
|
105
|
-
|
|
246
|
+
// expectedVersion is honoured on BOTH address forms. Verified live 2026-08-07:
|
|
247
|
+
// a stale expectedVersion on a name-addressed /datatables/ write was rejected
|
|
248
|
+
// with "There are conflicting changes in the file." and the content was left
|
|
249
|
+
// untouched. Omitting it here would silently downgrade every parser, lookup,
|
|
250
|
+
// datatable and /automaticLookups write to last-write-wins.
|
|
251
|
+
const data = udoId
|
|
252
|
+
? await sdlGraphql('addConfigFile',
|
|
253
|
+
`mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
254
|
+
{ udoId: assertSafeUdoId(udoId), content, expectedVersion })
|
|
255
|
+
: await sdlGraphql('addConfigFile',
|
|
256
|
+
`mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
257
|
+
{ name, content, expectedVersion });
|
|
258
|
+
return data?.addConfigFile ?? null;
|
|
106
259
|
}
|
|
107
260
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Delete a config file. Dashboards delete by udoId, plain files by name.
|
|
263
|
+
* A null return with no errors array is SUCCESS; the deleted object is not
|
|
264
|
+
* echoed back. Treating that null as a failure is the classic mistake here.
|
|
265
|
+
*/
|
|
266
|
+
export async function deleteConfigFile({ name, udoId, expectedVersion }) {
|
|
267
|
+
if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
|
|
268
|
+
const raw = udoId
|
|
269
|
+
? await sdlGraphql('deleteConfigFile',
|
|
270
|
+
'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
|
|
271
|
+
{ udoId: assertSafeUdoId(udoId), expectedVersion })
|
|
272
|
+
: await sdlGraphql('deleteConfigFile',
|
|
273
|
+
'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
|
|
274
|
+
{ id: name, expectedVersion });
|
|
275
|
+
|
|
276
|
+
// The mutation returns null on success and does not echo the deleted object,
|
|
277
|
+
// so its response cannot distinguish "deleted" from "matched nothing". Confirm
|
|
278
|
+
// by re-reading. This is the house rule established by uamSetStatus in
|
|
279
|
+
// lib/s1.js: never treat a mutation response as proof, re-get and verify.
|
|
280
|
+
const still = await configFile({ name, udoId });
|
|
281
|
+
if (still) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
|
|
284
|
+
`(version ${still.version}). The mutation reported no errors but nothing was removed.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return { status: 'success', deleted: udoId ? { udoId: String(udoId) } : { name }, raw: raw?.deleteConfigFile ?? null };
|
|
113
288
|
}
|
|
114
289
|
|
|
290
|
+
// The legacy REST config-file endpoints (/api/listFiles, /api/getFile,
|
|
291
|
+
// /api/putFile) are not wrapped here. They cannot see or modify a
|
|
292
|
+
// udoId-addressed dashboard, so their listing is unsafe for any "does this file
|
|
293
|
+
// exist" decision. The GraphQL operations above cover every namespace,
|
|
294
|
+
// including parsers, lookups, datatables and /automaticLookups.
|
|
295
|
+
|
|
115
296
|
// ─── V1 Query (schema discovery) ─────────────────────────────────────────────
|
|
116
297
|
// Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
|
|
117
298
|
// Use for schema discovery; use LRQ for hunting.
|
package/lib/server-core.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmoses-s1/s1-secops-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "node index.js",
|
|
21
21
|
"start:http": "node index.js --transport http",
|
|
22
22
|
"dev": "node --watch index.js",
|
|
23
|
-
"test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs tests/ssrf-path.test.mjs tests/http-origin-guard.test.mjs tests/regressions-2026-07-29.test.mjs tests/regressions-2026-07-31.test.mjs",
|
|
23
|
+
"test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs tests/ssrf-path.test.mjs tests/http-origin-guard.test.mjs tests/sdl-graphql.test.mjs tests/regressions-2026-07-29.test.mjs tests/regressions-2026-07-31.test.mjs",
|
|
24
24
|
"regen:readme": "node scripts/regen-readme-tools-table.mjs"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
package/tools/sdl-api.js
CHANGED
|
@@ -2,48 +2,76 @@
|
|
|
2
2
|
* SDL API tools: sdl-api, sdl-dashboard, sdl-log-parser skills
|
|
3
3
|
*
|
|
4
4
|
* Tools:
|
|
5
|
-
* sdl_list_files List
|
|
6
|
-
* sdl_get_file Get file content and version
|
|
5
|
+
* sdl_list_files List every config file on the SDL tenant (GraphQL configFiles)
|
|
6
|
+
* sdl_get_file Get file content and version, by path or udoId
|
|
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
|
+
*
|
|
11
|
+
* All four config-file tools run on `POST /sdl/v2/graphql`. The legacy REST
|
|
12
|
+
* `/sdl/api/*File` endpoints are NOT used: they silently omit every
|
|
13
|
+
* udoId-addressed dashboard (1,914 vs 2,264 files on a live tenant) and return
|
|
14
|
+
* `success/noSuchFile` for any of them.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import {
|
|
17
|
+
import { configFiles, configFile, putConfigFile, deleteConfigFile } from '../lib/sdl.js';
|
|
13
18
|
import { hecIngest } from '../lib/hec.js';
|
|
14
19
|
|
|
20
|
+
const UDOID_NOTE =
|
|
21
|
+
'Dashboards are addressed by udoId, everything else by path. The console shows a dashboard as ' +
|
|
22
|
+
'"/dashboards/id/<udoId>/<name>" in its Configuration Files grid; that display string is NOT a path, ' +
|
|
23
|
+
'the number in it is the udoId and the real path is "/dashboards/<name>". ' +
|
|
24
|
+
'udoId assignment is by namespace: only /dashboards/ files have one, /lookups/, /datatables/, ' +
|
|
25
|
+
'/logParsers/ and /automaticLookups are all name-addressed with udoId null.';
|
|
26
|
+
|
|
15
27
|
export const tools = [
|
|
16
28
|
// ─── sdl_list_files ───────────────────────────────────────────────────────
|
|
17
29
|
{
|
|
18
30
|
name: 'sdl_list_files',
|
|
19
|
-
description: `List
|
|
31
|
+
description: `List every configuration file on the SDL tenant via the GraphQL configFiles query: /logParsers/, /dashboards/, /alerts/, /lookups/, /datatables/, /automaticLookups. Returns {udoId, name, readOnly, version} per file. ${UDOID_NOTE} Use this to discover what is deployed, and to resolve a dashboard name to the udoId that sdl_get_file/sdl_put_file need. Never conclude a file is absent from a listing produced any other way; the legacy REST listing omits ~350 dashboards.`,
|
|
20
32
|
inputSchema: {
|
|
21
33
|
type: 'object',
|
|
22
|
-
properties: {
|
|
34
|
+
properties: {
|
|
35
|
+
pathPrefix: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Optional filter, e.g. "/dashboards/" or "/logParsers/". Applied client-side to the full listing.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
23
40
|
required: [],
|
|
24
41
|
},
|
|
25
|
-
async handler() {
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
async handler({ pathPrefix } = {}) {
|
|
43
|
+
let files = await configFiles();
|
|
44
|
+
if (pathPrefix) files = files.filter(f => (f.name || '').startsWith(pathPrefix));
|
|
45
|
+
return JSON.stringify({ count: files.length, files }, null, 2);
|
|
28
46
|
},
|
|
29
47
|
},
|
|
30
48
|
|
|
31
49
|
// ─── sdl_get_file ─────────────────────────────────────────────────────────
|
|
32
50
|
{
|
|
33
51
|
name: 'sdl_get_file',
|
|
34
|
-
description: `Get the content and current version
|
|
52
|
+
description: `Get the content and current version of a SDL configuration file. Pass "path" for name-addressed files (parsers, lookups, datatables, alerts, /automaticLookups) or "udoId" for a dashboard. ${UDOID_NOTE} Read this before sdl_put_file and pass the returned version as expectedVersion so the write is optimistically locked. If a lookup by path returns nothing, the file is probably udoId-addressed: list it with sdl_list_files and retry with its udoId rather than reporting it as missing.`,
|
|
35
53
|
inputSchema: {
|
|
36
54
|
type: 'object',
|
|
37
55
|
properties: {
|
|
38
56
|
path: {
|
|
39
57
|
type: 'string',
|
|
40
|
-
description: 'Full SDL config path, e.g. "/logParsers/FortiGate" or "/
|
|
58
|
+
description: 'Full SDL config path, e.g. "/logParsers/FortiGate" or "/lookups/assets.csv".',
|
|
59
|
+
},
|
|
60
|
+
udoId: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Dashboard udoId, e.g. "3559330396332032". Get it from sdl_list_files. Required for /dashboards/ files.',
|
|
41
63
|
},
|
|
42
64
|
},
|
|
43
|
-
required: [
|
|
65
|
+
required: [],
|
|
44
66
|
},
|
|
45
|
-
async handler({ path }) {
|
|
46
|
-
const result = await
|
|
67
|
+
async handler({ path, udoId }) {
|
|
68
|
+
const result = await configFile({ name: path, udoId });
|
|
69
|
+
if (!result) {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
status: 'notFound',
|
|
72
|
+
hint: 'No file at that address. If this is a dashboard, it is udoId-addressed: run sdl_list_files with pathPrefix "/dashboards/" and retry with its udoId.',
|
|
73
|
+
}, null, 2);
|
|
74
|
+
}
|
|
47
75
|
return JSON.stringify(result, null, 2);
|
|
48
76
|
},
|
|
49
77
|
},
|
|
@@ -51,13 +79,17 @@ export const tools = [
|
|
|
51
79
|
// ─── sdl_put_file ─────────────────────────────────────────────────────────
|
|
52
80
|
{
|
|
53
81
|
name: 'sdl_put_file',
|
|
54
|
-
description: `
|
|
82
|
+
description: `Create or update a SDL configuration file. To create, pass "path". To update, pass the file's current address plus expectedVersion from sdl_get_file. ${UDOID_NOTE} CRITICAL for dashboards: update by udoId, never by path. A path-addressed write to /dashboards/ does not update, it creates a duplicate file sharing the name; that is how one tenant accumulated 152 copies of "/dashboards/AI Usage". This tool refuses path-addressed writes to /dashboards/ on an existing file for that reason. Name-addressed writes to every other namespace update in place normally.`,
|
|
55
83
|
inputSchema: {
|
|
56
84
|
type: 'object',
|
|
57
85
|
properties: {
|
|
58
86
|
path: {
|
|
59
87
|
type: 'string',
|
|
60
|
-
description: 'Full SDL config path, e.g. "/logParsers/MyParser"
|
|
88
|
+
description: 'Full SDL config path, e.g. "/logParsers/MyParser". Use for creates, and for updates to non-dashboard files.',
|
|
89
|
+
},
|
|
90
|
+
udoId: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'Dashboard udoId. REQUIRED to update an existing dashboard; a path-addressed dashboard write duplicates instead of updating.',
|
|
61
93
|
},
|
|
62
94
|
content: {
|
|
63
95
|
type: 'string',
|
|
@@ -65,13 +97,13 @@ export const tools = [
|
|
|
65
97
|
},
|
|
66
98
|
expectedVersion: {
|
|
67
99
|
type: 'number',
|
|
68
|
-
description: 'Current file version from sdl_get_file
|
|
100
|
+
description: 'Current file version from sdl_get_file, for optimistic locking. Honoured on udoId-addressed writes. Omit when creating a new file.',
|
|
69
101
|
},
|
|
70
102
|
},
|
|
71
|
-
required: ['
|
|
103
|
+
required: ['content'],
|
|
72
104
|
},
|
|
73
|
-
async handler({ path, content, expectedVersion }) {
|
|
74
|
-
const result = await
|
|
105
|
+
async handler({ path, udoId, content, expectedVersion }) {
|
|
106
|
+
const result = await putConfigFile({ name: path, udoId, content, expectedVersion });
|
|
75
107
|
return JSON.stringify(result, null, 2);
|
|
76
108
|
},
|
|
77
109
|
},
|
|
@@ -79,23 +111,27 @@ export const tools = [
|
|
|
79
111
|
// ─── sdl_delete_file ──────────────────────────────────────────────────────
|
|
80
112
|
{
|
|
81
113
|
name: 'sdl_delete_file',
|
|
82
|
-
description: `Delete a SDL configuration file (parser, dashboard, alert, lookup, datatable).
|
|
114
|
+
description: `Delete a SDL configuration file (parser, dashboard, alert, lookup, datatable). Deletion is permanent. Pass "udoId" for dashboards, "path" for everything else. ${UDOID_NOTE} Always read the file with sdl_get_file first to confirm the address and to get expectedVersion.`,
|
|
83
115
|
inputSchema: {
|
|
84
116
|
type: 'object',
|
|
85
117
|
properties: {
|
|
86
118
|
path: {
|
|
87
119
|
type: 'string',
|
|
88
|
-
description: 'Full SDL config path to delete.',
|
|
120
|
+
description: 'Full SDL config path to delete (non-dashboard files).',
|
|
121
|
+
},
|
|
122
|
+
udoId: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Dashboard udoId to delete. Required for /dashboards/ files.',
|
|
89
125
|
},
|
|
90
126
|
expectedVersion: {
|
|
91
127
|
type: 'number',
|
|
92
128
|
description: 'Current file version for optimistic locking (from sdl_get_file). Strongly recommended.',
|
|
93
129
|
},
|
|
94
130
|
},
|
|
95
|
-
required: [
|
|
131
|
+
required: [],
|
|
96
132
|
},
|
|
97
|
-
async handler({ path, expectedVersion }) {
|
|
98
|
-
const result = await
|
|
133
|
+
async handler({ path, udoId, expectedVersion }) {
|
|
134
|
+
const result = await deleteConfigFile({ name: path, udoId, expectedVersion });
|
|
99
135
|
return JSON.stringify(result, null, 2);
|
|
100
136
|
},
|
|
101
137
|
},
|