mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-02 05:55:10 +09:00
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.
This commit is contained in:
@@ -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.
|
||||
@@ -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'
|
||||
@@ -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 }}"
|
||||
@@ -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 |
|
||||
@@ -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<AWS::EC2::Image::Id>"
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-21
@@ -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<Credentials>): 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<T>(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.
|
||||
*
|
||||
|
||||
+13
-5
@@ -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;
|
||||
|
||||
@@ -53,8 +53,15 @@ export function parseIni(iniData: string): Record<string, Record<string, string>
|
||||
export function stringifyIni(data: Record<string, Record<string, string>>): 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'));
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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', {}, () => {
|
||||
|
||||
+47
-5
@@ -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', {}, () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user