@newrelic/security-agent 3.0.4 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ### v4.0.0 (2026-11-03)
2
+ ### ⚠ BREAKING CHANGES
3
+ * Dropped support for Node.js v20.x
4
+
5
+ #### Features
6
+ * Added Node 26.x support and removed 20.x, updated CI matrix and fs callback hooks (#379)
7
+
8
+ #### Miscellaneous chores
9
+ * Updated Readme (#381)
10
+
11
+ ### v3.0.5 (2026-10-29)
12
+ #### Bug fixes
13
+ * Subscribe to agent mongodb channels instead of wrapping prototypes (#375)
14
+ * Delegate wrapped mongodb methods to the prototype at call time (#376)
15
+
16
+ #### Miscellaneous chores
17
+ * bump undici to 7.30.1 and ws to 8.22.0 (#377)
18
+
1
19
  ### v3.0.4 (2026-05-14)
2
20
  #### Bug fixes
3
21
  * remove dependency on uuid module (#357)
package/README.md CHANGED
@@ -42,7 +42,7 @@ The `@newrelic/security-agent` must be explicitly enabled in order to perform IA
42
42
  The [Developer docs](http://newrelic.github.io/node-newrelic/) for writing instrumentation will be of help. We particularly recommend the [tutorials](https://newrelic.github.io/node-newrelic/tutorial-Instrumentation-Basics.html) and the [shim API](https://newrelic.github.io/node-newrelic/Shim.html) documentation.
43
43
 
44
44
  ### Supported Node.js Versions
45
- - Node version 20.x, 22.x and 24.x
45
+ - Node version 22.x, 24.x and 26.x
46
46
 
47
47
  ### Supported Modules
48
48
 
@@ -57,6 +57,13 @@ let functionProbableToFI = [
57
57
  "mkdir",
58
58
  ];
59
59
 
60
+ // Node 26+ callback readFile/writeFile/appendFile no longer go through binding.open,
61
+ // so the open hook misses them. Older versions still do; wrapping there would double-report.
62
+ if (semver.satisfies(process.version, '>=26.0.0')) {
63
+ functionsProbableToFA.push("readFile");
64
+ functionProbableToFI.push("writeFile", "appendFile");
65
+ }
66
+
60
67
  const promiseFunctionsProbableToFA = [
61
68
  "open",
62
69
  "readdir",
@@ -7,6 +7,7 @@
7
7
 
8
8
  /* eslint sonarjs/cognitive-complexity: ["error", 18] -- TODO: https://issues.newrelic.com/browse/NEWRELIC-5252 */
9
9
 
10
+ const diagnosticsChannel = require("diagnostics_channel");
10
11
  const { CURSOR_OPS, COLLECTION_OPS, DB_OPS } = require("./constants");
11
12
  const common = module.exports;
12
13
  const requestManager = require("../../core/request-manager");
@@ -18,6 +19,30 @@ const { EVENT_TYPE, EVENT_CATEGORY } = require('../../core/event-constants');
18
19
  const { NR_CSEC_FUZZ_REQUEST_ID } = require('../../core/constants');
19
20
  const logger = API.getLogger();
20
21
 
22
+ // Prefix for the newrelic agent's mongodb tracing channels. The agent publishes
23
+ // one tracing channel per instrumented mongodb class; subscribing to these lets
24
+ // us attach to constructed instances without reassigning prototype methods.
25
+ const AGENT_MONGODB_CHANNEL_PREFIX = "orchestrion:mongodb:";
26
+ // Marks an instance whose methods we have already wrapped, so repeated channel
27
+ // events for the same object do not double-wrap.
28
+ const CSEC_MONGO_INSTANCE_HOOKED = Symbol("csecMongoInstanceHooked");
29
+ // Tracks the active subscription handlers per agent channel. The mongodb hook
30
+ // can run more than once in a process (e.g. the newrelic agent being unloaded
31
+ // and re-instrumented), each time with a fresh `shim`. The tracing channel is
32
+ // process-global, so on each run we must remove the previous subscription
33
+ // (bound to the old `shim`) before adding a new one, otherwise we stack
34
+ // duplicate subscribers that serve a stale `shim`.
35
+ //
36
+ // This registry must live on `globalThis`, not in a module-scoped variable:
37
+ // this file's path contains "mongodb", so test/require-cache busters that evict
38
+ // modules matching "mongodb" would reload this module and reset a module-scoped
39
+ // Map — losing the handle needed to unsubscribe the still-live prior
40
+ // subscription. Keyed by full channel name -> handlers object.
41
+ const CHANNEL_SUBSCRIPTIONS_KEY = Symbol.for("newrelic.csec.mongodb.channelSubscriptions");
42
+ const channelSubscriptions =
43
+ globalThis[CHANNEL_SUBSCRIPTIONS_KEY] ||
44
+ (globalThis[CHANNEL_SUBSCRIPTIONS_KEY] = new Map());
45
+
21
46
  function methodChecker(data) {
22
47
  data = data.toLowerCase();
23
48
  if (data.includes("delete")) {
@@ -53,52 +78,200 @@ function payloadType(obj) {
53
78
 
54
79
  }
55
80
 
56
- function queryHook(shim, mod, method) {
57
- shim.wrap(mod, method, function makeQueryWrapper(shim, fn) {
58
- logger.debug(`Instrumenting mongodb.${method}`);
59
- return function queryWrapper() {
60
- const request = requestManager.getRequest(shim);
61
- if (CURSOR_OPS.includes(method)) {
62
- if (!this.mongo_sec_hooked && this.operation && this.operation.cmd) {
63
- const pType = payloadType(this.operation.cmd);
64
- const payloadData = {
65
- payloadType: pType,
66
- payload: this.operation.cmd,
67
- };
68
- shim.interceptedArgs = payloadData;
69
- }
70
- } else {
71
- this.mongo_sec_hooked = true;
72
-
73
- const pType = methodChecker(method);
81
+ /**
82
+ * Builds the security wrapper for a mongodb operation. The returned function
83
+ * reads the operation payload, generates the NoSQL security event, and (for
84
+ * fuzz requests) hooks the callback, then delegates to the original method.
85
+ *
86
+ * This is shared by both attachment strategies: the legacy prototype wrap
87
+ * (`queryHook`, used for mongodb < 4 where the newrelic agent does not provide
88
+ * subscriber based instrumentation) and the per-instance attachment
89
+ * (`instrumentInstance`, used for mongodb >= 4 so we do not reassign a
90
+ * prototype method already instrumented by the newrelic agent).
91
+ *
92
+ * @param {Shim} shim
93
+ * @param {string} method name of the operation being wrapped
94
+ * @param {Function} fn the original method
95
+ * @returns {Function} the wrapped method
96
+ */
97
+ function makeQueryWrapper(shim, method, fn) {
98
+ return function queryWrapper() {
99
+ const request = requestManager.getRequest(shim);
100
+ if (CURSOR_OPS.includes(method)) {
101
+ if (!this.mongo_sec_hooked && this.operation && this.operation.cmd) {
102
+ const pType = payloadType(this.operation.cmd);
74
103
  const payloadData = {
75
104
  payloadType: pType,
76
- payload: arguments,
105
+ payload: this.operation.cmd,
77
106
  };
78
107
  shim.interceptedArgs = payloadData;
79
-
80
108
  }
109
+ } else {
110
+ this.mongo_sec_hooked = true;
81
111
 
82
- try {
83
- if (request) {
84
- //generate event here
85
- const traceObject = secUtils.getTraceObject(shim);
86
- const secMetadata = securityMetaData.getSecurityMetaData(request, JSON.parse(JSON.stringify(shim.interceptedArgs)), traceObject, secUtils.getExecutionId(), EVENT_TYPE.NOSQL_DB_COMMAND, EVENT_CATEGORY.MONGO)
87
- const secEvent = API.generateSecEvent(secMetadata);
88
- API.sendEvent(secEvent);
89
- if (request.headers[NR_CSEC_FUZZ_REQUEST_ID]) {
90
- callbackHook(shim, arguments, arguments.length - 1, secEvent);
91
- }
112
+ const pType = methodChecker(method);
113
+ const payloadData = {
114
+ payloadType: pType,
115
+ payload: arguments,
116
+ };
117
+ shim.interceptedArgs = payloadData;
118
+
119
+ }
120
+
121
+ try {
122
+ if (request) {
123
+ //generate event here
124
+ const traceObject = secUtils.getTraceObject(shim);
125
+ const secMetadata = securityMetaData.getSecurityMetaData(request, JSON.parse(JSON.stringify(shim.interceptedArgs)), traceObject, secUtils.getExecutionId(), EVENT_TYPE.NOSQL_DB_COMMAND, EVENT_CATEGORY.MONGO)
126
+ const secEvent = API.generateSecEvent(secMetadata);
127
+ API.sendEvent(secEvent);
128
+ if (request.headers[NR_CSEC_FUZZ_REQUEST_ID]) {
129
+ callbackHook(shim, arguments, arguments.length - 1, secEvent);
92
130
  }
93
- } catch (error) {
94
131
  }
95
-
132
+ } catch (error) {
133
+ }
96
134
 
97
- return fn.apply(this, arguments);
98
- };
135
+
136
+ return fn.apply(this, arguments);
137
+ };
138
+ }
139
+
140
+ function queryHook(shim, mod, method) {
141
+ shim.wrap(mod, method, function wrapQuery(shim, fn) {
142
+ logger.debug(`Instrumenting mongodb.${method}`);
143
+ return makeQueryWrapper(shim, method, fn);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Attaches the security wrapper to a single mongodb instance (a `Collection`,
149
+ * cursor, etc.) as an own property, leaving the class prototype untouched.
150
+ *
151
+ * The newrelic agent (>= the release that instruments mongodb via
152
+ * `diagnostics_channel`) reassigns the driver prototype methods. If we also
153
+ * wrapped the prototype, the two reassignments would clobber one another and
154
+ * the agent's datastore instrumentation would be lost. Wrapping the instance
155
+ * sits below the prototype, so both coexist. See
156
+ * https://github.com/newrelic/csec-node-agent/issues/374.
157
+ *
158
+ * @param {Shim} shim
159
+ * @param {object} instance a constructed mongodb object (e.g. a `Collection`)
160
+ * @param {string[]} methods operation names to wrap when present on `instance`
161
+ */
162
+ function instrumentInstance(shim, instance, methods) {
163
+ if (!instance || instance[CSEC_MONGO_INSTANCE_HOOKED]) {
164
+ return;
165
+ }
166
+ // Guard so repeated channel events for the same instance do not re-wrap.
167
+ Object.defineProperty(instance, CSEC_MONGO_INSTANCE_HOOKED, {
168
+ value: true,
169
+ enumerable: false,
170
+ writable: true,
171
+ configurable: true
99
172
  });
173
+
174
+ const proto = Object.getPrototypeOf(instance);
175
+ for (let i = 0; i < methods.length; i++) {
176
+ const method = methods[i];
177
+ if (typeof instance[method] !== "function") {
178
+ continue;
179
+ }
180
+ logger.debug(`Instrumenting mongodb instance method ${method}`);
181
+ // Own property on the instance; does not touch the prototype. Delegate to
182
+ // the prototype method *lazily* (resolved at call time) rather than
183
+ // capturing it now: the newrelic agent wraps the prototype method on each
184
+ // (re-)instrumentation, so a value captured at construction time can go
185
+ // stale and bypass the agent's current wrapper. Reading it from the
186
+ // prototype at call time always runs whatever the agent currently has.
187
+ instance[method] = makeQueryWrapper(shim, method, function callProto() {
188
+ return proto[method].apply(this, arguments);
189
+ });
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Subscribes to a newrelic agent mongodb construction tracing channel and
195
+ * attaches the security wrapper to each constructed instance. Returns false if
196
+ * the agent does not expose the channel (e.g. an older agent without
197
+ * subscriber based mongodb instrumentation), so callers can fall back to the
198
+ * legacy prototype wrapping.
199
+ *
200
+ * @param {Shim} shim
201
+ * @param {string} channelName the agent channel, e.g. `nr_collection`
202
+ * @param {string[]} methods operation names to wrap on each instance
203
+ * @returns {boolean} whether the channel subscription was established
204
+ */
205
+ function subscribeToAgentChannel(shim, channelName, methods) {
206
+ const fullName = `${AGENT_MONGODB_CHANNEL_PREFIX}${channelName}`;
207
+ try {
208
+ const channel = diagnosticsChannel.tracingChannel(fullName);
209
+ // Only ride the agent's channel when the agent is actually instrumenting
210
+ // mongodb on it (it subscribes its own handler via `start`). If nothing is
211
+ // publishing here (e.g. an older newrelic agent without subscriber based
212
+ // mongodb instrumentation, or the agent disabled), report failure so the
213
+ // caller falls back to the legacy prototype wrapping. There is no colliding
214
+ // agent instrumentation in that case, so prototype wrapping is safe.
215
+ if (channel.start.hasSubscribers !== true) {
216
+ return false;
217
+ }
218
+ // Remove any prior subscription for this channel. On re-instrumentation the
219
+ // previous handlers closed over a now-stale `shim`; replacing them keeps a
220
+ // single live subscription bound to the current `shim`.
221
+ const previous = channelSubscriptions.get(fullName);
222
+ if (previous) {
223
+ channel.unsubscribe(previous);
224
+ }
225
+ const handlers = {
226
+ end(event) {
227
+ try {
228
+ instrumentInstance(shim, event && event.self, methods);
229
+ } catch (error) {
230
+ logger.debug(`Error instrumenting mongodb instance via ${channelName}`, error);
231
+ }
232
+ }
233
+ };
234
+ channel.subscribe(handlers);
235
+ channelSubscriptions.set(fullName, handlers);
236
+ return true;
237
+ } catch (error) {
238
+ logger.debug(`Could not subscribe to agent channel ${channelName}`, error);
239
+ return false;
240
+ }
100
241
  }
101
242
 
243
+ /**
244
+ * Instruments cursor operations for mongodb >= 4 by subscribing to the newrelic
245
+ * agent's cursor construction channel and attaching to each instance. Falls
246
+ * back to the legacy prototype wrapping when the agent channel is unavailable.
247
+ *
248
+ * @param {Shim} shim
249
+ * @param {Cursor} [Cursor] cursor class, used only for the fallback path
250
+ */
251
+ common.instrumentCursorViaChannel = function instrumentCursorViaChannel(shim, Cursor) {
252
+ const methods = CURSOR_OPS.concat(["each"]);
253
+ const subscribed = subscribeToAgentChannel(shim, "nr_cursor", methods);
254
+ if (!subscribed) {
255
+ common.instrumentCursor(shim, Cursor);
256
+ }
257
+ };
258
+
259
+ /**
260
+ * Instruments collection operations for mongodb >= 4 by subscribing to the
261
+ * newrelic agent's collection construction channel and attaching to each
262
+ * instance. Falls back to the legacy prototype wrapping when the agent channel
263
+ * is unavailable.
264
+ *
265
+ * @param {Shim} shim
266
+ * @param {Collection} [Collection] collection class, used only for the fallback path
267
+ */
268
+ common.instrumentCollectionViaChannel = function instrumentCollectionViaChannel(shim, Collection) {
269
+ const subscribed = subscribeToAgentChannel(shim, "nr_collection", COLLECTION_OPS);
270
+ if (!subscribed) {
271
+ common.instrumentCollection(shim, Collection);
272
+ }
273
+ };
274
+
102
275
  /**
103
276
  * Instruments all methods from constants.CURSOR_OPS on a given
104
277
  * cursor class
@@ -6,12 +6,22 @@
6
6
 
7
7
  'use strict'
8
8
 
9
- const { instrumentCollection, instrumentCursor, instrumentDb } = require('./common')
9
+ const { instrumentCollectionViaChannel, instrumentCursorViaChannel, instrumentDb } = require('./common')
10
10
 
11
11
  module.exports = function instrument(shim, mongodb) {
12
- instrumentCursor(shim, mongodb.AbstractCursor)
13
- instrumentCursor(shim, mongodb.FindCursor)
14
- instrumentCursor(shim, mongodb.AggregationCursor)
15
- instrumentCollection(shim, mongodb.Collection)
12
+ // The newrelic agent instruments mongodb (>= 4) via diagnostics_channel and
13
+ // reassigns the driver prototype methods. Reassigning those same prototype
14
+ // methods here would clobber the agent's instrumentation (and vice versa), so
15
+ // we subscribe to the agent's construction channels and attach our security
16
+ // wrapper to each constructed instance instead. A single `nr_cursor`
17
+ // subscription covers AbstractCursor and its subclasses (FindCursor,
18
+ // AggregationCursor). See https://github.com/newrelic/csec-node-agent/issues/374.
19
+ //
20
+ // If the agent does not expose those channels (older agent without
21
+ // subscriber based mongodb instrumentation), these fall back to the legacy
22
+ // prototype wrapping, which is safe because there is no colliding agent
23
+ // instrumentation in that case.
24
+ instrumentCursorViaChannel(shim, mongodb.AbstractCursor)
25
+ instrumentCollectionViaChannel(shim, mongodb.Collection)
16
26
  instrumentDb(shim, mongodb.Db)
17
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newrelic/security-agent",
3
- "version": "3.0.4",
3
+ "version": "4.0.0",
4
4
  "description": "New Relic Security Agent for Node.js",
5
5
  "main": "index.js",
6
6
  "jsonVersion": "1.2.9",
@@ -29,7 +29,7 @@
29
29
  "Node.js Application Security"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=20",
32
+ "node": ">=22.16.0",
33
33
  "npm": ">=6.0.0"
34
34
  },
35
35
  "author": "newrelic",
@@ -52,10 +52,10 @@
52
52
  "request-ip": "^3.3.0",
53
53
  "ringbufferjs": "^2.0.0",
54
54
  "semver": "^7.5.4",
55
- "undici": "^7.19.0",
55
+ "undici": "^7.30.1",
56
56
  "unescape": "^1.0.1",
57
57
  "unescape-js": "^1.1.4",
58
- "ws": "^8.17.1"
58
+ "ws": "^8.22.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@grpc/proto-loader": "^0.7.10",