From 02f47eb61e6c91bf6214c3bf0057d45ebeafdcd3 Mon Sep 17 00:00:00 2001 From: Prithvi Moses Date: Fri, 7 Aug 2026 23:01:58 -0700 Subject: [PATCH 1/6] sync claude-skills: plugin 1.3.1, s1-secops-mcp 1.3.3 Syncs the eight skills, the MCP server, docker assets and CLAUDE.md from claude-skills, and rebuilds the plugin bundle in this repo. SDL configuration files move to POST /sdl/v2/graphql. The legacy REST endpoints are incomplete: measured on a live tenant, listFiles returned 1,914 paths against configFiles' 2,264, and getFile on any of the 350 udoId-addressed dashboards returns success/noSuchFile. The sdl-api skill and the MCP tools now use GraphQL, address dashboards by udoId, and refuse a name-addressed write to an existing dashboard because that creates a duplicate rather than updating. One console API token authorises every SDL surface. SDL_XDR_URL and the scoped SDL config and log keys are retired and no longer read. Plugin 1.3.1: the previously distributed bundles were built before this work, so four skills shipped superseded guidance. Rebuilt from source; the version bump is what makes the corrected skills reach anyone already on 1.3.0. --- .claude-plugin/marketplace.json | 2 +- mcp/docker/README.md | 4 +- mcp/docker/build.sh | 2 +- mcp/s1-secops-mcp/CHANGELOG.md | 119 ++++++- mcp/s1-secops-mcp/README.md | 21 +- mcp/s1-secops-mcp/deploy/README.md | 21 +- mcp/s1-secops-mcp/deploy/bridge/README.md | 2 +- mcp/s1-secops-mcp/lib/sdl.js | 299 +++++++++++++++-- mcp/s1-secops-mcp/lib/server-core.js | 2 +- mcp/s1-secops-mcp/package.json | 4 +- mcp/s1-secops-mcp/tests/sdl-graphql.test.mjs | 272 ++++++++++++++++ mcp/s1-secops-mcp/tools/sdl-api.js | 84 +++-- .../.claude-plugin/plugin.json | 2 +- plugins/s1-secops-skills/CLAUDE.md | 38 ++- .../dist/hyperautomation.skill | Bin 87297 -> 87298 bytes .../dist/mgmt-console-api.skill | Bin 2150699 -> 2150807 bytes .../s1-secops-skills/dist/powerquery.skill | Bin 99559 -> 99965 bytes ....plugin => s1-secops-skills-v1.3.1.plugin} | Bin 3255557 -> 3271316 bytes plugins/s1-secops-skills/dist/sdl-api.skill | Bin 41160 -> 54389 bytes .../s1-secops-skills/dist/sdl-dashboard.skill | Bin 94161 -> 95764 bytes .../dist/sdl-log-parser.skill | Bin 172500 -> 172585 bytes .../s1-secops-skills/dist/sdl-solutions.skill | Bin 585974 -> 586267 bytes .../dist/soc-investigator.skill | Bin 17040 -> 17044 bytes .../references/api-integration.md | 35 +- .../references/building-blocks-catalog.md | 48 ++- .../references/building-blocks.md | 54 ++- .../hyperautomation/references/connections.md | 2 +- .../references/functions-reference.md | 18 +- .../references/integration-catalog.md | 1 - .../references/validation-rules.md | 3 + .../references/workflow-schema.md | 17 +- .../references/CAPABILITY_MAP.md | 2 +- .../references/POWERQUERY_RECIPES.md | 21 +- .../references/UNIFIED_ALERTS.md | 8 +- .../mgmt-console-api/references/WORKFLOWS.md | 1 + .../mgmt-console-api/references/lrq-api.md | 14 +- .../references/tags/Accounts.md | 34 +- .../references/tags/Activities.md | 7 + .../references/tags/Agent_Actions.md | 154 ++++++--- .../references/tags/Agent_Support_Actions.md | 4 +- .../references/tags/Agents.md | 34 +- .../references/tags/Agents_Repository_Beta.md | 6 + .../references/tags/Application_Management.md | 34 +- .../tags/Application_Management_Settings.md | 6 +- .../references/tags/Application_Risk.md | 2 + .../tags/Application_Risk_Deprecated.md | 4 + .../references/tags/Auto_Upgrade_Policy.md | 26 ++ .../references/tags/Cloud_Funnel.md | 26 +- .../references/tags/Cloud_Resources.md | 4 + .../references/tags/Config_Overrides.md | 16 +- .../references/tags/Custom_Detection_Rule.md | 23 +- .../tags/Datalake_Unified_Actions.md | 16 +- .../references/tags/Deep_Visibility.md | 24 +- .../references/tags/Default_Reports.md | 25 +- .../references/tags/Device_Control.md | 50 ++- .../references/tags/Dynamic_tag_rules.md | 16 +- .../tags/Exclusions_and_Blocklist.md | 58 +++- .../references/tags/Exclusions_v2_1.md | 26 +- .../references/tags/Filters.md | 40 ++- .../references/tags/Firewall_Control.md | 60 +++- .../references/tags/Forensics.md | 8 + .../references/tags/Gateways.md | 12 +- .../mgmt-console-api/references/tags/Graph.md | 12 +- .../references/tags/Graph_Query_Builder.md | 16 + .../references/tags/Graph_Query_Management.md | 16 +- .../references/tags/Groups.md | 30 +- .../references/tags/Hashes.md | 4 +- .../references/tags/Hyperautomation.md | 252 +++++++------- .../mgmt-console-api/references/tags/ISPM.md | 22 +- .../references/tags/Inventory.md | 24 +- .../references/tags/Inventory_AI_ML.md | 16 +- .../tags/Inventory_AI_ML_Filters.md | 5 + .../references/tags/Inventory_Account.md | 16 +- .../tags/Inventory_Account_Filters.md | 5 + .../tags/Inventory_Application_Integration.md | 16 +- ...ventory_Application_Integration_Filters.md | 5 + .../tags/Inventory_Cloud_Application.md | 16 +- .../Inventory_Cloud_Application_Filters.md | 5 + .../tags/Inventory_Cloud_Surface.md | 12 +- .../tags/Inventory_Cloud_Surface_Filters.md | 5 + .../references/tags/Inventory_Container.md | 16 +- .../tags/Inventory_Container_Filters.md | 5 + .../tags/Inventory_Data_Analysis.md | 16 +- .../tags/Inventory_Data_Analysis_Filters.md | 5 + .../references/tags/Inventory_Data_Store.md | 16 +- .../tags/Inventory_Data_Store_Filters.md | 5 + .../tags/Inventory_Developer_Tool.md | 16 +- .../tags/Inventory_Developer_Tool_Filters.md | 5 + .../references/tags/Inventory_Device.md | 16 +- .../tags/Inventory_Device_Filters.md | 5 + .../tags/Inventory_Endpoint_Surface.md | 16 +- .../Inventory_Endpoint_Surface_Filters.md | 5 + .../references/tags/Inventory_Filters.md | 7 + .../references/tags/Inventory_Function.md | 16 +- .../tags/Inventory_Function_Filters.md | 5 + .../references/tags/Inventory_Governance.md | 16 +- .../tags/Inventory_Governance_Filters.md | 5 + .../references/tags/Inventory_Identity.md | 32 +- .../tags/Inventory_Identity_Filters.md | 13 +- .../tags/Inventory_Identity_Surface.md | 28 +- .../Inventory_Identity_Surface_Filters.md | 13 +- .../references/tags/Inventory_Network.md | 16 +- .../Inventory_Network_Discovery_Surface.md | 12 +- ...ntory_Network_Discovery_Surface_Filters.md | 5 + .../tags/Inventory_Network_Filters.md | 5 + .../references/tags/Inventory_Notes.md | 8 +- .../references/tags/Inventory_Server.md | 16 +- .../tags/Inventory_Server_Filters.md | 5 + .../references/tags/Inventory_Storage.md | 16 +- .../tags/Inventory_Storage_Filters.md | 5 + .../references/tags/Inventory_Tags.md | 12 +- .../tags/Inventory_Unified_Actions.md | 24 +- .../references/tags/Inventory_Workstation.md | 21 +- .../references/tags/Live_Updates.md | 2 + .../references/tags/Locations.md | 16 +- .../references/tags/Log_Collection.md | 26 +- .../references/tags/Long_Running_Query.md | 14 +- .../references/tags/Mobile_Integration.md | 32 +- .../references/tags/Network_Discovery.md | 28 +- .../tags/Network_Discovery_Self_Enablement.md | 16 +- .../tags/Network_Quarantine_Control.md | 54 ++- .../tags/Platform_Detection_Rules.md | 28 +- .../references/tags/Policies.md | 25 +- .../mgmt-console-api/references/tags/RBAC.md | 18 +- .../references/tags/RemoteOps_Forensics.md | 27 +- .../references/tags/RemoteOps_Scripts.md | 47 ++- .../references/tags/Remote_Ops_MMS.md | 42 ++- .../references/tags/Saved_Searches.md | 10 +- .../references/tags/Sentinel_Deploy.md | 20 +- .../references/tags/Service_Users.md | 24 +- .../references/tags/Settings.md | 86 ++++- .../mgmt-console-api/references/tags/Sites.md | 50 ++- .../references/tags/System.md | 11 +- .../references/tags/Tag_Manager.md | 12 +- .../mgmt-console-api/references/tags/Tags.md | 16 +- .../mgmt-console-api/references/tags/Tasks.md | 18 +- .../references/tags/Threat_Intelligence.md | 26 +- .../references/tags/Threat_Notes.md | 12 +- .../references/tags/Threats.md | 78 ++++- .../tags/Unprotected_Endpoints_Discovery.md | 12 +- .../references/tags/Updates.md | 21 +- .../mgmt-console-api/references/tags/Users.md | 134 ++++++-- .../references/tags/VCS_Integration.md | 180 ++++++---- .../references/tags/alerts.md | 10 +- .../references/tags/licenses.md | 4 +- .../references/tags/marketplace.md | 32 +- .../references/tags/overview.md | 4 +- .../references/tenant_capabilities.md | 1 + .../examples/behavioral-baselines.md | 8 +- .../powerquery/examples/detection-library.md | 52 +-- .../powerquery/examples/investigations.md | 60 ++-- .../powerquery/examples/o365-email-hunting.md | 14 +- .../references/automatic-lookups.md | 10 +- .../references/commands-reference.md | 52 +-- .../references/datasource-command.md | 12 +- .../powerquery/references/detection-rules.md | 20 +- .../references/fields-and-schema.md | 9 +- .../references/functions-reference.md | 13 +- .../skills/powerquery/references/lrq-api.md | 14 +- .../powerquery/references/o365-fields.md | 16 +- .../skills/powerquery/references/pitfalls.md | 91 +++--- .../references/syntax-and-operators.md | 15 +- .../s1-secops-skills/skills/sdl-api/README.md | 34 +- .../s1-secops-skills/skills/sdl-api/SKILL.md | 86 ++++- .../sdl-api/references/auth_and_limits.md | 4 + .../sdl-api/references/config-file-graphql.md | 187 +++++++++++ .../skills/sdl-api/references/lrq-api.md | 14 +- .../skills/sdl-api/references/methods.md | 22 +- .../skills/sdl-api/scripts/sdl_cli.py | 80 ++++- .../skills/sdl-api/scripts/sdl_client.py | 308 +++++++++++++++++- .../skills/sdl-api/tests/smoke_test.py | 99 ++++-- .../skills/sdl-api/tests/test_client.py | 236 ++++++++++++++ .../skills/sdl-dashboard/README.md | 2 +- .../skills/sdl-dashboard/SKILL.md | 77 +++-- .../references/common-queries.md | 81 +++-- .../references/community-examples.md | 28 +- .../references/evidence-report-template.md | 5 +- .../references/lessons-learned.md | 61 ++-- .../references/panel-type-cheatsheet.md | 23 +- .../skills/sdl-log-parser/SKILL.md | 2 +- .../sdl-log-parser/references/mappers.md | 1 + .../sdl-log-parser/references/ocsf-mapping.md | 5 + .../references/ocsf-schema-documentation.md | 134 ++++---- .../references/parse-directives.md | 9 +- .../sdl-log-parser/references/syntax.md | 7 +- .../skills/sdl-solutions/SKILL.md | 1 + .../references/alert-noise-reduction.md | 22 +- .../references/asset-enrichment.md | 8 +- .../references/custom-detection-exclusions.md | 19 +- .../references/data-source-onboarding.md | 18 +- .../references/detection-as-code.md | 4 +- .../references/ingest-health-monitoring.md | 7 +- .../references/risk-based-alerting.md | 14 +- .../correlation-and-hunt-methodology.md | 2 +- 194 files changed, 4618 insertions(+), 1198 deletions(-) create mode 100644 mcp/s1-secops-mcp/tests/sdl-graphql.test.mjs rename plugins/s1-secops-skills/dist/{s1-secops-skills-v1.3.0.plugin => s1-secops-skills-v1.3.1.plugin} (77%) create mode 100644 plugins/s1-secops-skills/skills/sdl-api/references/config-file-graphql.md create mode 100644 plugins/s1-secops-skills/skills/sdl-api/tests/test_client.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 836bd7f..ae11b5a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,7 @@ "name": "s1-secops-skills", "source": "./plugins/s1-secops-skills", "description": "SentinelOne SecOps skills for Claude: PowerQuery threat hunting and STAR/Custom Detection rules; Management Console API; Singularity Data Lake API; SDL dashboards; log parsing (OCSF); Hyperautomation SOAR; z-score anomaly baselining; autonomous DFIR alert investigation (soc-investigator); and one-prompt SDL solutions: source onboarding, asset enrichment, UEBA, ingest health, detection exclusions, Risk-Based Alerting, alert noise reduction, and Detection as Code.", - "version": "1.3.0", + "version": "1.3.1", "author": { "name": "Prithvi Moses", "email": "prithvi.moses@sentinelone.com" diff --git a/mcp/docker/README.md b/mcp/docker/README.md index 3a18479..76c9a9a 100644 --- a/mcp/docker/README.md +++ b/mcp/docker/README.md @@ -6,7 +6,7 @@ End-user reference: [`docs/docker.md`](../docs/docker.md). This file is for imag ## Layout -``` +```bash docker/ ├── Dockerfile # multi-arch, all 3 MCPs at pinned versions ├── entrypoint.sh # dispatcher: argv[1] selects which MCP to run @@ -29,7 +29,7 @@ When bumping a pin, edit both. They are checked once via `grep` in CI; a mismatc | What | Source | Current pin | |---|---|---| | Image version (`IMAGE_VERSION`) | this repo | `1.3.1` | -| `@pmoses-s1/s1-secops-mcp` | npm | `1.3.1` | +| `@pmoses-s1/s1-secops-mcp` | npm | `1.3.3` | | `@burtthecoder/mcp-virustotal` | npm | `1.0.21` | | `purple-mcp` | git | `07d4992` (Sentinel-One/purple-mcp `v0.7.0`, 2026-06-26) | diff --git a/mcp/docker/build.sh b/mcp/docker/build.sh index 346a3f4..55130e3 100755 --- a/mcp/docker/build.sh +++ b/mcp/docker/build.sh @@ -21,7 +21,7 @@ set -euo pipefail IMAGE_VERSION="${IMAGE_VERSION:-1.3.1}" # ── Pinned MCP versions ────────────────────────────────────────────────────── -S1_MCP_VERSION="${S1_MCP_VERSION:-1.3.1}" +S1_MCP_VERSION="${S1_MCP_VERSION:-1.3.3}" VT_MCP_PACKAGE="${VT_MCP_PACKAGE:-@burtthecoder/mcp-virustotal}" VT_MCP_VERSION="${VT_MCP_VERSION:-1.0.21}" # purple-mcp v0.7.0 (2026-06-26). Pinned to the release commit, not a floating diff --git a/mcp/s1-secops-mcp/CHANGELOG.md b/mcp/s1-secops-mcp/CHANGELOG.md index 5bf4802..6a6a622 100644 --- a/mcp/s1-secops-mcp/CHANGELOG.md +++ b/mcp/s1-secops-mcp/CHANGELOG.md @@ -1,10 +1,112 @@ # Changelog +## 1.3.3 - 2026-08-07 + +Fixes a user-facing regression in 1.3.2 found by running the live MCP tools +against a tenant for the first time. **Upgrade from 1.3.2 is recommended.** + +### Fixed + +- **Every successful `sdl_delete_file` reported an error in 1.3.2.** The delete + verification added in 1.3.2 re-reads the file to confirm removal, but the server + reports absence as a GraphQL error rather than a null result, so the confirming + read threw on exactly the success path. The delete itself always worked; only the + reported outcome was wrong. Verified live: three deletes across `/dashboards/`, + `/datatables/` and `/logParsers/` all removed their file and all three surfaced + as errors. +- **`sdl_get_file` on a missing path returned a raw GraphQL error** instead of the + actionable "this may be udoId-addressed, list it and retry" hint. The hint branch + was unreachable because the library threw before returning. +- **Absence is now detected reliably on both address forms.** The error text differs: + a missing name gives "Config file with name X not found.", a missing `udoId` gives + the generic "Something went wrong. Please try again...", which is also what a + version conflict returns. `configFile` normalises the explicit form and + disambiguates the generic one against the file listing, so a deleted dashboard + reads as absent while a genuine server error still propagates. +- **An out-of-range numeric `udoId` is no longer swallowed by the absence path.** + Validation now runs before the lookup, so a caller bug surfaces as a caller bug. +- **A transport error is never read as "file absent".** Absence detection now requires a + GraphQL-layer error, so a 404 page or WAF block whose body contains the words "not found" + no longer satisfies it. Without this a delete could confirm itself against a file it + never checked. +- **A failing listing during absence disambiguation keeps the original error** instead of + replacing it with the listing failure. +- **The duplicate guard is no longer bypassed by case.** Its namespace test was + case-sensitive while its name comparison was not, so `/Dashboards/AI Usage` skipped the + guard entirely. Both now share one normaliser. +- **`v1Query` keeps its backoff.** Restricting status retry to idempotent methods removed it + from this read-only POST, which schema discovery iterates once per data source. + +### Python client (`sdl-api/scripts/sdl_client.py`) + +Brought to parity with the JS client: + +- Status retry is restricted to idempotent methods. The Python client was retrying POST + mutations, which is the mechanism that duplicates a dashboard on a re-sent write. +- `Retry-After` is capped at 30s; an unbounded value parked the process. +- The duplicate guard fails closed on an empty listing. +- Absence detection, name normalisation and delete verification match the JS behaviour. + +### Tests + +- `tests/sdl-graphql.test.mjs`: 86 cases, adding the transport-error-is-not-absence case, + listing-failure error preservation, the case-variant guard bypass, and `v1Query` retry. +- `sdl-api/tests/test_client.py`: new. 19 cases over a stubbed session, so the Python client + is no longer invisible to CI. Runs in ~0.01s with no network. +- Live regression through the real MCP stdio protocol: handshake, 26 tools, and full + create/read/update/stale-reject/delete/confirm-absent cycles for `/datatables/` and + `/dashboards/`, plus the duplicate guard, the notFound hint, and `/automaticLookups`. + +## 1.3.2 - 2026-08-07 + +Config-file operations move from the legacy REST endpoints to GraphQL. Tool count unchanged at +26, and all four tool names are unchanged, so no caller needs to change. + +### Fixed + +- **`sdl_list_files` no longer returns an incomplete listing.** The REST `/sdl/api/listFiles` + endpoint omits every udoId-addressed dashboard. Measured live on `usea1-purple`: REST returned + 1,914 paths against `configFiles`' 2,264, a 350-file gap consisting entirely of `/dashboards/` + files that carry a `udoId`. REST `getFile` on any of them returns `success/noSuchFile`. The + practical impact was a false negative: a dashboard that existed in the console was reported as + not found. All four config-file tools now run on `POST /sdl/v2/graphql`. +- **`sdl_list_files` description no longer claims to return "all" files.** It did not, and the + claim was load-bearing: an agent reading it had no reason to look further after an empty result. +- **`sdl_get_file` / `sdl_put_file` / `sdl_delete_file` can now address dashboards.** New `udoId` + parameter. The console's Configuration Files grid displays a dashboard as + `/dashboards/id//`; that string is not a path, and reading it as one returns + `no file exists at path`. + +### Added + +- **`lib/sdl.js`: `configFiles`, `configFile`, `putConfigFile`, `deleteConfigFile`** over + `POST /sdl/v2/graphql`. GraphQL reports failure as HTTP 200 with an `errors` array, so the + wrapper raises on that array rather than trusting the status code. +- **Duplicate guardrail on dashboard writes.** `addConfigFile(name:)` updates in place for a + name-addressed file but creates a duplicate for a dashboard (both verified live). `sdl_put_file` + now refuses a name-addressed write to an *existing* dashboard and names the `udoId`s already + holding it, while still allowing the initial create, which has no `udoId` yet. The tenant this + was found on already carries 256 surplus dashboard copies from this behaviour, including 152 of + `/dashboards/AI Usage`. +- **`pathPrefix` filter on `sdl_list_files`**, so callers can scope to `/dashboards/` or + `/logParsers/` without pulling the full listing into context. + +### Notes + +- `udoId` is assigned by namespace, verified live: only `/dashboards/` files get one. `/lookups/`, + `/datatables/`, `/logParsers/` and `/automaticLookups` are name-addressed with `udoId` null. +- `expectedVersion` is enforced on both address forms. A stale value is rejected with + "There are conflicting changes in the file." and the stored content is left untouched. +- A `deleteConfigFile` returning `null` with no `errors` array is success, not failure. +- The scoped SDL keys (`SDL_CONFIG_READ_KEY` and friends) are retired; the console API token + covers every SDL operation. + ## 1.2.4 - 2026-07-31 Hardening release from the 2026-07-31 code review. Tool count unchanged at 26. ### Fixed + - **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. - **`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. - **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. - **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. ### Changed + - **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`. - **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). - **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. - **`const status = response.error ? 200 : 200`** simplified; JSON-RPC errors still return HTTP 200 with an error envelope. ### Tests + - 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`. - Transport and smoke tests read the expected version from `package.json` instead of a hardcoded string. @@ -31,12 +135,14 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26. 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.) ### Fixed + - **`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. - **`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. - **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. - **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. ### Changed + - **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. - **`Retry-After` parsing hardened:** an HTTP-date value no longer collapses to `sleep(NaN)`; waits are validated and capped at 30s. - **`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 - **`powerquery_schema_discover` escapes single quotes** in the data-source name before building the V1 filter. ### Tests + - 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. ## 1.2.2 - 2026-06-13 ### Changed + - **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. - **`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`. ### Notes + - Tool count unchanged at 26 (the Hyperautomation tool was renamed, not added or removed). - `SERVER_INFO.version` bumped in lockstep with `package.json` (the drift that forced the 1.2.0 -> 1.2.1 re-release). @@ -62,22 +171,26 @@ Correctness release from the 2026-07-29 defect review. Fixes two bugs that produ 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. ### Added + - **`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). ### Removed + - **`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. ### Changed + - Tool count unchanged at 26 (removed `sdl_upload_logs`, added `hec_ingest`). - 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`. - ## 1.1.0 - 2026-05-28 (rebuilt 2026-05-31) ### Fixed (rebuild) + - **`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. ### Added + - **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. - **Per-user bearer token auth.** New `MCP_BEARER_TOKENS_FILE` env var pointing at a `{ "": "" }` 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. - **Audit logging.** Every authenticated HTTP request emits `[audit] | | | | ` 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 - **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). ### Fixed + - **README tool table.** Previous count was 19; actual is 26. Auto-generated now. - **Header comment in `index.js`.** Previously said 21; updated to 26. - **`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. - **`uam_set_status` documentation.** Doc previously said valid status values include `CLOSED`. The source enum is `NEW`, `IN_PROGRESS`, `RESOLVED`; doc now matches. ### Changed + - **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. - **package.json**: - `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 - new files included in the npm tarball: `deploy/`, `scripts/`, `CHANGELOG.md` ### Compatibility + - Default invocation is unchanged: `npx -y @pmoses-s1/s1-secops-mcp` still produces a stdio MCP server with identical behaviour to 1.0.0. - Existing `claude_desktop_config.json` and `.mcp.json` configs work without modification. - 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 ## 1.0.0 - 2026-05-07 Initial public release. + - 19 tools across PowerQuery, S1 Mgmt REST, UAM, SDL API, Hyperautomation. - stdio transport only. - Credentials via env vars or auto-discovered `credentials.json`. diff --git a/mcp/s1-secops-mcp/README.md b/mcp/s1-secops-mcp/README.md index e10ff8c..c6e28fd 100644 --- a/mcp/s1-secops-mcp/README.md +++ b/mcp/s1-secops-mcp/README.md @@ -43,10 +43,12 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou **2 resources:** + - `sentinelone://soc-context`: `CLAUDE.md`, the Principal SOC Analyst operating instructions. - `sentinelone://credentials-status`: which credentials are configured and which API surfaces are available. **2 prompts:** + - `soc_analyst`: embeds `CLAUDE.md` as a system prompt; call at session start. - `session_init`: structured init: enumerate sources + triage alerts in parallel. @@ -65,7 +67,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code): "mcpServers": { "s1-secops-mcp": { "command": "npx", - "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.1"], + "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"], "env": { "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net", "S1_CONSOLE_API_TOKEN": "eyJ...", @@ -126,16 +128,16 @@ Cmd+Q and reopen Claude Desktop. SentinelOne credentials live on the VM in `/etc Credential keys, where to get each one, and the two token types are documented canonically in **[docs/credentials.md](../../plugins/s1-secops-skills/docs/credentials.md)**. This section adds the MCP-server-specific detail: which tools each key gates, and the server's full credential-resolution order. -`S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, and UAM tools (16 of the 26). +`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). -`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`. Without it those tools error at call time; the rest still work. +`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. -`SDL_*` keys gate the SDL tools as follows: +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 /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. | Variable | Description | Required for | |----------|-------------|--------------| -| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery tools | -| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM tools | +| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery + SDL tools | +| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM + SDL config-file tools | | `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` | ### Credential resolution order (highest priority wins) @@ -204,7 +206,7 @@ If neither env var is set, HTTP transport runs **without** authentication and th Every authenticated HTTP request emits a structured stderr line that systemd captures via journald: -``` +```json [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok [audit] 2026-05-28T15:01:34.221Z | bob | tools/list | - | 200 ok [audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized @@ -434,7 +436,7 @@ The `maxRows` (`powerquery_run`) and `first` (`uam_list_alerts`) parameters are ## CLI reference -``` +```text s1-secops-mcp [options] OPTIONS @@ -448,7 +450,7 @@ OPTIONS ## Architecture -``` +```text s1-secops-mcp/ index.js Entry: flag parsing + transport selection lib/ @@ -485,6 +487,7 @@ s1-secops-mcp/ | Purple AI GraphQL | `Authorization: ApiToken ` | `S1_CONSOLE_API_TOKEN` | | UAM GraphQL | `Authorization: ApiToken ` | `S1_CONSOLE_API_TOKEN` | | UAM HEC ingest | `Authorization: Bearer ` | `S1_CONSOLE_API_TOKEN` | +| SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer `, an `s1-scope` header is ignored, not rejected | `S1_CONSOLE_API_TOKEN` | ## Testing diff --git a/mcp/s1-secops-mcp/deploy/README.md b/mcp/s1-secops-mcp/deploy/README.md index b65f0a4..a80d94f 100644 --- a/mcp/s1-secops-mcp/deploy/README.md +++ b/mcp/s1-secops-mcp/deploy/README.md @@ -21,6 +21,7 @@ bash /tmp/s1-mcp-install.sh --user ``` That runs `install.sh --user`, which: + 1. Confirms Node 18+ is present (errors out with install hints if not). 2. Sets up a per-user npm prefix at `~/.npm-global` if one isn't configured. 3. Installs `@pmoses-s1/s1-secops-mcp` globally for your user. @@ -56,7 +57,7 @@ Or, equivalently, by package name without the install: "mcpServers": { "s1-secops-mcp": { "command": "npx", - "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.1"] + "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"] } } } @@ -117,11 +118,13 @@ Team members connect from their Claude clients with their own bearer token. Audi 1. **Provision the VM.** Anything that runs systemd is fine: Ubuntu 22.04 LTS, Debian 12, Rocky/Alma 9, etc. 2. **Install Node 18+.** Pick one: + ```bash # Ubuntu / Debian curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt install -y nodejs ``` + ```bash # Rocky / Alma 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 ``` 3. **Run the installer in server mode:** + ```bash curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/s1-secops-mcp/deploy/install.sh | sudo bash -s -- --server ``` + 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. 4. **Fill in real SentinelOne credentials:** + ```bash sudo vim /etc/s1-secops-mcp/credentials.json sudo systemctl reload s1-secops-mcp @@ -142,15 +148,18 @@ Team members connect from their Claude clients with their own bearer token. Audi ``` 5. **Put TLS in front with Caddy** (the recommended option): + ```bash sudo apt install -y caddy sudo cp /usr/lib/node_modules/@pmoses-s1/s1-secops-mcp/deploy/caddy/Caddyfile.example /etc/caddy/Caddyfile sudo vim /etc/caddy/Caddyfile # change mcp.s1.internal to your DNS name sudo systemctl reload caddy ``` + 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 ` with a publicly resolvable hostname for Let's Encrypt. 6. **Add team members.** Generate a token per person and append to the file: + ```bash sudo bash -c 'cat > /etc/s1-secops-mcp/bearer-tokens.json' < | bridge | <---------------------------> | VM | +----------------+ +--------+ Bearer auth, JSON in/out +------+ diff --git a/mcp/s1-secops-mcp/lib/sdl.js b/mcp/s1-secops-mcp/lib/sdl.js index 38c6a4f..403b6b8 100644 --- a/mcp/s1-secops-mcp/lib/sdl.js +++ b/mcp/s1-secops-mcp/lib/sdl.js @@ -37,14 +37,44 @@ function retryAfterMs(res, fallback) { return fallback; } -async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, contentType = 'application/json' } = {}, retries = 3) { - const url = `${sdlBase()}${path}`; +/** + * Origin-pin the SDL request path, mirroring safeUrl() in lib/s1.js. + * + * Every current caller passes a literal, so this is defence in depth: the next + * caller to thread a tool-supplied path through sdlFetch would otherwise be + * able to rewrite the authority ("//evil.example/x", "@evil.example/x") and + * send the tenant bearer token to an attacker-chosen origin. + */ +function safeSdlUrl(path) { + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) { + throw new Error( + `SDL API path must be a string starting with a single "/" (got: ${JSON.stringify(path)?.slice(0, 80)})` + ); + } + const origin = new URL(sdlBase()).origin; + const u = new URL(sdlBase() + path, origin); + if (u.origin !== origin) { + throw new Error(`SDL API path may not change the request origin (resolved to ${u.origin})`); + } + return u.toString(); +} + +async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, contentType = 'application/json', allowRetry = null } = {}, retries = 3) { + const url = safeSdlUrl(path); const headers = { Authorization: `Bearer ${sdlToken()}`, 'Content-Type': contentType, ...extraHeaders, }; + // Status-based retry is restricted to idempotent methods, mirroring lib/s1.js. + // A 5xx received *after* the server committed a write would otherwise be + // re-sent, and a re-sent addConfigFile(name:) against /dashboards/ creates a + // duplicate. Callers opt in with allowRetry:true for read-only POSTs + // (GraphQL queries). Network-layer rejections are still always retried: + // those mean the request may never have reached the server at all. + const methodRetryable = allowRetry !== null ? allowRetry : (method === 'GET' || method === 'HEAD'); + let delay = 500; for (let attempt = 0; attempt <= retries; attempt++) { let res; @@ -61,7 +91,7 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, continue; } - if ((res.status === 429 || res.status >= 500) && attempt < retries) { + if (methodRetryable && (res.status === 429 || res.status >= 500) && attempt < retries) { await sleep(retryAfterMs(res, delay)); delay = Math.min(delay * 2, 8000); continue; @@ -80,38 +110,253 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null, throw new Error(`SDL API ${method} ${path}: request failed after retries`); } -// ─── Config file operations ─────────────────────────────────────────────────── +// ─── Config file operations (GraphQL, canonical) ────────────────────────────── +// +// `POST /sdl/v2/graphql` is the canonical config-file surface. It is a strict +// superset of the legacy REST `/api/*File` endpoints: +// +// * REST listFiles omits every udoId-addressed dashboard. Measured on a live +// tenant: REST returned 1,914 paths, GraphQL configFiles returned 2,264. +// The 350-file gap is entirely /dashboards/ files that carry a udoId, and +// REST getFile on any of them returns `success/noSuchFile`. +// * The console's Configuration Files grid renders a udoId dashboard as the +// display string `/dashboards/id//`. That is NOT a path. Reading +// it as one returns "no file exists at path". Address it by udoId; its real +// name is `/dashboards/`. +// +// udoId assignment is by namespace, verified live: only `/dashboards/` files get +// a udoId. `/lookups/`, `/datatables/`, `/logParsers/` and `/automaticLookups` +// all come back with udoId `null` and are addressed by name. +// +// Write rule, verified live: addConfigFile(name:) UPDATES IN PLACE for a +// name-addressed file, but CREATES A DUPLICATE for a dashboard. Always write a +// dashboard by udoId. Skipping that rule is how one tenant accumulated 152 +// copies of `/dashboards/AI Usage`. -/** POST /api/listFiles: list every configuration file path on the SDL tenant. */ -export async function listFiles() { - return sdlFetch('POST', '/api/listFiles', { body: {} }); +/** Marks an error as originating from the GraphQL layer rather than the + * transport. Absence detection keys off this: a 404 page whose body contains + * "not found" must never be read as "the file does not exist". */ +class SdlGraphqlError extends Error { + constructor(message) { + super(message); + this.name = 'SdlGraphqlError'; + this.graphql = true; + } } -/** POST /api/getFile: read a configuration file by path. - * Returns { path, content, version, ...status }. */ -export async function getFile(path) { - return sdlFetch('POST', '/api/getFile', { - body: { path, prettyprint: true }, - }); +/** + * POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array. + * + * GraphQL reports failure as HTTP 200 with an `errors` array, so the status + * code alone never proves success. Three things this must not do, each of which + * would resurface the exact false-negative class this module exists to remove: + * + * 1. Accept a non-JSON 200. sdlFetch falls back to raw text when JSON.parse + * fails, so a proxy interstitial or WAF page arrives as a string. Left + * unchecked, `payload.errors` is undefined and every caller returns its + * empty default: "no files", "not found", "deleted". + * 2. Require `errors` to be an array. A bare `{errors: {...}}` object would + * slip through an Array.isArray gate. + * 3. Return a payload carrying neither `data` nor `errors`. + * + * `readOnly` opts into status-based retry; only pass it for queries. + */ +async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) { + const body = { query }; + if (variables) body.variables = variables; + const payload = await sdlFetch( + 'POST', + `/v2/graphql?opname=${encodeURIComponent(opname)}`, + { body, allowRetry: readOnly } + ); + + if (typeof payload !== 'object' || payload === null) { + throw new Error( + `SDL GraphQL ${opname}: expected a JSON object, got ${typeof payload}. ` + + 'This usually means a proxy or auth interstitial answered instead of the API. ' + + `First 200 chars: ${String(payload).slice(0, 200)}` + ); + } + if (payload.errors) { + const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors]; + const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId; + const msg = errs[0]?.message || 'unknown GraphQL error'; + throw new SdlGraphqlError(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`); + } + if (!('data' in payload)) { + throw new SdlGraphqlError(`SDL GraphQL ${opname}: response carried neither data nor errors.`); + } + return payload.data; } -/** POST /api/putFile: create or update a configuration file. - * Pass expectedVersion (from a prior getFile) to enable optimistic locking. */ -export async function putFile(path, content, expectedVersion) { - const body = { path, content }; - if (expectedVersion !== undefined && expectedVersion !== null) { - body.expectedVersion = expectedVersion; +/** udoIds are 16 digits, within ~1.4x of Number.MAX_SAFE_INTEGER. A caller that + * sends one as a JSON number has already lost precision before we stringify. */ +function assertSafeUdoId(udoId) { + if (typeof udoId === 'number' && !Number.isSafeInteger(udoId)) { + throw new Error( + `udoId ${udoId} exceeds the JS safe-integer range and has already lost precision. Pass it as a string.` + ); } - return sdlFetch('POST', '/api/putFile', { body }); + return String(udoId); } -/** POST /api/putFile with deleteFile:true deletes a config file. */ -export async function deleteFile(path, expectedVersion) { - const body = { path, deleteFile: true }; - if (expectedVersion !== undefined) body.expectedVersion = expectedVersion; - return sdlFetch('POST', '/api/putFile', { body }); +const CONFIG_FIELDS = 'udoId name readOnly version'; + +/** SDL config names are case-insensitive and tolerate stray whitespace, so the + * absence check and the duplicate guard must normalise identically. */ +function normaliseName(n) { + return String(n ?? '').trim().toLowerCase(); +} +function matchesName(file, name) { + return normaliseName(file?.name) === normaliseName(name); +} + +/** Every config file on the tenant, including udoId-addressed dashboards. */ +export async function configFiles() { + const data = await sdlGraphql( + 'getConfigurationFiles', + `query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`, + undefined, + { readOnly: true } + ); + return data?.configFiles ?? []; } +/** + * Read one config file by name (plain files) or udoId (dashboards). + * Returns null when the file does not exist. + * + * Absence is a normal outcome of a lookup, but the server reports it as a + * GraphQL error, and the message differs by address form (verified live): + * + * by name : "Config file with name /x/y not found." <- explicit + * by udoId: "Something went wrong. Please try again..." <- generic, and the + * SAME text a version conflict returns, so it cannot be trusted + * on message alone. + * + * So the explicit form is normalised directly, and the ambiguous one is + * disambiguated against the file listing: absent from configFiles means the + * file is genuinely gone, otherwise the error was real and is rethrown. The + * extra listing only happens on the error path. + */ +export async function configFile({ name, udoId }) { + if (!name && !udoId) throw new Error('configFile requires either name or udoId'); + // Validate before the try: an invalid udoId is a caller bug, not a signal + // that the file is absent, and must never be swallowed by the absence path. + const safeUdoId = udoId ? assertSafeUdoId(udoId) : null; + try { + const data = safeUdoId + ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true }) + : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true }); + return data?.configFile ?? null; + } catch (err) { + // Only a GraphQL-layer error can mean "absent". A transport failure whose + // body happens to contain the words "not found" (a 404 page, a WAF block) + // must never be read as absence: that is how a delete gets confirmed + // against a file that was never checked. + if (!err.graphql) throw err; + if (/config file with (name|id) .* not found/i.test(err.message)) return null; + + // The udoId form returns a generic message that a version conflict also + // returns, so settle it against the listing. If the listing itself fails, + // surface the ORIGINAL error with the listing failure attached rather than + // replacing it. + let all; + try { + all = await configFiles(); + } catch (listErr) { + err.message += ` (absence check failed: ${listErr.message})`; + throw err; + } + const present = udoId + ? all.some(f => String(f.udoId) === String(udoId)) + : all.some(f => matchesName(f, name)); + if (!present) return null; + throw err; + } +} + +/** + * Create or update a config file. + * - udoId given → updates that file in place; pass expectedVersion to lock. + * - name given → updates in place for plain files, but CREATES A DUPLICATE + * for /dashboards/. Never write a dashboard by name. + */ +export async function putConfigFile({ name, udoId, content, expectedVersion }) { + if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId'); + // Creating a dashboard must go by name (no udoId exists yet); only an + // *existing* dashboard is at risk of being duplicated by a name-addressed + // write. So refuse only when a file of that name already exists. + if (!udoId && normaliseName(name).startsWith('/dashboards/')) { + const all = await configFiles(); + // An empty listing means the check could not run, not that the name is + // free. Failing open here would silently disable the guard. + if (!all.length) { + throw new Error( + `Refusing to write "${name}" by name: the configFiles listing came back empty, so the ` + + 'duplicate check could not run. Retry, or pass an explicit udoId.' + ); + } + const existing = all.filter(f => matchesName(f, name)); + if (existing.length) { + const ids = existing.map(f => f.udoId).filter(Boolean).join(', '); + throw new Error( + `Refusing to write "${name}" by name: ${existing.length} dashboard(s) already use that name, ` + + 'and a name-addressed write to /dashboards/ creates another duplicate rather than updating. ' + + `Pass one of these udoIds instead: ${ids || '(none, file has no udoId)'}.` + ); + } + } + // expectedVersion is honoured on BOTH address forms. Verified live 2026-08-07: + // a stale expectedVersion on a name-addressed /datatables/ write was rejected + // with "There are conflicting changes in the file." and the content was left + // untouched. Omitting it here would silently downgrade every parser, lookup, + // datatable and /automaticLookups write to last-write-wins. + const data = udoId + ? await sdlGraphql('addConfigFile', + `mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`, + { udoId: assertSafeUdoId(udoId), content, expectedVersion }) + : await sdlGraphql('addConfigFile', + `mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`, + { name, content, expectedVersion }); + return data?.addConfigFile ?? null; +} + +/** + * Delete a config file. Dashboards delete by udoId, plain files by name. + * A null return with no errors array is SUCCESS; the deleted object is not + * echoed back. Treating that null as a failure is the classic mistake here. + */ +export async function deleteConfigFile({ name, udoId, expectedVersion }) { + if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId'); + const raw = udoId + ? await sdlGraphql('deleteConfigFile', + 'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }', + { udoId: assertSafeUdoId(udoId), expectedVersion }) + : await sdlGraphql('deleteConfigFile', + 'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }', + { id: name, expectedVersion }); + + // The mutation returns null on success and does not echo the deleted object, + // so its response cannot distinguish "deleted" from "matched nothing". Confirm + // by re-reading. This is the house rule established by uamSetStatus in + // lib/s1.js: never treat a mutation response as proof, re-get and verify. + const still = await configFile({ name, udoId }); + if (still) { + throw new Error( + `deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` + + `(version ${still.version}). The mutation reported no errors but nothing was removed.` + ); + } + return { status: 'success', deleted: udoId ? { udoId: String(udoId) } : { name }, raw: raw?.deleteConfigFile ?? null }; +} + +// The legacy REST config-file endpoints (/api/listFiles, /api/getFile, +// /api/putFile) are not wrapped here. They cannot see or modify a +// udoId-addressed dashboard, so their listing is unsafe for any "does this file +// exist" decision. The GraphQL operations above cover every namespace, +// including parsers, lookups, datatables and /automaticLookups. + // ─── V1 Query (schema discovery) ───────────────────────────────────────────── // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event. // Use for schema discovery; use LRQ for hunting. @@ -126,5 +371,7 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime startTime, }; if (endTime) body.endTime = endTime; - return sdlFetch('POST', '/api/query', { body }); + // Read-only POST: opt back into status retry. Schema discovery iterates this + // once per data source, which is the workload that trips the SDL QPS cap. + return sdlFetch('POST', '/api/query', { body, allowRetry: true }); } diff --git a/mcp/s1-secops-mcp/lib/server-core.js b/mcp/s1-secops-mcp/lib/server-core.js index dc9741d..f52bd74 100644 --- a/mcp/s1-secops-mcp/lib/server-core.js +++ b/mcp/s1-secops-mcp/lib/server-core.js @@ -94,7 +94,7 @@ const PROMPTS = [ export const SERVER_INFO = { name: 's1-secops-mcp-server', - version: '1.3.1', + version: '1.3.3', }; export const PROTOCOL_VERSION = '2024-11-05'; diff --git a/mcp/s1-secops-mcp/package.json b/mcp/s1-secops-mcp/package.json index d435f35..46f9946 100644 --- a/mcp/s1-secops-mcp/package.json +++ b/mcp/s1-secops-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@pmoses-s1/s1-secops-mcp", - "version": "1.3.1", + "version": "1.3.3", "description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context. Stdio or Streamable HTTP transport with per-user bearer auth for team deployments.", "type": "module", "main": "index.js", @@ -20,7 +20,7 @@ "start": "node index.js", "start:http": "node index.js --transport http", "dev": "node --watch index.js", - "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", + "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", "regen:readme": "node scripts/regen-readme-tools-table.mjs" }, "engines": { diff --git a/mcp/s1-secops-mcp/tests/sdl-graphql.test.mjs b/mcp/s1-secops-mcp/tests/sdl-graphql.test.mjs new file mode 100644 index 0000000..ae913d3 --- /dev/null +++ b/mcp/s1-secops-mcp/tests/sdl-graphql.test.mjs @@ -0,0 +1,272 @@ +/** + * Behavioural tests for the GraphQL config-file layer in lib/sdl.js. + * + * These stub global.fetch, so they need no tenant and no credentials beyond + * the two env vars the module reads at call time. Every case here corresponds + * to a defect found in the 1.3.2 pre-release review; they exist so those + * defects cannot come back silently. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.S1_CONSOLE_URL ||= 'https://tenant.sentinelone.net'; +process.env.S1_CONSOLE_API_TOKEN ||= 'test-token'; + +const { configFiles, configFile, putConfigFile, deleteConfigFile, v1Query } = await import('../lib/sdl.js'); + +/** Queue up canned responses; records every outbound request. */ +function stubFetch(responses) { + const calls = []; + global.fetch = async (url, opts) => { + calls.push({ url, body: opts.body ? JSON.parse(opts.body) : undefined, method: opts.method }); + const next = responses.shift(); + if (!next) throw new Error('stubFetch: ran out of queued responses'); + return { + ok: next.status === undefined || (next.status >= 200 && next.status < 300), + status: next.status ?? 200, + headers: new Map(), + text: async () => (typeof next.body === 'string' ? next.body : JSON.stringify(next.body)), + }; + }; + return calls; +} + +const gql = data => ({ body: { data } }); + +test('non-JSON 200 throws instead of reporting an empty listing', async () => { + stubFetch([{ body: 'SSO interstitial' }]); + await assert.rejects(configFiles(), /expected a JSON object/); +}); + +test('non-JSON 200 does not let a delete report success', async () => { + stubFetch([{ body: 'proxy' }]); + await assert.rejects(deleteConfigFile({ name: '/lookups/x.csv' }), /expected a JSON object/); +}); + +test('a non-array errors object still throws', async () => { + stubFetch([{ body: { errors: { message: 'boom' } } }]); + await assert.rejects(configFiles(), /boom/); +}); + +test('a payload with neither data nor errors throws', async () => { + stubFetch([{ body: { extensions: {} } }]); + await assert.rejects(configFiles(), /neither data nor errors/); +}); + +test('correlationId is surfaced from per-error extensions too', async () => { + stubFetch([{ body: { errors: [{ message: 'conflict', extensions: { correlationId: 'abc123' } }] } }]); + await assert.rejects(configFiles(), /abc123/); +}); + +test('mutations are NOT retried on 5xx (a retried create duplicates a dashboard)', async () => { + const calls = stubFetch([ + // Non-empty listing so the duplicate guard passes and we reach the mutation. + gql({ configFiles: [{ udoId: '1', name: '/dashboards/Other', version: 1 }] }), + { status: 502, body: 'bad gateway' }, + ]); + await assert.rejects(putConfigFile({ name: '/dashboards/New', content: '{}' }), /502/); + const mutations = calls.filter(c => c.body?.query?.startsWith('mutation')); + assert.equal(mutations.length, 1, 'the addConfigFile mutation must be sent exactly once'); +}); + +test('read-only queries ARE still retried on 5xx', async () => { + const calls = stubFetch([ + { status: 503, body: 'unavailable' }, + gql({ configFiles: [{ udoId: null, name: '/lookups/a.csv', version: 1 }] }), + ]); + const files = await configFiles(); + assert.equal(files.length, 1); + assert.equal(calls.length, 2, 'the query should have been retried once'); +}); + +test('expectedVersion is sent on name-addressed writes (server honours it)', async () => { + const calls = stubFetch([gql({ addConfigFile: { udoId: null, name: '/logParsers/P', version: 2 } })]); + await putConfigFile({ name: '/logParsers/P', content: 'x', expectedVersion: 1168977232 }); + const sent = calls[0].body; + assert.match(sent.query, /\$expectedVersion: Long/, 'mutation must declare $expectedVersion'); + assert.equal(sent.variables.expectedVersion, 1168977232, 'expectedVersion must be transmitted'); +}); + +test('expectedVersion is sent on udoId-addressed writes', async () => { + const calls = stubFetch([gql({ addConfigFile: { udoId: '123', name: '/dashboards/D', version: 2 } })]); + await putConfigFile({ udoId: '123', content: 'x', expectedVersion: 99 }); + assert.equal(calls[0].body.variables.expectedVersion, 99); +}); + +test('duplicate guard fails closed when the listing comes back empty', async () => { + stubFetch([gql({ configFiles: [] })]); + await assert.rejects( + putConfigFile({ name: '/dashboards/Existing', content: '{}' }), + /came back empty/, + ); +}); + +test('duplicate guard blocks a name-addressed write to an existing dashboard', async () => { + stubFetch([gql({ configFiles: [{ udoId: '777', name: '/dashboards/Existing', version: 1 }] })]); + await assert.rejects( + putConfigFile({ name: '/dashboards/Existing', content: '{}' }), + /already use that name.*777/s, + ); +}); + +test('duplicate guard is case- and whitespace-insensitive', async () => { + stubFetch([gql({ configFiles: [{ udoId: '777', name: '/dashboards/Existing', version: 1 }] })]); + await assert.rejects( + putConfigFile({ name: '/dashboards/existing ', content: '{}' }), + /already use that name/, + ); +}); + +test('a create of a genuinely new dashboard is allowed through the guard', async () => { + stubFetch([ + gql({ configFiles: [{ udoId: '777', name: '/dashboards/Other', version: 1 }] }), + gql({ addConfigFile: { udoId: '888', name: '/dashboards/Brand New', version: 1 } }), + ]); + const created = await putConfigFile({ name: '/dashboards/Brand New', content: '{}' }); + assert.equal(created.udoId, '888'); +}); + +test('delete verifies removal and throws when the file survives', async () => { + stubFetch([ + gql({ deleteConfigFile: null }), + gql({ configFile: { udoId: '5', name: '/dashboards/D', version: 9, content: '{}' } }), + ]); + await assert.rejects(deleteConfigFile({ udoId: '5' }), /still exists after the delete mutation/); +}); + +test('delete reports success only once the file is gone', async () => { + stubFetch([ + gql({ deleteConfigFile: null }), + gql({ configFile: null }), + ]); + const res = await deleteConfigFile({ udoId: '5' }); + assert.equal(res.status, 'success'); + assert.deepEqual(res.deleted, { udoId: '5' }); +}); + +test('configFile returns null (not a throw) when the server says "not found"', async () => { + // The server signals absence as a GraphQL error. Absence is a normal lookup + // outcome, so configFile normalises it to null; every other error propagates. + stubFetch([{ body: { errors: [{ message: 'Config file with name /lookups/x.csv not found.' }] } }]); + assert.equal(await configFile({ name: '/lookups/x.csv' }), null); +}); + +test('a deleted udoId returns null despite the generic error (listing disambiguates)', async () => { + // Live: configFile(udoId:) on a deleted dashboard returns "Something went + // wrong...", not "not found", and that is the same text a version conflict + // returns. The listing is what settles it. + stubFetch([ + { body: { errors: [{ message: 'Something went wrong. Please try again and if the issue persists contact Support.' }] } }, + gql({ configFiles: [{ udoId: '999', name: '/dashboards/Other', version: 1 }] }), + ]); + assert.equal(await configFile({ udoId: '5' }), null); +}); + +test('a generic error on a udoId that IS still present rethrows', async () => { + stubFetch([ + { body: { errors: [{ message: 'Something went wrong. Please try again.' }] } }, + gql({ configFiles: [{ udoId: '5', name: '/dashboards/Still Here', version: 1 }] }), + ]); + await assert.rejects(configFile({ udoId: '5' }), /Something went wrong/); +}); + +test('configFile still throws on a non-"not found" error when the file exists', async () => { + stubFetch([ + { body: { errors: [{ message: 'internal server explosion' }] } }, + gql({ configFiles: [{ udoId: null, name: '/lookups/x.csv', version: 1 }] }), + ]); + await assert.rejects(configFile({ name: '/lookups/x.csv' }), /internal server explosion/); +}); + +test('delete succeeds when the confirming read reports the file absent', async () => { + stubFetch([ + gql({ deleteConfigFile: null }), + { body: { errors: [{ message: 'Config file with name /lookups/x.csv not found.' }] } }, + ]); + const res = await deleteConfigFile({ name: '/lookups/x.csv' }); + assert.equal(res.status, 'success'); +}); + +test('delete still propagates a non-"not found" error from the confirming read', async () => { + stubFetch([ + gql({ deleteConfigFile: null }), + { body: { errors: [{ message: 'internal server explosion' }] } }, + gql({ configFiles: [{ udoId: null, name: '/lookups/x.csv', version: 1 }] }), + ]); + await assert.rejects(deleteConfigFile({ name: '/lookups/x.csv' }), /internal server explosion/); +}); + +test('a numeric udoId beyond the safe-integer range is rejected, not silently truncated', async () => { + stubFetch([]); + await assert.rejects(configFile({ udoId: 9007199254740993 }), /safe-integer range/); +}); + +test('no caller-supplied value is interpolated into the query document', async () => { + const calls = stubFetch([gql({ configFile: null })]); + await configFile({ name: '/lookups/a"}) { evil }' }); + assert.ok(!calls[0].body.query.includes('evil'), 'name must travel in variables, not the document'); + assert.equal(calls[0].body.variables.id, '/lookups/a"}) { evil }'); +}); + +// ─── transport vs GraphQL: absence must never be inferred from a transport error ─── + +test('a transport 404 whose body says "not found" is NOT treated as absence', async () => { + // The highest-value case in this file. sdlFetch builds the message as + // "SDL API POST ... -> 404: ", so a 404 page or WAF block containing + // the words "not found" used to satisfy the absence regex, and a delete + // would then accept it as proof the file was gone. + stubFetch([{ status: 404, body: { error: 'not found' } }]); + await assert.rejects(configFile({ name: '/lookups/x.csv' }), /404/); +}); + +test('a transport 404 does not let a delete report success', async () => { + stubFetch([ + gql({ deleteConfigFile: null }), + { status: 404, body: 'nginx: not found' }, + ]); + await assert.rejects(deleteConfigFile({ name: '/lookups/x.csv' }), /404/); +}); + +test('a listing failure during disambiguation preserves the ORIGINAL error', async () => { + stubFetch([ + { body: { errors: [{ message: 'There are conflicting changes in the file.' }] } }, + { status: 500, body: 'listing exploded' }, + { status: 500, body: 'listing exploded' }, + { status: 500, body: 'listing exploded' }, + { status: 500, body: 'listing exploded' }, + ]); + await assert.rejects( + configFile({ udoId: '5' }), + err => /conflicting changes/.test(err.message) && /absence check failed/.test(err.message), + ); +}); + +// ─── duplicate guard: namespace test must be case-insensitive ─── + +test('a case-variant /Dashboards/ path does NOT bypass the duplicate guard', async () => { + stubFetch([gql({ configFiles: [{ udoId: '777', name: '/dashboards/AI Usage', version: 1 }] })]); + await assert.rejects( + putConfigFile({ name: '/Dashboards/AI Usage', content: '{}' }), + /already use that name/, + ); +}); + +test('the guard still runs (and fails closed) for a case-variant path', async () => { + stubFetch([gql({ configFiles: [] })]); + await assert.rejects( + putConfigFile({ name: '/DASHBOARDS/Something', content: '{}' }), + /came back empty/, + ); +}); + +// ─── v1Query is a read-only POST and must keep its backoff ─── + +test('v1Query retries on 5xx (schema discovery is the workload that hits the QPS cap)', async () => { + const calls = stubFetch([ + { status: 503, body: 'unavailable' }, + { body: { matches: [{ attributes: { a: 1 } }] } }, + ]); + const res = await v1Query("dataSource.name='X'"); + assert.equal(res.matches.length, 1); + assert.equal(calls.length, 2, 'v1Query must back off and retry, not fail on the first 503'); +}); diff --git a/mcp/s1-secops-mcp/tools/sdl-api.js b/mcp/s1-secops-mcp/tools/sdl-api.js index 0b9349e..4e7d9ea 100644 --- a/mcp/s1-secops-mcp/tools/sdl-api.js +++ b/mcp/s1-secops-mcp/tools/sdl-api.js @@ -2,48 +2,76 @@ * SDL API tools: sdl-api, sdl-dashboard, sdl-log-parser skills * * Tools: - * sdl_list_files List all config files on the SDL tenant - * sdl_get_file Get file content and version (parsers, dashboards, alerts, lookups) + * sdl_list_files List every config file on the SDL tenant (GraphQL configFiles) + * sdl_get_file Get file content and version, by path or udoId * sdl_put_file Deploy or update a config file (with optimistic locking) * sdl_delete_file Delete a config file * hec_ingest Ingest raw logs/events into SDL via the HEC endpoint (replaces uploadLogs) + * + * All four config-file tools run on `POST /sdl/v2/graphql`. The legacy REST + * `/sdl/api/*File` endpoints are NOT used: they silently omit every + * udoId-addressed dashboard (1,914 vs 2,264 files on a live tenant) and return + * `success/noSuchFile` for any of them. */ -import { listFiles, getFile, putFile, deleteFile } from '../lib/sdl.js'; +import { configFiles, configFile, putConfigFile, deleteConfigFile } from '../lib/sdl.js'; import { hecIngest } from '../lib/hec.js'; +const UDOID_NOTE = + 'Dashboards are addressed by udoId, everything else by path. The console shows a dashboard as ' + + '"/dashboards/id//" in its Configuration Files grid; that display string is NOT a path, ' + + 'the number in it is the udoId and the real path is "/dashboards/". ' + + 'udoId assignment is by namespace: only /dashboards/ files have one, /lookups/, /datatables/, ' + + '/logParsers/ and /automaticLookups are all name-addressed with udoId null.'; + export const tools = [ // ─── sdl_list_files ─────────────────────────────────────────────────────── { name: 'sdl_list_files', - 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.`, + 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.`, inputSchema: { type: 'object', - properties: {}, + properties: { + pathPrefix: { + type: 'string', + description: 'Optional filter, e.g. "/dashboards/" or "/logParsers/". Applied client-side to the full listing.', + }, + }, required: [], }, - async handler() { - const result = await listFiles(); - return JSON.stringify(result, null, 2); + async handler({ pathPrefix } = {}) { + let files = await configFiles(); + if (pathPrefix) files = files.filter(f => (f.name || '').startsWith(pathPrefix)); + return JSON.stringify({ count: files.length, files }, null, 2); }, }, // ─── sdl_get_file ───────────────────────────────────────────────────────── { name: 'sdl_get_file', - 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/), dashboards (/dashboards/), alerts (/alerts/), lookups (/lookups/), datatables (/datatables/). Always read before overwriting; this prevents concurrent-edit conflicts.`, + 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.`, inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Full SDL config path, e.g. "/logParsers/FortiGate" or "/dashboards/SOC-Overview". Get the path from sdl_list_files.', + description: 'Full SDL config path, e.g. "/logParsers/FortiGate" or "/lookups/assets.csv".', + }, + udoId: { + type: 'string', + description: 'Dashboard udoId, e.g. "3559330396332032". Get it from sdl_list_files. Required for /dashboards/ files.', }, }, - required: ['path'], + required: [], }, - async handler({ path }) { - const result = await getFile(path); + async handler({ path, udoId }) { + const result = await configFile({ name: path, udoId }); + if (!result) { + return JSON.stringify({ + status: 'notFound', + 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.', + }, null, 2); + } return JSON.stringify(result, null, 2); }, }, @@ -51,13 +79,17 @@ export const tools = [ // ─── sdl_put_file ───────────────────────────────────────────────────────── { name: 'sdl_put_file', - 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/, dashboards to /dashboards/, alerts to /alerts/, lookups to /lookups/. Authorised by S1_CONSOLE_API_TOKEN.`, + 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.`, inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Full SDL config path, e.g. "/logParsers/MyParser" or "/dashboards/SOC-Ops".', + description: 'Full SDL config path, e.g. "/logParsers/MyParser". Use for creates, and for updates to non-dashboard files.', + }, + udoId: { + type: 'string', + description: 'Dashboard udoId. REQUIRED to update an existing dashboard; a path-addressed dashboard write duplicates instead of updating.', }, content: { type: 'string', @@ -65,13 +97,13 @@ export const tools = [ }, expectedVersion: { type: 'number', - description: 'Current file version from sdl_get_file. Required for updates to enable optimistic locking. Omit only when creating a new file.', + 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.', }, }, - required: ['path', 'content'], + required: ['content'], }, - async handler({ path, content, expectedVersion }) { - const result = await putFile(path, content, expectedVersion); + async handler({ path, udoId, content, expectedVersion }) { + const result = await putConfigFile({ name: path, udoId, content, expectedVersion }); return JSON.stringify(result, null, 2); }, }, @@ -79,23 +111,27 @@ export const tools = [ // ─── sdl_delete_file ────────────────────────────────────────────────────── { name: 'sdl_delete_file', - 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.`, + 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.`, inputSchema: { type: 'object', properties: { path: { type: 'string', - description: 'Full SDL config path to delete.', + description: 'Full SDL config path to delete (non-dashboard files).', + }, + udoId: { + type: 'string', + description: 'Dashboard udoId to delete. Required for /dashboards/ files.', }, expectedVersion: { type: 'number', description: 'Current file version for optimistic locking (from sdl_get_file). Strongly recommended.', }, }, - required: ['path'], + required: [], }, - async handler({ path, expectedVersion }) { - const result = await deleteFile(path, expectedVersion); + async handler({ path, udoId, expectedVersion }) { + const result = await deleteConfigFile({ name: path, udoId, expectedVersion }); return JSON.stringify(result, null, 2); }, }, diff --git a/plugins/s1-secops-skills/.claude-plugin/plugin.json b/plugins/s1-secops-skills/.claude-plugin/plugin.json index b05f36c..2782182 100644 --- a/plugins/s1-secops-skills/.claude-plugin/plugin.json +++ b/plugins/s1-secops-skills/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "s1-secops-skills", - "version": "1.3.0", + "version": "1.3.1", "description": "SentinelOne SecOps skills for Claude: PowerQuery threat hunting and STAR/Custom Detection rules; Management Console API; Singularity Data Lake API; SDL dashboards; log parsing (OCSF); Hyperautomation SOAR; z-score anomaly baselining; autonomous DFIR alert investigation (soc-investigator); and one-prompt SDL solutions: source onboarding, asset enrichment, UEBA, ingest health, detection exclusions, Risk-Based Alerting, alert noise reduction, and Detection as Code.", "author": { "name": "Prithvi Moses", diff --git a/plugins/s1-secops-skills/CLAUDE.md b/plugins/s1-secops-skills/CLAUDE.md index 6a9789d..e97738c 100644 --- a/plugins/s1-secops-skills/CLAUDE.md +++ b/plugins/s1-secops-skills/CLAUDE.md @@ -9,6 +9,7 @@ You are a **Principal SOC Analyst** in a SentinelOne SecOps environment. Mission Discovery is mandatory before querying any source, but it runs ONCE PER PROJECT and is REUSED from a cached, versioned file. Tenant-wide per-source schema discovery is the single most expensive part of an investigation; caching it is the primary cost optimization. **Step 0, cache check (FIRST, before any enumeration or discovery):** + - Look for `s1_sdl_schema_cache.json` in the project root. - If it EXISTS and `schema_cache_version` (ISO-8601 UTC timestamp) is within `ttl_days` (default 30): load it, treat its `data_source_enumeration` + `schemas` as source of truth, SKIP Steps 1-2 (no re-enumeration, no re-discovery), go straight to triage. - If MISSING or STALE (older than `ttl_days`): run Steps 1-2, write/overwrite the cache, set `schema_cache_version` to the current UTC timestamp. @@ -17,7 +18,8 @@ Discovery is mandatory before querying any source, but it runs ONCE PER PROJECT **This Step 0 rule supersedes any "every session" / "discover fresh each session" phrasing elsewhere in this document (Core Mindset, Sections 6-7):** enumerate and discover once per project, reuse from the versioned cache, refresh on expiry or per-source on demand. Schemas still drift (parser edits, reserved-field rewrites, ingestion changes), which is exactly what `ttl_days` and per-source re-discovery guard against. **Step 1, enumerate data sources (ONLY on cache miss/stale):** -``` + +```text | group UniqueDataSourceNames = array_agg_distinct( dataSource.name ), UniqueVendors = array_agg_distinct( dataSource.vendor ), UniqueCategories = array_agg_distinct( dataSource.category ) @@ -25,6 +27,7 @@ Discovery is mandatory before querying any source, but it runs ONCE PER PROJECT ``` **Step 2, from the returned list:** + - For EVERY source returned, including S1 internal streams (`alert`, `vulnerability`, `misconfiguration`, `asset`, `finding`, `ActivityFeed`, `Identity`, `indicator`) and every third-party source, use the field schema recorded in the project cache under the Step 0 trust/refresh rules. - Do NOT assume any field namespace (vendor-prefixed `..*`, OCSF `src.ip.address` / `dst.ip.address` / `actor.user.name`, `unmapped.*`, or anything else) applies to a source unless confirmed by the cache or fresh discovery. - **Trailing-underscore convention:** field names ending in `_` (e.g. `severity_`, `status_`, `classification_`) are SDL's auto-rename when source data collides with an SDL reserved name. The underscored form IS the canonical, queryable field, not a sparse alternate. Numeric OCSF variants (`severity_id`, `status_id`, `class_uid`) live alongside the underscored string fields. @@ -125,6 +128,7 @@ A SOC peer should be able to paste your queries and reproduce the answer. ## Investigation Workflow ### 1. Triage & Context Gathering + - `get_alert`: read severity, classification, detection source, analyst verdict. - **CRITICAL CHECK: read `get_alert_notes` and `get_alert_history` BEFORE proceeding.** An MDR/analyst verdict of False Positive, Benign, or Resolved takes precedence; do NOT override it without new evidence they did not have. If FP, note it and move on, do not escalate. - `get_inventory_item`: OS, role, location, criticality, agent health. @@ -146,6 +150,7 @@ Every IP, domain, URL, or file hash encountered MUST be enriched before a verdic #### Relationship pivot tools (EXPAND the investigation after initial reports) **File: `get_file_relationship(hash, relationship)`, 41 pivot types.** + - Behavioural (`behaviours`, `dropped_files`, `contacted_domains`, `contacted_ips`, `contacted_urls`): what the file DOES when executed; C2 infrastructure, payloads dropped, network footprint. - Execution chain (`execution_parents`, `bundled_files`, `compressed_parents`, `email_parents`, `email_attachments`): how it arrived; archive bundle, email attachment, or parent process. - Embedded content (`embedded_domains`, `embedded_ips`, `embedded_urls`, `urls_for_embedded_js`): IOCs hardcoded in the binary; C2 addresses, download URLs, exfil endpoints. @@ -178,19 +183,23 @@ The critical decision point: systematically determine true positive, suspicious, | **First/Last Submission** | Recently submitted (fresh IOC, active campaign) | Very old with no recent activity | #### Step 2: Behavioural correlation (files) + For any suspicious hash ALWAYS pivot: `behaviours` (what it does), `contacted_domains` and `contacted_ips` (where it calls home), `dropped_files` (what it deploys), `execution_parents` (what launched it). TP confidence boosters: contacts known malicious IPs/domains; drops additional executables/scripts; behaviour shows credential access, persistence installation, or lateral movement; execution chain traces to a phishing email or exploit. #### Step 3: Infrastructure pivoting (network IOCs) + Pivot to the full attack infrastructure: `get_ip_relationship` with `communicating_files`, `resolutions`, `historical_ssl_certificates`; `get_domain_report(domain, relationships=["subdomains", "siblings", "resolutions", "communicating_files"])`. Correlation signals: multiple malicious files communicating with the same IP = confirmed C2 server; domain registered < 30 days ago with privacy-protected WHOIS = suspicious; SSL certificate shared across multiple domains = attacker infrastructure cluster; subdomain patterns `update.`, `cdn.`, `api.`, `mail.` = mimicking legitimate services. #### Step 4: Threat actor attribution + For EVERY confirmed malicious IOC check `related_threat_actors` (file, IP, and URL relationship pivots; domain via `relationships=["related_threat_actors"]`). If a group is identified: research their TTPs and map to MITRE ATT&CK; hunt their OTHER known IOCs environment-wide via `purple_ai` + `powerquery`; check their typical persistence, lateral movement, and exfiltration methods; assess whether the group typically targets your industry/region. #### Step 5: Cross-reference with SentinelOne telemetry + Correlate findings back into the environment: hunt other endpoints contacting the same C2 (`purple_ai`); same hash on other endpoints; similar behaviour patterns (process trees, registry modifications, scheduled tasks); check the asset for exploitable vulnerabilities (`search_vulnerabilities`) aligning with the actor's known exploitation techniques. #### Verdict Decision Matrix @@ -220,6 +229,7 @@ With none of these, the maximum classification is **SUSPICIOUS, Pending Confirma --- ### 4. Threat Hunting with Purple AI & PowerQuery + - Use `purple_ai` to generate PowerQueries from natural language; do NOT hand-write PowerQuery syntax. - Always use `get_timestamp_range` for time windows (default: 24 hours). - Hunt lateral movement, persistence, privilege escalation, data staging, and exfiltration related to the initial finding; check the same IOCs/TTPs on other endpoints. @@ -243,6 +253,7 @@ Breadth (fleet-wide hash/TTP sweep, cross-source correlation) is necessary but n Every item above is a query to execute against this session's telemetry, not a topic to consider. Deliver depth AND breadth (this checklist plus the Section 4/6 fleet sweep and cross-source correlation); one without the other is incomplete. ### 5. Vulnerability & Misconfiguration Correlation + - `search_vulnerabilities` / `get_vulnerability` on the affected asset, especially active exploits or high EPSS. Prioritize `exploitedInTheWild: true` or `kevAvailable: true`. - `search_misconfigurations` / `get_misconfiguration` on the same asset for enablers of the attack. - Cross-reference: if a threat actor is attributed, check for vulnerabilities that group commonly exploits. @@ -283,7 +294,7 @@ Use `purple_ai` for OCSF sources and confirmed namespaces for non-OCSF. After ea When a suspicious IOC (IP, domain, hash, user, hostname) appears in any one source, immediately hunt it across ALL other sources with an `OR` clause over every confirmed-populated IP / hostname / username field: -``` +```text | filter( == "SUSPICIOUS_IP" OR == "SUSPICIOUS_IP" OR src.ip.address == "SUSPICIOUS_IP" @@ -314,19 +325,22 @@ Many third-party sources (firewalls, SIEMs, appliances forwarding raw syslog, CE ### Schema discovery workflow (any unknown source) **Step 1, confirm the source is ingesting and get its exact name:** -``` + +```text | group UniqueDataSourceNames = array_agg_distinct( dataSource.name ) | limit 100 ``` **Step 2, probe for field population:** -``` + +```text | filter( dataSource.name == "TARGET_SOURCE_NAME" ) | group Fields = array_agg_distinct( dataSource.name ), Vendors = array_agg_distinct( dataSource.vendor ) | limit 5 ``` **Step 3, attempt namespace variants one at a time until non-null results**, using the shape `| filter( dataSource.name == "TARGET_SOURCE_NAME" ) | columns timestamp, | filter( == * ) | limit 10`: + 1. Vendor-prefixed `..` (most common for syslog sources) 2. Unmapped: `unmapped.src, unmapped.dst, unmapped.proto, unmapped.action, unmapped.msg` 3. Generic SDL network: `src.ip.address, dst.ip.address, dst.port.number, ipProtocol, networkAction, direction` @@ -338,7 +352,7 @@ Many third-party sources (firewalls, SIEMs, appliances forwarding raw syslog, CE After discovery confirms the action, source-IP, destination-IP, destination-port, protocol, and direction fields: -``` +```text | filter( dataSource.name == "" ) | filter( == * ) | columns timestamp, , , , @@ -397,7 +411,8 @@ Apply to every identity source query; flag matches for threat-intel enrichment a | **Lateral movement via legitimate credentials** | User authenticates to systems never accessed before | T1021 Remote Services | High | PQ pattern, auth outside business hours (any identity source): -``` + +```text | filter( dataSource.name == "" ) | filter( == "" ) | columns timestamp, actor.user.name, actor.user.email_addr, src_endpoint.ip, @@ -574,7 +589,7 @@ Use installed skills eagerly; do not hand-author SDL config files, Hyperautomati - `s1-secops-skills:powerquery`: author/optimise/debug/explain any PowerQuery (STAR rule body, dashboard panel, hunt, alert). LRQ runner, syntax reference, performance rules (filter early, group narrow, `top` over `group` for huge ranges, `transpose` LAST, escape regex, percentile rules). - `s1-secops-skills:mgmt-console-api`: site / agent / threat / IOC / Custom Detection rule console operations; deploying STAR rules; UAM alert triage. `S1Client`, endpoint index, UAM GraphQL wrapper, `pq.py` LRQ runner, IOC lifecycle test, asset linkage ref. -- `s1-secops-skills:sdl-api`: SDL configuration files (parsers, dashboards, lookups), custom log ingestion, V1 query for ad-hoc <24h stats. `SDLClient.put_file` / `get_file` / `list_files`, ingestion methods, key-matrix auto-routing. +- `s1-secops-skills:sdl-api`: SDL configuration files (parsers, dashboards, lookups), custom log ingestion, V1 query for ad-hoc <24h stats. `SDLClient.config_files` / `config_file` / `put_config_file` / `delete_config_file` over `POST /sdl/v2/graphql`; the legacy `list_files` / `get_file` / `put_file` REST methods cannot see udoId-addressed dashboards. One console token authorises everything. - `s1-secops-skills:sdl-dashboard`: building or editing any SDL dashboard JSON. Panel-type cheatsheet, community examples, query performance rules, parameters & filters. - `s1-secops-skills:hyperautomation`: authoring SOAR / playbook / alert-response workflow JSON. Workflow envelope, building blocks, action types, integration warnings, examples. - `s1-secops-skills:sdl-log-parser`: authoring/debugging an SDL `/logParsers/` parser (CEF, syslog, key=value, multi-line). Parser DSL, end-to-end validation via `putFile → hec_ingest → query`. @@ -592,7 +607,7 @@ For any investigation culminating in deliverables: 3. **Schema for every source you'll query** via the cache / Section 7 workflow; persist dumps to `outputs/sdl_schemas_.json`. 4. **Hunt, enrich, correlate:** Purple MCP hunts, threat-intel MCP for every IOC, cross-source correlation. 5. **Build deliverables:** dashboard JSON via `sdl-dashboard`, workflows via `hyperautomation`, detection rules via `mgmt-console-api`, report via `docx`. -6. **Deploy live:** `SDLClient.put_file('/dashboards/')` for dashboards, `POST /web/api/v2.1/cloud-detection/rules` for STAR rules. Read the existing version first; pass `expected_version` on overwrite. +6. **Deploy live:** dashboards via `SDLClient.put_config_file()` on `POST /sdl/v2/graphql`, creating by name once and addressing by `udo_id` with `expected_version` on every write after that; a name-addressed write to an existing dashboard is refused because it duplicates. STAR rules via `POST /web/api/v2.1/cloud-detection/rules`. Read the existing version first; pass `expected_version` on overwrite. 7. **Verify:** re-fetch deployed artifacts, confirm versions, run a sample query against each rule's PQ body to confirm it parses. --- @@ -629,7 +644,7 @@ Common across tenants and platform versions; apply preemptively. ### Endpoint and wire format -``` +```text POST https://.sentinelone.net/sdl/v2/api/queries GET https://.sentinelone.net/sdl/v2/api/queries/{id}?lastStepSeen={n} DELETE https://.sentinelone.net/sdl/v2/api/queries/{id} @@ -640,6 +655,7 @@ DELETE https://.sentinelone.net/sdl/v2/api/queries/{id} **Auth:** `Authorization: Bearer `, the same token the Mgmt API uses with `ApiToken` prefix. Using `ApiToken` prefix on `/sdl/v2/api/queries` returns HTTP 500. **Launch body:** + ```json { "queryType": "PQ", @@ -650,6 +666,7 @@ DELETE https://.sentinelone.net/sdl/v2/api/queries/{id} "tenant": true } ``` + Query string goes inside `pq.query`, NOT top level. `queryType` must be uppercase `"PQ"`; omitting it returns HTTP 400. **`X-Dataset-Query-Forward-Tag` is mandatory.** Capture it from the POST response header and echo it on every GET and DELETE; without it the routing layer rejects the request. Session-scoped: one client's tag cannot be used by another. @@ -715,7 +732,8 @@ This file improves itself. At the END of every session, before signing off: 5. **Apply on approval** here, and for schema/field facts also to `s1_sdl_schema_cache.json` with a `schema_cache_version` bump. ### Session Learnings Ledger -_Compounded and lean. Promote stable items into formal sections, then prune. Newest appended; merge rather than restate._ + +_Compounded and lean. Promote stable items into formal sections, then prune. Newest appended; merge rather than restate.* - **Windows auth triage:** `winEventLog.description` holds the full human-readable Target/Source/Status block; use it when `winEventLog.data.event.eventData.*` field names are uncertain. Core IDs: 4625 fail, 4624 success, 4768/4769 Kerberos, 4771 pre-auth fail, 4776 NTLM, 4740 lockout. - **4625 SubStatus decode:** `0xC000006A` wrong password (user exists), `0xC0000064` no such user, `0xC0000234` locked, `0xC0000072` disabled, `0xC000006F`/`0xC0000070` time/workstation restriction. diff --git a/plugins/s1-secops-skills/dist/hyperautomation.skill b/plugins/s1-secops-skills/dist/hyperautomation.skill index 11d1b5c7570ff4d34aab85be6cc11432cdd99f95..27fb831e917e725b9c7e517be368469c6d6c33fc 100644 GIT binary patch delta 41007 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