From 6c0d50703f394e5ac528da179455de2b7d745f41 Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@keylocation.sg>
Date: Fri, 1 Sep 2017 06:45:51 +0200
Subject: [PATCH] feat: encrypted configuration strings (#759)

A new config object `encrypted` can be defined at any level and contain encrypted configuration strings. Initial use is for encrypting an npm token for use with the hosted renovate app.

Closes #650
---
 lib/config/decrypt.js           | 55 ++++++++++++++++++++++++++++++
 lib/config/definitions.js       | 16 +++++++++
 lib/logger/config-serializer.js |  1 +
 lib/workers/repository/index.js |  3 ++
 test/_fixtures/keys/private.pem | 27 +++++++++++++++
 test/config/decrypt.spec.js     | 59 +++++++++++++++++++++++++++++++++
 6 files changed, 161 insertions(+)
 create mode 100644 lib/config/decrypt.js
 create mode 100644 test/_fixtures/keys/private.pem
 create mode 100644 test/config/decrypt.spec.js

diff --git a/lib/config/decrypt.js b/lib/config/decrypt.js
new file mode 100644
index 0000000000..45a5ff94cc
--- /dev/null
+++ b/lib/config/decrypt.js
@@ -0,0 +1,55 @@
+const crypto = require('crypto');
+
+module.exports = {
+  decryptConfig,
+};
+
+function decryptConfig(
+  config,
+  logger = config.logger,
+  privateKey = config.privateKey
+) {
+  const decryptedConfig = { ...config };
+  logger.trace({ config }, 'decryptConfig');
+  for (const key of Object.keys(config)) {
+    const val = config[key];
+    if (key === 'encrypted' && isObject(val)) {
+      logger.debug({ config: val }, 'Found encrypted config');
+      if (privateKey) {
+        for (const encryptedKey of Object.keys(val)) {
+          try {
+            decryptedConfig[encryptedKey] = crypto
+              .privateDecrypt(
+                privateKey,
+                new Buffer(val[encryptedKey], 'base64')
+              )
+              .toString();
+            logger.debug(`Decrypted ${encryptedKey}`);
+          } catch (err) {
+            logger.warn({ err }, `Error decrypting ${encryptedKey}`);
+          }
+        }
+      } else {
+        logger.error('Found encrypted data but no privateKey');
+      }
+      delete decryptedConfig.encrypted;
+    } else if (isObject(val) && key !== 'content' && key !== 'logger') {
+      decryptedConfig[key] = decryptConfig(val, logger, privateKey);
+    } else if (Array.isArray(val)) {
+      decryptedConfig[key] = [];
+      val.forEach(item => {
+        if (isObject(item)) {
+          decryptedConfig[key].push(decryptConfig(item, logger, privateKey));
+        } else {
+          decryptedConfig[key].push(item);
+        }
+      });
+    }
+  }
+  delete decryptedConfig.encrypted;
+  return decryptedConfig;
+}
+
+function isObject(obj) {
+  return Object.prototype.toString.call(obj) === '[object Object]';
+}
diff --git a/lib/config/definitions.js b/lib/config/definitions.js
index 2dca42af88..4f7193ef5b 100644
--- a/lib/config/definitions.js
+++ b/lib/config/definitions.js
@@ -71,6 +71,22 @@ const options = [
     stage: 'repository',
     type: 'boolean',
   },
+  // encryption
+  {
+    name: 'privateKey',
+    description: 'Server-side private key',
+    stage: 'repository',
+    type: 'string',
+    replaceLineReturns: true,
+  },
+  {
+    name: 'encrypted',
+    description:
+      'A configuration object containing configuration encrypted with project key',
+    stage: 'repository',
+    type: 'json',
+    default: null,
+  },
   // Scheduling
   {
     name: 'timezone',
diff --git a/lib/logger/config-serializer.js b/lib/logger/config-serializer.js
index fff9cee8f2..c8e51cd9ee 100644
--- a/lib/logger/config-serializer.js
+++ b/lib/logger/config-serializer.js
@@ -9,6 +9,7 @@ function configSerializer(config) {
     'npmToken',
     'npmrc',
     'yarnrc',
+    'privateKey',
   ];
   const functionFields = ['api', 'logger'];
   const templateFields = ['commitMessage', 'prTitle', 'prBody'];
diff --git a/lib/workers/repository/index.js b/lib/workers/repository/index.js
index fd603979b4..61f8c6899b 100644
--- a/lib/workers/repository/index.js
+++ b/lib/workers/repository/index.js
@@ -7,6 +7,7 @@ const apis = require('./apis');
 const onboarding = require('./onboarding');
 const upgrades = require('./upgrades');
 const cleanup = require('./cleanup');
+const { decryptConfig } = require('../../config/decrypt');
 
 module.exports = {
   renovateRepository,
@@ -100,6 +101,8 @@ async function renovateRepository(repoConfig, token) {
         config.logger = logger;
         logger.trace({ config }, 'onboarding config');
       }
+      config = decryptConfig(config);
+      logger.trace({ config }, 'post-decrypt config');
       const allUpgrades = await upgrades.determineRepoUpgrades(config);
       const res = await upgrades.branchifyUpgrades(allUpgrades, logger);
       config.errors = config.errors.concat(res.errors);
diff --git a/test/_fixtures/keys/private.pem b/test/_fixtures/keys/private.pem
new file mode 100644
index 0000000000..efee57e904
--- /dev/null
+++ b/test/_fixtures/keys/private.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAzVLc1KmhWxcLnoPTPpQwxVHySFx4vY+0Sk+2AdnlJvrTFlNR
+8XgBPdgU9SDwHFFTYyXRQ/msm0YMOBRoIdz4/psz6IzRV80aOtDIkUPAU+cdqnje
+3rusrPnqCykDuIHGoBZ8zlt57t/8OVNOduUflrQRqMFors8iLWcNjuGJfZAusI5B
+M8KYYDPL2oUo/8AV0bWN8MdNnxDSwGtan4bbzrtG6dpNzyntsG5DkL1h8OZRki4S
+kHf+71ZzJFz1KcyrzBVJ9DBxdiJdmLLkv9caLLc2QS0VFmtfLnlrga3Sahy6YNwc
+rN33aaNOXwFPSErsKWIgIf16eOaSUQCFusXY9QIDAQABAoIBAQCkltMM6nm9Ikkf
+JX9V/8bkth7o4K+tDSAyHZnB/CBUUeaaU+oxDci5AZkzMtcnbA3TQcJxohg6VDmB
+TuJ2msNCnblLpm492t023pyYzd3DpFXEjKXjmEAAXUm+7n7cDbPiKoSbivrAgO6Y
+KW6RonPjA6/QPlIjJ0m3aY+VxLfJXTYVfFBk+0HGEAvmrSSSEezXej8Qzs9CRKtz
+cQkR5Bs749SS509MHxaslP7n366EvJBJkqrjUxA6kbxOxUkuuOWF9jfduDAG+NYc
+pe4IXFOYMpK/w29wqvkNulKYs+FHXr1sGjkztGjyNnjP0NnX+r0EYU07xLJ26krD
+KMWQG8sBAoGBAO93iFrPnEBnnKojI8b+u3YowVCz0HVuHHIStHd9uUgfsn/Bxfls
+HRIlg8l1MJd69TT0knOhkJxBCP+b/qBvid7YLbTxIVELCAfLAzsmGgY+DEN/TTUb
+FDvHGa/drCnkSR/O/RbtHvMQISzTja1siYrdwwY+wpwzR2tB7ZH/9iO1AoGBANt/
+3Srpv4BbP7JZ+cNte4JfI2gZq41mo+DF5ryuFZzsP3R5SZ1BOapQGyLEATQaoxsJ
+QZsI73KPBzab0/+E75qJuXckIGmFftHXgoQpClGvPaEDF9M21QW5Y3vKVa/45qhy
+3wpb8gEYqrt1x0rmzyumCtCD8J470Er64gE1eKhBAoGBALW6ScFYwqRhvROkvTb8
+A7mE7kfXXgBwAqhTJ59yytRAMc8gd6R0do9Z5uxQwgKDLmj0ndugpcTe2fxZHuAU
+JVX3SqCBSZ5eN8bqOtZ9cMyB8/6ZMjd2CGHhE85R9KCJ/TBlfc4TPySIfhStq1wL
+/UlkR+eKY1f01mNAUhE1ZU7tAoGBAKwS/B51KsSERFYcRToYbRfSX55vaVarnVNL
+scw+qQDhEAnOP5CBHqTOscc6YzsmmrFKO10/zv8+80ezN6n73B6JU5T8BFDU74uv
+6EiVJ9rLh4PfOeFB/hPDtyLHhw8yEBkEHKgxVnHXlZjqBzdH5Cdyvs2icZKKj4sI
+TP7nnVRBAoGAPZBjjTn9HgB9et5Kdovp+7nr842WboxwklVyTpbNGAjIxNXIXex+
+TST21UbquTIpIrpYRk1WMIu9T5PndAagDWVEsmUYQZeuhmuY64K+iviRsfdGthws
+nbbR8sMEkn4XEtPZfrEBq27g01RNmIIMW5Es2O5N2AjLlyeBOh099Fw=
+-----END RSA PRIVATE KEY-----
diff --git a/test/config/decrypt.spec.js b/test/config/decrypt.spec.js
new file mode 100644
index 0000000000..95508e4b15
--- /dev/null
+++ b/test/config/decrypt.spec.js
@@ -0,0 +1,59 @@
+const { decryptConfig } = require('../../lib/config/decrypt.js');
+const defaultConfig = require('../../lib/config/defaults').getConfig();
+const logger = require('../_fixtures/logger');
+const fs = require('fs');
+
+const privateKey = fs.readFileSync('test/_fixtures/keys/private.pem');
+
+describe('config/massage', () => {
+  describe('massageConfig', () => {
+    let config;
+    beforeEach(() => {
+      config = { ...defaultConfig, logger };
+    });
+    it('returns empty with no privateKey', () => {
+      delete config.encrypted;
+      const res = decryptConfig(config);
+      expect(res).toMatchObject(config);
+    });
+    it('warns if no privateKey found', () => {
+      config.encrypted = { a: '1' };
+      const res = decryptConfig(config);
+      expect(res.encrypted).not.toBeDefined();
+      expect(res.a).not.toBeDefined();
+    });
+    it('handles invalid encrypted type', () => {
+      config.encrypted = 1;
+      config.privateKey = privateKey;
+      const res = decryptConfig(config);
+      expect(res.encrypted).not.toBeDefined();
+    });
+    it('handles invalid encrypted value', () => {
+      config.encrypted = { a: 1 };
+      config.privateKey = privateKey;
+      const res = decryptConfig(config);
+      expect(res.encrypted).not.toBeDefined();
+      expect(res.a).not.toBeDefined();
+    });
+    it('decrypts nested', () => {
+      config.privateKey = privateKey;
+      config.packageFiles = [
+        {
+          packageFile: 'package.json',
+          devDependencies: {
+            encrypted: {
+              branchPrefix:
+                'FLA9YHIzpE7YetAg/P0X46npGRCMqn7hgyzwX5ZQ9wYgu9BRRbTiBVsUIFTyM5BuP1Q22slT2GkWvFvum7GU236Y6QiT7Nr8SLvtsJn2XUuq8H7REFKzdy3+wqyyWbCErYTFyY1dcPM7Ht+CaGDWdd8u/FsoX7AdMRs/X1jNUo6iSmlUiyGlYDKF+QMnCJom1VPVgZXWsGKdjI2MLny991QMaiv0VajmFIh4ENv4CtXOl/1twvIl/6XTXAaqpJJKDTPZEuydi+PHDZmal2RAOfrkH4m0UURa7SlfpUlIg+EaqbNGp85hCYXLwRcEET1OnYr3rH1oYkcYJ40any1tvQ==',
+            },
+          },
+        },
+      ];
+      const res = decryptConfig(config);
+      expect(res.encrypted).not.toBeDefined();
+      expect(res.packageFiles[0].devDependencies.encrypted).not.toBeDefined();
+      expect(res.packageFiles[0].devDependencies.branchPrefix).toEqual(
+        'abcdef-ghijklm-nopqf-stuvwxyz'
+      );
+    });
+  });
+});
-- 
GitLab