From aa6526434b08748f8776b29964e3f1f5d90e7b63 Mon Sep 17 00:00:00 2001 From: Tom Keller <1083460+kellertk@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:31:24 -0700 Subject: [PATCH] fix: account-ids handling, mask proxy as secret in logs (#1943) * fix: enforce allowed-account-ids when the list contains empty entries An empty first element previously short-circuited the allowed account check. Empty entries are now filtered out and validation applies whenever any non-empty entry exists. * fix: enforce allowed-account-ids on the use-existing-credentials path The early return for valid pre-existing credentials skipped the allowed-account-ids check, now included. * fix: reject newlines in names and values when writing profile files If the profile file writing was enabled, we emitted newlines into the file verbatim, permitting injecting arbitrary profiles into the file. Writing now fails instead. * fix: honor configured STS endpoint for "ambient" credentials Ambient credential resolution built a bare STS client, so a web-identity token found by the SDK default chain (e.g. AWS_WEB_IDENTITY_TOKEN_FILE on a self-hosted runner) was exchanged with public STS instead of any operator-configured sts-endpoint. Resolution now passes the configured region, endpoint, and proxy handler to the default provider chain. * fix: mask proxy URL credentials in job logs Basic-auth userinfo in the http-proxy input or HTTP(S)_PROXY environment variables was never registered as a secret, so error messages carrying the proxy URL printed the credentials unmasked in the job log. * fix: omit account IDs from the allowed-account-ids failure message The mismatch error is thrown before exportAccountId registers the account-id mask, so setFailed wrote the raw account ID (and the configured allow-list) into a public annotation. (C4) * chore: remove outdated examples All of the examples were out of date and we do not have a mechanism for keeping them up to date. Removed the examples. --- examples/README.md | 14 -- .../.github/workflows/compliance.yml | 15 -- .../.github/workflows/deploy.yml | 38 ----- examples/cfn-deploy-example/README.md | 24 --- examples/cfn-deploy-example/ec2-bastion.yml | 150 ------------------ examples/federated-setup/README.md | 11 -- ...ithub-actions-oidc-federation-and-role.yml | 82 ---------- .../github-actions-oidc-federation.yml | 43 ----- package-lock.json | 1 + package.json | 1 + src/CredentialsClient.ts | 18 ++- src/helpers.ts | 49 +++--- src/index.ts | 18 ++- src/profileManager.ts | 7 + test/CredentialsClient.test.ts | 30 ++++ test/helpers.test.ts | 39 +++++ test/index.test.ts | 52 +++++- test/profileManager.test.ts | 34 ++++ 18 files changed, 213 insertions(+), 413 deletions(-) delete mode 100644 examples/README.md delete mode 100644 examples/cfn-deploy-example/.github/workflows/compliance.yml delete mode 100644 examples/cfn-deploy-example/.github/workflows/deploy.yml delete mode 100644 examples/cfn-deploy-example/README.md delete mode 100644 examples/cfn-deploy-example/ec2-bastion.yml delete mode 100644 examples/federated-setup/README.md delete mode 100644 examples/federated-setup/github-actions-oidc-federation-and-role.yml delete mode 100644 examples/federated-setup/github-actions-oidc-federation.yml create mode 100644 test/CredentialsClient.test.ts 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',