From cec2e14a64aa10151762f270a308677893ae07f2 Mon Sep 17 00:00:00 2001 From: Rhys Arkins Date: Sat, 25 Mar 2023 10:14:33 +0100 Subject: [PATCH] feat(config): multi-org secrets decrypt (#21147) --- docs/usage/configuration-options.md | 4 ++++ lib/config/decrypt.spec.ts | 34 +++++++++++++++++++++++++++++ lib/config/decrypt.ts | 30 +++++++++++++++++-------- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 687ac7876ab14d..c071f485201df4 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -802,6 +802,8 @@ For the full list of available managers, see the [Supported Managers](https://do ## encrypted +Use this to encrypt secrets in a way which can be stored in repository configs. + See [Private module support](https://docs.renovatebot.com/getting-started/private-packages) for details on how this is used to encrypt npm tokens. @@ -809,6 +811,8 @@ See [Private module support](https://docs.renovatebot.com/getting-started/privat Encrypted secrets must have at least an org/group scope, and optionally a repository scope. This means that Renovate will check if a secret's scope matches the current repository before applying it, and warn/discard if there is a mismatch. +Encrypted secrets typically have a single org, but you may encrypt a secret with more than one, e.g. specifying `org1,org2` to allow the secret to be used in both `org1` and `org2` organizations. + ## excludeCommitPaths Be careful you know what you're doing with this option. diff --git a/lib/config/decrypt.spec.ts b/lib/config/decrypt.spec.ts index 953bbebcfff05e..2fb33a08fa759b 100644 --- a/lib/config/decrypt.spec.ts +++ b/lib/config/decrypt.spec.ts @@ -161,6 +161,23 @@ describe('config/decrypt', () => { ); }); + it('handles PGP multi-org constraint', async () => { + GlobalConfig.set({ privateKey: privateKeyPgp }); + config.encrypted = { + token: + 'wcFMAw+4H7SgaqGOAQ//Yk4RTQoLEhO0TKxN2IUBrCi88ts+CG1SXKeL06sJ2qikN/3n2JYAGGKgkHRICfu5dOnsjyFdLJ1XWUrbsM3XgVWikMbrmzD1Xe7N5DsoZXlt4Wa9pZ+IkZuE6XcKKu9whIJ22ciEwCzFwDmk/CBshdCCVVQ3IYuM6uibEHn/AHQ8K15XhraiSzF6DbJpevs5Cy7b5YHFyE936H25CVnouUQnMPsirpQq3pYeMq/oOtV/m4mfRUUQ7MUxvtrwE4lq4hLjFu5n9rwlcqaFPl7I7BEM++1c9LFpYsP5mTS7hHCZ9wXBqER8fa3fKYx0bK1ihCpjP4zUkR7P/uhWDArXamv7gHX2Kj/Qsbegn7KjTdZlggAmaJl/CuSgCbhySy+E55g3Z1QFajiLRpQ5+RsWFDbbI08YEgzyQ0yNCaRvrkgo7kZ1D95rEGRfY96duOQbjzOEqtvYmFChdemZ2+f9Kh/JH1+X9ynxY/zYe/0p/U7WD3QNTYN18loc4aXiB1adXD5Ka2QfNroLudQBmLaJpJB6wASFfuxddsD5yRnO32NSdRaqIWC1x6ti3ZYJZ2RsNwJExPDzjpQTuMOH2jtpu3q7NHmW3snRKy2YAL2UjI0YdeKIlhc/qLCJt9MRcOxWYvujTMD/yGprhG44qf0jjMkJBu7NjuVIMONujabl9b7SUQGfO/t+3rMuC68bQdCGLlO8gf3hvtD99utzXphi6idjC0HKSW/9KzuMkm+syGmIAYq/0L3EFvpZ38uq7z8KzwFFQHI3sBA34bNEr5zpU5OMWg', + }; + let res = await decryptConfig(config, repository); + expect(res.encrypted).toBeUndefined(); + expect(res.token).toBe('123'); + res = await decryptConfig(config, 'def/ghi'); + expect(res.encrypted).toBeUndefined(); + expect(res.token).toBe('123'); + await expect(decryptConfig(config, 'wrong/org')).rejects.toThrow( + CONFIG_VALIDATION + ); + }); + it('handles PGP org/repo constraint', async () => { GlobalConfig.set({ privateKey: privateKeyPgp }); config.encrypted = { @@ -174,5 +191,22 @@ describe('config/decrypt', () => { CONFIG_VALIDATION ); }); + + it('handles PGP multi-org/repo constraint', async () => { + GlobalConfig.set({ privateKey: privateKeyPgp }); + config.encrypted = { + token: + 'wcFMAw+4H7SgaqGOARAAibXL3zr0KZawiND868UGdPpGRo1aVZfn0NUBHpm8mXfgB1rBHaLsP7qa8vxDHpwH9DRD1IyB4vvPUwtu7wmuv1Vtr596tD40CCcCZYB5JjZLWRF0O0xaZFCOi7Z9SqqdaOQoMScyvPO+3/lJkS7zmLllJFH0mQoX5Cr+owUAMSWqbeCQ9r/KAXpnhmpraDjTav48WulcdTMc8iQ/DHimcdzHErLOAjtiQi4OUe1GnDCcN76KQ+c+ZHySnkXrYi/DhOOu9qB4glJ5n68NueFja+8iR39z/wqCI6V6TIUiOyjFN86iVyNPQ4Otem3KuNwrnwSABLDqP491eUNjT8DUDffsyhNC9lnjQLmtViK0EN2yLVpMdHq9cq8lszBChB7gobD9rm8nUHnTuLf6yJvZOj6toD5Yqj8Ibj58wN90Q8CUsBp9/qp0J+hBVUPOx4sT6kM2p6YarlgX3mrIW5c1U+q1eDbCddLjHiU5cW7ja7o+cqlA6mbDRu3HthjBweiXTicXZcRu1o/wy/+laQQ95x5FzAXDnOwQUHBmpTDI3tUJvQ+oy8XyBBbyC0LsBye2c2SLkPJ4Ai3IMR+Mh8puSzVywTbneiAQNBzJHlj5l85nCF2tUjvNo3dWC+9mU5sfXg11iEC6LRbg+icjpqRtTjmQURtciKDUbibWacwU5T/SVAGPXnW7adBOS0PZPIZQcSwjchOdOl0IjzBy6ofu7ODdn2CXZXi8zbevTICXsHvjnW4MAj5oXrStxK3LkWyM3YBOLe7sOfWvWz7n9TM3dHg032navQ', + }; + let res = await decryptConfig(config, repository); + expect(res.encrypted).toBeUndefined(); + expect(res.token).toBe('123'); + res = await decryptConfig(config, 'def/def'); + expect(res.encrypted).toBeUndefined(); + expect(res.token).toBe('123'); + await expect(decryptConfig(config, 'abc/defg')).rejects.toThrow( + CONFIG_VALIDATION + ); + }); }); }); diff --git a/lib/config/decrypt.ts b/lib/config/decrypt.ts index 660965fc388a12..613d27025331d6 100644 --- a/lib/config/decrypt.ts +++ b/lib/config/decrypt.ts @@ -5,6 +5,7 @@ import { logger } from '../logger'; import { maskToken } from '../util/mask'; import { regEx } from '../util/regex'; import { addSecretForSanitizing } from '../util/sanitize'; +import { ensureTrailingSlash } from '../util/url'; import { GlobalConfig } from './global'; import { DecryptedObject } from './schema'; import type { RenovateConfig } from './types'; @@ -106,33 +107,44 @@ export async function tryDecrypt( const { o: org, r: repo, v: value } = decryptedObj.data; if (is.nonEmptyString(value)) { if (is.nonEmptyString(org)) { - const orgName = org.replace(regEx(/\/$/), ''); // Strip trailing slash + const orgPrefixes = org + .split(',') + .map((o) => o.trim()) + .map((o) => o.toUpperCase()) + .map((o) => ensureTrailingSlash(o)); if (is.nonEmptyString(repo)) { - const scopedRepository = `${orgName}/${repo}`; - if (scopedRepository.toLowerCase() === repository.toLowerCase()) { + const scopedRepos = orgPrefixes.map((orgPrefix) => + `${orgPrefix}${repo}`.toUpperCase() + ); + if (scopedRepos.some((r) => r === repository.toUpperCase())) { decryptedStr = value; } else { logger.debug( - { scopedRepository }, + { scopedRepos }, 'Secret is scoped to a different repository' ); const error = new Error('config-validation'); - error.validationError = `Encrypted secret is scoped to a different repository: "${scopedRepository}".`; + error.validationError = `Encrypted secret is scoped to a different repository: "${scopedRepos.join( + ',' + )}".`; throw error; } } else { - const scopedOrg = `${orgName}/`; if ( - repository.toLowerCase().startsWith(scopedOrg.toLowerCase()) + orgPrefixes.some((orgPrefix) => + repository.toUpperCase().startsWith(orgPrefix) + ) ) { decryptedStr = value; } else { logger.debug( - { scopedOrg }, + { orgPrefixes }, 'Secret is scoped to a different org' ); const error = new Error('config-validation'); - error.validationError = `Encrypted secret is scoped to a different org: "${scopedOrg}".`; + error.validationError = `Encrypted secret is scoped to a different org: "${orgPrefixes.join( + ',' + )}".`; throw error; } }