From db3779a0e9d3ffe1c63ae0251d05c765062183e8 Mon Sep 17 00:00:00 2001 From: Jan Feddern Date: Sun, 21 Dec 2025 11:28:36 +0100 Subject: [PATCH] Support usage of AWS Profiles --- README.md | 58 +++++ action.yml | 3 + dist/cleanup/index.js | 1 + dist/index.js | 482 +++++++++++++++++++++++++++++++++++- package-lock.json | 36 ++- package.json | 2 + src/cleanup/index.ts | 1 + src/index.ts | 37 ++- src/profileManager.ts | 145 +++++++++++ test/cleanup.test.ts | 3 +- test/index.test.ts | 217 ++++++++++++++++ test/profileManager.test.ts | 342 +++++++++++++++++++++++++ 12 files changed, 1308 insertions(+), 19 deletions(-) create mode 100644 src/profileManager.ts create mode 100644 test/profileManager.test.ts diff --git a/README.md b/README.md index d1c840e..612b4db 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ See [action.yml](./action.yml) for more detail. | Option | Description | Required | |---------------------------|---------------------------------------------------------------------------------------------------|----------| | aws-region | Which AWS region to use | Yes | +| aws-profile | Name of the AWS profile to configure. When provided, credentials are written to `~/.aws/credentials` and `~/.aws/config` files instead of environment variables. This enables configuring multiple profiles in a single workflow. | No | | role-to-assume | Role for which to fetch credentials. Only required for some authentication types. | No | | aws-access-key-id | AWS access key to use. Only required for some authentication types. | No | | aws-secret-access-key | AWS secret key to use. Only required for some authentication types. | No | @@ -171,6 +172,26 @@ Sometimes, existing credentials in your runner can get in the way of the intended outcome. You can set the `unset-current-credentials` input to `true` to work around this issue. +#### Configure named AWS profiles + +By default, this action exports credentials as environment variables +(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.). However, you can use the +`aws-profile` input to configure named AWS profiles instead. When `aws-profile` +is provided, credentials are written to `~/.aws/credentials` and +`~/.aws/config` files, allowing you to: + +- Configure multiple AWS profiles in a single workflow +- Use the `--profile` flag with AWS CLI and SDKs +- Work naturally with tools like Terraform and AWS CDK that read from AWS config files + +When using profiles: +- Credentials are **not** exported as environment variables (AWS_ACCESS_KEY_ID, etc.) +- The `AWS_PROFILE` environment variable is set (unless `output-env-credentials: false`) +- The `AWS_REGION` environment variable is always set for convenience +- All authentication methods (OIDC, static credentials, role assumption) are supported + +See the [Examples](#examples) section for usage examples. + #### Use an HTTP proxy If need use an HTTP proxy you can set it in the action manually. Additionally @@ -483,6 +504,43 @@ This example shows that you can reference the fetched credentials as outputs if the `aws-session-token` input in a situation where session tokens are fetched and passed to this action. +### Configure multiple AWS profiles in a single workflow +```yaml + - name: Configure AWS Credentials for Dev + uses: aws-actions/configure-aws-credentials@v5.1.1 + with: + aws-region: us-east-1 + role-to-assume: arn:aws:iam::111111111111:role/dev-role + aws-profile: dev + + - name: Configure AWS Credentials for Prod + uses: aws-actions/configure-aws-credentials@v5.1.1 + with: + aws-region: us-west-2 + role-to-assume: arn:aws:iam::222222222222:role/prod-role + aws-profile: prod + + - name: Use multiple profiles + run: | + # Check caller identity for dev account + aws sts get-caller-identity --profile dev + + # Check caller identity for prod account + aws sts get-caller-identity --profile prod + + # Deploy to dev using Terraform + terraform apply -var="profile=dev" +``` +This example shows how to configure multiple named AWS profiles in a single +workflow. When using the `aws-profile` input, credentials are written to +`~/.aws/credentials` and `~/.aws/config` files instead of environment +variables, allowing you to reference different profiles using the `--profile` +flag with AWS CLI, SDKs, Terraform, and other tools. + +Each profile is independent and can authenticate to different AWS accounts or +use different roles. This is particularly useful for multi-account deployments +or when you need to interact with multiple AWS environments in a single job. + Versioning ---------- Starting with version 5.0.0, this action uses semantic-style release tags and diff --git a/action.yml b/action.yml index 8680a47..368e1b1 100644 --- a/action.yml +++ b/action.yml @@ -12,6 +12,9 @@ inputs: aws-region: description: AWS Region, e.g. us-east-2 required: true + aws-profile: + description: Name of the AWS profile to configure. When provided, credentials and config are written to ~/.aws/credentials and ~/.aws/config files instead of environment variables. + required: false role-to-assume: description: The Amazon Resource Name (ARN) of the role to assume. Use the provided credentials to assume an IAM role and configure the Actions environment with the assumed role credentials rather than with the provided credentials. required: false diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 87bb7b0..14de01a 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -43243,6 +43243,7 @@ function cleanup() { core.exportVariable('AWS_SESSION_TOKEN', ''); core.exportVariable('AWS_DEFAULT_REGION', ''); core.exportVariable('AWS_REGION', ''); + core.exportVariable('AWS_PROFILE', ''); } catch (error) { core.setFailed((0, helpers_1.errorMessage)(error)); diff --git a/dist/index.js b/dist/index.js index 00def51..bfa1b94 100644 --- a/dist/index.js +++ b/dist/index.js @@ -658,6 +658,7 @@ const core = __importStar(__nccwpck_require__(7484)); const assumeRole_1 = __nccwpck_require__(6993); const CredentialsClient_1 = __nccwpck_require__(8234); const helpers_1 = __nccwpck_require__(2918); +const profileManager_1 = __nccwpck_require__(1351); const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds) const ROLE_SESSION_NAME = 'GitHubActions'; const REGION_REGEX = /^[a-z0-9-]+$/g; @@ -671,6 +672,7 @@ async function run() { const sessionTokenInput = core.getInput('aws-session-token', { required: false }); const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput; const region = core.getInput('aws-region', { required: true }); + const awsProfile = core.getInput('aws-profile', { required: false }); const roleToAssume = core.getInput('role-to-assume', { required: false }); const audience = core.getInput('audience', { required: false }); const maskAccountId = (0, helpers_1.getBooleanInput)('mask-aws-account-id', { required: false }); @@ -746,7 +748,10 @@ async function run() { if (!region.match(REGION_REGEX)) { throw new Error(`Region is not valid: ${region}`); } - (0, helpers_1.exportRegion)(region, outputEnvCredentials); + // When using profile mode, always export region env vars + // When not using profile mode, respect outputEnvCredentials + const shouldExportRegionEnvVars = awsProfile ? true : outputEnvCredentials; + (0, helpers_1.exportRegion)(region, shouldExportRegionEnvVars); // Instantiate credentials client const clientProps = { region }; if (proxyServer) @@ -787,7 +792,9 @@ async function run() { // Plus, in the assume role case, if the AssumeRole call fails, we want // the source credentials to already be masked as secrets // in any error messages. - (0, helpers_1.exportCredentials)({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials); + // When using profile mode, we don't export to env vars (set to false) but still mask secrets + const shouldExportEnvCreds = awsProfile ? false : outputEnvCredentials; + (0, helpers_1.exportCredentials)({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, shouldExportEnvCreds); } else if (!webIdentityTokenFile && !roleChaining) { // Proceed only if credentials can be picked up @@ -822,7 +829,9 @@ async function run() { }, !disableRetry, maxRetries); } while (specialCharacterWorkaround && !(0, helpers_1.verifyKeys)(roleCredentials.Credentials)); core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`); - (0, helpers_1.exportCredentials)(roleCredentials.Credentials, outputCredentials, outputEnvCredentials); + // When using profile mode, we don't export credentials to env vars but still mask secrets and handle step outputs + const shouldExportEnvCreds = awsProfile ? false : outputEnvCredentials; + (0, helpers_1.exportCredentials)(roleCredentials.Credentials, outputCredentials, shouldExportEnvCreds); // We need to validate the credentials in 2 of our use-cases // First: self-hosted runners. If the GITHUB_ACTIONS environment variable // is set to `true` then we are NOT in a self-hosted runner. @@ -833,9 +842,25 @@ async function run() { if (outputEnvCredentials) { await (0, helpers_1.exportAccountId)(credentialsClient, maskAccountId); } + // Write profile files if profile mode is enabled + if (awsProfile) { + (0, profileManager_1.writeProfileFiles)(awsProfile, roleCredentials.Credentials || {}, region); + // Export AWS_PROFILE env var if outputEnvCredentials is true + if (outputEnvCredentials) { + core.exportVariable('AWS_PROFILE', awsProfile); + } + } } else { core.info('Proceeding with IAM user credentials'); + // Write profile files if profile mode is enabled (for IAM user credentials without role assumption) + if (awsProfile) { + (0, profileManager_1.writeProfileFiles)(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region); + // Export AWS_PROFILE env var if outputEnvCredentials is true + if (outputEnvCredentials) { + core.exportVariable('AWS_PROFILE', awsProfile); + } + } } // Clear timeout on successful completion if (timeoutId) @@ -862,6 +887,170 @@ if (require.main === require.cache[eval('__filename')]) { /***/ }), +/***/ 1351: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getProfileFilePaths = getProfileFilePaths; +exports.ensureAwsDirectoryExists = ensureAwsDirectoryExists; +exports.validateProfileName = validateProfileName; +exports.mergeProfileSection = mergeProfileSection; +exports.writeProfileFiles = writeProfileFiles; +const fs = __importStar(__nccwpck_require__(3024)); +const os = __importStar(__nccwpck_require__(8161)); +const path = __importStar(__nccwpck_require__(6760)); +const core = __importStar(__nccwpck_require__(7484)); +const ini = __importStar(__nccwpck_require__(5756)); +/** + * Get the file paths for AWS credentials and config files + * Respects AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE environment variables + */ +function getProfileFilePaths() { + const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), '.aws', 'credentials'); + const configPath = process.env.AWS_CONFIG_FILE || path.join(os.homedir(), '.aws', 'config'); + return { + credentials: credentialsPath, + config: configPath, + }; +} +/** + * Ensure the AWS directory exists with secure permissions + * Creates the directory with 700 permissions (rwx for owner only) + */ +function ensureAwsDirectoryExists(filePath) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + core.debug(`Creating directory: ${dir}`); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } +} +/** + * Validate profile name format + * Profile names must be non-empty, contain no whitespace, brackets, or path separators + */ +function validateProfileName(profileName) { + if (!profileName || profileName.trim() === '') { + throw new Error('aws-profile must not be empty'); + } + if (/\s/.test(profileName)) { + throw new Error('aws-profile must not contain whitespace'); + } + // INI section names can't contain brackets + if (/[[\]]/.test(profileName)) { + throw new Error('aws-profile must not contain brackets'); + } + // Prevent path traversal + if (profileName.includes('/') || profileName.includes('\\')) { + throw new Error('aws-profile must not contain path separators'); + } +} +/** + * Merge a profile section into an INI file + * Reads existing file, updates the specified section, and writes back atomically + */ +function mergeProfileSection(filePath, sectionName, data) { + let existingContent = {}; + // Read existing file if it exists + if (fs.existsSync(filePath)) { + core.debug(`Reading existing file: ${filePath}`); + const fileContent = fs.readFileSync(filePath, 'utf-8'); + existingContent = ini.parse(fileContent); + } + // Merge: update existing profile or add new one + existingContent[sectionName] = data; + // Atomic write: write to temp file, then rename + const tempFile = `${filePath}.tmp`; + const content = ini.stringify(existingContent); + core.debug(`Writing profile to ${filePath}`); + fs.writeFileSync(tempFile, content, { mode: 0o600 }); + fs.renameSync(tempFile, filePath); +} +/** + * Write AWS profile files with credentials and configuration + * This is the main entry point for profile file operations + * + * @param profileName - Name of the AWS profile to configure + * @param credentials - AWS credentials (access key, secret key, session token) + * @param region - AWS region + */ +function writeProfileFiles(profileName, credentials, region) { + try { + // Validate profile name + validateProfileName(profileName); + const paths = getProfileFilePaths(); + // Ensure .aws directory exists + ensureAwsDirectoryExists(paths.credentials); + ensureAwsDirectoryExists(paths.config); + // Prepare credentials data + const credentialsData = {}; + if (credentials.AccessKeyId) { + credentialsData.aws_access_key_id = credentials.AccessKeyId; + } + if (credentials.SecretAccessKey) { + credentialsData.aws_secret_access_key = credentials.SecretAccessKey; + } + if (credentials.SessionToken) { + credentialsData.aws_session_token = credentials.SessionToken; + } + // Credentials file uses [profileName] syntax + const credsSectionName = profileName; + // Config file uses [profile profileName] syntax, except for 'default' + const configSectionName = profileName === 'default' ? 'default' : `profile ${profileName}`; + // Prepare config data + const configData = { + region: region, + }; + // Write to credentials file + core.info(`Writing credentials to profile: ${profileName}`); + mergeProfileSection(paths.credentials, credsSectionName, credentialsData); + // Write to config file + core.info(`Writing config to profile: ${profileName}`); + mergeProfileSection(paths.config, configSectionName, configData); + core.info(`✓ Successfully configured AWS profile: ${profileName}`); + } + catch (error) { + throw new Error(`Failed to write AWS profile '${profileName}': ${error instanceof Error ? error.message : String(error)}`); + } +} +//# sourceMappingURL=profileManager.js.map + +/***/ }), + /***/ 4914: /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { @@ -44188,6 +44377,293 @@ function parseProxyResponse(socket) { exports.parseProxyResponse = parseProxyResponse; //# sourceMappingURL=parse-proxy-response.js.map +/***/ }), + +/***/ 5756: +/***/ ((module) => { + +const { hasOwnProperty } = Object.prototype + +const encode = (obj, opt = {}) => { + if (typeof opt === 'string') { + opt = { section: opt } + } + opt.align = opt.align === true + opt.newline = opt.newline === true + opt.sort = opt.sort === true + opt.whitespace = opt.whitespace === true || opt.align === true + // The `typeof` check is required because accessing the `process` directly fails on browsers. + /* istanbul ignore next */ + opt.platform = opt.platform || (typeof process !== 'undefined' && process.platform) + opt.bracketedArray = opt.bracketedArray !== false + + /* istanbul ignore next */ + const eol = opt.platform === 'win32' ? '\r\n' : '\n' + const separator = opt.whitespace ? ' = ' : '=' + const children = [] + + const keys = opt.sort ? Object.keys(obj).sort() : Object.keys(obj) + + let padToChars = 0 + // If aligning on the separator, then padToChars is determined as follows: + // 1. Get the keys + // 2. Exclude keys pointing to objects unless the value is null or an array + // 3. Add `[]` to array keys + // 4. Ensure non empty set of keys + // 5. Reduce the set to the longest `safe` key + // 6. Get the `safe` length + if (opt.align) { + padToChars = safe( + ( + keys + .filter(k => obj[k] === null || Array.isArray(obj[k]) || typeof obj[k] !== 'object') + .map(k => Array.isArray(obj[k]) ? `${k}[]` : k) + ) + .concat(['']) + .reduce((a, b) => safe(a).length >= safe(b).length ? a : b) + ).length + } + + let out = '' + const arraySuffix = opt.bracketedArray ? '[]' : '' + + for (const k of keys) { + const val = obj[k] + if (val && Array.isArray(val)) { + for (const item of val) { + out += safe(`${k}${arraySuffix}`).padEnd(padToChars, ' ') + separator + safe(item) + eol + } + } else if (val && typeof val === 'object') { + children.push(k) + } else { + out += safe(k).padEnd(padToChars, ' ') + separator + safe(val) + eol + } + } + + if (opt.section && out.length) { + out = '[' + safe(opt.section) + ']' + (opt.newline ? eol + eol : eol) + out + } + + for (const k of children) { + const nk = splitSections(k, '.').join('\\.') + const section = (opt.section ? opt.section + '.' : '') + nk + const child = encode(obj[k], { + ...opt, + section, + }) + if (out.length && child.length) { + out += eol + } + + out += child + } + + return out +} + +function splitSections (str, separator) { + var lastMatchIndex = 0 + var lastSeparatorIndex = 0 + var nextIndex = 0 + var sections = [] + + do { + nextIndex = str.indexOf(separator, lastMatchIndex) + + if (nextIndex !== -1) { + lastMatchIndex = nextIndex + separator.length + + if (nextIndex > 0 && str[nextIndex - 1] === '\\') { + continue + } + + sections.push(str.slice(lastSeparatorIndex, nextIndex)) + lastSeparatorIndex = nextIndex + separator.length + } + } while (nextIndex !== -1) + + sections.push(str.slice(lastSeparatorIndex)) + + return sections +} + +const decode = (str, opt = {}) => { + opt.bracketedArray = opt.bracketedArray !== false + const out = Object.create(null) + let p = out + let section = null + // section |key = value + const re = /^\[([^\]]*)\]\s*$|^([^=]+)(=(.*))?$/i + const lines = str.split(/[\r\n]+/g) + const duplicates = {} + + for (const line of lines) { + if (!line || line.match(/^\s*[;#]/) || line.match(/^\s*$/)) { + continue + } + const match = line.match(re) + if (!match) { + continue + } + if (match[1] !== undefined) { + section = unsafe(match[1]) + if (section === '__proto__') { + // not allowed + // keep parsing the section, but don't attach it. + p = Object.create(null) + continue + } + p = out[section] = out[section] || Object.create(null) + continue + } + const keyRaw = unsafe(match[2]) + let isArray + if (opt.bracketedArray) { + isArray = keyRaw.length > 2 && keyRaw.slice(-2) === '[]' + } else { + duplicates[keyRaw] = (duplicates?.[keyRaw] || 0) + 1 + isArray = duplicates[keyRaw] > 1 + } + const key = isArray && keyRaw.endsWith('[]') + ? keyRaw.slice(0, -2) : keyRaw + + if (key === '__proto__') { + continue + } + const valueRaw = match[3] ? unsafe(match[4]) : true + const value = valueRaw === 'true' || + valueRaw === 'false' || + valueRaw === 'null' ? JSON.parse(valueRaw) + : valueRaw + + // Convert keys with '[]' suffix to an array + if (isArray) { + if (!hasOwnProperty.call(p, key)) { + p[key] = [] + } else if (!Array.isArray(p[key])) { + p[key] = [p[key]] + } + } + + // safeguard against resetting a previously defined + // array by accidentally forgetting the brackets + if (Array.isArray(p[key])) { + p[key].push(value) + } else { + p[key] = value + } + } + + // {a:{y:1},"a.b":{x:2}} --> {a:{y:1,b:{x:2}}} + // use a filter to return the keys that have to be deleted. + const remove = [] + for (const k of Object.keys(out)) { + if (!hasOwnProperty.call(out, k) || + typeof out[k] !== 'object' || + Array.isArray(out[k])) { + continue + } + + // see if the parent section is also an object. + // if so, add it to that, and mark this one for deletion + const parts = splitSections(k, '.') + p = out + const l = parts.pop() + const nl = l.replace(/\\\./g, '.') + for (const part of parts) { + if (part === '__proto__') { + continue + } + if (!hasOwnProperty.call(p, part) || typeof p[part] !== 'object') { + p[part] = Object.create(null) + } + p = p[part] + } + if (p === out && nl === l) { + continue + } + + p[nl] = out[k] + remove.push(k) + } + for (const del of remove) { + delete out[del] + } + + return out +} + +const isQuoted = val => { + return (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) +} + +const safe = val => { + if ( + typeof val !== 'string' || + val.match(/[=\r\n]/) || + val.match(/^\[/) || + (val.length > 1 && isQuoted(val)) || + val !== val.trim() + ) { + return JSON.stringify(val) + } + return val.split(';').join('\\;').split('#').join('\\#') +} + +const unsafe = val => { + val = (val || '').trim() + if (isQuoted(val)) { + // remove the single quotes before calling JSON.parse + if (val.charAt(0) === "'") { + val = val.slice(1, -1) + } + try { + val = JSON.parse(val) + } catch { + // ignore errors + } + } else { + // walk the val to find the first not-escaped ; character + let esc = false + let unesc = '' + for (let i = 0, l = val.length; i < l; i++) { + const c = val.charAt(i) + if (esc) { + if ('\\;#'.indexOf(c) !== -1) { + unesc += c + } else { + unesc += '\\' + c + } + + esc = false + } else if (';#'.indexOf(c) !== -1) { + break + } else if (c === '\\') { + esc = true + } else { + unesc += c + } + } + if (esc) { + unesc += '\\' + } + + return unesc.trim() + } + return val +} + +module.exports = { + parse: decode, + decode, + stringify: encode, + encode, + safe, + unsafe, +} + + /***/ }), /***/ 8850: diff --git a/package-lock.json b/package-lock.json index b6734ea..0e37013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@actions/core": "^2.0.1", "@aws-sdk/client-sts": "^3.952.0", "@smithy/node-http-handler": "^4.4.5", + "ini": "^5.0.0", "proxy-agent": "^6.5.0" }, "devDependencies": { "@aws-sdk/credential-provider-env": "^3.936.0", "@biomejs/biome": "2.3.9", "@smithy/property-provider": "^4.2.5", + "@types/ini": "^4.1.1", "@types/node": "^25.0.2", "@vercel/ncc": "^0.38.4", "@vitest/coverage-v8": "^3.2.4", @@ -2626,6 +2628,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -2639,7 +2648,6 @@ "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5267,6 +5275,13 @@ "ini": "^1.3.2" } }, + "node_modules/gitconfiglocal/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5511,11 +5526,13 @@ "license": "ISC" }, "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/ip-address": { "version": "10.0.1", @@ -7471,7 +7488,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7553,8 +7569,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -7679,7 +7694,6 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7796,7 +7810,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7810,7 +7823,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index 7edca48..7523344 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@aws-sdk/credential-provider-env": "^3.936.0", "@biomejs/biome": "2.3.9", "@smithy/property-provider": "^4.2.5", + "@types/ini": "^4.1.1", "@types/node": "^25.0.2", "@vercel/ncc": "^0.38.4", "@vitest/coverage-v8": "^3.2.4", @@ -35,6 +36,7 @@ "@actions/core": "^2.0.1", "@aws-sdk/client-sts": "^3.952.0", "@smithy/node-http-handler": "^4.4.5", + "ini": "^5.0.0", "proxy-agent": "^6.5.0" }, "keywords": [ diff --git a/src/cleanup/index.ts b/src/cleanup/index.ts index 81bbdb5..1f10d7f 100644 --- a/src/cleanup/index.ts +++ b/src/cleanup/index.ts @@ -25,6 +25,7 @@ export function cleanup() { core.exportVariable('AWS_SESSION_TOKEN', ''); core.exportVariable('AWS_DEFAULT_REGION', ''); core.exportVariable('AWS_REGION', ''); + core.exportVariable('AWS_PROFILE', ''); } catch (error) { core.setFailed(errorMessage(error)); } diff --git a/src/index.ts b/src/index.ts index ff56db7..9f61832 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { unsetCredentials, verifyKeys, } from './helpers'; +import { writeProfileFiles } from './profileManager'; const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds) const ROLE_SESSION_NAME = 'GitHubActions'; @@ -29,6 +30,7 @@ export async function run() { const sessionTokenInput = core.getInput('aws-session-token', { required: false }); const SessionToken = sessionTokenInput === '' ? undefined : sessionTokenInput; const region = core.getInput('aws-region', { required: true }); + const awsProfile = core.getInput('aws-profile', { required: false }); const roleToAssume = core.getInput('role-to-assume', { required: false }); const audience = core.getInput('audience', { required: false }); const maskAccountId = getBooleanInput('mask-aws-account-id', { required: false }); @@ -117,7 +119,11 @@ export async function run() { if (!region.match(REGION_REGEX)) { throw new Error(`Region is not valid: ${region}`); } - exportRegion(region, outputEnvCredentials); + + // When using profile mode, always export region env vars + // When not using profile mode, respect outputEnvCredentials + const shouldExportRegionEnvVars = awsProfile ? true : outputEnvCredentials; + exportRegion(region, shouldExportRegionEnvVars); // Instantiate credentials client const clientProps: { region: string; proxyServer?: string; noProxy?: string } = { region }; @@ -160,7 +166,9 @@ export async function run() { // Plus, in the assume role case, if the AssumeRole call fails, we want // the source credentials to already be masked as secrets // in any error messages. - exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, outputEnvCredentials); + // When using profile mode, we don't export to env vars (set to false) but still mask secrets + const shouldExportEnvCreds = awsProfile ? false : outputEnvCredentials; + exportCredentials({ AccessKeyId, SecretAccessKey, SessionToken }, outputCredentials, shouldExportEnvCreds); } else if (!webIdentityTokenFile && !roleChaining) { // Proceed only if credentials can be picked up await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds); @@ -200,7 +208,10 @@ export async function run() { ); } while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials)); core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`); - exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials); + + // When using profile mode, we don't export credentials to env vars but still mask secrets and handle step outputs + const shouldExportEnvCreds = awsProfile ? false : outputEnvCredentials; + exportCredentials(roleCredentials.Credentials, outputCredentials, shouldExportEnvCreds); // We need to validate the credentials in 2 of our use-cases // First: self-hosted runners. If the GITHUB_ACTIONS environment variable // is set to `true` then we are NOT in a self-hosted runner. @@ -215,8 +226,28 @@ export async function run() { if (outputEnvCredentials) { await exportAccountId(credentialsClient, maskAccountId); } + + // Write profile files if profile mode is enabled + if (awsProfile) { + writeProfileFiles(awsProfile, roleCredentials.Credentials || {}, region); + + // Export AWS_PROFILE env var if outputEnvCredentials is true + if (outputEnvCredentials) { + core.exportVariable('AWS_PROFILE', awsProfile); + } + } } else { core.info('Proceeding with IAM user credentials'); + + // Write profile files if profile mode is enabled (for IAM user credentials without role assumption) + if (awsProfile) { + writeProfileFiles(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region); + + // Export AWS_PROFILE env var if outputEnvCredentials is true + if (outputEnvCredentials) { + core.exportVariable('AWS_PROFILE', awsProfile); + } + } } // Clear timeout on successful completion diff --git a/src/profileManager.ts b/src/profileManager.ts new file mode 100644 index 0000000..0e9804d --- /dev/null +++ b/src/profileManager.ts @@ -0,0 +1,145 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as core from '@actions/core'; +import type { Credentials } from '@aws-sdk/client-sts'; +import * as ini from 'ini'; + +interface ProfileFilePaths { + credentials: string; + config: string; +} + +/** + * Get the file paths for AWS credentials and config files + * Respects AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE environment variables + */ +export function getProfileFilePaths(): ProfileFilePaths { + const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), '.aws', 'credentials'); + const configPath = process.env.AWS_CONFIG_FILE || path.join(os.homedir(), '.aws', 'config'); + + return { + credentials: credentialsPath, + config: configPath, + }; +} + +/** + * Ensure the AWS directory exists with secure permissions + * Creates the directory with 700 permissions (rwx for owner only) + */ +export function ensureAwsDirectoryExists(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + core.debug(`Creating directory: ${dir}`); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } +} + +/** + * Validate profile name format + * Profile names must be non-empty, contain no whitespace, brackets, or path separators + */ +export function validateProfileName(profileName: string): void { + if (!profileName || profileName.trim() === '') { + throw new Error('aws-profile must not be empty'); + } + + if (/\s/.test(profileName)) { + throw new Error('aws-profile must not contain whitespace'); + } + + // INI section names can't contain brackets + if (/[[\]]/.test(profileName)) { + throw new Error('aws-profile must not contain brackets'); + } + + // Prevent path traversal + if (profileName.includes('/') || profileName.includes('\\')) { + throw new Error('aws-profile must not contain path separators'); + } +} + +/** + * Merge a profile section into an INI file + * Reads existing file, updates the specified section, and writes back atomically + */ +export function mergeProfileSection(filePath: string, sectionName: string, data: Record): void { + let existingContent: Record> = {}; + + // Read existing file if it exists + if (fs.existsSync(filePath)) { + core.debug(`Reading existing file: ${filePath}`); + const fileContent = fs.readFileSync(filePath, 'utf-8'); + existingContent = ini.parse(fileContent); + } + + // Merge: update existing profile or add new one + existingContent[sectionName] = data; + + // Atomic write: write to temp file, then rename + const tempFile = `${filePath}.tmp`; + const content = ini.stringify(existingContent); + + core.debug(`Writing profile to ${filePath}`); + fs.writeFileSync(tempFile, content, { mode: 0o600 }); + fs.renameSync(tempFile, filePath); +} + +/** + * Write AWS profile files with credentials and configuration + * This is the main entry point for profile file operations + * + * @param profileName - Name of the AWS profile to configure + * @param credentials - AWS credentials (access key, secret key, session token) + * @param region - AWS region + */ +export function writeProfileFiles(profileName: string, credentials: Partial, region: string): void { + try { + // Validate profile name + validateProfileName(profileName); + + const paths = getProfileFilePaths(); + + // Ensure .aws directory exists + ensureAwsDirectoryExists(paths.credentials); + ensureAwsDirectoryExists(paths.config); + + // Prepare credentials data + const credentialsData: Record = {}; + if (credentials.AccessKeyId) { + credentialsData.aws_access_key_id = credentials.AccessKeyId; + } + if (credentials.SecretAccessKey) { + credentialsData.aws_secret_access_key = credentials.SecretAccessKey; + } + if (credentials.SessionToken) { + credentialsData.aws_session_token = credentials.SessionToken; + } + + // Credentials file uses [profileName] syntax + const credsSectionName = profileName; + + // Config file uses [profile profileName] syntax, except for 'default' + const configSectionName = profileName === 'default' ? 'default' : `profile ${profileName}`; + + // Prepare config data + const configData: Record = { + region: region, + }; + + // Write to credentials file + core.info(`Writing credentials to profile: ${profileName}`); + mergeProfileSection(paths.credentials, credsSectionName, credentialsData); + + // Write to config file + core.info(`Writing config to profile: ${profileName}`); + mergeProfileSection(paths.config, configSectionName, configData); + + core.info(`✓ Successfully configured AWS profile: ${profileName}`); + } catch (error) { + throw new Error( + `Failed to write AWS profile '${profileName}': ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts index 02b2468..155a137 100644 --- a/test/cleanup.test.ts +++ b/test/cleanup.test.ts @@ -31,12 +31,13 @@ describe('Configure AWS Credentials cleanup', {}, () => { it('replaces AWS credential and region environment variables with empty strings', {}, () => { cleanup(); expect(core.setFailed).toHaveBeenCalledTimes(0); - expect(core.exportVariable).toHaveBeenCalledTimes(5); + expect(core.exportVariable).toHaveBeenCalledTimes(6); expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', ''); expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', ''); expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', ''); expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', ''); expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', ''); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', ''); }); it('handles errors', {}, () => { vi.spyOn(core, 'exportVariable').mockImplementationOnce(() => { diff --git a/test/index.test.ts b/test/index.test.ts index 6df229d..f50c46d 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -822,4 +822,221 @@ describe('Configure AWS Credentials', {}, () => { expect(core.setFailed).not.toHaveBeenCalled(); }); }); + + describe('AWS Profile Support', {}, () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedSTSClient.reset(); + vi.mock('node:fs'); + vol.reset(); + }); + + it('writes profile files with OIDC authentication', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'aws-profile': 'dev', + }), + ); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + // Verify credentials were NOT exported to environment variables + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything()); + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything()); + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything()); + + // Verify AWS_PROFILE was exported + expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'dev'); + + // Verify region was still exported + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1'); + + // Verify profile files were written + expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev'); + expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev'); + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('writes profile files with IAM user credentials', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.IAM_USER_INPUTS, + 'aws-profile': 'production', + }), + ); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({ + accessKeyId: 'MYAWSACCESSKEYID', + }); + + await run(); + + // Verify credentials were NOT exported to env vars + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything()); + + // Verify AWS_PROFILE was exported + expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'production'); + + // Verify region was exported + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1'); + + // Verify profile files were written + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: production'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('writes profile files with role assumption', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.IAM_ASSUMEROLE_INPUTS, + 'aws-profile': 'assumed-role', + }), + ); + mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + // biome-ignore lint/suspicious/noExplicitAny: any required to mock private method + vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials') + .mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' }) + .mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' }); + + await run(); + + // Verify assumed role credentials were NOT exported to env vars + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID'); + + // Verify AWS_PROFILE was exported + expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'assumed-role'); + + // Verify profile files were written + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: assumed-role'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('respects output-env-credentials=false for AWS_PROFILE', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'aws-profile': 'dev', + 'output-env-credentials': 'false', + }), + ); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + // Verify AWS_PROFILE was NOT exported when output-env-credentials is false + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything()); + + // Verify region is still exported (always exported in profile mode) + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1'); + + // Verify profile files were still written + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('maintains backward compatibility when aws-profile is not specified', async () => { + vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS)); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + // Verify credentials WERE exported to environment variables (backward compatibility) + expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'STSAWSSECRETACCESSKEY'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'STSAWSSESSIONTOKEN'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1'); + + // Verify AWS_PROFILE was NOT exported + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything()); + + // Verify profile files were NOT written + expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Writing credentials to profile')); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('handles default profile correctly', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'aws-profile': 'default', + }), + ); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + // Verify profile files were written for 'default' profile + expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: default'); + expect(core.info).toHaveBeenCalledWith('Writing config to profile: default'); + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: default'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + it('rejects invalid profile names with whitespace', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'aws-profile': 'invalid profile', + }), + ); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('whitespace')); + }); + + it('always exports region in profile mode even with output-env-credentials=false', async () => { + vi.spyOn(core, 'getInput').mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'aws-profile': 'dev', + 'output-env-credentials': 'false', + }), + ); + vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken'); + mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token'; + + await run(); + + // Verify region was exported even when output-env-credentials is false + expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1'); + expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1'); + + // Verify credential env vars were NOT exported + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything()); + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything()); + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything()); + + // Verify AWS_PROFILE was NOT exported (output-env-credentials=false) + expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything()); + + expect(core.setFailed).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/profileManager.test.ts b/test/profileManager.test.ts new file mode 100644 index 0000000..83da570 --- /dev/null +++ b/test/profileManager.test.ts @@ -0,0 +1,342 @@ +import * as core from '@actions/core'; +import { fs, vol } from 'memfs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ini from 'ini'; +import { + ensureAwsDirectoryExists, + getProfileFilePaths, + mergeProfileSection, + validateProfileName, + writeProfileFiles, +} from '../src/profileManager'; + +describe('Profile Manager', {}, () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.mock('node:fs'); + vol.reset(); + vi.spyOn(core, 'debug').mockImplementation(() => {}); + vi.spyOn(core, 'info').mockImplementation(() => {}); + }); + + describe('validateProfileName', {}, () => { + it('accepts valid profile names', {}, () => { + expect(() => validateProfileName('dev')).not.toThrow(); + expect(() => validateProfileName('production')).not.toThrow(); + expect(() => validateProfileName('my-profile-123')).not.toThrow(); + expect(() => validateProfileName('default')).not.toThrow(); + }); + + it('rejects empty profile names', {}, () => { + expect(() => validateProfileName('')).toThrow('aws-profile must not be empty'); + expect(() => validateProfileName(' ')).toThrow('aws-profile must not be empty'); + }); + + it('rejects profile names with whitespace', {}, () => { + expect(() => validateProfileName('my profile')).toThrow('aws-profile must not contain whitespace'); + expect(() => validateProfileName('dev\ntest')).toThrow('aws-profile must not contain whitespace'); + expect(() => validateProfileName('prod\tenv')).toThrow('aws-profile must not contain whitespace'); + }); + + it('rejects profile names with brackets', {}, () => { + expect(() => validateProfileName('dev[test]')).toThrow('aws-profile must not contain brackets'); + expect(() => validateProfileName('[profile]')).toThrow('aws-profile must not contain brackets'); + }); + + it('rejects profile names with path separators', {}, () => { + expect(() => validateProfileName('dev/test')).toThrow('aws-profile must not contain path separators'); + expect(() => validateProfileName('dev\\test')).toThrow('aws-profile must not contain path separators'); + expect(() => validateProfileName('../etc/passwd')).toThrow('aws-profile must not contain path separators'); + }); + }); + + describe('getProfileFilePaths', {}, () => { + it('returns default paths when env vars not set', {}, () => { + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + delete process.env.AWS_CONFIG_FILE; + + const paths = getProfileFilePaths(); + + expect(paths.credentials).toMatch(/\.aws\/credentials$/); + expect(paths.config).toMatch(/\.aws\/config$/); + }); + + it('respects AWS_SHARED_CREDENTIALS_FILE env var', {}, () => { + process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/path/credentials'; + process.env.AWS_CONFIG_FILE = '/custom/path/config'; + + const paths = getProfileFilePaths(); + + expect(paths.credentials).toBe('/custom/path/credentials'); + expect(paths.config).toBe('/custom/path/config'); + }); + }); + + describe('ensureAwsDirectoryExists', {}, () => { + it('creates directory if it does not exist', {}, () => { + const filePath = '/home/runner/.aws/credentials'; + + ensureAwsDirectoryExists(filePath); + + expect(fs.existsSync('/home/runner/.aws')).toBe(true); + }); + + it('does not error if directory already exists', {}, () => { + const filePath = '/home/runner/.aws/credentials'; + + fs.mkdirSync('/home/runner/.aws', { recursive: true }); + + expect(() => ensureAwsDirectoryExists(filePath)).not.toThrow(); + }); + + it('creates nested directories', {}, () => { + const filePath = '/home/runner/custom/path/.aws/credentials'; + + ensureAwsDirectoryExists(filePath); + + expect(fs.existsSync('/home/runner/custom/path/.aws')).toBe(true); + }); + }); + + describe('mergeProfileSection', {}, () => { + it('creates new file with profile section', {}, () => { + const filePath = '/home/runner/.aws/credentials'; + fs.mkdirSync('/home/runner/.aws', { recursive: true }); + + mergeProfileSection(filePath, 'dev', { + aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', + aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }); + + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = ini.parse(content); + + expect(parsed.dev).toBeDefined(); + expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE'); + expect(parsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'); + }); + + it('merges with existing profiles', {}, () => { + const filePath = '/home/runner/.aws/credentials'; + fs.mkdirSync('/home/runner/.aws', { recursive: true }); + + // Create initial profile + mergeProfileSection(filePath, 'dev', { + aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE', + aws_secret_access_key: 'devSecretKey', + }); + + // Add second profile + mergeProfileSection(filePath, 'prod', { + aws_access_key_id: 'AKIAPRODEXAMPLE', + aws_secret_access_key: 'prodSecretKey', + }); + + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = ini.parse(content); + + expect(parsed.dev).toBeDefined(); + expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE'); + expect(parsed.prod).toBeDefined(); + expect(parsed.prod.aws_access_key_id).toBe('AKIAPRODEXAMPLE'); + }); + + it('overwrites existing profile with same name', {}, () => { + const filePath = '/home/runner/.aws/credentials'; + fs.mkdirSync('/home/runner/.aws', { recursive: true }); + + // Create initial profile + mergeProfileSection(filePath, 'dev', { + aws_access_key_id: 'OLD_KEY', + aws_secret_access_key: 'oldSecretKey', + }); + + // Overwrite with new credentials + mergeProfileSection(filePath, 'dev', { + aws_access_key_id: 'NEW_KEY', + aws_secret_access_key: 'newSecretKey', + aws_session_token: 'sessionToken', + }); + + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = ini.parse(content); + + expect(parsed.dev.aws_access_key_id).toBe('NEW_KEY'); + expect(parsed.dev.aws_secret_access_key).toBe('newSecretKey'); + expect(parsed.dev.aws_session_token).toBe('sessionToken'); + }); + }); + + describe('writeProfileFiles', {}, () => { + beforeEach(() => { + delete process.env.AWS_SHARED_CREDENTIALS_FILE; + delete process.env.AWS_CONFIG_FILE; + }); + + it('writes credentials and config for new profile', {}, () => { + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE', + }, + 'us-east-1', + ); + + // Check credentials file + const credsPath = getProfileFilePaths().credentials; + const credContent = fs.readFileSync(credsPath, 'utf-8'); + const credParsed = ini.parse(credContent); + + expect(credParsed.dev).toBeDefined(); + expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE'); + expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'); + expect(credParsed.dev.aws_session_token).toBe('FwoGZXIvYXdzEBYaDEXAMPLE'); + + // Check config file + const configPath = getProfileFilePaths().config; + const configContent = fs.readFileSync(configPath, 'utf-8'); + const configParsed = ini.parse(configContent); + + expect(configParsed['profile dev']).toBeDefined(); + expect(configParsed['profile dev'].region).toBe('us-east-1'); + }); + + it('uses correct section naming for default profile', {}, () => { + writeProfileFiles( + 'default', + { + AccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + 'us-west-2', + ); + + // Check credentials file uses [default] + const credsPath = getProfileFilePaths().credentials; + const credContent = fs.readFileSync(credsPath, 'utf-8'); + const credParsed = ini.parse(credContent); + + expect(credParsed.default).toBeDefined(); + expect(credParsed['profile default']).toBeUndefined(); + + // Check config file uses [default] (not [profile default]) + const configPath = getProfileFilePaths().config; + const configContent = fs.readFileSync(configPath, 'utf-8'); + const configParsed = ini.parse(configContent); + + expect(configParsed.default).toBeDefined(); + expect(configParsed['profile default']).toBeUndefined(); + }); + + it('supports multiple profiles', {}, () => { + // Write first profile + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIADEV', + SecretAccessKey: 'devSecret', + }, + 'us-east-1', + ); + + // Write second profile + writeProfileFiles( + 'prod', + { + AccessKeyId: 'AKIAPROD', + SecretAccessKey: 'prodSecret', + SessionToken: 'prodToken', + }, + 'us-west-2', + ); + + // Verify both profiles exist + const credsPath = getProfileFilePaths().credentials; + const credContent = fs.readFileSync(credsPath, 'utf-8'); + const credParsed = ini.parse(credContent); + + expect(credParsed.dev).toBeDefined(); + expect(credParsed.prod).toBeDefined(); + expect(credParsed.dev.aws_access_key_id).toBe('AKIADEV'); + expect(credParsed.prod.aws_access_key_id).toBe('AKIAPROD'); + }); + + it('handles credentials without session token', {}, () => { + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + 'us-east-1', + ); + + const credsPath = getProfileFilePaths().credentials; + const credContent = fs.readFileSync(credsPath, 'utf-8'); + const credParsed = ini.parse(credContent); + + expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE'); + expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'); + expect(credParsed.dev.aws_session_token).toBeUndefined(); + }); + + it('throws error for invalid profile name', {}, () => { + expect(() => + writeProfileFiles( + 'invalid profile', + { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + }, + 'us-east-1', + ), + ).toThrow('Failed to write AWS profile'); + expect(() => + writeProfileFiles( + 'invalid profile', + { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + }, + 'us-east-1', + ), + ).toThrow('whitespace'); + }); + + it('respects custom file paths from env vars', {}, () => { + process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/credentials'; + process.env.AWS_CONFIG_FILE = '/custom/config'; + + fs.mkdirSync('/custom', { recursive: true }); + + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + }, + 'us-east-1', + ); + + expect(fs.existsSync('/custom/credentials')).toBe(true); + expect(fs.existsSync('/custom/config')).toBe(true); + }); + + it('logs info messages', {}, () => { + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIA', + SecretAccessKey: 'secret', + }, + 'us-east-1', + ); + + expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev'); + expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev'); + expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev'); + }); + }); +});