@pmoses-s1/s1-secops-mcp 1.3.4 → 1.3.6
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 +88 -0
- package/README.md +1 -1
- package/deploy/README.md +2 -2
- package/lib/s1.js +8 -1
- package/lib/sdl.js +17 -3
- package/lib/server-core.js +1 -1
- package/package.json +1 -1
- package/scripts/test-mac.sh +0 -0
- package/tools/powerquery.js +18 -6
- package/tools/sdl-api.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.6 - 2026-08-17
|
|
4
|
+
|
|
5
|
+
Behaviour change, found by deploying to a real site and not being able to see the
|
|
6
|
+
result.
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- **`isPublic` / `public` now defaults to TRUE on dashboard creation** in both
|
|
11
|
+
clients, where the raw `createDashboardV2` API defaults it to false.
|
|
12
|
+
|
|
13
|
+
`access.owner` is set to the calling identity. With an API service-account
|
|
14
|
+
token that is `serviceuser-<uuid>@mgmt-<n>.sentinelone.net`, not a person, so a
|
|
15
|
+
private dashboard is readable through the API and **invisible in the console to
|
|
16
|
+
the human operator at any scope**. That is indistinguishable from a failed
|
|
17
|
+
deploy: the tool reports success, the object exists, and the user sees nothing.
|
|
18
|
+
|
|
19
|
+
Verified live: the identical config at the identical site scope went from
|
|
20
|
+
invisible to visible purely by recreating it with `public: true`, and every
|
|
21
|
+
pre-existing dashboard at that site carried `public: true`.
|
|
22
|
+
|
|
23
|
+
Pass `isPublic: false` / `public=False` deliberately for a dashboard that
|
|
24
|
+
should stay private to the service account. `shareResource` to a scope does not
|
|
25
|
+
flip `public`; the two are independent.
|
|
26
|
+
|
|
27
|
+
### Documented
|
|
28
|
+
|
|
29
|
+
- **Dashboard names reject punctuation, with only `Invalid name` as the error.**
|
|
30
|
+
Probed one character class at a time against a live tenant:
|
|
31
|
+
|
|
32
|
+
| Accepted | Rejected |
|
|
33
|
+
|---|---|
|
|
34
|
+
| letters, digits, space, `-`, `_`, `.`, `/` | `(` `)` `[` `]` `{` `}` `:` `,` `&` `'` `%` `#` |
|
|
35
|
+
|
|
36
|
+
So `My Dashboard (prod)` fails with no indication of which character offended.
|
|
37
|
+
Recorded in the tool description, both client docstrings,
|
|
38
|
+
`sdl-api/references/config-file-graphql.md` and
|
|
39
|
+
`sdl-dashboard/references/deployment.md`.
|
|
40
|
+
|
|
41
|
+
### Tests
|
|
42
|
+
|
|
43
|
+
115 JS (+1), 61 Python client (+2), 19 panel-safety. The two default-value
|
|
44
|
+
assertions were updated and paired with explicit-false cases, so an accidental
|
|
45
|
+
revert of the default fails the suite.
|
|
46
|
+
|
|
47
|
+
### Docker
|
|
48
|
+
|
|
49
|
+
Bundle image stays **1.3.2**; its npm pin moves to 1.3.6.
|
|
50
|
+
|
|
51
|
+
## 1.3.5 - 2026-08-17
|
|
52
|
+
|
|
53
|
+
Completes the scope work in 1.3.4. **Upgrade from 1.3.4 is recommended.**
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **Query methods ignored `scope`.** 1.3.4 added the `scope` argument to the
|
|
58
|
+
config-file and dashboard operations but not to the query paths, even though
|
|
59
|
+
log reads are filtered by the same `S1-Scope` header. A hunt or a
|
|
60
|
+
panel-validation query run without the intended scope silently answered for
|
|
61
|
+
the token default, which is the worst shape of this bug: a plausible number
|
|
62
|
+
for the wrong boundary, with no error.
|
|
63
|
+
|
|
64
|
+
Scope now threads through `lrqRun` (launch **and** poll, so the forward-tagged
|
|
65
|
+
follow-ups stay on the same scope) and through all five Python query methods:
|
|
66
|
+
`query`, `power_query`, `facet_query`, `numeric_query`, `timeseries_query`.
|
|
67
|
+
Exposed on the `powerquery_run`, `powerquery_enumerate_sources` and
|
|
68
|
+
`powerquery_schema_discover` tools.
|
|
69
|
+
|
|
70
|
+
Found by using the shipped 1.3.4 client to discover schema at a site scope and
|
|
71
|
+
getting `power_query() got an unexpected keyword argument 'scope'`.
|
|
72
|
+
|
|
73
|
+
- **`scopeHeaders` is now exported from `lib/sdl.js`** and imported by the LRQ
|
|
74
|
+
path in `lib/s1.js`, rather than each surface resolving scope its own way. A
|
|
75
|
+
second implementation would drift, and the validation rules (numeric ids,
|
|
76
|
+
`null` suppresses the default) have to be identical on both.
|
|
77
|
+
|
|
78
|
+
### Tests
|
|
79
|
+
|
|
80
|
+
- 4 new JS cases: `v1Query` header presence and absence, malformed-scope
|
|
81
|
+
rejection before any request, and a direct contract test on the exported
|
|
82
|
+
`scopeHeaders`.
|
|
83
|
+
- 8 new Python cases covering all five query methods, unscoped omission,
|
|
84
|
+
malformed-scope rejection, and `scope=None` suppression.
|
|
85
|
+
- Totals: 114 JS, 59 Python client, 19 panel-safety. No regressions.
|
|
86
|
+
|
|
87
|
+
### Docker
|
|
88
|
+
|
|
89
|
+
Bundle image stays **1.3.2**; its npm pin moves to 1.3.5.
|
|
90
|
+
|
|
3
91
|
## 1.3.4 - 2026-08-17
|
|
4
92
|
|
|
5
93
|
Adds site-level dashboard lifecycle. Two gaps closed: SDL GraphQL calls never
|
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
|
|
|
73
73
|
"mcpServers": {
|
|
74
74
|
"s1-secops-mcp": {
|
|
75
75
|
"command": "npx",
|
|
76
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
76
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.6"],
|
|
77
77
|
"env": {
|
|
78
78
|
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
|
|
79
79
|
"S1_CONSOLE_API_TOKEN": "eyJ...",
|
package/deploy/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Or, equivalently, by package name without the install:
|
|
|
57
57
|
"mcpServers": {
|
|
58
58
|
"s1-secops-mcp": {
|
|
59
59
|
"command": "npx",
|
|
60
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
60
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.6"]
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -376,7 +376,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
|
|
|
376
376
|
|
|
377
377
|
These are supported but not first-class:
|
|
378
378
|
|
|
379
|
-
- **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.
|
|
379
|
+
- **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.6` + `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`.
|
|
380
380
|
|
|
381
381
|
- **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.
|
|
382
382
|
|
package/lib/s1.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { getCreds } from './credentials.js';
|
|
12
|
+
import { scopeHeaders } from './sdl.js';
|
|
12
13
|
|
|
13
14
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -191,7 +192,7 @@ export function pickMatchCount(result) {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
/** Run a full LRQ PowerQuery lifecycle. Returns { columns, rows, rowCount, matchCount }. */
|
|
194
|
-
export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000 } = {}) {
|
|
195
|
+
export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000, scope } = {}) {
|
|
195
196
|
const b = base();
|
|
196
197
|
const tok = jwt();
|
|
197
198
|
|
|
@@ -207,12 +208,17 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
|
|
|
207
208
|
pq: { query, resultType: 'TABLE' },
|
|
208
209
|
};
|
|
209
210
|
|
|
211
|
+
// S1-Scope applies to log reads exactly as it does to config reads: an LRQ
|
|
212
|
+
// run without the intended scope silently answers for the token default.
|
|
213
|
+
const scopeHdrs = scopeHeaders(scope);
|
|
214
|
+
|
|
210
215
|
// Launch
|
|
211
216
|
const launchRes = await fetch(launchUrl, {
|
|
212
217
|
method: 'POST',
|
|
213
218
|
headers: {
|
|
214
219
|
Authorization: `Bearer ${tok}`,
|
|
215
220
|
'Content-Type': 'application/json',
|
|
221
|
+
...scopeHdrs,
|
|
216
222
|
},
|
|
217
223
|
body: JSON.stringify(launchBody),
|
|
218
224
|
});
|
|
@@ -230,6 +236,7 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
|
|
|
230
236
|
const pollHeaders = {
|
|
231
237
|
Authorization: `Bearer ${tok}`,
|
|
232
238
|
'Content-Type': 'application/json',
|
|
239
|
+
...scopeHdrs,
|
|
233
240
|
...(forwardTag ? { 'X-Dataset-Query-Forward-Tag': forwardTag } : {}),
|
|
234
241
|
};
|
|
235
242
|
|
package/lib/sdl.js
CHANGED
|
@@ -62,8 +62,10 @@ function resolveScope(scope) {
|
|
|
62
62
|
return trimmed;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** Header object carrying S1-Scope, or empty when the request is unscoped.
|
|
66
|
-
|
|
65
|
+
/** Header object carrying S1-Scope, or empty when the request is unscoped.
|
|
66
|
+
* Exported so the LRQ path in lib/s1.js scopes identically; a second
|
|
67
|
+
* implementation would drift. */
|
|
68
|
+
export function scopeHeaders(scope) {
|
|
67
69
|
const resolved = resolveScope(scope);
|
|
68
70
|
return resolved ? { 'S1-Scope': resolved } : {};
|
|
69
71
|
}
|
|
@@ -514,13 +516,25 @@ export async function getDashboard({ id, name, scope }) {
|
|
|
514
516
|
* "Content is invalid json" / "Additional text after JSON object". Going
|
|
515
517
|
* through this function cannot hit that class of error.
|
|
516
518
|
*
|
|
519
|
+
* `isPublic` DEFAULTS TO TRUE, unlike the raw API, which defaults it to false.
|
|
520
|
+
* `access.owner` is set to the calling identity; with a service-account token that
|
|
521
|
+
* is `serviceuser-<uuid>@mgmt-<n>.sentinelone.net`, not a person. A private
|
|
522
|
+
* service-user dashboard is readable through the API and INVISIBLE in the console
|
|
523
|
+
* to the human operator, at any scope, which is indistinguishable from a failed
|
|
524
|
+
* deploy. Verified live: the same config at the same scope became visible purely
|
|
525
|
+
* by recreating it with public true. Pass `isPublic: false` deliberately if the
|
|
526
|
+
* dashboard really should be private to the service account.
|
|
527
|
+
*
|
|
528
|
+
* Names reject punctuation and answer only "Invalid name". Accepted: letters,
|
|
529
|
+
* digits, space, `-`, `_`, `.`, `/`. Rejected: `( ) [ ] { } : , & ' % #`.
|
|
530
|
+
*
|
|
517
531
|
* DUPLICATE NAMES ARE ALLOWED HERE, unlike putConfigFile. The console itself
|
|
518
532
|
* creates "<name> - Copy" siblings, and shareResource addresses dashboards by
|
|
519
533
|
* id, so duplicate names are not the footgun they are for name-addressed
|
|
520
534
|
* config-file writes. Set `failIfNameExists` to opt into the stricter
|
|
521
535
|
* behaviour; it costs one extra listing call.
|
|
522
536
|
*/
|
|
523
|
-
export async function createDashboard({ name, config, isPublic =
|
|
537
|
+
export async function createDashboard({ name, config, isPublic = true, scope, failIfNameExists = false }) {
|
|
524
538
|
if (!name || typeof name !== 'string') throw new Error('createDashboard requires a name');
|
|
525
539
|
if (typeof config !== 'string' || !config.trim()) {
|
|
526
540
|
throw new Error('createDashboard requires config as a JSON string (the full dashboard document).');
|
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.6",
|
|
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",
|
package/scripts/test-mac.sh
CHANGED
|
File without changes
|
package/tools/powerquery.js
CHANGED
|
@@ -23,15 +23,19 @@ export const tools = [
|
|
|
23
23
|
description: 'Lookback window in hours (default 24). Increase to 168 (7d) if the last 24h had low volume.',
|
|
24
24
|
default: 24,
|
|
25
25
|
},
|
|
26
|
+
scope: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". LOG READS ARE SCOPE-FILTERED just like config reads, so this changes which events the query can see. Use it to hunt within one site, and to validate a site-scoped dashboard panel against the same boundary the dashboard will see. Omit to use S1_SCOPE from credentials.json, or the token default when that is unset.',
|
|
29
|
+
},
|
|
26
30
|
},
|
|
27
31
|
required: [],
|
|
28
32
|
},
|
|
29
|
-
async handler({ hours = 24 } = {}) {
|
|
33
|
+
async handler({ hours = 24, scope } = {}) {
|
|
30
34
|
const query = `| group UniqueDataSourceNames = array_agg_distinct(dataSource.name),
|
|
31
35
|
UniqueVendors = array_agg_distinct(dataSource.vendor),
|
|
32
36
|
UniqueCategories = array_agg_distinct(dataSource.category)
|
|
33
37
|
| limit 1000`;
|
|
34
|
-
const result = await lrqRun(query, { hours });
|
|
38
|
+
const result = await lrqRun(query, { hours, scope });
|
|
35
39
|
return JSON.stringify(result, null, 2);
|
|
36
40
|
},
|
|
37
41
|
},
|
|
@@ -65,11 +69,15 @@ export const tools = [
|
|
|
65
69
|
description: 'Client-side cap on rows returned (default 1000). Not a hard backend limit: the LRQ engine returns as many rows as the query\'s own `| limit N` asks for (live-verified 2026-07-29: a `| limit 20000` query returned 20,000 rows in one response). Raise this to match a large `| limit`; the real ceiling is LRQ response size, not a fixed 5000.',
|
|
66
70
|
default: 1000,
|
|
67
71
|
},
|
|
72
|
+
scope: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". LOG READS ARE SCOPE-FILTERED just like config reads, so this changes which events the query can see. Use it to hunt within one site, and to validate a site-scoped dashboard panel against the same boundary the dashboard will see. Omit to use S1_SCOPE from credentials.json, or the token default when that is unset.',
|
|
75
|
+
},
|
|
68
76
|
},
|
|
69
77
|
required: ['query'],
|
|
70
78
|
},
|
|
71
|
-
async handler({ query, startTime, endTime, hours = 24, maxRows = 1000 }) {
|
|
72
|
-
const result = await lrqRun(query, { startTime, endTime, hours, maxRows });
|
|
79
|
+
async handler({ query, startTime, endTime, hours = 24, maxRows = 1000, scope }) {
|
|
80
|
+
const result = await lrqRun(query, { startTime, endTime, hours, maxRows, scope });
|
|
73
81
|
return JSON.stringify(result, null, 2);
|
|
74
82
|
},
|
|
75
83
|
},
|
|
@@ -95,17 +103,21 @@ export const tools = [
|
|
|
95
103
|
description: 'Lookback string or ISO date, e.g. "24h", "7d", or "2026-04-20T00:00:00Z" (default "24h").',
|
|
96
104
|
default: '24h',
|
|
97
105
|
},
|
|
106
|
+
scope: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". Schema discovery is scope-filtered: a source present at one site may be absent at another, so discover at the scope you will query.',
|
|
109
|
+
},
|
|
98
110
|
},
|
|
99
111
|
required: ['dataSourceName'],
|
|
100
112
|
},
|
|
101
|
-
async handler({ dataSourceName, maxEvents = 5, startTime = '24h' }) {
|
|
113
|
+
async handler({ dataSourceName, maxEvents = 5, startTime = '24h', scope }) {
|
|
102
114
|
// Escape backslashes first, then single quotes, to keep tenant-defined
|
|
103
115
|
// source names from breaking (or altering) the V1 filter expression.
|
|
104
116
|
// Quote-only escaping let a trailing backslash neutralise the added
|
|
105
117
|
// escape (e.g. name\' -> \\' which re-opens the string).
|
|
106
118
|
const safeName = String(dataSourceName).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
107
119
|
const filter = `dataSource.name=='${safeName}'`;
|
|
108
|
-
const result = await v1Query(filter, { maxCount: Math.min(maxEvents, 50), startTime });
|
|
120
|
+
const result = await v1Query(filter, { maxCount: Math.min(maxEvents, 50), startTime, scope });
|
|
109
121
|
|
|
110
122
|
const matches = result.matches || [];
|
|
111
123
|
if (matches.length === 0) {
|
package/tools/sdl-api.js
CHANGED
|
@@ -213,13 +213,13 @@ export const tools = [
|
|
|
213
213
|
// ─── sdl_create_dashboard ─────────────────────────────────────────────────
|
|
214
214
|
{
|
|
215
215
|
name: 'sdl_create_dashboard',
|
|
216
|
-
description: `Create a dashboard from a complete dashboard-JSON document via createDashboardV2, filed at the given scope. THIS IS THE PREFERRED WAY TO DEPLOY A NEW DASHBOARD: it accepts the whole document (configType, duration, description, tabs[]) in one call, unlike sdl_put_file which writes the raw config file. It also avoids the console's stub-append trap, where creating an empty dashboard in the UI and pasting JSON after the existing "{graphs: []}" stub yields "Content is invalid json / Additional text after JSON object" and leaves an empty dashboard behind. To deploy to a SITE, either pass scope as "<accountId>:<siteId>" here, or create at account scope and then use sdl_share_dashboard. Duplicate names ARE allowed (the console itself makes "<name> - Copy" siblings); set failIfNameExists to refuse instead. ${SCOPE_NOTE}`,
|
|
216
|
+
description: `Create a dashboard from a complete dashboard-JSON document via createDashboardV2, filed at the given scope. THIS IS THE PREFERRED WAY TO DEPLOY A NEW DASHBOARD: it accepts the whole document (configType, duration, description, tabs[]) in one call, unlike sdl_put_file which writes the raw config file. It also avoids the console's stub-append trap, where creating an empty dashboard in the UI and pasting JSON after the existing "{graphs: []}" stub yields "Content is invalid json / Additional text after JSON object" and leaves an empty dashboard behind. To deploy to a SITE, either pass scope as "<accountId>:<siteId>" here, or create at account scope and then use sdl_share_dashboard. Duplicate names ARE allowed (the console itself makes "<name> - Copy" siblings); set failIfNameExists to refuse instead. Names reject punctuation with only "Invalid name" as the error: accepted are letters, digits, space, hyphen, underscore, dot and slash; rejected are ( ) [ ] { } : , & ' % #. ${SCOPE_NOTE}`,
|
|
217
217
|
inputSchema: {
|
|
218
218
|
type: 'object',
|
|
219
219
|
properties: {
|
|
220
220
|
name: { type: 'string', description: 'Dashboard display name, e.g. "Metacortex Site Replica".' },
|
|
221
221
|
config: { type: 'string', description: 'The full dashboard JSON document as a string: {"configType":"TABBED","duration":"24h","description":"...","tabs":[...]}. Validated as JSON before the mutation is sent.' },
|
|
222
|
-
isPublic: { type: 'boolean', description: 'Share with all users in scope (the console\'s "Public" badge).
|
|
222
|
+
isPublic: { type: 'boolean', default: true, description: 'Share with all users in scope (the console\'s "Public" badge). DEFAULTS TO TRUE, unlike the raw API. access.owner is the calling identity, so with a service-account token a private dashboard is readable via API but INVISIBLE in the console to the human operator at any scope, which looks exactly like a failed deploy. Set false only if the dashboard should stay private to the service account.' },
|
|
223
223
|
failIfNameExists: { type: 'boolean', description: 'Refuse if a dashboard of this name already exists at this scope. Default false, which permits siblings. Costs one extra listing call.' },
|
|
224
224
|
scope: scopeProp,
|
|
225
225
|
},
|