Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: secrets #8070

Merged
merged 37 commits into from
Mar 22, 2021
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5e30617
secrets
rarkins Jul 3, 2020
3be604e
Merge branch 'master' into feat/6662-secrets
rarkins Aug 28, 2020
baefe2f
secrets admin-only
rarkins Aug 28, 2020
af7a0dd
refactor
rarkins Aug 28, 2020
82b7acb
fixes
rarkins Aug 28, 2020
0d26f1a
Merge branch 'master' into feat/6662-secrets
rarkins Aug 28, 2020
f9668de
fix validate
rarkins Aug 28, 2020
aaf0494
fix test
rarkins Aug 28, 2020
9247ea5
fix test
rarkins Aug 28, 2020
870db39
Merge branch 'master' into feat/6662-secrets
rarkins Aug 28, 2020
476b67a
add tests
rarkins Aug 28, 2020
03f8789
coverage
rarkins Aug 28, 2020
acb0967
lint
rarkins Aug 28, 2020
e71a919
Improve doc
rarkins Aug 28, 2020
f8d4041
disallow prefixes
rarkins Aug 29, 2020
fad854c
Merge remote-tracking branch 'origin/master' into feat/6662-secrets
rarkins Sep 1, 2020
23cd465
fix test
rarkins Sep 1, 2020
b262ea8
fix typo
rarkins Sep 1, 2020
2d50147
Merge branch 'master' into feat/6662-secrets
rarkins Sep 1, 2020
b69f260
Merge branch 'master' into feat/6662-secrets
rarkins Sep 22, 2020
8b99692
Merge branch 'master' into feat/6662-secrets
rarkins Sep 22, 2020
4e09653
Merge branch 'master' into feat/6662-secrets
rarkins Oct 19, 2020
75e23b6
Merge branch 'master' into feat/6662-secrets
rarkins Oct 27, 2020
d121746
secrets typing
rarkins Oct 27, 2020
ff9e845
Merge branch 'master' into feat/6662-secrets
JamieMagee Oct 28, 2020
9978794
Merge branch 'master' into feat/6662-secrets
rarkins Nov 24, 2020
3d0a9fa
Merge branch 'master' into feat/6662-secrets
rarkins Dec 2, 2020
05d1e32
Update docs/usage/self-hosted-configuration.md
rarkins Dec 2, 2020
c04316b
Merge branch 'master' into feat/6662-secrets
rarkins Dec 4, 2020
c8fda47
Merge branch 'master' into feat/6662-secrets
rarkins Dec 11, 2020
3268558
Merge branch 'master' into feat/6662-secrets
viceice Dec 18, 2020
b613585
fix: do nothing if no secrets are used
viceice Dec 18, 2020
dd29407
Merge branch 'master' into feat/6662-secrets
viceice Dec 23, 2020
2e4aa9c
Merge branch 'master' into feat/6662-secrets
viceice Jan 14, 2021
e97b1aa
Merge branch 'master' into feat/6662-secrets
rarkins Mar 9, 2021
250290b
Merge branch 'master' into feat/6662-secrets
rarkins Mar 21, 2021
e92a62c
Merge branch 'master' into feat/6662-secrets
rarkins Mar 22, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/usage/self-hosted-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,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.
Expand Down
34 changes: 34 additions & 0 deletions lib/config/__snapshots__/secrets.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -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==",
}
`;
3 changes: 2 additions & 1 deletion lib/config/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface RenovateAdminConfig {
dockerUser?: string;

dryRun?: boolean;

secrets?: Record<string, string>;
endpoint?: string;

global?: GlobalConfig;
Expand Down Expand Up @@ -124,6 +124,7 @@ export type RenovateRepository =
| string
| {
repository: string;
secrets?: Record<string, string>;
};

export interface CustomManager {
Expand Down
11 changes: 11 additions & 0 deletions lib/config/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,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:
Expand Down
101 changes: 101 additions & 0 deletions lib/config/secrets.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
124 changes: 124 additions & 0 deletions lib/config/secrets.ts
Original file line number Diff line number Diff line change
@@ -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 { RenovateAdminConfig, RenovateConfig } from './common';

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: RenovateAdminConfig): 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, string>
): 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<string, string> = {}
): 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);
}
1 change: 1 addition & 0 deletions lib/constants/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 Error
export const REPOSITORY_ACCESS_FORBIDDEN = 'forbidden';
Expand Down
4 changes: 4 additions & 0 deletions lib/logger/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ describe('logger', () => {
buffer: Buffer.from('test'),
content: 'test',
prBody: 'test',
secrets: {
foo: 'barsecret',
},
});

expect(logged.foo).not.toEqual('secret"password');
Expand All @@ -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('***********');
});
});
5 changes: 5 additions & 0 deletions lib/logger/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down