@pmoses-s1/s1-secops-mcp 1.3.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,344 @@
1
+ /**
2
+ * Management Console API tools: mgmt-console-api skill
3
+ *
4
+ * Tools:
5
+ * s1_api_get Generic GET against S1 Mgmt Console REST API
6
+ * s1_api_post Generic POST against S1 Mgmt Console REST API
7
+ * s1_api_put Generic PUT against S1 Mgmt Console REST API
8
+ * s1_api_delete Generic DELETE against S1 Mgmt Console REST API
9
+ * s1_api_patch Generic PATCH against S1 Mgmt Console REST API
10
+ * purple_ai_alert_summary Get a Purple AI natural-language summary for a specific UAM alert
11
+ * uam_list_alerts List/search UAM alerts via GraphQL
12
+ * uam_get_alert Get full alert details (notes, history)
13
+ * uam_add_note Add analyst note to an alert
14
+ * uam_set_status Update alert status (NEW, IN_PROGRESS, RESOLVED)
15
+ *
16
+ * REMOVED (2026-05-03: confirmed non-functional for API tokens):
17
+ * purple_ai_query : requires browser-session teamToken from /sdl/v2/graphql that
18
+ * API-token service accounts never obtain. Use Purple MCP instead.
19
+ * purple_ai_investigate : same root cause (SERVICE_ERROR). Use Purple MCP instead.
20
+ */
21
+
22
+ import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus } from '../lib/s1.js';
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
+ // Respect an explicit inline override in the path query string, e.g.
39
+ // /cloud-detection/rules?isLegacy=true; do not append a conflicting
40
+ // isLegacy=false. Mirrors _maybe_inject_islegacy in
41
+ // mgmt-console-api/scripts/s1_client.py.
42
+ !/[?&]is_?legacy=/i.test(path) &&
43
+ p.isLegacy === undefined &&
44
+ p.is_legacy === undefined
45
+ ) {
46
+ p.isLegacy = false;
47
+ }
48
+ return p;
49
+ }
50
+
51
+ export const tools = [
52
+ // ─── s1_api_get ───────────────────────────────────────────────────────────
53
+ {
54
+ name: 's1_api_get',
55
+ 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).
56
+
57
+ ⚠️ 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.`,
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ path: {
62
+ type: 'string',
63
+ pattern: '^/web/api/v2\\.1/',
64
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/agents".',
65
+ },
66
+ params: {
67
+ type: 'object',
68
+ 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.',
69
+ additionalProperties: true,
70
+ },
71
+ },
72
+ required: ['path'],
73
+ },
74
+ async handler({ path, params = {} }) {
75
+ // Safety net: /cloud-detection/rules silently hides scheduled
76
+ // PowerQuery rules unless isLegacy=false is passed.
77
+ const normalized = normalizeS1ApiGetParams(path, params);
78
+ const result = await apiGet(path, normalized);
79
+ return JSON.stringify(result, null, 2);
80
+ },
81
+ },
82
+
83
+ // ─── s1_api_post ──────────────────────────────────────────────────────────
84
+ {
85
+ name: 's1_api_post',
86
+ description: `Generic POST request to the SentinelOne Management Console REST API (v2.1). Use ONLY for write and action operations: create IOC, isolate agent, add exclusion, create custom detection rule, trigger RemoteOps, etc. The path should start with /web/api/v2.1/. NEVER use POST for listing, counting, or exporting; all reads are GET. POST to a read path returns HTTP 404 because the path does not exist in the API (e.g. POST /agents/ids, POST /threats/summary, POST /export/threats are all wrong). Before calling, verify the path exists with: python3 scripts/search_endpoints.py "<keyword>". The body is NOT auto-wrapped; pass the complete envelope, e.g. {"data": {...}, "filter": {...}}.`,
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ path: {
91
+ type: 'string',
92
+ pattern: '^/web/api/v2\\.1/',
93
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/threats/mark-as-threats".',
94
+ },
95
+ body: {
96
+ type: 'object',
97
+ description: 'Request body as JSON. For most S1 endpoints use the {"data": {...}, "filter": {...}} envelope. Pass the complete body.',
98
+ additionalProperties: true,
99
+ },
100
+ },
101
+ required: ['path', 'body'],
102
+ },
103
+ async handler({ path, body }) {
104
+ const result = await apiPost(path, body);
105
+ return JSON.stringify(result, null, 2);
106
+ },
107
+ },
108
+
109
+ // ─── s1_api_put ───────────────────────────────────────────────────────────
110
+ {
111
+ name: 's1_api_put',
112
+ description: `Generic PUT request to the SentinelOne Management Console REST API (v2.1). Use for full-replacement updates: update agent policies, replace exclusion rules, set system configuration, update firewall/device control rules, update group settings, etc. The path should start with /web/api/v2.1/. The body replaces the resource in full; include all required fields, not just the changed ones. Consult the swagger reference at references/tags/ in the mgmt-console-api skill before calling. Examples: path="/web/api/v2.1/accounts/{id}/policy" body={"data":{...policy fields...}}, path="/web/api/v2.1/system/configuration" body={"data":{...}}.`,
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ path: {
117
+ type: 'string',
118
+ pattern: '^/web/api/v2\\.1/',
119
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/accounts/123/policy".',
120
+ },
121
+ body: {
122
+ type: 'object',
123
+ description: 'Full replacement body as JSON. Most S1 PUT endpoints use {"data": {...}} envelope. Include all required fields for the resource.',
124
+ additionalProperties: true,
125
+ },
126
+ },
127
+ required: ['path', 'body'],
128
+ },
129
+ async handler({ path, body }) {
130
+ const result = await apiPut(path, body);
131
+ return JSON.stringify(result, null, 2);
132
+ },
133
+ },
134
+
135
+ // ─── s1_api_delete ────────────────────────────────────────────────────────
136
+ {
137
+ name: 's1_api_delete',
138
+ description: `Generic DELETE request to the SentinelOne Management Console REST API (v2.1). Use for delete operations: delete IOCs (DELETE /web/api/v2.1/threat-intelligence/iocs), delete unified exclusions, delete custom detection rules, delete remote scripts, delete firewall/device control rules, delete tags, etc. The path should start with /web/api/v2.1/. Many S1 DELETE endpoints accept a filter body (e.g. IOCDeleteSchema uses accountId + one other field); pass it as the body param. Some accept query params only (body can be omitted). Consult the swagger reference at references/tags/ in the mgmt-console-api skill for the exact filter schema before calling. WARNING: deletions are irreversible. Confirm the target ID/filter before executing.`,
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ path: {
143
+ type: 'string',
144
+ pattern: '^/web/api/v2\\.1/',
145
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/threat-intelligence/iocs".',
146
+ },
147
+ body: {
148
+ type: 'object',
149
+ description: 'Optional request body as JSON. Required for filter-based deletes (e.g. IOC delete requires {"filter": {"accountId": "...", "uuids": [...]}}). Omit for ID-in-path deletes.',
150
+ additionalProperties: true,
151
+ },
152
+ },
153
+ required: ['path'],
154
+ },
155
+ async handler({ path, body = {} }) {
156
+ const result = await apiDelete(path, body);
157
+ return JSON.stringify(result, null, 2);
158
+ },
159
+ },
160
+
161
+ // ─── s1_api_patch ─────────────────────────────────────────────────────────
162
+ {
163
+ name: 's1_api_patch',
164
+ description: `Generic PATCH request to the SentinelOne Management Console REST API (v2.1). Use for partial updates where only specific fields need to change without replacing the full resource. Less common in the S1 API than PUT, but used by some endpoints for partial config updates and field-level changes. The path should start with /web/api/v2.1/. Pass only the fields to change in the body. Consult the swagger reference at references/tags/ in the mgmt-console-api skill to confirm whether a given endpoint expects PUT (full replace) or PATCH (partial update).`,
165
+ inputSchema: {
166
+ type: 'object',
167
+ properties: {
168
+ path: {
169
+ type: 'string',
170
+ pattern: '^/web/api/v2\\.1/',
171
+ description: 'API path starting with /web/api/v2.1/.',
172
+ },
173
+ body: {
174
+ type: 'object',
175
+ description: 'Partial update body as JSON. Include only the fields to change.',
176
+ additionalProperties: true,
177
+ },
178
+ },
179
+ required: ['path', 'body'],
180
+ },
181
+ async handler({ path, body }) {
182
+ const result = await apiPatch(path, body);
183
+ return JSON.stringify(result, null, 2);
184
+ },
185
+ },
186
+
187
+ // ─── purple_ai_alert_summary ──────────────────────────────────────────────
188
+ {
189
+ name: 'purple_ai_alert_summary',
190
+ description: `Get a Purple AI natural-language summary for a specific UAM alert. Calls purpleAlertSummary (operation AlertSummary) at /web/api/v2.1/graphql with the full OCSF alert JSON. This is what populates the "Purple AI" card in the alert detail panel. Synchronous (no polling required). Returns { token, summary }. Use uam_get_alert first to retrieve the raw alert data, then pass its OCSF JSON here.`,
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ alertJson: {
195
+ type: 'string',
196
+ description: 'The full alert as a JSON string (OCSF format, as returned by uam_get_alert or the GetAlert GraphQL query). Pass the entire alert object serialised to a string.',
197
+ },
198
+ },
199
+ required: ['alertJson'],
200
+ },
201
+ async handler({ alertJson }) {
202
+ const result = await purpleAlertSummary(alertJson);
203
+ return JSON.stringify(result, null, 2);
204
+ },
205
+ },
206
+
207
+ // ─── uam_list_alerts ──────────────────────────────────────────────────────
208
+ {
209
+ name: 'uam_list_alerts',
210
+ description: `List UAM (Unified Alert Management) alerts via GraphQL. The PRIMARY alert API in S1, covers all alert types (EDR, STAR, cloud, identity, third-party). Uses the correct FilterInput schema: dateTimeRange { start, end } for time windows (epoch ms). USE THIS instead of Purple MCP search_alerts for time-scoped searches; the Purple MCP sends date_range (snake_case) which UAM rejects; this tool uses dateTimeRange (the actual schema field). Convenience params (status, severity, startTime, endTime) build FilterInputs automatically. For deeper analysis, follow up with uam_get_alert.`,
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ first: {
215
+ type: 'number',
216
+ description: 'Number of alerts to fetch per page (default 20). Not enforced client-side; the UAM GraphQL backend accepts larger pages (live-verified 2026-07-29: first=500 returned 500 with hasNextPage=true). Use the returned pageInfo.endCursor with `after` to paginate rather than requesting an unbounded page.',
217
+ default: 20,
218
+ },
219
+ after: {
220
+ type: 'string',
221
+ description: 'Pagination cursor from a prior call\'s pageInfo.endCursor. Omit for first page.',
222
+ },
223
+ viewType: {
224
+ type: 'string',
225
+ description: 'Alert view scope.',
226
+ enum: ['ALL', 'ENDPOINT', 'IDENTITY', 'STAR', 'CUSTOM_ALERTS', 'CLOUD', 'THIRD_PARTY'],
227
+ default: 'ALL',
228
+ },
229
+ status: {
230
+ type: 'string',
231
+ description: 'Filter by status. Uses stringEqual FilterInput. Valid values (confirmed against live tenant): "NEW", "IN_PROGRESS", "RESOLVED". "OPEN" is NOT a valid value and silently returns 0 results. "FALSE_POSITIVE" is an analystVerdict field, not a status; uam_set_status cannot set it. To set the analyst verdict, POST the raw alertTriggerActions mutation with the S1/alert/analystVerdictUpdate action via s1_api_post to /web/api/v2.1/unifiedalerts/graphql.',
232
+ },
233
+ severity: {
234
+ type: 'string',
235
+ description: 'Filter by severity. Uses stringEqual FilterInput. e.g. "CRITICAL", "HIGH", "MEDIUM", "LOW".',
236
+ },
237
+ detectionProduct: {
238
+ type: 'string',
239
+ description: 'Filter by detection product. e.g. "EDR", "STAR", "CLOUD".',
240
+ },
241
+ searchText: {
242
+ type: 'string',
243
+ description: 'Full-text search across alert fields.',
244
+ },
245
+ startTime: {
246
+ type: 'string',
247
+ description: 'Start of time window. ISO-8601 string ("2026-05-03T07:32:00Z") or epoch milliseconds as string. Builds a dateTimeRange FilterInput on detectedAt using { start, end }, the actual UAM schema fields confirmed by introspection.',
248
+ },
249
+ endTime: {
250
+ type: 'string',
251
+ description: 'End of time window. ISO-8601 string or epoch ms. Defaults to now when startTime is provided.',
252
+ },
253
+ },
254
+ required: [],
255
+ },
256
+ async handler({ first = 20, after, viewType = 'ALL', status, severity, detectionProduct, searchText, startTime, endTime } = {}) {
257
+ // Convert string epoch ms to numbers if needed
258
+ const parseTime = (v) => {
259
+ if (!v) return null;
260
+ const n = Number(v);
261
+ return isNaN(n) ? v : n; // if numeric string, use as epoch ms; otherwise pass as ISO
262
+ };
263
+ const result = await uamListAlerts({
264
+ first, after, viewType,
265
+ status: status || null,
266
+ severity: severity || null,
267
+ detectionProduct: detectionProduct || null,
268
+ searchText: searchText || null,
269
+ startTime: parseTime(startTime),
270
+ endTime: parseTime(endTime),
271
+ });
272
+ return JSON.stringify(result, null, 2);
273
+ },
274
+ },
275
+
276
+ // ─── uam_get_alert ────────────────────────────────────────────────────────
277
+ {
278
+ name: 'uam_get_alert',
279
+ description: `Get full details for a specific UAM alert including analyst notes. ALWAYS call this before making a verdict; notes may contain MDR verdicts (False Positive / Benign / Resolved) that take precedence over detection engine severity. Returns alert fields plus a notes array from alertNotes query.`,
280
+ inputSchema: {
281
+ type: 'object',
282
+ properties: {
283
+ alertId: {
284
+ type: 'string',
285
+ description: 'The UAM alert ID (string UUID, from uam_list_alerts results).',
286
+ },
287
+ },
288
+ required: ['alertId'],
289
+ },
290
+ async handler({ alertId }) {
291
+ const result = await uamGetAlert(alertId);
292
+ return JSON.stringify(result, null, 2);
293
+ },
294
+ },
295
+
296
+ // ─── uam_add_note ─────────────────────────────────────────────────────────
297
+ {
298
+ name: 'uam_add_note',
299
+ description: `Add an analyst note to a UAM alert. Use to document investigation findings, intermediate verdicts, IOC enrichment results, or escalation decisions. Notes are visible to all analysts and MDR. Best practice: include a timestamp-like prefix and cite the evidence inline (e.g. "VT: 12/72 malicious on hash abc123; cross-correlated with FortiGate BLOCK events on same dst IP").`,
300
+ inputSchema: {
301
+ type: 'object',
302
+ properties: {
303
+ alertId: {
304
+ type: 'string',
305
+ description: 'The UAM alert ID.',
306
+ },
307
+ note: {
308
+ type: 'string',
309
+ description: 'Note text. Cite evidence inline. Avoid vague statements, be specific about what queries were run, what IOCs were checked, and what the results were.',
310
+ },
311
+ },
312
+ required: ['alertId', 'note'],
313
+ },
314
+ async handler({ alertId, note }) {
315
+ const result = await uamAddNote(alertId, note);
316
+ return JSON.stringify(result, null, 2);
317
+ },
318
+ },
319
+
320
+ // ─── uam_set_status ───────────────────────────────────────────────────────
321
+ {
322
+ name: 'uam_set_status',
323
+ description: `Update the status of a UAM alert. Valid values: NEW (reopen), IN_PROGRESS (actively investigating), RESOLVED (threat contained and remediated). Note: FALSE_POSITIVE is NOT a status value on this API; it is an analystVerdict. To mark an alert as a false positive, add a note explaining why and set status to RESOLVED. Always add a note via uam_add_note before closing an alert.`,
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: {
327
+ alertId: {
328
+ type: 'string',
329
+ description: 'The UAM alert ID.',
330
+ },
331
+ status: {
332
+ type: 'string',
333
+ description: 'New status. Must be one of the confirmed enum values.',
334
+ enum: ['NEW', 'IN_PROGRESS', 'RESOLVED'],
335
+ },
336
+ },
337
+ required: ['alertId', 'status'],
338
+ },
339
+ async handler({ alertId, status }) {
340
+ const result = await uamSetStatus(alertId, status);
341
+ return JSON.stringify(result, null, 2);
342
+ },
343
+ },
344
+ ];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PowerQuery tools: powerquery skill
3
+ *
4
+ * Tools:
5
+ * powerquery_run Run a PowerQuery via the LRQ API
6
+ * powerquery_schema_discover Discover field schema for a data source via V1 query
7
+ * powerquery_enumerate_sources List all data sources active in SDL (session init)
8
+ */
9
+
10
+ import { lrqRun } from '../lib/s1.js';
11
+ import { v1Query } from '../lib/sdl.js';
12
+
13
+ export const tools = [
14
+ // ─── powerquery_enumerate_sources ─────────────────────────────────────────
15
+ {
16
+ name: 'powerquery_enumerate_sources',
17
+ description: `MANDATORY SESSION INIT: Run the standard data-source enumeration query to discover every dataSource.name, dataSource.vendor, and dataSource.category active in this SDL tenant. Always call this at the start of every session before writing any hunt queries. Results are environment-specific and can change between sessions as integrations are added or removed. Never assume sources from a prior session.`,
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {
21
+ hours: {
22
+ type: 'number',
23
+ description: 'Lookback window in hours (default 24). Increase to 168 (7d) if the last 24h had low volume.',
24
+ default: 24,
25
+ },
26
+ },
27
+ required: [],
28
+ },
29
+ async handler({ hours = 24 } = {}) {
30
+ const query = `| group UniqueDataSourceNames = array_agg_distinct(dataSource.name),
31
+ UniqueVendors = array_agg_distinct(dataSource.vendor),
32
+ UniqueCategories = array_agg_distinct(dataSource.category)
33
+ | limit 1000`;
34
+ const result = await lrqRun(query, { hours });
35
+ return JSON.stringify(result, null, 2);
36
+ },
37
+ },
38
+
39
+ // ─── powerquery_run ────────────────────────────────────────────────────────
40
+ {
41
+ name: 'powerquery_run',
42
+ description: `Run a SentinelOne PowerQuery against the Singularity Data Lake using the LRQ API. The LRQ API is async; this tool handles the full launch-poll-cancel lifecycle and returns results. Use for threat hunting, telemetry analysis, dashboard panel validation, and STAR rule testing. Auth: Bearer <jwt> (same token as mgmt API). Time range defaults to last 24 hours if startTime/endTime are omitted.`,
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ query: {
47
+ type: 'string',
48
+ description: 'The PowerQuery string. Use pipe-separated commands: | filter | group | sort | limit | columns. Three distinct wildcard idioms; use the right one: (1) FIELD PRESENCE / ATTRIBUTE WILDCARD: field=* means "field is present/non-null", e.g. dataSource.name=* | group count=count() by dataSource.name; use this as a query-opener or whenever you need "all events that have this field". (2) ALL-COLUMN TEXT SEARCH: * contains \'value\' or * matches \'regex\' in the initial filter (before the first |) searches ALL indexed fields; use when the user asks to find text anywhere in the event, e.g. dataSource.name=\'MySource\' * contains \'evil.com\'. Dramatically faster than message contains. (3) EMPTY FILTER (all events): start with | and no initial predicate, e.g. | group ct=count() by event.type. Do NOT use bare * alone as the initial filter; that causes HTTP 500 ("Don\'t understand [*]"). Beyond filtering, | datasource <name> [from <dataset>] reads SentinelOne-managed inventory (assets, alerts, vulnerabilities, misconfigurations, metering; e.g. | datasource assets from \'surface/identity\') and | savelookup \'<name>\' persists the result as a reusable lookup table (see references/datasource-command.md in powerquery).',
49
+ },
50
+ startTime: {
51
+ type: 'string',
52
+ description: 'ISO-8601 UTC start time, e.g. "2026-04-20T00:00:00Z". If omitted, defaults to (now - hours) ago.',
53
+ },
54
+ endTime: {
55
+ type: 'string',
56
+ description: 'ISO-8601 UTC end time, e.g. "2026-04-21T00:00:00Z". If omitted, defaults to now.',
57
+ },
58
+ hours: {
59
+ type: 'number',
60
+ description: 'Lookback window in hours when startTime/endTime are not specified (default 24).',
61
+ default: 24,
62
+ },
63
+ maxRows: {
64
+ type: 'number',
65
+ 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
+ default: 1000,
67
+ },
68
+ },
69
+ required: ['query'],
70
+ },
71
+ async handler({ query, startTime, endTime, hours = 24, maxRows = 1000 }) {
72
+ const result = await lrqRun(query, { startTime, endTime, hours, maxRows });
73
+ return JSON.stringify(result, null, 2);
74
+ },
75
+ },
76
+
77
+ // ─── powerquery_schema_discover ────────────────────────────────────────────
78
+ {
79
+ name: 'powerquery_schema_discover',
80
+ description: `Discover the field schema for a specific SDL data source by fetching raw event JSON via the V1 query endpoint. PowerQuery's default projection only returns timestamp+message; V1 query returns full event attributes so you can see what field names are actually present. Use this before authoring any hunt query or dashboard panel against a non-OCSF source. The V1 endpoint is deprecated (sunset Feb 2027) but is still the only way to get full event JSON per-source. Auth tries each configured SDL key in scope order and falls through to the console JWT on 401/403.`,
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ dataSourceName: {
85
+ type: 'string',
86
+ description: 'Exact dataSource.name value (case-sensitive, as returned by powerquery_enumerate_sources).',
87
+ },
88
+ maxEvents: {
89
+ type: 'number',
90
+ description: 'Number of sample events to retrieve (default 5, max 50).',
91
+ default: 5,
92
+ },
93
+ startTime: {
94
+ type: 'string',
95
+ description: 'Lookback string or ISO date, e.g. "24h", "7d", or "2026-04-20T00:00:00Z" (default "24h").',
96
+ default: '24h',
97
+ },
98
+ },
99
+ required: ['dataSourceName'],
100
+ },
101
+ async handler({ dataSourceName, maxEvents = 5, startTime = '24h' }) {
102
+ // Escape backslashes first, then single quotes, to keep tenant-defined
103
+ // source names from breaking (or altering) the V1 filter expression.
104
+ // Quote-only escaping let a trailing backslash neutralise the added
105
+ // escape (e.g. name\' -> \\' which re-opens the string).
106
+ const safeName = String(dataSourceName).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
107
+ const filter = `dataSource.name=='${safeName}'`;
108
+ const result = await v1Query(filter, { maxCount: Math.min(maxEvents, 50), startTime });
109
+
110
+ const matches = result.matches || [];
111
+ if (matches.length === 0) {
112
+ return JSON.stringify({ dataSourceName, message: 'No events found in the specified time range. Try a longer startTime like "7d".', result }, null, 2);
113
+ }
114
+
115
+ // Extract field names from first event
116
+ const firstAttrs = matches[0]?.attributes || {};
117
+ const allFields = new Set();
118
+ matches.forEach(m => Object.keys(m?.attributes || {}).forEach(k => allFields.add(k)));
119
+
120
+ return JSON.stringify({
121
+ dataSourceName,
122
+ sampleEventCount: matches.length,
123
+ confirmedFields: Array.from(allFields).sort(),
124
+ firstEventAttributes: firstAttrs,
125
+ allSampleAttributes: matches.map(m => m.attributes),
126
+ }, null, 2);
127
+ },
128
+ },
129
+ ];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * SDL API tools: sdl-api, sdl-dashboard, sdl-log-parser skills
3
+ *
4
+ * Tools:
5
+ * sdl_list_files List all config files on the SDL tenant
6
+ * sdl_get_file Get file content and version (parsers, dashboards, alerts, lookups)
7
+ * sdl_put_file Deploy or update a config file (with optimistic locking)
8
+ * sdl_delete_file Delete a config file
9
+ * hec_ingest Ingest raw logs/events into SDL via the HEC endpoint (replaces uploadLogs)
10
+ */
11
+
12
+ import { listFiles, getFile, putFile, deleteFile } from '../lib/sdl.js';
13
+ import { hecIngest } from '../lib/hec.js';
14
+
15
+ export const tools = [
16
+ // ─── sdl_list_files ───────────────────────────────────────────────────────
17
+ {
18
+ name: 'sdl_list_files',
19
+ description: `List all configuration files stored in the SDL tenant. Returns all paths organized by type: /logParsers/, /dashboards/, /alerts/, /lookups/, /datatables/. Use this to discover what parsers and dashboards are already deployed, or to find a file path before calling sdl_get_file or sdl_put_file.`,
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {},
23
+ required: [],
24
+ },
25
+ async handler() {
26
+ const result = await listFiles();
27
+ return JSON.stringify(result, null, 2);
28
+ },
29
+ },
30
+
31
+ // ─── sdl_get_file ─────────────────────────────────────────────────────────
32
+ {
33
+ name: 'sdl_get_file',
34
+ description: `Get the content and current version number of a SDL configuration file. Use before sdl_put_file to read the current version for optimistic locking (pass the returned version as expectedVersion). Supports any file type: parsers (/logParsers/<name>), dashboards (/dashboards/<name>), alerts (/alerts/<name>), lookups (/lookups/<name>), datatables (/datatables/<name>). Always read before overwriting; this prevents concurrent-edit conflicts.`,
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ path: {
39
+ type: 'string',
40
+ description: 'Full SDL config path, e.g. "/logParsers/FortiGate" or "/dashboards/SOC-Overview". Get the path from sdl_list_files.',
41
+ },
42
+ },
43
+ required: ['path'],
44
+ },
45
+ async handler({ path }) {
46
+ const result = await getFile(path);
47
+ return JSON.stringify(result, null, 2);
48
+ },
49
+ },
50
+
51
+ // ─── sdl_put_file ─────────────────────────────────────────────────────────
52
+ {
53
+ name: 'sdl_put_file',
54
+ description: `Deploy or update a SDL configuration file. Always call sdl_get_file first to obtain the current expectedVersion; this prevents overwriting concurrent edits. If creating a new file, omit expectedVersion. File type conventions: parsers go to /logParsers/<name>, dashboards to /dashboards/<name>, alerts to /alerts/<name>, lookups to /lookups/<name>. Authorised by S1_CONSOLE_API_TOKEN.`,
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ path: {
59
+ type: 'string',
60
+ description: 'Full SDL config path, e.g. "/logParsers/MyParser" or "/dashboards/SOC-Ops".',
61
+ },
62
+ content: {
63
+ type: 'string',
64
+ description: 'File content as a string. For dashboards: valid dashboard JSON. For parsers: augmented-JSON parser definition. For lookups: CSV or JSON.',
65
+ },
66
+ expectedVersion: {
67
+ type: 'number',
68
+ description: 'Current file version from sdl_get_file. Required for updates to enable optimistic locking. Omit only when creating a new file.',
69
+ },
70
+ },
71
+ required: ['path', 'content'],
72
+ },
73
+ async handler({ path, content, expectedVersion }) {
74
+ const result = await putFile(path, content, expectedVersion);
75
+ return JSON.stringify(result, null, 2);
76
+ },
77
+ },
78
+
79
+ // ─── sdl_delete_file ──────────────────────────────────────────────────────
80
+ {
81
+ name: 'sdl_delete_file',
82
+ description: `Delete a SDL configuration file (parser, dashboard, alert, lookup, datatable). Use with caution; deletion is permanent. Always read the file with sdl_get_file first to confirm you have the right path and version.`,
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ path: {
87
+ type: 'string',
88
+ description: 'Full SDL config path to delete.',
89
+ },
90
+ expectedVersion: {
91
+ type: 'number',
92
+ description: 'Current file version for optimistic locking (from sdl_get_file). Strongly recommended.',
93
+ },
94
+ },
95
+ required: ['path'],
96
+ },
97
+ async handler({ path, expectedVersion }) {
98
+ const result = await deleteFile(path, expectedVersion);
99
+ return JSON.stringify(result, null, 2);
100
+ },
101
+ },
102
+
103
+ // ─── hec_ingest ─────────────────────────────────────────────────────────────
104
+ {
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.`,
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
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.' },
117
+ },
118
+ required: ['logContent', 'scope'],
119
+ },
120
+ async handler({ logContent, parser, fields, scope, endpoint, compress, isParsed }) {
121
+ const result = await hecIngest(logContent, { parser, fields, scope, endpoint, compress, isParsed });
122
+ return JSON.stringify(result, null, 2);
123
+ },
124
+ },
125
+ ];