Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mcp/s1-secops-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,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.2.2"],
"args": ["-y", "@pmoses-s1/s1-secops-mcp@1.2.3"],
"env": {
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
"S1_CONSOLE_API_TOKEN": "eyJ...",
Expand Down
22 changes: 18 additions & 4 deletions mcp/s1-secops-mcp/lib/hec.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
* 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.)
* @param {string} opts.scope REQUIRED. accountId or "accountId:siteId" -> S1-Scope header. HEC returns 400 "Missing S1-Scope header" without it.
* @param {('raw'|'event')} [opts.endpoint='raw']
* For 'event', logContent must be newline-separated HEC JSON envelopes:
* {"time": <epoch seconds>, "event": <string|object>, "fields": {...}}.
* The body is passed through verbatim and Content-Type is application/json,
* so per-event "time" BACKDATES the event (live-verified 2026-07-29; with the
* old text/plain Content-Type the envelope was indexed as opaque text at
* receive time and "time" was ignored).
* @param {boolean} [opts.compress=true] gzip the body (Content-Encoding: gzip)
* @param {boolean} [opts.isParsed=false] /event only: set ?isParsed=true to index already-structured JSON fields without an SDL parser.
* @returns {Promise<{status:number, endpoint:string, url:string, body:any}>}
Expand Down Expand Up @@ -89,7 +95,10 @@ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoi

const headers = {
Authorization: `Bearer ${hecToken()}`,
'Content-Type': 'text/plain',
// /event takes HEC JSON envelopes and must be application/json, or the
// envelope (including per-event "time") is treated as opaque text and the
// event is indexed at receive time. /raw is plain text. Fixed 2026-07-29.
'Content-Type': endpoint === 'event' ? 'application/json' : 'text/plain',
};
if (compress) headers['Content-Encoding'] = 'gzip';
headers['S1-Scope'] = scope;
Expand All @@ -108,9 +117,14 @@ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoi
continue;
}

if ((res.status === 429 || res.status >= 500) && attempt < 3) {
const retryAfter = res.headers.get('Retry-After');
await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
// 429 means the request was rejected before processing: safe to retry.
// 5xx after a raw-log POST is ambiguous (the events may already be
// committed) and HEC has no idempotency key, so retrying risks duplicate
// events inflating SDL counts. Fixed 2026-07-29: no automatic 5xx retry.
if (res.status === 429 && attempt < 3) {
// Retry-After may be an HTTP date; Number() of that is NaN -> fall back to delay.
const ra = Number(res.headers.get('Retry-After'));
await sleep(Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay);
delay = Math.min(delay * 2, 8000);
continue;
}
Expand Down
78 changes: 56 additions & 22 deletions mcp/s1-secops-mcp/lib/s1.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@ function safeUrl(path) {
return u;
}

async function doFetch(url, opts, retries = 3) {
async function doFetch(url, opts, retries = 3, { allowRetry = null } = {}) {
// Status-based retry is restricted to idempotent methods (GET/HEAD) unless the
// caller opts in: a 5xx received after the server committed a write would
// otherwise be re-POSTed (duplicate rules/notes/ingestion). Fixed 2026-07-29,
// mirroring the same fix in scripts/s1_client.py.
const method = (opts.method || 'GET').toUpperCase();
const methodRetryable = allowRetry !== null ? allowRetry : (method === 'GET' || method === 'HEAD');
let delay = 500;
for (let attempt = 0; attempt <= retries; attempt++) {
let res;
Expand All @@ -62,10 +68,11 @@ async function doFetch(url, opts, retries = 3) {
continue;
}

// Retry on 429 / 5xx
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
const retryAfter = res.headers.get('Retry-After');
const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
// Retry on 429 / 5xx (idempotent methods, or explicit opt-in, only)
if ((res.status === 429 || res.status >= 500) && attempt < retries && methodRetryable) {
// Retry-After may be an HTTP date; Number() of that is NaN -> fall back to delay.
const ra = Number(res.headers.get('Retry-After'));
const wait = Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay;
await sleep(wait);
delay = Math.min(delay * 2, 8000);
continue;
Expand Down Expand Up @@ -104,16 +111,18 @@ export async function apiGet(path, params = {}) {
});
}

/** POST /web/api/v2.1/<path> */
export async function apiPost(path, body = {}) {
/** POST /web/api/v2.1/<path>.
* Pass { allowRetry: true } ONLY for read-only POSTs (GraphQL queries, Purple AI
* launches, validate endpoints); mutating POSTs must not auto-retry on 5xx. */
export async function apiPost(path, body = {}, { allowRetry = false } = {}) {
return doFetch(safeUrl(path).toString(), {
method: 'POST',
headers: {
Authorization: `ApiToken ${jwt()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}, 3, { allowRetry });
}

/** PUT /web/api/v2.1/<path> */
Expand Down Expand Up @@ -157,17 +166,33 @@ export async function apiPatch(path, body = {}) {
// Must echo X-Dataset-Query-Forward-Tag on every subsequent GET/DELETE.
// Poll every 1s; query expires 30s after last poll. Always cancel after use.

/** Resolve the LRQ time window. Each bound defaults INDEPENDENTLY, per the tool schema.
* Bug fixed 2026-07-29: the old `if (!startTime || !endTime)` overwrote BOTH bounds
* whenever either was missing, so a call with only startTime silently ran over the
* last `hours` instead of the requested window (plausible-but-wrong results).
* Demonstrated live: startTime-only for a 7.4-day window returned 12,880 events
* (== the 24h control, 12,875) vs 73,099 for the true pinned window. */
export function resolveLrqWindow({ startTime, endTime, hours = 24 } = {}) {
const iso = (d) => d.toISOString().replace(/\.\d+Z$/, 'Z');
if (!endTime) endTime = iso(new Date());
if (!startTime) startTime = iso(new Date(new Date(endTime) - hours * 3600 * 1000));
return { startTime, endTime };
}

/** matchCount lives inside the data block on current engines; top-level is a legacy
* fallback. Fixed 2026-07-29: reading only result.matchCount returned null on every
* live call, breaking the 0-rows-vs-0-matches triage. */
export function pickMatchCount(result) {
const d = (result && result.data) || {};
return d.matchCount ?? (result && result.matchCount) ?? null;
}

/** Run a full LRQ PowerQuery lifecycle. Returns { columns, rows, rowCount, matchCount }. */
export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000 } = {}) {
const b = base();
const tok = jwt();

// Resolve time range
if (!startTime || !endTime) {
const now = new Date();
endTime = now.toISOString().replace(/\.\d+Z$/, 'Z');
startTime = new Date(now - hours * 3600 * 1000).toISOString().replace(/\.\d+Z$/, 'Z');
}
({ startTime, endTime } = resolveLrqWindow({ startTime, endTime, hours }));

const launchUrl = `${b}/sdl/v2/api/queries`;
const launchBody = {
Expand Down Expand Up @@ -266,7 +291,7 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
rows,
rowCount: rows.length,
totalRows: rawRows.length,
matchCount: result.matchCount ?? null,
matchCount: pickMatchCount(result),
queryId,
};
}
Expand Down Expand Up @@ -382,7 +407,7 @@ export async function purpleAiQuery(userInput, { viewSelector = 'EDR', hours = 2
`,
};

const data = await apiPost('/web/api/v2.1/graphql', gqlBody);
const data = await apiPost('/web/api/v2.1/graphql', gqlBody, { allowRetry: true }); // read-only launch

if (data.errors?.length) {
throw new Error(`Purple AI GraphQL error: ${data.errors[0].message}`);
Expand Down Expand Up @@ -465,7 +490,7 @@ export async function purpleAlertSummary(alertOcsfJson, { userDetails = null } =
`,
};

const data = await apiPost('/web/api/v2.1/graphql', gqlBody);
const data = await apiPost('/web/api/v2.1/graphql', gqlBody, { allowRetry: true }); // read-only summary
if (data.errors?.length) throw new Error(`Purple AI AlertSummary error: ${data.errors[0].message}`);

const pas = data?.data?.purpleAlertSummary || {};
Expand Down Expand Up @@ -600,10 +625,12 @@ export async function purpleAiInvestigate(alertId, { scopeId, scopeType = 'ACCOU
// ─── UAM GraphQL ─────────────────────────────────────────────────────────────

/** Execute a raw UAM GraphQL operation. */
export async function uamGraphql(query, variables = {}, operationName) {
export async function uamGraphql(query, variables = {}, operationName, { readOnly = false } = {}) {
const body = { query, variables };
if (operationName) body.operationName = operationName;
const data = await apiPost('/web/api/v2.1/unifiedalerts/graphql', body);
// readOnly=true (list/get queries) re-enables 429/5xx retry, which is safe
// for GraphQL reads; mutations (addNote, setStatus) must not auto-retry.
const data = await apiPost('/web/api/v2.1/unifiedalerts/graphql', body, { allowRetry: readOnly });
if (data.errors?.length) {
throw new Error(`UAM GraphQL error: ${data.errors[0].message}`);
}
Expand Down Expand Up @@ -705,7 +732,7 @@ export async function uamListAlerts({
}
}
`;
const data = await uamGraphql(query, variables);
const data = await uamGraphql(query, variables, undefined, { readOnly: true });
const edges = data?.alerts?.edges || [];
return {
alerts: edges.map(e => e.node),
Expand Down Expand Up @@ -762,8 +789,15 @@ export async function uamAddNote(alertId, noteText) {
`;
const data = await uamGraphql(query, { alertId, text: noteText });
const notes = data?.addAlertNote?.data || [];
// Return the most recently added note (last in the list)
return notes.length > 0 ? notes[notes.length - 1] : null;
// Fixed 2026-07-29: do not assume list ordering (newest-last was unverified).
// Prefer the note whose text matches what we just posted; tiebreak/fallback on
// the newest createdAt.
const pool = notes.filter(n => n?.text === noteText);
const candidates = pool.length ? pool : notes;
return candidates.reduce((best, n) => {
if (!best) return n;
return new Date(n?.createdAt || 0) >= new Date(best?.createdAt || 0) ? n : best;
}, null);
}

/**
Expand Down
113 changes: 69 additions & 44 deletions mcp/s1-secops-mcp/lib/sdl.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,65 +21,90 @@ function xdrBase() {
return url;
}

function pickKey(chain) {
export function keyCandidates(chain) {
const c = getCreds();
const chains = {
config_write: [c.SDL_CONFIG_WRITE_KEY],
config_write: [c.SDL_CONFIG_WRITE_KEY, c.S1_CONSOLE_API_TOKEN],
config_read: [c.SDL_CONFIG_WRITE_KEY, c.SDL_CONFIG_READ_KEY, c.S1_CONSOLE_API_TOKEN],
// Confirmed: SDL_CONFIG_WRITE_KEY does NOT grant "View logs" permission on /api/query.
// SDL_LOG_READ_KEY must be first in chain for V1 query to succeed.
log_read: [c.SDL_LOG_READ_KEY, c.SDL_CONFIG_READ_KEY, c.SDL_CONFIG_WRITE_KEY, c.S1_CONSOLE_API_TOKEN],
};
const candidates = chains[chain] || chains.config_read;
const key = candidates.find(k => k);
if (!key) throw new Error(`No SDL credential available for chain "${chain}". Drop credentials.json into your project folder.`);
return key;
const candidates = (chains[chain] || chains.config_read).filter(k => k);
if (!candidates.length) throw new Error(`No SDL credential available for chain "${chain}". Drop credentials.json into your project folder.`);
return candidates;
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function retryAfterMs(res, fallback) {
// Retry-After may be seconds OR an HTTP date; parseInt on a date yields NaN
// and sleep(NaN) fires immediately (no backoff). Validate and cap.
const raw = res.headers.get('Retry-After');
const secs = Number(raw);
if (raw && Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, 30000);
return fallback;
}

async function sdlFetch(method, path, { body, chain = 'config_read', extraHeaders = {}, rawBody = null, contentType = 'application/json' } = {}, retries = 3) {
const url = `${xdrBase()}${path}`;
const token = pickKey(chain);
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': contentType,
...extraHeaders,
};

let delay = 500;
for (let attempt = 0; attempt <= retries; attempt++) {
let res;
try {
res = await fetch(url, {
method,
headers,
body: rawBody !== null ? rawBody : (body !== undefined ? JSON.stringify(body) : undefined),
});
} catch (err) {
if (attempt === retries) throw err;
await sleep(delay);
delay = Math.min(delay * 2, 8000);
continue;
}

if ((res.status === 429 || res.status >= 500) && attempt < retries) {
const retryAfter = res.headers.get('Retry-After');
await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
delay = Math.min(delay * 2, 8000);
continue;
}

const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }

if (!res.ok) {
const msg = typeof data === 'object' ? JSON.stringify(data) : text;
throw new Error(`SDL API ${method} ${path} → ${res.status}: ${msg}`);
// Fixed 2026-07-29: previously only the first CONFIGURED key was tried and a
// 401/403 was fatal, even when a later key in the chain (e.g. the console JWT)
// would have worked. Now auth failures advance to the next candidate key.
const candidates = keyCandidates(chain);
let lastAuthError = null;

for (const token of candidates) {
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': contentType,
...extraHeaders,
};

let delay = 500;
let authFailed = false;
for (let attempt = 0; attempt <= retries; attempt++) {
let res;
try {
res = await fetch(url, {
method,
headers,
body: rawBody !== null ? rawBody : (body !== undefined ? JSON.stringify(body) : undefined),
});
} catch (err) {
if (attempt === retries) throw err;
await sleep(delay);
delay = Math.min(delay * 2, 8000);
continue;
}

if ((res.status === 429 || res.status >= 500) && attempt < retries) {
await sleep(retryAfterMs(res, delay));
delay = Math.min(delay * 2, 8000);
continue;
}

const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }

if (res.status === 401 || res.status === 403) {
// Wrong-scoped key — fall through to the next candidate in the chain.
const msg = typeof data === 'object' ? JSON.stringify(data) : text;
lastAuthError = new Error(`SDL API ${method} ${path} → ${res.status}: ${msg}`);
authFailed = true;
break;
}

if (!res.ok) {
const msg = typeof data === 'object' ? JSON.stringify(data) : text;
throw new Error(`SDL API ${method} ${path} → ${res.status}: ${msg}`);
}
return data;
}
return data;
if (!authFailed) break; // retries exhausted on non-auth errors
}
throw lastAuthError || new Error(`SDL API ${method} ${path}: request failed after retries`);
}

// ─── Config file operations ───────────────────────────────────────────────────
Expand Down
5 changes: 2 additions & 3 deletions mcp/s1-secops-mcp/lib/server-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ function loadSocContext() {
const candidates = [
process.env.S1_CLAUDE_MD_PATH,
process.cwd() ? join(process.cwd(), 'CLAUDE.md') : null,
join(__dir, '..', '..', '..', 'plugins', 's1-secops-skills', 'CLAUDE.md'), // ai-siem layout
join(__dir, '..', '..', 'CLAUDE.md'), // legacy monorepo layout
join(__dir, '..', '..', 'CLAUDE.md'), // claude-skills/CLAUDE.md (git clone)
join(__dir, '..', '..', '..', 'CLAUDE.md'),
join(__dir, '..', 'CLAUDE.md'),
].filter(Boolean);
Expand Down Expand Up @@ -95,7 +94,7 @@ const PROMPTS = [

export const SERVER_INFO = {
name: 's1-secops-mcp-server',
version: '1.2.2',
version: '1.2.3',
};

export const PROTOCOL_VERSION = '2024-11-05';
Expand Down
8 changes: 6 additions & 2 deletions mcp/s1-secops-mcp/lib/uam-ingest.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,13 @@ async function hecPost(path, payloads, scope, retries = 3) {
continue;
}

// Retry is acceptable here despite POST semantics: UAM alert/indicator
// payloads carry metadata.uid, which the stitcher dedupes on, so a re-POST
// after an ambiguous 5xx does not double-ingest. (2026-07-29 review note.)
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
const retryAfter = res.headers.get('Retry-After');
await sleep(retryAfter ? parseInt(retryAfter, 10) * 1000 : delay);
// Retry-After may be an HTTP date; Number() of that is NaN -> fall back to delay.
const ra = Number(res.headers.get('Retry-After'));
await sleep(Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay);
delay = Math.min(delay * 2, 8000);
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion mcp/s1-secops-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pmoses-s1/s1-secops-mcp",
"version": "1.2.2",
"version": "1.2.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",
Expand Down
Loading
Loading