@pmoses-s1/s1-secops-mcp 1.3.1 → 1.3.3
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 +118 -1
- package/README.md +12 -9
- package/deploy/README.md +17 -4
- package/deploy/bridge/README.md +1 -1
- package/lib/credentials.js +0 -1
- package/lib/sdl.js +273 -26
- package/lib/server-core.js +1 -1
- package/package.json +2 -2
- package/tools/sdl-api.js +60 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,112 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.3 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
Fixes a user-facing regression in 1.3.2 found by running the live MCP tools
|
|
6
|
+
against a tenant for the first time. **Upgrade from 1.3.2 is recommended.**
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **Every successful `sdl_delete_file` reported an error in 1.3.2.** The delete
|
|
11
|
+
verification added in 1.3.2 re-reads the file to confirm removal, but the server
|
|
12
|
+
reports absence as a GraphQL error rather than a null result, so the confirming
|
|
13
|
+
read threw on exactly the success path. The delete itself always worked; only the
|
|
14
|
+
reported outcome was wrong. Verified live: three deletes across `/dashboards/`,
|
|
15
|
+
`/datatables/` and `/logParsers/` all removed their file and all three surfaced
|
|
16
|
+
as errors.
|
|
17
|
+
- **`sdl_get_file` on a missing path returned a raw GraphQL error** instead of the
|
|
18
|
+
actionable "this may be udoId-addressed, list it and retry" hint. The hint branch
|
|
19
|
+
was unreachable because the library threw before returning.
|
|
20
|
+
- **Absence is now detected reliably on both address forms.** The error text differs:
|
|
21
|
+
a missing name gives "Config file with name X not found.", a missing `udoId` gives
|
|
22
|
+
the generic "Something went wrong. Please try again...", which is also what a
|
|
23
|
+
version conflict returns. `configFile` normalises the explicit form and
|
|
24
|
+
disambiguates the generic one against the file listing, so a deleted dashboard
|
|
25
|
+
reads as absent while a genuine server error still propagates.
|
|
26
|
+
- **An out-of-range numeric `udoId` is no longer swallowed by the absence path.**
|
|
27
|
+
Validation now runs before the lookup, so a caller bug surfaces as a caller bug.
|
|
28
|
+
- **A transport error is never read as "file absent".** Absence detection now requires a
|
|
29
|
+
GraphQL-layer error, so a 404 page or WAF block whose body contains the words "not found"
|
|
30
|
+
no longer satisfies it. Without this a delete could confirm itself against a file it
|
|
31
|
+
never checked.
|
|
32
|
+
- **A failing listing during absence disambiguation keeps the original error** instead of
|
|
33
|
+
replacing it with the listing failure.
|
|
34
|
+
- **The duplicate guard is no longer bypassed by case.** Its namespace test was
|
|
35
|
+
case-sensitive while its name comparison was not, so `/Dashboards/AI Usage` skipped the
|
|
36
|
+
guard entirely. Both now share one normaliser.
|
|
37
|
+
- **`v1Query` keeps its backoff.** Restricting status retry to idempotent methods removed it
|
|
38
|
+
from this read-only POST, which schema discovery iterates once per data source.
|
|
39
|
+
|
|
40
|
+
### Python client (`sdl-api/scripts/sdl_client.py`)
|
|
41
|
+
|
|
42
|
+
Brought to parity with the JS client:
|
|
43
|
+
|
|
44
|
+
- Status retry is restricted to idempotent methods. The Python client was retrying POST
|
|
45
|
+
mutations, which is the mechanism that duplicates a dashboard on a re-sent write.
|
|
46
|
+
- `Retry-After` is capped at 30s; an unbounded value parked the process.
|
|
47
|
+
- The duplicate guard fails closed on an empty listing.
|
|
48
|
+
- Absence detection, name normalisation and delete verification match the JS behaviour.
|
|
49
|
+
|
|
50
|
+
### Tests
|
|
51
|
+
|
|
52
|
+
- `tests/sdl-graphql.test.mjs`: 86 cases, adding the transport-error-is-not-absence case,
|
|
53
|
+
listing-failure error preservation, the case-variant guard bypass, and `v1Query` retry.
|
|
54
|
+
- `sdl-api/tests/test_client.py`: new. 19 cases over a stubbed session, so the Python client
|
|
55
|
+
is no longer invisible to CI. Runs in ~0.01s with no network.
|
|
56
|
+
- Live regression through the real MCP stdio protocol: handshake, 26 tools, and full
|
|
57
|
+
create/read/update/stale-reject/delete/confirm-absent cycles for `/datatables/` and
|
|
58
|
+
`/dashboards/`, plus the duplicate guard, the notFound hint, and `/automaticLookups`.
|
|
59
|
+
|
|
60
|
+
## 1.3.2 - 2026-08-07
|
|
61
|
+
|
|
62
|
+
Config-file operations move from the legacy REST endpoints to GraphQL. Tool count unchanged at
|
|
63
|
+
26, and all four tool names are unchanged, so no caller needs to change.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **`sdl_list_files` no longer returns an incomplete listing.** The REST `/sdl/api/listFiles`
|
|
68
|
+
endpoint omits every udoId-addressed dashboard. Measured live on `usea1-purple`: REST returned
|
|
69
|
+
1,914 paths against `configFiles`' 2,264, a 350-file gap consisting entirely of `/dashboards/`
|
|
70
|
+
files that carry a `udoId`. REST `getFile` on any of them returns `success/noSuchFile`. The
|
|
71
|
+
practical impact was a false negative: a dashboard that existed in the console was reported as
|
|
72
|
+
not found. All four config-file tools now run on `POST /sdl/v2/graphql`.
|
|
73
|
+
- **`sdl_list_files` description no longer claims to return "all" files.** It did not, and the
|
|
74
|
+
claim was load-bearing: an agent reading it had no reason to look further after an empty result.
|
|
75
|
+
- **`sdl_get_file` / `sdl_put_file` / `sdl_delete_file` can now address dashboards.** New `udoId`
|
|
76
|
+
parameter. The console's Configuration Files grid displays a dashboard as
|
|
77
|
+
`/dashboards/id/<udoId>/<name>`; that string is not a path, and reading it as one returns
|
|
78
|
+
`no file exists at path`.
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **`lib/sdl.js`: `configFiles`, `configFile`, `putConfigFile`, `deleteConfigFile`** over
|
|
83
|
+
`POST /sdl/v2/graphql`. GraphQL reports failure as HTTP 200 with an `errors` array, so the
|
|
84
|
+
wrapper raises on that array rather than trusting the status code.
|
|
85
|
+
- **Duplicate guardrail on dashboard writes.** `addConfigFile(name:)` updates in place for a
|
|
86
|
+
name-addressed file but creates a duplicate for a dashboard (both verified live). `sdl_put_file`
|
|
87
|
+
now refuses a name-addressed write to an *existing* dashboard and names the `udoId`s already
|
|
88
|
+
holding it, while still allowing the initial create, which has no `udoId` yet. The tenant this
|
|
89
|
+
was found on already carries 256 surplus dashboard copies from this behaviour, including 152 of
|
|
90
|
+
`/dashboards/AI Usage`.
|
|
91
|
+
- **`pathPrefix` filter on `sdl_list_files`**, so callers can scope to `/dashboards/` or
|
|
92
|
+
`/logParsers/` without pulling the full listing into context.
|
|
93
|
+
|
|
94
|
+
### Notes
|
|
95
|
+
|
|
96
|
+
- `udoId` is assigned by namespace, verified live: only `/dashboards/` files get one. `/lookups/`,
|
|
97
|
+
`/datatables/`, `/logParsers/` and `/automaticLookups` are name-addressed with `udoId` null.
|
|
98
|
+
- `expectedVersion` is enforced on both address forms. A stale value is rejected with
|
|
99
|
+
"There are conflicting changes in the file." and the stored content is left untouched.
|
|
100
|
+
- A `deleteConfigFile` returning `null` with no `errors` array is success, not failure.
|
|
101
|
+
- The scoped SDL keys (`SDL_CONFIG_READ_KEY` and friends) are retired; the console API token
|
|
102
|
+
covers every SDL operation.
|
|
103
|
+
|
|
3
104
|
## 1.2.4 - 2026-07-31
|
|
4
105
|
|
|
5
106
|
Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
|
|
6
107
|
|
|
7
108
|
### Fixed
|
|
109
|
+
|
|
8
110
|
- **Missing `Retry-After` header no longer sleeps 0ms before retrying.** `Number(null)` is 0, so `lib/s1.js`, `lib/hec.js`, and `lib/uam-ingest.js` treated an absent header as "wait 0ms" and hammered the backend. All three now use the validated pattern from `lib/sdl.js`: honor the header only when present and parseable as a finite number of seconds (capped at 30s), otherwise fall back to the exponential delay. Numeric headers behave exactly as before.
|
|
9
111
|
- **`uam_set_status` no longer reports silent success.** The `alertTriggerActions` mutation selected only `__typename`, so a skipped or failed action still returned as if it worked (observed live: status unchanged after a "successful" call). The selection now mirrors the full `actions { success failure skip }` shape and the client throws when the backend reports a failure entry, skips the action without a success, or returns an empty actions list (nothing applied, e.g. the filter matched no alert). `uam_add_note` was audited for the same pattern; it already verifies via the returned note list.
|
|
10
112
|
- **LRQ polling tolerates transient poll errors.** A single 429/5xx poll response used to throw and cancel the running query. Transient statuses now keep polling (interval doubles up to 5s) until the existing 5-minute deadline; other 4xx responses remain fatal.
|
|
@@ -16,6 +118,7 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
|
|
|
16
118
|
- **SDL `config_read` key chain reordered to least-privilege first** (config_read, config_write, console JWT), matching the Python `SDLClient` the header claims to mirror.
|
|
17
119
|
|
|
18
120
|
### Changed
|
|
121
|
+
|
|
19
122
|
- **Removed dead exports `purpleAiQuery` and `purpleAiInvestigate`** from `lib/s1.js`; their MCP tools were removed 2026-05-03 (browser-session teamToken requirement) and nothing referenced the library functions. Corrected stale doc text that pointed at a nonexistent `uam_set_analyst_verdict` tool: the analyst verdict is set via a raw `alertTriggerActions` mutation with the `analystVerdictUpdate` action through `s1_api_post`.
|
|
20
123
|
- **Deploy docs: credentials.json changes require `systemctl restart`.** SIGHUP reload only re-reads bearer tokens; the installer output and systemd unit comment said reload was enough. `deploy/install.sh` also sets `umask 077` so token/credential files are never world-readable at creation (the explicit `chmod 600` lines remain).
|
|
21
124
|
- **Claude Desktop bridge hardening:** 120s fetch timeout (`AbortSignal.timeout`), notification responses are drained so keep-alive sockets are released, and the URL constant no longer shadows the global `URL` constructor.
|
|
@@ -23,6 +126,7 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
|
|
|
23
126
|
- **`const status = response.error ? 200 : 200`** simplified; JSON-RPC errors still return HTTP 200 with an error envelope.
|
|
24
127
|
|
|
25
128
|
### Tests
|
|
129
|
+
|
|
26
130
|
- New `tests/regressions-2026-07-31.test.mjs` (mocked fetch, no network): missing `Retry-After` uses the exponential delay, `uamSetStatus` throws on failure/skip results, inline `?isLegacy=` is honored. Both regression suites are now part of `npm test`.
|
|
27
131
|
- Transport and smoke tests read the expected version from `package.json` instead of a hardcoded string.
|
|
28
132
|
|
|
@@ -31,12 +135,14 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
|
|
|
31
135
|
Correctness release from the 2026-07-29 defect review. Fixes two bugs that produced plausible-but-wrong query results, hardens the SDL auth chain and HTTP retry paths, corrects the HEC `/event` content type, and adds a regression suite. Tool count unchanged at 26. (The Docker bundle image moved to 1.2.4 pinning `S1_MCP_VERSION=1.2.3`; the image tag stays ahead of the npm tag as it has since the split.)
|
|
32
136
|
|
|
33
137
|
### Fixed
|
|
138
|
+
|
|
34
139
|
- **`powerquery_run` no longer collapses a caller-supplied time window.** The old code overwrote BOTH `startTime` and `endTime` with the last-`hours` default whenever either was missing, so a startTime-only call silently ran over the last 24 hours. Each bound now defaults independently. Live A/B: a startTime-only 7.4-day query returned 73,755 events on the fixed server vs 12,911 (the 24h control) on the old one.
|
|
35
140
|
- **`powerquery_run` now reports `matchCount`.** It was read from the top level of the LRQ response and came back `null` on every call; it lives inside the `data` block on current engines.
|
|
36
141
|
- **SDL auth chain falls through on 401/403.** `lib/sdl.js` treated an auth failure on the first configured key as fatal even when a later key (e.g. the console JWT) would work. It now advances through the chain and raises only when exhausted.
|
|
37
142
|
- **HEC `/event` ingestion uses `application/json`,** so per-event `time` backdating is honored instead of the envelope being indexed as opaque text at receive time.
|
|
38
143
|
|
|
39
144
|
### Changed
|
|
145
|
+
|
|
40
146
|
- **Write requests no longer auto-retry on 5xx.** Retry is restricted to idempotent methods (GET/HEAD); read-only POSTs (GraphQL queries, Purple AI launches) opt back in via `allowRetry`. HEC raw ingest no longer retries 5xx (no idempotency key); UAM ingest still retries because `metadata.uid` dedupes.
|
|
41
147
|
- **`Retry-After` parsing hardened:** an HTTP-date value no longer collapses to `sleep(NaN)`; waits are validated and capped at 30s.
|
|
42
148
|
- **`uam_add_note` returns the correct note** (matches by text, tiebreaks on newest `createdAt`) instead of assuming newest-last ordering.
|
|
@@ -45,15 +151,18 @@ Correctness release from the 2026-07-29 defect review. Fixes two bugs that produ
|
|
|
45
151
|
- **`powerquery_schema_discover` escapes single quotes** in the data-source name before building the V1 filter.
|
|
46
152
|
|
|
47
153
|
### Tests
|
|
154
|
+
|
|
48
155
|
- New `tests/regressions-2026-07-29.test.mjs` (mocked fetch, no network): independent time-bound defaults, `matchCount` extraction, SDL 401/403 fall-through, HEC per-endpoint content type, write-vs-read retry semantics.
|
|
49
156
|
|
|
50
157
|
## 1.2.2 - 2026-06-13
|
|
51
158
|
|
|
52
159
|
### Changed
|
|
160
|
+
|
|
53
161
|
- **Renamed `ha_archive_workflow` to `ha_delete_workflow`.** The old tool hit `POST /hyper-automate/api/v1/workflows/archive`, which returns HTTP 500 on this tenant. The replacement uses the validated `DELETE /hyper-automate/api/v1/workflows/{id}` endpoint (a soft, recoverable delete equivalent to clicking Delete in the Hyperautomation UI). Scope the call with `accountIds` or `siteIds`; a 404 "Object not found" means the id is not under that scope or is already deleted. Updated `README.md`, the tools-table regenerator, and the smoke test in lockstep.
|
|
54
162
|
- **`powerquery_run` description now documents the `datasource` and `savelookup` capabilities** (querying SentinelOne-managed inventory such as assets/alerts/vulnerabilities/misconfigurations, and persisting a result as a reusable lookup table), pointing at the new `powerquery/references/datasource-command.md`.
|
|
55
163
|
|
|
56
164
|
### Notes
|
|
165
|
+
|
|
57
166
|
- Tool count unchanged at 26 (the Hyperautomation tool was renamed, not added or removed).
|
|
58
167
|
- `SERVER_INFO.version` bumped in lockstep with `package.json` (the drift that forced the 1.2.0 -> 1.2.1 re-release).
|
|
59
168
|
|
|
@@ -62,22 +171,26 @@ Correctness release from the 2026-07-29 defect review. Fixes two bugs that produ
|
|
|
62
171
|
Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a stale internal `SERVER_INFO.version` of `1.1.0` despite a `1.2.0` package version, so the server announced the wrong version on `initialize`. 1.2.1 is identical in features and corrects the reported runtime version. The content below is unchanged from the 1.2.0 work.
|
|
63
172
|
|
|
64
173
|
### Added
|
|
174
|
+
|
|
65
175
|
- **`hec_ingest` tool**: raw-log/event ingestion into the Singularity Data Lake via the HEC (HTTP Event Collector) endpoint (`/services/collector/raw` and `/services/collector/event`). Supports `parser` (-> `?sourcetype=`), custom `fields` (query params), **required** `scope` (S1-Scope header), gzip compression, and `isParsed` (-> `?isParsed=true`, indexes already-structured JSON with no SDL parser). Replaces the removed `sdl_upload_logs`. Validated live across the full HEC matrix (both endpoints, gzip on/off, parser field extraction, multi-line, batched, reserved-field handling, scope enforcement, isParsed). Grounded in the S-26.1 HEC docs (p.4723-4726).
|
|
66
176
|
|
|
67
177
|
### Removed
|
|
178
|
+
|
|
68
179
|
- **`sdl_upload_logs` tool** plus the underlying SDL `uploadLogs`/`addEvents` library functions and `SDL_LOG_WRITE_KEY` plumbing. SDL raw-log ingestion moves to the HEC path (`hec_ingest`). The `sdl-api` skill is now query + configuration only; the `sdl-log-parser` validation loop uses HEC ingest.
|
|
69
180
|
|
|
70
181
|
### Changed
|
|
182
|
+
|
|
71
183
|
- Tool count unchanged at 26 (removed `sdl_upload_logs`, added `hec_ingest`).
|
|
72
184
|
- Skill docs corrected: scheduled detection rules bind the Target Asset via `entityMappings` ("Entity column mapping"); the full scheduled-rule option set (UI <-> API) is catalogued in `powerquery/references/detection-rules.md`.
|
|
73
185
|
|
|
74
|
-
|
|
75
186
|
## 1.1.0 - 2026-05-28 (rebuilt 2026-05-31)
|
|
76
187
|
|
|
77
188
|
### Fixed (rebuild)
|
|
189
|
+
|
|
78
190
|
- **`s1_api_get` now auto-injects `isLegacy=false` for `/cloud-detection/rules` listings.** Without `isLegacy=false` the S1 API silently omits `queryType="scheduled"` PowerQuery rules from the response; no error, no warning, the response just lies by omission. The handler now guards against this when the caller forgets, and the tool description loudly flags the requirement. This eliminates the "I see zero scheduled detections" failure mode that was producing wrong verdicts when listing Custom Detection rules. Same `1.1.0` version per the rebuild request.
|
|
79
191
|
|
|
80
192
|
### Added
|
|
193
|
+
|
|
81
194
|
- **Streamable HTTP transport.** New `--transport http` mode (default stays `stdio`). Single-endpoint POST `/mcp` per the MCP 2024-11-05 spec, plus `/healthz` for load balancer probes. Implementation is pure `node:http`, no new dependencies.
|
|
82
195
|
- **Per-user bearer token auth.** New `MCP_BEARER_TOKENS_FILE` env var pointing at a `{ "<name>": "<token>" }` JSON file gives each team member a stable name in audit logs and supports rotation. SIGHUP reloads tokens without dropping connections. `MCP_BEARER_TOKENS` env var (comma-separated raw tokens) is a fallback for small or quick-test setups.
|
|
83
196
|
- **Audit logging.** Every authenticated HTTP request emits `[audit] <ts> | <name> | <method> | <param-summary> | <status>` to stderr; systemd captures it via journald.
|
|
@@ -94,12 +207,14 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
|
|
|
94
207
|
- **README auto-regenerator** at `scripts/regen-readme-tools-table.mjs`. `npm run regen:readme` keeps the README table in sync with `ALL_TOOLS`. `npm run regen:readme -- --check` fails when stale (suitable for CI).
|
|
95
208
|
|
|
96
209
|
### Fixed
|
|
210
|
+
|
|
97
211
|
- **README tool table.** Previous count was 19; actual is 26. Auto-generated now.
|
|
98
212
|
- **Header comment in `index.js`.** Previously said 21; updated to 26.
|
|
99
213
|
- **`purple_ai_query`** removed from the documentation. The tool itself was removed 2026-05-03 because the underlying API requires a browser-session `teamToken` that service-account API tokens never obtain. The README, `index.js`, and `docs/mcp-tools.md` no longer reference it.
|
|
100
214
|
- **`uam_set_status` documentation.** Doc previously said valid status values include `CLOSED`. The source enum is `NEW`, `IN_PROGRESS`, `RESOLVED`; doc now matches.
|
|
101
215
|
|
|
102
216
|
### Changed
|
|
217
|
+
|
|
103
218
|
- **Refactored** dispatch out of `index.js` into `lib/server-core.js` so both transports use one code path. `lib/stdio-transport.js` is the extracted stdio loop; `lib/http-transport.js` is new.
|
|
104
219
|
- **package.json**:
|
|
105
220
|
- `version` 1.0.0 → 1.1.0
|
|
@@ -107,6 +222,7 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
|
|
|
107
222
|
- new files included in the npm tarball: `deploy/`, `scripts/`, `CHANGELOG.md`
|
|
108
223
|
|
|
109
224
|
### Compatibility
|
|
225
|
+
|
|
110
226
|
- Default invocation is unchanged: `npx -y @pmoses-s1/s1-secops-mcp` still produces a stdio MCP server with identical behaviour to 1.0.0.
|
|
111
227
|
- Existing `claude_desktop_config.json` and `.mcp.json` configs work without modification.
|
|
112
228
|
- The 26 tools, 2 resources, and 2 prompts are unchanged from the late-1.0.0 line; only the documentation now matches reality.
|
|
@@ -114,6 +230,7 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
|
|
|
114
230
|
## 1.0.0 - 2026-05-07
|
|
115
231
|
|
|
116
232
|
Initial public release.
|
|
233
|
+
|
|
117
234
|
- 19 tools across PowerQuery, S1 Mgmt REST, UAM, SDL API, Hyperautomation.
|
|
118
235
|
- stdio transport only.
|
|
119
236
|
- Credentials via env vars or auto-discovered `credentials.json`.
|
package/README.md
CHANGED
|
@@ -43,10 +43,12 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
|
|
|
43
43
|
<!-- END AUTO-GENERATED TOOLS TABLE -->
|
|
44
44
|
|
|
45
45
|
**2 resources:**
|
|
46
|
+
|
|
46
47
|
- `sentinelone://soc-context`: `CLAUDE.md`, the Principal SOC Analyst operating instructions.
|
|
47
48
|
- `sentinelone://credentials-status`: which credentials are configured and which API surfaces are available.
|
|
48
49
|
|
|
49
50
|
**2 prompts:**
|
|
51
|
+
|
|
50
52
|
- `soc_analyst`: embeds `CLAUDE.md` as a system prompt; call at session start.
|
|
51
53
|
- `session_init`: structured init: enumerate sources + triage alerts in parallel.
|
|
52
54
|
|
|
@@ -65,7 +67,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
|
|
|
65
67
|
"mcpServers": {
|
|
66
68
|
"s1-secops-mcp": {
|
|
67
69
|
"command": "npx",
|
|
68
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
70
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"],
|
|
69
71
|
"env": {
|
|
70
72
|
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
|
|
71
73
|
"S1_CONSOLE_API_TOKEN": "eyJ...",
|
|
@@ -126,16 +128,16 @@ Cmd+Q and reopen Claude Desktop. SentinelOne credentials live on the VM in `/etc
|
|
|
126
128
|
|
|
127
129
|
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
130
|
|
|
129
|
-
`S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, and
|
|
131
|
+
`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
132
|
|
|
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
|
|
133
|
+
`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
134
|
|
|
133
|
-
`
|
|
135
|
+
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
136
|
|
|
135
137
|
| Variable | Description | Required for |
|
|
136
138
|
|----------|-------------|--------------|
|
|
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 |
|
|
139
|
+
| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery + SDL tools |
|
|
140
|
+
| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM + SDL config-file tools |
|
|
139
141
|
| `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
142
|
|
|
141
143
|
### Credential resolution order (highest priority wins)
|
|
@@ -204,7 +206,7 @@ If neither env var is set, HTTP transport runs **without** authentication and th
|
|
|
204
206
|
|
|
205
207
|
Every authenticated HTTP request emits a structured stderr line that systemd captures via journald:
|
|
206
208
|
|
|
207
|
-
```
|
|
209
|
+
```json
|
|
208
210
|
[audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
|
|
209
211
|
[audit] 2026-05-28T15:01:34.221Z | bob | tools/list | - | 200 ok
|
|
210
212
|
[audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
|
|
@@ -434,7 +436,7 @@ The `maxRows` (`powerquery_run`) and `first` (`uam_list_alerts`) parameters are
|
|
|
434
436
|
|
|
435
437
|
## CLI reference
|
|
436
438
|
|
|
437
|
-
```
|
|
439
|
+
```text
|
|
438
440
|
s1-secops-mcp [options]
|
|
439
441
|
|
|
440
442
|
OPTIONS
|
|
@@ -448,7 +450,7 @@ OPTIONS
|
|
|
448
450
|
|
|
449
451
|
## Architecture
|
|
450
452
|
|
|
451
|
-
```
|
|
453
|
+
```text
|
|
452
454
|
s1-secops-mcp/
|
|
453
455
|
index.js Entry: flag parsing + transport selection
|
|
454
456
|
lib/
|
|
@@ -485,6 +487,7 @@ s1-secops-mcp/
|
|
|
485
487
|
| Purple AI GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
486
488
|
| UAM GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
487
489
|
| UAM HEC ingest | `Authorization: Bearer <jwt>` | `S1_CONSOLE_API_TOKEN` |
|
|
490
|
+
| SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer <jwt>`, an `s1-scope` header is ignored, not rejected | `S1_CONSOLE_API_TOKEN` |
|
|
488
491
|
|
|
489
492
|
## Testing
|
|
490
493
|
|
package/deploy/README.md
CHANGED
|
@@ -21,6 +21,7 @@ bash /tmp/s1-mcp-install.sh --user
|
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
That runs `install.sh --user`, which:
|
|
24
|
+
|
|
24
25
|
1. Confirms Node 18+ is present (errors out with install hints if not).
|
|
25
26
|
2. Sets up a per-user npm prefix at `~/.npm-global` if one isn't configured.
|
|
26
27
|
3. Installs `@pmoses-s1/s1-secops-mcp` globally for your user.
|
|
@@ -56,7 +57,7 @@ Or, equivalently, by package name without the install:
|
|
|
56
57
|
"mcpServers": {
|
|
57
58
|
"s1-secops-mcp": {
|
|
58
59
|
"command": "npx",
|
|
59
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
60
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"]
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
}
|
|
@@ -117,11 +118,13 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
117
118
|
1. **Provision the VM.** Anything that runs systemd is fine: Ubuntu 22.04 LTS, Debian 12, Rocky/Alma 9, etc.
|
|
118
119
|
|
|
119
120
|
2. **Install Node 18+.** Pick one:
|
|
121
|
+
|
|
120
122
|
```bash
|
|
121
123
|
# Ubuntu / Debian
|
|
122
124
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
123
125
|
sudo apt install -y nodejs
|
|
124
126
|
```
|
|
127
|
+
|
|
125
128
|
```bash
|
|
126
129
|
# Rocky / Alma
|
|
127
130
|
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
|
|
@@ -129,12 +132,15 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
129
132
|
```
|
|
130
133
|
|
|
131
134
|
3. **Run the installer in server mode:**
|
|
135
|
+
|
|
132
136
|
```bash
|
|
133
137
|
curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/s1-secops-mcp/deploy/install.sh | sudo bash -s -- --server
|
|
134
138
|
```
|
|
139
|
+
|
|
135
140
|
It creates the `mcp` user, drops `/etc/s1-secops-mcp/credentials.json` (placeholder) and `/etc/s1-secops-mcp/bearer-tokens.json` (one freshly-generated admin token, printed once to stdout), installs the systemd unit, and starts the service.
|
|
136
141
|
|
|
137
142
|
4. **Fill in real SentinelOne credentials:**
|
|
143
|
+
|
|
138
144
|
```bash
|
|
139
145
|
sudo vim /etc/s1-secops-mcp/credentials.json
|
|
140
146
|
sudo systemctl reload s1-secops-mcp
|
|
@@ -142,15 +148,18 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
142
148
|
```
|
|
143
149
|
|
|
144
150
|
5. **Put TLS in front with Caddy** (the recommended option):
|
|
151
|
+
|
|
145
152
|
```bash
|
|
146
153
|
sudo apt install -y caddy
|
|
147
154
|
sudo cp /usr/lib/node_modules/@pmoses-s1/s1-secops-mcp/deploy/caddy/Caddyfile.example /etc/caddy/Caddyfile
|
|
148
155
|
sudo vim /etc/caddy/Caddyfile # change mcp.s1.internal to your DNS name
|
|
149
156
|
sudo systemctl reload caddy
|
|
150
157
|
```
|
|
158
|
+
|
|
151
159
|
Default Caddyfile uses `tls internal` which signs with Caddy's own CA. Distribute `/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt` to your team for trust, or use `tls <your-email>` with a publicly resolvable hostname for Let's Encrypt.
|
|
152
160
|
|
|
153
161
|
6. **Add team members.** Generate a token per person and append to the file:
|
|
162
|
+
|
|
154
163
|
```bash
|
|
155
164
|
sudo bash -c 'cat > /etc/s1-secops-mcp/bearer-tokens.json' <<EOF
|
|
156
165
|
{
|
|
@@ -164,9 +173,11 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
164
173
|
sudo chown mcp:mcp /etc/s1-secops-mcp/bearer-tokens.json
|
|
165
174
|
sudo systemctl reload s1-secops-mcp # SIGHUP, no downtime
|
|
166
175
|
```
|
|
176
|
+
|
|
167
177
|
Hand each person their token over a secure channel (1Password, Signal, etc.).
|
|
168
178
|
|
|
169
179
|
7. **Connect from a Claude client.** Each user adds the server to their config with their personal token:
|
|
180
|
+
|
|
170
181
|
```json
|
|
171
182
|
{
|
|
172
183
|
"mcpServers": {
|
|
@@ -182,6 +193,7 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
182
193
|
```
|
|
183
194
|
|
|
184
195
|
8. **Verify end-to-end.** From a team member's machine:
|
|
196
|
+
|
|
185
197
|
```bash
|
|
186
198
|
curl -s -X POST https://mcp.s1.internal/mcp \
|
|
187
199
|
-H "Authorization: Bearer $TOKEN" \
|
|
@@ -191,6 +203,7 @@ Team members connect from their Claude clients with their own bearer token. Audi
|
|
|
191
203
|
```
|
|
192
204
|
|
|
193
205
|
9. **Watch the audit log.** Every authenticated request is logged with the bearer name, method, and param summary:
|
|
206
|
+
|
|
194
207
|
```bash
|
|
195
208
|
sudo journalctl -u s1-secops-mcp -f | grep '\[audit\]'
|
|
196
209
|
# [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
|
|
@@ -231,7 +244,7 @@ sudo systemctl restart s1-secops-mcp
|
|
|
231
244
|
|
|
232
245
|
The structured audit lines look like:
|
|
233
246
|
|
|
234
|
-
```
|
|
247
|
+
```json
|
|
235
248
|
[audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
|
|
236
249
|
[audit] 2026-05-28T16:42:55.108Z | bob | tools/list | - | 200 ok
|
|
237
250
|
[audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
|
|
@@ -296,7 +309,7 @@ The instance's `*.compute.internal` DNS name (e.g. `ip-172-31-7-227.ap-southeast
|
|
|
296
309
|
|
|
297
310
|
If you try to issue a cert for the EC2 public DNS, LE returns:
|
|
298
311
|
|
|
299
|
-
```
|
|
312
|
+
```text
|
|
300
313
|
HTTP 400 urn:ietf:params:acme:error:rejectedIdentifier
|
|
301
314
|
The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
|
|
302
315
|
```
|
|
@@ -363,7 +376,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
|
|
|
363
376
|
|
|
364
377
|
These are supported but not first-class:
|
|
365
378
|
|
|
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.
|
|
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.3` + `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
380
|
|
|
368
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.
|
|
369
382
|
|
package/deploy/bridge/README.md
CHANGED
|
@@ -76,7 +76,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
|
|
76
76
|
|
|
77
77
|
## How it works
|
|
78
78
|
|
|
79
|
-
```
|
|
79
|
+
```text
|
|
80
80
|
+----------------+ stdio JSON-RPC +--------+ HTTPS POST /mcp +------+
|
|
81
81
|
| Claude Desktop | <------------------------------> | bridge | <---------------------------> | VM |
|
|
82
82
|
+----------------+ +--------+ Bearer auth, JSON in/out +------+
|
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,253 @@ 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`.
|
|
84
135
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
136
|
+
/** Marks an error as originating from the GraphQL layer rather than the
|
|
137
|
+
* transport. Absence detection keys off this: a 404 page whose body contains
|
|
138
|
+
* "not found" must never be read as "the file does not exist". */
|
|
139
|
+
class SdlGraphqlError extends Error {
|
|
140
|
+
constructor(message) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.name = 'SdlGraphqlError';
|
|
143
|
+
this.graphql = true;
|
|
144
|
+
}
|
|
88
145
|
}
|
|
89
146
|
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
147
|
+
/**
|
|
148
|
+
* POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array.
|
|
149
|
+
*
|
|
150
|
+
* GraphQL reports failure as HTTP 200 with an `errors` array, so the status
|
|
151
|
+
* code alone never proves success. Three things this must not do, each of which
|
|
152
|
+
* would resurface the exact false-negative class this module exists to remove:
|
|
153
|
+
*
|
|
154
|
+
* 1. Accept a non-JSON 200. sdlFetch falls back to raw text when JSON.parse
|
|
155
|
+
* fails, so a proxy interstitial or WAF page arrives as a string. Left
|
|
156
|
+
* unchecked, `payload.errors` is undefined and every caller returns its
|
|
157
|
+
* empty default: "no files", "not found", "deleted".
|
|
158
|
+
* 2. Require `errors` to be an array. A bare `{errors: {...}}` object would
|
|
159
|
+
* slip through an Array.isArray gate.
|
|
160
|
+
* 3. Return a payload carrying neither `data` nor `errors`.
|
|
161
|
+
*
|
|
162
|
+
* `readOnly` opts into status-based retry; only pass it for queries.
|
|
163
|
+
*/
|
|
164
|
+
async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
|
|
165
|
+
const body = { query };
|
|
166
|
+
if (variables) body.variables = variables;
|
|
167
|
+
const payload = await sdlFetch(
|
|
168
|
+
'POST',
|
|
169
|
+
`/v2/graphql?opname=${encodeURIComponent(opname)}`,
|
|
170
|
+
{ body, allowRetry: readOnly }
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`SDL GraphQL ${opname}: expected a JSON object, got ${typeof payload}. ` +
|
|
176
|
+
'This usually means a proxy or auth interstitial answered instead of the API. ' +
|
|
177
|
+
`First 200 chars: ${String(payload).slice(0, 200)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (payload.errors) {
|
|
181
|
+
const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors];
|
|
182
|
+
const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId;
|
|
183
|
+
const msg = errs[0]?.message || 'unknown GraphQL error';
|
|
184
|
+
throw new SdlGraphqlError(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
|
|
185
|
+
}
|
|
186
|
+
if (!('data' in payload)) {
|
|
187
|
+
throw new SdlGraphqlError(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
|
|
188
|
+
}
|
|
189
|
+
return payload.data;
|
|
96
190
|
}
|
|
97
191
|
|
|
98
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
192
|
+
/** udoIds are 16 digits, within ~1.4x of Number.MAX_SAFE_INTEGER. A caller that
|
|
193
|
+
* sends one as a JSON number has already lost precision before we stringify. */
|
|
194
|
+
function assertSafeUdoId(udoId) {
|
|
195
|
+
if (typeof udoId === 'number' && !Number.isSafeInteger(udoId)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`udoId ${udoId} exceeds the JS safe-integer range and has already lost precision. Pass it as a string.`
|
|
198
|
+
);
|
|
104
199
|
}
|
|
105
|
-
return
|
|
200
|
+
return String(udoId);
|
|
106
201
|
}
|
|
107
202
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
203
|
+
const CONFIG_FIELDS = 'udoId name readOnly version';
|
|
204
|
+
|
|
205
|
+
/** SDL config names are case-insensitive and tolerate stray whitespace, so the
|
|
206
|
+
* absence check and the duplicate guard must normalise identically. */
|
|
207
|
+
function normaliseName(n) {
|
|
208
|
+
return String(n ?? '').trim().toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
function matchesName(file, name) {
|
|
211
|
+
return normaliseName(file?.name) === normaliseName(name);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every config file on the tenant, including udoId-addressed dashboards. */
|
|
215
|
+
export async function configFiles() {
|
|
216
|
+
const data = await sdlGraphql(
|
|
217
|
+
'getConfigurationFiles',
|
|
218
|
+
`query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
|
|
219
|
+
undefined,
|
|
220
|
+
{ readOnly: true }
|
|
221
|
+
);
|
|
222
|
+
return data?.configFiles ?? [];
|
|
113
223
|
}
|
|
114
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Read one config file by name (plain files) or udoId (dashboards).
|
|
227
|
+
* Returns null when the file does not exist.
|
|
228
|
+
*
|
|
229
|
+
* Absence is a normal outcome of a lookup, but the server reports it as a
|
|
230
|
+
* GraphQL error, and the message differs by address form (verified live):
|
|
231
|
+
*
|
|
232
|
+
* by name : "Config file with name /x/y not found." <- explicit
|
|
233
|
+
* by udoId: "Something went wrong. Please try again..." <- generic, and the
|
|
234
|
+
* SAME text a version conflict returns, so it cannot be trusted
|
|
235
|
+
* on message alone.
|
|
236
|
+
*
|
|
237
|
+
* So the explicit form is normalised directly, and the ambiguous one is
|
|
238
|
+
* disambiguated against the file listing: absent from configFiles means the
|
|
239
|
+
* file is genuinely gone, otherwise the error was real and is rethrown. The
|
|
240
|
+
* extra listing only happens on the error path.
|
|
241
|
+
*/
|
|
242
|
+
export async function configFile({ name, udoId }) {
|
|
243
|
+
if (!name && !udoId) throw new Error('configFile requires either name or udoId');
|
|
244
|
+
// Validate before the try: an invalid udoId is a caller bug, not a signal
|
|
245
|
+
// that the file is absent, and must never be swallowed by the absence path.
|
|
246
|
+
const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
|
|
247
|
+
try {
|
|
248
|
+
const data = safeUdoId
|
|
249
|
+
? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true })
|
|
250
|
+
: await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true });
|
|
251
|
+
return data?.configFile ?? null;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Only a GraphQL-layer error can mean "absent". A transport failure whose
|
|
254
|
+
// body happens to contain the words "not found" (a 404 page, a WAF block)
|
|
255
|
+
// must never be read as absence: that is how a delete gets confirmed
|
|
256
|
+
// against a file that was never checked.
|
|
257
|
+
if (!err.graphql) throw err;
|
|
258
|
+
if (/config file with (name|id) .* not found/i.test(err.message)) return null;
|
|
259
|
+
|
|
260
|
+
// The udoId form returns a generic message that a version conflict also
|
|
261
|
+
// returns, so settle it against the listing. If the listing itself fails,
|
|
262
|
+
// surface the ORIGINAL error with the listing failure attached rather than
|
|
263
|
+
// replacing it.
|
|
264
|
+
let all;
|
|
265
|
+
try {
|
|
266
|
+
all = await configFiles();
|
|
267
|
+
} catch (listErr) {
|
|
268
|
+
err.message += ` (absence check failed: ${listErr.message})`;
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
const present = udoId
|
|
272
|
+
? all.some(f => String(f.udoId) === String(udoId))
|
|
273
|
+
: all.some(f => matchesName(f, name));
|
|
274
|
+
if (!present) return null;
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Create or update a config file.
|
|
281
|
+
* - udoId given → updates that file in place; pass expectedVersion to lock.
|
|
282
|
+
* - name given → updates in place for plain files, but CREATES A DUPLICATE
|
|
283
|
+
* for /dashboards/. Never write a dashboard by name.
|
|
284
|
+
*/
|
|
285
|
+
export async function putConfigFile({ name, udoId, content, expectedVersion }) {
|
|
286
|
+
if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
|
|
287
|
+
// Creating a dashboard must go by name (no udoId exists yet); only an
|
|
288
|
+
// *existing* dashboard is at risk of being duplicated by a name-addressed
|
|
289
|
+
// write. So refuse only when a file of that name already exists.
|
|
290
|
+
if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
|
|
291
|
+
const all = await configFiles();
|
|
292
|
+
// An empty listing means the check could not run, not that the name is
|
|
293
|
+
// free. Failing open here would silently disable the guard.
|
|
294
|
+
if (!all.length) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Refusing to write "${name}" by name: the configFiles listing came back empty, so the ` +
|
|
297
|
+
'duplicate check could not run. Retry, or pass an explicit udoId.'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const existing = all.filter(f => matchesName(f, name));
|
|
301
|
+
if (existing.length) {
|
|
302
|
+
const ids = existing.map(f => f.udoId).filter(Boolean).join(', ');
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Refusing to write "${name}" by name: ${existing.length} dashboard(s) already use that name, ` +
|
|
305
|
+
'and a name-addressed write to /dashboards/ creates another duplicate rather than updating. ' +
|
|
306
|
+
`Pass one of these udoIds instead: ${ids || '(none, file has no udoId)'}.`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// expectedVersion is honoured on BOTH address forms. Verified live 2026-08-07:
|
|
311
|
+
// a stale expectedVersion on a name-addressed /datatables/ write was rejected
|
|
312
|
+
// with "There are conflicting changes in the file." and the content was left
|
|
313
|
+
// untouched. Omitting it here would silently downgrade every parser, lookup,
|
|
314
|
+
// datatable and /automaticLookups write to last-write-wins.
|
|
315
|
+
const data = udoId
|
|
316
|
+
? await sdlGraphql('addConfigFile',
|
|
317
|
+
`mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
318
|
+
{ udoId: assertSafeUdoId(udoId), content, expectedVersion })
|
|
319
|
+
: await sdlGraphql('addConfigFile',
|
|
320
|
+
`mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
|
|
321
|
+
{ name, content, expectedVersion });
|
|
322
|
+
return data?.addConfigFile ?? null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Delete a config file. Dashboards delete by udoId, plain files by name.
|
|
327
|
+
* A null return with no errors array is SUCCESS; the deleted object is not
|
|
328
|
+
* echoed back. Treating that null as a failure is the classic mistake here.
|
|
329
|
+
*/
|
|
330
|
+
export async function deleteConfigFile({ name, udoId, expectedVersion }) {
|
|
331
|
+
if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
|
|
332
|
+
const raw = udoId
|
|
333
|
+
? await sdlGraphql('deleteConfigFile',
|
|
334
|
+
'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
|
|
335
|
+
{ udoId: assertSafeUdoId(udoId), expectedVersion })
|
|
336
|
+
: await sdlGraphql('deleteConfigFile',
|
|
337
|
+
'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
|
|
338
|
+
{ id: name, expectedVersion });
|
|
339
|
+
|
|
340
|
+
// The mutation returns null on success and does not echo the deleted object,
|
|
341
|
+
// so its response cannot distinguish "deleted" from "matched nothing". Confirm
|
|
342
|
+
// by re-reading. This is the house rule established by uamSetStatus in
|
|
343
|
+
// lib/s1.js: never treat a mutation response as proof, re-get and verify.
|
|
344
|
+
const still = await configFile({ name, udoId });
|
|
345
|
+
if (still) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
|
|
348
|
+
`(version ${still.version}). The mutation reported no errors but nothing was removed.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return { status: 'success', deleted: udoId ? { udoId: String(udoId) } : { name }, raw: raw?.deleteConfigFile ?? null };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The legacy REST config-file endpoints (/api/listFiles, /api/getFile,
|
|
355
|
+
// /api/putFile) are not wrapped here. They cannot see or modify a
|
|
356
|
+
// udoId-addressed dashboard, so their listing is unsafe for any "does this file
|
|
357
|
+
// exist" decision. The GraphQL operations above cover every namespace,
|
|
358
|
+
// including parsers, lookups, datatables and /automaticLookups.
|
|
359
|
+
|
|
115
360
|
// ─── V1 Query (schema discovery) ─────────────────────────────────────────────
|
|
116
361
|
// Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
|
|
117
362
|
// Use for schema discovery; use LRQ for hunting.
|
|
@@ -126,5 +371,7 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
|
|
|
126
371
|
startTime,
|
|
127
372
|
};
|
|
128
373
|
if (endTime) body.endTime = endTime;
|
|
129
|
-
|
|
374
|
+
// Read-only POST: opt back into status retry. Schema discovery iterates this
|
|
375
|
+
// once per data source, which is the workload that trips the SDL QPS cap.
|
|
376
|
+
return sdlFetch('POST', '/api/query', { body, allowRetry: true });
|
|
130
377
|
}
|
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.3",
|
|
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. Enforced on BOTH address forms, path and udoId: a stale value is rejected and nothing is written. Omit only 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
|
},
|