From 2515a89dabf88bd6d4cf06386053352ea4107f7e Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Sat, 16 May 2020 12:35:41 +0200
Subject: [PATCH] fix(logging): sanitize known token (#5917)

---
 lib/config/decrypt.ts                         |  3 +
 lib/datasource/npm/npmrc.spec.ts              | 56 +++++++++++++++++++
 lib/datasource/npm/npmrc.ts                   | 23 +++++++-
 .../config-serializer.spec.ts.snap            |  8 ---
 lib/logger/config-serializer.spec.ts          |  8 ---
 lib/logger/config-serializer.ts               | 15 -----
 lib/logger/index.spec.ts                      |  8 +++
 lib/logger/utils.ts                           | 28 ++++++++--
 lib/util/sanitize.ts                          | 13 +++++
 lib/workers/repository/init/index.spec.ts     |  3 +
 10 files changed, 129 insertions(+), 36 deletions(-)
 create mode 100644 lib/datasource/npm/npmrc.spec.ts

diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts
index 682cd5a179..7fa3a9c860 100644
--- a/lib/config/decrypt.ts
+++ b/lib/config/decrypt.ts
@@ -2,6 +2,7 @@ import crypto from 'crypto';
 import is from '@sindresorhus/is';
 import { logger } from '../logger';
 import { maskToken } from '../util/mask';
+import { add } from '../util/sanitize';
 import { RenovateConfig } from './common';
 
 export function decryptConfig(
@@ -43,6 +44,7 @@ export function decryptConfig(
             logger.debug(`Decrypted ${eKey}`);
             if (eKey === 'npmToken') {
               const token = decryptedStr.replace(/\n$/, '');
+              add(token);
               logger.debug(
                 { decryptedToken: maskToken(token) },
                 'Migrating npmToken to npmrc'
@@ -71,6 +73,7 @@ export function decryptConfig(
               }
             } else {
               decryptedConfig[eKey] = decryptedStr;
+              add(decryptedStr);
             }
           } catch (err) {
             const error = new Error('config-validation');
diff --git a/lib/datasource/npm/npmrc.spec.ts b/lib/datasource/npm/npmrc.spec.ts
new file mode 100644
index 0000000000..ee4f5e63ec
--- /dev/null
+++ b/lib/datasource/npm/npmrc.spec.ts
@@ -0,0 +1,56 @@
+import { getName, mocked } from '../../../test/util';
+import * as _sanitize from '../../util/sanitize';
+import { getNpmrc, setNpmrc } from './npmrc';
+
+jest.mock('../../util/sanitize');
+
+const sanitize = mocked(_sanitize);
+
+describe(getName(__filename), () => {
+  beforeEach(() => {
+    delete process.env.NPM_TOKEN;
+    delete global.trustLevel;
+    setNpmrc('');
+    jest.resetAllMocks();
+  });
+
+  it('sanitize _auth', () => {
+    setNpmrc('_auth=test');
+    expect(sanitize.add).toBeCalledWith('test');
+    expect(sanitize.add).toBeCalledTimes(1);
+  });
+
+  it('sanitize _authtoken', () => {
+    // eslint-disable-next-line no-template-curly-in-string
+    setNpmrc('//registry.test.com:_authToken=test\n_authToken=${NPM_TOKEN}');
+    expect(sanitize.add).toBeCalledWith('test');
+    expect(sanitize.add).toBeCalledTimes(1);
+  });
+
+  it('sanitize _password', () => {
+    setNpmrc(
+      `registry=https://test.org\n//test.org/:username=test\n//test.org/:_password=dGVzdA==`
+    );
+    expect(sanitize.add).toHaveBeenNthCalledWith(1, 'dGVzdA==');
+    expect(sanitize.add).toHaveBeenNthCalledWith(2, 'test');
+    expect(sanitize.add).toHaveBeenNthCalledWith(3, 'dGVzdDp0ZXN0');
+    expect(sanitize.add).toBeCalledTimes(3);
+  });
+
+  it('sanitize _authtoken with high trust', () => {
+    global.trustLevel = 'high';
+    process.env.TEST_TOKEN = 'test';
+    setNpmrc(
+      // eslint-disable-next-line no-template-curly-in-string
+      '//registry.test.com:_authToken=${TEST_TOKEN}\n_authToken=\nregistry=http://localhost'
+    );
+    expect(sanitize.add).toBeCalledWith('test');
+    expect(sanitize.add).toBeCalledTimes(1);
+  });
+
+  it('ignores localhost', () => {
+    setNpmrc(`registry=http://localhost`);
+    expect(sanitize.add).toHaveBeenCalledTimes(0);
+    expect(getNpmrc()).toBeNull();
+  });
+});
diff --git a/lib/datasource/npm/npmrc.ts b/lib/datasource/npm/npmrc.ts
index 2c78b868bf..c383519ae4 100644
--- a/lib/datasource/npm/npmrc.ts
+++ b/lib/datasource/npm/npmrc.ts
@@ -1,6 +1,7 @@
 import is from '@sindresorhus/is';
 import ini from 'ini';
 import { logger } from '../../logger';
+import { add } from '../../util/sanitize';
 
 let npmrc: Record<string, any> | null = null;
 let npmrcRaw: string;
@@ -26,6 +27,23 @@ function envReplace(value: any, env = process.env): any {
   });
 }
 
+const envRe = /(\\*)\$\{([^}]+)\}/;
+// TODO: better add to host rules
+function sanitize(key: string, val: string): void {
+  if (!val || envRe.test(val)) {
+    return;
+  }
+  if (key.endsWith('_authToken') || key.endsWith('_auth')) {
+    add(val);
+  } else if (key.endsWith(':_password')) {
+    add(val);
+    const password = Buffer.from(val, 'base64').toString();
+    add(password);
+    const username = npmrc[key.replace(':_password', ':username')];
+    add(Buffer.from(`${username}:${password}`).toString('base64'));
+  }
+}
+
 export function setNpmrc(input?: string): void {
   if (input) {
     if (input === npmrcRaw) {
@@ -36,7 +54,9 @@ export function setNpmrc(input?: string): void {
     logger.debug('Setting npmrc');
     npmrc = ini.parse(input.replace(/\\n/g, '\n'));
     for (const [key, val] of Object.entries(npmrc)) {
-      // istanbul ignore if
+      if (global.trustLevel !== 'high') {
+        sanitize(key, val);
+      }
       if (
         global.trustLevel !== 'high' &&
         key.endsWith('registry') &&
@@ -56,6 +76,7 @@ export function setNpmrc(input?: string): void {
     }
     for (const key of Object.keys(npmrc)) {
       npmrc[key] = envReplace(npmrc[key]);
+      sanitize(key, npmrc[key]);
     }
   } else if (npmrc) {
     logger.debug('Resetting npmrc');
diff --git a/lib/logger/__snapshots__/config-serializer.spec.ts.snap b/lib/logger/__snapshots__/config-serializer.spec.ts.snap
index 75f1a41fae..7d087484e2 100644
--- a/lib/logger/__snapshots__/config-serializer.spec.ts.snap
+++ b/lib/logger/__snapshots__/config-serializer.spec.ts.snap
@@ -1,13 +1,5 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`logger/config-serializer redacts sensitive fields 1`] = `
-Object {
-  "githubAppKey": "***********",
-  "nottoken": "b",
-  "token": "***********",
-}
-`;
-
 exports[`logger/config-serializer squashes templates 1`] = `
 Object {
   "nottoken": "b",
diff --git a/lib/logger/config-serializer.spec.ts b/lib/logger/config-serializer.spec.ts
index 95cbfa9081..d3079b2b5d 100644
--- a/lib/logger/config-serializer.spec.ts
+++ b/lib/logger/config-serializer.spec.ts
@@ -1,14 +1,6 @@
 import configSerializer from './config-serializer';
 
 describe('logger/config-serializer', () => {
-  it('redacts sensitive fields', () => {
-    const config = {
-      token: 'a',
-      nottoken: 'b',
-      githubAppKey: 'c',
-    };
-    expect(configSerializer(config)).toMatchSnapshot();
-  });
   it('squashes templates', () => {
     const config = {
       nottoken: 'b',
diff --git a/lib/logger/config-serializer.ts b/lib/logger/config-serializer.ts
index 6167662bba..856531945e 100644
--- a/lib/logger/config-serializer.ts
+++ b/lib/logger/config-serializer.ts
@@ -4,18 +4,6 @@ import { RenovateConfig } from '../config/common';
 export default function configSerializer(
   config: RenovateConfig
 ): RenovateConfig {
-  const redactedFields = [
-    'authorization',
-    'token',
-    'githubAppKey',
-    'npmToken',
-    'npmrc',
-    'yarnrc',
-    'privateKey',
-    'gitPrivateKey',
-    'forkToken',
-    'password',
-  ];
   const templateFields = ['prBody'];
   const contentFields = [
     'content',
@@ -28,9 +16,6 @@ export default function configSerializer(
   return traverse(config).map(
     // eslint-disable-next-line array-callback-return
     function scrub(val: string) {
-      if (val && redactedFields.includes(this.key)) {
-        this.update('***********');
-      }
       if (val && templateFields.includes(this.key)) {
         this.update('[Template]');
       }
diff --git a/lib/logger/index.spec.ts b/lib/logger/index.spec.ts
index 37decab0d4..892cbafb4a 100644
--- a/lib/logger/index.spec.ts
+++ b/lib/logger/index.spec.ts
@@ -143,11 +143,19 @@ describe('logger', () => {
     logger.error({
       foo: 'secret"password',
       bar: ['somethingelse', 'secret"password'],
+      npmToken: 'token',
+      buffer: Buffer.from('test'),
+      content: 'test',
+      prBody: 'test',
     });
 
     expect(logged.foo).not.toEqual('secret"password');
     expect(logged.bar[0]).toEqual('somethingelse');
     expect(logged.foo).toContain('redacted');
     expect(logged.bar[1]).toContain('redacted');
+    expect(logged.npmToken).not.toEqual('token');
+    expect(logged.buffer).toEqual('[content]');
+    expect(logged.content).toEqual('[content]');
+    expect(logged.prBody).toEqual('[Template]');
   });
 });
diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts
index 258edcc8d4..c21302907f 100644
--- a/lib/logger/utils.ts
+++ b/lib/logger/utils.ts
@@ -1,7 +1,7 @@
 import { Stream } from 'stream';
 import bunyan from 'bunyan';
 import fs from 'fs-extra';
-import { sanitize } from '../util/sanitize';
+import { redactedFields, sanitize } from '../util/sanitize';
 
 export interface BunyanRecord extends Record<string, any> {
   level: number;
@@ -37,6 +37,13 @@ export class ErrorStream extends Stream {
     return this._errors;
   }
 }
+const templateFields = ['prBody'];
+const contentFields = [
+  'content',
+  'contents',
+  'packageLockParsed',
+  'yarnLockParsed',
+];
 
 function sanitizeValue(value: any, seen = new WeakMap()): any {
   if (Array.isArray(value)) {
@@ -52,6 +59,10 @@ function sanitizeValue(value: any, seen = new WeakMap()): any {
     return arrayResult;
   }
 
+  if (value instanceof Buffer) {
+    return '[content]';
+  }
+
   const valueType = typeof value;
 
   if (value != null && valueType !== 'function' && valueType === 'object') {
@@ -62,9 +73,18 @@ function sanitizeValue(value: any, seen = new WeakMap()): any {
     const objectResult: Record<string, any> = {};
     seen.set(value, objectResult);
     for (const [key, val] of Object.entries<any>(value)) {
-      objectResult[key] = seen.has(val)
-        ? seen.get(val)
-        : sanitizeValue(val, seen);
+      let curValue: any;
+      if (redactedFields.includes(key)) {
+        curValue = '***********';
+      } else if (contentFields.includes(key)) {
+        curValue = '[content]';
+      } else if (templateFields.includes(key)) {
+        curValue = '[Template]';
+      } else {
+        curValue = seen.has(val) ? seen.get(val) : sanitizeValue(val, seen);
+      }
+
+      objectResult[key] = curValue;
     }
     return objectResult;
   }
diff --git a/lib/util/sanitize.ts b/lib/util/sanitize.ts
index e2e8e90bb4..0ff2667d29 100644
--- a/lib/util/sanitize.ts
+++ b/lib/util/sanitize.ts
@@ -1,5 +1,18 @@
 const secrets = new Set<string>();
 
+export const redactedFields = [
+  'authorization',
+  'token',
+  'githubAppKey',
+  'npmToken',
+  'npmrc',
+  'yarnrc',
+  'privateKey',
+  'gitPrivateKey',
+  'forkToken',
+  'password',
+];
+
 export function sanitize(input: string): string {
   if (!input) {
     return input;
diff --git a/lib/workers/repository/init/index.spec.ts b/lib/workers/repository/init/index.spec.ts
index 0a3e6c7627..204803170b 100644
--- a/lib/workers/repository/init/index.spec.ts
+++ b/lib/workers/repository/init/index.spec.ts
@@ -1,6 +1,7 @@
 import { mocked } from '../../../../test/util';
 import * as _apis from './apis';
 import * as _base from './base';
+import * as _config from './config';
 import { initRepo } from '.';
 
 jest.mock('../../../workers/repository/onboarding/branch');
@@ -12,12 +13,14 @@ jest.mock('../../../workers/repository/init/semantic');
 
 const base = mocked(_base);
 const apis = mocked(_apis);
+const config = mocked(_config);
 
 describe('workers/repository/init', () => {
   describe('initRepo', () => {
     it('runs', async () => {
       base.checkBaseBranch.mockResolvedValue({});
       apis.initApis.mockResolvedValue({} as never);
+      config.mergeRenovateConfig.mockResolvedValueOnce({});
       await initRepo({});
     });
   });
-- 
GitLab