@redthreadlabs/tracelog 1.7.0 → 1.9.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.
@@ -10,6 +10,10 @@ const querystring = require('querystring');
10
10
  const HEADER_FORM_URLENCODED = 'application/x-www-form-urlencoded';
11
11
  const REDACTED = require('../constants').REDACTED;
12
12
 
13
+ // Depth guard for deep JSON redaction. Far deeper than any sane request
14
+ // body; protects against adversarial nesting.
15
+ const MAX_REDACT_DEPTH = 32;
16
+
13
17
  /**
14
18
  * Handles req.body as object or string
15
19
  *
@@ -42,6 +46,91 @@ function redactKeysFromPostedFormVariables(body, requestHeaders, regexes) {
42
46
  return body;
43
47
  }
44
48
 
49
+ /**
50
+ * Redact sensitive fields from a captured request body, returning a
51
+ * structured value wherever the body is structured (since 1.9.0):
52
+ *
53
+ * - JSON content types (`application/json`, `application/*+json`, with or
54
+ * without a charset suffix) — **deep** redaction: any key at any depth
55
+ * matching the sanitizeFieldNames patterns is replaced, recursing through
56
+ * nested objects and arrays. The result is an **embedded object/array**,
57
+ * whether the body arrived parsed or as a JSON string. Strings that fail
58
+ * to parse are returned untouched as strings.
59
+ * - `application/x-www-form-urlencoded` — also returned as an embedded,
60
+ * deep-redacted object (extended parsers can nest); historically this
61
+ * re-serialized to a query string.
62
+ * - anything else — returned as-is.
63
+ *
64
+ * @param {Object | String} body
65
+ * @param {Object} requestHeaders
66
+ * @param {Array<RegExp>} regexes
67
+ * @returns {Object | Array | String} redacted body, structured when possible
68
+ */
69
+ function redactKeysFromBody(body, requestHeaders, regexes) {
70
+ // Operate even without patterns: redactDeep also normalizes circulars and
71
+ // pathological nesting, which must never reach the serializer.
72
+ const res = Array.isArray(regexes) ? regexes : [];
73
+ const contentType = String(requestHeaders['content-type'] || '');
74
+ const mime = contentType.split(';')[0].trim().toLowerCase();
75
+
76
+ // A body some middleware already parsed is structured data no matter what
77
+ // the content-type header claims — deep-redact and embed it.
78
+ if (body !== null && !Buffer.isBuffer(body) && typeof body === 'object') {
79
+ return redactDeep(body, res, 0, new WeakSet());
80
+ }
81
+
82
+ if (typeof body !== 'string') {
83
+ return body;
84
+ }
85
+
86
+ if (mime === HEADER_FORM_URLENCODED) {
87
+ // querystring.parse returns a null-prototype object; copy to a plain one
88
+ return redactDeep({ ...querystring.parse(body) }, res, 0, new WeakSet());
89
+ }
90
+
91
+ if (isJsonContentType(contentType)) {
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(body);
95
+ } catch (_err) {
96
+ return body; // claimed JSON but isn't; leave it alone
97
+ }
98
+ return redactDeep(parsed, res, 0, new WeakSet());
99
+ }
100
+
101
+ return body;
102
+ }
103
+
104
+ function isJsonContentType(contentType) {
105
+ // 'application/json', 'application/json; charset=utf-8',
106
+ // 'application/vnd.api+json', …
107
+ const mime = contentType.split(';')[0].trim().toLowerCase();
108
+ return mime === 'application/json' || mime.endsWith('+json');
109
+ }
110
+
111
+ function redactDeep(value, regexes, depth, seen) {
112
+ if (value === null || typeof value !== 'object') {
113
+ return value;
114
+ }
115
+ if (depth >= MAX_REDACT_DEPTH || seen.has(value)) {
116
+ return REDACTED; // too deep / circular: redact rather than risk leaking
117
+ }
118
+ seen.add(value);
119
+
120
+ if (Array.isArray(value)) {
121
+ return value.map((item) => redactDeep(item, regexes, depth + 1, seen));
122
+ }
123
+
124
+ const result = {};
125
+ for (const key of Object.keys(value)) {
126
+ const shouldRedact = regexes.some((regex) => regex.test(key));
127
+ result[key] = shouldRedact
128
+ ? REDACTED
129
+ : redactDeep(value[key], regexes, depth + 1, seen);
130
+ }
131
+ return result;
132
+ }
133
+
45
134
  /**
46
135
  * Returns a copy of the provided object. Each entry of the copy will have
47
136
  * its value REDACTEd if the key matches any of the regexes
@@ -66,4 +155,5 @@ function redactKeysFromObject(obj, regexes, redactedStr = REDACTED) {
66
155
  module.exports = {
67
156
  redactKeysFromObject,
68
157
  redactKeysFromPostedFormVariables,
158
+ redactKeysFromBody,
69
159
  };
package/lib/parsers.js CHANGED
@@ -12,12 +12,11 @@ const basicAuth = require('basic-auth');
12
12
  const getUrlFromRequest = require('original-url');
13
13
  const parseHttpHeadersFromReqOrRes = require('http-headers');
14
14
  const cookie = require('cookie');
15
- const stringify = require('fast-safe-stringify');
16
15
 
17
16
  const REDACTED = require('./constants').REDACTED;
18
17
  const {
19
18
  redactKeysFromObject,
20
- redactKeysFromPostedFormVariables,
19
+ redactKeysFromBody,
21
20
  } = require('./filters/sanitize-field-names');
22
21
 
23
22
  // When redacting individual cookie field values, this string is used instead
@@ -97,23 +96,26 @@ function getContextFromRequest(req, conf, type) {
97
96
  var haveBody = body && (chunked || contentLength > 0);
98
97
 
99
98
  if (haveBody) {
99
+ const bodyContentType = String(req.headers['content-type'] || '');
100
100
  if (!captureBody) {
101
101
  context.body = '[REDACTED]';
102
+ } else if (bodyContentType.split(';')[0].trim().toLowerCase().startsWith('multipart/')) {
103
+ // File uploads: never record the raw multipart body — it can embed
104
+ // entire file contents and no per-field redaction applies to it.
105
+ context.body = '[REDACTED: multipart body]';
102
106
  } else if (Buffer.isBuffer(body)) {
103
107
  context.body = '<Buffer>';
104
108
  } else {
105
109
  if (typeof body === 'string' && req.bodyIsBase64Encoded === true) {
106
110
  body = Buffer.from(body, 'base64').toString('utf8');
107
111
  }
108
- body = redactKeysFromPostedFormVariables(
112
+ // Structured bodies (JSON, form-encoded) come back as deep-redacted
113
+ // objects and are recorded embedded, not stringified (since 1.9.0).
114
+ context.body = redactKeysFromBody(
109
115
  body,
110
116
  req.headers,
111
117
  conf.sanitizeFieldNamesRegExp,
112
118
  );
113
- if (typeof body !== 'string') {
114
- body = tryJsonStringify(body) || stringify(body);
115
- }
116
- context.body = body;
117
119
  }
118
120
  }
119
121
 
@@ -211,11 +213,6 @@ function parseUrl(urlStr) {
211
213
  return new url.URL(urlStr, 'relative:///');
212
214
  }
213
215
 
214
- function tryJsonStringify(obj) {
215
- try {
216
- return JSON.stringify(obj);
217
- } catch (e) {}
218
- }
219
216
 
220
217
  module.exports = {
221
218
  getContextFromRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redthreadlabs/tracelog",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Node.js APM instrumentation that writes traces to JSONL files",
5
5
  "publishConfig": {
6
6
  "access": "public"