@pmoses-s1/s1-secops-mcp 1.3.2 → 1.3.4
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 +166 -2
- package/README.md +16 -8
- package/deploy/README.md +18 -5
- package/deploy/bridge/README.md +2 -2
- package/deploy/bridge/s1-secops-mcp-bridge.mjs +1 -1
- package/index.js +1 -1
- package/lib/credentials.js +6 -0
- package/lib/sdl.js +406 -26
- package/lib/server-core.js +1 -1
- package/package.json +1 -1
- package/scripts/regen-readme-tools-table.mjs +7 -0
- package/scripts/smoke-test-http.sh +2 -2
- package/scripts/test-mac.sh +4 -4
- package/tools/sdl-api.js +188 -20
package/lib/sdl.js
CHANGED
|
@@ -26,6 +26,48 @@ export function sdlToken() {
|
|
|
26
26
|
return token;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the S1-Scope header value for a request.
|
|
31
|
+
*
|
|
32
|
+
* SDL objects (dashboards, saved searches, config files) are filed against the
|
|
33
|
+
* scope the request carries, and reads are filtered by it. Verified live on
|
|
34
|
+
* usea1-purple 2026-08-17, same token: `configFiles` returned 113 files at
|
|
35
|
+
* account scope (20 of them dashboards) and 4 at a site scope (all 4
|
|
36
|
+
* dashboards). A dashboard created at site scope is invisible to an
|
|
37
|
+
* account-scoped listing, so a missing header is not a neutral default, it
|
|
38
|
+
* silently changes which objects exist as far as the caller can tell.
|
|
39
|
+
*
|
|
40
|
+
* Precedence: explicit per-call scope, then S1_SCOPE from credentials. Passing
|
|
41
|
+
* `null` is NOT the same as omitting: `null` deliberately suppresses the creds
|
|
42
|
+
* default and sends no header, which is what the account-wide listing needs.
|
|
43
|
+
*
|
|
44
|
+
* Format: "<accountId>" for account scope, "<accountId>:<siteId>" for site
|
|
45
|
+
* scope. Group scope does not exist in SDL; the console silently promotes a
|
|
46
|
+
* Group selection to the Site above it.
|
|
47
|
+
*/
|
|
48
|
+
function resolveScope(scope) {
|
|
49
|
+
if (scope === null) return null;
|
|
50
|
+
const raw = scope !== undefined ? scope : getCreds().S1_SCOPE;
|
|
51
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
52
|
+
if (typeof raw !== 'string') {
|
|
53
|
+
throw new Error(`S1-Scope must be a string, got ${typeof raw}. Use "<accountId>" or "<accountId>:<siteId>".`);
|
|
54
|
+
}
|
|
55
|
+
const trimmed = raw.trim();
|
|
56
|
+
if (!/^\d+(:\d+)?$/.test(trimmed)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Invalid S1-Scope ${JSON.stringify(trimmed)}. Expected "<accountId>" or "<accountId>:<siteId>", ` +
|
|
59
|
+
'both numeric ids. Get them from GET /web/api/v2.1/accounts and /web/api/v2.1/sites.'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return trimmed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Header object carrying S1-Scope, or empty when the request is unscoped. */
|
|
66
|
+
function scopeHeaders(scope) {
|
|
67
|
+
const resolved = resolveScope(scope);
|
|
68
|
+
return resolved ? { 'S1-Scope': resolved } : {};
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
30
72
|
|
|
31
73
|
function retryAfterMs(res, fallback) {
|
|
@@ -133,6 +175,17 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
|
|
|
133
175
|
// dashboard by udoId. Skipping that rule is how one tenant accumulated 152
|
|
134
176
|
// copies of `/dashboards/AI Usage`.
|
|
135
177
|
|
|
178
|
+
/** Marks an error as originating from the GraphQL layer rather than the
|
|
179
|
+
* transport. Absence detection keys off this: a 404 page whose body contains
|
|
180
|
+
* "not found" must never be read as "the file does not exist". */
|
|
181
|
+
class SdlGraphqlError extends Error {
|
|
182
|
+
constructor(message) {
|
|
183
|
+
super(message);
|
|
184
|
+
this.name = 'SdlGraphqlError';
|
|
185
|
+
this.graphql = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
/**
|
|
137
190
|
* POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array.
|
|
138
191
|
*
|
|
@@ -149,14 +202,15 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
|
|
|
149
202
|
* 3. Return a payload carrying neither `data` nor `errors`.
|
|
150
203
|
*
|
|
151
204
|
* `readOnly` opts into status-based retry; only pass it for queries.
|
|
205
|
+
* `scope` sets the S1-Scope header; see resolveScope for precedence.
|
|
152
206
|
*/
|
|
153
|
-
async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
|
|
207
|
+
async function sdlGraphql(opname, query, variables, { readOnly = false, scope } = {}) {
|
|
154
208
|
const body = { query };
|
|
155
209
|
if (variables) body.variables = variables;
|
|
156
210
|
const payload = await sdlFetch(
|
|
157
211
|
'POST',
|
|
158
212
|
`/v2/graphql?opname=${encodeURIComponent(opname)}`,
|
|
159
|
-
{ body, allowRetry: readOnly }
|
|
213
|
+
{ body, allowRetry: readOnly, extraHeaders: scopeHeaders(scope) }
|
|
160
214
|
);
|
|
161
215
|
|
|
162
216
|
if (typeof payload !== 'object' || payload === null) {
|
|
@@ -170,10 +224,10 @@ async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
|
|
|
170
224
|
const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors];
|
|
171
225
|
const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId;
|
|
172
226
|
const msg = errs[0]?.message || 'unknown GraphQL error';
|
|
173
|
-
throw new
|
|
227
|
+
throw new SdlGraphqlError(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
|
|
174
228
|
}
|
|
175
229
|
if (!('data' in payload)) {
|
|
176
|
-
throw new
|
|
230
|
+
throw new SdlGraphqlError(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
|
|
177
231
|
}
|
|
178
232
|
return payload.data;
|
|
179
233
|
}
|
|
@@ -191,24 +245,84 @@ function assertSafeUdoId(udoId) {
|
|
|
191
245
|
|
|
192
246
|
const CONFIG_FIELDS = 'udoId name readOnly version';
|
|
193
247
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
248
|
+
/** SDL config names are case-insensitive and tolerate stray whitespace, so the
|
|
249
|
+
* absence check and the duplicate guard must normalise identically. */
|
|
250
|
+
function normaliseName(n) {
|
|
251
|
+
return String(n ?? '').trim().toLowerCase();
|
|
252
|
+
}
|
|
253
|
+
function matchesName(file, name) {
|
|
254
|
+
return normaliseName(file?.name) === normaliseName(name);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every config file visible at `scope`, including udoId-addressed dashboards.
|
|
258
|
+
* This listing IS scope-filtered: a site-scoped dashboard does not appear in an
|
|
259
|
+
* account-scoped listing and vice versa. */
|
|
260
|
+
export async function configFiles({ scope } = {}) {
|
|
196
261
|
const data = await sdlGraphql(
|
|
197
262
|
'getConfigurationFiles',
|
|
198
263
|
`query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
|
|
199
264
|
undefined,
|
|
200
|
-
{ readOnly: true }
|
|
265
|
+
{ readOnly: true, scope }
|
|
201
266
|
);
|
|
202
267
|
return data?.configFiles ?? [];
|
|
203
268
|
}
|
|
204
269
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Read one config file by name (plain files) or udoId (dashboards).
|
|
272
|
+
* Returns null when the file does not exist.
|
|
273
|
+
*
|
|
274
|
+
* Absence is a normal outcome of a lookup, but the server reports it as a
|
|
275
|
+
* GraphQL error, and the message differs by address form (verified live):
|
|
276
|
+
*
|
|
277
|
+
* by name : "Config file with name /x/y not found." <- explicit
|
|
278
|
+
* by udoId: "Something went wrong. Please try again..." <- generic, and the
|
|
279
|
+
* SAME text a version conflict returns, so it cannot be trusted
|
|
280
|
+
* on message alone.
|
|
281
|
+
*
|
|
282
|
+
* So the explicit form is normalised directly, and the ambiguous one is
|
|
283
|
+
* disambiguated against the file listing: absent from configFiles means the
|
|
284
|
+
* file is genuinely gone, otherwise the error was real and is rethrown. The
|
|
285
|
+
* extra listing only happens on the error path.
|
|
286
|
+
*/
|
|
287
|
+
export async function configFile({ name, udoId, scope }) {
|
|
207
288
|
if (!name && !udoId) throw new Error('configFile requires either name or udoId');
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
289
|
+
// Validate before the try: an invalid udoId is a caller bug, not a signal
|
|
290
|
+
// that the file is absent, and must never be swallowed by the absence path.
|
|
291
|
+
const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
|
|
292
|
+
try {
|
|
293
|
+
const data = safeUdoId
|
|
294
|
+
? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true, scope })
|
|
295
|
+
: await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true, scope });
|
|
296
|
+
return data?.configFile ?? null;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
// Only a GraphQL-layer error can mean "absent". A transport failure whose
|
|
299
|
+
// body happens to contain the words "not found" (a 404 page, a WAF block)
|
|
300
|
+
// must never be read as absence: that is how a delete gets confirmed
|
|
301
|
+
// against a file that was never checked.
|
|
302
|
+
if (!err.graphql) throw err;
|
|
303
|
+
if (/config file with (name|id) .* not found/i.test(err.message)) return null;
|
|
304
|
+
|
|
305
|
+
// The udoId form returns a generic message that a version conflict also
|
|
306
|
+
// returns, so settle it against the listing. If the listing itself fails,
|
|
307
|
+
// surface the ORIGINAL error with the listing failure attached rather than
|
|
308
|
+
// replacing it.
|
|
309
|
+
// Same scope as the failed lookup. Disambiguating against a DIFFERENT
|
|
310
|
+
// scope's listing would report a site-scoped file as absent purely because
|
|
311
|
+
// the listing was taken at account scope, which is the exact false-negative
|
|
312
|
+
// class this branch exists to remove.
|
|
313
|
+
let all;
|
|
314
|
+
try {
|
|
315
|
+
all = await configFiles({ scope });
|
|
316
|
+
} catch (listErr) {
|
|
317
|
+
err.message += ` (absence check failed: ${listErr.message})`;
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
const present = udoId
|
|
321
|
+
? all.some(f => String(f.udoId) === String(udoId))
|
|
322
|
+
: all.some(f => matchesName(f, name));
|
|
323
|
+
if (!present) return null;
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
212
326
|
}
|
|
213
327
|
|
|
214
328
|
/**
|
|
@@ -217,13 +331,16 @@ export async function configFile({ name, udoId }) {
|
|
|
217
331
|
* - name given → updates in place for plain files, but CREATES A DUPLICATE
|
|
218
332
|
* for /dashboards/. Never write a dashboard by name.
|
|
219
333
|
*/
|
|
220
|
-
export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
334
|
+
export async function putConfigFile({ name, udoId, content, expectedVersion, scope }) {
|
|
221
335
|
if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
|
|
222
336
|
// Creating a dashboard must go by name (no udoId exists yet); only an
|
|
223
337
|
// *existing* dashboard is at risk of being duplicated by a name-addressed
|
|
224
338
|
// write. So refuse only when a file of that name already exists.
|
|
225
|
-
if (!udoId &&
|
|
226
|
-
|
|
339
|
+
if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
|
|
340
|
+
// Scoped to match the write. An account-scoped listing would not see a
|
|
341
|
+
// same-named site-scoped dashboard, so the guard has to look where the
|
|
342
|
+
// write is going, not where the token defaults.
|
|
343
|
+
const all = await configFiles({ scope });
|
|
227
344
|
// An empty listing means the check could not run, not that the name is
|
|
228
345
|
// free. Failing open here would silently disable the guard.
|
|
229
346
|
if (!all.length) {
|
|
@@ -232,8 +349,7 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
|
232
349
|
'duplicate check could not run. Retry, or pass an explicit udoId.'
|
|
233
350
|
);
|
|
234
351
|
}
|
|
235
|
-
const
|
|
236
|
-
const existing = all.filter(f => String(f.name || '').trim().toLowerCase() === key);
|
|
352
|
+
const existing = all.filter(f => matchesName(f, name));
|
|
237
353
|
if (existing.length) {
|
|
238
354
|
const ids = existing.map(f => f.udoId).filter(Boolean).join(', ');
|
|
239
355
|
throw new Error(
|
|
@@ -251,10 +367,10 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
|
251
367
|
const data = udoId
|
|
252
368
|
? await sdlGraphql('addConfigFile',
|
|
253
369
|
`mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
254
|
-
{ udoId: assertSafeUdoId(udoId), content, expectedVersion })
|
|
370
|
+
{ udoId: assertSafeUdoId(udoId), content, expectedVersion }, { scope })
|
|
255
371
|
: await sdlGraphql('addConfigFile',
|
|
256
372
|
`mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
257
|
-
{ name, content, expectedVersion });
|
|
373
|
+
{ name, content, expectedVersion }, { scope });
|
|
258
374
|
return data?.addConfigFile ?? null;
|
|
259
375
|
}
|
|
260
376
|
|
|
@@ -263,21 +379,21 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
|
263
379
|
* A null return with no errors array is SUCCESS; the deleted object is not
|
|
264
380
|
* echoed back. Treating that null as a failure is the classic mistake here.
|
|
265
381
|
*/
|
|
266
|
-
export async function deleteConfigFile({ name, udoId, expectedVersion }) {
|
|
382
|
+
export async function deleteConfigFile({ name, udoId, expectedVersion, scope }) {
|
|
267
383
|
if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
|
|
268
384
|
const raw = udoId
|
|
269
385
|
? await sdlGraphql('deleteConfigFile',
|
|
270
386
|
'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
|
|
271
|
-
{ udoId: assertSafeUdoId(udoId), expectedVersion })
|
|
387
|
+
{ udoId: assertSafeUdoId(udoId), expectedVersion }, { scope })
|
|
272
388
|
: await sdlGraphql('deleteConfigFile',
|
|
273
389
|
'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
|
|
274
|
-
{ id: name, expectedVersion });
|
|
390
|
+
{ id: name, expectedVersion }, { scope });
|
|
275
391
|
|
|
276
392
|
// The mutation returns null on success and does not echo the deleted object,
|
|
277
393
|
// so its response cannot distinguish "deleted" from "matched nothing". Confirm
|
|
278
394
|
// by re-reading. This is the house rule established by uamSetStatus in
|
|
279
395
|
// lib/s1.js: never treat a mutation response as proof, re-get and verify.
|
|
280
|
-
const still = await configFile({ name, udoId });
|
|
396
|
+
const still = await configFile({ name, udoId, scope });
|
|
281
397
|
if (still) {
|
|
282
398
|
throw new Error(
|
|
283
399
|
`deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
|
|
@@ -293,13 +409,275 @@ export async function deleteConfigFile({ name, udoId, expectedVersion }) {
|
|
|
293
409
|
// exist" decision. The GraphQL operations above cover every namespace,
|
|
294
410
|
// including parsers, lookups, datatables and /automaticLookups.
|
|
295
411
|
|
|
412
|
+
// ─── Dashboard lifecycle (dashboardsV2, GraphQL) ──────────────────────────────
|
|
413
|
+
//
|
|
414
|
+
// A SECOND, HIGHER-LEVEL SURFACE on the same `POST /sdl/v2/graphql` endpoint.
|
|
415
|
+
// This is what the console itself drives; captured from live console traffic on
|
|
416
|
+
// usea1-purple 2026-08-17 (280 requests, 23 operations).
|
|
417
|
+
//
|
|
418
|
+
// Relationship to the config-file layer above:
|
|
419
|
+
//
|
|
420
|
+
// dashboardsV2 dashboard-aware: name, description, tabs, access/sharing,
|
|
421
|
+
// createdBy/updatedBy, isBuiltIn/isEditable. Create takes
|
|
422
|
+
// the whole dashboard JSON as one `config` string.
|
|
423
|
+
// configFiles the raw file underneath, addressed by udoId. Same object,
|
|
424
|
+
// no sharing or authorship metadata, `content` is the JSON.
|
|
425
|
+
//
|
|
426
|
+
// The `id` in dashboardsV2 IS the `udoId` in configFiles. Verified: dashboard
|
|
427
|
+
// "meta1" is id 6999000578736128 in getDashboardV2 and udoId 6999000578736128
|
|
428
|
+
// / name "/dashboards/meta1" in configFile.
|
|
429
|
+
//
|
|
430
|
+
// WHY THIS EXISTS: creating a dashboard through addConfigFile(name:) files it at
|
|
431
|
+
// the request's scope but gives no way to share it elsewhere, and the console's
|
|
432
|
+
// own create path is createDashboardV2. Site-level lifecycle needs both this and
|
|
433
|
+
// shareResource, which is the ONLY operation that takes an explicit scope target
|
|
434
|
+
// rather than inferring one from the request header.
|
|
435
|
+
//
|
|
436
|
+
// VERSION FIELDS DIFFER, do not cross them. getDashboardV2 returns
|
|
437
|
+
// `version: ""` (a display string, empty in practice); configFile returns
|
|
438
|
+
// `version: 215771284` (the numeric CAS token). Only the configFile value is
|
|
439
|
+
// valid as expectedVersion.
|
|
440
|
+
|
|
441
|
+
const DASHBOARD_SUMMARY_FIELDS = 'id name description configType access { public users owner }';
|
|
442
|
+
|
|
443
|
+
/** Every dashboard visible at `scope`, with sharing metadata. Prefer this over
|
|
444
|
+
* configFiles({pathPrefix:'/dashboards/'}) when you need owner or access. */
|
|
445
|
+
export async function listDashboards({ scope } = {}) {
|
|
446
|
+
const data = await sdlGraphql(
|
|
447
|
+
'GetDashboardNames',
|
|
448
|
+
`query GetDashboardNames { dashboardsV2 { ${DASHBOARD_SUMMARY_FIELDS} } }`,
|
|
449
|
+
undefined,
|
|
450
|
+
{ readOnly: true, scope }
|
|
451
|
+
);
|
|
452
|
+
return data?.dashboardsV2 ?? [];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Read one dashboard by id (preferred) or name, including its tabs.
|
|
457
|
+
* Returns null when it does not exist at `scope`.
|
|
458
|
+
*
|
|
459
|
+
* `tabs[].graphs` / `.parameters` / `.filters` / `.options` come back as JSON
|
|
460
|
+
* STRINGS, not objects; the console parses them client-side. Callers that want
|
|
461
|
+
* structure must JSON.parse each one.
|
|
462
|
+
*
|
|
463
|
+
* ABSENCE HANDLING mirrors configFile deliberately. The live capture only shows
|
|
464
|
+
* a successful read, so it is not established whether a missing dashboard comes
|
|
465
|
+
* back as `data.getDashboardV2 = null` or as a GraphQL error. Both are treated
|
|
466
|
+
* as absence, disambiguated against the dashboard listing. Assuming only the
|
|
467
|
+
* null form is what broke every `sdl_delete_file` in 1.3.2: the confirming
|
|
468
|
+
* re-read threw on precisely the success path. A transport-layer error is still
|
|
469
|
+
* rethrown, so a proxy page containing "not found" can never be read as absence.
|
|
470
|
+
*/
|
|
471
|
+
export async function getDashboard({ id, name, scope }) {
|
|
472
|
+
if (!id && !name) throw new Error('getDashboard requires either id or name');
|
|
473
|
+
const safeId = id ? assertSafeUdoId(id) : undefined;
|
|
474
|
+
try {
|
|
475
|
+
const data = await sdlGraphql(
|
|
476
|
+
'GetDashboardConfigV2',
|
|
477
|
+
`query GetDashboardConfigV2($id: ID, $dashboardName: String) {
|
|
478
|
+
getDashboardV2(id: $id, dashboardName: $dashboardName, resolveParameters: true) {
|
|
479
|
+
id name description configType duration isBuiltIn isEditable version
|
|
480
|
+
access { public users owner }
|
|
481
|
+
tabs { tabName parameters graphs filters options }
|
|
482
|
+
createdAt createdBy updatedAt updatedBy
|
|
483
|
+
}
|
|
484
|
+
}`,
|
|
485
|
+
{ id: safeId, dashboardName: name },
|
|
486
|
+
{ readOnly: true, scope }
|
|
487
|
+
);
|
|
488
|
+
return data?.getDashboardV2 ?? null;
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (!err.graphql) throw err;
|
|
491
|
+
let all;
|
|
492
|
+
try {
|
|
493
|
+
all = await listDashboards({ scope });
|
|
494
|
+
} catch (listErr) {
|
|
495
|
+
err.message += ` (absence check failed: ${listErr.message})`;
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
const present = safeId
|
|
499
|
+
? all.some(d => String(d.id) === safeId)
|
|
500
|
+
: all.some(d => normaliseName(d.name) === normaliseName(name));
|
|
501
|
+
if (!present) return null;
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Create a dashboard from a full dashboard-JSON config, at `scope`.
|
|
508
|
+
*
|
|
509
|
+
* This is the console's own create path and it accepts the complete dashboard
|
|
510
|
+
* document (configType, duration, description, tabs[]) as ONE string. That is
|
|
511
|
+
* the important difference from the UI's "new dashboard then paste JSON" flow,
|
|
512
|
+
* which starts from a `{graphs: []}` stub: pasting after the stub instead of
|
|
513
|
+
* replacing it produces `{graphs: []}{...}` and the server rejects it with
|
|
514
|
+
* "Content is invalid json" / "Additional text after JSON object". Going
|
|
515
|
+
* through this function cannot hit that class of error.
|
|
516
|
+
*
|
|
517
|
+
* DUPLICATE NAMES ARE ALLOWED HERE, unlike putConfigFile. The console itself
|
|
518
|
+
* creates "<name> - Copy" siblings, and shareResource addresses dashboards by
|
|
519
|
+
* id, so duplicate names are not the footgun they are for name-addressed
|
|
520
|
+
* config-file writes. Set `failIfNameExists` to opt into the stricter
|
|
521
|
+
* behaviour; it costs one extra listing call.
|
|
522
|
+
*/
|
|
523
|
+
export async function createDashboard({ name, config, isPublic = false, scope, failIfNameExists = false }) {
|
|
524
|
+
if (!name || typeof name !== 'string') throw new Error('createDashboard requires a name');
|
|
525
|
+
if (typeof config !== 'string' || !config.trim()) {
|
|
526
|
+
throw new Error('createDashboard requires config as a JSON string (the full dashboard document).');
|
|
527
|
+
}
|
|
528
|
+
// Fail before the mutation rather than filing a broken dashboard the console
|
|
529
|
+
// then renders as an empty shell.
|
|
530
|
+
try {
|
|
531
|
+
JSON.parse(config);
|
|
532
|
+
} catch (e) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`createDashboard: config is not valid JSON (${e.message}). ` +
|
|
535
|
+
'If this came from the console\'s JSON editor, check for a leading "{graphs: []}" stub: ' +
|
|
536
|
+
'the new document must REPLACE it, not follow it.'
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (failIfNameExists) {
|
|
541
|
+
const existing = (await listDashboards({ scope })).filter(d => normaliseName(d.name) === normaliseName(name));
|
|
542
|
+
if (existing.length) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`createDashboard: ${existing.length} dashboard(s) named "${name}" already exist at this scope ` +
|
|
545
|
+
`(ids: ${existing.map(d => d.id).join(', ')}). Pass failIfNameExists:false to create a sibling anyway.`
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const data = await sdlGraphql(
|
|
551
|
+
'CreateDashboard',
|
|
552
|
+
`mutation CreateDashboard($dashboardName: String!, $config: String, $public: Boolean) {
|
|
553
|
+
createDashboardV2(dashboardName: $dashboardName, config: $config, public: $public) { id name }
|
|
554
|
+
}`,
|
|
555
|
+
{ dashboardName: name, config, public: isPublic },
|
|
556
|
+
{ scope }
|
|
557
|
+
);
|
|
558
|
+
const created = data?.createDashboardV2 ?? null;
|
|
559
|
+
if (!created?.id) {
|
|
560
|
+
throw new SdlGraphqlError('createDashboard: mutation returned no id, so the dashboard was not created.');
|
|
561
|
+
}
|
|
562
|
+
return created;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Share a dashboard to scopes and/or users. THE ONLY OPERATION THAT TAKES AN
|
|
567
|
+
* EXPLICIT SCOPE TARGET; everything else infers scope from the S1-Scope header.
|
|
568
|
+
*
|
|
569
|
+
* `scopes` entries are {scopeType, scopeId, operation}:
|
|
570
|
+
* scopeType 'site' | 'account' | 'global'
|
|
571
|
+
* scopeId the numeric id from /web/api/v2.1/sites or /accounts
|
|
572
|
+
* operation 'ADD' | 'REMOVE'
|
|
573
|
+
*
|
|
574
|
+
* `scope` (the option, not the array) is still the header for the CALL, i.e.
|
|
575
|
+
* where you are standing when you share. It is independent of the targets.
|
|
576
|
+
*/
|
|
577
|
+
const VALID_SCOPE_TYPES = new Set(['site', 'account', 'global']);
|
|
578
|
+
const VALID_SCOPE_OPS = new Set(['ADD', 'REMOVE']);
|
|
579
|
+
|
|
580
|
+
export async function shareDashboard({ id, scopes = [], users = [], scope }) {
|
|
581
|
+
if (!id) throw new Error('shareDashboard requires the dashboard id');
|
|
582
|
+
if (!Array.isArray(scopes) || !Array.isArray(users)) {
|
|
583
|
+
throw new Error('shareDashboard: scopes and users must both be arrays.');
|
|
584
|
+
}
|
|
585
|
+
if (!scopes.length && !users.length) {
|
|
586
|
+
throw new Error('shareDashboard: pass at least one scope or user, otherwise the call is a no-op.');
|
|
587
|
+
}
|
|
588
|
+
// Validate up front: the server accepts a malformed entry and silently shares
|
|
589
|
+
// nothing, which reads as success.
|
|
590
|
+
const normalisedScopes = scopes.map((s, i) => {
|
|
591
|
+
const type = String(s?.scopeType ?? '').toLowerCase();
|
|
592
|
+
const op = String(s?.operation ?? 'ADD').toUpperCase();
|
|
593
|
+
if (!VALID_SCOPE_TYPES.has(type)) {
|
|
594
|
+
throw new Error(`shareDashboard: scopes[${i}].scopeType must be one of ${[...VALID_SCOPE_TYPES].join(', ')} (got ${JSON.stringify(s?.scopeType)}).`);
|
|
595
|
+
}
|
|
596
|
+
if (!VALID_SCOPE_OPS.has(op)) {
|
|
597
|
+
throw new Error(`shareDashboard: scopes[${i}].operation must be ADD or REMOVE (got ${JSON.stringify(s?.operation)}).`);
|
|
598
|
+
}
|
|
599
|
+
if (type !== 'global' && !/^\d+$/.test(String(s?.scopeId ?? ''))) {
|
|
600
|
+
throw new Error(`shareDashboard: scopes[${i}].scopeId must be a numeric id for scopeType "${type}" (got ${JSON.stringify(s?.scopeId)}).`);
|
|
601
|
+
}
|
|
602
|
+
return { scopeType: type, scopeId: String(s.scopeId), operation: op };
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const data = await sdlGraphql(
|
|
606
|
+
'ShareDashboard',
|
|
607
|
+
`mutation ShareDashboard($id: ID!, $users: [UserSharingCommand], $scopes: [ScopeSharingCommand]) {
|
|
608
|
+
shareResource(id: $id, users: $users, scopes: $scopes) { id name }
|
|
609
|
+
}`,
|
|
610
|
+
{ id: assertSafeUdoId(id), users, scopes: normalisedScopes },
|
|
611
|
+
{ scope }
|
|
612
|
+
);
|
|
613
|
+
const shared = data?.shareResource ?? null;
|
|
614
|
+
if (!shared?.id) {
|
|
615
|
+
throw new SdlGraphqlError('shareDashboard: shareResource returned no id, so nothing was shared.');
|
|
616
|
+
}
|
|
617
|
+
return { status: 'success', dashboard: { id: String(shared.id), name: shared.name }, scopes: normalisedScopes, users };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Replace the panel layout of ONE tab. `graphs` is a JSON string shaped
|
|
622
|
+
* `{"graphs":[...]}` (note the wrapper key; the response echoes a bare array).
|
|
623
|
+
* Use this for incremental panel edits; use createDashboard for a whole document.
|
|
624
|
+
*/
|
|
625
|
+
export async function saveDashboardLayout({ id, name, tabName, graphs, options, scope }) {
|
|
626
|
+
if (!id && !name) throw new Error('saveDashboardLayout requires either id or name');
|
|
627
|
+
if (typeof graphs !== 'string' || !graphs.trim()) {
|
|
628
|
+
throw new Error('saveDashboardLayout requires graphs as a JSON string, shaped {"graphs":[...]}.');
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(graphs);
|
|
632
|
+
if (!parsed || !Array.isArray(parsed.graphs)) {
|
|
633
|
+
throw new Error('missing the top-level "graphs" array');
|
|
634
|
+
}
|
|
635
|
+
} catch (e) {
|
|
636
|
+
throw new Error(`saveDashboardLayout: graphs is not a valid {"graphs":[...]} JSON string (${e.message}).`);
|
|
637
|
+
}
|
|
638
|
+
const data = await sdlGraphql(
|
|
639
|
+
'SaveDashboardLayout',
|
|
640
|
+
`mutation SaveDashboardLayout($id: ID, $dashboardName: String, $graphs: String, $options: String, $tabName: String) {
|
|
641
|
+
saveDashboardLayout(id: $id, dashboardName: $dashboardName, graphs: $graphs, options: $options, tabName: $tabName) { graphs options }
|
|
642
|
+
}`,
|
|
643
|
+
{ id: id ? assertSafeUdoId(id) : undefined, dashboardName: name, graphs, options, tabName },
|
|
644
|
+
{ scope }
|
|
645
|
+
);
|
|
646
|
+
return data?.saveDashboardLayout ?? null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Delete a dashboard by id or name. `deleteDashboard` returns a bare boolean,
|
|
651
|
+
* so per the house rule the removal is confirmed by re-reading rather than
|
|
652
|
+
* trusted from the mutation response.
|
|
653
|
+
*/
|
|
654
|
+
export async function deleteDashboard({ id, name, scope }) {
|
|
655
|
+
if (!id && !name) throw new Error('deleteDashboard requires either id or name');
|
|
656
|
+
const data = await sdlGraphql(
|
|
657
|
+
'DeleteDashboard',
|
|
658
|
+
'mutation DeleteDashboard($id: ID, $dashboardName: String) { deleteDashboard(id: $id, dashboardName: $dashboardName) }',
|
|
659
|
+
{ id: id ? assertSafeUdoId(id) : undefined, dashboardName: name },
|
|
660
|
+
{ scope }
|
|
661
|
+
);
|
|
662
|
+
const reported = data?.deleteDashboard;
|
|
663
|
+
|
|
664
|
+
const still = await getDashboard({ id, name, scope });
|
|
665
|
+
if (still) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
`deleteDashboard: ${id ? `id ${id}` : name} still exists after the delete mutation ` +
|
|
668
|
+
`(mutation returned ${JSON.stringify(reported)}). Nothing was removed.`
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
return { status: 'success', deleted: id ? { id: String(id) } : { name }, raw: reported ?? null };
|
|
672
|
+
}
|
|
673
|
+
|
|
296
674
|
// ─── V1 Query (schema discovery) ─────────────────────────────────────────────
|
|
297
675
|
// Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
|
|
298
676
|
// Use for schema discovery; use LRQ for hunting.
|
|
299
677
|
|
|
300
678
|
/** POST /api/query: retrieve raw event JSON for schema discovery.
|
|
301
679
|
* Returns { matches: [{ timestamp, message, attributes }] }. */
|
|
302
|
-
export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
|
|
680
|
+
export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime, scope } = {}) {
|
|
303
681
|
const body = {
|
|
304
682
|
queryType: 'log',
|
|
305
683
|
filter,
|
|
@@ -307,5 +685,7 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
|
|
|
307
685
|
startTime,
|
|
308
686
|
};
|
|
309
687
|
if (endTime) body.endTime = endTime;
|
|
310
|
-
|
|
688
|
+
// Read-only POST: opt back into status retry. Schema discovery iterates this
|
|
689
|
+
// once per data source, which is the workload that trips the SDL QPS cap.
|
|
690
|
+
return sdlFetch('POST', '/api/query', { body, allowRetry: true, extraHeaders: scopeHeaders(scope) });
|
|
311
691
|
}
|
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.4",
|
|
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,6 +44,13 @@ const TOOL_SKILL = {
|
|
|
44
44
|
sdl_get_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
|
|
45
45
|
sdl_put_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
|
|
46
46
|
sdl_delete_file: 'sdl-api',
|
|
47
|
+
// SDL dashboard lifecycle (dashboardsV2)
|
|
48
|
+
sdl_list_dashboards: 'sdl-api / sdl-dashboard',
|
|
49
|
+
sdl_get_dashboard: 'sdl-api / sdl-dashboard',
|
|
50
|
+
sdl_create_dashboard: 'sdl-api / sdl-dashboard',
|
|
51
|
+
sdl_share_dashboard: 'sdl-api / sdl-dashboard',
|
|
52
|
+
sdl_save_dashboard_layout: 'sdl-api / sdl-dashboard',
|
|
53
|
+
sdl_delete_dashboard: 'sdl-api / sdl-dashboard',
|
|
47
54
|
hec_ingest: 'sdl-api / sdl-log-parser',
|
|
48
55
|
// Hyperautomation
|
|
49
56
|
ha_list_workflows: 'hyperautomation',
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Exercises the public contract end-to-end:
|
|
6
6
|
# 1. healthz returns 200 (no auth)
|
|
7
7
|
# 2. initialize returns the expected protocol version and server info
|
|
8
|
-
# 3. tools/list returns
|
|
8
|
+
# 3. tools/list returns 32 tools
|
|
9
9
|
# 4. tools/call s1_api_get works (uses /agents/count as a cheap probe)
|
|
10
10
|
# 5. bad bearer returns HTTP 401
|
|
11
11
|
# 6. unknown method returns JSON-RPC error -32601 inside a 200 envelope
|
|
@@ -81,7 +81,7 @@ echo "=== 3. tools/list count ==="
|
|
|
81
81
|
TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
|
|
82
82
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
|
|
83
83
|
jq '.result.tools | length')
|
|
84
|
-
[[ "$TOOLS_COUNT" == "
|
|
84
|
+
[[ "$TOOLS_COUNT" == "32" ]] && pass "tools/list returned 32 tools" || fail "tools/list returned $TOOLS_COUNT"
|
|
85
85
|
|
|
86
86
|
echo
|
|
87
87
|
echo "=== 4. tools/call s1_api_get on /agents/count ==="
|
package/scripts/test-mac.sh
CHANGED
|
@@ -128,8 +128,8 @@ STDIO_REPLY="$(printf '%s\n%s\n' \
|
|
|
128
128
|
node index.js 2>/dev/null)"
|
|
129
129
|
|
|
130
130
|
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")}})')"
|
|
131
|
-
if [[ "$TOOL_COUNT" == "
|
|
132
|
-
pass "stdio tools/list returned
|
|
131
|
+
if [[ "$TOOL_COUNT" == "32" ]]; then
|
|
132
|
+
pass "stdio tools/list returned 32 tools"
|
|
133
133
|
else
|
|
134
134
|
fail "stdio tools/list returned $TOOL_COUNT (expected 26)" "$STDIO_REPLY"
|
|
135
135
|
fi
|
|
@@ -159,8 +159,8 @@ HTTP_REPLY="$(curl -sf -X POST "http://127.0.0.1:$PORT/mcp" \
|
|
|
159
159
|
-H 'Content-Type: application/json' \
|
|
160
160
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')"
|
|
161
161
|
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")}})')"
|
|
162
|
-
if [[ "$HTTP_COUNT" == "
|
|
163
|
-
pass "HTTP tools/list returned
|
|
162
|
+
if [[ "$HTTP_COUNT" == "32" ]]; then
|
|
163
|
+
pass "HTTP tools/list returned 32 tools"
|
|
164
164
|
else
|
|
165
165
|
fail "HTTP tools/list returned $HTTP_COUNT" "$HTTP_REPLY"
|
|
166
166
|
fi
|