Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function makeConfig(): Promise<CliConfig> {
'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 like the AWS CLI does: 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 },
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/cli-type-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 like the AWS CLI does: 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": {
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
const { agent: proxyAgent, caBundlePath } = await new ProxyAgentProvider(ioHelper).create({
proxyAddress: proxyUrl,
caBundlePath: normalizeNetworkSetting(configuration.settings.get(['caBundlePath'])),
profile: normalizeNetworkSetting(configuration.settings.get(['profile'])),
});

try {
Expand Down
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/parse-command-line-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function parseCommandLineArguments(args: Array<string>): 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 like the AWS CLI does: 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', {
Expand Down
65 changes: 55 additions & 10 deletions packages/aws-cdk/lib/cli/proxy-agent.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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, the same way the AWS CLI does
*/
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;
}

/**
Expand Down Expand Up @@ -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({
Expand All @@ -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<string | undefined> {
const configured = bundlePath || this.caBundlePathFromEnvironment();
private async resolveCABundlePath(bundlePath?: string, profile?: string): Promise<string | undefined> {
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));
Expand All @@ -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 the same way the AWS CLI does when none is given explicitly.
*
* The order mirrors botocore: 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 because botocore merges the credentials
* file over the config file, and that is also the order `AwsCliCompatible` uses for the region.
*/
private async caBundlePathFromProfile(profile: string): Promise<string | undefined> {
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;
}
}
}
2 changes: 1 addition & 1 deletion packages/aws-cdk/lib/cli/user-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,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 like the AWS CLI does: the AWS_CA_BUNDLE environment variable, then the ca_bundle setting of the AWS profile, then the REQUESTS_CA_BUNDLE environment variable
*
* @default - undefined
*/
Expand Down
138 changes: 137 additions & 1 deletion packages/aws-cdk/test/cli/proxy-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down Expand Up @@ -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<string, string | undefined>;
let tmpDir: string;
let ioHost: TestIoHost;
const bundles: Record<string, string> = {};

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, like botocore', 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, matching the AWS CLI', 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 ''`
Expand Down
Loading