securenow 8.12.3 → 8.13.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 +2 -0
- package/README.md +3 -1
- package/SKILL-API.md +3 -1
- package/SKILL-CLI.md +2 -0
- package/cli/threatModel.js +7 -5
- package/crash-capture.js +125 -0
- package/mcp/catalog.js +316 -25
- package/mcp/server.js +21 -13
- package/nextjs.js +9 -13
- package/package.json +4 -2
- package/tracing.js +19 -25
package/NPM_README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow - Complete OpenTelemetry Observability for Node.js
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
OpenTelemetry instrumentation library for Node.js, Next.js, and Nuxt applications. Send distributed traces and logs to any OTLP-compatible observability backend.
|
|
4
6
|
|
|
5
7
|
**Features:**
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
**Production security for AI-native development teams.**
|
|
4
6
|
|
|
5
7
|
Your coding agent knows your code. SecureNow shows it what's happening in production. One Node.js package, CLI, and MCP server connect Claude Code, Codex, and Cursor to your live application security — attacks, routes, users, traces, IP intelligence, and response controls.
|
|
@@ -94,7 +96,7 @@ export default defineNuxtConfig({
|
|
|
94
96
|
});
|
|
95
97
|
```
|
|
96
98
|
|
|
97
|
-
**What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
|
|
99
|
+
**What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, crashes (uncaught exceptions and unhandled rejections) as `FATAL` logs with their stack, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
|
|
98
100
|
|
|
99
101
|
**Production** — same file structure, no env vars. Generate a tokenless runtime credentials file and mount it as a deployment secret:
|
|
100
102
|
|
package/SKILL-API.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow SDK — Agent Skill
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
Instrument any Node.js application with OpenTelemetry tracing, structured logging, request body capture, and a multi-layer IP firewall. Supports Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt 3, Vite (browser), and raw `http.createServer` — with zero code changes for most setups.
|
|
4
6
|
|
|
5
7
|
**CLI parity:** every capability exposed below (redaction, CIDR matching, log/span emission, firewall preload, config inspection) has an equivalent `securenow` CLI command. See [SKILL-CLI.md](./SKILL-CLI.md) for the terminal surface.
|
|
@@ -670,7 +672,7 @@ Make sure an API key is resolvable by running `npx securenow app connect` first.
|
|
|
670
672
|
### Instrument a Docker Container
|
|
671
673
|
|
|
672
674
|
```dockerfile
|
|
673
|
-
FROM node:
|
|
675
|
+
FROM node:24.22.1-slim
|
|
674
676
|
WORKDIR /app
|
|
675
677
|
COPY package*.json ./
|
|
676
678
|
RUN npm ci --omit=dev
|
package/SKILL-CLI.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# SecureNow CLI — Agent Skill
|
|
2
2
|
|
|
3
|
+
> **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
|
|
4
|
+
|
|
3
5
|
Use the `securenow` CLI to perform security DevOps from the terminal: manage apps, investigate threats, control the firewall, analyze traces, handle false positives, and run instrumented Node.js processes. Every command supports `--json` for machine-readable output.
|
|
4
6
|
|
|
5
7
|
## Installation
|
package/cli/threatModel.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// `securenow threat-model <category>` — print the copy-paste threat-modeling prompt for a category,
|
|
4
4
|
// fetched from the published set at securenow.ai (the same prompt shown on securenow.ai/threat-modeling).
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// Save it and have your coding agent follow the file (prompts run to ~136 KB, over
|
|
6
|
+
// Linux's 128 KB single-argument limit, so `claude -p "$(...)"` fails):
|
|
7
|
+
// npx securenow threat-model authentication --out threat-prompt.md
|
|
8
|
+
// or pipe it on stdin: npx securenow threat-model authentication | claude -p
|
|
8
9
|
const fs = require('fs');
|
|
9
10
|
const ui = require('./ui');
|
|
10
11
|
|
|
@@ -34,8 +35,9 @@ function listCategories() {
|
|
|
34
35
|
console.log(' Copy-paste a prompt into your coding agent, or run it with the SecureNow CLI.\n');
|
|
35
36
|
for (const slug of CATEGORIES) console.log(' ' + slug);
|
|
36
37
|
console.log('\n Print one: securenow threat-model authentication');
|
|
37
|
-
console.log('
|
|
38
|
-
console.log('
|
|
38
|
+
console.log(' Save it: securenow threat-model authentication --out threat-prompt.md');
|
|
39
|
+
console.log(' then tell your agent: "Follow threat-prompt.md"');
|
|
40
|
+
console.log(' Pipe it: securenow threat-model authentication | claude -p');
|
|
39
41
|
console.log(' On the web: securenow.ai/threat-modeling');
|
|
40
42
|
}
|
|
41
43
|
|
package/crash-capture.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Crash capture: an uncaught exception or an unhandled promise rejection is
|
|
4
|
+
// recorded as a FATAL log record (with exception.type/message/stacktrace), the
|
|
5
|
+
// log pipeline is flushed, and the process then exits the way Node would have
|
|
6
|
+
// (stack on stderr, exit code 1). When the app registered its own handler, the
|
|
7
|
+
// error is recorded as ERROR and the app's handler decides what happens next.
|
|
8
|
+
// Transient OTLP exporter errors are still swallowed so telemetry outages never
|
|
9
|
+
// crash the host app.
|
|
10
|
+
|
|
11
|
+
const util = require('util');
|
|
12
|
+
|
|
13
|
+
const FLUSH_TIMEOUT_MS = 2000;
|
|
14
|
+
|
|
15
|
+
function unhandledRejectionMode() {
|
|
16
|
+
const args = [...process.execArgv, ...String(process.env.NODE_OPTIONS || '').split(/\s+/)];
|
|
17
|
+
for (const a of args) {
|
|
18
|
+
const m = /^--unhandled-rejections=(\S+)$/.exec(a);
|
|
19
|
+
if (m) return m[1];
|
|
20
|
+
}
|
|
21
|
+
return 'throw';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function describeError(err) {
|
|
25
|
+
if (err instanceof Error) {
|
|
26
|
+
const type = err.name || (err.constructor && err.constructor.name) || 'Error';
|
|
27
|
+
const message = err.message || '';
|
|
28
|
+
return { type, message, stack: err.stack || `${type}: ${message}` };
|
|
29
|
+
}
|
|
30
|
+
const text = util.inspect(err, { depth: 3, breakLength: Infinity });
|
|
31
|
+
const type = err && typeof err === 'object' ? (err.constructor && err.constructor.name) || 'Object' : typeof err;
|
|
32
|
+
return { type, message: text, stack: text };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installCrashCapture({ getLogger, flush, isSdkError, onSdkError, write } = {}) {
|
|
36
|
+
const writeErr = write || ((s) => { try { process.stderr.write(s); } catch (_) {} });
|
|
37
|
+
let crashing = false;
|
|
38
|
+
|
|
39
|
+
function record(err, origin, fatal) {
|
|
40
|
+
let logger = null;
|
|
41
|
+
try { logger = getLogger ? getLogger() : null; } catch (_) {}
|
|
42
|
+
if (!logger) return;
|
|
43
|
+
const d = describeError(err);
|
|
44
|
+
const what = origin === 'unhandledRejection' ? 'Unhandled promise rejection' : 'Uncaught exception';
|
|
45
|
+
try {
|
|
46
|
+
logger.emit({
|
|
47
|
+
severityNumber: fatal ? 21 : 17,
|
|
48
|
+
severityText: fatal ? 'FATAL' : 'ERROR',
|
|
49
|
+
body: `${what}${fatal ? ', process crashed' : ' (handled by the app)'}: ${d.stack}`,
|
|
50
|
+
attributes: {
|
|
51
|
+
'log.source': 'crash',
|
|
52
|
+
'exception.type': d.type,
|
|
53
|
+
'exception.message': d.message.slice(0, 4000),
|
|
54
|
+
'exception.stacktrace': d.stack.slice(0, 16000),
|
|
55
|
+
'exception.origin': origin || 'uncaughtException',
|
|
56
|
+
'exception.escaped': Boolean(fatal),
|
|
57
|
+
'process.pid': process.pid,
|
|
58
|
+
'process.uptime_s': Math.round(process.uptime()),
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function flushQuietly() {
|
|
65
|
+
try {
|
|
66
|
+
return Promise.resolve(flush ? flush() : undefined).catch(() => {});
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return Promise.resolve();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Print like Node's default handler, give the exporter a bounded window to
|
|
73
|
+
// ship the crash record, then exit with code 1.
|
|
74
|
+
function crash(err, origin) {
|
|
75
|
+
if (crashing) process.exit(1);
|
|
76
|
+
crashing = true;
|
|
77
|
+
const d = describeError(err);
|
|
78
|
+
if (origin === 'unhandledRejection' && !(err instanceof Error)) {
|
|
79
|
+
writeErr(`[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "${d.message}".]\n`);
|
|
80
|
+
} else {
|
|
81
|
+
writeErr(`${d.stack}\n`);
|
|
82
|
+
}
|
|
83
|
+
writeErr(`\nNode.js ${process.version}\n`);
|
|
84
|
+
const hardStop = setTimeout(() => process.exit(1), FLUSH_TIMEOUT_MS + 250);
|
|
85
|
+
Promise.race([flushQuietly(), new Promise((r) => setTimeout(r, FLUSH_TIMEOUT_MS))])
|
|
86
|
+
.then(() => { clearTimeout(hardStop); process.exit(1); });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.on('uncaughtException', function securenowUncaughtException(err, origin) {
|
|
90
|
+
if (isSdkError && isSdkError(err)) {
|
|
91
|
+
if (onSdkError) onSdkError('error', err, origin);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const appHandles = process.listenerCount('uncaughtException') > 1;
|
|
95
|
+
record(err, origin, !appHandles);
|
|
96
|
+
if (appHandles) {
|
|
97
|
+
flushQuietly();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
crash(err, origin);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
process.on('unhandledRejection', function securenowUnhandledRejection(reason) {
|
|
104
|
+
if (isSdkError && isSdkError(reason)) {
|
|
105
|
+
if (onSdkError) onSdkError('rejection', reason, 'unhandledRejection');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const appHandles = process.listenerCount('unhandledRejection') > 1;
|
|
109
|
+
const mode = unhandledRejectionMode();
|
|
110
|
+
if (appHandles || mode === 'warn' || mode === 'none' || mode === 'warn-with-error-code') {
|
|
111
|
+
record(reason, 'unhandledRejection', false);
|
|
112
|
+
if (!appHandles && mode === 'warn-with-error-code') {
|
|
113
|
+
writeErr(`(node:${process.pid}) UnhandledPromiseRejectionWarning: ${describeError(reason).stack}\n`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
}
|
|
116
|
+
flushQuietly();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// Default (--unhandled-rejections=throw): an unhandled rejection crashes.
|
|
120
|
+
record(reason, 'unhandledRejection', true);
|
|
121
|
+
crash(reason, 'unhandledRejection');
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { installCrashCapture, describeError, unhandledRejectionMode };
|
package/mcp/catalog.js
CHANGED
|
@@ -190,7 +190,7 @@ const TOOLS = [
|
|
|
190
190
|
{
|
|
191
191
|
name: 'securenow_auth_status',
|
|
192
192
|
title: 'SecureNow Auth Status',
|
|
193
|
-
description: 'Show
|
|
193
|
+
description: 'Show who is signed in and how: over the hosted connector, the OAuth client, access level (investigate or investigate_and_respond) and the organization with the role of the person signed in; locally, the credential source, selected app and masked runtime API key.',
|
|
194
194
|
scope: null,
|
|
195
195
|
localOnly: true,
|
|
196
196
|
readOnly: true,
|
|
@@ -209,7 +209,7 @@ const TOOLS = [
|
|
|
209
209
|
{
|
|
210
210
|
name: 'securenow_apps_create',
|
|
211
211
|
title: 'Create Application',
|
|
212
|
-
description: 'Create a SecureNow application. Write action; requires confirmation. Each app is billed $99/month on subscribed accounts (free during the 14-day trial).
|
|
212
|
+
description: 'Create a SecureNow application. Write action; requires confirmation. Each app is billed $99/month on subscribed accounts (free during the 14-day trial). Creating one changes the bill.',
|
|
213
213
|
scope: 'applications:write',
|
|
214
214
|
readOnly: false,
|
|
215
215
|
confirm: true,
|
|
@@ -379,7 +379,7 @@ const TOOLS = [
|
|
|
379
379
|
...appKeysInput,
|
|
380
380
|
...environmentInput,
|
|
381
381
|
...timeRangeInput,
|
|
382
|
-
severity: string('Optional severity filter.'),
|
|
382
|
+
severity: string('Optional severity filter: debug, info, warn, error or fatal (case-insensitive). error also returns fatal crash records.'),
|
|
383
383
|
limit: number('Maximum logs to return.', { minimum: 1, maximum: 500 }),
|
|
384
384
|
}, ['appKeys']),
|
|
385
385
|
},
|
|
@@ -403,13 +403,19 @@ const TOOLS = [
|
|
|
403
403
|
{
|
|
404
404
|
name: 'securenow_notifications_list',
|
|
405
405
|
title: 'List Notifications',
|
|
406
|
-
description: 'List security notifications.',
|
|
406
|
+
description: 'List security alerts (notifications), newest first, as compact summaries: rule, severity, status, test mode, IPs with AI verdicts, agent status and proposed actions. Open alerts by default; securenow_notifications_get returns one alert in full.',
|
|
407
407
|
scope: 'notifications:read',
|
|
408
408
|
readOnly: true,
|
|
409
409
|
method: 'GET',
|
|
410
410
|
endpoint: '/notifications',
|
|
411
|
-
queryFields: ['limit', 'page'],
|
|
412
|
-
inputSchema: objectSchema({
|
|
411
|
+
queryFields: ['limit', 'page', 'status', 'severity', 'search', 'archived'],
|
|
412
|
+
inputSchema: objectSchema({
|
|
413
|
+
...pagingInput,
|
|
414
|
+
status: string('Optional status filter, for example open, pending_review or resolved.'),
|
|
415
|
+
severity: string('Optional severity filter: critical, high, medium or low.'),
|
|
416
|
+
search: string('Optional text search over the alert title, IPs and rule.'),
|
|
417
|
+
archived: boolean('true lists archived alerts instead of open ones.'),
|
|
418
|
+
}),
|
|
413
419
|
},
|
|
414
420
|
{
|
|
415
421
|
name: 'securenow_notifications_unread',
|
|
@@ -465,7 +471,7 @@ const TOOLS = [
|
|
|
465
471
|
{
|
|
466
472
|
name: 'securenow_human_actions_list',
|
|
467
473
|
title: 'List Human Action Queue',
|
|
468
|
-
description: 'List approval-gated
|
|
474
|
+
description: 'List approval-gated decisions prepared by SecureNow AI, most urgent first, as compact summaries: IP rows (kind ip) with the AI recommendation, risk score, confidence and proofs, and rule-level case actions (kind case_action, no IP) such as tune_rule.',
|
|
469
475
|
scope: 'notifications:read',
|
|
470
476
|
readOnly: true,
|
|
471
477
|
method: 'GET',
|
|
@@ -609,7 +615,7 @@ const TOOLS = [
|
|
|
609
615
|
prototypeRunId: string('Optional notification id whose root-cause key should be used.'),
|
|
610
616
|
notificationIds: arrayOfStrings('Optional subset of eligible notification ids to close. Leave empty to apply to every eligible row in the group.'),
|
|
611
617
|
status: string('Resolution status: resolved or dismissed. Defaults to resolved.'),
|
|
612
|
-
allowMixed: boolean('
|
|
618
|
+
allowMixed: boolean('Allows a mixed decision/attribution group, meant for groups whose rows were all reviewed manually. Defaults to false.'),
|
|
613
619
|
previewOnly: boolean('When true, returns the eligible rows without writing.'),
|
|
614
620
|
maxRows: number('Maximum rows to close in this batch. Defaults to 100, capped at 250.', { minimum: 1, maximum: 250 }),
|
|
615
621
|
maxScan: number('Maximum pending-review rows to scan before grouping. Defaults to 500, capped at 1000.', { minimum: 1, maximum: 1000 }),
|
|
@@ -635,7 +641,7 @@ const TOOLS = [
|
|
|
635
641
|
prototypeRunId: string('Optional notification id whose root-cause key should be used.'),
|
|
636
642
|
notificationIds: arrayOfStrings('Optional subset of eligible notification ids to close. Leave empty to apply to every eligible row in the group.'),
|
|
637
643
|
status: string('Resolution status: resolved or dismissed. Defaults to resolved.'),
|
|
638
|
-
allowMixed: boolean('
|
|
644
|
+
allowMixed: boolean('Allows a mixed decision/attribution group, meant for groups whose rows were all reviewed manually. Defaults to false.'),
|
|
639
645
|
previewOnly: boolean('When true, returns the eligible rows without writing.'),
|
|
640
646
|
maxRows: number('Maximum rows to close in this batch. Defaults to 100, capped at 250.', { minimum: 1, maximum: 250 }),
|
|
641
647
|
maxScan: number('Maximum pending-review rows to scan before grouping. Defaults to 500, capped at 1000.', { minimum: 1, maximum: 1000 }),
|
|
@@ -751,7 +757,7 @@ const TOOLS = [
|
|
|
751
757
|
inputSchema: objectSchema({
|
|
752
758
|
notificationId: string('Notification id from the human action row.'),
|
|
753
759
|
ip: string('IP address to mark as false positive.'),
|
|
754
|
-
conditions: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Restrictive false-positive conditions
|
|
760
|
+
conditions: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'Restrictive false-positive conditions, scoped by app, rule, path, method, status, user agent or body.' },
|
|
755
761
|
matchMode: string('Condition match mode: all or any.'),
|
|
756
762
|
createExclusion: boolean('Whether to create a restrictive exclusion rule.'),
|
|
757
763
|
applyToExisting: boolean('Whether to apply to existing matching IP investigations.'),
|
|
@@ -818,30 +824,39 @@ const TOOLS = [
|
|
|
818
824
|
{
|
|
819
825
|
name: 'securenow_ip_traces',
|
|
820
826
|
title: 'IP Traces',
|
|
821
|
-
description: 'Fetch
|
|
827
|
+
description: 'Fetch the requests an IP made: time, app key, route, method, status code, duration and user agent, newest first. Covers the last 24 hours unless from/to are given, up to 500 requests per telemetry store, across every app unless appKeys are given; the response states the window.',
|
|
822
828
|
scope: 'ip_intel:read',
|
|
823
829
|
readOnly: true,
|
|
824
830
|
method: 'GET',
|
|
825
831
|
endpoint: '/ip/:ip/traces',
|
|
826
832
|
pathParams: ['ip'],
|
|
827
|
-
queryFields: ['appKeys', 'environment'],
|
|
833
|
+
queryFields: ['appKeys', 'environment', 'from', 'to'],
|
|
828
834
|
normalize: { appKeys: normalizeAppKeys },
|
|
829
835
|
inputSchema: objectSchema({
|
|
830
836
|
ip: string('IPv4 address.'),
|
|
831
837
|
...appKeysInput,
|
|
832
838
|
...environmentInput,
|
|
839
|
+
from: string('Start time as ISO 8601. Defaults to 24 hours before to.'),
|
|
840
|
+
to: string('End time as ISO 8601. Defaults to now.'),
|
|
833
841
|
}, ['ip']),
|
|
834
842
|
},
|
|
835
843
|
{
|
|
836
844
|
name: 'securenow_forensics_query',
|
|
837
845
|
title: 'Run Forensics Query',
|
|
838
|
-
description: 'Run a natural-language forensic query. Write scope because it creates an async query job.',
|
|
846
|
+
description: 'Run a natural-language forensic query over your traffic and return the result rows. Waits up to 30 seconds; a query still running after that returns its jobId with status processing. Write scope because it creates an async query job.',
|
|
839
847
|
scope: 'forensics:write',
|
|
840
848
|
readOnly: false,
|
|
841
849
|
confirm: true,
|
|
842
850
|
method: 'POST',
|
|
843
851
|
endpoint: '/forensics/query',
|
|
844
852
|
bodyFields: ['query', 'applicationId', 'instanceId', 'environment'],
|
|
853
|
+
poll: {
|
|
854
|
+
tool: 'securenow_forensics_query_status',
|
|
855
|
+
idField: 'jobId',
|
|
856
|
+
pending: ['processing'],
|
|
857
|
+
timeoutMs: 30000,
|
|
858
|
+
intervalMs: 2000,
|
|
859
|
+
},
|
|
845
860
|
inputSchema: objectSchema({
|
|
846
861
|
query: string('Natural-language forensic query.'),
|
|
847
862
|
applicationId: string('Optional application database id.'),
|
|
@@ -850,6 +865,19 @@ const TOOLS = [
|
|
|
850
865
|
...confirmSchema,
|
|
851
866
|
}, ['query', 'confirm', 'reason']),
|
|
852
867
|
},
|
|
868
|
+
{
|
|
869
|
+
name: 'securenow_forensics_query_status',
|
|
870
|
+
title: 'Forensics Query Result',
|
|
871
|
+
description: 'Get the status (processing, completed or failed), generated SQL and result rows of a forensics query job.',
|
|
872
|
+
scope: 'forensics:read',
|
|
873
|
+
readOnly: true,
|
|
874
|
+
method: 'GET',
|
|
875
|
+
endpoint: '/forensics/query/status/:jobId',
|
|
876
|
+
pathParams: ['jobId'],
|
|
877
|
+
inputSchema: objectSchema({
|
|
878
|
+
jobId: string('Forensics query job id.'),
|
|
879
|
+
}, ['jobId']),
|
|
880
|
+
},
|
|
853
881
|
{
|
|
854
882
|
name: 'securenow_forensics_library',
|
|
855
883
|
title: 'Forensics Query Library',
|
|
@@ -1105,12 +1133,18 @@ const TOOLS = [
|
|
|
1105
1133
|
{
|
|
1106
1134
|
name: 'securenow_alert_rules_list',
|
|
1107
1135
|
title: 'List Alert Rules',
|
|
1108
|
-
description: 'List
|
|
1136
|
+
description: 'List detection rules as compact summaries: name, mode (test or prod), status, severity, schedule, throttle, app scope and trigger stats. The detection SQL is left out; securenow_alert_rule_get returns one rule in full.',
|
|
1109
1137
|
scope: 'alerts:read',
|
|
1110
1138
|
readOnly: true,
|
|
1111
1139
|
method: 'GET',
|
|
1112
1140
|
endpoint: '/alert-rules',
|
|
1113
|
-
|
|
1141
|
+
queryFields: ['status', 'mode', 'isSystem', 'active'],
|
|
1142
|
+
inputSchema: objectSchema({
|
|
1143
|
+
status: string('Optional status filter: Active, Disabled or Paused.'),
|
|
1144
|
+
mode: string('Optional mode filter: test or prod.'),
|
|
1145
|
+
isSystem: boolean('true lists only template rules, false only your own.'),
|
|
1146
|
+
active: boolean('true lists only rules with status Active.'),
|
|
1147
|
+
}),
|
|
1114
1148
|
},
|
|
1115
1149
|
{
|
|
1116
1150
|
name: 'securenow_alert_rule_get',
|
|
@@ -1167,7 +1201,7 @@ const TOOLS = [
|
|
|
1167
1201
|
bodyFields: ['version', 'reason'],
|
|
1168
1202
|
inputSchema: objectSchema({
|
|
1169
1203
|
id: string('Alert rule id (must be a custom, non-system rule).'),
|
|
1170
|
-
version: number('Version number to restore
|
|
1204
|
+
version: number('Version number to restore, as listed by securenow_alert_rule_versions.', { minimum: 1 }),
|
|
1171
1205
|
...confirmSchema,
|
|
1172
1206
|
}, ['id', 'version', 'confirm', 'reason']),
|
|
1173
1207
|
},
|
|
@@ -1257,7 +1291,7 @@ const TOOLS = [
|
|
|
1257
1291
|
],
|
|
1258
1292
|
fixedBody: { auditSource: 'mcp' },
|
|
1259
1293
|
inputSchema: objectSchema({
|
|
1260
|
-
id: string('Alert rule id.
|
|
1294
|
+
id: string('Alert rule id.'),
|
|
1261
1295
|
operations: {
|
|
1262
1296
|
type: 'array',
|
|
1263
1297
|
items: { type: 'object', additionalProperties: true },
|
|
@@ -1341,7 +1375,7 @@ const TOOLS = [
|
|
|
1341
1375
|
],
|
|
1342
1376
|
fixedBody: { auditSource: 'mcp' },
|
|
1343
1377
|
inputSchema: objectSchema({
|
|
1344
|
-
id: string('Alert rule id.
|
|
1378
|
+
id: string('Alert rule id.'),
|
|
1345
1379
|
operations: {
|
|
1346
1380
|
type: 'array',
|
|
1347
1381
|
items: { type: 'object', additionalProperties: true },
|
|
@@ -1400,7 +1434,7 @@ const TOOLS = [
|
|
|
1400
1434
|
],
|
|
1401
1435
|
fixedBody: { auditSource: 'mcp', dryRun: true },
|
|
1402
1436
|
inputSchema: objectSchema({
|
|
1403
|
-
id: string('Alert rule id.
|
|
1437
|
+
id: string('Alert rule id.'),
|
|
1404
1438
|
operations: {
|
|
1405
1439
|
type: 'array',
|
|
1406
1440
|
items: { type: 'object', additionalProperties: true },
|
|
@@ -1456,7 +1490,7 @@ const TOOLS = [
|
|
|
1456
1490
|
],
|
|
1457
1491
|
fixedBody: { auditSource: 'mcp', dryRun: true },
|
|
1458
1492
|
inputSchema: objectSchema({
|
|
1459
|
-
id: string('Alert rule id.
|
|
1493
|
+
id: string('Alert rule id.'),
|
|
1460
1494
|
operations: {
|
|
1461
1495
|
type: 'array',
|
|
1462
1496
|
items: { type: 'object', additionalProperties: true },
|
|
@@ -1766,7 +1800,7 @@ const TOOLS = [
|
|
|
1766
1800
|
{
|
|
1767
1801
|
name: 'securenow_allowlist_add',
|
|
1768
1802
|
title: 'Add Restrictive Allowlist IP',
|
|
1769
|
-
description: 'Dangerous production action: add an IP/CIDR to the restrictive allowlist. Once active, allowlist mode blocks every IP except listed entries
|
|
1803
|
+
description: 'Dangerous production action: add an IP/CIDR to the restrictive allowlist. Once active, allowlist mode blocks every IP except listed entries, so it is not a way to mark an IP trusted or suppress false positives (trusted IPs are a separate list). The server rejects the call unless allowlistDenyAllApproved is true.',
|
|
1770
1804
|
scope: 'allowlist:write',
|
|
1771
1805
|
readOnly: false,
|
|
1772
1806
|
confirm: true,
|
|
@@ -1781,7 +1815,7 @@ const TOOLS = [
|
|
|
1781
1815
|
expiresAt: string('Optional expiry time as ISO 8601.'),
|
|
1782
1816
|
applicationsAll: boolean('Apply to all applications.'),
|
|
1783
1817
|
applicationKeys: arrayOfStrings('Application keys to scope this allowlist entry to.'),
|
|
1784
|
-
allowlistDenyAllApproved: boolean('
|
|
1818
|
+
allowlistDenyAllApproved: boolean('Confirms the deny-by-default behavior: once the allowlist has entries, every unlisted IP is blocked. Must be true.'),
|
|
1785
1819
|
...environmentInput,
|
|
1786
1820
|
...confirmSchema,
|
|
1787
1821
|
}, ['ip', 'confirm', 'reason', 'allowlistDenyAllApproved']),
|
|
@@ -1852,6 +1886,54 @@ const TOOLS = [
|
|
|
1852
1886
|
},
|
|
1853
1887
|
];
|
|
1854
1888
|
|
|
1889
|
+
// Annotation policy, matching how Claude's and ChatGPT's directory reviews read
|
|
1890
|
+
// the hints: a write is destructive when it removes or overwrites something,
|
|
1891
|
+
// or can stop production traffic or detection, even if it can be undone. Pure
|
|
1892
|
+
// record-keeping writes (reports, queue closures, read markers, dry runs) are
|
|
1893
|
+
// not. Only tools that reach arbitrary public entities are open-world.
|
|
1894
|
+
const DESTRUCTIVE_TOOLS = new Set([
|
|
1895
|
+
'securenow_firewall_enable',
|
|
1896
|
+
'securenow_firewall_disable',
|
|
1897
|
+
'securenow_firewall_set_threshold',
|
|
1898
|
+
'securenow_human_action_block',
|
|
1899
|
+
'securenow_human_action_false_positive',
|
|
1900
|
+
'securenow_human_case_action_update',
|
|
1901
|
+
'securenow_automation_defaults_ensure',
|
|
1902
|
+
'securenow_automation_rule_create',
|
|
1903
|
+
'securenow_automation_rule_update',
|
|
1904
|
+
'securenow_automation_rule_execute',
|
|
1905
|
+
'securenow_automation_rule_delete',
|
|
1906
|
+
'securenow_rate_limit_remove',
|
|
1907
|
+
'securenow_rate_limit_create_from_text',
|
|
1908
|
+
'securenow_alert_rule_rollback',
|
|
1909
|
+
'securenow_alert_rule_update',
|
|
1910
|
+
'securenow_alert_rule_query_update',
|
|
1911
|
+
'securenow_alert_rule_instant_update',
|
|
1912
|
+
'securenow_alert_rule_condition_update',
|
|
1913
|
+
'securenow_alert_rule_exclusion_add',
|
|
1914
|
+
'securenow_alert_rule_exclusion_remove',
|
|
1915
|
+
'securenow_blocklist_pending_approve',
|
|
1916
|
+
'securenow_blocklist_pending_reject',
|
|
1917
|
+
'securenow_blocklist_pending_bulk_approve',
|
|
1918
|
+
'securenow_blocklist_pending_bulk_reject',
|
|
1919
|
+
'securenow_blocklist_add',
|
|
1920
|
+
'securenow_blocklist_remove',
|
|
1921
|
+
'securenow_blocklist_unblock',
|
|
1922
|
+
'securenow_allowlist_add',
|
|
1923
|
+
'securenow_allowlist_remove',
|
|
1924
|
+
'securenow_trusted_add',
|
|
1925
|
+
'securenow_trusted_remove',
|
|
1926
|
+
]);
|
|
1927
|
+
|
|
1928
|
+
const OPEN_WORLD_TOOLS = new Set([
|
|
1929
|
+
'securenow_ip_lookup', // reputation of any public IP address
|
|
1930
|
+
]);
|
|
1931
|
+
|
|
1932
|
+
for (const tool of TOOLS) {
|
|
1933
|
+
if (DESTRUCTIVE_TOOLS.has(tool.name)) tool.destructive = true;
|
|
1934
|
+
tool.openWorld = OPEN_WORLD_TOOLS.has(tool.name);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1855
1937
|
const RESOURCES = [
|
|
1856
1938
|
{
|
|
1857
1939
|
uri: 'securenow://docs/skill-api',
|
|
@@ -2246,11 +2328,14 @@ function mcpToolDefinition(tool) {
|
|
|
2246
2328
|
description: tool.description,
|
|
2247
2329
|
inputSchema: tool.inputSchema || objectSchema({}),
|
|
2248
2330
|
annotations: {
|
|
2331
|
+
// Directory listings (Claude's connector review) read the title here too.
|
|
2332
|
+
title: tool.title,
|
|
2249
2333
|
readOnlyHint: !!tool.readOnly,
|
|
2250
|
-
destructiveHint: !!tool.destructive,
|
|
2334
|
+
destructiveHint: !tool.readOnly && !!tool.destructive,
|
|
2251
2335
|
idempotentHint: !!tool.readOnly,
|
|
2252
|
-
|
|
2253
|
-
}
|
|
2336
|
+
openWorldHint: !!tool.openWorld,
|
|
2337
|
+
},
|
|
2338
|
+
};
|
|
2254
2339
|
}
|
|
2255
2340
|
|
|
2256
2341
|
function listTools() {
|
|
@@ -2323,6 +2408,209 @@ function buildApiRequest(tool, rawArgs = {}) {
|
|
|
2323
2408
|
};
|
|
2324
2409
|
}
|
|
2325
2410
|
|
|
2411
|
+
// Async tools (tool.poll) return a job id; follow it with the status tool until
|
|
2412
|
+
// the job settles or the wait runs out. fetchStatus(request) performs one API call.
|
|
2413
|
+
async function pollToolResult(tool, first, fetchStatus) {
|
|
2414
|
+
const spec = tool.poll;
|
|
2415
|
+
const id = spec && first && first[spec.idField];
|
|
2416
|
+
if (!id || !spec.pending.includes(first.status)) return first;
|
|
2417
|
+
const statusTool = getTool(spec.tool);
|
|
2418
|
+
const deadline = Date.now() + spec.timeoutMs;
|
|
2419
|
+
let last = first;
|
|
2420
|
+
while (Date.now() < deadline) {
|
|
2421
|
+
await new Promise((resolve) => setTimeout(resolve, spec.intervalMs));
|
|
2422
|
+
last = await fetchStatus(buildApiRequest(statusTool, { [spec.idField]: id }));
|
|
2423
|
+
if (!last || !spec.pending.includes(last.status)) return last;
|
|
2424
|
+
}
|
|
2425
|
+
if (last && last.error) {
|
|
2426
|
+
return { ...last, message: `Not finished after ${Math.round(spec.timeoutMs / 1000)} seconds, and the last attempt failed: ${last.error}. It may be retrying; ${statusTool.name} with ${spec.idField} ${id} shows its state. A narrower question (one app, a shorter window) usually avoids this.` };
|
|
2427
|
+
}
|
|
2428
|
+
return { ...last, message: `Still running. Call ${statusTool.name} with ${spec.idField} ${id} to get the result.` };
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// Tool results land in a chat context, so very large API responses (a 500-IP
|
|
2432
|
+
// investigation, every saved rule) are shrunk: long arrays keep their first
|
|
2433
|
+
// items and long strings are cut, with a note saying how to narrow the call.
|
|
2434
|
+
const MAX_RESULT_CHARS = 60000;
|
|
2435
|
+
const TRIM_STEPS = [[50, 4000], [20, 2000], [10, 1000], [5, 400], [3, 200], [1, 100]];
|
|
2436
|
+
const TRUNCATED_NOTE = 'This result was shortened to fit the conversation. Use filters, a smaller limit or a get tool for the full details.';
|
|
2437
|
+
|
|
2438
|
+
function trimValue(value, maxItems, maxString) {
|
|
2439
|
+
if (typeof value === 'string') {
|
|
2440
|
+
return value.length > maxString ? `${value.slice(0, maxString)}… [${value.length - maxString} more characters]` : value;
|
|
2441
|
+
}
|
|
2442
|
+
if (Array.isArray(value)) {
|
|
2443
|
+
const kept = value.slice(0, maxItems).map((item) => trimValue(item, maxItems, maxString));
|
|
2444
|
+
if (value.length > maxItems) kept.push(`… ${value.length - maxItems} more items not shown`);
|
|
2445
|
+
return kept;
|
|
2446
|
+
}
|
|
2447
|
+
if (value && typeof value === 'object') {
|
|
2448
|
+
const out = {};
|
|
2449
|
+
for (const [key, item] of Object.entries(value)) out[key] = trimValue(item, maxItems, maxString);
|
|
2450
|
+
return out;
|
|
2451
|
+
}
|
|
2452
|
+
return value;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// Tool results are sent as compact JSON, so sizes are measured that way.
|
|
2456
|
+
const compactJson = (value) => JSON.stringify(value);
|
|
2457
|
+
|
|
2458
|
+
function fitToolResult(value, maxChars = MAX_RESULT_CHARS) {
|
|
2459
|
+
if (value == null || typeof value !== 'object') return value;
|
|
2460
|
+
if (compactJson(value).length <= maxChars) return value;
|
|
2461
|
+
for (const [maxItems, maxString] of TRIM_STEPS) {
|
|
2462
|
+
const trimmed = trimValue(value, maxItems, maxString);
|
|
2463
|
+
const wrapped = Array.isArray(trimmed) ? { _truncated: TRUNCATED_NOTE, result: trimmed } : { _truncated: TRUNCATED_NOTE, ...trimmed };
|
|
2464
|
+
if (compactJson(wrapped).length <= maxChars) return wrapped;
|
|
2465
|
+
}
|
|
2466
|
+
return { _truncated: TRUNCATED_NOTE, preview: compactJson(value).slice(0, maxChars) };
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// List tools return compact rows so a page of results fits in a chat context;
|
|
2470
|
+
// the matching get tools return one record in full. Each summarizer keeps the
|
|
2471
|
+
// response envelope (pagination, summary) and only reshapes the rows.
|
|
2472
|
+
const clip = (value, max = 300) => (typeof value === 'string' && value.length > max ? `${value.slice(0, max)}…` : value);
|
|
2473
|
+
const firstN = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
|
|
2474
|
+
|
|
2475
|
+
function summarizeNotification(n) {
|
|
2476
|
+
const investigations = Array.isArray(n.ipInvestigations) ? n.ipInvestigations : [];
|
|
2477
|
+
return {
|
|
2478
|
+
id: n._id || n.id,
|
|
2479
|
+
title: n.title,
|
|
2480
|
+
detectionName: n.alertContext?.detectionName || null,
|
|
2481
|
+
alertRuleId: n.alertRuleId,
|
|
2482
|
+
severity: n.severity,
|
|
2483
|
+
status: n.status,
|
|
2484
|
+
testMode: n.testMode === true,
|
|
2485
|
+
applications: n.applications,
|
|
2486
|
+
occurrences: n.occurrences,
|
|
2487
|
+
firstOccurredAt: n.firstOccurredAt,
|
|
2488
|
+
lastOccurredAt: n.lastOccurredAt,
|
|
2489
|
+
createdAt: n.createdAt,
|
|
2490
|
+
readAt: n.readAt || null,
|
|
2491
|
+
primaryIp: n.primaryIp || null,
|
|
2492
|
+
ipCount: Math.max(n.ipSummary?.totalCount || 0, investigations.length, (n.relatedIps || []).length + (n.primaryIp ? 1 : 0)),
|
|
2493
|
+
stormSignal: n.ipSummary?.stormSignal === true,
|
|
2494
|
+
resultCount: n.resultCount,
|
|
2495
|
+
agent: n.agentState ? { status: n.agentState.status, summary: clip(n.agentState.summary, 200), confidence: n.agentState.confidence } : null,
|
|
2496
|
+
proposedActions: firstN(n.proposedActions, 3).map((a) => ({
|
|
2497
|
+
actionKey: a.actionKey, type: a.type, status: a.status, confidence: a.confidence, ips: firstN(a.scope?.ips, 5),
|
|
2498
|
+
})),
|
|
2499
|
+
ips: firstN(investigations, 5).map((i) => ({ ip: i.ip, status: i.status, riskScore: i.riskScore, hitCount: i.hitCount })),
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
function summarizeRule(r) {
|
|
2504
|
+
return {
|
|
2505
|
+
id: r.id || r._id,
|
|
2506
|
+
name: r.name,
|
|
2507
|
+
detection: clip(r.queryMappingId?.nlpQuery || r.description, 140) || null,
|
|
2508
|
+
mode: r.mode,
|
|
2509
|
+
status: r.status,
|
|
2510
|
+
severity: r.severity,
|
|
2511
|
+
isSystem: r.isSystem === true,
|
|
2512
|
+
executionMode: r.executionMode,
|
|
2513
|
+
applicationsAll: r.applicationsAll,
|
|
2514
|
+
applications: r.applications,
|
|
2515
|
+
schedule: r.schedule?.enabled === false ? null : r.schedule?.cronExpression || null,
|
|
2516
|
+
throttleMinutes: r.throttle?.enabled === false ? 0 : r.throttle?.minutes ?? null,
|
|
2517
|
+
instant: r.instant ? { enabled: r.instant.enabled === true, block: r.instant.block === true } : null,
|
|
2518
|
+
exclusions: Array.isArray(r.exclusions) ? r.exclusions.length : 0,
|
|
2519
|
+
stats: r.stats ? {
|
|
2520
|
+
triggerCount: r.stats.triggerCount, executionCount: r.stats.executionCount,
|
|
2521
|
+
lastTriggeredAt: r.stats.lastTriggeredAt || null, lastExecutedAt: r.stats.lastExecutedAt || null,
|
|
2522
|
+
} : null,
|
|
2523
|
+
currentVersion: r.currentVersion,
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function summarizeTask(t) {
|
|
2528
|
+
const proofs = t.proofs || {};
|
|
2529
|
+
return {
|
|
2530
|
+
id: t.id,
|
|
2531
|
+
kind: t.kind || 'ip',
|
|
2532
|
+
actionKey: t.actionKey,
|
|
2533
|
+
actionType: t.actionType,
|
|
2534
|
+
notificationId: t.notificationId,
|
|
2535
|
+
ip: t.ip,
|
|
2536
|
+
alertRuleId: t.alertRuleId,
|
|
2537
|
+
title: t.title,
|
|
2538
|
+
detectionName: t.detectionName,
|
|
2539
|
+
severity: t.severity,
|
|
2540
|
+
applications: t.applications,
|
|
2541
|
+
hitCount: t.hitCount,
|
|
2542
|
+
firstSeenAt: t.firstSeenAt,
|
|
2543
|
+
lastSeenAt: t.lastSeenAt,
|
|
2544
|
+
urgencyScore: t.urgencyScore,
|
|
2545
|
+
cluster: t.cluster ? { clusterId: t.cluster.clusterId, label: t.cluster.label, ipCount: t.cluster.ipCount, riskScore: t.cluster.riskScore } : null,
|
|
2546
|
+
aiDecision: t.aiDecision ? {
|
|
2547
|
+
recommendation: t.aiDecision.recommendation, label: t.aiDecision.label, riskScore: t.aiDecision.riskScore,
|
|
2548
|
+
confidence: t.aiDecision.confidence, completedAt: t.aiDecision.completedAt, reason: clip(t.aiDecision.reason || t.aiDecision.summary, 300),
|
|
2549
|
+
} : null,
|
|
2550
|
+
proofs: {
|
|
2551
|
+
paths: firstN(proofs.paths, 5), methods: firstN(proofs.methods, 5), statusCodes: firstN(proofs.statusCodes, 5),
|
|
2552
|
+
userAgents: firstN(proofs.userAgents, 3).map((ua) => clip(ua, 120)), traceIds: firstN(proofs.traceIds, 5),
|
|
2553
|
+
rootCauseKey: proofs.rootCauseKey, evidenceShape: proofs.evidenceShape,
|
|
2554
|
+
},
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
function summarizeApp(a) {
|
|
2559
|
+
return {
|
|
2560
|
+
id: a._id || a.id,
|
|
2561
|
+
key: a.key,
|
|
2562
|
+
name: a.name,
|
|
2563
|
+
status: a.status,
|
|
2564
|
+
hosts: a.hosts,
|
|
2565
|
+
firewallEnabled: a.firewallEnabled,
|
|
2566
|
+
tracingEnabled: a.tracingEnabled,
|
|
2567
|
+
loggingEnabled: a.loggingEnabled,
|
|
2568
|
+
lastTelemetryAt: a.lastTelemetryAt || null,
|
|
2569
|
+
lastTelemetryEnvironment: a.lastTelemetryEnvironment || null,
|
|
2570
|
+
lastTraceAt: a.lastTraceAt || null,
|
|
2571
|
+
lastTraceEnvironment: a.lastTraceEnvironment || null,
|
|
2572
|
+
lastLogAt: a.lastLogAt || null,
|
|
2573
|
+
lastLogEnvironment: a.lastLogEnvironment || null,
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
function summarizeRateLimit(r) {
|
|
2578
|
+
const expired = !!r.expiresAt && new Date(r.expiresAt).getTime() < Date.now();
|
|
2579
|
+
return { ...r, metadata: undefined, effectiveStatus: r.status === 'active' && expired ? 'expired' : r.status };
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
const SUMMARIZERS = {
|
|
2583
|
+
securenow_notifications_list: (res) => (Array.isArray(res?.notifications) ? { ...res, notifications: res.notifications.map(summarizeNotification) } : res),
|
|
2584
|
+
securenow_alert_rules_list: (res) => {
|
|
2585
|
+
if (!Array.isArray(res?.alertRules)) return res;
|
|
2586
|
+
const rules = res.alertRules;
|
|
2587
|
+
const count = (fn) => rules.filter(fn).length;
|
|
2588
|
+
const counts = {
|
|
2589
|
+
total: rules.length,
|
|
2590
|
+
active: count((r) => r.status === 'Active'),
|
|
2591
|
+
prodActive: count((r) => r.status === 'Active' && r.mode === 'prod'),
|
|
2592
|
+
testActive: count((r) => r.status === 'Active' && r.mode === 'test'),
|
|
2593
|
+
paused: count((r) => r.status === 'Paused'),
|
|
2594
|
+
disabled: count((r) => r.status === 'Disabled'),
|
|
2595
|
+
neverTriggered: count((r) => !r.stats?.triggerCount),
|
|
2596
|
+
};
|
|
2597
|
+
return { counts, ...res, alertRules: rules.map(summarizeRule) };
|
|
2598
|
+
},
|
|
2599
|
+
securenow_human_actions_list: (res) => (Array.isArray(res?.tasks) ? { ...res, tasks: res.tasks.map(summarizeTask) } : res),
|
|
2600
|
+
securenow_apps_list: (res) => (Array.isArray(res?.applications) ? { ...res, applications: res.applications.map(summarizeApp) } : res),
|
|
2601
|
+
securenow_rate_limit_list: (res) => (Array.isArray(res?.rateLimits) ? { ...res, rateLimits: res.rateLimits.map(summarizeRateLimit) } : res),
|
|
2602
|
+
};
|
|
2603
|
+
|
|
2604
|
+
function summarizeToolResult(tool, result) {
|
|
2605
|
+
const summarize = tool && SUMMARIZERS[tool.name];
|
|
2606
|
+
if (!summarize || result == null || typeof result !== 'object') return result;
|
|
2607
|
+
try {
|
|
2608
|
+
return summarize(result);
|
|
2609
|
+
} catch (_) {
|
|
2610
|
+
return result;
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2326
2614
|
function readStaticResource(resource) {
|
|
2327
2615
|
const filepath = path.resolve(__dirname, resource.file);
|
|
2328
2616
|
return fs.readFileSync(filepath, 'utf8');
|
|
@@ -2351,6 +2639,9 @@ module.exports = {
|
|
|
2351
2639
|
promptMessages,
|
|
2352
2640
|
assertConfirmed,
|
|
2353
2641
|
buildApiRequest,
|
|
2642
|
+
pollToolResult,
|
|
2643
|
+
fitToolResult,
|
|
2644
|
+
summarizeToolResult,
|
|
2354
2645
|
resourceContent,
|
|
2355
2646
|
jsonText,
|
|
2356
2647
|
maskSecret,
|
package/mcp/server.js
CHANGED
|
@@ -14,6 +14,9 @@ const {
|
|
|
14
14
|
promptMessages,
|
|
15
15
|
assertConfirmed,
|
|
16
16
|
buildApiRequest,
|
|
17
|
+
pollToolResult,
|
|
18
|
+
fitToolResult,
|
|
19
|
+
summarizeToolResult,
|
|
17
20
|
resourceContent,
|
|
18
21
|
jsonText,
|
|
19
22
|
maskSecret,
|
|
@@ -160,27 +163,32 @@ async function callApiTool(tool, args) {
|
|
|
160
163
|
throw new Error('Admin auth is missing. Run `npx securenow admin login` from the project root, then retry. Runtime app credentials cannot grant admin/control-plane permissions.');
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
const send = (request) => {
|
|
167
|
+
const options = {
|
|
168
|
+
token,
|
|
169
|
+
...(Object.keys(request.query || {}).length > 0 ? { query: request.query } : {}),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (request.method === 'GET') return api.get(request.endpoint, options);
|
|
173
|
+
if (request.method === 'POST') return api.post(request.endpoint, request.body || {}, options);
|
|
174
|
+
if (request.method === 'PUT') return api.put(request.endpoint, request.body || {}, options);
|
|
175
|
+
if (request.method === 'PATCH') return api.patch(request.endpoint, request.body || {}, options);
|
|
176
|
+
if (request.method === 'DELETE') return api.delete(request.endpoint, options);
|
|
168
177
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (request.method === 'PUT') return api.put(request.endpoint, request.body || {}, options);
|
|
172
|
-
if (request.method === 'PATCH') return api.patch(request.endpoint, request.body || {}, options);
|
|
173
|
-
if (request.method === 'DELETE') return api.delete(request.endpoint, options);
|
|
178
|
+
throw new Error(`Unsupported method for ${tool.name}: ${request.method}`);
|
|
179
|
+
};
|
|
174
180
|
|
|
175
|
-
|
|
181
|
+
const result = await send(buildApiRequest(tool, finalArgs));
|
|
182
|
+
return summarizeToolResult(tool, await pollToolResult(tool, result, send));
|
|
176
183
|
}
|
|
177
184
|
|
|
178
|
-
function asToolResult(
|
|
185
|
+
function asToolResult(rawResult) {
|
|
186
|
+
const result = fitToolResult(rawResult);
|
|
179
187
|
return {
|
|
180
188
|
content: [
|
|
181
189
|
{
|
|
182
190
|
type: 'text',
|
|
183
|
-
text: typeof result === 'string' ? result :
|
|
191
|
+
text: typeof result === 'string' ? result : JSON.stringify(result),
|
|
184
192
|
},
|
|
185
193
|
],
|
|
186
194
|
structuredContent: typeof result === 'object' && result !== null ? result : { value: result },
|
package/nextjs.js
CHANGED
|
@@ -514,19 +514,14 @@ function registerSecureNow(options = {}) {
|
|
|
514
514
|
_formatOtlpError(err)
|
|
515
515
|
);
|
|
516
516
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
|
|
526
|
-
_reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
throw reason;
|
|
517
|
+
// Crash capture (see crash-capture.js). The logger provider only exists on
|
|
518
|
+
// self-hosted deployments; on Vercel crashes keep Node's default behaviour.
|
|
519
|
+
let _crashLoggerProvider = null;
|
|
520
|
+
require('./crash-capture').installCrashCapture({
|
|
521
|
+
getLogger: () => (_crashLoggerProvider ? _crashLoggerProvider.getLogger('securenow.crash', '1.0.0') : null),
|
|
522
|
+
flush: () => (_crashLoggerProvider ? _crashLoggerProvider.forceFlush() : undefined),
|
|
523
|
+
isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
|
|
524
|
+
onSdkError: _reportSuppressedOtlpError,
|
|
530
525
|
});
|
|
531
526
|
|
|
532
527
|
if (isVercel) {
|
|
@@ -606,6 +601,7 @@ function registerSecureNow(options = {}) {
|
|
|
606
601
|
// sdk-logs >= 0.220 takes a single options object ({ exporter }).
|
|
607
602
|
processors: [new BatchLogRecordProcessor({ exporter: logExporter })],
|
|
608
603
|
});
|
|
604
|
+
_crashLoggerProvider = loggerProvider;
|
|
609
605
|
|
|
610
606
|
// Patch console to forward logs as OTLP log records
|
|
611
607
|
const logger = loggerProvider.getLogger('console', '1.0.0');
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securenow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.13.0",
|
|
4
|
+
"mcpName": "ai.securenow/securenow",
|
|
4
5
|
"description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
|
|
5
6
|
"type": "commonjs",
|
|
6
7
|
"main": "register.js",
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
"securenow-mcp": "mcp/server.js"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
13
|
-
"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-log-format.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-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
17
|
"opentelemetry",
|
|
@@ -164,6 +165,7 @@
|
|
|
164
165
|
"header-redaction.js",
|
|
165
166
|
"firewall-paths.js",
|
|
166
167
|
"log-format.js",
|
|
168
|
+
"crash-capture.js",
|
|
167
169
|
"sdk-disabled.js",
|
|
168
170
|
"rate-limits.js",
|
|
169
171
|
"rate-limits.d.ts",
|
package/tracing.js
CHANGED
|
@@ -9,7 +9,7 @@ if (require('./sdk-disabled').sdkDisabled('tracing')) return;
|
|
|
9
9
|
* Preload with: node --require securenow/register app.js
|
|
10
10
|
*
|
|
11
11
|
* Works for both CJS and ESM apps. On Node >=20.6 the ESM loader hook is
|
|
12
|
-
* auto-registered via module.register()
|
|
12
|
+
* auto-registered via module.register() — no --import flag needed.
|
|
13
13
|
* On Node 18 with "type": "module", add the hook manually:
|
|
14
14
|
* node --import @opentelemetry/instrumentation/hook.mjs --require securenow/register app.js
|
|
15
15
|
*
|
|
@@ -348,7 +348,7 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
|
|
|
348
348
|
const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
|
|
349
349
|
const hasModuleRegister = typeof require('node:module').register === 'function';
|
|
350
350
|
if (!hasCliHook && !hasModuleRegister) {
|
|
351
|
-
console.warn('[securenow]
|
|
351
|
+
console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
|
|
352
352
|
console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
|
|
353
353
|
}
|
|
354
354
|
}
|
|
@@ -384,7 +384,7 @@ const headers = resolvedEndpoints.headers;
|
|
|
384
384
|
const rawBase = (resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
|
|
385
385
|
const baseName = rawBase || null;
|
|
386
386
|
// Auto-disables the per-worker suffix when we resolved a routing UUID from
|
|
387
|
-
// credentials
|
|
387
|
+
// credentials — the dashboard does exact-match IN on service.name, so any
|
|
388
388
|
// suffix breaks routing. config.runtime.noUuid can override.
|
|
389
389
|
const noUuid = appConfig.resolveNoUuid();
|
|
390
390
|
const strict = appConfig.boolConfig('runtime.strict', false);
|
|
@@ -411,7 +411,7 @@ const instancePrefix = baseName || 'securenow';
|
|
|
411
411
|
const serviceInstanceId = `${instancePrefix}-${randomUUID()}`;
|
|
412
412
|
|
|
413
413
|
// Loud line per worker to prove what was used
|
|
414
|
-
console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s
|
|
414
|
+
console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
|
|
415
415
|
process.pid,
|
|
416
416
|
JSON.stringify(baseName),
|
|
417
417
|
JSON.stringify(endpointBase),
|
|
@@ -739,21 +739,15 @@ function _reportSuppressedOtlpError(kind, err, origin) {
|
|
|
739
739
|
);
|
|
740
740
|
}
|
|
741
741
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
|
|
752
|
-
_reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
|
|
753
|
-
return; // swallow
|
|
754
|
-
}
|
|
755
|
-
// Not ours — re-throw as unhandled so Node's default behaviour applies
|
|
756
|
-
throw reason;
|
|
742
|
+
// Crashes: OTLP exporter blips are swallowed (never crash the host app); every
|
|
743
|
+
// other uncaught exception / unhandled rejection is recorded as a FATAL log
|
|
744
|
+
// record, flushed, and the process exits like Node's default (code 1). If the
|
|
745
|
+
// app has its own handler, the error is recorded and that handler decides.
|
|
746
|
+
require('./crash-capture').installCrashCapture({
|
|
747
|
+
getLogger: () => (loggerProvider ? loggerProvider.getLogger('securenow.crash', '1.0.0') : null),
|
|
748
|
+
flush: () => (loggerProvider ? loggerProvider.forceFlush() : undefined),
|
|
749
|
+
isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
|
|
750
|
+
onSdkError: _reportSuppressedOtlpError,
|
|
757
751
|
});
|
|
758
752
|
|
|
759
753
|
// -------- SDK --------
|
|
@@ -779,17 +773,17 @@ const sdk = new NodeSDK({
|
|
|
779
773
|
(async () => {
|
|
780
774
|
try {
|
|
781
775
|
await Promise.resolve(sdk.start?.());
|
|
782
|
-
console.log('[securenow] OTel SDK started
|
|
776
|
+
console.log('[securenow] OTel SDK started → %s', tracesUrl);
|
|
783
777
|
if (loggingEnabled) {
|
|
784
|
-
console.log('[securenow]
|
|
778
|
+
console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
|
|
785
779
|
} else {
|
|
786
780
|
console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
|
|
787
781
|
}
|
|
788
782
|
if (captureBody) {
|
|
789
|
-
console.log('[securenow]
|
|
783
|
+
console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
|
|
790
784
|
}
|
|
791
785
|
if (captureMultipart) {
|
|
792
|
-
console.log('[securenow]
|
|
786
|
+
console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
|
|
793
787
|
}
|
|
794
788
|
if (appConfig.boolConfig('runtime.testSpan', false)) {
|
|
795
789
|
const api = require('@opentelemetry/api');
|
|
@@ -797,9 +791,9 @@ const sdk = new NodeSDK({
|
|
|
797
791
|
const span = tracer.startSpan('securenow.startup.smoke'); span.end();
|
|
798
792
|
}
|
|
799
793
|
|
|
800
|
-
// Firewall
|
|
794
|
+
// Firewall — auto-activates only when a real snk_live_ key is resolvable.
|
|
801
795
|
// resolveApiKey() enforces the prefix, so we skip cleanly when the app has
|
|
802
|
-
// only an app-routing UUID (or nothing at all)
|
|
796
|
+
// only an app-routing UUID (or nothing at all) — no 401 polling loops.
|
|
803
797
|
const firewallOptions = appConfig.resolveFirewallOptions();
|
|
804
798
|
if (firewallOptions.apiKey) {
|
|
805
799
|
require('./firewall').init({
|