@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/CHANGELOG.md +119 -0
- package/README.md +519 -0
- package/deploy/README.md +370 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +122 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +280 -0
- package/deploy/systemd/s1-secops-mcp.service +59 -0
- package/index.js +171 -0
- package/lib/auth.js +161 -0
- package/lib/credentials.js +124 -0
- package/lib/hec.js +144 -0
- package/lib/http-transport.js +289 -0
- package/lib/s1.js +610 -0
- package/lib/sdl.js +130 -0
- package/lib/server-core.js +263 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +444 -0
- package/package.json +50 -0
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +125 -0
- package/scripts/test-mac.sh +187 -0
- package/tools/hyperautomation.js +284 -0
- package/tools/mgmt-console.js +344 -0
- package/tools/powerquery.js +129 -0
- package/tools/sdl-api.js +125 -0
- package/tools/uam-ingest.js +128 -0
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
|
+
}
|