securenow 8.17.0 → 8.18.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/SKILL-CLI.md +22 -0
- package/cli/human.js +63 -0
- package/cli/monitor.js +43 -0
- package/cli/security.js +121 -2
- package/cli.js +20 -6
- package/esm-detect.js +57 -0
- package/mcp/catalog.js +118 -8
- package/mcp/server.js +17 -2
- package/package.json +3 -2
- package/register.js +3 -7
- package/tracing.js +7 -13
package/SKILL-CLI.md
CHANGED
|
@@ -28,6 +28,22 @@ npx -p securenow securenow-mcp
|
|
|
28
28
|
|
|
29
29
|
The MCP server reads admin/control-plane auth from `.securenow/admin.json` and SDK runtime app credentials from `.securenow/runtime.json`, with legacy `.securenow/credentials.json` still supported. Admin/global tools use admin auth; runtime-scoped read tools can use the runtime API key where its scopes allow it. Write tools require `confirm:true` plus a reason.
|
|
30
30
|
|
|
31
|
+
**For agents, prefer the hosted MCP** (`https://api.securenow.ai/mcp`, OAuth sign-in, no install and always the current tools). With the local stdio server, warm the package once so the first connection doesn't time out on the download (`npx -y securenow@latest --version`), and give the client a longer connect timeout (Claude Code: `MCP_TIMEOUT=120000`).
|
|
32
|
+
|
|
33
|
+
**Tool profiles** (stdio server): `securenow mcp --profile <name>` (or `SECURENOW_MCP_PROFILE=<name>`) serves only the tools one job needs, so the agent carries less context. Every profile keeps `securenow_auth_status`, `securenow_apps_list` and `securenow_apps_info`; calling a tool outside the profile returns an error naming the profile.
|
|
34
|
+
|
|
35
|
+
| Profile | Tools | What's in it |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `all` (default) | 112 | everything |
|
|
38
|
+
| `investigator` | 58 | every read-only tool, plus forensics question/chat/confirm |
|
|
39
|
+
| `detection` | 47 | `securenow_alert_rule*` (draft, create, test, run, runs, explain, versions, exclusions, request promotion…), traces, logs, forensics, IP intelligence, notifications list/get |
|
|
40
|
+
| `responder` | 77 | notifications, human queue, alert reviews, blocklist, allowlist, trusted IPs, rate limits, challenges, automations, firewall, IP intelligence, plus rule get/runs/explain and traces/logs for context |
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
claude mcp add securenow-investigator -- npx -y securenow@latest mcp --profile investigator
|
|
44
|
+
codex mcp add securenow-detection -- npx securenow mcp --profile detection
|
|
45
|
+
```
|
|
46
|
+
|
|
31
47
|
### Authenticate
|
|
32
48
|
|
|
33
49
|
```bash
|
|
@@ -213,8 +229,11 @@ securenow traces analyze <traceId> --env production # AI-powered trace analysi
|
|
|
213
229
|
securenow logs [--app <key>] [--env production] [--limit N] [--minutes M] [--level error|warn|info]
|
|
214
230
|
securenow logs list --app my-app --env production --minutes 30 --level error
|
|
215
231
|
securenow logs trace <traceId> --env production # logs correlated to a specific trace
|
|
232
|
+
securenow logs errors --app my-app --since 6h [--warn] # what is failing: error lines grouped by message (ids/numbers normalized), counts, first/last seen, sample traces
|
|
216
233
|
```
|
|
217
234
|
|
|
235
|
+
For "what's broken right now?", start with `logs errors` (MCP `securenow_logs_errors`): one call returns the distinct failures, not hundreds of raw lines.
|
|
236
|
+
|
|
218
237
|
### Notifications
|
|
219
238
|
|
|
220
239
|
```bash
|
|
@@ -295,6 +314,7 @@ securenow alerts rules delete <id> --yes # delete a custom rule (system rules
|
|
|
295
314
|
securenow alerts rules update <id> --applications-all # all current & future apps
|
|
296
315
|
securenow alerts rules update <id> --apps key1,key2 # explicit app keys only
|
|
297
316
|
securenow alerts rules test <id> --wait # dry-run an existing rule's query (default; --mode live inserts 6 synthetic attack requests)
|
|
317
|
+
securenow alerts rules change <id> "raise the threshold to 20" # rewrite a rule from English: checked, shown before/after, applied as a new version
|
|
298
318
|
securenow alerts rules runs <id> [--since 1h] # what each run saw and why it did or didn't notify (+ next scheduled run)
|
|
299
319
|
securenow alerts rules run <id> # run a periodic rule now and print the run (--no-wait to just queue it)
|
|
300
320
|
securenow alerts rules why <id> --ip <ip> [--since 1h] # why the rule did or didn't alert on one address: verdict + findings
|
|
@@ -322,6 +342,8 @@ right tenant-scope column per table: logs (`signoz_logs.distributed_logs_v2`) us
|
|
|
322
342
|
|
|
323
343
|
MCP parity for quiet rules: `securenow_alert_rule_runs` (run history), `securenow_alert_rule_run_now` (write, confirm) and `securenow_alert_rule_explain` (`ip`, `since`: verdict + findings).
|
|
324
344
|
|
|
345
|
+
**Agents don't promote rules; they ask.** When a test-mode rule has proved itself, an agent runs `securenow alerts rules request-promotion <id> --reason "..." --evidence "..."` (MCP `securenow_alert_rule_request_promotion`). A person sees it on the rules page or with `securenow human promotions` and runs `securenow human approve-promotion <request-id> --yes` (the rule goes to prod, recorded as a rule version naming the approver) or `deny-promotion <request-id> --reason "..."`. API keys and MCP tokens can ask but not decide.
|
|
346
|
+
|
|
325
347
|
MCP parity for noisy alert-rule reviews:
|
|
326
348
|
|
|
327
349
|
- `securenow_alert_rule_get`
|
package/cli/human.js
CHANGED
|
@@ -569,6 +569,66 @@ async function work(args, flags) {
|
|
|
569
569
|
console.log('');
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
+
// ── Rule promotion requests ──
|
|
573
|
+
// Agents create rules in test mode and ask a person to promote them. A person approves (the rule
|
|
574
|
+
// goes to prod, recorded as a rule version) or denies here or on the dashboard's rules page.
|
|
575
|
+
|
|
576
|
+
async function promotions(args, flags) {
|
|
577
|
+
requireAuth();
|
|
578
|
+
const status = typeof flags.status === 'string' ? flags.status : 'pending';
|
|
579
|
+
const s = ui.spinner('Fetching promotion requests');
|
|
580
|
+
let data;
|
|
581
|
+
try {
|
|
582
|
+
data = await api.get('/alert-rules/promotion-requests', { query: { status, limit: flags.limit || 50 } });
|
|
583
|
+
s.stop(`Found ${(data.requests || []).length} ${status === 'all' ? '' : `${status} `}request${(data.requests || []).length === 1 ? '' : 's'}`);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
s.fail('Failed to fetch promotion requests');
|
|
586
|
+
throw err;
|
|
587
|
+
}
|
|
588
|
+
if (flags.json) { ui.json(data); return; }
|
|
589
|
+
const requests = data.requests || [];
|
|
590
|
+
if (requests.length === 0) { ui.info('No promotion requests waiting.'); return; }
|
|
591
|
+
console.log('');
|
|
592
|
+
for (const r of requests) {
|
|
593
|
+
console.log(` ${ui.c.bold(r.ruleName || r.ruleId)} ${ui.c.dim(r.id)} ${r.status}`);
|
|
594
|
+
console.log(` asked by ${r.requestedBy?.name || r.requestedBy?.kind || 'unknown'} ${ui.timeAgo(r.createdAt)}: ${r.reason}`);
|
|
595
|
+
if (r.evidence) console.log(ui.c.dim(` evidence: ${ui.truncate(String(r.evidence).replace(/\s+/g, ' '), 160)}`));
|
|
596
|
+
if (r.status !== 'pending') console.log(ui.c.dim(` ${r.status} by ${r.decidedByName || 'someone'}${r.decisionNote ? `: ${r.decisionNote}` : ''}`));
|
|
597
|
+
}
|
|
598
|
+
console.log('');
|
|
599
|
+
if (requests.some((r) => r.status === 'pending')) {
|
|
600
|
+
console.log(ui.c.dim(' Approve: securenow human approve-promotion <id> --yes [--note "..."] Deny: securenow human deny-promotion <id> --reason "..."'));
|
|
601
|
+
console.log(ui.c.dim(' Before approving, check what it would arm: securenow alerts rules show <ruleId> and securenow alerts rules runs <ruleId>'));
|
|
602
|
+
console.log('');
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function decidePromotion(args, flags, decision) {
|
|
607
|
+
requireAuth();
|
|
608
|
+
const id = args[0];
|
|
609
|
+
const verb = decision === 'approve' ? 'approve-promotion' : 'deny-promotion';
|
|
610
|
+
if (!id) {
|
|
611
|
+
ui.error(`Usage: securenow human ${verb} <request-id> ${decision === 'approve' ? '--yes [--note "..."]' : '--reason "..."'}`);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
if (decision === 'approve' && !flags.yes && !flags.force) {
|
|
615
|
+
ui.warn('Approving moves the rule to prod: its instant block, matching automations and the AI pipeline can act on its alerts.');
|
|
616
|
+
const ok = await ui.confirm('Approve this promotion?');
|
|
617
|
+
if (!ok) { ui.info('Cancelled'); return; }
|
|
618
|
+
}
|
|
619
|
+
const note = flags.note || flags.reason || '';
|
|
620
|
+
const s = ui.spinner(decision === 'approve' ? 'Approving' : 'Denying');
|
|
621
|
+
try {
|
|
622
|
+
const data = await api.post(`/alert-rules/promotion-requests/${id}/${decision}`, { note });
|
|
623
|
+
s.stop(decision === 'approve' ? `Approved: "${data.alertRule?.name || data.request?.ruleName}" is in prod` : 'Denied: the rule stays in test mode');
|
|
624
|
+
for (const warning of data.warnings || []) ui.warn(warning);
|
|
625
|
+
if (flags.json) ui.json(data);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
s.fail(`Failed to ${decision} the promotion`);
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
572
632
|
module.exports = {
|
|
573
633
|
list,
|
|
574
634
|
show,
|
|
@@ -578,4 +638,7 @@ module.exports = {
|
|
|
578
638
|
report,
|
|
579
639
|
prompt,
|
|
580
640
|
work,
|
|
641
|
+
promotions,
|
|
642
|
+
approvePromotion: (args, flags) => decidePromotion(args, flags, 'approve'),
|
|
643
|
+
denyPromotion: (args, flags) => decidePromotion(args, flags, 'deny'),
|
|
581
644
|
};
|
package/cli/monitor.js
CHANGED
|
@@ -294,6 +294,48 @@ async function logsList(args, flags) {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// Error lines grouped by message (ids and numbers normalized): what is failing, how often, since when.
|
|
298
|
+
async function logsErrors(args, flags) {
|
|
299
|
+
requireAuth();
|
|
300
|
+
const appKey = resolveApp(flags);
|
|
301
|
+
if (!appKey) {
|
|
302
|
+
ui.error('No app specified. Use --app <key> or set a default.');
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
const query = { appKeys: appKey, since: flags.since || '1h', limit: flags.limit || 20 };
|
|
306
|
+
if (flags.warn) query.includeWarn = 'true';
|
|
307
|
+
addEnvironment(query, flags);
|
|
308
|
+
const s = ui.spinner('Grouping error logs');
|
|
309
|
+
let data;
|
|
310
|
+
try {
|
|
311
|
+
data = await api.get('/logs/errors', { query });
|
|
312
|
+
const kind = data.includeWarn ? 'error/warning' : 'error';
|
|
313
|
+
s.stop(`${data.total} ${kind} line${data.total === 1 ? '' : 's'} in ${data.distinct} group${data.distinct === 1 ? '' : 's'}`);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
s.fail('Failed to summarize error logs');
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
if (flags.json) { ui.json(data); return; }
|
|
319
|
+
const groups = data.groups || [];
|
|
320
|
+
console.log('');
|
|
321
|
+
if (groups.length === 0) { ui.info(`No ${data.includeWarn ? 'warnings or errors' : 'errors'} since ${data.window?.from}.`); return; }
|
|
322
|
+
for (const g of groups) {
|
|
323
|
+
const last = g.lastSeen ? ui.timeAgo(g.lastSeen) : '';
|
|
324
|
+
console.log(` ${ui.c.red(String(g.occurrences).padStart(5))} ${ui.truncate(String(g.sample || g.signature).replace(/\s+/g, ' '), 110)}`);
|
|
325
|
+
const meta = [
|
|
326
|
+
(g.severities || []).join('/'),
|
|
327
|
+
(g.environments || []).join(', '),
|
|
328
|
+
g.exceptionType,
|
|
329
|
+
last && `last ${last}`,
|
|
330
|
+
g.sampleTraceIds?.[0] && `trace ${g.sampleTraceIds[0]}`,
|
|
331
|
+
].filter(Boolean).join(' · ');
|
|
332
|
+
if (meta) console.log(ui.c.dim(` ${meta}`));
|
|
333
|
+
}
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log(ui.c.dim(' A group\'s requests: securenow logs trace <traceId> Ask why: securenow forensics chat --app <key> "<question>"'));
|
|
336
|
+
console.log('');
|
|
337
|
+
}
|
|
338
|
+
|
|
297
339
|
async function logsTrace(args, flags) {
|
|
298
340
|
requireAuth();
|
|
299
341
|
const traceId = args[0];
|
|
@@ -499,6 +541,7 @@ module.exports = {
|
|
|
499
541
|
tracesAnalyze,
|
|
500
542
|
logsList,
|
|
501
543
|
logsTrace,
|
|
544
|
+
logsErrors,
|
|
502
545
|
notificationsList,
|
|
503
546
|
notificationsRead,
|
|
504
547
|
notificationsReadAll,
|
package/cli/security.js
CHANGED
|
@@ -55,6 +55,9 @@ async function alertRulesRoute(args, flags) {
|
|
|
55
55
|
if (sub === 'from-text' || sub === 'draft') {
|
|
56
56
|
return alertRuleFromText(args.slice(1), flags);
|
|
57
57
|
}
|
|
58
|
+
if (sub === 'change') {
|
|
59
|
+
return alertRuleChange(args.slice(1), flags);
|
|
60
|
+
}
|
|
58
61
|
if (sub === 'show') {
|
|
59
62
|
return alertRuleShow(args.slice(1), flags);
|
|
60
63
|
}
|
|
@@ -94,6 +97,9 @@ async function alertRulesRoute(args, flags) {
|
|
|
94
97
|
if (sub === 'runs') {
|
|
95
98
|
return alertRuleRuns(args.slice(1), flags);
|
|
96
99
|
}
|
|
100
|
+
if (sub === 'request-promotion') {
|
|
101
|
+
return alertRuleRequestPromotion(args.slice(1), flags);
|
|
102
|
+
}
|
|
97
103
|
if (sub === 'run' || sub === 'run-now') {
|
|
98
104
|
return alertRuleRunNow(args.slice(1), flags);
|
|
99
105
|
}
|
|
@@ -472,9 +478,14 @@ async function alertRuleUpdate(args, flags) {
|
|
|
472
478
|
|
|
473
479
|
// Lifecycle mode promote/demote (also available as: alerts rules promote|demote <id>).
|
|
474
480
|
if (flags.mode === 'test' || flags.mode === 'prod') body.mode = flags.mode;
|
|
481
|
+
if (flags.severity) {
|
|
482
|
+
const severity = String(flags.severity).toLowerCase();
|
|
483
|
+
if (!['critical', 'high', 'medium', 'low'].includes(severity)) { ui.error('--severity must be critical, high, medium or low'); process.exit(1); }
|
|
484
|
+
body.severity = severity;
|
|
485
|
+
}
|
|
475
486
|
|
|
476
487
|
if (Object.keys(body).length === 0) {
|
|
477
|
-
ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), or --mode test|prod (or: alerts rules promote/demote <id>).');
|
|
488
|
+
ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), --severity, or --mode test|prod (or: alerts rules promote/demote <id>).');
|
|
478
489
|
process.exit(1);
|
|
479
490
|
}
|
|
480
491
|
|
|
@@ -482,7 +493,7 @@ async function alertRuleUpdate(args, flags) {
|
|
|
482
493
|
try {
|
|
483
494
|
await api.put(`/alert-rules/${id}`, body);
|
|
484
495
|
s.stop('Updated');
|
|
485
|
-
const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`].filter(Boolean);
|
|
496
|
+
const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`, body.severity && `severity → ${body.severity}`].filter(Boolean);
|
|
486
497
|
ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
|
|
487
498
|
} catch (err) {
|
|
488
499
|
s.fail('Failed to update alert rule');
|
|
@@ -873,6 +884,87 @@ async function alertRuleFromText(args, flags) {
|
|
|
873
884
|
console.log(ui.c.dim(` Runs: securenow alerts rules runs ${id} Why no alert: securenow alerts rules why ${id} --ip <ip>`));
|
|
874
885
|
}
|
|
875
886
|
|
|
887
|
+
// Describe a change to an existing rule in English: the API rewrites the rule from its current
|
|
888
|
+
// version and the app's data, checks the result, and the change is applied as a new rule version.
|
|
889
|
+
async function alertRuleChange(args, flags) {
|
|
890
|
+
requireAuth();
|
|
891
|
+
const id = args[0];
|
|
892
|
+
const text = args.slice(1).join(' ').trim() || (typeof flags.text === 'string' ? flags.text.trim() : '');
|
|
893
|
+
if (!id || !text) {
|
|
894
|
+
ui.error('Usage: securenow alerts rules change <rule-id> "<what should change>" [--app <key>] [--yes] [--draft-only] [--json]');
|
|
895
|
+
console.log(ui.c.dim(' Example: securenow alerts rules change <id> "raise the threshold to 20 failed sign-ins"'));
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
const body = { text, ruleId: id };
|
|
899
|
+
if (flags.app) body.applicationKey = flags.app;
|
|
900
|
+
|
|
901
|
+
const s = ui.spinner('Rewriting the rule from your app\'s data');
|
|
902
|
+
let result;
|
|
903
|
+
try {
|
|
904
|
+
const started = await api.post('/alert-rules/draft', body);
|
|
905
|
+
result = await waitForDraft(started.draftId, s);
|
|
906
|
+
} catch (err) {
|
|
907
|
+
s.fail('Failed to draft the change');
|
|
908
|
+
throw err;
|
|
909
|
+
}
|
|
910
|
+
if (result.status !== 'complete') {
|
|
911
|
+
s.fail(result.error || 'Could not draft the change');
|
|
912
|
+
process.exit(1);
|
|
913
|
+
}
|
|
914
|
+
s.stop('Change drafted');
|
|
915
|
+
|
|
916
|
+
const matches = Number(result.check?.matches || 0);
|
|
917
|
+
const refuse = Boolean(flags.yes) && !flags.force && !flags['draft-only'] && matches > 0;
|
|
918
|
+
if (flags.json && (flags['draft-only'] || !flags.yes)) { ui.json(result); return; }
|
|
919
|
+
if (flags.json && refuse) {
|
|
920
|
+
ui.json({ ...result, applied: false, refused: 'check_matches' });
|
|
921
|
+
process.exit(1);
|
|
922
|
+
}
|
|
923
|
+
if (result.current?.kind === 'instant') {
|
|
924
|
+
console.log('');
|
|
925
|
+
console.log(ui.c.dim(` Before: ${result.current.matchMode === 'all' ? 'all of' : 'any of'} ${(result.current.conditions || []).map((c) => `${c.field} ${c.operator} ${c.value}`).join('; ')}`));
|
|
926
|
+
} else if (result.current?.sql) {
|
|
927
|
+
console.log('');
|
|
928
|
+
console.log(ui.c.dim(' Before'));
|
|
929
|
+
for (const line of String(result.current.sql).split('\n')) console.log(ui.c.dim(` ${line}`));
|
|
930
|
+
}
|
|
931
|
+
printDraft(result);
|
|
932
|
+
console.log('');
|
|
933
|
+
if (flags['draft-only']) { ui.info('Draft only: nothing was changed.'); return; }
|
|
934
|
+
if (refuse) {
|
|
935
|
+
ui.warn(`Not applied: the changed rule matches ${matches} row${matches === 1 ? '' : 's'} in your data right now, so it would alert at once. Apply anyway with --yes --force.`);
|
|
936
|
+
process.exit(1);
|
|
937
|
+
}
|
|
938
|
+
if (!flags.yes) {
|
|
939
|
+
if (matches > 0) ui.warn(`The changed rule matches ${matches} row${matches === 1 ? '' : 's'} right now (above): it alerts as soon as it is applied.`);
|
|
940
|
+
const ok = await ui.confirm('Apply this change? It is saved as a new rule version (roll back with alerts rules rollback)');
|
|
941
|
+
if (!ok) { ui.info('Nothing changed'); return; }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const d = result.draft || {};
|
|
945
|
+
const update = {
|
|
946
|
+
name: d.name,
|
|
947
|
+
description: d.summary || undefined,
|
|
948
|
+
severity: d.severity,
|
|
949
|
+
reviewNote: `Changed in English: ${text}`.slice(0, 1000),
|
|
950
|
+
// Instant: only what the draft decides, so the rule keeps its own instant-block settings.
|
|
951
|
+
...(d.executionMode === 'instant'
|
|
952
|
+
? { instant: { matchMode: d.instant?.matchMode, conditions: d.instant?.conditions || [], environments: d.instant?.environments || [] } }
|
|
953
|
+
: { sqlQuery: d.sqlQuery, schedule: d.schedule }),
|
|
954
|
+
};
|
|
955
|
+
const c = ui.spinner('Applying the change');
|
|
956
|
+
try {
|
|
957
|
+
const data = await api.put(`/alert-rules/${id}`, update);
|
|
958
|
+
c.stop(`Changed: version ${data.configVersion || '?'}`);
|
|
959
|
+
printWarnings(data);
|
|
960
|
+
if (flags.json) ui.json(data);
|
|
961
|
+
else console.log(ui.c.dim(` History: securenow alerts rules versions ${id} Runs: securenow alerts rules runs ${id}`));
|
|
962
|
+
} catch (err) {
|
|
963
|
+
c.fail('Failed to apply the change');
|
|
964
|
+
throw err;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
876
968
|
async function alertRuleTest(args, flags) {
|
|
877
969
|
requireAuth();
|
|
878
970
|
const id = args[0];
|
|
@@ -1230,6 +1322,33 @@ async function alertRuleWhy(args, flags) {
|
|
|
1230
1322
|
console.log('');
|
|
1231
1323
|
}
|
|
1232
1324
|
|
|
1325
|
+
// An agent hands the promotion of its test-mode rule to a person: the request waits on the rules
|
|
1326
|
+
// page and in `securenow human promotions` until someone approves or denies it.
|
|
1327
|
+
async function alertRuleRequestPromotion(args, flags) {
|
|
1328
|
+
requireAuth();
|
|
1329
|
+
const id = args[0];
|
|
1330
|
+
const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
|
|
1331
|
+
if (!id || !reason) {
|
|
1332
|
+
ui.error('Usage: securenow alerts rules request-promotion <rule-id> --reason "why it is ready for prod" [--evidence "<text>|@file"]');
|
|
1333
|
+
process.exit(1);
|
|
1334
|
+
}
|
|
1335
|
+
const body = { reason };
|
|
1336
|
+
if (typeof flags.evidence === 'string') {
|
|
1337
|
+
body.evidence = flags.evidence.startsWith('@') ? fs.readFileSync(flags.evidence.slice(1), 'utf8') : flags.evidence;
|
|
1338
|
+
}
|
|
1339
|
+
const s = ui.spinner('Asking for promotion');
|
|
1340
|
+
try {
|
|
1341
|
+
const data = await api.post(`/alert-rules/${id}/promotion-requests`, body);
|
|
1342
|
+
s.stop(data.existing ? 'A request for this rule is already waiting' : 'Promotion requested');
|
|
1343
|
+
if (flags.json) { ui.json(data); return; }
|
|
1344
|
+
ui.info(data.message || 'Waiting for a person to approve it.');
|
|
1345
|
+
if (data.request?.id) console.log(ui.c.dim(` Request ${data.request.id}. A person decides with: securenow human promotions`));
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
s.fail('Failed to request promotion');
|
|
1348
|
+
throw err;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1233
1352
|
// ── Alert Channels ──
|
|
1234
1353
|
|
|
1235
1354
|
function channelDestination(ch) {
|
package/cli.js
CHANGED
|
@@ -195,6 +195,7 @@ const COMMANDS = {
|
|
|
195
195
|
sub: {
|
|
196
196
|
list: { desc: 'List recent logs', flags: { app: 'App key', env: 'Environment (production, staging, preview, local, or all)', environment: 'Alias for --env', limit: 'Max results (default 200)', since: 'Time window (e.g. 30m, 6h, 2d)', minutes: 'Time window in minutes (alias for --since Nm)', level: 'Filter by level (error, warn, info, debug)', start: 'Start time (ISO 8601)', end: 'End time (ISO 8601)' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
|
|
197
197
|
trace: { desc: 'Show logs for a trace', usage: 'securenow logs trace <traceId>', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env' }, run: (a, f) => require('./cli/monitor').logsTrace(a, f) },
|
|
198
|
+
errors: { desc: 'Error lines grouped by message (ids and numbers normalized), most frequent first', usage: 'securenow logs errors [--app <key>] [--env <env>] [--since 1h] [--warn] [--limit 20]', flags: { app: 'App key', env: 'Environment', environment: 'Alias for --env', since: 'Window, e.g. 30m, 6h, 2d (default 1h)', warn: 'Include warnings', limit: 'Max groups (default 20)', json: 'Output as JSON' }, run: (a, f) => require('./cli/monitor').logsErrors(a, f) },
|
|
198
199
|
},
|
|
199
200
|
defaultSub: 'list',
|
|
200
201
|
},
|
|
@@ -249,9 +250,10 @@ const COMMANDS = {
|
|
|
249
250
|
},
|
|
250
251
|
human: {
|
|
251
252
|
desc: 'Work the human action queue prepared by SecureNow AI',
|
|
252
|
-
usage: 'securenow human <list|show|block|fp|report|action|prompt|work> [row|notificationId:ip] [options]',
|
|
253
|
+
usage: 'securenow human <list|show|block|fp|report|action|prompt|work|promotions|approve-promotion|deny-promotion> [row|notificationId:ip|request-id] [options]',
|
|
253
254
|
flags: {
|
|
254
255
|
json: 'Output as JSON',
|
|
256
|
+
note: 'With approve-promotion / deny-promotion: a note recorded with the decision',
|
|
255
257
|
page: 'Queue page number',
|
|
256
258
|
limit: 'Queue page size',
|
|
257
259
|
search: 'Search IP, rule, path, or verdict',
|
|
@@ -286,6 +288,9 @@ const COMMANDS = {
|
|
|
286
288
|
action: { desc: 'Approve/reject/execute a case-level proposed action', usage: 'securenow human action <row|notificationId> [actionKey] --status approved --yes --reason "..."', run: (a, f) => require('./cli/human').action(a, f) },
|
|
287
289
|
prompt: { desc: 'Print a Codex/Claude MCP prompt for row or queue work', usage: 'securenow human prompt [row|notificationId:ip] [--limit 10]', run: (a, f) => require('./cli/human').prompt(a, f) },
|
|
288
290
|
work: { desc: 'List the queue and print the MCP runbook to work it deeply', usage: 'securenow human work [--limit 10]', run: (a, f) => require('./cli/human').work(a, f) },
|
|
291
|
+
promotions: { desc: 'List requests to promote a test-mode rule to prod (agents ask, a person decides)', usage: 'securenow human promotions [--status pending|approved|denied|all]', run: (a, f) => require('./cli/human').promotions(a, f) },
|
|
292
|
+
'approve-promotion': { desc: 'Approve a promotion request: the rule goes to prod', usage: 'securenow human approve-promotion <request-id> --yes [--note "..."]', run: (a, f) => require('./cli/human').approvePromotion(a, f) },
|
|
293
|
+
'deny-promotion': { desc: 'Deny a promotion request: the rule stays in test mode', usage: 'securenow human deny-promotion <request-id> --reason "..."', run: (a, f) => require('./cli/human').denyPromotion(a, f) },
|
|
289
294
|
},
|
|
290
295
|
defaultSub: 'list',
|
|
291
296
|
},
|
|
@@ -314,9 +319,11 @@ const COMMANDS = {
|
|
|
314
319
|
desc: 'Create (from English or SQL), list, show, update, duplicate, promote/demote, set-sql, validate, delete, test, run, explain, or tune alert rules',
|
|
315
320
|
usage: 'securenow alerts rules <list|from-text|create|show|update|duplicate|promote|demote|set-sql|validate|delete|test|run|runs|why|dry-run-query|tune-query|exclusions|versions|version|rollback> [options]\n'
|
|
316
321
|
+ ' securenow alerts rules from-text "<what to catch, in English>" --app <key> [--env <environment>] [--instant|--periodic] [--yes|--draft-only]\n'
|
|
322
|
+
+ ' securenow alerts rules change <id> "<what should change>" [--yes|--draft-only] rewrite a rule from English, checked, applied as a new version\n'
|
|
317
323
|
+ ' securenow alerts rules runs <id> [--limit 20] [--since 1h] what each run saw and why it did or didn\'t notify\n'
|
|
318
324
|
+ ' securenow alerts rules run <id> [--no-wait] run a periodic rule now\n'
|
|
319
|
-
+ ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address'
|
|
325
|
+
+ ' securenow alerts rules why <id> --ip <ip> [--since 1h] why the rule did or didn\'t alert on one address\n'
|
|
326
|
+
+ ' securenow alerts rules request-promotion <id> --reason "..." ask a person to promote a test-mode rule (for agents)',
|
|
320
327
|
flags: {
|
|
321
328
|
json: 'Output as JSON',
|
|
322
329
|
env: 'With from-text or create --conditions: only watch this environment (default: every environment)',
|
|
@@ -330,7 +337,7 @@ const COMMANDS = {
|
|
|
330
337
|
nlp: 'With create: plain-English intent stored on the query',
|
|
331
338
|
text: 'Alias for --nlp',
|
|
332
339
|
category: 'With create: query category (default: custom)',
|
|
333
|
-
severity: 'With create: critical | high | medium | low',
|
|
340
|
+
severity: 'With create/update: critical | high | medium | low',
|
|
334
341
|
schedule: 'With create: cron expression (default */15 * * * *)',
|
|
335
342
|
'throttle-minutes': 'With create: one alert per address every N minutes (default 15)',
|
|
336
343
|
'no-throttle': 'With create: disable throttling',
|
|
@@ -359,7 +366,8 @@ const COMMANDS = {
|
|
|
359
366
|
query: 'Alias for --sql',
|
|
360
367
|
file: 'Read SQL from a file',
|
|
361
368
|
'no-validate': 'With create/set-sql: skip the post-write dry-run + rollback',
|
|
362
|
-
reason: 'Audit reason for a write',
|
|
369
|
+
reason: 'Audit reason for a write. With request-promotion: why the rule is ready for prod',
|
|
370
|
+
evidence: 'With request-promotion: evidence text or @file (runs, notifications, false-positive checks)',
|
|
363
371
|
'apply-globally': 'Required for system query tuning',
|
|
364
372
|
'reactivate-paused': 'Reactivate paused system copies after tuning',
|
|
365
373
|
'expected-hash': 'Expected current SQL SHA-256 hash',
|
|
@@ -775,8 +783,14 @@ const COMMANDS = {
|
|
|
775
783
|
},
|
|
776
784
|
mcp: {
|
|
777
785
|
desc: 'Start the SecureNow MCP server over stdio',
|
|
778
|
-
usage: 'securenow mcp',
|
|
779
|
-
|
|
786
|
+
usage: 'securenow mcp [--profile all|investigator|detection|responder]',
|
|
787
|
+
flags: {
|
|
788
|
+
profile: 'Serve a subset of the tools: investigator (read-only investigation), detection (alert rules), responder (alerts, blocks, limits). Default: all',
|
|
789
|
+
},
|
|
790
|
+
run: (a, f) => {
|
|
791
|
+
if (typeof f.profile === 'string') process.env.SECURENOW_MCP_PROFILE = f.profile;
|
|
792
|
+
require('./mcp/server');
|
|
793
|
+
},
|
|
780
794
|
},
|
|
781
795
|
version: {
|
|
782
796
|
desc: 'Show CLI version',
|
package/esm-detect.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Is this an ESM app, so the preload must register the ESM loader hook?
|
|
5
|
+
*
|
|
6
|
+
* Node decides a file's module type from the nearest package.json above THE FILE, not from
|
|
7
|
+
* the working directory. Process managers often start apps from another directory (pm2 `cwd`,
|
|
8
|
+
* systemd WorkingDirectory, a Docker WORKDIR, a supervisor that runs each environment from its
|
|
9
|
+
* own folder). Checking only process.cwd() then missed the hook, and every library the app
|
|
10
|
+
* imports (Express, pg, ...) went uninstrumented: no http.route on any request span.
|
|
11
|
+
*
|
|
12
|
+
* ESM when any of these says so: the entry script is .mjs; the nearest package.json above the
|
|
13
|
+
* entry script (process.argv[1], or pm2's pm_exec_path) has "type": "module"; or the working
|
|
14
|
+
* directory's package.json does (the original check, kept for launchers whose argv[1] is a wrapper).
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
function readType(pkgPath) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^/, '')).type || 'commonjs';
|
|
22
|
+
} catch (_) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function nearestPackageJson(startDir) {
|
|
28
|
+
let dir = startDir;
|
|
29
|
+
for (let i = 0; i < 64; i++) {
|
|
30
|
+
const candidate = path.join(dir, 'package.json');
|
|
31
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
32
|
+
const parent = path.dirname(dir);
|
|
33
|
+
if (parent === dir) return null;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @returns {{ esm: boolean, reason: string|null }} */
|
|
40
|
+
function detectEsmApp({ argv = process.argv, cwd = process.cwd(), env = process.env } = {}) {
|
|
41
|
+
const entries = [argv && argv[1], env && env.pm_exec_path]
|
|
42
|
+
.filter((p) => typeof p === 'string' && p && !p.startsWith('-'))
|
|
43
|
+
.map((p) => path.resolve(cwd, p));
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (/\.mjs$/i.test(entry)) return { esm: true, reason: `${path.basename(entry)} is an .mjs file` };
|
|
47
|
+
if (/\.cjs$/i.test(entry)) continue;
|
|
48
|
+
const pkgPath = nearestPackageJson(path.dirname(entry));
|
|
49
|
+
if (pkgPath && readType(pkgPath) === 'module') return { esm: true, reason: `${pkgPath} has "type": "module"` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cwdPkg = path.resolve(cwd, 'package.json');
|
|
53
|
+
if (fs.existsSync(cwdPkg) && readType(cwdPkg) === 'module') return { esm: true, reason: `${cwdPkg} has "type": "module"` };
|
|
54
|
+
return { esm: false, reason: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { detectEsmApp, nearestPackageJson };
|
package/mcp/catalog.js
CHANGED
|
@@ -353,7 +353,7 @@ const TOOLS = [
|
|
|
353
353
|
{
|
|
354
354
|
name: 'securenow_traces_show',
|
|
355
355
|
title: 'Show Trace',
|
|
356
|
-
description: 'Show spans for a trace id scoped to one or more app keys.',
|
|
356
|
+
description: 'Show spans for a trace id scoped to one or more app keys. Fields identical on every span (service, environment and other resource attributes) come once under shared; empty fields are left out.',
|
|
357
357
|
scope: 'traces:read',
|
|
358
358
|
readOnly: true,
|
|
359
359
|
method: 'GET',
|
|
@@ -385,6 +385,25 @@ const TOOLS = [
|
|
|
385
385
|
limit: number('Maximum logs to return.', { minimum: 1, maximum: 500 }),
|
|
386
386
|
}, ['appKeys']),
|
|
387
387
|
},
|
|
388
|
+
{
|
|
389
|
+
name: 'securenow_logs_errors',
|
|
390
|
+
title: 'Summarize Error Logs',
|
|
391
|
+
description: 'What is failing: error (and fatal) log lines grouped by message, with ids, uuids, IPs and numbers normalized so repeats are one group. Each group: a sample line, how many times, first and last seen, environments, exception type and sample trace ids (pass one to securenow_logs_for_trace or securenow_traces_show). Far cheaper than listing raw logs. includeWarn adds warnings.',
|
|
392
|
+
scope: 'logs:read',
|
|
393
|
+
readOnly: true,
|
|
394
|
+
method: 'GET',
|
|
395
|
+
endpoint: '/logs/errors',
|
|
396
|
+
queryFields: ['appKeys', 'environment', 'since', 'from', 'to', 'includeWarn', 'limit'],
|
|
397
|
+
normalize: { appKeys: normalizeAppKeys },
|
|
398
|
+
inputSchema: objectSchema({
|
|
399
|
+
...appKeysInput,
|
|
400
|
+
...environmentInput,
|
|
401
|
+
since: string('Window: 30m, 1h (default), 6h, 2d; or pass from/to.'),
|
|
402
|
+
...timeRangeInput,
|
|
403
|
+
includeWarn: boolean('Also group warnings.'),
|
|
404
|
+
limit: number('Maximum groups (default 20).', { minimum: 1, maximum: 100 }),
|
|
405
|
+
}, ['appKeys']),
|
|
406
|
+
},
|
|
388
407
|
{
|
|
389
408
|
name: 'securenow_logs_for_trace',
|
|
390
409
|
title: 'Logs For Trace',
|
|
@@ -1381,13 +1400,13 @@ const TOOLS = [
|
|
|
1381
1400
|
{
|
|
1382
1401
|
name: 'securenow_alert_rule_draft',
|
|
1383
1402
|
title: 'Draft an Alert Rule From English',
|
|
1384
|
-
description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. Waits up to 120 seconds.',
|
|
1403
|
+
description: 'Turn a detection idea in plain English into an alert rule draft for one app, checked against that app\'s data. It reads the app\'s real event types, attribute keys and routes, then picks the kind: instant (conditions checked on every incoming request, alerts within seconds; one request is enough evidence) or periodic (SQL run on a schedule: counts, thresholds, distinct counts, ratios, sequences, baselines, security events, app log lines). Periodic SQL is run once against the data; instant conditions are replayed over the app\'s last 2000 requests, so check.matches shows what would alert today (false positives). Nothing is saved: create it with securenow_alert_rule_create, passing the draft fields. To CHANGE an existing rule, pass ruleId and describe the change in text: the draft is the whole rule rewritten (same kind, the rest kept), with `current` showing the rule as it is; apply it with securenow_alert_rule_update (sqlQuery + schedule, or instant {matchMode, conditions, environments}). Waits up to 120 seconds.',
|
|
1385
1404
|
scope: 'alerts:write',
|
|
1386
1405
|
readOnly: false,
|
|
1387
1406
|
confirm: true,
|
|
1388
1407
|
method: 'POST',
|
|
1389
1408
|
endpoint: '/alert-rules/draft',
|
|
1390
|
-
bodyFields: ['text', 'applicationKey', 'environment', 'kind'],
|
|
1409
|
+
bodyFields: ['text', 'applicationKey', 'environment', 'kind', 'ruleId'],
|
|
1391
1410
|
poll: {
|
|
1392
1411
|
tool: 'securenow_alert_rule_draft_status',
|
|
1393
1412
|
idField: 'draftId',
|
|
@@ -1400,8 +1419,9 @@ const TOOLS = [
|
|
|
1400
1419
|
applicationKey: string('App key the rule watches; the draft is checked against its data.'),
|
|
1401
1420
|
environment: string('Only watch this environment (e.g. production). Omit to watch every environment.'),
|
|
1402
1421
|
kind: string('Optional: instant or periodic. Omit to let the drafter choose.'),
|
|
1422
|
+
ruleId: string('Optional: an existing custom rule to change; text then describes the change. applicationKey defaults to one the rule watches.'),
|
|
1403
1423
|
...confirmSchema,
|
|
1404
|
-
}, ['text', '
|
|
1424
|
+
}, ['text', 'confirm', 'reason']),
|
|
1405
1425
|
},
|
|
1406
1426
|
{
|
|
1407
1427
|
name: 'securenow_alert_rule_draft_status',
|
|
@@ -1521,23 +1541,26 @@ const TOOLS = [
|
|
|
1521
1541
|
{
|
|
1522
1542
|
name: 'securenow_alert_rule_update',
|
|
1523
1543
|
title: 'Update Alert Rule',
|
|
1524
|
-
description: 'Update alert rule scope/status/schedule/throttle or record a review action. Write action; requires confirmation.',
|
|
1544
|
+
description: 'Update alert rule scope/status/severity/schedule/throttle, a custom rule\'s SQL (sqlQuery, checked like create: scoped, time-bounded, with an ip column) or instant conditions (instant: {matchMode, conditions, environments}; omitted fields such as the instant block stay as they are), or record a review action. reviewNote is kept as the version\'s reason. Write action; requires confirmation.',
|
|
1525
1545
|
scope: 'alerts:write',
|
|
1526
1546
|
readOnly: false,
|
|
1527
1547
|
confirm: true,
|
|
1528
1548
|
method: 'PUT',
|
|
1529
1549
|
endpoint: '/alert-rules/:id',
|
|
1530
1550
|
pathParams: ['id'],
|
|
1531
|
-
bodyFields: ['name', 'description', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
|
|
1551
|
+
bodyFields: ['name', 'description', 'severity', 'status', 'applicationsAll', 'applications', 'schedule', 'throttle', 'sqlQuery', 'instant', 'alertChannelIds', 'reviewAction', 'reviewNote', 'reviewNotificationId'],
|
|
1532
1552
|
inputSchema: objectSchema({
|
|
1533
1553
|
id: string('Alert rule id.'),
|
|
1534
1554
|
name: string('Rule name for custom rules.'),
|
|
1535
1555
|
description: string('Rule description for custom rules.'),
|
|
1556
|
+
severity: string('critical, high, medium or low (custom rules).'),
|
|
1536
1557
|
status: string('Active, Disabled, or Paused.'),
|
|
1537
1558
|
applicationsAll: boolean('Scope rule to all applications.'),
|
|
1538
1559
|
applications: arrayOfStrings('Application keys when applicationsAll is false.'),
|
|
1539
1560
|
schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
|
|
1540
1561
|
throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch {enabled, minutes}: one alert per address every N minutes.' },
|
|
1562
|
+
sqlQuery: string('Custom periodic rules: the replacement detection SELECT (e.g. from a securenow_alert_rule_draft with ruleId).'),
|
|
1563
|
+
instant: { type: 'object', additionalProperties: true, description: 'Custom instant rules: {matchMode, conditions: [{field, operator, value}], environments}. Fields left out keep their current values.' },
|
|
1541
1564
|
alertChannelIds: arrayOfStrings('Alert channel ids.'),
|
|
1542
1565
|
reviewAction: string('Rule review action: keep_active (keep it running and stop auto-pausing it) or tuned_version (the rule was tuned; resume it).'),
|
|
1543
1566
|
reviewNote: string('Review note.'),
|
|
@@ -1928,6 +1951,37 @@ const TOOLS = [
|
|
|
1928
1951
|
since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
|
|
1929
1952
|
}, ['id', 'ip']),
|
|
1930
1953
|
},
|
|
1954
|
+
{
|
|
1955
|
+
name: 'securenow_alert_rule_request_promotion',
|
|
1956
|
+
title: 'Ask a Person to Promote an Alert Rule',
|
|
1957
|
+
description: 'Ask a person to promote a test-mode rule to prod, with why it is ready and the evidence (runs, notifications it caught, false-positive checks). Agents create rules in test mode and cannot approve this themselves: a person approves on the rules page or with `securenow human approve-promotion`, which moves the rule to prod (its instant block, matching automations and the AI pipeline can then act on its alerts). One pending request per rule. Write action; requires confirmation.',
|
|
1958
|
+
scope: 'alerts:write',
|
|
1959
|
+
readOnly: false,
|
|
1960
|
+
confirm: true,
|
|
1961
|
+
method: 'POST',
|
|
1962
|
+
endpoint: '/alert-rules/:id/promotion-requests',
|
|
1963
|
+
pathParams: ['id'],
|
|
1964
|
+
bodyFields: ['reason', 'evidence'],
|
|
1965
|
+
inputSchema: objectSchema({
|
|
1966
|
+
id: string('Alert rule id (a custom rule in test mode).'),
|
|
1967
|
+
evidence: string('What shows it is ready: e.g. runs and their outcomes, notifications it raised on real attacks, how many false positives in how long.'),
|
|
1968
|
+
...confirmSchema,
|
|
1969
|
+
}, ['id', 'confirm', 'reason']),
|
|
1970
|
+
},
|
|
1971
|
+
{
|
|
1972
|
+
name: 'securenow_alert_rule_promotion_requests',
|
|
1973
|
+
title: 'List Alert Rule Promotion Requests',
|
|
1974
|
+
description: 'Requests to promote test-mode rules to prod and their status (pending, approved, denied), who asked and why, and who decided.',
|
|
1975
|
+
scope: 'alerts:read',
|
|
1976
|
+
readOnly: true,
|
|
1977
|
+
method: 'GET',
|
|
1978
|
+
endpoint: '/alert-rules/promotion-requests',
|
|
1979
|
+
queryFields: ['status', 'limit'],
|
|
1980
|
+
inputSchema: objectSchema({
|
|
1981
|
+
status: string('pending (default), approved, denied or all.'),
|
|
1982
|
+
limit: number('How many (default 50, max 100).', { minimum: 1, maximum: 100 }),
|
|
1983
|
+
}),
|
|
1984
|
+
},
|
|
1931
1985
|
{
|
|
1932
1986
|
name: 'securenow_alert_rule_exclusions_list',
|
|
1933
1987
|
title: 'List Alert Rule Exclusions',
|
|
@@ -2704,8 +2758,40 @@ function mcpToolDefinition(tool) {
|
|
|
2704
2758
|
};
|
|
2705
2759
|
}
|
|
2706
2760
|
|
|
2707
|
-
|
|
2708
|
-
|
|
2761
|
+
// Tool profiles: a smaller tool list for an agent that does one job (fewer tools, less context).
|
|
2762
|
+
// Every profile keeps the auth and app tools. `securenow mcp --profile <name>` (or
|
|
2763
|
+
// SECURENOW_MCP_PROFILE) picks one; the default is every tool.
|
|
2764
|
+
const PROFILE_BASE_TOOLS = new Set(['securenow_auth_status', 'securenow_apps_list', 'securenow_apps_info']);
|
|
2765
|
+
const TOOL_PROFILES = {
|
|
2766
|
+
investigator: {
|
|
2767
|
+
description: 'Read-only investigation: notifications, traces, logs, IP intelligence, forensics questions and chat, rule run history and "why didn\'t this alert".',
|
|
2768
|
+
includes: (tool) => tool.readOnly || /^securenow_forensics_(query|chat|chat_confirm)$/.test(tool.name),
|
|
2769
|
+
},
|
|
2770
|
+
detection: {
|
|
2771
|
+
description: 'Write and tune detections: draft, create, test, run and explain alert rules, with the traces, logs, notifications and forensics to check them against.',
|
|
2772
|
+
includes: (tool) => /^securenow_alert_rules?_/.test(tool.name)
|
|
2773
|
+
|| /^securenow_(traces|logs|forensics|ip)_/.test(tool.name)
|
|
2774
|
+
|| /^securenow_notifications_(list|get|batch_get)$/.test(tool.name),
|
|
2775
|
+
},
|
|
2776
|
+
responder: {
|
|
2777
|
+
description: 'Respond to alerts: notifications, the human action queue and alert reviews, blocks, allowlist, trusted IPs, rate limits, challenges, automations, firewall and IP intelligence.',
|
|
2778
|
+
includes: (tool) => /^securenow_(notifications|human|alert_review|blocklist|allowlist|trusted|rate_limit|challenge|automation|ip|firewall)_/.test(tool.name)
|
|
2779
|
+
|| ['securenow_alert_rule_get', 'securenow_alert_rule_runs', 'securenow_alert_rule_explain', 'securenow_traces_list', 'securenow_traces_show', 'securenow_logs_for_trace'].includes(tool.name),
|
|
2780
|
+
},
|
|
2781
|
+
};
|
|
2782
|
+
|
|
2783
|
+
function toolsForProfile(profile) {
|
|
2784
|
+
const name = String(profile || 'all').trim().toLowerCase();
|
|
2785
|
+
if (name === 'all') return TOOLS;
|
|
2786
|
+
const selected = TOOL_PROFILES[name];
|
|
2787
|
+
if (!selected) {
|
|
2788
|
+
throw new Error(`Unknown MCP profile "${profile}". Use one of: all, ${Object.keys(TOOL_PROFILES).join(', ')}`);
|
|
2789
|
+
}
|
|
2790
|
+
return TOOLS.filter((tool) => PROFILE_BASE_TOOLS.has(tool.name) || selected.includes(tool));
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function listTools(profile) {
|
|
2794
|
+
return toolsForProfile(profile).map(mcpToolDefinition);
|
|
2709
2795
|
}
|
|
2710
2796
|
|
|
2711
2797
|
function listResources() {
|
|
@@ -2945,7 +3031,29 @@ function summarizeRateLimit(r) {
|
|
|
2945
3031
|
return { ...r, metadata: undefined, effectiveStatus: r.status === 'active' && expired ? 'expired' : r.status };
|
|
2946
3032
|
}
|
|
2947
3033
|
|
|
3034
|
+
// One trace's spans repeat the same resource fields (service, environment, SDK...) on every span:
|
|
3035
|
+
// keep those once under `shared` and drop empty fields, so a trace costs far less context.
|
|
3036
|
+
function compactSpans(res) {
|
|
3037
|
+
if (!Array.isArray(res?.spans) || res.spans.length < 2) return res;
|
|
3038
|
+
const spans = res.spans;
|
|
3039
|
+
const isEmpty = (v) => v == null || v === '' || (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0)
|
|
3040
|
+
|| (Array.isArray(v) && v.length === 0);
|
|
3041
|
+
const shared = {};
|
|
3042
|
+
for (const key of new Set(spans.flatMap((span) => Object.keys(span || {})))) {
|
|
3043
|
+
const first = JSON.stringify(spans[0]?.[key]);
|
|
3044
|
+
if (spans.every((span) => JSON.stringify(span?.[key]) === first)) shared[key] = spans[0]?.[key];
|
|
3045
|
+
}
|
|
3046
|
+
const keep = (span) => Object.fromEntries(Object.entries(span || {}).filter(([key, value]) => !(key in shared) && !isEmpty(value)));
|
|
3047
|
+
return {
|
|
3048
|
+
...res,
|
|
3049
|
+
_compact: 'Fields identical on every span are listed once under shared; empty fields are left out.',
|
|
3050
|
+
shared: Object.fromEntries(Object.entries(shared).filter(([, value]) => !isEmpty(value))),
|
|
3051
|
+
spans: spans.map(keep),
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2948
3055
|
const SUMMARIZERS = {
|
|
3056
|
+
securenow_traces_show: compactSpans,
|
|
2949
3057
|
securenow_notifications_list: (res) => (Array.isArray(res?.notifications) ? { ...res, notifications: res.notifications.map(summarizeNotification) } : res),
|
|
2950
3058
|
securenow_alert_rules_list: (res) => {
|
|
2951
3059
|
if (!Array.isArray(res?.alertRules)) return res;
|
|
@@ -3000,6 +3108,8 @@ module.exports = {
|
|
|
3000
3108
|
getTool,
|
|
3001
3109
|
mcpToolDefinition,
|
|
3002
3110
|
listTools,
|
|
3111
|
+
toolsForProfile,
|
|
3112
|
+
TOOL_PROFILES,
|
|
3003
3113
|
listResources,
|
|
3004
3114
|
listPrompts,
|
|
3005
3115
|
promptMessages,
|
package/mcp/server.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
RESOURCES,
|
|
10
10
|
getTool,
|
|
11
11
|
listTools,
|
|
12
|
+
toolsForProfile,
|
|
12
13
|
listResources,
|
|
13
14
|
listPrompts,
|
|
14
15
|
promptMessages,
|
|
@@ -26,6 +27,17 @@ const {
|
|
|
26
27
|
const SERVER_NAME = 'securenow';
|
|
27
28
|
const PROTOCOL_VERSION = '2025-06-18';
|
|
28
29
|
|
|
30
|
+
// `securenow mcp --profile investigator|detection|responder` (or SECURENOW_MCP_PROFILE) serves a
|
|
31
|
+
// subset of the tools; the default is every tool.
|
|
32
|
+
const PROFILE = String(process.env.SECURENOW_MCP_PROFILE || 'all').trim().toLowerCase() || 'all';
|
|
33
|
+
let PROFILE_TOOL_NAMES;
|
|
34
|
+
try {
|
|
35
|
+
PROFILE_TOOL_NAMES = new Set(toolsForProfile(PROFILE).map((tool) => tool.name));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stderr.write(`[securenow mcp] ${err.message}\n`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
function write(message) {
|
|
30
42
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
31
43
|
}
|
|
@@ -201,7 +213,7 @@ function readResource(uri) {
|
|
|
201
213
|
|
|
202
214
|
const text = resourceContent(resource, {
|
|
203
215
|
projectConfig: () => jsonText(localAuthStatus()),
|
|
204
|
-
toolsCatalog: () => jsonText(listTools().map((tool) => ({
|
|
216
|
+
toolsCatalog: () => jsonText(listTools(PROFILE).map((tool) => ({
|
|
205
217
|
name: tool.name,
|
|
206
218
|
description: tool.description,
|
|
207
219
|
readOnly: tool.annotations?.readOnlyHint === true,
|
|
@@ -240,7 +252,7 @@ async function handleRequest(message) {
|
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
if (method === 'ping') return ok(id, {});
|
|
243
|
-
if (method === 'tools/list') return ok(id, { tools: listTools() });
|
|
255
|
+
if (method === 'tools/list') return ok(id, { tools: listTools(PROFILE) });
|
|
244
256
|
if (method === 'resources/list') return ok(id, { resources: listResources() });
|
|
245
257
|
if (method === 'prompts/list') return ok(id, { prompts: listPrompts() });
|
|
246
258
|
|
|
@@ -262,6 +274,9 @@ async function handleRequest(message) {
|
|
|
262
274
|
const args = params.arguments || {};
|
|
263
275
|
const tool = getTool(name);
|
|
264
276
|
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
277
|
+
if (!PROFILE_TOOL_NAMES.has(name)) {
|
|
278
|
+
throw new Error(`Tool ${name} is not in the "${PROFILE}" MCP profile. Restart the server without --profile (or with another profile) to use it.`);
|
|
279
|
+
}
|
|
265
280
|
|
|
266
281
|
const result = await callApiTool(tool, args);
|
|
267
282
|
return ok(id, asToolResult({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securenow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.18.0",
|
|
4
4
|
"mcpName": "ai.securenow/securenow",
|
|
5
5
|
"description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"securenow-mcp": "mcp/server.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
|
|
14
|
+
"test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"opentelemetry",
|
|
@@ -160,6 +160,7 @@
|
|
|
160
160
|
"challenge.js",
|
|
161
161
|
"otel-defaults.js",
|
|
162
162
|
"internal-thread.js",
|
|
163
|
+
"esm-detect.js",
|
|
163
164
|
"legacy-semconv.js",
|
|
164
165
|
"otp-redaction.js",
|
|
165
166
|
"header-redaction.js",
|
package/register.js
CHANGED
|
@@ -14,13 +14,9 @@ if (require('./sdk-disabled').sdkDisabled('register')) return;
|
|
|
14
14
|
// 1. Auto-register the ESM loader hook so customers never need --import
|
|
15
15
|
(() => {
|
|
16
16
|
try {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (!fs.existsSync(pkgPath)) return;
|
|
21
|
-
|
|
22
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
23
|
-
if (pkg.type !== 'module') return;
|
|
17
|
+
// ESM is decided by the entry script's package.json, not only the working directory's
|
|
18
|
+
// (process managers often start apps from another directory). See esm-detect.js.
|
|
19
|
+
if (!require('./esm-detect').detectEsmApp().esm) return;
|
|
24
20
|
|
|
25
21
|
// Already registered via --import?
|
|
26
22
|
const execArgv = process.execArgv.join(' ');
|
package/tracing.js
CHANGED
|
@@ -338,19 +338,13 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
|
|
|
338
338
|
// (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
|
|
339
339
|
(() => {
|
|
340
340
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
|
|
349
|
-
const hasModuleRegister = typeof require('node:module').register === 'function';
|
|
350
|
-
if (!hasCliHook && !hasModuleRegister) {
|
|
351
|
-
console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
|
|
352
|
-
console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
|
|
353
|
-
}
|
|
341
|
+
if (require('./esm-detect').detectEsmApp().esm) {
|
|
342
|
+
const execArgv = process.execArgv.join(' ');
|
|
343
|
+
const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
|
|
344
|
+
const hasModuleRegister = typeof require('node:module').register === 'function';
|
|
345
|
+
if (!hasCliHook && !hasModuleRegister) {
|
|
346
|
+
console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
|
|
347
|
+
console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
|
|
354
348
|
}
|
|
355
349
|
}
|
|
356
350
|
} catch (_) {}
|