@pmoses-s1/s1-secops-mcp 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/auth.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bearer token auth loader for HTTP transport.
3
+ *
4
+ * Sources (highest wins):
5
+ * 1. MCP_BEARER_TOKENS_FILE: path to JSON: { "<name>": "<token>", ... }
6
+ * Allows per-user tokens with stable names for audit logs.
7
+ * File mode is enforced via the install script (0600 recommended).
8
+ * 2. MCP_BEARER_TOKENS: comma-separated raw tokens (no per-user names).
9
+ * Names default to "token-1", "token-2", etc.
10
+ *
11
+ * If neither is set, HTTP transport runs with NO authentication. The server
12
+ * logs a loud warning at startup. Suitable only for stdio-style local-only
13
+ * deployments where the bind address is 127.0.0.1 and no other process can
14
+ * reach the port.
15
+ *
16
+ * SIGHUP reloads the token store without restarting the server, so rotation
17
+ * is "edit file, kill -HUP <pid>" with zero downtime.
18
+ *
19
+ * Zero dependencies. Synchronous load, async reload via SIGHUP.
20
+ */
21
+
22
+ import { readFileSync, existsSync, statSync } from 'fs';
23
+
24
+ let _tokens = new Map(); // token -> name
25
+ let _loadedFrom = null;
26
+ let _warnedNoAuth = false;
27
+
28
+ function parseFileTokens(path) {
29
+ const raw = readFileSync(path, 'utf-8');
30
+ const obj = JSON.parse(raw);
31
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
32
+ throw new Error('MCP_BEARER_TOKENS_FILE must contain a JSON object of {name: token}');
33
+ }
34
+ const m = new Map();
35
+ for (const [name, token] of Object.entries(obj)) {
36
+ if (typeof token !== 'string' || token.length < 16) {
37
+ throw new Error(`Token for "${name}" must be a string of at least 16 chars`);
38
+ }
39
+ m.set(token, name);
40
+ }
41
+ return m;
42
+ }
43
+
44
+ function parseEnvTokens(raw) {
45
+ const m = new Map();
46
+ const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
47
+ parts.forEach((token, i) => {
48
+ if (token.length < 16) {
49
+ throw new Error(`Token #${i + 1} in MCP_BEARER_TOKENS must be at least 16 chars`);
50
+ }
51
+ m.set(token, `token-${i + 1}`);
52
+ });
53
+ return m;
54
+ }
55
+
56
+ function logModeForPath(path) {
57
+ try {
58
+ const s = statSync(path);
59
+ const mode = (s.mode & 0o777).toString(8);
60
+ if ((s.mode & 0o077) !== 0) {
61
+ process.stderr.write(
62
+ `[auth] WARNING: ${path} mode is ${mode}; recommended 0600. ` +
63
+ `Run: chmod 600 ${path}\n`
64
+ );
65
+ }
66
+ return mode;
67
+ } catch { return null; }
68
+ }
69
+
70
+ export function loadTokens() {
71
+ const filePath = process.env.MCP_BEARER_TOKENS_FILE;
72
+ const envRaw = process.env.MCP_BEARER_TOKENS;
73
+
74
+ if (filePath) {
75
+ if (!existsSync(filePath)) {
76
+ throw new Error(`MCP_BEARER_TOKENS_FILE points at non-existent path: ${filePath}`);
77
+ }
78
+ _tokens = parseFileTokens(filePath);
79
+ _loadedFrom = `file:${filePath}`;
80
+ const mode = logModeForPath(filePath);
81
+ process.stderr.write(
82
+ `[auth] Loaded ${_tokens.size} bearer token(s) from ${filePath} (mode ${mode || 'unknown'})\n`
83
+ );
84
+ return _tokens.size;
85
+ }
86
+
87
+ if (envRaw) {
88
+ _tokens = parseEnvTokens(envRaw);
89
+ _loadedFrom = 'env:MCP_BEARER_TOKENS';
90
+ process.stderr.write(
91
+ `[auth] Loaded ${_tokens.size} bearer token(s) from MCP_BEARER_TOKENS env var\n`
92
+ );
93
+ return _tokens.size;
94
+ }
95
+
96
+ _tokens = new Map();
97
+ _loadedFrom = null;
98
+ return 0;
99
+ }
100
+
101
+ /**
102
+ * Returns true if any token store is configured. When false, HTTP transport
103
+ * must either (a) refuse to start, or (b) start with a loud warning,
104
+ * depending on caller policy.
105
+ */
106
+ export function isAuthConfigured() {
107
+ return _tokens.size > 0;
108
+ }
109
+
110
+ /**
111
+ * Validates an Authorization header value and returns the matched token name
112
+ * (for audit logging), or null if the header is missing/invalid.
113
+ *
114
+ * Accepts: "Bearer <token>"
115
+ * Rejects: empty, malformed, unknown token.
116
+ */
117
+ export function authenticate(authHeader) {
118
+ if (!authHeader || typeof authHeader !== 'string') return null;
119
+ const m = authHeader.match(/^Bearer\s+(.+)$/i);
120
+ if (!m) return null;
121
+ const token = m[1].trim();
122
+ return _tokens.get(token) || null;
123
+ }
124
+
125
+ /**
126
+ * Warn once if HTTP transport is enabled with no auth. Caller invokes this
127
+ * at startup.
128
+ */
129
+ export function warnIfNoAuth(host) {
130
+ if (!isAuthConfigured() && !_warnedNoAuth) {
131
+ _warnedNoAuth = true;
132
+ const reachable = host !== '127.0.0.1' && host !== 'localhost';
133
+ process.stderr.write(
134
+ `[auth] WARNING: HTTP transport is running with NO authentication.\n` +
135
+ (reachable
136
+ ? `[auth] WARNING: bound to ${host} (reachable from other hosts). ` +
137
+ `Set MCP_BEARER_TOKENS_FILE or MCP_BEARER_TOKENS, or bind to 127.0.0.1.\n`
138
+ : `[auth] (Bound to ${host}; OK for purely local single-user use, ` +
139
+ `not OK for team / VM deployments.)\n`)
140
+ );
141
+ }
142
+ }
143
+
144
+ export function authSourceForLogging() {
145
+ return _loadedFrom;
146
+ }
147
+
148
+ /**
149
+ * Install a SIGHUP handler that reloads tokens from the same source.
150
+ * Returns nothing; intended to be called once at startup.
151
+ */
152
+ export function installSighupReload() {
153
+ process.on('SIGHUP', () => {
154
+ try {
155
+ const n = loadTokens();
156
+ process.stderr.write(`[auth] SIGHUP: reloaded, ${n} token(s) active\n`);
157
+ } catch (e) {
158
+ process.stderr.write(`[auth] SIGHUP: reload FAILED, keeping previous tokens: ${e.message}\n`);
159
+ }
160
+ });
161
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Credential loader: zero dependencies, synchronous.
3
+ *
4
+ * Resolution order (highest wins):
5
+ * 1. Environment variables
6
+ * 2. S1_CREDS_FILE (explicit absolute path; recommended for team / VM deployments)
7
+ * 3. COWORK_WORKSPACE/credentials.json
8
+ * 4. Walk-up from cwd looking for credentials.json
9
+ * 5. ~/mnt/<any-folder>/credentials.json (Cowork workspace mounts)
10
+ * 6. CLAUDE_CONFIG_DIR/sentinelone/credentials.json
11
+ * 7. ~/.config/sentinelone/credentials.json
12
+ */
13
+
14
+ import { readFileSync, existsSync, readdirSync } from 'fs';
15
+ import { join, dirname } from 'path';
16
+ import { homedir } from 'os';
17
+
18
+ const CRED_FILENAMES = [
19
+ 'credentials.json',
20
+ '.sentinelone/credentials.json',
21
+ '.claude/sentinelone/credentials.json',
22
+ ];
23
+
24
+ const MNT_SKIP = new Set(['.claude', '.auto-memory', '.remote-plugins', 'outputs', 'uploads']);
25
+
26
+ function tryLoad(dir) {
27
+ for (const rel of CRED_FILENAMES) {
28
+ const p = join(dir, rel);
29
+ if (existsSync(p)) {
30
+ try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { /* bad JSON */ }
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function discoverCredentials() {
37
+ // 1. S1_CREDS_FILE: explicit absolute path. Useful for VM deployments and
38
+ // secret-store integrations (Vault / Doppler / 1Password / sealed-secrets)
39
+ // that render a credentials file to a known path at boot.
40
+ const credsFile = process.env.S1_CREDS_FILE;
41
+ if (credsFile && existsSync(credsFile)) {
42
+ try { return JSON.parse(readFileSync(credsFile, 'utf-8')); }
43
+ catch (e) {
44
+ process.stderr.write(`[credentials] S1_CREDS_FILE set but unreadable: ${e.message}\n`);
45
+ }
46
+ }
47
+
48
+ // 2. COWORK_WORKSPACE env override
49
+ const ws = process.env.COWORK_WORKSPACE;
50
+ if (ws) {
51
+ const found = tryLoad(ws);
52
+ if (found) return found;
53
+ }
54
+
55
+ // 2. Walk up from cwd
56
+ let dir;
57
+ try { dir = process.cwd(); } catch { dir = '/'; }
58
+ for (let i = 0; i < 20; i++) {
59
+ const found = tryLoad(dir);
60
+ if (found) return found;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+
66
+ // 3. ~/mnt/* scan (Cowork workspace mounts)
67
+ const homeMnt = join(homedir(), 'mnt');
68
+ if (existsSync(homeMnt)) {
69
+ try {
70
+ const entries = readdirSync(homeMnt, { withFileTypes: true });
71
+ for (const e of entries) {
72
+ if (!e.isDirectory() || MNT_SKIP.has(e.name)) continue;
73
+ const found = tryLoad(join(homeMnt, e.name));
74
+ if (found) return found;
75
+ }
76
+ } catch { /* skip */ }
77
+ }
78
+
79
+ // 4. CLAUDE_CONFIG_DIR plugin creds
80
+ const ccDir = process.env.CLAUDE_CONFIG_DIR;
81
+ if (ccDir) {
82
+ const p = join(ccDir, 'sentinelone', 'credentials.json');
83
+ if (existsSync(p)) {
84
+ try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { /* bad JSON */ }
85
+ }
86
+ }
87
+
88
+ // 5. ~/.config/sentinelone/credentials.json
89
+ const configPath = join(homedir(), '.config', 'sentinelone', 'credentials.json');
90
+ if (existsSync(configPath)) {
91
+ try { return JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* bad JSON */ }
92
+ }
93
+
94
+ return {};
95
+ }
96
+
97
+ // Load once at module init
98
+ const _file = discoverCredentials();
99
+
100
+ /**
101
+ * Returns merged credentials. Environment variables take precedence over file values.
102
+ */
103
+ export function getCreds() {
104
+ const e = (key) => process.env[key] || _file[key] || '';
105
+ return {
106
+ S1_CONSOLE_URL: e('S1_CONSOLE_URL'),
107
+ S1_CONSOLE_API_TOKEN: e('S1_CONSOLE_API_TOKEN') || e('S1_API_TOKEN'),
108
+ S1_HEC_INGEST_URL: e('S1_HEC_INGEST_URL'),
109
+ VT_API_KEY: e('VT_API_KEY'),
110
+ };
111
+ }
112
+
113
+ /** True if minimum required credentials for S1 Mgmt API are present. */
114
+ export function hasS1Creds() {
115
+ const c = getCreds();
116
+ return !!(c.S1_CONSOLE_URL && c.S1_CONSOLE_API_TOKEN);
117
+ }
118
+
119
+ /** True if minimum required credentials for SDL are present.
120
+ * SDL lives under <console>/sdl and uses the console API token. */
121
+ export function hasSdlCreds() {
122
+ const c = getCreds();
123
+ return !!(c.S1_CONSOLE_URL && c.S1_CONSOLE_API_TOKEN);
124
+ }
package/lib/hec.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * HEC (HTTP Event Collector) raw-log ingestion into the SentinelOne AI SIEM
3
+ * Singularity Data Lake. This is the SDL log-ingestion path and the replacement
4
+ * for the removed SDL `uploadLogs`. It is NOT UAM ingest: the `uam_*` tools post
5
+ * OCSF indicators/alerts to /v1/* on the same ingest host, but that is a separate
6
+ * API and is not connected to HEC.
7
+ *
8
+ * Source of truth: S-26.1 User Guide, "Singularity Data Lake > Data Ingestion >
9
+ * Additional Integrations > HTTP Event Collector (HEC)", p.4723-4726.
10
+ * Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
11
+ * Endpoints : /services/collector/raw (raw text, recommended for logs)
12
+ * /services/collector/event (structured JSON)
13
+ * Auth : Authorization: Bearer <S1_CONSOLE_API_TOKEN> (the same Management Console API token the other tools use)
14
+ * Scope : S1-Scope header is REQUIRED (accountId or accountId:siteId). Without it HEC returns 400 "Missing S1-Scope header".
15
+ * Parser : ?sourcetype=<parserName> query param. Other query params become fields in the UI.
16
+ * Pre-parsed: /event with ?isParsed=true indexes already-structured JSON fields directly, with no SDL parser.
17
+ * Compress : optional "Content-Encoding: gzip" (or zstd), recommended, lowers egress cost.
18
+ * Limits : 10 MB uncompressed per request, 1000 requests/sec, 2 GB/sec per account.
19
+ */
20
+
21
+ import { gzipSync } from 'zlib';
22
+ import { getCreds } from './credentials.js';
23
+
24
+ const MAX_UNCOMPRESSED = 10 * 1024 * 1024; // 10 MB per HEC docs
25
+
26
+ function hecBase() {
27
+ const url = (getCreds().S1_HEC_INGEST_URL || '').replace(/\/+$/, '');
28
+ if (!url) {
29
+ throw new Error(
30
+ 'S1_HEC_INGEST_URL not configured. Add it to credentials.json ' +
31
+ '(e.g. "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"). ' +
32
+ 'Find the regional ingest URL at https://community.sentinelone.com/s/article/000004961'
33
+ );
34
+ }
35
+ return url;
36
+ }
37
+
38
+ function hecToken() {
39
+ const tok = getCreds().S1_CONSOLE_API_TOKEN;
40
+ if (!tok) {
41
+ throw new Error('S1_CONSOLE_API_TOKEN not configured. HEC uses the same Management Console API token as the Bearer.');
42
+ }
43
+ return tok;
44
+ }
45
+
46
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
47
+
48
+ /**
49
+ * Ingest raw logs/events into SDL via the HEC endpoint.
50
+ *
51
+ * @param {string} logContent Raw text. For /raw, newline-separated lines become separate events.
52
+ * @param {object} [opts]
53
+ * @param {string} [opts.parser] Parser name -> ?sourcetype=
54
+ * @param {object} [opts.fields] Extra {key: value} pairs -> query params, each becomes a UI field.
55
+ * Avoid HEC-reserved keys (event, time, host, source, sourcetype, index, fields):
56
+ * 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 REQUIRED. accountId or "accountId:siteId" -> S1-Scope header. HEC returns 400 "Missing S1-Scope header" without it.
58
+ * @param {('raw'|'event')} [opts.endpoint='raw']
59
+ * For 'event', logContent must be newline-separated HEC JSON envelopes:
60
+ * {"time": <epoch seconds>, "event": <string|object>, "fields": {...}}.
61
+ * The body is passed through verbatim and Content-Type is application/json,
62
+ * so per-event "time" BACKDATES the event (live-verified 2026-07-29; with the
63
+ * old text/plain Content-Type the envelope was indexed as opaque text at
64
+ * receive time and "time" was ignored).
65
+ * @param {boolean} [opts.compress=true] gzip the body (Content-Encoding: gzip)
66
+ * @param {boolean} [opts.isParsed=false] /event only: set ?isParsed=true to index already-structured JSON fields without an SDL parser.
67
+ * @returns {Promise<{status:number, endpoint:string, url:string, body:any}>}
68
+ */
69
+ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoint = 'raw', compress = true, isParsed = false } = {}) {
70
+ if (typeof logContent !== 'string' || logContent.length === 0) {
71
+ throw new Error('hecIngest: logContent must be a non-empty string.');
72
+ }
73
+ if (endpoint !== 'raw' && endpoint !== 'event') {
74
+ throw new Error("hecIngest: endpoint must be 'raw' or 'event'.");
75
+ }
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
+
80
+ const qs = new URLSearchParams();
81
+ if (parser) qs.set('sourcetype', parser);
82
+ for (const [k, v] of Object.entries(fields || {})) qs.set(k, String(v));
83
+ if (isParsed) qs.set('isParsed', 'true');
84
+ const query = qs.toString();
85
+ const url = `${hecBase()}/services/collector/${endpoint}${query ? `?${query}` : ''}`;
86
+
87
+ const rawBuf = Buffer.from(logContent, 'utf-8');
88
+ if (rawBuf.length > MAX_UNCOMPRESSED) {
89
+ throw new Error(
90
+ `hecIngest: payload is ${rawBuf.length} bytes, over the 10 MB uncompressed HEC limit. ` +
91
+ 'Split into smaller batches.'
92
+ );
93
+ }
94
+ const body = compress ? gzipSync(rawBuf) : rawBuf;
95
+
96
+ const headers = {
97
+ Authorization: `Bearer ${hecToken()}`,
98
+ // /event takes HEC JSON envelopes and must be application/json, or the
99
+ // envelope (including per-event "time") is treated as opaque text and the
100
+ // event is indexed at receive time. /raw is plain text. Fixed 2026-07-29.
101
+ 'Content-Type': endpoint === 'event' ? 'application/json' : 'text/plain',
102
+ };
103
+ if (compress) headers['Content-Encoding'] = 'gzip';
104
+ headers['S1-Scope'] = scope;
105
+
106
+ let delay = 1000;
107
+ let lastErr;
108
+ for (let attempt = 0; attempt <= 3; attempt++) {
109
+ let res;
110
+ try {
111
+ res = await fetch(url, { method: 'POST', headers, body });
112
+ } catch (err) {
113
+ lastErr = err;
114
+ if (attempt === 3) throw err;
115
+ await sleep(delay);
116
+ delay = Math.min(delay * 2, 8000);
117
+ continue;
118
+ }
119
+
120
+ // 429 means the request was rejected before processing: safe to retry.
121
+ // 5xx after a raw-log POST is ambiguous (the events may already be
122
+ // committed) and HEC has no idempotency key, so retrying risks duplicate
123
+ // events inflating SDL counts. Fixed 2026-07-29: no automatic 5xx retry.
124
+ if (res.status === 429 && attempt < 3) {
125
+ // Retry-After may be missing or an HTTP date. Number(null) is 0, so a
126
+ // missing header must fall back to the exponential delay, not sleep 0ms.
127
+ const raRaw = res.headers.get('Retry-After');
128
+ const ra = Number(raRaw);
129
+ await sleep(raRaw && Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, 30000) : delay);
130
+ delay = Math.min(delay * 2, 8000);
131
+ continue;
132
+ }
133
+
134
+ const text = await res.text();
135
+ let data;
136
+ try { data = JSON.parse(text); } catch { data = text; }
137
+
138
+ if (!res.ok) {
139
+ throw new Error(`HEC POST /services/collector/${endpoint} -> ${res.status}: ${JSON.stringify(data)}`);
140
+ }
141
+ return { status: res.status, endpoint, url, body: data };
142
+ }
143
+ throw lastErr;
144
+ }