securenow 8.16.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/NPM_README.md +8 -1
- package/SKILL-CLI.md +33 -2
- package/cli/client.js +4 -1
- package/cli/human.js +63 -0
- package/cli/monitor.js +43 -0
- package/cli/security.js +357 -16
- package/cli.js +33 -11
- package/esm-detect.js +57 -0
- package/mcp/catalog.js +173 -15
- package/mcp/server.js +17 -2
- package/package.json +3 -2
- package/register.js +3 -7
- package/tracing.js +7 -13
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',
|
|
@@ -1419,13 +1439,13 @@ const TOOLS = [
|
|
|
1419
1439
|
{
|
|
1420
1440
|
name: 'securenow_alert_rule_create',
|
|
1421
1441
|
title: 'Create Alert Rule (Test Mode)',
|
|
1422
|
-
description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (
|
|
1442
|
+
description: 'Create an alert rule. It always starts in test mode: it alerts, but nothing blocks, rate-limits or acts on its alerts until a person promotes it. Instant rule: executionMode "instant" plus instant {matchMode: any|all, conditions: [{field: path|method|user_agent|status_code|request_body|request_headers|client_ip|host, operator, value}]}. Periodic rule: sqlQuery plus schedule {cronExpression}; the SQL must scope every table with the __USER_APP_KEYS__ placeholder, bound time on every table read, and return a column named ip (the API refuses SQL with no ip column, since it would never alert, unless allowNoIp). A periodic rule runs once right away (firstRunQueued); see securenow_alert_rule_runs. Easiest path: securenow_alert_rule_draft, then pass its draft fields here. Write action; requires confirmation.',
|
|
1423
1443
|
scope: 'alerts:write',
|
|
1424
1444
|
readOnly: false,
|
|
1425
1445
|
confirm: true,
|
|
1426
1446
|
method: 'POST',
|
|
1427
1447
|
endpoint: '/alert-rules',
|
|
1428
|
-
bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle'],
|
|
1448
|
+
bodyFields: ['name', 'description', 'nlpQuery', 'category', 'severity', 'applications', 'applicationsAll', 'executionMode', 'sqlQuery', 'schedule', 'instant', 'throttle', 'allowNoIp'],
|
|
1429
1449
|
inputSchema: objectSchema({
|
|
1430
1450
|
name: string('Rule name.'),
|
|
1431
1451
|
description: string('What it catches, in a sentence.'),
|
|
@@ -1438,7 +1458,8 @@ const TOOLS = [
|
|
|
1438
1458
|
sqlQuery: string('Periodic rules: the detection SELECT.'),
|
|
1439
1459
|
schedule: { type: 'object', additionalProperties: true, description: 'Periodic rules: {cronExpression: "* * * * *", description: "Every minute"}. Default every 15 minutes.' },
|
|
1440
1460
|
instant: { type: 'object', additionalProperties: true, description: 'Instant rules: {matchMode: "any" | "all", conditions: [{field, operator, value}], environments: ["staging"] (optional; omit to watch every environment)}.' },
|
|
1441
|
-
throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per
|
|
1461
|
+
throttle: { type: 'object', additionalProperties: true, description: 'Optional {enabled: true, minutes: 15}: one alert per address every N minutes (other addresses still alert).' },
|
|
1462
|
+
allowNoIp: boolean('Periodic rules: save SQL that returns no ip column anyway (it will never notify). Default false.'),
|
|
1442
1463
|
...confirmSchema,
|
|
1443
1464
|
}, ['name', 'confirm', 'reason']),
|
|
1444
1465
|
},
|
|
@@ -1520,25 +1541,28 @@ const TOOLS = [
|
|
|
1520
1541
|
{
|
|
1521
1542
|
name: 'securenow_alert_rule_update',
|
|
1522
1543
|
title: 'Update Alert Rule',
|
|
1523
|
-
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.',
|
|
1524
1545
|
scope: 'alerts:write',
|
|
1525
1546
|
readOnly: false,
|
|
1526
1547
|
confirm: true,
|
|
1527
1548
|
method: 'PUT',
|
|
1528
1549
|
endpoint: '/alert-rules/:id',
|
|
1529
1550
|
pathParams: ['id'],
|
|
1530
|
-
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'],
|
|
1531
1552
|
inputSchema: objectSchema({
|
|
1532
1553
|
id: string('Alert rule id.'),
|
|
1533
1554
|
name: string('Rule name for custom rules.'),
|
|
1534
1555
|
description: string('Rule description for custom rules.'),
|
|
1556
|
+
severity: string('critical, high, medium or low (custom rules).'),
|
|
1535
1557
|
status: string('Active, Disabled, or Paused.'),
|
|
1536
1558
|
applicationsAll: boolean('Scope rule to all applications.'),
|
|
1537
1559
|
applications: arrayOfStrings('Application keys when applicationsAll is false.'),
|
|
1538
1560
|
schedule: { type: 'object', additionalProperties: true, description: 'Schedule patch.' },
|
|
1539
|
-
throttle: { type: 'object', additionalProperties: true, description: 'Throttle patch.' },
|
|
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.' },
|
|
1540
1564
|
alertChannelIds: arrayOfStrings('Alert channel ids.'),
|
|
1541
|
-
reviewAction: string('Rule review action
|
|
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).'),
|
|
1542
1566
|
reviewNote: string('Review note.'),
|
|
1543
1567
|
reviewNotificationId: string('Notification id tied to this review.'),
|
|
1544
1568
|
...confirmSchema,
|
|
@@ -1832,7 +1856,7 @@ const TOOLS = [
|
|
|
1832
1856
|
{
|
|
1833
1857
|
name: 'securenow_alert_rule_test',
|
|
1834
1858
|
title: 'Test Alert Rule',
|
|
1835
|
-
description: 'Start an alert rule test.
|
|
1859
|
+
description: 'Start an alert rule test. By default (mode "dry_run") it runs the rule\'s query once on the app\'s real data and inserts nothing. Mode "live" first inserts 6 synthetic attack requests into the app\'s telemetry; only ask for it explicitly.',
|
|
1836
1860
|
scope: 'alerts:write',
|
|
1837
1861
|
readOnly: false,
|
|
1838
1862
|
confirm: true,
|
|
@@ -1843,7 +1867,7 @@ const TOOLS = [
|
|
|
1843
1867
|
inputSchema: objectSchema({
|
|
1844
1868
|
id: string('Alert rule id.'),
|
|
1845
1869
|
applicationKey: string('Application key to test.'),
|
|
1846
|
-
mode: string('dry_run (read-only
|
|
1870
|
+
mode: string('dry_run (default, read-only) or live (inserts 6 synthetic attack requests).'),
|
|
1847
1871
|
...confirmSchema,
|
|
1848
1872
|
}, ['id', 'confirm', 'reason']),
|
|
1849
1873
|
},
|
|
@@ -1880,6 +1904,84 @@ const TOOLS = [
|
|
|
1880
1904
|
testId: string('Alert rule test id.'),
|
|
1881
1905
|
}, ['id', 'testId']),
|
|
1882
1906
|
},
|
|
1907
|
+
{
|
|
1908
|
+
name: 'securenow_alert_rule_runs',
|
|
1909
|
+
title: 'List Alert Rule Runs',
|
|
1910
|
+
description: 'A rule\'s recent runs, newest first: when it ran (schedule, manual, create or instant trigger), what it returned (rows, addresses), and the outcome: notified, no_rows, throttled (address alerted on within the throttle window), suppressed_blocked (already blocked), suppressed_trusted, no_ip (rows without an address), storm, auto_resolved (dismissed by an exclusion, trusted IP or verified bot), skipped or error. suppressedIps says which addresses were left out and why. Also nextRunAt for periodic rules. Quiet runs are kept 2 days, the rest 14.',
|
|
1911
|
+
scope: 'alerts:read',
|
|
1912
|
+
readOnly: true,
|
|
1913
|
+
method: 'GET',
|
|
1914
|
+
endpoint: '/alert-rules/:id/runs',
|
|
1915
|
+
pathParams: ['id'],
|
|
1916
|
+
queryFields: ['limit', 'since'],
|
|
1917
|
+
inputSchema: objectSchema({
|
|
1918
|
+
id: string('Alert rule id.'),
|
|
1919
|
+
limit: number('How many runs (default 50, max 200).', { minimum: 1, maximum: 200 }),
|
|
1920
|
+
since: string('Only runs since then: 30m, 1h, 2d or an ISO date.'),
|
|
1921
|
+
}, ['id']),
|
|
1922
|
+
},
|
|
1923
|
+
{
|
|
1924
|
+
name: 'securenow_alert_rule_run_now',
|
|
1925
|
+
title: 'Run Alert Rule Now',
|
|
1926
|
+
description: 'Run a periodic (scheduled) rule once now, outside its schedule. It can notify like a scheduled run (test-mode rules alert only). The run appears in securenow_alert_rule_runs within seconds with trigger "manual". Instant rules have nothing to run (they check every request); use securenow_alert_rule_explain instead. Write action; requires confirmation.',
|
|
1927
|
+
scope: 'alerts:write',
|
|
1928
|
+
readOnly: false,
|
|
1929
|
+
confirm: true,
|
|
1930
|
+
method: 'POST',
|
|
1931
|
+
endpoint: '/alert-rules/:id/run',
|
|
1932
|
+
pathParams: ['id'],
|
|
1933
|
+
inputSchema: objectSchema({
|
|
1934
|
+
id: string('Alert rule id (an active periodic rule).'),
|
|
1935
|
+
...confirmSchema,
|
|
1936
|
+
}, ['id', 'confirm', 'reason']),
|
|
1937
|
+
},
|
|
1938
|
+
{
|
|
1939
|
+
name: 'securenow_alert_rule_explain',
|
|
1940
|
+
title: 'Explain Why a Rule Did or Didn\'t Alert',
|
|
1941
|
+
description: 'Why did (or didn\'t) this rule alert on this address? Checks, in a time window: the rule\'s status, mode, apps and environments; its runs and what each did with the address (notified, throttled, already blocked, trusted, dismissed); the per-address throttle; blocks on the address and the rule that made them; trusted and allowlist entries; notifications; and the address\'s requests, replayed through an instant rule\'s conditions. Returns ordered findings and a one-line verdict, e.g. "Detected at 15:06:31 but not notified: 203.0.113.50 was already blocked by instant rule X".',
|
|
1942
|
+
scope: 'alerts:read',
|
|
1943
|
+
readOnly: true,
|
|
1944
|
+
method: 'GET',
|
|
1945
|
+
endpoint: '/alert-rules/:id/explain',
|
|
1946
|
+
pathParams: ['id'],
|
|
1947
|
+
queryFields: ['ip', 'since'],
|
|
1948
|
+
inputSchema: objectSchema({
|
|
1949
|
+
id: string('Alert rule id.'),
|
|
1950
|
+
ip: string('The address to explain (IPv4 or IPv6).'),
|
|
1951
|
+
since: string('Window start: 30m, 1h (default), 2d or an ISO date; at most 7 days.'),
|
|
1952
|
+
}, ['id', 'ip']),
|
|
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
|
+
},
|
|
1883
1985
|
{
|
|
1884
1986
|
name: 'securenow_alert_rule_exclusions_list',
|
|
1885
1987
|
title: 'List Alert Rule Exclusions',
|
|
@@ -2656,8 +2758,40 @@ function mcpToolDefinition(tool) {
|
|
|
2656
2758
|
};
|
|
2657
2759
|
}
|
|
2658
2760
|
|
|
2659
|
-
|
|
2660
|
-
|
|
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);
|
|
2661
2795
|
}
|
|
2662
2796
|
|
|
2663
2797
|
function listResources() {
|
|
@@ -2897,7 +3031,29 @@ function summarizeRateLimit(r) {
|
|
|
2897
3031
|
return { ...r, metadata: undefined, effectiveStatus: r.status === 'active' && expired ? 'expired' : r.status };
|
|
2898
3032
|
}
|
|
2899
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
|
+
|
|
2900
3055
|
const SUMMARIZERS = {
|
|
3056
|
+
securenow_traces_show: compactSpans,
|
|
2901
3057
|
securenow_notifications_list: (res) => (Array.isArray(res?.notifications) ? { ...res, notifications: res.notifications.map(summarizeNotification) } : res),
|
|
2902
3058
|
securenow_alert_rules_list: (res) => {
|
|
2903
3059
|
if (!Array.isArray(res?.alertRules)) return res;
|
|
@@ -2952,6 +3108,8 @@ module.exports = {
|
|
|
2952
3108
|
getTool,
|
|
2953
3109
|
mcpToolDefinition,
|
|
2954
3110
|
listTools,
|
|
3111
|
+
toolsForProfile,
|
|
3112
|
+
TOOL_PROFILES,
|
|
2955
3113
|
listResources,
|
|
2956
3114
|
listPrompts,
|
|
2957
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 (_) {}
|