diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index 548e279bd9a7e9..73ca769ec2a1ab 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -265,6 +265,49 @@ Warning: this is an experimental feature and may be modified or removed in a fut ## requireConfig +## secrets + +Secrets may be configured by a bot admin in `config.js`, which will then make them available for templating within repository configs. +For example, to configure a `GOOGLE_TOKEN` to be accessible by all repositories: + +```js +module.exports = { + secrets: { + GOOGLE_TOKEN: 'abc123', + }, +}; +``` + +They can also be configured per repository, e.g. + +```js +module.exports = { + repositories: [ + { + repository: 'abc/def', + secrets: { + GOOGLE_TOKEN: 'abc123', + }, + }, + ], +}; +``` + +It could then be used in a repository config or preset like so: + +```json +{ + "hostRules": [ + { + "domainName": "google.com", + "token": "{{ secrets.GOOGLE_TOKEN }}" + } + ] +} +``` + +Secret names must start with a upper or lower case character and can contain only characters, digits, or underscores. + ## skipInstalls By default, Renovate will use the most efficient approach to updating package files and lock files, which in most cases skips the need to perform a full module install by the bot. diff --git a/lib/config/__snapshots__/secrets.spec.ts.snap b/lib/config/__snapshots__/secrets.spec.ts.snap new file mode 100644 index 00000000000000..39a49c77ad7ea2 --- /dev/null +++ b/lib/config/__snapshots__/secrets.spec.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`config/secrets applySecretsToConfig(config) replaces secrets in a array of objects 1`] = ` +Object { + "hostRules": Array [ + Object { + "hostType": "npm", + "token": "abc123==", + }, + ], +} +`; + +exports[`config/secrets applySecretsToConfig(config) replaces secrets in a array of strings 1`] = ` +Object { + "allowedManagers": Array [ + "npm", + ], +} +`; + +exports[`config/secrets applySecretsToConfig(config) replaces secrets in a subobject 1`] = ` +Object { + "npm": Object { + "npmToken": "abc123==", + }, +} +`; + +exports[`config/secrets applySecretsToConfig(config) replaces secrets in the top level 1`] = ` +Object { + "npmToken": "abc123==", +} +`; diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index eae0fb02e28e19..cd825d0e120c58 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -106,6 +106,17 @@ const options: RenovateOptions[] = [ format: 'uri', }, }, + { + name: 'secrets', + description: 'Object containing secret name/value pairs', + type: 'object', + admin: true, + mergeable: true, + default: {}, + additionalProperties: { + type: 'string', + }, + }, { name: 'extends', description: diff --git a/lib/config/secrets.spec.ts b/lib/config/secrets.spec.ts new file mode 100644 index 00000000000000..1ea2c095baf14d --- /dev/null +++ b/lib/config/secrets.spec.ts @@ -0,0 +1,101 @@ +import { defaultConfig, getName } from '../../test/util'; +import { + CONFIG_SECRETS_INVALID, + CONFIG_VALIDATION, +} from '../constants/error-messages'; +import { applySecretsToConfig, validateConfigSecrets } from './secrets'; + +describe(getName(__filename), () => { + describe('validateConfigSecrets(config)', () => { + it('works with default config', () => { + expect(() => validateConfigSecrets(defaultConfig)).not.toThrow(); + }); + it('returns if no secrets', () => { + expect(validateConfigSecrets({})).toBeUndefined(); + }); + it('throws if secrets is not an object', () => { + expect(() => validateConfigSecrets({ secrets: 'hello' } as any)).toThrow( + CONFIG_SECRETS_INVALID + ); + }); + it('throws for invalid secret names', () => { + expect(() => + validateConfigSecrets({ secrets: { '123': 'abc' } }) + ).toThrow(CONFIG_SECRETS_INVALID); + }); + it('throws for non-string secret', () => { + expect(() => + validateConfigSecrets({ secrets: { abc: 123 } } as any) + ).toThrow(CONFIG_SECRETS_INVALID); + }); + it('throws for secrets inside repositories', () => { + expect(() => + validateConfigSecrets({ + repositories: [ + { repository: 'abc/def', secrets: { abc: 123 } }, + ] as any, + }) + ).toThrow(CONFIG_SECRETS_INVALID); + }); + }); + + describe('applySecretsToConfig(config)', () => { + it('works with default config', () => { + expect(() => applySecretsToConfig(defaultConfig)).not.toThrow(); + }); + + it('throws if disallowed field is used', () => { + const config = { + prTitle: '{{ secrets.ARTIFACTORY_TOKEN }}', + secrets: { + ARTIFACTORY_TOKEN: 'abc123==', + }, + }; + expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION); + }); + it('throws if an unknown secret is used', () => { + const config = { + npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}', + }; + expect(() => applySecretsToConfig(config)).toThrow(CONFIG_VALIDATION); + }); + it('replaces secrets in the top level', () => { + const config = { + secrets: { ARTIFACTORY_TOKEN: 'abc123==' }, + npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}', + }; + const res = applySecretsToConfig(config); + expect(res).toMatchSnapshot(); + expect(Object.keys(res)).not.toContain('secrets'); + }); + it('replaces secrets in a subobject', () => { + const config = { + secrets: { ARTIFACTORY_TOKEN: 'abc123==' }, + npm: { npmToken: '{{ secrets.ARTIFACTORY_TOKEN }}' }, + }; + const res = applySecretsToConfig(config); + expect(res).toMatchSnapshot(); + expect(Object.keys(res)).not.toContain('secrets'); + }); + it('replaces secrets in a array of objects', () => { + const config = { + secrets: { ARTIFACTORY_TOKEN: 'abc123==' }, + hostRules: [ + { hostType: 'npm', token: '{{ secrets.ARTIFACTORY_TOKEN }}' }, + ], + }; + const res = applySecretsToConfig(config); + expect(res).toMatchSnapshot(); + expect(Object.keys(res)).not.toContain('secrets'); + }); + it('replaces secrets in a array of strings', () => { + const config = { + secrets: { SECRET_MANAGER: 'npm' }, + allowedManagers: ['{{ secrets.SECRET_MANAGER }}'], + }; + const res = applySecretsToConfig(config); + expect(res).toMatchSnapshot(); + expect(Object.keys(res)).not.toContain('secrets'); + }); + }); +}); diff --git a/lib/config/secrets.ts b/lib/config/secrets.ts new file mode 100644 index 00000000000000..7b1fd70ed6cec9 --- /dev/null +++ b/lib/config/secrets.ts @@ -0,0 +1,124 @@ +import is from '@sindresorhus/is'; +import { + CONFIG_SECRETS_INVALID, + CONFIG_VALIDATION, +} from '../constants/error-messages'; +import { logger } from '../logger'; +import { regEx } from '../util/regex'; +import { add } from '../util/sanitize'; +import { GlobalConfig, RenovateConfig } from './types'; + +const secretNamePattern = '[A-Za-z][A-Za-z0-9_]*'; + +const secretNameRegex = regEx(`^${secretNamePattern}$`); +const secretTemplateRegex = regEx(`{{ secrets\\.(${secretNamePattern}) }}`); + +function validateSecrets(secrets_: unknown): void { + if (!secrets_) { + return; + } + const validationErrors: string[] = []; + if (is.plainObject(secrets_)) { + for (const [secretName, secretValue] of Object.entries(secrets_)) { + if (!secretNameRegex.test(secretName)) { + validationErrors.push(`Invalid secret name "${secretName}"`); + } + if (!is.string(secretValue)) { + validationErrors.push( + `Secret values must be strings. Found type ${typeof secretValue} for secret ${secretName}` + ); + } + } + } else { + validationErrors.push( + `Config secrets must be a plain object. Found: ${typeof secrets_}` + ); + } + if (validationErrors.length) { + logger.error({ validationErrors }, 'Invalid secrets configured'); + throw new Error(CONFIG_SECRETS_INVALID); + } +} + +export function validateConfigSecrets(config: GlobalConfig): void { + validateSecrets(config.secrets); + if (config.repositories) { + for (const repository of config.repositories) { + if (is.plainObject(repository)) { + validateSecrets(repository.secrets); + } + } + } +} + +function replaceSecretsInString( + key: string, + value: string, + secrets: Record +): string { + // do nothing if no secret template found + if (!secretTemplateRegex.test(value)) { + return value; + } + + const disallowedPrefixes = ['branch', 'commit', 'group', 'pr', 'semantic']; + if (disallowedPrefixes.some((prefix) => key.startsWith(prefix))) { + const error = new Error(CONFIG_VALIDATION); + error.configFile = 'config'; + error.validationError = 'Disallowed secret substitution'; + error.validationMessage = `The field ${key} may not use secret substitution`; + throw error; + } + return value.replace(secretTemplateRegex, (_, secretName) => { + if (secrets[secretName]) { + return secrets[secretName]; + } + const error = new Error(CONFIG_VALIDATION); + error.configFile = 'config'; + error.validationError = 'Unknown secret name'; + error.validationMessage = `The following secret name was not found in config: ${String( + secretName + )}`; + throw error; + }); +} + +function replaceSecretsinObject( + config_: RenovateConfig, + secrets: Record = {} +): RenovateConfig { + const config = { ...config_ }; + delete config.secrets; + for (const [key, value] of Object.entries(config)) { + if (is.plainObject(value)) { + config[key] = replaceSecretsinObject(value, secrets); + } + if (is.string(value)) { + config[key] = replaceSecretsInString(key, value, secrets); + } + if (is.array(value)) { + for (const [arrayIndex, arrayItem] of value.entries()) { + if (is.plainObject(arrayItem)) { + config[key][arrayIndex] = replaceSecretsinObject(arrayItem, secrets); + } else if (is.string(arrayItem)) { + config[key][arrayIndex] = replaceSecretsInString( + key, + arrayItem, + secrets + ); + } + } + } + } + return config; +} + +export function applySecretsToConfig(config: RenovateConfig): RenovateConfig { + // Add all secrets to be sanitized + if (is.plainObject(config.secrets)) { + for (const secret of Object.values(config.secrets)) { + add(String(secret)); + } + } + return replaceSecretsinObject(config, config.secrets); +} diff --git a/lib/config/types.ts b/lib/config/types.ts index 82e2658e233082..0d9b623746dc2e 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -127,6 +127,7 @@ export type RenovateRepository = | string | { repository: string; + secrets?: Record; }; export interface CustomManager { @@ -189,6 +190,7 @@ export interface RenovateConfig regexManagers?: CustomManager[]; fetchReleaseNotes?: boolean; + secrets?: Record; } export interface GlobalConfig extends RenovateConfig, GlobalOnlyConfig {} diff --git a/lib/constants/error-messages.ts b/lib/constants/error-messages.ts index b2dff002a2f4b9..b032e18fcee8a2 100644 --- a/lib/constants/error-messages.ts +++ b/lib/constants/error-messages.ts @@ -13,6 +13,7 @@ export const PLATFORM_RATE_LIMIT_EXCEEDED = 'rate-limit-exceeded'; // Config Error export const CONFIG_VALIDATION = 'config-validation'; export const CONFIG_SECRETS_EXPOSED = 'config-secrets-exposed'; +export const CONFIG_SECRETS_INVALID = 'config-secrets-invalid'; // Repository Errors - causes repo to be considered as disabled export const REPOSITORY_ACCESS_FORBIDDEN = 'forbidden'; diff --git a/lib/logger/index.spec.ts b/lib/logger/index.spec.ts index 79f599a06199b2..38ed6bf4d1af9e 100644 --- a/lib/logger/index.spec.ts +++ b/lib/logger/index.spec.ts @@ -154,6 +154,9 @@ describe('logger', () => { buffer: Buffer.from('test'), content: 'test', prBody: 'test', + secrets: { + foo: 'barsecret', + }, }); expect(logged.foo).not.toEqual('secret"password'); @@ -164,5 +167,6 @@ describe('logger', () => { expect(logged.buffer).toEqual('[content]'); expect(logged.content).toEqual('[content]'); expect(logged.prBody).toEqual('[Template]'); + expect(logged.secrets.foo).toEqual('***********'); }); }); diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts index d9cb166a507471..77f77e73ca2268 100644 --- a/lib/logger/utils.ts +++ b/lib/logger/utils.ts @@ -135,6 +135,11 @@ export function sanitizeValue(value: unknown, seen = new WeakMap()): any { curValue = '[content]'; } else if (templateFields.includes(key)) { curValue = '[Template]'; + } else if (key === 'secrets') { + curValue = {}; + Object.keys(val).forEach((secretKey) => { + curValue[secretKey] = '***********'; + }); } else { curValue = seen.has(val) ? seen.get(val) : sanitizeValue(val, seen); } diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts index 3576a08d133218..06600d5da6ef1b 100644 --- a/lib/workers/global/index.ts +++ b/lib/workers/global/index.ts @@ -6,6 +6,7 @@ import upath from 'upath'; import * as pkg from '../../../package.json'; import * as configParser from '../../config'; import { GlobalConfig } from '../../config'; +import { validateConfigSecrets } from '../../config/secrets'; import { getProblems, logger, setMeta } from '../../logger'; import { setUtilConfig } from '../../util'; import * as hostRules from '../../util/host-rules'; @@ -78,6 +79,9 @@ export async function start(): Promise { checkEnv(); + // validate secrets. Will throw and abort if invalid + validateConfigSecrets(config); + // autodiscover repositories (needs to come after platform initialization) config = await autodiscoverRepositories(config); // Iterate through repositories sequentially diff --git a/lib/workers/repository/init/index.spec.ts b/lib/workers/repository/init/index.spec.ts index 6554432451fd69..c4a27665f6f7a5 100644 --- a/lib/workers/repository/init/index.spec.ts +++ b/lib/workers/repository/init/index.spec.ts @@ -1,4 +1,5 @@ import { mocked } from '../../../../test/util'; +import * as _secrets from '../../../config/secrets'; import * as _onboarding from '../onboarding/branch'; import * as _apis from './apis'; import * as _config from './config'; @@ -9,11 +10,13 @@ jest.mock('../onboarding/branch'); jest.mock('../configured'); jest.mock('../init/apis'); jest.mock('../init/config'); +jest.mock('../../../config/secrets'); jest.mock('../init/semantic'); const apis = mocked(_apis); const config = mocked(_config); const onboarding = mocked(_onboarding); +const secrets = mocked(_secrets); describe('workers/repository/init', () => { describe('initRepo', () => { @@ -22,6 +25,7 @@ describe('workers/repository/init', () => { onboarding.checkOnboardingBranch.mockResolvedValueOnce({}); config.getRepoConfig.mockResolvedValueOnce({}); config.mergeRenovateConfig.mockResolvedValueOnce({}); + secrets.applySecretsToConfig.mockReturnValueOnce({} as never); const renovateConfig = await initRepo({}); expect(renovateConfig).toMatchSnapshot(); }); diff --git a/lib/workers/repository/init/index.ts b/lib/workers/repository/init/index.ts index da3a12015119fe..4896b9501d5806 100644 --- a/lib/workers/repository/init/index.ts +++ b/lib/workers/repository/init/index.ts @@ -1,4 +1,5 @@ import { RenovateConfig } from '../../../config'; +import { applySecretsToConfig } from '../../../config/secrets'; import { logger } from '../../../logger'; import { clone } from '../../../util/clone'; import { setUserRepoConfig } from '../../../util/git'; @@ -20,6 +21,7 @@ export async function initRepo( config = await initApis(config); config = await getRepoConfig(config); checkIfConfigured(config); + config = applySecretsToConfig(config); await setUserRepoConfig(config); config = await detectVulnerabilityAlerts(config); // istanbul ignore if