@pmoses-s1/s1-secops-mcp 1.3.6 → 1.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -2
- package/README.md +7 -4
- package/deploy/README.md +2 -2
- package/index.js +1 -1
- package/lib/credentials.js +13 -0
- package/lib/hec.js +25 -9
- package/lib/s1.js +64 -1
- package/lib/sdl.js +2 -2
- package/lib/server-core.js +1 -1
- package/lib/uam-ingest.js +41 -85
- package/package.json +1 -1
- package/scripts/regen-readme-tools-table.mjs +0 -1
- package/tools/mgmt-console.js +47 -1
- package/tools/sdl-api.js +6 -3
- package/tools/uam-ingest.js +31 -46
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.7
|
|
4
|
+
|
|
5
|
+
Minor, not patch: a tool is gone and the log-ingest credential changed. Both
|
|
6
|
+
break an existing caller, so the version says so.
|
|
7
|
+
|
|
8
|
+
### Log ingest needs an SDL Log Write Key
|
|
9
|
+
|
|
10
|
+
`hec_ingest` now authenticates with **`S1_HEC_TOKEN`**, an SDL Log Write Key,
|
|
11
|
+
and no longer sends an `S1-Scope` header. The Management Console API token is
|
|
12
|
+
refused by the event collector. Measured on a live tenant, same request, same
|
|
13
|
+
endpoint:
|
|
14
|
+
|
|
15
|
+
write key -> HTTP 200 {"text":"Success","code":0}
|
|
16
|
+
console token -> HTTP 400 {"text":"Missing S1-Scope header","code":5}
|
|
17
|
+
|
|
18
|
+
The key is minted per account or site and writes only there, so it fixes the
|
|
19
|
+
destination and there is nothing for a scope header to override. Mint it at
|
|
20
|
+
Console > Singularity Data Lake > API Keys > Log Write Key; no API creates one.
|
|
21
|
+
`S1_HEC_TOKEN` is optional, because only raw log ingest needs it, and it is named
|
|
22
|
+
to match the deployer repos so one value covers both.
|
|
23
|
+
|
|
24
|
+
UAM alert ingest and IOCs are NOT affected. They still use
|
|
25
|
+
`S1_CONSOLE_API_TOKEN`, and `/v1/alerts` still requires `S1-Scope`.
|
|
26
|
+
|
|
27
|
+
### Indicators ride inside the alert
|
|
28
|
+
|
|
29
|
+
**`uam_post_indicators` is removed** (32 tools -> 31). Indicators can no longer
|
|
30
|
+
be ingested on their own: `/v1/indicators` refuses the console user token and the
|
|
31
|
+
Log Write Key alike, so no credential can drive it. They now travel inline in
|
|
32
|
+
`finding_info.related_events[]` on a single `POST /v1/alerts`, which is also what
|
|
33
|
+
the console Indicators tab reads.
|
|
34
|
+
|
|
35
|
+
`uam_ingest_alert` posts once, inline. The `inline` parameter is still accepted
|
|
36
|
+
so existing callers do not crash, but it is forced to true and a false value
|
|
37
|
+
comes back with a note saying it was ignored, rather than quietly doing something
|
|
38
|
+
different. The old two-call flow, its ~3s sleep and its ordering contract are
|
|
39
|
+
deleted rather than left throwing: a code path that can only fail invites callers
|
|
40
|
+
to keep it alive.
|
|
41
|
+
|
|
42
|
+
Verified end to end on a live tenant: one alert carrying four indicators inline
|
|
43
|
+
in a single POST surfaced in UAM with all four rendered and every uid matching.
|
|
44
|
+
|
|
45
|
+
### Also
|
|
46
|
+
|
|
47
|
+
- `Indicator` in the UAM GraphQL schema has fields `type, uid, title,
|
|
48
|
+
description, message, severity`. There is no `name` and no `category`;
|
|
49
|
+
querying either returns `FieldUndefined`. Examples across the docs used them.
|
|
50
|
+
- `sdlToken`'s missing-credential test now runs in a child process. It deleted an
|
|
51
|
+
env var, but credentials are resolved once at import and fall back to a file,
|
|
52
|
+
so the exception it asserted could only be thrown on a machine with no
|
|
53
|
+
credentials at all. It passed in CI for exactly that reason.
|
|
54
|
+
|
|
3
55
|
## 1.3.6 - 2026-08-17
|
|
4
56
|
|
|
5
57
|
Behaviour change, found by deploying to a real site and not being able to see the
|
|
@@ -131,7 +183,7 @@ were not wrapped at all. **26 tools → 32.**
|
|
|
131
183
|
|
|
132
184
|
- `sdl-api/references/config-file-graphql.md` claimed the `s1-scope` header was
|
|
133
185
|
"ignored, not rejected" on `/sdl/v2/graphql`. **That was wrong.** Measured on
|
|
134
|
-
|
|
186
|
+
`<console>`, same token and query: `configFiles` returned 113 files at
|
|
135
187
|
account scope and 4 at a site scope. Config listings and dashboard reads are
|
|
136
188
|
scope-FILTERED, so a dropped header changes which objects appear to exist.
|
|
137
189
|
`auth_and_limits.md` corrected to match.
|
|
@@ -243,7 +295,7 @@ Config-file operations move from the legacy REST endpoints to GraphQL. Tool coun
|
|
|
243
295
|
### Fixed
|
|
244
296
|
|
|
245
297
|
- **`sdl_list_files` no longer returns an incomplete listing.** The REST `/sdl/api/listFiles`
|
|
246
|
-
endpoint omits every udoId-addressed dashboard. Measured live on
|
|
298
|
+
endpoint omits every udoId-addressed dashboard. Measured live on `<console>`: REST returned
|
|
247
299
|
1,914 paths against `configFiles`' 2,264, a 350-file gap consisting entirely of `/dashboards/`
|
|
248
300
|
files that carry a `udoId`. REST `getFile` on any of them returns `success/noSuchFile`. The
|
|
249
301
|
practical impact was a false negative: a dashboard that existed in the console was reported as
|
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
|
|
|
24
24
|
| Mgmt Console | `s1_api_post` | mgmt-console-api |
|
|
25
25
|
| Mgmt Console | `s1_api_put` | mgmt-console-api |
|
|
26
26
|
| Mgmt Console | `uam_add_note` | mgmt-console-api |
|
|
27
|
+
| Mgmt Console | `uam_available_actions` | mgmt-console-api |
|
|
27
28
|
| Mgmt Console | `uam_get_alert` | mgmt-console-api |
|
|
28
29
|
| Mgmt Console | `uam_list_alerts` | mgmt-console-api |
|
|
29
30
|
| Mgmt Console | `uam_set_status` | mgmt-console-api |
|
|
@@ -45,7 +46,6 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
|
|
|
45
46
|
| Hyperautomation | `ha_list_workflows` | hyperautomation |
|
|
46
47
|
| UAM Ingest | `uam_ingest_alert` | mgmt-console-api (UAM Alert Interface) |
|
|
47
48
|
| UAM Ingest | `uam_post_alert` | mgmt-console-api (UAM Alert Interface) |
|
|
48
|
-
| UAM Ingest | `uam_post_indicators` | mgmt-console-api (UAM Alert Interface) |
|
|
49
49
|
<!-- END AUTO-GENERATED TOOLS TABLE -->
|
|
50
50
|
|
|
51
51
|
**2 resources:**
|
|
@@ -73,7 +73,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
|
|
|
73
73
|
"mcpServers": {
|
|
74
74
|
"s1-secops-mcp": {
|
|
75
75
|
"command": "npx",
|
|
76
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
76
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.8"],
|
|
77
77
|
"env": {
|
|
78
78
|
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
|
|
79
79
|
"S1_CONSOLE_API_TOKEN": "eyJ...",
|
|
@@ -136,7 +136,9 @@ Credential keys, where to get each one, and the two token types are documented c
|
|
|
136
136
|
|
|
137
137
|
`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).
|
|
138
138
|
|
|
139
|
-
`S1_HEC_INGEST_URL` is **required** for the
|
|
139
|
+
`S1_HEC_INGEST_URL` is **required** for the two UAM Ingest tools (`uam_ingest_alert`, `uam_post_alert`) and for `hec_ingest`, the only three tools that need it. Without it those tools error at call time; the rest still work.
|
|
140
|
+
|
|
141
|
+
`hec_ingest` additionally needs **`S1_HEC_TOKEN`**, an SDL Log Write Key. It is a different credential from the console API token, which the event collector refuses outright, and no API mints one: Console > Singularity Data Lake > API Keys > Log Write Key. The key is issued for a single account or site and writes only there, so it fixes the destination and there is no scope header to override it. UAM alert ingest and IOCs are unaffected and still use `S1_CONSOLE_API_TOKEN`.
|
|
140
142
|
|
|
141
143
|
The SDL config-file tools (`sdl_list_files`, `sdl_get_file`, `sdl_put_file`, `sdl_delete_file`) are authorised by `S1_CONSOLE_API_TOKEN` against `POST <console>/sdl/v2/graphql`. The scoped SDL keys (`SDL_CONFIG_READ_KEY`, `SDL_CONFIG_WRITE_KEY`, `SDL_LOG_READ_KEY`, `SDL_LOG_WRITE_KEY`, `SDL_XDR_URL`) are retired and are no longer read.
|
|
142
144
|
|
|
@@ -144,7 +146,8 @@ The SDL config-file tools (`sdl_list_files`, `sdl_get_file`, `sdl_put_file`, `sd
|
|
|
144
146
|
|----------|-------------|--------------|
|
|
145
147
|
| `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery + SDL tools |
|
|
146
148
|
| `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM + SDL config-file tools |
|
|
147
|
-
| `S1_HEC_INGEST_URL` |
|
|
149
|
+
| `S1_HEC_INGEST_URL` | Ingest host, e.g. `https://ingest.us1.sentinelone.net` | `uam_ingest_alert`, `uam_post_alert`, `hec_ingest` |
|
|
150
|
+
| `S1_HEC_TOKEN` | SDL Log Write Key, scoped to one account or site. Optional; only raw log ingest needs it. Same variable name as the deployer repos. | `hec_ingest` |
|
|
148
151
|
|
|
149
152
|
### Credential resolution order (highest priority wins)
|
|
150
153
|
|
package/deploy/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Or, equivalently, by package name without the install:
|
|
|
57
57
|
"mcpServers": {
|
|
58
58
|
"s1-secops-mcp": {
|
|
59
59
|
"command": "npx",
|
|
60
|
-
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.
|
|
60
|
+
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.8"]
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -376,7 +376,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
|
|
|
376
376
|
|
|
377
377
|
These are supported but not first-class:
|
|
378
378
|
|
|
379
|
-
- **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.
|
|
379
|
+
- **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.8` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
|
|
380
380
|
|
|
381
381
|
- **External bridge (`supergateway`, `mcp-proxy`).** Pre-1.1.0 deployments used these to wrap the stdio-only server. They still work; this server's native HTTP mode is functionally equivalent and removes the extra process. Prefer native unless you have a specific reason.
|
|
382
382
|
|
package/index.js
CHANGED
|
@@ -108,7 +108,7 @@ ENVIRONMENT
|
|
|
108
108
|
S1_CONSOLE_URL Console URL, e.g. https://usea1-acme.sentinelone.net
|
|
109
109
|
S1_CONSOLE_API_TOKEN Mgmt Console API token. Required for most tools.
|
|
110
110
|
S1_HEC_INGEST_URL HEC ingest host. Required for uam_ingest_alert,
|
|
111
|
-
|
|
111
|
+
uam_post_alert.
|
|
112
112
|
S1_CREDS_FILE Explicit path to a credentials.json file.
|
|
113
113
|
Highest priority for credential resolution.
|
|
114
114
|
S1_CLAUDE_MD_PATH Absolute path to CLAUDE.md for the soc_analyst
|
package/lib/credentials.js
CHANGED
|
@@ -106,6 +106,19 @@ export function getCreds() {
|
|
|
106
106
|
S1_CONSOLE_URL: e('S1_CONSOLE_URL'),
|
|
107
107
|
S1_CONSOLE_API_TOKEN: e('S1_CONSOLE_API_TOKEN') || e('S1_API_TOKEN'),
|
|
108
108
|
S1_HEC_INGEST_URL: e('S1_HEC_INGEST_URL'),
|
|
109
|
+
// SDL Log Write Key, used ONLY for log ingest over the event collector.
|
|
110
|
+
// Optional: every other tool authenticates with S1_CONSOLE_API_TOKEN, and a
|
|
111
|
+
// deployment that never ingests logs does not need this at all.
|
|
112
|
+
//
|
|
113
|
+
// It is a DIFFERENT credential, not an alias. The collector refuses a
|
|
114
|
+
// console user token, and the key is minted for one account or site and
|
|
115
|
+
// writes only there, so it also fixes the destination: there is no scope
|
|
116
|
+
// header to override. Mint it at Console > Singularity Data Lake >
|
|
117
|
+
// API Keys > Log Write Key. No API creates one.
|
|
118
|
+
//
|
|
119
|
+
// Named to match the deployer repos, which already ship this variable, so
|
|
120
|
+
// one value can be pasted across all of them.
|
|
121
|
+
S1_HEC_TOKEN: e('S1_HEC_TOKEN'),
|
|
109
122
|
// Default S1-Scope for SDL requests: "<accountId>" for account scope or
|
|
110
123
|
// "<accountId>:<siteId>" for site scope. Optional. Per-call scope arguments
|
|
111
124
|
// override it; passing scope:null suppresses it entirely. SDL reads are
|
package/lib/hec.js
CHANGED
|
@@ -10,8 +10,16 @@
|
|
|
10
10
|
* Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
|
|
11
11
|
* Endpoints : /services/collector/raw (raw text, recommended for logs)
|
|
12
12
|
* /services/collector/event (structured JSON)
|
|
13
|
-
* Auth : Authorization: Bearer <
|
|
14
|
-
*
|
|
13
|
+
* Auth : Authorization: Bearer <S1_HEC_TOKEN>, an SDL Log Write Key. NOT the
|
|
14
|
+
* Management Console API token: the collector refuses a user token.
|
|
15
|
+
* Mint one at Console > Singularity Data Lake > API Keys > Log Write Key;
|
|
16
|
+
* no API creates one.
|
|
17
|
+
* Scope : NONE. A Log Write Key is minted for one account or site and writes only
|
|
18
|
+
* there, so the key itself fixes the destination and the S1-Scope header is
|
|
19
|
+
* neither required nor honoured. Measured on a live tenant: the write key
|
|
20
|
+
* returns 200 with no S1-Scope header at all, while the console token on the
|
|
21
|
+
* same request returns 400 "Missing S1-Scope header". To write somewhere
|
|
22
|
+
* else, use a key minted for that scope.
|
|
15
23
|
* Parser : ?sourcetype=<parserName> query param. Other query params become fields in the UI.
|
|
16
24
|
* Pre-parsed: /event with ?isParsed=true indexes already-structured JSON fields directly, with no SDL parser.
|
|
17
25
|
* Compress : optional "Content-Encoding: gzip" (or zstd), recommended, lowers egress cost.
|
|
@@ -36,9 +44,15 @@ function hecBase() {
|
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
function hecToken() {
|
|
39
|
-
const tok = getCreds().
|
|
47
|
+
const tok = getCreds().S1_HEC_TOKEN;
|
|
40
48
|
if (!tok) {
|
|
41
|
-
throw new Error(
|
|
49
|
+
throw new Error(
|
|
50
|
+
'S1_HEC_TOKEN not configured. Log ingest needs an SDL Log Write Key, not the ' +
|
|
51
|
+
'Management Console API token: the event collector refuses a user token. ' +
|
|
52
|
+
'Mint one at Console > Singularity Data Lake > API Keys > Log Write Key ' +
|
|
53
|
+
'(no API creates one) and set S1_HEC_TOKEN. The key is scoped to one account or ' +
|
|
54
|
+
'site and writes only there.'
|
|
55
|
+
);
|
|
42
56
|
}
|
|
43
57
|
return tok;
|
|
44
58
|
}
|
|
@@ -54,7 +68,10 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
54
68
|
* @param {object} [opts.fields] Extra {key: value} pairs -> query params, each becomes a UI field.
|
|
55
69
|
* Avoid HEC-reserved keys (event, time, host, source, sourcetype, index, fields):
|
|
56
70
|
* HEC interprets those, they are not stored as custom fields. Use `parser` (not a field) to set sourcetype. (S-26.1 HEC docs, p.4708.)
|
|
57
|
-
* @param {string} opts.scope
|
|
71
|
+
* @param {string} [opts.scope] IGNORED, accepted only so existing callers do not break.
|
|
72
|
+
* The S1-Scope header is not sent: the Log Write Key already
|
|
73
|
+
* determines the destination and the collector does not honour
|
|
74
|
+
* an override. Passing one has no effect.
|
|
58
75
|
* @param {('raw'|'event')} [opts.endpoint='raw']
|
|
59
76
|
* For 'event', logContent must be newline-separated HEC JSON envelopes:
|
|
60
77
|
* {"time": <epoch seconds>, "event": <string|object>, "fields": {...}}.
|
|
@@ -67,15 +84,13 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
67
84
|
* @returns {Promise<{status:number, endpoint:string, url:string, body:any}>}
|
|
68
85
|
*/
|
|
69
86
|
export async function hecIngest(logContent, { parser, fields = {}, scope, endpoint = 'raw', compress = true, isParsed = false } = {}) {
|
|
87
|
+
void scope; // accepted and deliberately unused; see the param doc above.
|
|
70
88
|
if (typeof logContent !== 'string' || logContent.length === 0) {
|
|
71
89
|
throw new Error('hecIngest: logContent must be a non-empty string.');
|
|
72
90
|
}
|
|
73
91
|
if (endpoint !== 'raw' && endpoint !== 'event') {
|
|
74
92
|
throw new Error("hecIngest: endpoint must be 'raw' or 'event'.");
|
|
75
93
|
}
|
|
76
|
-
if (!scope || typeof scope !== 'string') {
|
|
77
|
-
throw new Error('hecIngest: scope is required. HEC rejects requests without an S1-Scope header (400 "Missing S1-Scope header"). Pass an accountId or "accountId:siteId".');
|
|
78
|
-
}
|
|
79
94
|
|
|
80
95
|
const qs = new URLSearchParams();
|
|
81
96
|
if (parser) qs.set('sourcetype', parser);
|
|
@@ -101,7 +116,8 @@ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoi
|
|
|
101
116
|
'Content-Type': endpoint === 'event' ? 'application/json' : 'text/plain',
|
|
102
117
|
};
|
|
103
118
|
if (compress) headers['Content-Encoding'] = 'gzip';
|
|
104
|
-
|
|
119
|
+
// No S1-Scope. The Log Write Key fixes the destination; sending a scope does not
|
|
120
|
+
// move the events and an empty one is a different request to none at all.
|
|
105
121
|
|
|
106
122
|
let delay = 1000;
|
|
107
123
|
let lastErr;
|
package/lib/s1.js
CHANGED
|
@@ -558,6 +558,44 @@ export async function uamAddNote(alertId, noteText) {
|
|
|
558
558
|
}, null);
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
+
/**
|
|
562
|
+
* What actions does the API say this caller may trigger on this alert?
|
|
563
|
+
*
|
|
564
|
+
* The authoritative capability answer, and the thing to consult before
|
|
565
|
+
* explaining any refused action. Returns the raw list, each entry carrying
|
|
566
|
+
* `{id, title, type, isDisabled, disabledReason}`.
|
|
567
|
+
*
|
|
568
|
+
* `alertAvailableActions` needs a non-null `scope`, unlike alertTriggerActions,
|
|
569
|
+
* so account ids are resolved first. Availability is scope-sensitive (measured:
|
|
570
|
+
* the S1/incident/* actions report
|
|
571
|
+
* INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are
|
|
572
|
+
* enabled under SITE), so pass `scope` explicitly when you care about a
|
|
573
|
+
* site-scoped action.
|
|
574
|
+
*/
|
|
575
|
+
export async function uamAvailableActions(alertId, scope) {
|
|
576
|
+
let resolved = scope;
|
|
577
|
+
if (!resolved) {
|
|
578
|
+
const accts = await apiGet('/web/api/v2.1/accounts', { limit: 100 });
|
|
579
|
+
const ids = (accts?.data || []).map((a) => a.id).filter(Boolean);
|
|
580
|
+
if (!ids.length) throw new Error('no accounts visible to this token');
|
|
581
|
+
resolved = { scopeIds: ids, scopeType: 'ACCOUNT' };
|
|
582
|
+
}
|
|
583
|
+
const query = `
|
|
584
|
+
query AvailableActions($scope: ScopeSelectorInput!, $filter: OrFilterSelectionInput) {
|
|
585
|
+
alertAvailableActions(scope: $scope, filter: $filter) {
|
|
586
|
+
data { id title type isDisabled disabledReason }
|
|
587
|
+
errors { errorMessage }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
`;
|
|
591
|
+
const variables = {
|
|
592
|
+
scope: resolved,
|
|
593
|
+
filter: { or: [{ and: [{ fieldId: 'id', stringEqual: { value: alertId } }] }] },
|
|
594
|
+
};
|
|
595
|
+
const data = await uamGraphql(query, variables, undefined, { readOnly: true });
|
|
596
|
+
return data?.alertAvailableActions?.data || [];
|
|
597
|
+
}
|
|
598
|
+
|
|
561
599
|
/**
|
|
562
600
|
* Update the status of a UAM alert via alertTriggerActions.
|
|
563
601
|
* Valid status values (confirmed via Status enum introspection): NEW | IN_PROGRESS | RESOLVED
|
|
@@ -605,7 +643,32 @@ export async function uamSetStatus(alertId, status) {
|
|
|
605
643
|
}
|
|
606
644
|
if (action.failure?.length) {
|
|
607
645
|
const f = action.failure[0];
|
|
608
|
-
|
|
646
|
+
// errorMessage names the failure, not the cause. Ask alertAvailableActions,
|
|
647
|
+
// which is filtered by the caller's permissions AND the alert type.
|
|
648
|
+
let hint = '';
|
|
649
|
+
try {
|
|
650
|
+
const avail = await uamAvailableActions(alertId);
|
|
651
|
+
const ids = avail.map((a) => a.id);
|
|
652
|
+
if (!ids.includes('S1/alert/statusUpdate')) {
|
|
653
|
+
hint = ' | alertAvailableActions: statusUpdate is NOT OFFERED to this '
|
|
654
|
+
+ `caller for this alert (available: ${ids.join(', ') || 'none'}). `
|
|
655
|
+
+ 'Availability is filtered by the caller\'s permissions and the '
|
|
656
|
+
+ 'alert type: check the service user\'s UAM permissions. A '
|
|
657
|
+
+ 'console user session may still be able to perform it.';
|
|
658
|
+
} else {
|
|
659
|
+
const a = avail.find((x) => x.id === 'S1/alert/statusUpdate');
|
|
660
|
+
hint = a?.isDisabled
|
|
661
|
+
? ` | alertAvailableActions: offered but DISABLED (${a.disabledReason || 'no reason given'}).`
|
|
662
|
+
: ' | alertAvailableActions: statusUpdate IS available here, so the '
|
|
663
|
+
+ 'refusal is not availability. Escalate as a genuine permission '
|
|
664
|
+
+ 'or state problem.';
|
|
665
|
+
}
|
|
666
|
+
} catch (e) {
|
|
667
|
+
hint = ` | could not query alertAvailableActions to diagnose: ${e.message}`;
|
|
668
|
+
}
|
|
669
|
+
throw new Error(
|
|
670
|
+
`uamSetStatus failed for alert ${alertId}: ${f.errorMessage || f.errorType || 'unknown error'}${hint}`
|
|
671
|
+
);
|
|
609
672
|
}
|
|
610
673
|
if (!(action.success?.length) && action.skip?.length) {
|
|
611
674
|
throw new Error(
|
package/lib/sdl.js
CHANGED
|
@@ -31,7 +31,7 @@ export function sdlToken() {
|
|
|
31
31
|
*
|
|
32
32
|
* SDL objects (dashboards, saved searches, config files) are filed against the
|
|
33
33
|
* scope the request carries, and reads are filtered by it. Verified live on
|
|
34
|
-
*
|
|
34
|
+
* <console> 2026-08-17, same token: `configFiles` returned 113 files at
|
|
35
35
|
* account scope (20 of them dashboards) and 4 at a site scope (all 4
|
|
36
36
|
* dashboards). A dashboard created at site scope is invisible to an
|
|
37
37
|
* account-scoped listing, so a missing header is not a neutral default, it
|
|
@@ -415,7 +415,7 @@ export async function deleteConfigFile({ name, udoId, expectedVersion, scope })
|
|
|
415
415
|
//
|
|
416
416
|
// A SECOND, HIGHER-LEVEL SURFACE on the same `POST /sdl/v2/graphql` endpoint.
|
|
417
417
|
// This is what the console itself drives; captured from live console traffic on
|
|
418
|
-
//
|
|
418
|
+
// <console> 2026-08-17 (280 requests, 23 operations).
|
|
419
419
|
//
|
|
420
420
|
// Relationship to the config-file layer above:
|
|
421
421
|
//
|
package/lib/server-core.js
CHANGED
package/lib/uam-ingest.js
CHANGED
|
@@ -9,15 +9,19 @@
|
|
|
9
9
|
* Scope : S1-Scope: <accountId>[:<siteId>[:<groupId>]] (mandatory)
|
|
10
10
|
*
|
|
11
11
|
* Endpoints:
|
|
12
|
-
* POST /v1/
|
|
13
|
-
* POST /v1/alerts , OCSF SecurityAlert (ONE per call, see below)
|
|
12
|
+
* POST /v1/alerts , OCSF SecurityAlert (ONE per call, see below)
|
|
14
13
|
*
|
|
15
|
-
*
|
|
14
|
+
* INDICATORS ARE NOT SEPARATELY INGESTIBLE. There is no usable POST
|
|
15
|
+
* /v1/indicators any more: the endpoint refuses the console user token AND the
|
|
16
|
+
* SDL Log Write Key, so no credential can drive it. Every indicator now rides
|
|
17
|
+
* inside the alert, in finding_info.related_events[], and the Indicators tab in
|
|
18
|
+
* the console is fed from there. This removed the sleep, the ordering contract
|
|
19
|
+
* and a whole class of silent drop, so the single POST is simpler as well as
|
|
20
|
+
* being the only thing that works.
|
|
21
|
+
*
|
|
22
|
+
* Critical constraints (empirically confirmed on a live tenant):
|
|
16
23
|
* - ONE alert per POST /v1/alerts. Multi-alert bodies return HTTP 202 but the
|
|
17
24
|
* stitcher silently drops all but one. Loop callers for multiple alerts.
|
|
18
|
-
* - Sleep ~3s between POST /v1/indicators and POST /v1/alerts. If the alert
|
|
19
|
-
* lands before the indicator's metadata.uid is registered the stitcher silently
|
|
20
|
-
* drops the alert (still HTTP 202). ingestAlert() enforces the sleep.
|
|
21
25
|
* - file.hashes MUST be OCSF Fingerprint array [{algorithm_id, algorithm, value}],
|
|
22
26
|
* NOT a plain dict. Dict form causes silent drop even on HTTP 202.
|
|
23
27
|
* - finding_info.related_events[] entries MUST carry class_uid, type_uid,
|
|
@@ -197,13 +201,16 @@ export function buildFileIndicator({
|
|
|
197
201
|
*
|
|
198
202
|
* Returns a complete alert object ready to POST to /v1/alerts (one at a time).
|
|
199
203
|
*
|
|
200
|
-
* @param {boolean} [inline=
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
204
|
+
* @param {boolean} [inline=true]
|
|
205
|
+
* The only supported value is true, and it is the default. related_events[] embeds the
|
|
206
|
+
* full indicator context (file, device, actor) inline and everything ships in one
|
|
207
|
+
* /v1/alerts call.
|
|
208
|
+
*
|
|
209
|
+
* Passing false used to emit reference-only entries for the stitcher to resolve against
|
|
210
|
+
* a prior /v1/indicators POST. That POST is no longer possible, so a reference-only
|
|
211
|
+
* alert now resolves to nothing: it is accepted with HTTP 202 and shows an empty
|
|
212
|
+
* Indicators tab. The parameter is still accepted so old callers do not crash, but it
|
|
213
|
+
* is forced to true and a warning is emitted.
|
|
207
214
|
*/
|
|
208
215
|
export function buildSecurityAlert({
|
|
209
216
|
alertUid,
|
|
@@ -212,7 +219,7 @@ export function buildSecurityAlert({
|
|
|
212
219
|
description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
|
|
213
220
|
detectionProduct = 'smoke-product',
|
|
214
221
|
detectionVendor = 'smoke-vendor',
|
|
215
|
-
inline =
|
|
222
|
+
inline = true,
|
|
216
223
|
nowMs,
|
|
217
224
|
} = {}) {
|
|
218
225
|
const ts = nowMs || Date.now();
|
|
@@ -286,72 +293,19 @@ export function buildSecurityAlert({
|
|
|
286
293
|
|
|
287
294
|
// ─── High-level end-to-end helpers ────────────────────────────────────────────
|
|
288
295
|
|
|
289
|
-
|
|
290
|
-
* Create a synthetic test alert in UAM end-to-end.
|
|
291
|
-
*
|
|
292
|
-
* Builds an OCSF FileSystem Activity indicator and a SecurityAlert,
|
|
293
|
-
* POSTs them to the HEC ingest host with the required 3s sleep in between,
|
|
294
|
-
* and returns the UIDs and HTTP responses.
|
|
296
|
+
/* ingestAlert(), the two-step indicator-then-alert flow, was REMOVED.
|
|
295
297
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
+
* It posted the indicator to /v1/indicators, slept ~3s for the stitcher, then
|
|
299
|
+
* posted an alert referencing it by uid. That path no longer works: the
|
|
300
|
+
* indicators endpoint refuses both the console user token and the SDL Log Write
|
|
301
|
+
* Key, so there is no credential that can drive it. Indicators are now carried
|
|
302
|
+
* inline in the alert body instead, which is what ingestAlertInline() below
|
|
303
|
+
* does, and which never needed the sleep or the sequencing in the first place.
|
|
298
304
|
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* @param {string} [opts.title] Alert name shown in UAM (default: "MCP Test Alert")
|
|
302
|
-
* @param {string} [opts.description] Alert description
|
|
303
|
-
* @param {string} [opts.hostname] Hostname for the indicator device
|
|
304
|
-
* @param {string} [opts.filename] Filename for the FileSystem indicator
|
|
305
|
-
* @param {string} [opts.sha256] SHA-256 hash (64 hex chars); random if omitted
|
|
306
|
-
* @param {number} [opts.sleepMs=3000] Sleep between indicator POST and alert POST
|
|
305
|
+
* Deliberately deleted rather than left throwing: a function that can only fail
|
|
306
|
+
* invites callers to keep a code path alive for it.
|
|
307
307
|
*/
|
|
308
|
-
export async function ingestAlert({
|
|
309
|
-
scope,
|
|
310
|
-
title = 'MCP Test Alert',
|
|
311
|
-
description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
|
|
312
|
-
hostname = 'mcp-test-host',
|
|
313
|
-
filename = 'test-payload.exe',
|
|
314
|
-
sha256,
|
|
315
|
-
sleepMs = 3000,
|
|
316
|
-
} = {}) {
|
|
317
|
-
if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
|
|
318
|
-
|
|
319
|
-
const nowMs = Date.now();
|
|
320
|
-
const indicatorUid = randomUUID();
|
|
321
|
-
const alertUid = randomUUID();
|
|
322
|
-
|
|
323
|
-
const indicator = buildFileIndicator({
|
|
324
|
-
indicatorUid,
|
|
325
|
-
filename,
|
|
326
|
-
sha256,
|
|
327
|
-
hostname,
|
|
328
|
-
nowMs,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
const indicatorResp = await hecPost('/v1/indicators', [indicator], scope);
|
|
332
308
|
|
|
333
|
-
// Wait for the stitcher to register the indicator uid before posting the alert.
|
|
334
|
-
// Reducing below ~2s has been observed to cause silent drops on loaded tenants.
|
|
335
|
-
await sleep(sleepMs);
|
|
336
|
-
|
|
337
|
-
const alert = buildSecurityAlert({
|
|
338
|
-
alertUid,
|
|
339
|
-
indicator,
|
|
340
|
-
title,
|
|
341
|
-
description,
|
|
342
|
-
nowMs,
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
const alertResp = await hecPost('/v1/alerts', alert, scope);
|
|
346
|
-
|
|
347
|
-
return {
|
|
348
|
-
indicator_uid: indicatorUid,
|
|
349
|
-
alert_uid: alertUid,
|
|
350
|
-
indicator_response: indicatorResp,
|
|
351
|
-
alert_response: alertResp,
|
|
352
|
-
next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
309
|
|
|
356
310
|
/**
|
|
357
311
|
* Create a synthetic test alert in UAM in a single /v1/alerts POST.
|
|
@@ -411,15 +365,10 @@ export async function ingestAlertInline({
|
|
|
411
365
|
|
|
412
366
|
// ─── Low-level raw-payload helpers ────────────────────────────────────────────
|
|
413
367
|
|
|
414
|
-
|
|
415
|
-
*
|
|
416
|
-
* Caller is responsible for correct OCSF shape.
|
|
368
|
+
/* postIndicators() was REMOVED along with the /v1/indicators path it wrapped.
|
|
369
|
+
* See the note above ingestAlertInline(). Indicators ride inside the alert.
|
|
417
370
|
*/
|
|
418
|
-
|
|
419
|
-
if (!scope) throw new Error('scope is required.');
|
|
420
|
-
const items = Array.isArray(indicators) ? indicators : [indicators];
|
|
421
|
-
return hecPost('/v1/indicators', items, scope);
|
|
422
|
-
}
|
|
371
|
+
|
|
423
372
|
|
|
424
373
|
/**
|
|
425
374
|
* POST a single raw OCSF SecurityAlert to /v1/alerts.
|
|
@@ -437,7 +386,14 @@ export async function postAlert({ scope, alert }) {
|
|
|
437
386
|
return hecPost('/v1/alerts', alert, scope);
|
|
438
387
|
}
|
|
439
388
|
|
|
440
|
-
/** True if
|
|
389
|
+
/** True if UAM ingest credentials are configured.
|
|
390
|
+
*
|
|
391
|
+
* UAM alert ingest posts OCSF to /v1/alerts on the ingest host and authenticates
|
|
392
|
+
* with the CONSOLE token, not the Log Write Key: alert creation and IOCs are
|
|
393
|
+
* user-token operations. Only raw log ingest over the event collector uses
|
|
394
|
+
* S1_HEC_TOKEN, and that is checked separately in hec.js. Conflating the two is
|
|
395
|
+
* what made this function demand the wrong credential.
|
|
396
|
+
*/
|
|
441
397
|
export function hasHecCreds() {
|
|
442
398
|
const c = getCreds();
|
|
443
399
|
return !!(c.S1_HEC_INGEST_URL && c.S1_CONSOLE_API_TOKEN);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmoses-s1/s1-secops-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.8",
|
|
4
4
|
"description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context. Stdio or Streamable HTTP transport with per-user bearer auth for team deployments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -60,7 +60,6 @@ const TOOL_SKILL = {
|
|
|
60
60
|
ha_export_workflow: 'hyperautomation',
|
|
61
61
|
// UAM Ingest
|
|
62
62
|
uam_ingest_alert: 'mgmt-console-api (UAM Alert Interface)',
|
|
63
|
-
uam_post_indicators: 'mgmt-console-api (UAM Alert Interface)',
|
|
64
63
|
uam_post_alert: 'mgmt-console-api (UAM Alert Interface)',
|
|
65
64
|
};
|
|
66
65
|
|
package/tools/mgmt-console.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* uam_get_alert Get full alert details (notes, history)
|
|
13
13
|
* uam_add_note Add analyst note to an alert
|
|
14
14
|
* uam_set_status Update alert status (NEW, IN_PROGRESS, RESOLVED)
|
|
15
|
+
* uam_available_actions What actions this alert allows (isDisabled + reason)
|
|
15
16
|
*
|
|
16
17
|
* REMOVED (2026-05-03: confirmed non-functional for API tokens):
|
|
17
18
|
* purple_ai_query : requires browser-session teamToken from /sdl/v2/graphql that
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
* purple_ai_investigate : same root cause (SERVICE_ERROR). Use Purple MCP instead.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
|
-
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus } from '../lib/s1.js';
|
|
23
|
+
import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus, uamAvailableActions } from '../lib/s1.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Defensive normalization for GET /cloud-detection/rules calls.
|
|
@@ -341,4 +342,49 @@ export const tools = [
|
|
|
341
342
|
return JSON.stringify(result, null, 2);
|
|
342
343
|
},
|
|
343
344
|
},
|
|
345
|
+
|
|
346
|
+
// ─── uam_available_actions ────────────────────────────────────────────────
|
|
347
|
+
{
|
|
348
|
+
name: 'uam_available_actions',
|
|
349
|
+
description: `Ask the API which actions can be triggered on a UAM alert, with isDisabled and disabledReason per action. This is the authoritative capability answer and the ONLY correct way to explain a refused write. Availability is filtered by the caller's permissions AND the alert type: one service-user token is offered S1/alert/statusUpdate on a native STAR alert and not on an alert ingested via the UAM Alert Interface (/v1/alerts), where only S1/alert/addNote and S1/alert/eventSearch are listed, while a console user session performs the identical mutation on either. So an action missing here means this identity lacks the permission for this alert type, not that it is impossible: check the service user's UAM permissions. Call this before concluding anything about permissions, and before a bulk write you cannot undo. Availability is also scope-sensitive: the S1/incident/* actions report INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are enabled under SITE, so pass scopeIds/scopeType when you care about a site-scoped action. Read-only.`,
|
|
350
|
+
inputSchema: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
properties: {
|
|
353
|
+
alertId: {
|
|
354
|
+
type: 'string',
|
|
355
|
+
description: 'The UAM alert ID to check.',
|
|
356
|
+
},
|
|
357
|
+
scopeIds: {
|
|
358
|
+
type: 'array',
|
|
359
|
+
items: { type: 'string' },
|
|
360
|
+
description: 'Optional. Account or site ids for the scope. Defaults to every account visible to the token, resolved via GET /accounts.',
|
|
361
|
+
},
|
|
362
|
+
scopeType: {
|
|
363
|
+
type: 'string',
|
|
364
|
+
description: 'Optional. Scope type for scopeIds. Availability differs between ACCOUNT and SITE for some actions.',
|
|
365
|
+
enum: ['ACCOUNT', 'SITE', 'GROUP', 'GLOBAL'],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
required: ['alertId'],
|
|
369
|
+
},
|
|
370
|
+
async handler({ alertId, scopeIds, scopeType }) {
|
|
371
|
+
const scope = scopeIds?.length
|
|
372
|
+
? { scopeIds, scopeType: scopeType || 'ACCOUNT' }
|
|
373
|
+
: undefined;
|
|
374
|
+
const actions = await uamAvailableActions(alertId, scope);
|
|
375
|
+
return JSON.stringify(
|
|
376
|
+
{
|
|
377
|
+
alertId,
|
|
378
|
+
scope: scope || 'all accounts visible to the token (ACCOUNT)',
|
|
379
|
+
enabled: actions.filter((a) => !a.isDisabled).map((a) => a.id),
|
|
380
|
+
disabled: actions
|
|
381
|
+
.filter((a) => a.isDisabled)
|
|
382
|
+
.map((a) => ({ id: a.id, reason: a.disabledReason })),
|
|
383
|
+
actions,
|
|
384
|
+
},
|
|
385
|
+
null,
|
|
386
|
+
2
|
|
387
|
+
);
|
|
388
|
+
},
|
|
389
|
+
},
|
|
344
390
|
];
|
package/tools/sdl-api.js
CHANGED
|
@@ -307,19 +307,22 @@ export const tools = [
|
|
|
307
307
|
// ─── hec_ingest ─────────────────────────────────────────────────────────────
|
|
308
308
|
{
|
|
309
309
|
name: 'hec_ingest',
|
|
310
|
-
description: `Ingest raw logs/events into the SentinelOne AI SIEM Singularity Data Lake via the HEC (HTTP Event Collector) endpoint. Applies a named parser via ?sourcetype and lands the data in the Data Lake for Event Search, PowerQuery, and detection rules. Replaces the removed sdl_upload_logs. NOT UAM ingest (the uam_* tools post OCSF indicators/alerts to /v1/* on the same host but a separate API).
|
|
310
|
+
description: `Ingest raw logs/events into the SentinelOne AI SIEM Singularity Data Lake via the HEC (HTTP Event Collector) endpoint. Applies a named parser via ?sourcetype and lands the data in the Data Lake for Event Search, PowerQuery, and detection rules. Replaces the removed sdl_upload_logs. NOT UAM ingest (the uam_* tools post OCSF indicators/alerts to /v1/* on the same host but a separate API). POST {S1_HEC_INGEST_URL}/services/collector/raw with Authorization: Bearer <S1_HEC_TOKEN>, an SDL Log Write Key. NOT the Management Console API token: the collector refuses it. Measured on a live tenant, identical request: write key returns 200 {"text":"Success","code":0}, console token returns 400 {"text":"Missing S1-Scope header","code":5}. Mint the key at Console > Singularity Data Lake > API Keys > Log Write Key; no API creates one. The key is issued for one account or site and writes only there, so it fixes the destination and NO S1-Scope header is sent. Query params become fields, gzip recommended, 10 MB uncompressed per request.`,
|
|
311
311
|
inputSchema: {
|
|
312
312
|
type: 'object',
|
|
313
313
|
properties: {
|
|
314
314
|
logContent: { type: 'string', description: 'Raw log text. For the /raw endpoint, newline-separated lines become separate events.' },
|
|
315
315
|
parser: { type: 'string', description: 'Parser name, sent as the ?sourcetype= query param. Omit to skip parsing (structured JSON on /event auto-parses).' },
|
|
316
316
|
fields: { type: 'object', description: 'Extra key-value pairs sent as query params; each key becomes a field in the UI, e.g. {"server":"dev","region":"ap1"}. Avoid HEC-reserved names (event, time, host, source, sourcetype, index, fields) as keys; use the parser arg to set sourcetype.' },
|
|
317
|
-
scope: { type: 'string', description: '
|
|
317
|
+
scope: { type: 'string', description: 'IGNORED, accepted only so older callers do not break. The Log Write Key already fixes the destination and no S1-Scope header is sent, so passing this has no effect. To write to a different account or site, use a key minted for that scope.' },
|
|
318
318
|
endpoint: { type: 'string', enum: ['raw','event'], description: "HEC endpoint: 'raw' (default, raw text) or 'event' (structured JSON)." },
|
|
319
319
|
compress: { type: 'boolean', description: 'gzip the body (Content-Encoding: gzip). Default true.' },
|
|
320
320
|
isParsed: { type: 'boolean', description: 'For /event with structured JSON: set ?isParsed=true so SDL indexes the JSON fields directly, with no SDL parser. Confirmed working.' },
|
|
321
321
|
},
|
|
322
|
-
required
|
|
322
|
+
// `scope` is NOT required any more. Leaving it in `required` made the schema
|
|
323
|
+
// demand an argument the implementation discards, so a correct call looked
|
|
324
|
+
// invalid and an invalid one looked correct.
|
|
325
|
+
required: ['logContent'],
|
|
323
326
|
},
|
|
324
327
|
async handler({ logContent, parser, fields, scope, endpoint, compress, isParsed }) {
|
|
325
328
|
const result = await hecIngest(logContent, { parser, fields, scope, endpoint, compress, isParsed });
|
package/tools/uam-ingest.js
CHANGED
|
@@ -3,28 +3,35 @@
|
|
|
3
3
|
* via the SentinelOne HEC ingest host (ingest.us1.sentinelone.net).
|
|
4
4
|
*
|
|
5
5
|
* Tools:
|
|
6
|
-
* uam_ingest_alert End-to-end: build + POST one
|
|
7
|
-
* uam_post_indicators Low-level: POST raw OCSF indicators to /v1/indicators
|
|
6
|
+
* uam_ingest_alert End-to-end: build + POST one SecurityAlert carrying its indicator inline
|
|
8
7
|
* uam_post_alert Low-level: POST a single raw OCSF SecurityAlert to /v1/alerts
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* uam_post_indicators WAS REMOVED. Indicators can no longer be ingested on their
|
|
10
|
+
* own: /v1/indicators refuses the console user token and the SDL Log Write Key
|
|
11
|
+
* alike, so no credential can drive it. They are carried inside the alert, in
|
|
12
|
+
* finding_info.related_events[], which is also what the console Indicators tab
|
|
13
|
+
* reads. A tool that can only ever return 403 is worse than no tool, because it
|
|
14
|
+
* reads as a supported path.
|
|
15
|
+
*
|
|
16
|
+
* These tools require S1_HEC_INGEST_URL plus S1_CONSOLE_API_TOKEN. Alert creation
|
|
17
|
+
* and IOCs remain user-token operations; only raw LOG ingest over the event
|
|
18
|
+
* collector uses S1_HEC_TOKEN.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
|
-
import {
|
|
21
|
+
import { ingestAlertInline, postAlert } from '../lib/uam-ingest.js';
|
|
15
22
|
|
|
16
23
|
export const tools = [
|
|
17
24
|
|
|
18
25
|
// ─── uam_ingest_alert ─────────────────────────────────────────────────────
|
|
19
26
|
{
|
|
20
27
|
name: 'uam_ingest_alert',
|
|
21
|
-
description: `Create a synthetic test alert in Unified Alert Management (UAM) via the SentinelOne
|
|
28
|
+
description: `Create a synthetic test alert in Unified Alert Management (UAM) via the SentinelOne ingest API.
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
ONE round-trip: a single SecurityAlert POSTed to /v1/alerts with its indicator embedded in finding_info.related_events[]. No sleep, no stitcher race, no ordering contract. That inline copy is what populates alert.indicators, the field the console Indicators tab renders.
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
There is no longer a two-call alternative. Posting indicators separately to /v1/indicators is refused for every credential type: the console user token and the SDL Log Write Key both fail, so nothing can drive that endpoint. The old flow also wrote alert.rawIndicators, a separate store the UI never read. The inline parameter is still accepted so existing callers do not break, but it is forced to true.
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
Returns indicator_uid and alert_uid. The alert surfaces in UAM within 30-60s. Verify with alert(id){indicators{...}}; alertWithRawIndicators stays empty by design. Requires S1_HEC_INGEST_URL and S1_CONSOLE_API_TOKEN.`,
|
|
28
35
|
inputSchema: {
|
|
29
36
|
type: 'object',
|
|
30
37
|
properties: {
|
|
@@ -55,48 +62,26 @@ Both modes return indicator_uid and alert_uid. The alert surfaces in UAM within
|
|
|
55
62
|
type: 'string',
|
|
56
63
|
description: 'SHA-256 hash (64 lowercase hex chars). If omitted, a zeroed placeholder hash is used.',
|
|
57
64
|
},
|
|
58
|
-
sleep_ms: {
|
|
59
|
-
type: 'number',
|
|
60
|
-
description: 'Two-call mode only. Milliseconds to sleep between the indicator POST and the alert POST. Default 3000. Do not go below 2000 on loaded tenants.',
|
|
61
|
-
default: 3000,
|
|
62
|
-
},
|
|
63
65
|
inline: {
|
|
64
66
|
type: 'boolean',
|
|
65
|
-
description: '
|
|
66
|
-
default:
|
|
67
|
+
description: 'Accepted for backward compatibility and ignored: the value is always true. Indicators ride inside the alert because there is no working way to post them separately. Passing false returns a note saying so rather than silently doing something different.',
|
|
68
|
+
default: true,
|
|
67
69
|
},
|
|
68
70
|
},
|
|
69
71
|
required: ['scope'],
|
|
70
72
|
},
|
|
71
|
-
async handler({ scope, title, description, hostname, filename, sha256,
|
|
72
|
-
const result =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
inputSchema: {
|
|
84
|
-
type: 'object',
|
|
85
|
-
properties: {
|
|
86
|
-
scope: {
|
|
87
|
-
type: 'string',
|
|
88
|
-
description: 'accountId or "accountId:siteId". Mandatory.',
|
|
89
|
-
},
|
|
90
|
-
indicators: {
|
|
91
|
-
type: 'array',
|
|
92
|
-
description: 'Array of OCSF indicator objects. Each must have metadata.uid, metadata.profiles=["s1/security_indicator"], class_uid, and observables[]. file.hashes must be a Fingerprint array [{algorithm_id, algorithm, value}], not a plain dict.',
|
|
93
|
-
items: { type: 'object', additionalProperties: true },
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
required: ['scope', 'indicators'],
|
|
97
|
-
},
|
|
98
|
-
async handler({ scope, indicators }) {
|
|
99
|
-
const result = await postIndicators({ scope, indicators });
|
|
73
|
+
async handler({ scope, title, description, hostname, filename, sha256, inline = true }) {
|
|
74
|
+
const result = await ingestAlertInline({ scope, title, description, hostname, filename, sha256 });
|
|
75
|
+
// Say so rather than quietly substituting a different behaviour. A caller
|
|
76
|
+
// that asked for the two-call flow is working from a stale assumption and
|
|
77
|
+
// needs to know the request was not honoured as written.
|
|
78
|
+
if (inline === false) {
|
|
79
|
+
result.note =
|
|
80
|
+
'inline:false was ignored. Indicators cannot be posted separately any more: ' +
|
|
81
|
+
'/v1/indicators refuses both the console token and the SDL Log Write Key. ' +
|
|
82
|
+
'The indicator was embedded in the alert instead, which is what the ' +
|
|
83
|
+
'console Indicators tab reads.';
|
|
84
|
+
}
|
|
100
85
|
return JSON.stringify(result, null, 2);
|
|
101
86
|
},
|
|
102
87
|
},
|
|
@@ -104,7 +89,7 @@ Both modes return indicator_uid and alert_uid. The alert surfaces in UAM within
|
|
|
104
89
|
// ─── uam_post_alert ───────────────────────────────────────────────────────
|
|
105
90
|
{
|
|
106
91
|
name: 'uam_post_alert',
|
|
107
|
-
description: `POST a single raw OCSF SecurityAlert to /v1/alerts on the SentinelOne HEC ingest host. IMPORTANT: one alert per call. The HEC stitcher silently drops all but one alert in a multi-alert POST body (HTTP 202 still returned), so this tool rejects arrays. To send multiple alerts, loop this call.
|
|
92
|
+
description: `POST a single raw OCSF SecurityAlert to /v1/alerts on the SentinelOne HEC ingest host. IMPORTANT: one alert per call. The HEC stitcher silently drops all but one alert in a multi-alert POST body (HTTP 202 still returned), so this tool rejects arrays. To send multiple alerts, loop this call. Carry the indicator INLINE in finding_info.related_events[] (uid, title, desc, message, time, severity_id, class_uid, type_uid, category_uid, activity_id, and observables[] with type + typeName). That alone populates alert.indicators, which is what the UAM Indicators tab renders; no /v1/indicators call is needed and none should be made, because that endpoint returns 403 "User token not allowed for this endpoint" for a service-user token while /v1/alerts accepts the same credential. Requires S1_HEC_INGEST_URL in credentials.json.`,
|
|
108
93
|
inputSchema: {
|
|
109
94
|
type: 'object',
|
|
110
95
|
properties: {
|