Repository: semantic-release/semantic-release
Pull request #910 (2018-08-27 19:59:24)

fix: hide sensitive data in relesae notes and `fail`/`success` plugin params

felixfbecker: Thanks!

Patch:
diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js
index bd37c3a..bac4c6c 100644
--- a/lib/definitions/plugins.js
+++ b/lib/definitions/plugins.js
@@ -1,5 +1,7 @@
 const {isString, isPlainObject} = require('lodash');
 const {gitHead} = require('../git');
+const hideSensitive = require('../hide-sensitive');
+const {hideSensitiveValues} = require('../utils');
 const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants');
 
 module.exports = {
@@ -40,7 +42,7 @@ module.exports = {
         },
       }),
     }),
-    postprocess: results => results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR),
+    postprocess: (results, {env}) => hideSensitive(env)(results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR)),
   },
   prepare: {
     default: ['@semantic-release/npm'],
@@ -80,11 +82,13 @@ module.exports = {
     multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
+    preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}),
   },
   fail: {
     default: ['@semantic-release/github'],
     multiple: true,
     required: false,
     pipelineConfig: () => ({settleAll: true}),
+    preprocess: ({errors, env, ...inputs}) => ({...inputs, env, errors: hideSensitiveValues(env, errors)}),
   },
 };
diff --git a/lib/hide-sensitive.js b/lib/hide-sensitive.js
index bd68056..01dba6a 100644
--- a/lib/hide-sensitive.js
+++ b/lib/hide-sensitive.js
@@ -1,4 +1,4 @@
-const {escapeRegExp, size} = require('lodash');
+const {escapeRegExp, size, isString} = require('lodash');
 const {SECRET_REPLACEMENT, SECRET_MIN_SIZE} = require('./definitions/constants');
 
 module.exports = env => {
@@ -7,5 +7,6 @@ module.exports = env => {
   );
 
   const regexp = new RegExp(toReplace.map(envVar => escapeRegExp(env[envVar])).join('|'), 'g');
-  return output => (output && toReplace.length > 0 ? output.toString().replace(regexp, SECRET_REPLACEMENT) : output);
+  return output =>
+    output && isString(output) && toReplace.length > 0 ? output.toString().replace(regexp, SECRET_REPLACEMENT) : output;
 };
diff --git a/lib/plugins/index.js b/lib/plugins/index.js
index 9abd058..12a2055 100644
--- a/lib/plugins/index.js
+++ b/lib/plugins/index.js
@@ -36,7 +36,10 @@ module.exports = (context, pluginsPath) => {
       );
 
       plugins[type] = async input =>
-        postprocess(await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)));
+        postprocess(
+          await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)),
+          input
+        );
 
       return plugins;
     },
diff --git a/lib/utils.js b/lib/utils.js
index 66fa78e..b3e1a25 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -1,7 +1,20 @@
 const {isFunction} = require('lodash');
+const hideSensitive = require('./hide-sensitive');
 
 function extractErrors(err) {
   return err && isFunction(err[Symbol.iterator]) ? [...err] : [err];
 }
 
-module.exports = {extractErrors};
+function hideSensitiveValues(env, objs) {
+  const hideFunction = hideSensitive(env);
+  return objs.map(obj => {
+    Object.getOwnPropertyNames(obj).forEach(prop => {
+      if (obj[prop]) {
+        obj[prop] = hideFunction(obj[prop]);
+      }
+    });
+    return obj;
+  });
+}
+
+module.exports = {extractErrors, hideSensitiveValues};


Test patch:
diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js
index 1ca6b3c..61a86c7 100644
--- a/test/definitions/plugins.test.js
+++ b/test/definitions/plugins.test.js
@@ -1,6 +1,6 @@
 import test from 'ava';
 import plugins from '../../lib/definitions/plugins';
-import {RELEASE_NOTES_SEPARATOR} from '../../lib/definitions/constants';
+import {RELEASE_NOTES_SEPARATOR, SECRET_REPLACEMENT} from '../../lib/definitions/constants';
 
 test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
   t.false(plugins.analyzeCommits.outputValidator('invalid'));
@@ -32,10 +32,22 @@ test('The "publish" plugin output, if defined, must be an object', t => {
   t.true(plugins.publish.outputValidator(''));
 });
 
-test('The "generateNotes" plugins output are concatenated with separator', t => {
-  t.is(plugins.generateNotes.postprocess(['note 1', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
-  t.is(plugins.generateNotes.postprocess(['', 'note']), 'note');
-  t.is(plugins.generateNotes.postprocess([undefined, 'note']), 'note');
-  t.is(plugins.generateNotes.postprocess(['note 1', '', 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
-  t.is(plugins.generateNotes.postprocess(['note 1', undefined, 'note 2']), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
+test('The "generateNotes" plugins output are concatenated with separator and sensitive data is hidden', t => {
+  const env = {MY_TOKEN: 'secret token'};
+  t.is(plugins.generateNotes.postprocess(['note 1', 'note 2'], {env}), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
+  t.is(plugins.generateNotes.postprocess(['', 'note'], {env}), 'note');
+  t.is(plugins.generateNotes.postprocess([undefined, 'note'], {env}), 'note');
+  t.is(plugins.generateNotes.postprocess(['note 1', '', 'note 2'], {env}), `note 1${RELEASE_NOTES_SEPARATOR}note 2`);
+  t.is(
+    plugins.generateNotes.postprocess(['note 1', undefined, 'note 2'], {env}),
+    `note 1${RELEASE_NOTES_SEPARATOR}note 2`
+  );
+
+  t.is(
+    plugins.generateNotes.postprocess(
+      [`Note 1: Exposing token ${env.MY_TOKEN}`, `Note 2: Exposing token ${SECRET_REPLACEMENT}`],
+      {env}
+    ),
+    `Note 1: Exposing token ${SECRET_REPLACEMENT}${RELEASE_NOTES_SEPARATOR}Note 2: Exposing token ${SECRET_REPLACEMENT}`
+  );
 });
diff --git a/test/index.test.js b/test/index.test.js
index a57187e..6c08deb 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -1,10 +1,11 @@
 import test from 'ava';
+import {escapeRegExp, isString} from 'lodash';
 import proxyquire from 'proxyquire';
 import {spy, stub} from 'sinon';
 import {WritableStreamBuffer} from 'stream-buffers';
 import AggregateError from 'aggregate-error';
 import SemanticReleaseError from '@semantic-release/error';
-import {COMMIT_NAME, COMMIT_EMAIL} from '../lib/definitions/constants';
+import {COMMIT_NAME, COMMIT_EMAIL, SECRET_REPLACEMENT} from '../lib/definitions/constants';
 import {
   gitHead as getGitHead,
   gitTagHead,
@@ -1031,6 +1032,87 @@ test('Throw an Error if plugin returns an unexpected value', async t => {
   t.regex(error.details, /string/);
 });
 
+test('Hide sensitive information passed to "fail" plugin', async t => {
+  const {cwd, repositoryUrl} = await gitRepo(true);
+
+  const fail = stub().resolves();
+  const env = {MY_TOKEN: 'secret token'};
+  const options = {
+    branch: 'master',
+    repositoryUrl,
+    verifyConditions: stub().throws(
+      new SemanticReleaseError(
+        `Message: Exposing token ${env.MY_TOKEN}`,
+        'ERR',
+        `Details: Exposing token ${env.MY_TOKEN}`
+      )
+    ),
+    success: stub().resolves(),
+    fail,
+  };
+
+  const semanticRelease = requireNoCache('..', {
+    './lib/get-logger': () => t.context.logger,
+    'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
+  });
+  await t.throws(
+    semanticRelease(options, {cwd, env, stdout: new WritableStreamBuffer(), stderr: new WritableStreamBuffer()}),
+    Error
+  );
+
+  const error = fail.args[0][1].errors[0];
+
+  t.is(error.message, `Message: Exposing token ${SECRET_REPLACEMENT}`);
+  t.is(error.details, `Details: Exposing token ${SECRET_REPLACEMENT}`);
+
+  Object.getOwnPropertyNames(error).forEach(prop => {
+    if (isString(error[prop])) {
+      t.notRegex(error[prop], new RegExp(escapeRegExp(env.MY_TOKEN)));
+    }
+  });
+});
+
+test('Hide sensitive information passed to "success" plugin', async t => {
+  const {cwd, repositoryUrl} = await gitRepo(true);
+  await gitCommits(['feat: initial release'], {cwd});
+  await gitTagVersion('v1.0.0', undefined, {cwd});
+  await gitCommits(['feat: new feature'], {cwd});
+  await gitPush(repositoryUrl, 'master', {cwd});
+
+  const success = stub().resolves();
+  const env = {MY_TOKEN: 'secret token'};
+  const options = {
+    branch: 'master',
+    repositoryUrl,
+    verifyConditions: false,
+    verifyRelease: false,
+    prepare: false,
+    publish: stub().resolves({
+      name: `Name: Exposing token ${env.MY_TOKEN}`,
+      url: `URL: Exposing token ${env.MY_TOKEN}`,
+    }),
+    success,
+    fail: stub().resolves(),
+  };
+
+  const semanticRelease = requireNoCache('..', {
+    './lib/get-logger': () => t.context.logger,
+    'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
+  });
+  await semanticRelease(options, {cwd, env, stdout: new WritableStreamBuffer(), stderr: new WritableStreamBuffer()});
+
+  const release = success.args[0][1].releases[0];
+
+  t.is(release.name, `Name: Exposing token ${SECRET_REPLACEMENT}`);
+  t.is(release.url, `URL: Exposing token ${SECRET_REPLACEMENT}`);
+
+  Object.getOwnPropertyNames(release).forEach(prop => {
+    if (isString(release[prop])) {
+      t.notRegex(release[prop], new RegExp(escapeRegExp(env.MY_TOKEN)));
+    }
+  });
+});
+
 test('Get all commits including the ones not in the shallow clone', async t => {
   let {cwd, repositoryUrl} = await gitRepo(true);
   await gitTagVersion('v1.0.0', undefined, {cwd});