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 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:20-slim
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
@@ -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
- // Pipe it straight into your coding agent:
6
- // claude -p "$(npx securenow threat-model authentication)"
7
- // or save it: npx securenow threat-model authentication --out prompt.md
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(' Pipe it: claude -p "$(securenow threat-model authentication)"');
38
- console.log(' Save it: securenow threat-model authentication --out prompt.md');
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
 
@@ -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 local SecureNow credential source, selected app, and masked runtime API key.',
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). Confirm with the user before creating apps.',
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({ ...pagingInput }),
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 human decisions prepared by SecureNow AI, ordered by urgency.',
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('Allow a mixed decision/attribution group only after every row was manually reviewed. Defaults to false.'),
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('Allow a mixed decision/attribution group only after every row was manually reviewed. Defaults to false.'),
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. Prefer app/rule/path/method/status/user-agent/body scope.' },
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 traces associated with an IP address.',
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 alert rules, including system rules and app scope.',
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
- inputSchema: objectSchema({}),
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. List options with securenow_alert_rule_versions.', { minimum: 1 }),
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. Fetch with securenow_alert_rule_get first to get ruleVersion and instantHash.'),
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. Fetch with securenow_alert_rule_conditions_get first to get ruleVersion and instantHash.'),
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. Fetch with securenow_alert_rule_conditions_get first to get ruleVersion and instantHash.'),
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. Fetch with securenow_alert_rule_conditions_get first to get ruleVersion and instantHash.'),
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. Do not use this to mark an IP trusted or suppress false positives; use securenow_trusted_add instead. Requires explicit human approval confirming deny-all behavior.',
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('Must be true only after the user explicitly approves deny-by-default allowlist behavior. Do not set this for trusted IPs, monitors, false positives, or investigation cleanup.'),
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 request = buildApiRequest(tool, finalArgs);
164
- const options = {
165
- token,
166
- ...(Object.keys(request.query || {}).length > 0 ? { query: request.query } : {}),
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
- if (request.method === 'GET') return api.get(request.endpoint, options);
170
- if (request.method === 'POST') return api.post(request.endpoint, request.body || {}, options);
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
- throw new Error(`Unsupported method for ${tool.name}: ${request.method}`);
181
+ const result = await send(buildApiRequest(tool, finalArgs));
182
+ return summarizeToolResult(tool, await pollToolResult(tool, result, send));
176
183
  }
177
184
 
178
- function asToolResult(result) {
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 : jsonText(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
- process.on('uncaughtException', (err, origin) => {
518
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
519
- _reportSuppressedOtlpError('error', err, origin);
520
- return;
521
- }
522
- throw err;
523
- });
524
- process.on('unhandledRejection', (reason) => {
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.12.3",
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() — no --import flag needed.
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] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
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 — the dashboard does exact-match IN on service.name, so any
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 → service.name=%s instance.id=%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
- process.on('uncaughtException', (err, origin) => {
743
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
744
- _reportSuppressedOtlpError('error', err, origin);
745
- return; // swallow - do not crash
746
- }
747
- // Not ours — re-throw so the default handler (or the app's own handler) fires
748
- throw err;
749
- });
750
- process.on('unhandledRejection', (reason) => {
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 → %s', tracesUrl);
776
+ console.log('[securenow] OTel SDK started → %s', tracesUrl);
783
777
  if (loggingEnabled) {
784
- console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
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] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
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] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
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 — auto-activates only when a real snk_live_ key is resolvable.
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) — no 401 polling loops.
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({