diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index 254d61cc2..24aa5b082 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -35,7 +35,7 @@ export async function makeConfig(): Promise { 'profile': { type: 'string', desc: 'Use the indicated AWS profile as the default environment', requiresArg: true }, 'region': { type: 'string', desc: 'Use the indicated AWS region as the default region', requiresArg: true }, 'proxy': { type: 'string', desc: 'Use the indicated proxy. Will read from HTTPS_PROXY environment variable if not specified', requiresArg: true }, - 'ca-bundle-path': { type: 'string', desc: 'Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified', requiresArg: true }, + 'ca-bundle-path': { type: 'string', desc: 'Path to CA certificate to use when validating HTTPS requests. If not specified, it is resolved from the AWS_CA_BUNDLE environment variable, then the ca_bundle setting of the AWS profile, then the REQUESTS_CA_BUNDLE environment variable', requiresArg: true }, 'ec2creds': { type: 'boolean', alias: 'i', default: undefined, desc: 'Force trying to fetch EC2 instance credentials. Default: guess EC2 instance status' }, 'version-reporting': { type: 'boolean', desc: 'Disable CLI telemetry and do not include the "AWS::CDK::Metadata" resource in synthesized templates (enabled by default)', default: undefined, alias: 'telemetry' }, 'path-metadata': { type: 'boolean', desc: 'Include "aws:cdk:path" CloudFormation metadata for each resource (enabled by default)', default: undefined }, diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index dee8198f1..f4794a224 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -87,7 +87,7 @@ }, "ca-bundle-path": { "type": "string", - "desc": "Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified", + "desc": "Path to CA certificate to use when validating HTTPS requests. If not specified, it is resolved from the AWS_CA_BUNDLE environment variable, then the ca_bundle setting of the AWS profile, then the REQUESTS_CA_BUNDLE environment variable", "requiresArg": true }, "ec2creds": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index 7f9143d33..efa3ccbd2 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -136,6 +136,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { .option('ca-bundle-path', { default: undefined, type: 'string', - desc: 'Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified', + desc: 'Path to CA certificate to use when validating HTTPS requests. If not specified, it is resolved from the AWS_CA_BUNDLE environment variable, then the ca_bundle setting of the AWS profile, then the REQUESTS_CA_BUNDLE environment variable', requiresArg: true, }) .option('ec2creds', { diff --git a/packages/aws-cdk/lib/cli/proxy-agent.ts b/packages/aws-cdk/lib/cli/proxy-agent.ts index 0864fd256..bc390ed9a 100644 --- a/packages/aws-cdk/lib/cli/proxy-agent.ts +++ b/packages/aws-cdk/lib/cli/proxy-agent.ts @@ -1,4 +1,5 @@ import * as path from 'node:path'; +import { loadSharedConfigFiles } from '@smithy/shared-ini-file-loader'; import * as fs from 'fs-extra'; import { ProxyAgent, proxies } from 'proxy-agent'; import { ToolkitError } from '../api-private-error'; @@ -63,9 +64,17 @@ interface ProxyAgentOptions { /** * A path to a certificate bundle that contains a cert to be trusted. * - * @default No certificate bundle + * @default - resolved from the environment and the shared AWS config files */ readonly caBundlePath?: string; + + /** + * The AWS profile whose `ca_bundle` setting is consulted when no CA bundle is configured explicitly + * or through `AWS_CA_BUNDLE`. + * + * @default - `AWS_PROFILE`, then `AWS_DEFAULT_PROFILE`, then `default` + */ + readonly profile?: string; } /** @@ -123,7 +132,7 @@ export class ProxyAgentProvider { ? () => Promise.resolve(proxyAddress) : undefined; - const caBundlePath = await this.resolveCABundlePath(normalizeNetworkSetting(options.caBundlePath)); + const caBundlePath = await this.resolveCABundlePath(normalizeNetworkSetting(options.caBundlePath), options.profile); return { agent: new ProxyAgent({ @@ -139,15 +148,17 @@ export class ProxyAgentProvider { * * Absolute because the path is handed to the detached sender, which runs from a different cwd. */ - private async resolveCABundlePath(bundlePath?: string): Promise { - const configured = bundlePath || this.caBundlePathFromEnvironment(); + private async resolveCABundlePath(bundlePath?: string, profile?: string): Promise { + const configured = bundlePath + ? { path: bundlePath, source: '--ca-bundle-path (caBundlePath setting)' } + : await this.caBundlePathFromEnvironment(profile); if (!configured) { return undefined; } try { - const resolved = path.resolve(configured); - await this.ioHelper.defaults.debug(`Using CA bundle path: ${resolved}`); + const resolved = path.resolve(configured.path); + await this.ioHelper.defaults.debug(`Using CA bundle path from ${configured.source}: ${resolved}`); return fs.pathExistsSync(resolved) ? resolved : undefined; } catch (e: any) { await this.ioHelper.defaults.debug(String(e)); @@ -168,15 +179,49 @@ export class ProxyAgentProvider { } /** - * Find and return a CA certificate bundle path to be passed into the SDK. + * Find a CA certificate bundle path when none is given explicitly. + * + * The order is: the `AWS_CA_BUNDLE` environment variable, then the `ca_bundle` setting + * of the active profile in the shared credentials/config files, then the `REQUESTS_CA_BUNDLE` + * environment variable. + * + * The profile is picked the way `AwsCliCompatible` in toolkit-lib picks it: `--profile`, then + * `AWS_PROFILE`, then `AWS_DEFAULT_PROFILE`, then `default`. */ - private caBundlePathFromEnvironment(): string | undefined { + private async caBundlePathFromEnvironment(profile?: string): Promise<{ path: string; source: string } | undefined> { if (process.env.aws_ca_bundle) { - return process.env.aws_ca_bundle; + return { path: process.env.aws_ca_bundle, source: 'aws_ca_bundle' }; } if (process.env.AWS_CA_BUNDLE) { - return process.env.AWS_CA_BUNDLE; + return { path: process.env.AWS_CA_BUNDLE, source: 'AWS_CA_BUNDLE' }; + } + + const profileName = profile || process.env.AWS_PROFILE || process.env.AWS_DEFAULT_PROFILE || 'default'; + const profileCaBundle = await this.caBundlePathFromProfile(profileName); + if (profileCaBundle) { + return { path: profileCaBundle, source: `ca_bundle of profile '${profileName}'` }; + } + + if (process.env.REQUESTS_CA_BUNDLE) { + return { path: process.env.REQUESTS_CA_BUNDLE, source: 'REQUESTS_CA_BUNDLE' }; } return undefined; } + + /** + * The `ca_bundle` setting of a profile in the shared ini files. + * + * The credentials file is consulted before the config file, the same order `AwsCliCompatible` + * uses for the region. + */ + private async caBundlePathFromProfile(profile: string): Promise { + try { + const { credentialsFile, configFile } = await loadSharedConfigFiles({ ignoreCache: true }); + const caBundle = credentialsFile?.[profile]?.ca_bundle ?? configFile?.[profile]?.ca_bundle; + return typeof caBundle === 'string' && caBundle ? caBundle : undefined; + } catch (e: any) { + await this.ioHelper.defaults.debug(`Could not read ca_bundle from the AWS config files: ${e}`); + return undefined; + } + } } diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 315b0b0a1..0551f416f 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -289,7 +289,7 @@ export interface GlobalOptions { readonly proxy?: string; /** - * Path to CA certificate to use when validating HTTPS requests. Will read from AWS_CA_BUNDLE environment variable if not specified + * Path to CA certificate to use when validating HTTPS requests. If not specified, it is resolved from the AWS_CA_BUNDLE environment variable, then the ca_bundle setting of the AWS profile, then the REQUESTS_CA_BUNDLE environment variable * * @default - undefined */ diff --git a/packages/aws-cdk/test/cli/proxy-agent.test.ts b/packages/aws-cdk/test/cli/proxy-agent.test.ts index c662d5bc8..d3e32d679 100644 --- a/packages/aws-cdk/test/cli/proxy-agent.test.ts +++ b/packages/aws-cdk/test/cli/proxy-agent.test.ts @@ -1,6 +1,8 @@ +import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'fs-extra'; import { normalizeNetworkSetting, ProxyAgentProvider, validateProxyAddress } from '../../lib/cli/proxy-agent'; -import { TestIoHost } from '../_helpers/io-host'; +import { expectIoMsg, TestIoHost } from '../_helpers/io-host'; describe('validateProxyAddress', () => { test.each([ @@ -49,6 +51,140 @@ describe('ProxyAgentProvider', () => { }); }); +describe('CA bundle resolution', () => { + const ENV_VARS = [ + 'aws_ca_bundle', 'AWS_CA_BUNDLE', 'REQUESTS_CA_BUNDLE', 'SSL_CERT_FILE', + 'AWS_CONFIG_FILE', 'AWS_SHARED_CREDENTIALS_FILE', 'AWS_PROFILE', 'AWS_DEFAULT_PROFILE', + ]; + let savedEnv: Record; + let tmpDir: string; + let ioHost: TestIoHost; + const bundles: Record = {}; + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_VARS.map((name) => [name, process.env[name]])); + ENV_VARS.forEach((name) => delete process.env[name]); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-ca-bundle-')); + for (const name of ['explicit', 'aws-env', 'default-profile', 'named-profile', 'credentials-file', 'requests-env']) { + bundles[name] = path.join(tmpDir, `${name}.pem`); + fs.writeFileSync(bundles[name], `-----BEGIN CERTIFICATE-----\n${name}\n-----END CERTIFICATE-----\n`); + } + + const configFile = path.join(tmpDir, 'config'); + fs.writeFileSync(configFile, [ + '[default]', + `ca_bundle = ${bundles['default-profile']}`, + '', + '[profile named]', + `ca_bundle = ${bundles['named-profile']}`, + '', + '[profile no-bundle]', + 'region = us-east-1', + '', + ].join('\n')); + process.env.AWS_CONFIG_FILE = configFile; + // Keep the developer's own ~/.aws/credentials out of the picture; a test that needs one writes it. + process.env.AWS_SHARED_CREDENTIALS_FILE = path.join(tmpDir, 'no-credentials'); + + ioHost = new TestIoHost('debug'); + }); + + afterEach(() => { + for (const [name, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + fs.removeSync(tmpDir); + }); + + async function resolveBundle(options: { caBundlePath?: string; profile?: string } = {}) { + const { caBundlePath } = await new ProxyAgentProvider(ioHost.asHelper('deploy')).create(options); + return caBundlePath; + } + + test('an explicit caBundlePath wins over everything else', async () => { + process.env.AWS_CA_BUNDLE = bundles['aws-env']; + process.env.REQUESTS_CA_BUNDLE = bundles['requests-env']; + + await expect(resolveBundle({ caBundlePath: bundles.explicit, profile: 'named' })).resolves.toBe(bundles.explicit); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expectIoMsg(expect.stringContaining('from --ca-bundle-path'), 'debug')); + }); + + test('AWS_CA_BUNDLE wins over the profile ca_bundle and REQUESTS_CA_BUNDLE', async () => { + process.env.AWS_CA_BUNDLE = bundles['aws-env']; + process.env.REQUESTS_CA_BUNDLE = bundles['requests-env']; + + await expect(resolveBundle({ profile: 'named' })).resolves.toBe(bundles['aws-env']); + expect(ioHost.notifySpy).toHaveBeenCalledWith(expectIoMsg(`Using CA bundle path from AWS_CA_BUNDLE: ${bundles['aws-env']}`, 'debug')); + }); + + test('the ca_bundle of the profile passed in wins over REQUESTS_CA_BUNDLE', async () => { + process.env.REQUESTS_CA_BUNDLE = bundles['requests-env']; + + await expect(resolveBundle({ profile: 'named' })).resolves.toBe(bundles['named-profile']); + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expectIoMsg(`Using CA bundle path from ca_bundle of profile 'named': ${bundles['named-profile']}`, 'debug'), + ); + }); + + test('a ca_bundle in the credentials file wins over the one in the config file', async () => { + const credentialsFile = path.join(tmpDir, 'credentials'); + fs.writeFileSync(credentialsFile, ['[named]', `ca_bundle = ${bundles['credentials-file']}`, ''].join('\n')); + process.env.AWS_SHARED_CREDENTIALS_FILE = credentialsFile; + + await expect(resolveBundle({ profile: 'named' })).resolves.toBe(bundles['credentials-file']); + }); + + test('the profile falls back to AWS_PROFILE', async () => { + process.env.AWS_PROFILE = 'named'; + + await expect(resolveBundle()).resolves.toBe(bundles['named-profile']); + }); + + test('the profile falls back to AWS_DEFAULT_PROFILE', async () => { + process.env.AWS_DEFAULT_PROFILE = 'named'; + + await expect(resolveBundle()).resolves.toBe(bundles['named-profile']); + }); + + test('the profile falls back to the default profile', async () => { + await expect(resolveBundle()).resolves.toBe(bundles['default-profile']); + }); + + test('REQUESTS_CA_BUNDLE is used when the profile has no ca_bundle', async () => { + process.env.REQUESTS_CA_BUNDLE = bundles['requests-env']; + + await expect(resolveBundle({ profile: 'no-bundle' })).resolves.toBe(bundles['requests-env']); + expect(ioHost.notifySpy).toHaveBeenCalledWith( + expectIoMsg(`Using CA bundle path from REQUESTS_CA_BUNDLE: ${bundles['requests-env']}`, 'debug'), + ); + }); + + test('REQUESTS_CA_BUNDLE is used when there is no config file', async () => { + process.env.AWS_CONFIG_FILE = path.join(tmpDir, 'does-not-exist'); + process.env.REQUESTS_CA_BUNDLE = bundles['requests-env']; + + await expect(resolveBundle()).resolves.toBe(bundles['requests-env']); + }); + + test('no CA bundle when nothing is configured', async () => { + process.env.AWS_CONFIG_FILE = path.join(tmpDir, 'does-not-exist'); + + await expect(resolveBundle()).resolves.toBeUndefined(); + }); + + test('SSL_CERT_FILE is not consulted', async () => { + process.env.SSL_CERT_FILE = bundles['requests-env']; + process.env.AWS_CONFIG_FILE = path.join(tmpDir, 'does-not-exist'); + + await expect(resolveBundle()).resolves.toBeUndefined(); + }); +}); + describe('normalizeNetworkSetting', () => { test('keeps an empty string, which means "go direct" and is NOT the same as unconfigured', () => { // The whole point of normalizing: a truthiness check here would turn an explicit `--proxy ''`