zapier-platform-core 12.0.2 → 12.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapier-platform-core",
3
- "version": "12.0.2",
3
+ "version": "12.0.3",
4
4
  "description": "The core SDK for CLI apps in the Zapier Developer Platform.",
5
5
  "repository": "zapier/zapier-platform",
6
6
  "homepage": "https://platform.zapier.com/",
@@ -50,7 +50,7 @@
50
50
  "node-fetch": "2.6.7",
51
51
  "oauth-sign": "0.9.0",
52
52
  "semver": "7.3.5",
53
- "zapier-platform-schema": "12.0.2"
53
+ "zapier-platform-schema": "12.0.3"
54
54
  },
55
55
  "devDependencies": {
56
56
  "adm-zip": "0.5.5",
@@ -76,4 +76,4 @@ const logResponse = (resp) => {
76
76
  .catch(() => resp);
77
77
  };
78
78
 
79
- module.exports = logResponse;
79
+ module.exports = { logResponse, prepareRequestLog };
@@ -16,7 +16,7 @@ const oauth1SignRequest = require('../http-middlewares/before/oauth1-sign-reques
16
16
  const prepareRequest = require('../http-middlewares/before/prepare-request');
17
17
 
18
18
  // after middles
19
- const logResponse = require('../http-middlewares/after/log-response');
19
+ const { logResponse } = require('../http-middlewares/after/log-response');
20
20
  const prepareResponse = require('../http-middlewares/after/prepare-response');
21
21
  const throwForStaleAuth = require('../http-middlewares/after/throw-for-stale-auth');
22
22
  const throwForStatusMiddleware = require('../http-middlewares/after/throw-for-status');
@@ -1,11 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const { Transform } = require('stream');
4
+ const { parse: querystringParse } = require('querystring');
4
5
 
5
6
  const _ = require('lodash');
6
7
 
7
8
  const request = require('./request-client-internal');
8
- const { simpleTruncate, recurseReplace } = require('./data');
9
+ const { simpleTruncate, recurseReplace, truncateData } = require('./data');
9
10
  const {
10
11
  DEFAULT_LOGGING_HTTP_API_KEY,
11
12
  DEFAULT_LOGGING_HTTP_ENDPOINT,
@@ -18,7 +19,10 @@ const {
18
19
  recurseExtract,
19
20
  } = require('@zapier/secret-scrubber');
20
21
  // not really a public function, but it came from here originally
21
- const { isUrlWithSecrets } = require('@zapier/secret-scrubber/lib/convenience');
22
+ const {
23
+ isUrlWithSecrets,
24
+ isSensitiveKey,
25
+ } = require('@zapier/secret-scrubber/lib/convenience');
22
26
 
23
27
  // The payload size per request to stream logs. This should be slighly lower
24
28
  // than the limit (16 MB) on the server side.
@@ -34,7 +38,8 @@ const isUrl = (url) => {
34
38
  }
35
39
  };
36
40
 
37
- const truncate = (str) => simpleTruncate(str, 3500, ' [...]');
41
+ const MAX_LENGTH = 3500;
42
+ const truncateString = (str) => simpleTruncate(str, MAX_LENGTH, ' [...]');
38
43
 
39
44
  const formatHeaders = (headers = {}) => {
40
45
  if (_.isEmpty(headers)) {
@@ -70,7 +75,7 @@ const httpDetailsLogMessage = (data) => {
70
75
  (result, value, key) => {
71
76
  result[key] = value;
72
77
  if (typeof value === 'string') {
73
- result[key] = truncate(value);
78
+ result[key] = truncateString(value);
74
79
  }
75
80
  return result;
76
81
  },
@@ -108,22 +113,54 @@ const toStdout = (event, msg, data) => {
108
113
  }
109
114
  };
110
115
 
116
+ // try to parse json; if successful, find secrets in it
117
+ const attemptFindSecretsInStr = (s) => {
118
+ let parsedRespContent;
119
+ try {
120
+ parsedRespContent = JSON.parse(s);
121
+ } catch {
122
+ return [];
123
+ }
124
+ return findSensitiveValues(parsedRespContent);
125
+ };
126
+
111
127
  const buildSensitiveValues = (event, data) => {
112
128
  const bundle = event.bundle || {};
113
129
  const authData = bundle.authData || {};
114
130
  // for the most part, we should censor all the values from authData
115
131
  // the exception is safe urls, which should be filtered out - we want those to be logged
132
+ // but, we _should_ censor-no-matter-what sensitive keys, even if their value is a safe url
133
+ // this covers the case where someone's password is a valid url ¯\_(ツ)_/¯
116
134
  const sensitiveAuthData = recurseExtract(authData, (key, value) => {
135
+ if (isSensitiveKey(key)) {
136
+ return true;
137
+ }
138
+
117
139
  if (isUrl(value) && !isUrlWithSecrets(value)) {
118
140
  return false;
119
141
  }
120
142
  return true;
121
143
  });
122
- return [
144
+
145
+ const result = [
123
146
  ...sensitiveAuthData,
124
147
  ...findSensitiveValues(process.env),
125
148
  ...findSensitiveValues(data),
126
149
  ];
150
+
151
+ // for our http logs (genrated by prepareRequestLog), make sure that we try to parse the content to find any new strings
152
+ // (such as what comes back in the response during an auth refresh)
153
+
154
+ for (const prop of ['response_content', 'request_data']) {
155
+ if (data[prop]) {
156
+ result.push(...attemptFindSecretsInStr(data[prop]));
157
+ }
158
+ }
159
+ if (data.request_params) {
160
+ result.push(...findSensitiveValues(querystringParse(data.request_params)));
161
+ }
162
+ // unique- no point in duplicates
163
+ return [...new Set(result)];
127
164
  };
128
165
 
129
166
  class LogStream extends Transform {
@@ -207,15 +244,21 @@ const sendLog = async (logStreamFactory, options, event, message, data) => {
207
244
  data.response_headers = unheader(data.response_headers);
208
245
 
209
246
  const sensitiveValues = buildSensitiveValues(event, data);
247
+
248
+ // data.input and data.output have the ability to grow unbounded; the following caps the size to a reasonable amount
249
+ if (data.log_type === 'bundle') {
250
+ data.input = truncateData(data.input, MAX_LENGTH);
251
+ data.output = truncateData(data.output, MAX_LENGTH);
252
+ }
210
253
  // scrub throws an error if there are no secrets
211
- const safeMessage = truncate(
254
+ const safeMessage = truncateString(
212
255
  sensitiveValues.length ? scrub(message, sensitiveValues) : message
213
256
  );
214
257
  const safeData = recurseReplace(
215
258
  sensitiveValues.length ? scrub(data, sensitiveValues) : data,
216
- truncate
259
+ truncateString
217
260
  );
218
- const unsafeData = recurseReplace(data, truncate);
261
+ const unsafeData = recurseReplace(data, truncateString);
219
262
  // Keep safe log keys uncensored
220
263
  Object.keys(safeData).forEach((key) => {
221
264
  if (SAFE_LOG_KEYS.includes(key)) {
package/src/tools/data.js CHANGED
@@ -172,6 +172,162 @@ const simpleTruncate = (string, length, suffix) => {
172
172
  return string;
173
173
  };
174
174
 
175
+ /**
176
+ * Adds an item to an object or array.
177
+ * If the parent is an object, the value will be set at the specified key.
178
+ * If the parent is an array, the value will be added to the end of the array (and the key will be ignored).
179
+ * Used by truncateData.
180
+ *
181
+ * @param {object | any[]} parent An object or array.
182
+ * @param {string} key The key to set the value at (objects only; ignored for arrays).
183
+ * @param {any} value The value to add.
184
+ */
185
+ const _addItem = (parent, key, value) => {
186
+ if (Array.isArray(parent)) {
187
+ parent.push(value);
188
+ } else {
189
+ parent[key] = value;
190
+ }
191
+ };
192
+
193
+ /**
194
+ * Examines an object entry or array entry (`item`) and determines its "cost" (how many characters will it take to add it to `parent`).
195
+ * If the item is a string, `availableSpace` is used to determine if the string should be truncated.
196
+ * If the item is an object or array, its entries / values are added to `queue`.
197
+ * Used by truncateData.
198
+ *
199
+ * @param {any[]} queue
200
+ * @param {object | any[]} parent
201
+ * @param {string} key
202
+ * @param {any} item
203
+ * @param {number} availableSpace
204
+ * @returns
205
+ */
206
+ const _processItem = (queue, parent, key, item, availableSpace) => {
207
+ let itemLength = 0;
208
+ let itemToAdd = item;
209
+ let wasTruncated = false;
210
+
211
+ itemLength += key.length; // array keys are empty strings, so this is a noop
212
+ itemLength += key.length ? 3 : 0; // objects get +2 for "" around the key and +1 for the :
213
+ itemLength += 1; // arrays and objects both have +1 for commas between entries
214
+ if (parent && typeof parent === 'object' && _.isEmpty(parent)) {
215
+ itemLength -= 1; // this is the first entry for an object or array; remove the count for a comma
216
+ }
217
+
218
+ if (typeof item === 'number' || typeof item === 'boolean' || item == null) {
219
+ itemLength += String(item).length;
220
+ } else if (typeof item === 'string') {
221
+ const overhead = itemLength + 2; // the minimum amount of space needed after truncation
222
+ if (item.length + overhead > availableSpace) {
223
+ // this string is going to push us over the edge; truncate it
224
+ itemToAdd = simpleTruncate(item, availableSpace - overhead, ' [...]');
225
+ wasTruncated = true;
226
+ }
227
+ itemLength += itemToAdd.length + 2; // 2 for quotes around the string value
228
+ } else if (typeof item === 'object') {
229
+ itemLength += 2; // '{}' or '[]'
230
+
231
+ let entries;
232
+ if (Array.isArray(item)) {
233
+ const newArr = [];
234
+ itemToAdd = newArr;
235
+ entries = item.map((subValue) => [newArr, '', subValue]);
236
+ } else {
237
+ const newObj = {};
238
+ itemToAdd = newObj;
239
+ entries = Object.entries(item).map(([subKey, subValue]) => [
240
+ newObj,
241
+ subKey,
242
+ subValue,
243
+ ]);
244
+ }
245
+ queue.unshift(...entries);
246
+ } else {
247
+ // JSON.stringify doesn't usually really do anything for any other typeofs
248
+ // we're just going to use `undefined` and hope for the best
249
+ itemLength += 'undefined'.length;
250
+ itemToAdd = undefined;
251
+ }
252
+ return [itemLength, itemToAdd, wasTruncated];
253
+ };
254
+
255
+ /**
256
+ * Takes a given `data` object or array and copies pieces of that data into `output` until its stringified length fits in `maxLength` characters.
257
+ *
258
+ * In general, output should track with `JSON.stringify(item).substring(0, maxLength)` (i.e. depth-first traversal of arrays and object entries), but in a JSON-aware way.
259
+ * If the item's initial stringified length is less than or equal to `maxLength`, the item is returned as-is.
260
+ * @param {object | any[]} data The JSON object or array to be truncated.
261
+ * @param {number} maxLength The maximum length of JSON.stringify(output). Note that this may not be the exact output length, but it serves as an upper bound. Minimum value is 40.
262
+ * @returns {object | any[]} The truncated object or array.
263
+ */
264
+ const truncateData = (data, maxLength) => {
265
+ if (!data || typeof data !== 'object') {
266
+ // the following code is only meant to work on objects and arrays
267
+ return data;
268
+ }
269
+ if (JSON.stringify(data).length <= maxLength) {
270
+ // no need to truncate
271
+ return data;
272
+ }
273
+
274
+ const root = Array.isArray(data) ? [] : {};
275
+ let length = 2; // '{}' or '[]'
276
+ let dataWasTruncated = false; // used during iteration to track if a string was truncated
277
+ const truncateMessageSize = 39; // the overhead required to add a message about truncating data
278
+
279
+ if (maxLength < 40) {
280
+ // adding the truncate message takes 39 characters, but the minimum output (i.e. just the message wrapped in an object or array)
281
+ // is 40 characters due to the overhead of the {} or [] characters (+2) minus the comma (-1)
282
+ throw new Error(`maxLength must be at least 40`);
283
+ }
284
+
285
+ const queue = Array.isArray(data)
286
+ ? data.map((value) => [root, '', value])
287
+ : Object.entries(data).map(([key, value]) => [root, key, value]);
288
+
289
+ // iterate over the queue
290
+ while (queue.length > 0) {
291
+ const [parent, key, item] = queue.shift();
292
+ const [itemLength, processedItem, itemWasTruncated] = _processItem(
293
+ queue,
294
+ parent,
295
+ key,
296
+ item,
297
+ maxLength - length - truncateMessageSize
298
+ );
299
+
300
+ if (itemWasTruncated) {
301
+ // if a string was truncated, we mark the total data as truncated for messaging purposes
302
+ dataWasTruncated = true;
303
+ }
304
+
305
+ if (length + itemLength + truncateMessageSize < maxLength) {
306
+ // we're still under the max length, add this and keep going
307
+ _addItem(parent, key, processedItem);
308
+ length += itemLength;
309
+ } else {
310
+ if (length + itemLength + truncateMessageSize === maxLength) {
311
+ // we can fit this item + the truncate message, so let's add it before we stop
312
+ _addItem(parent, key, processedItem);
313
+ }
314
+ dataWasTruncated = true;
315
+ break;
316
+ }
317
+ }
318
+
319
+ // we can hit the following even if we got through all the items in the queue in the case that any strings were truncated
320
+ if (dataWasTruncated) {
321
+ if (Array.isArray(root)) {
322
+ root.push('NOTE : This data has been truncated.');
323
+ } else {
324
+ root.NOTE = 'This data has been truncated.';
325
+ }
326
+ }
327
+
328
+ return root;
329
+ };
330
+
175
331
  const genId = () => parseInt(Math.random() * 100000000);
176
332
 
177
333
  module.exports = {
@@ -186,4 +342,5 @@ module.exports = {
186
342
  memoizedFindMapDeep,
187
343
  recurseReplace,
188
344
  simpleTruncate,
345
+ truncateData,
189
346
  };