diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index e494f10..0000000 --- a/examples/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Examples - -## [federated-setup](./federated-setup/README.md) - -The directory contains templates for setting up the `configure-aws-credentials` -federation between your GitHub Organization/repository and your AWS account. - -## [cfn-deploy-example](./cfn-deploy-example/README.md) - -Repository example uses aws-action `configure-aws-credentials` with OIDC -federation template -[github-actions-oidc-federation-and-role](./github-actions-oidc-federation-and-role.yml). -Example demonstrates a repository that deploys AWS CloudFormation template using -cfn-deploy GitHub Action. diff --git a/examples/cfn-deploy-example/.github/workflows/compliance.yml b/examples/cfn-deploy-example/.github/workflows/compliance.yml deleted file mode 100644 index 9e0cc79..0000000 --- a/examples/cfn-deploy-example/.github/workflows/compliance.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: 'compliance' -## run ci testing on all push events -on: [push] -jobs: - ## Guard rule set - sast-guard: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: grolston/guard-action@main - with: - data_directory: './cloudformation/' ## change to your template directory - rule_set: 'FedRAMP-Moderate' - show_summary: 'all' - output_format: 'single-line-summary' \ No newline at end of file diff --git a/examples/cfn-deploy-example/.github/workflows/deploy.yml b/examples/cfn-deploy-example/.github/workflows/deploy.yml deleted file mode 100644 index 8d02cb6..0000000 --- a/examples/cfn-deploy-example/.github/workflows/deploy.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: deploy -on: - push: - branches: - - main -env: - AWS_DEFAULT_REGION: us-east-1 - AWS_DEFAULT_OUTPUT: json - -jobs: - deploy-cfn: - name: deploy - runs-on: ubuntu-latest - # These permissions are needed to interact with GitHub’s OIDC Token endpoint. - permissions: - id-token: write - contents: read - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 - with: - aws-region: us-east-1 - ## the following creates an ARN based on the values entered into github secrets - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_DEPLOY_ROLE }} - role-session-name: myGitHubActions - - name: Deploy EC2 Bastion - uses: aws-actions/aws-cloudformation-github-deploy@v1.3.0 - with: - name: myEC2bastion - ## change to path to template in your github repo - template: cloudformation/ec2-bastion.yml - capabilities: CAPABILITY_IAM, CAPABILITY_NAMED_IAM - no-fail-on-empty-changeset: "1" - ## parameter set in github secrets - parameter-overrides: "pVpc=${{ secrets.VPC_ID }},pSubnet=${{ secrets.SUBNET_ID }}" diff --git a/examples/cfn-deploy-example/README.md b/examples/cfn-deploy-example/README.md deleted file mode 100644 index 7f68737..0000000 --- a/examples/cfn-deploy-example/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# cfn-deploy example - -Example uses aws-action `configure-aws-credentials` with OIDC federation. Prior -to using this example project, the user needs to deploy the -[github-actions-oidc-federation-and-role](../federated-setup/github-actions-oidc-federation-and-role.yml) -template in the AWS account they want to deploy the CloudFormation template -into. Specify the GitHub Organization name, repository name, and the specific -branch you want to deploy on. - -Within the [github/workflows](./.github/workflows/) directory there is a -[compliance.yml](./.github/workflows/compliance.yml) and a -[deploy.yml](./.github/workflows/deploy.yml). The deploy.yml file leverages the -aws-action `configure-aws-credentials` and accesses GitHub Action Secrets for -some of the variables. The compliance.yml runs static application security -testing using cfn-guard. - -To use the example you will need to set the following GitHub Action Secrets: - -| Secret Key | Used With | Description | -| --------------- | -------------------------------- | ---------------------------------------- | -| AWS_ACCOUNT_ID | configure-aws-credentials | The AWS account ID | -| AWS_DEPLOY_ROLE | configure-aws-credentials | The name of the IAM role | -| VPC_ID | aws-cloudformation-github-deploy | VPC ID the EC2 Bastion is deployed to | -| SUBNET_ID | aws-cloudformation-github-deploy | Subnet ID the EC2 Bastion is deployed to | diff --git a/examples/cfn-deploy-example/ec2-bastion.yml b/examples/cfn-deploy-example/ec2-bastion.yml deleted file mode 100644 index 28c6a92..0000000 --- a/examples/cfn-deploy-example/ec2-bastion.yml +++ /dev/null @@ -1,150 +0,0 @@ ---- -AWSTemplateFormatVersion: "2010-09-09" -Description: EC2 bastion for latest AWS Linux 2 EC2 deployment -Metadata: - AWS::CloudFormation::Interface: - ParameterGroups: - - Label: - default: "EC2 Configuration" - Parameters: - - pTagNameValue - - pOperatingSystem - - pInstanceType - - pVolumeSize - - pEbsDeleteOnTermination - - Label: - default: "Network Configuration" - Parameters: - - pVpc - - pSubnet - ParameterLabels: - pOperatingSystem: - default: "Operating System" - pInstanceType: - default: "Instance Type" - pTagNameValue: - default: "EC2 Name" - pVolumeSize: - default: "Volume Size" - pEbsDeleteOnTermination: - default: "Delete EBS Volume on Termination" - pSubnet: - default: "Subnet" - pVpc: - default: "VPC" -Parameters: - pSubnet: - Description: The subnet to launch the instance in to. It must be part of the VPC chosen above. - Type: AWS::EC2::Subnet::Id - pVpc: - Description: The VPC to launch the EC2 instance in to. - Type: AWS::EC2::VPC::Id - pOperatingSystem: - Type: "AWS::SSM::Parameter::Value" - Default: "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-ebs" - pInstanceType: - Description: Desired Instance Size - Type: String - Default: t3.small - AllowedValues: - - t3.small - - t3.medium - - t3.nano - pTagNameValue: - Description: "Required: Enter the tag name you'd like applied to the instance. Tag Name gives the name to the EC2 instance." - Type: String - MinLength: 1 - Default: "myBastion" - pVolumeSize: - Description: - Enter the number of GBs you want your volume to be. The minimum value - is 8 GBs - Type: Number - Default: 50 - MinValue: 8 - pEbsDeleteOnTermination: - Description: "Specify if the EBS volume should be deleted if EC2 is deleted." - Type: String - Default: true - AllowedValues: - - true - - false -Rules: - SubnetInVPC: - Assertions: - - Assert: !EachMemberIn - - !ValueOfAll - - AWS::EC2::Subnet::Id - - VpcId - - !RefAll "AWS::EC2::VPC::Id" - AssertDescription: All subnets must in the VPC -Resources: - rSecurityGroupDefault: - Type: AWS::EC2::SecurityGroup - Properties: - GroupDescription: !Sub "Default SG for SC Product ${pTagNameValue} " - VpcId: !Ref pVpc - SecurityGroupEgress: - - Description: Outbound unrestricted traffic - IpProtocol: "-1" - CidrIp: 0.0.0.0/0 - Tags: - - Key: Name - Value: !Ref pTagNameValue - rLinuxEc2: - Type: AWS::EC2::Instance - Metadata: - guard: - SuppressedRules: - - 'EC2_INSTANCE_DETAILED_MONITORING_ENABLED' - Properties: - ImageId: !Ref pOperatingSystem - IamInstanceProfile: !Ref rec2InstanceProfile - Monitoring: false - InstanceType: !Ref pInstanceType - EbsOptimized: true - SourceDestCheck: true - SubnetId: !Ref pSubnet - SecurityGroupIds: - - !Ref rSecurityGroupDefault - BlockDeviceMappings: - - DeviceName: "/dev/xvda" - Ebs: - VolumeSize: !Ref pVolumeSize - DeleteOnTermination: !Ref pEbsDeleteOnTermination - Tags: - - Key: Name - Value: !Ref pTagNameValue - UserData: - Fn::Base64: - yum update -y - ## Instance Profiles - ## EC2 IAM Roles - rEc2Role: - Type: AWS::IAM::Role - Properties: - RoleName: !Sub "ec2-role-${AWS::StackName}" - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Principal: - Service: [ec2.amazonaws.com] - Action: ['sts:AssumeRole'] - Path: / - ManagedPolicyArns: - - !Sub 'arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore' - - !Sub 'arn:${AWS::Partition}:iam::aws:policy/CloudWatchAgentServerPolicy' - rec2InstanceProfile: - Type: AWS::IAM::InstanceProfile - Properties: - InstanceProfileName: !Sub "ec2-profile-${AWS::StackName}" - Path: / - Roles: - - !Ref rEc2Role -Outputs: - oLinuxEc2InstanceId: - Description: Resource ID of the newly created EC2 instance - Value: !Ref rLinuxEc2 - oLinuxEc2PrivateIP: - Description: Private IP Address for EC2 - Value: !GetAtt rLinuxEc2.PrivateIp diff --git a/examples/federated-setup/README.md b/examples/federated-setup/README.md deleted file mode 100644 index 684488c..0000000 --- a/examples/federated-setup/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# federated-setup - -## [github-action-oidc-federation](./github-actions-oidc-federation.yml) - -Setup of the OIDC federation between your GitHub Organization/repository and -your AWS account. - -## [github-actions-oidc-federation-and-role](./github-actions-oidc-federation-and-role.yml) - -Setup of the OIDC federation between your GitHub Organization/repository and -your AWS account along with a role that only executes on specific branch. diff --git a/examples/federated-setup/github-actions-oidc-federation-and-role.yml b/examples/federated-setup/github-actions-oidc-federation-and-role.yml deleted file mode 100644 index 0c8de29..0000000 --- a/examples/federated-setup/github-actions-oidc-federation-and-role.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -AWSTemplateFormatVersion: "2010-09-09" -Description: Github Actions configuration - OIDC IAM IdP and associated role CI/CD - -Parameters: - - GitHubOrganization: - Type: String - Description: This is the root organization or personal account where repos are stored (Case Sensitive) - - RepositoryName: - Type: String - Description: The repo(s) these roles will have access to. (Use * for all org or personal repos) - Default: "*" - - BranchName: - Type: String - Description: Name of the git branch to to trust. (Use * for all branches) - Default: "*" - - RoleName: - Type: String - Description: Name the Role - - UseExistingProvider: - Type: String - Description: "Only one GitHub Provider can exists. Choose yes if one is already present in account" - Default: "no" - AllowedValues: - - "yes" - - "no" - -Conditions: - - CreateProvider: !Equals ["no", !Ref UseExistingProvider] - -Resources: - - IdpGitHubOidc: - Type: AWS::IAM::OIDCProvider - Condition: CreateProvider - Properties: - Url: https://token.actions.githubusercontent.com - ClientIdList: - - sts.amazonaws.com - - !Sub https://github.com/${GitHubOrganization}/${RepositoryName} - ThumbprintList: - - 6938fd4d98bab03faadb97b34396831e3780aea1 - Tags: - - Key: Name - Value: !Sub ${RoleName}-OIDC-Provider - - RoleGithubActions: - Type: AWS::IAM::Role - Properties: - RoleName: !Ref RoleName - AssumeRolePolicyDocument: - Statement: - - Effect: Allow - Action: sts:AssumeRoleWithWebIdentity - Principal: - Federated: !If - - CreateProvider - - !Ref IdpGitHubOidc - - !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com - Condition: - StringLike: - token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrganization}/${RepositoryName}:ref:refs/heads/${BranchName} - ManagedPolicyArns: - ## edit the managed policy to give least privileges - - !Sub arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess - -Outputs: - - IdpGitHubOidc: - Condition: CreateProvider - Description: "ARN of Github OIDC Provider" - Value: !GetAtt IdpGitHubOidc.Arn - - RoleGithubActionsARN: - Description: "CICD Role for GitHub Actions" - Value: !GetAtt RoleGithubActions.Arn diff --git a/examples/federated-setup/github-actions-oidc-federation.yml b/examples/federated-setup/github-actions-oidc-federation.yml deleted file mode 100644 index b0dadf2..0000000 --- a/examples/federated-setup/github-actions-oidc-federation.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -AWSTemplateFormatVersion: "2010-09-09" -Description: Github Actions configuration - OIDC IAM IdP Federation - -Parameters: - - GitHubOrganization: - Type: String - Description: This is the root organization or personal account where repos are stored (Case Sensitive) - Default: "" - - RepositoryName: - Type: String - Description: The repo(s) these roles will have access to. (Use * for all org or personal repos) - Default: "*" - - RoleName: - Type: String - Description: Name the Role - Default: "" - - -Resources: - - IdpGitHubOidc: - Type: AWS::IAM::OIDCProvider - Properties: - Url: https://token.actions.githubusercontent.com - ClientIdList: - - sts.amazonaws.com - - !Sub https://github.com/${GitHubOrganization}/${RepositoryName} - ThumbprintList: - - 6938fd4d98bab03faadb97b34396831e3780aea1 - Tags: - - Key: Name - Value: !Sub ${RoleName}-OIDC-Provider - - -Outputs: - - IdpGitHubOidc: - Description: "ARN of Github OIDC Provider" - Value: !GetAtt IdpGitHubOidc.Arn diff --git a/package-lock.json b/package-lock.json index 1c6c16c..b6edce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@actions/core": "^3.0.1", "@aws-sdk/client-sts": "^3.1116.0", + "@aws-sdk/credential-provider-node": "^3.972.63", "@smithy/node-http-handler": "^4.11.3", "proxy-agent": "^8.0.2" }, diff --git a/package.json b/package.json index c18ea26..7e1a193 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@actions/core": "^3.0.1", "@aws-sdk/client-sts": "^3.1116.0", + "@aws-sdk/credential-provider-node": "^3.972.63", "@smithy/node-http-handler": "^4.11.3", "proxy-agent": "^8.0.2" }, diff --git a/src/CredentialsClient.ts b/src/CredentialsClient.ts index 8347a99..43c16b1 100644 --- a/src/CredentialsClient.ts +++ b/src/CredentialsClient.ts @@ -1,9 +1,10 @@ import { info } from '@actions/core'; import { STSClient } from '@aws-sdk/client-sts'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; import type { AwsCredentialIdentity } from '@aws-sdk/types'; import { NodeHttpHandler } from '@smithy/node-http-handler'; import { ProxyAgent } from 'proxy-agent'; -import { buildCustomUserAgent, errorMessage, getCallerIdentity } from './helpers'; +import { buildCustomUserAgent, errorMessage, getCallerIdentity, maskProxyCredentials } from './helpers'; import { ProxyResolver } from './ProxyResolver'; if (!process.env.AWS_EXECUTION_ENV) { @@ -31,6 +32,7 @@ export class CredentialsClient { } if (props.proxyServer) { info('Configuring proxy handler for STS client'); + maskProxyCredentials(props.proxyServer); const proxyOptions: { httpProxy: string; httpsProxy: string; noProxy?: string } = { httpProxy: props.proxyServer, httpsProxy: props.proxyServer, @@ -105,9 +107,15 @@ export class CredentialsClient { } private async loadCredentials() { - const config = {} as { requestHandler?: NodeHttpHandler }; - if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler; - const client = new STSClient(config); - return client.config.credentials(); + // Previously we constructed a new client, but that picks up the default provider chain including the endpoint. + // Explicitly calling the default provider chain allows us to pass in the endpoint and region as well as the + // proxy config. + return defaultProvider({ + clientConfig: { + ...(this.region !== undefined && { region: this.region }), + ...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }), + ...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }), + }, + })(); } } diff --git a/src/helpers.ts b/src/helpers.ts index 468a12d..b304db6 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -5,7 +5,6 @@ import type { Credentials, STSClient } from '@aws-sdk/client-sts'; import { GetCallerIdentityCommand } from '@aws-sdk/client-sts'; import type { AwsCredentialIdentity } from '@aws-sdk/types'; import type { UserAgent } from '@smithy/types'; -import type { CredentialsClient } from './CredentialsClient'; const MAX_TAG_VALUE_LENGTH = 256; const SANITIZATION_CHARACTER = '_'; @@ -167,15 +166,13 @@ export function exportAccountId(identity: { Account: string; Arn: string }, mask // Validates that the account of the already-resolved caller identity is in the allow-list provided via the // `allowed-account-ids` input. export function validateAccountId(expectedAccountIds: string[] | undefined, account: string | undefined): void { - if (!expectedAccountIds || expectedAccountIds.length === 0 || expectedAccountIds[0] === '') { + const allowedAccountIds = expectedAccountIds?.filter((id) => id !== '') ?? []; + if (allowedAccountIds.length === 0) { return; } - if (!account || !expectedAccountIds.includes(account)) { - throw new Error( - `The account ID of the provided credentials (${ - account ?? 'unknown' - }) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`, - ); + if (!account || !allowedAccountIds.includes(account)) { + // Account IDs are deliberately omitted: this error reaches the job log before any mask exists. + throw new Error('The account ID of the provided credentials does not match any of the allowed account IDs'); } } @@ -193,6 +190,29 @@ export function toCredentialIdentity(creds?: Partial): AwsCredentia }; } +// Registers any userinfo embedded in a proxy URL as secrets so it is masked in job logs. +// First the literal proxy string, then any username/password components if parseable. +// If the username/password is percent-encoded, the decoded form is also masked. +export function maskProxyCredentials(proxyServer: string): void { + core.setSecret(proxyServer); + let url: URL; + try { + url = new URL(proxyServer); + } catch (_) { + return; + } + for (const part of [url.username, url.password]) { + if (!part) continue; + core.setSecret(part); + try { + const decoded = decodeURIComponent(part); + if (decoded !== part) core.setSecret(decoded); + } catch (_) { + // malformed percent-encoding; the raw form is already masked + } + } +} + // Tags have a more restrictive set of acceptable characters than GitHub environment variables can. // This replaces anything not conforming to the tag restrictions by inverting the regular expression. // See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html. @@ -281,19 +301,6 @@ export function isDefined(i: T | undefined | null): i is T { } /* c8 ignore stop */ -export async function areCredentialsValid(credentialsClient: CredentialsClient) { - const client = credentialsClient.stsClient; - try { - const identity = await client.send(new GetCallerIdentityCommand({})); - if (identity.Account) { - return true; - } - return false; - } catch (_) { - return false; - } -} - /** * Like core.getBooleanInput, but respects the required option. * diff --git a/src/index.ts b/src/index.ts index 9d77a52..340f44c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,12 @@ import type { AssumeRoleCommandOutput } from '@aws-sdk/client-sts'; import { assumeRole } from './assumeRole'; import { CredentialsClient } from './CredentialsClient'; import { - areCredentialsValid, errorMessage, exportAccountId, exportCredentials, exportRegion, getBooleanInput, + getCallerIdentity, retryAndBackoff, toCredentialIdentity, translateEnvVariables, @@ -53,8 +53,8 @@ export async function run() { }); const roleChaining = getBooleanInput('role-chaining', { required: false }); const outputCredentials = getBooleanInput('output-credentials', { required: false }); - // Default to always outputting environment credentials unless profile is specified. If profile is specified, default - // to no environment credentials (but still output them if the user specifically requests it). + // Default to always outputting environment credentials unless profile is specified. If profile is specified, + // default to no environment credentials (but still output them if the user specifically requests it). const outputEnvCredentials = getBooleanInput('output-env-credentials', { required: false, default: !awsProfile }); const unsetCurrentCredentials = getBooleanInput('unset-current-credentials', { required: false }); let disableRetry = getBooleanInput('disable-retry', { required: false }); @@ -165,8 +165,16 @@ export async function run() { //if the user wants to attempt to use existing credentials, check if we have some already if (useExistingCredentials) { - const validCredentials = await areCredentialsValid(credentialsClient); - if (validCredentials) { + const identity = await (async () => { + try { + return await getCallerIdentity(credentialsClient.stsClient); + } catch { + return null; + } + })(); + if (identity) { + // The allowed-account-ids guardrail applies to reused credentials too. + validateAccountId(expectedAccountIds, identity.Account); core.notice('Pre-existing credentials are valid. No need to generate new ones.'); if (timeoutId) clearTimeout(timeoutId); return; diff --git a/src/profileManager.ts b/src/profileManager.ts index 89e8ae6..f18cdbb 100644 --- a/src/profileManager.ts +++ b/src/profileManager.ts @@ -53,8 +53,15 @@ export function parseIni(iniData: string): Record export function stringifyIni(data: Record>): string { const sections: string[] = []; for (const [sectionName, sectionData] of Object.entries(data)) { + if (/[\r\n]/.test(sectionName)) { + throw new Error('INI section names must not contain newline characters'); + } const lines: string[] = [`[${sectionName}]`]; for (const [key, value] of Object.entries(sectionData)) { + // A newline in a key or value would inject arbitrary INI lines (e.g. credential_process). + if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) { + throw new Error('INI keys and values must not contain newline characters'); + } lines.push(`${key} = ${value}`); } sections.push(lines.join('\n')); diff --git a/test/CredentialsClient.test.ts b/test/CredentialsClient.test.ts new file mode 100644 index 0000000..65288f0 --- /dev/null +++ b/test/CredentialsClient.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@aws-sdk/credential-provider-node', () => ({ + defaultProvider: vi.fn(() => async () => ({ accessKeyId: 'AKIA', secretAccessKey: 'secret' })), +})); + +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { CredentialsClient } from '../src/CredentialsClient'; + +describe('CredentialsClient', {}, () => { + it('pins ambient credential resolution to the configured region and STS endpoint', {}, async () => { + const client = new CredentialsClient({ + region: 'eu-west-1', + stsEndpoint: 'https://sts.example.com', + roleChaining: false, + }); + // biome-ignore lint/suspicious/noExplicitAny: any required to call private method + await (client as any).loadCredentials(); + expect(defaultProvider).toHaveBeenCalledWith({ + clientConfig: expect.objectContaining({ region: 'eu-west-1', endpoint: 'https://sts.example.com' }), + }); + }); + + it('omits unset client config values from ambient credential resolution', {}, async () => { + const client = new CredentialsClient({ region: 'eu-west-1', roleChaining: false }); + // biome-ignore lint/suspicious/noExplicitAny: any required to call private method + await (client as any).loadCredentials(); + expect(defaultProvider).toHaveBeenLastCalledWith({ clientConfig: { region: 'eu-west-1' } }); + }); +}); diff --git a/test/helpers.test.ts b/test/helpers.test.ts index eefca3c..71a7c8e 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -126,6 +126,45 @@ describe('Configure AWS Credentials helpers', {}, () => { expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', ''); }); + describe('maskProxyCredentials', {}, () => { + it('masks username and password embedded in a proxy URL', {}, () => { + helpers.maskProxyCredentials('http://user:secretpass@proxy.example.com:8080'); + expect(core.setSecret).toHaveBeenCalledWith('user'); + expect(core.setSecret).toHaveBeenCalledWith('secretpass'); + }); + + it('masks both encoded and decoded forms of the credentials', {}, () => { + helpers.maskProxyCredentials('http://user:p%40ss@proxy.example.com:8080'); + expect(core.setSecret).toHaveBeenCalledWith('p%40ss'); + expect(core.setSecret).toHaveBeenCalledWith('p@ss'); + }); + + it('masks the whole value even without embedded credentials or when unparseable', {}, () => { + helpers.maskProxyCredentials('http://proxy.example.com:8080'); + expect(core.setSecret).toHaveBeenCalledWith('http://proxy.example.com:8080'); + helpers.maskProxyCredentials('not a url'); + expect(core.setSecret).toHaveBeenCalledWith('not a url'); + // no username/password parts, so exactly one mask per call + expect(core.setSecret).toHaveBeenCalledTimes(2); + }); + }); + + describe('validateAccountId', {}, () => { + it('enforces the allow-list even when the first element is empty', {}, () => { + expect(() => helpers.validateAccountId(['', '999999999999'], '111111111111')).toThrow(/does not match/); + }); + + it('passes an allowed account despite empty entries in the list', {}, () => { + expect(() => helpers.validateAccountId(['', '111111111111'], '111111111111')).not.toThrow(); + }); + + it('skips validation only when no non-empty entries exist', {}, () => { + expect(() => helpers.validateAccountId(undefined, '111111111111')).not.toThrow(); + expect(() => helpers.validateAccountId([], '111111111111')).not.toThrow(); + expect(() => helpers.validateAccountId([''], '111111111111')).not.toThrow(); + }); + }); + describe('filesystem helpers', {}, () => { describe('isSymlink', {}, () => { it('returns true for a symlink', {}, () => { diff --git a/test/index.test.ts b/test/index.test.ts index 24583b3..18665ca 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -841,7 +841,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalledWith( - 'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999', + 'The account ID of the provided credentials does not match any of the allowed account IDs', ); }); @@ -861,7 +861,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalledWith( - 'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999, 888888888888', + 'The account ID of the provided credentials does not match any of the allowed account IDs', ); }); @@ -917,7 +917,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalledWith( - 'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999', + 'The account ID of the provided credentials does not match any of the allowed account IDs', ); }); @@ -936,7 +936,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalledWith( - 'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999', + 'The account ID of the provided credentials does not match any of the allowed account IDs', ); }); @@ -956,7 +956,7 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).toHaveBeenCalledWith( - 'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999', + 'The account ID of the provided credentials does not match any of the allowed account IDs', ); }); @@ -1015,6 +1015,33 @@ describe('Configure AWS Credentials', {}, () => { await run(); expect(core.setFailed).not.toHaveBeenCalled(); }); + + it('fails on the use-existing-credentials path when the account is not allowed', async () => { + vi.mocked(core.getInput).mockImplementation( + mocks.getInput({ + ...mocks.USE_EXISTING_CREDENTIALS_INPUTS, + 'allowed-account-ids': '999999999999', + }), + ); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + + await run(); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('does not match')); + }); + + it('reuses existing credentials when their account is allowed', async () => { + vi.mocked(core.getInput).mockImplementation( + mocks.getInput({ + ...mocks.USE_EXISTING_CREDENTIALS_INPUTS, + 'allowed-account-ids': '111111111111', + }), + ); + mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); + + await run(); + expect(core.notice).toHaveBeenCalledWith('Pre-existing credentials are valid. No need to generate new ones.'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); }); describe('Global Timeout Configuration', {}, () => { @@ -1240,6 +1267,21 @@ describe('Configure AWS Credentials', {}, () => { expect(core.setFailed).not.toHaveBeenCalled(); }); + + it('masks credentials embedded in the proxy URL', async () => { + vi.mocked(core.getInput).mockImplementation( + mocks.getInput({ + ...mocks.GH_OIDC_INPUTS, + 'http-proxy': 'http://user:secretpass@proxy.example.com:8080', + }), + ); + + await run(); + + expect(core.setSecret).toHaveBeenCalledWith('user'); + expect(core.setSecret).toHaveBeenCalledWith('secretpass'); + expect(core.setFailed).not.toHaveBeenCalled(); + }); }); describe('AWS Profile Support', {}, () => { diff --git a/test/profileManager.test.ts b/test/profileManager.test.ts index b545840..2e0cbec 100644 --- a/test/profileManager.test.ts +++ b/test/profileManager.test.ts @@ -114,6 +114,22 @@ describe('Profile Manager', {}, () => { const result = stringifyIni({ dev: {} }); expect(result).toBe('[dev]\n'); }); + + it('rejects values containing newlines', {}, () => { + expect(() => + stringifyIni({ dev: { aws_session_token: 'token\n[injected]\ncredential_process = evil' } }), + ).toThrow('must not contain newline characters'); + }); + + it('rejects keys containing newlines', {}, () => { + expect(() => stringifyIni({ dev: { 'key\ninjected': 'val' } })).toThrow('must not contain newline characters'); + }); + + it('rejects section names containing newlines', {}, () => { + expect(() => stringifyIni({ 'dev\r\n[injected]': { key: 'val' } })).toThrow( + 'must not contain newline characters', + ); + }); }); describe('validateProfileName', {}, () => { @@ -423,6 +439,24 @@ describe('Profile Manager', {}, () => { expect(configParsed['profile dev'].region).toBe('us-east-1'); }); + it('refuses to write credentials containing newlines instead of injecting profiles', {}, () => { + expect(() => + writeProfileFiles( + 'dev', + { + AccessKeyId: 'AKIAIOSFODNN7EXAMPLE', + SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + SessionToken: 'token\n[injected]\ncredential_process = evil-command', + }, + 'us-east-1', + false, + ), + ).toThrow('must not contain newline characters'); + + const credsPath = getProfileFilePaths().credentials; + expect(fs.existsSync(credsPath)).toBe(false); + }); + it('uses correct section naming for default profile', {}, () => { writeProfileFiles( 'default',