mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-08-24 04:25:05 +09:00
Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcd4089080 | |||
| 721b15f0ee | |||
| 0e771c8069 | |||
| 6e36582a90 | |||
| 7a26c6cbd2 | |||
| 83c855008c | |||
| e2c213d5f6 | |||
| fd3f78a69a | |||
| ab089d1087 | |||
| 0ea2ab0d82 | |||
| 12d377b18d | |||
| 3f6acccbef | |||
| 9f178b3b31 | |||
| 4c029bab3d | |||
| 15313a702c | |||
| c52a2eac0d | |||
| 87f905da14 | |||
| ba7d3485af | |||
| 0802eece3e | |||
| b04158f834 | |||
| bc56d889a4 | |||
| a5fc44c8bc | |||
| e6bb36664c | |||
| c39789ae94 | |||
| 533ad3f138 | |||
| 5a98413048 | |||
| 6d65716bae | |||
| 1d168a55fe | |||
| 848d062284 | |||
| d3194f48b9 | |||
| 61c52b4114 | |||
| 91d3095422 | |||
| 43a31ecb43 | |||
| 13db8f061e | |||
| 7b65d1d5f3 | |||
| 49a3467caa | |||
| f0a75f4173 | |||
| 7d3d30aa06 | |||
| 544de3bdd1 | |||
| 63142d578f | |||
| 33103b67c5 | |||
| ebff9ed752 | |||
| 247bed75ed | |||
| e6de054238 | |||
| ab3b2ba025 | |||
| fa8d6a57bb | |||
| 42e118a656 | |||
| d86ddfcecc | |||
| 874aaac21e | |||
| d4341b65ac | |||
| fe51823c97 | |||
| a8be382115 | |||
| e000376c2c | |||
| c2828f8b08 | |||
| 20e62f617c | |||
| 66f02a3e6c | |||
| 82a0ed2cab | |||
| 9d2a1c17c0 | |||
| e977f476bf | |||
| ab0d61f6a0 | |||
| ccf493e3b1 | |||
| 05ed620dc8 | |||
| 62f98f6930 | |||
| 5a780d06ff | |||
| d3cb30b08b | |||
| eb48debd38 | |||
| e69af16b6c | |||
| 7fb20f0656 | |||
| 4e7ae70130 | |||
| 9ae780b171 | |||
| 517a711dbc | |||
| d01d678e65 | |||
| 8efa52b284 | |||
| 8e1eed5c14 | |||
| 112421a93a | |||
| fbc01c6585 | |||
| b12ca875eb | |||
| d314f7f43d | |||
| a53b65b84a | |||
| 338d2c1839 | |||
| b94086889d | |||
| 9df7c63a2f | |||
| c35a5960bf | |||
| 187c14ee87 | |||
| c403f3cb69 | |||
| 08319dee24 | |||
| d2e2926774 | |||
| 90f23cf274 | |||
| 26d9fee367 | |||
| 6686d5a051 | |||
| 254c19bd24 | |||
| a20cf827fe | |||
| 4d281fbc56 | |||
| e004cdcd28 | |||
| 88aa3695d3 | |||
| 687331b272 | |||
| ea607be060 | |||
| 6d136066d8 | |||
| 71a32ae408 | |||
| b290f2ca79 | |||
| 0cd4b34a9b | |||
| e6e5af76de | |||
| 7089b3a41f | |||
| 1a6323c7ef | |||
| 8136aa1bcb | |||
| ffffc0fe9d | |||
| 93823cfbdc | |||
| 7397aaead8 | |||
| fc8f6a6abe | |||
| 3c7bb1cb09 | |||
| 024bb07a82 | |||
| 86c1acf81a | |||
| 50c2567845 | |||
| 270cb5b1d9 | |||
| 51e8115730 | |||
| 7f56bcdc8b | |||
| 357812ba85 | |||
| 6a57fcec9b | |||
| 11b91e35b6 | |||
| eeef317384 | |||
| 586cfded7b | |||
| ff89a791a6 | |||
| c2036624d0 | |||
| 68fa256f0b | |||
| 97363c92bb | |||
| 05c3e92d7e | |||
| 037dd16322 | |||
| 89a34d9b83 | |||
| d63f12fba5 | |||
| aefb6ea018 | |||
| bf27562715 | |||
| 4f3ef32554 | |||
| 26b365ff2f | |||
| 262ce4cfb5 | |||
| e7f100cf4c | |||
| bbbffeab00 | |||
| d6f5dc331b | |||
| 12014c0798 | |||
| 4ab3589ed2 | |||
| 99214aa688 | |||
| 217d17914b | |||
| 5548f3441b | |||
| 77cd089899 | |||
| dbacf3135e | |||
| 87eb0cf693 | |||
| acca2b1b20 | |||
| c329d242ce | |||
| c39f282697 | |||
| 8188bee95b | |||
| 477988d772 | |||
| 9a5ab5bbe8 | |||
| baa1fdfef9 | |||
| 4be0a3c167 | |||
| f85f964a2e | |||
| 6fddd0cf67 | |||
| 254d42c4b6 | |||
| 01e40c97bf | |||
| 48a00774d0 | |||
| 18e0298e7d | |||
| 36eb080f7f | |||
| 3d40fa4093 | |||
| 3cc0e19239 | |||
| e8614cfbf0 | |||
| 4684f47f89 | |||
| 48b8685c96 | |||
| fe6ad3af19 | |||
| 2520c5e921 | |||
| bc1093db1d | |||
| ffde832a1d | |||
| 707acd96f6 | |||
| a7c33ae483 | |||
| 713aaabfec | |||
| e6e8eba750 | |||
| 58e7c47adf | |||
| f35a7d7d7e | |||
| 3884f59ecd | |||
| e0ba768507 | |||
| 6795889618 | |||
| 29d1be3027 | |||
| ef734cca81 | |||
| 7521c55910 | |||
| c0e2737f14 | |||
| dbd503f368 | |||
| 18a236fbd1 | |||
| 1ab31502aa | |||
| 1fb495c4b2 | |||
| 1e8fec8ea1 | |||
| a388f23f7d | |||
| 3f7e1b63d7 | |||
| e35449909c | |||
| 958a80fc34 | |||
| 540d0c13ae | |||
| 07ada0fe07 | |||
| f8d4eb68a9 | |||
| 8d52d05d7a | |||
| 681892c11b | |||
| dc2353e57a | |||
| 61f50f630f | |||
| c36525a567 | |||
| 39d1702721 | |||
| 4cfda40a13 | |||
| 8856e12f3a | |||
| 64d8e82527 | |||
| 78f374f6d1 | |||
| d979d5b3a7 | |||
| d4a9acd8d3 | |||
| fc44f4ae40 | |||
| 0b8336f1ba | |||
| 8c5bf33eed | |||
| 53df0c1c32 | |||
| c2c55827d2 | |||
| bd0031d7c3 | |||
| 6ab499a698 | |||
| bc94895858 | |||
| 934ca25ed2 | |||
| 2141953f88 | |||
| bee39e8d77 | |||
| 21c0950dc0 | |||
| 94fd6c27a4 | |||
| 3c8d2af9dc | |||
| ee2d8e963b | |||
| 66e76254c4 | |||
| c36c0e55fd | |||
| 59c3fbfdaa | |||
| 5e19c1aa7a | |||
| aa48afa741 | |||
| 3e32d92984 | |||
| 795d57b736 | |||
| 8ff6de5203 | |||
| 3436a392ac | |||
| 3c894dabff | |||
| 787075d425 | |||
| 80269b713c | |||
| 1f08c8a67c | |||
| 930440c681 | |||
| d3bef17c22 | |||
| 61dcf734bf | |||
| ff6a0a0749 | |||
| 30a5c4561c | |||
| 000d6227cc | |||
| c315e354eb | |||
| 7945953394 | |||
| 870695bc36 | |||
| ec43ad8ccf |
@@ -0,0 +1,5 @@
|
||||
name: "CodeQL config"
|
||||
|
||||
paths-ignore:
|
||||
- dist
|
||||
- node_modules
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Stale issue job
|
||||
steps:
|
||||
- uses: aws-actions/stale-issue-cleanup@v6
|
||||
- uses: aws-actions/stale-issue-cleanup@v7
|
||||
with:
|
||||
# Setting messages to an empty string will cause the automation to skip
|
||||
# that category
|
||||
|
||||
@@ -36,10 +36,44 @@
|
||||
${{ secrets.OSDS_PACKAGING_ROLE }}
|
||||
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
release-type: node
|
||||
token: ${{ env.OSDS_ACCESS_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
|
||||
- name: Checkout Again
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Tag Major Version
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "github-aws-sdk-osds-automation@amazon.com"
|
||||
echo "::add-mask::${{ env.OSDS_ACCESS_TOKEN }}"
|
||||
git remote set-url origin https://${{ env.OSDS_ACCESS_TOKEN }}@github.com/aws-actions/configure-aws-credentials.git
|
||||
if git rev-parse "v${{ steps.release.outputs.major }}" >/dev/null 2>&1; then
|
||||
git tag -d "v${{ steps.release.outputs.major }}"
|
||||
git push origin ":v${{ steps.release.outputs.major }}"
|
||||
fi
|
||||
git tag -a "v${{ steps.release.outputs.major }}" -m "Release v${{ steps.release.outputs.major }}"
|
||||
git push origin "v${{ steps.release.outputs.major }}"
|
||||
|
||||
- name: Update README version references
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
sed -i 's|configure-aws-credentials@v[0-9]*\.[0-9]*\.[0-9]*|configure-aws-credentials@${{ steps.release.outputs.tag_name }}|g' README.md
|
||||
if git diff --quiet README.md; then
|
||||
echo "README already up to date"
|
||||
else
|
||||
echo "::add-mask::${{ env.OSDS_ACCESS_TOKEN }}"
|
||||
git remote set-url origin https://${{ env.OSDS_ACCESS_TOKEN }}@github.com/aws-actions/configure-aws-credentials.git
|
||||
git add README.md
|
||||
git commit -m "docs: update README version references to ${{ steps.release.outputs.tag_name }}"
|
||||
git push --force origin
|
||||
fi
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
- name: "Checkout repository"
|
||||
uses: actions/checkout@v5
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4.4.0
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
- name: "Run tests"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.history
|
||||
node_modules
|
||||
coverage
|
||||
.DS_Store
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
".release-please-manifest.json": "4.0.2",
|
||||
"package.json": "6.0.0",
|
||||
".": "6.1.0"
|
||||
".": "6.2.3"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,63 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
||||
|
||||
## [6.2.3](https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.2...v6.2.3) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* attach git credentials before Tag Major Version push ([#1877](https://github.com/aws-actions/configure-aws-credentials/issues/1877)) ([9ae780b](https://github.com/aws-actions/configure-aws-credentials/commit/9ae780b171afa8c5a3a6a2d154a765b709492482))
|
||||
* PackedPolicyTooLarge detection in STS tags ([#1899](https://github.com/aws-actions/configure-aws-credentials/issues/1899)) ([fa8d6a5](https://github.com/aws-actions/configure-aws-credentials/commit/fa8d6a57bbf44b34439fb080bbdadc7c92c285eb))
|
||||
|
||||
## [6.2.2](https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.1...v6.2.2) (2026-07-07)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* release 6.2.2 ([d01d678](https://github.com/aws-actions/configure-aws-credentials/commit/d01d678e65d6d2bd9d5ca7a95d6f07b00e25f2c2))
|
||||
|
||||
## [6.2.1](https://github.com/aws-actions/configure-aws-credentials/compare/v6.2.0...v6.2.1) (2026-06-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce allowed-account-ids on all auth paths ([#1847](https://github.com/aws-actions/configure-aws-credentials/issues/1847)) ([4d281fb](https://github.com/aws-actions/configure-aws-credentials/commit/4d281fbc56a82e63c3fc14f2cc22361f34c97493))
|
||||
|
||||
## [6.2.0](https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.3...v6.2.0) (2026-06-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add additional session tags by default ([#1775](https://github.com/aws-actions/configure-aws-credentials/issues/1775)) ([e0ba768](https://github.com/aws-actions/configure-aws-credentials/commit/e0ba7685077379a14a82d01fefd511490344ebfc))
|
||||
* add more retry logic and better logging ([#1764](https://github.com/aws-actions/configure-aws-credentials/issues/1764)) ([540d0c1](https://github.com/aws-actions/configure-aws-credentials/commit/540d0c13aedb8d55501d220bd2f0b3cdedfe84e8))
|
||||
* add regex validation to role-session-name ([#1765](https://github.com/aws-actions/configure-aws-credentials/issues/1765)) ([e354499](https://github.com/aws-actions/configure-aws-credentials/commit/e35449909c6ede5083a48ba4b8bbfaaa1cf09ba1))
|
||||
* Allow custom session tags to be passed when assuming a role ([#1759](https://github.com/aws-actions/configure-aws-credentials/issues/1759)) ([61f50f6](https://github.com/aws-actions/configure-aws-credentials/commit/61f50f630f383628add73c1eab3f1935ba07da2b))
|
||||
* expose run id in STS client user-agent ([#1774](https://github.com/aws-actions/configure-aws-credentials/issues/1774)) ([29d1be3](https://github.com/aws-actions/configure-aws-credentials/commit/29d1be30273e7ef371d59fccf6ec54572c64ec89))
|
||||
* support custom STS endpoints ([#1762](https://github.com/aws-actions/configure-aws-credentials/issues/1762)) ([8d52d05](https://github.com/aws-actions/configure-aws-credentials/commit/8d52d05d7a4521fa52b39de50cb6114b12e5c332))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* skip credential check on output-env-credentials: false ([#1778](https://github.com/aws-actions/configure-aws-credentials/issues/1778)) ([58e7c47](https://github.com/aws-actions/configure-aws-credentials/commit/58e7c47adf77846879008deadfeeef8a6969fe6c))
|
||||
* assumeRole failing from session tag size too large ([#1808](https://github.com/aws-actions/configure-aws-credentials/issues/1808)) ([d6f5dc3](https://github.com/aws-actions/configure-aws-credentials/commit/d6f5dc331b44474b19a52caaf85fa4d637b13c8e))
|
||||
|
||||
## [6.1.3](https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.2...v6.1.3) (2026-05-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix: allow kubelet token symlink in [#1805](https://github.com/aws-actions/configure-aws-credentials/issues/1805)
|
||||
|
||||
## [6.1.2](https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.1...v6.1.2) (2026-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* additional filesystem checks ([#1799](https://github.com/aws-actions/configure-aws-credentials/issues/1799)) ([c39f282](https://github.com/aws-actions/configure-aws-credentials/commit/c39f282697aca8a78c522ecf1f7da9899a31432c))
|
||||
|
||||
## [6.1.1](https://github.com/aws-actions/configure-aws-credentials/compare/v6.1.0...v6.1.1) (2026-05-05)
|
||||
|
||||
|
||||
### Miscellaneous Chores
|
||||
|
||||
* various dependency updates
|
||||
|
||||
## [6.1.0](https://github.com/aws-actions/configure-aws-credentials/compare/v6.0.0...v6.1.0) (2026-04-06)
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ these are incredibly useful:
|
||||
- Any modifications you've made relevant to the bug
|
||||
- Anything unusual about your environment or deployment
|
||||
|
||||
We also ask that you refrain from opening issues via the `gh` CLI or GitHub
|
||||
API. These methods bypass our issue templates and therefore don't apply the
|
||||
proper labels or workflows that we use. Note that AI agents typically do not
|
||||
properly use issue templates. Issues that don't have the proper labels
|
||||
applied may be closed without comment.
|
||||
|
||||
## Contributing via Pull Requests
|
||||
|
||||
Contributions via pull requests are much appreciated. Before sending us a pull
|
||||
|
||||
@@ -1,71 +1,81 @@
|
||||
# Configure AWS Credentials
|
||||
|
||||
Authenticate to AWS in GitHub Actions! Works especially well with
|
||||
Authenticate to AWS in GitHub Actions (and others)! Works especially well with
|
||||
[AWS Secrets Manager][secretsmanager].
|
||||
|
||||
[secretsmanager]:
|
||||
https://github.com/aws-actions/aws-secretsmanager-get-secrets
|
||||
[secretsmanager]: https://github.com/aws-actions/aws-secretsmanager-get-secrets
|
||||
|
||||
## Quick Start (OIDC, recommended)
|
||||
|
||||
1. Create an IAM Identity Provider in your AWS account for GitHub OIDC. (See
|
||||
[OIDC configuration](#oidc-configuration-details) below for details.)
|
||||
2. Create an IAM Role in your AWS account with a trust policy that allows
|
||||
GitHub Actions to assume it. (Expand the sections below) <details>
|
||||
<summary>GitHub OIDC Trust Policy</summary>
|
||||
2. Create an IAM Role in your AWS account with a trust policy that allows GitHub
|
||||
Actions to assume it. (Expand the sections below) <details>
|
||||
<summary>GitHub OIDC Trust Policy</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
|
||||
},
|
||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": "repo:<GITHUB_ORG>/<GITHUB_REPOSITORY>:ref:refs/heads/<GITHUB_BRANCH>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": {
|
||||
"Federated": "arn:aws:iam::<AWS_ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
|
||||
},
|
||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": "repo:<GITHUB_ORG>@<ORG_ID>/<GITHUB_REPOSITORY>@<REPO_ID>:ref:refs/heads/<GITHUB_BRANCH>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
</details>
|
||||
|
||||
Note: The value of the `sub` claim may be different depending on the workflow
|
||||
and the environment in which it's running. Workflows in repositories created
|
||||
prior to [15 July 2026][immutable-sub] will omit the `@<ORG_ID>` and
|
||||
`@<REPO_ID>` suffixes unless opted in. Workflows running in GitHub
|
||||
environments will include an`environment:<ENVIRONMENT_NAME>` stanza. See
|
||||
[Claims and scoping permissions](#claims-and-scoping-permissions) for more
|
||||
information.
|
||||
|
||||
[immutable-sub]:
|
||||
https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/
|
||||
|
||||
3. Attach permissions to the IAM Role that allow it to access the AWS resources
|
||||
you need.
|
||||
you need.
|
||||
4. Add the following to your GitHub Actions workflow: <details>
|
||||
<summary>Example Workflow</summary>
|
||||
<summary>Example Workflow</summary>
|
||||
|
||||
```yaml
|
||||
# Need ID token write permission to use OIDC
|
||||
permissions:
|
||||
id-token: write
|
||||
jobs:
|
||||
run_job_with_aws:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
with:
|
||||
role-to-assume: <Role ARN you created in step 2>
|
||||
aws-region: <AWS Region you want to use>
|
||||
- name: Additional steps
|
||||
run: |
|
||||
# Your commands that require AWS credentials
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
```yaml
|
||||
# Need ID token write permission to use OIDC
|
||||
permissions:
|
||||
id-token: write
|
||||
jobs:
|
||||
run_job_with_aws:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
role-to-assume: <Role ARN you created in step 2>
|
||||
aws-region: <AWS Region you want to use>
|
||||
- name: Additional steps
|
||||
run: |
|
||||
# Your commands that require AWS credentials
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
|
||||
</details>
|
||||
</details>
|
||||
|
||||
That's it! Your GitHub Actions workflow can now access AWS resources using
|
||||
the IAM Role you created. Other authentication scenarios are also supported
|
||||
(see below).
|
||||
That's it! Your GitHub Actions workflow can now access AWS resources using the
|
||||
IAM Role you created. Other authentication scenarios are also supported (see
|
||||
below).
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
@@ -81,8 +91,8 @@ Authenticate to AWS in GitHub Actions! Works especially well with
|
||||
of the credentials used in workflows.
|
||||
- Periodically rotate any long-lived credentials that you use.
|
||||
- Store sensitive information in a secure way, such as using
|
||||
[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or
|
||||
[GitHub Secrets][gh-secrets].
|
||||
[AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) or [GitHub
|
||||
Secrets][gh-secrets].
|
||||
- Be especially careful about running Actions in non-ephemeral environments, or
|
||||
[triggering workflows on `pull_request_target`](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target)
|
||||
events.
|
||||
@@ -105,11 +115,12 @@ by specifying different inputs.
|
||||
5. Use credentials stored in the Action environment to fetch temporary
|
||||
credentials via STS AssumeRole.
|
||||
|
||||
Because we use the AWS JavaScript SDK, we always will use the
|
||||
[credential resolution flow for Node.js][cred-resolution].
|
||||
Because we use the AWS JavaScript SDK, we always will use the [credential
|
||||
resolution flow for Node.js][cred-resolution].
|
||||
|
||||
[cred-resolution]:
|
||||
https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html
|
||||
|
||||
Depending on your inputs, the action might override parts of this flow.
|
||||
|
||||
<details>
|
||||
@@ -131,8 +142,8 @@ enabling this option._
|
||||
|
||||
Additionally, **`aws-region`** is always required.
|
||||
|
||||
_Note: If you use GitHub Enterprise Server, you may need to adjust examples
|
||||
here to match your environment._
|
||||
_Note: If you use GitHub Enterprise Server, you may need to adjust examples here
|
||||
to match your environment._
|
||||
|
||||
## Additional Options
|
||||
|
||||
@@ -144,36 +155,39 @@ detail.
|
||||
<details>
|
||||
<summary>Options list and descriptions</summary>
|
||||
|
||||
| Option | Description | Required |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| aws-region | Which AWS region to use | Yes |
|
||||
| aws-profile | Name of the AWS profile to configure. When provided, credentials are written to `~/.aws/credentials` and `~/.aws/config` files. This enables configuring multiple profiles in a single workflow. Name cannot contain whitespace, square brackets, or slashes. When set, credentials will not be exported as environment variables unless `output-env-credentials` is manually set to true. | No |
|
||||
| overwrite-aws-profile | Overwrite the given AWS profile if it already exists. When set to false or not set, an error will be thrown if the profile already exists. | No |
|
||||
| role-to-assume | Role for which to fetch credentials. Only required for some authentication types. | No |
|
||||
| aws-access-key-id | AWS access key to use. Only required for some authentication types. | No |
|
||||
| aws-secret-access-key | AWS secret key to use. Only required for some authentication types. | No |
|
||||
| aws-session-token | AWS session token to use. Used in uncommon authentication scenarios. | No |
|
||||
| role-chaining | Use existing credentials from the environment to assume a new role. | No |
|
||||
| audience | The JWT audience when using OIDC. Used in non-default AWS partitions, like China regions. | No |
|
||||
| http-proxy | An HTTP proxy to use for API calls. | No |
|
||||
| mask-aws-account-id | AWS account IDs are not considered secret. Setting this will hide account IDs from output anyway. | No |
|
||||
| role-duration-seconds | The assumed role duration in seconds, if assuming a role. Defaults to 1 hour (3600 seconds). Acceptable values range from 15 minutes (900 seconds) to 12 hours (43200 seconds). | No |
|
||||
| role-external-id | The external ID of the role to assume. Only needed if your role requires it. | No |
|
||||
| role-session-name | Defaults to "GitHubActions", but may be changed if required. | No |
|
||||
| role-skip-session-tagging | Skips session tagging if set. | No |
|
||||
| transitive-tag-keys | Define a list of transitive tag keys to pass when assuming a role. | No |
|
||||
| inline-session-policy | You may further restrict the assumed role policy by defining an inline policy here. | No |
|
||||
| managed-session-policies | You may further restrict the assumed role policy by specifying a managed policy here. | No |
|
||||
| output-credentials | When set, outputs fetched credentials as action step output. (Outputs aws-access-key-id, aws-secret-access-key, aws-session-token, aws-account-id, authenticated-arn, and aws-expiration). Defaults to false. | No |
|
||||
| output-env-credentials | When set, outputs fetched credentials as environment variables (AWS_REGION, AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, and AWS_PROFILE (if profile option is used)). Defaults to true when `aws-profile` is not set, and false when `aws-profile` is set. Set to false to avoid setting env variables. (NOTE: Setting to false will prevent aws-account-id from being exported as a step output). | No |
|
||||
| unset-current-credentials | When set, attempts to unset any existing credentials in your action runner. | No |
|
||||
| disable-retry | Disabled retry/backoff logic for assume role calls. By default, retries are enabled. | No |
|
||||
| retry-max-attempts | Limits the number of retry attempts before giving up. Defaults to 12. | No |
|
||||
| special-characters-workaround | Uncommonly, some environments cannot tolerate special characters in a secret key. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. | No |
|
||||
| use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No |
|
||||
| allowed-account-ids | A comma-delimited list of expected AWS account IDs. The action will fail if we receive credentials for the wrong account. | No |
|
||||
| force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No |
|
||||
| action-timeout-s | Global timeout for the action in seconds. If set to a value greater than 0, the action will fail if it takes longer than this time to complete. | No |
|
||||
| Option | Description | Required |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| aws-region | Which AWS region to use | Yes |
|
||||
| aws-profile | Name of the AWS profile to configure. When provided, credentials are written to `~/.aws/credentials` and `~/.aws/config` files. This enables configuring multiple profiles in a single workflow. Name cannot contain whitespace, square brackets, or slashes. When set, credentials will not be exported as environment variables unless `output-env-credentials` is manually set to true. | No |
|
||||
| overwrite-aws-profile | Overwrite the given AWS profile if it already exists. When set to false or not set, an error will be thrown if the profile already exists. | No |
|
||||
| role-to-assume | Role for which to fetch credentials. Only required for some authentication types. | No |
|
||||
| aws-access-key-id | AWS access key to use. Only required for some authentication types. | No |
|
||||
| aws-secret-access-key | AWS secret key to use. Only required for some authentication types. | No |
|
||||
| aws-session-token | AWS session token to use. Used in uncommon authentication scenarios. | No |
|
||||
| role-chaining | Use existing credentials from the environment to assume a new role. | No |
|
||||
| audience | The JWT audience when using OIDC. Used in non-default AWS partitions, like China regions. | No |
|
||||
| http-proxy | An HTTP proxy to use for API calls. | No |
|
||||
| mask-aws-account-id | AWS account IDs are not considered secret. Setting this will hide account IDs from output anyway. | No |
|
||||
| role-duration-seconds | The assumed role duration in seconds, if assuming a role. Defaults to 1 hour (3600 seconds). Acceptable values range from 15 minutes (900 seconds) to 12 hours (43200 seconds). | No |
|
||||
| role-external-id | The external ID of the role to assume. Only needed if your role requires it. | No |
|
||||
| role-session-name | Defaults to "GitHubActions", but may be changed if required. | No |
|
||||
| role-skip-session-tagging | Skips session tagging if set. | No |
|
||||
| transitive-tag-keys | Define a list of transitive tag keys to pass when assuming a role. | No |
|
||||
| custom-tags | Additional tags to apply to the assumed role session. Must be a JSON object provided as a string. Custom tags are not usable with OIDC or web identity token authentication. | No |
|
||||
| inline-session-policy | You may further restrict the assumed role policy by defining an inline policy here. | No |
|
||||
| managed-session-policies | You may further restrict the assumed role policy by specifying a managed policy here. | No |
|
||||
| output-credentials | When set, outputs fetched credentials as action step output. (Outputs aws-access-key-id, aws-secret-access-key, aws-session-token, aws-account-id, authenticated-arn, and aws-expiration). Defaults to false. | No |
|
||||
| output-env-credentials | When set, outputs fetched credentials as environment variables (AWS_REGION, AWS_DEFAULT_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, and AWS_PROFILE (if profile option is used)). Defaults to true when `aws-profile` is not set, and false when `aws-profile` is set. Set to false to avoid setting env variables. (NOTE: Setting to false will prevent aws-account-id from being exported as a step output). | No |
|
||||
| unset-current-credentials | When set, attempts to unset any existing credentials in your action runner. | No |
|
||||
| disable-retry | Disabled retry/backoff logic for assume role calls. By default, retries are enabled. | No |
|
||||
| retry-max-attempts | Limits the number of retry attempts before giving up. Defaults to 12. | No |
|
||||
| special-characters-workaround | Uncommonly, some environments cannot tolerate special characters in a secret key. This option will retry fetching credentials until the secret access key does not contain special characters. This option overrides disable-retry and retry-max-attempts. | No |
|
||||
| use-existing-credentials | When set, the action will check if existing credentials are valid and exit if they are. Defaults to false. | No |
|
||||
| allowed-account-ids | A comma-delimited list of expected AWS account IDs. The action will fail if we receive credentials for the wrong account. | No |
|
||||
| force-skip-oidc | When set, the action will skip using GitHub OIDC provider even if the id-token permission is set. | No |
|
||||
| action-timeout-s | Global timeout for the action in seconds. If set to a value greater than 0, the action will fail if it takes longer than this time to complete. | No |
|
||||
| no-proxy | Hosts to skip for the proxy configuration. | No |
|
||||
| sts-endpoint | Custom STS endpoint URL. Use this to point to an STS-compatible API (e.g. MinIO, LocalStack) instead of the default AWS STS endpoint for the region. | No |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -210,8 +224,8 @@ Profile names may not contain whitespace, square brackets, or forward or
|
||||
backslashes.
|
||||
|
||||
Writing to a profile will prevent credentials being written to the environment
|
||||
by default. Use `output-env-credentials: true` if you would like the
|
||||
credentials to also be exported as environment variables.
|
||||
by default. Use `output-env-credentials: true` if you would like the credentials
|
||||
to also be exported as environment variables.
|
||||
|
||||
By default, the action will not overwrite existing profiles. If you would like
|
||||
to overwrite a profile, set the `overwrite-aws-profile` input to `true`.
|
||||
@@ -226,8 +240,8 @@ extreme care to ensure that this is safe in your environment and you do not leak
|
||||
valid credentials unintentionally. Writing to configuration files is intended
|
||||
for unusual authentication scenarios._
|
||||
|
||||
For using profiles with static IAM User Credentials or when using one
|
||||
role to assume another, role chaining is needed:
|
||||
For using profiles with static IAM User Credentials or when using one role to
|
||||
assume another, role chaining is needed:
|
||||
|
||||
<details>
|
||||
|
||||
@@ -236,8 +250,8 @@ specify the profile name as an environment variable in the job step:
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
with:
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: arn:aws:iam::123456789100:role/my-role
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -248,20 +262,20 @@ specify the profile name as an environment variable in the job step:
|
||||
AWS_PROFILE: MyProfile1
|
||||
```
|
||||
|
||||
If you are using one role to assume another while using profiles, the
|
||||
subsequent steps must set `role-chaining: true` and specify the prior profile's
|
||||
name as step environment variables:
|
||||
If you are using one role to assume another while using profiles, the subsequent
|
||||
steps must set `role-chaining: true` and specify the prior profile's name as
|
||||
step environment variables:
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: arn:aws:iam::123456789100:role/my-first-role
|
||||
aws-profile: firstRoleInChain
|
||||
|
||||
- name: assume second role
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: arn:aws:iam::987654321000:role/my-second-role
|
||||
@@ -282,8 +296,8 @@ from the environment. To skip this step, set the `AWS_SKIP_CLEANUP_STEP`
|
||||
environment variable to `true`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
AWS_SKIP_CLEANUP_STEP: 'true'
|
||||
env:
|
||||
AWS_SKIP_CLEANUP_STEP: "true"
|
||||
```
|
||||
|
||||
#### Use an HTTP proxy
|
||||
@@ -297,7 +311,7 @@ this action will always consider the `HTTP_PROXY` environment variable.
|
||||
Manually configured proxy:
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: my-github-actions-role
|
||||
@@ -316,11 +330,12 @@ HTTP_PROXY="http://companydomain.com:3128"
|
||||
#### Special characters in AWS_SECRET_ACCESS_KEY
|
||||
|
||||
Some edge cases are unable to properly parse an `AWS_SECRET_ACCESS_KEY` if it
|
||||
contains special characters. For more information, please see the
|
||||
[AWS CLI documentation][aws-cli-troubleshooting].
|
||||
contains special characters. For more information, please see the [AWS CLI
|
||||
documentation][aws-cli-troubleshooting].
|
||||
|
||||
[aws-cli-troubleshooting]:
|
||||
https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-troubleshooting.html#tshoot-signature-does-not-match
|
||||
|
||||
If you set the `special-characters-workaround` option, this action will
|
||||
continually retry fetching credentials until we get one that does not have
|
||||
special characters. This option overrides the `disable-retry` and
|
||||
@@ -337,13 +352,14 @@ _Note: you might find it helpful to set the `role-session-name` to
|
||||
`${{ github.run_id }}` so as to clarify in audit logs which AWS actions were
|
||||
performed by which workflow run._
|
||||
|
||||
The session will be tagged with the following tags: (Refer to
|
||||
[GitHub's documentation for `GITHUB_` environment variable
|
||||
definitions][gh-env-vars])
|
||||
The session will be tagged with the following tags: (Refer to [GitHub's
|
||||
documentation for `GITHUB_` environment variable definitions][gh-env-vars])
|
||||
|
||||
[gh-env-vars]:
|
||||
https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables
|
||||
|
||||
**Default tags** are always emitted when session tags are used.
|
||||
|
||||
| Key | Value |
|
||||
| ---------- | ----------------- |
|
||||
| GitHub | "Actions" |
|
||||
@@ -351,23 +367,48 @@ definitions][gh-env-vars])
|
||||
| Workflow | GITHUB_WORKFLOW |
|
||||
| Action | GITHUB_ACTION |
|
||||
| Actor | GITHUB_ACTOR |
|
||||
| Branch | GITHUB_REF |
|
||||
| Commit | GITHUB_SHA |
|
||||
| Branch | GITHUB_REF |
|
||||
|
||||
**Droppable tags** are automatically added to the set of default session tags.
|
||||
If the session tags exceed the [packed size limit][packed-size-limit], these
|
||||
tags will be dropped, and the AssumeRole call will be retried. If it still
|
||||
fails, the action will error out. (It is difficult to predict the packed size
|
||||
before making the call, as session tags and session policies are compressed into
|
||||
a binary format as part of the call.)
|
||||
|
||||
[packed-size-limit]:
|
||||
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_know
|
||||
|
||||
| Key | Value |
|
||||
| --------------- | ----------------------- |
|
||||
| EventName | GITHUB_EVENT_NAME |
|
||||
| BaseRef | GITHUB_BASE_REF |
|
||||
| HeadRef | GITHUB_HEAD_REF |
|
||||
| RunId | GITHUB_RUN_ID |
|
||||
| Job | GITHUB_JOB |
|
||||
| TriggeringActor | GITHUB_TRIGGERING_ACTOR |
|
||||
|
||||
Tags whose source environment variable is unset are omitted (e.g., `BaseRef` and
|
||||
`HeadRef` are only set on `pull_request` events).
|
||||
|
||||
_Note: all tag values must conform to
|
||||
[the tag requirements](https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html).
|
||||
Particularly, `GITHUB_WORKFLOW` will be truncated if it's too long. If
|
||||
`GITHUB_ACTOR` or `GITHUB_WORKFLOW` contain invalid characters, the characters
|
||||
will be replaced with an '\*'._
|
||||
[the tag requirements][sts-tag-requirements].
|
||||
Values longer than 256 characters will be truncated, and characters outside the
|
||||
allowed set will be replaced with an underscore (`_`)._
|
||||
|
||||
The action will use session tagging by default unless you are using OIDC.
|
||||
[sts-tag-requirements]:
|
||||
https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html
|
||||
|
||||
The action will use session tagging by default unless you are using OIDC or a
|
||||
Web Identify Token File.
|
||||
|
||||
To [forward session tags to subsequent sessions in a role
|
||||
chain][session-tag-chaining], you can use
|
||||
chain][session-tag-chaining], you can use the `transitive-tag-keys` input to
|
||||
specify the keys of the tags to be passed.
|
||||
|
||||
[session-tag-chaining]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_role-chaining
|
||||
|
||||
the `transitive-tag-keys` input to specify the keys of the tags to be passed.
|
||||
[session-tag-chaining]:
|
||||
https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html#id_session-tags_role-chaining
|
||||
|
||||
_Note that all subsequent roles in the chain must have
|
||||
`role-skip-session-tagging` set to `true`_
|
||||
@@ -382,6 +423,24 @@ with:
|
||||
Actor
|
||||
```
|
||||
|
||||
### Custom session tags
|
||||
|
||||
You can add custom session tags using the `custom-tags` input, which accepts a
|
||||
JSON object. Custom tags cannot override existing tags. Note that AWS allows a
|
||||
maximum of 50 tags (so you can supply a maximum of 43 custom tags), although it
|
||||
is likely that you will exceed the [packed size limit][packed-size-limit]
|
||||
before you exceed the maximum number of tags.
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
custom-tags: '{"Environment": "Production", "Team": "Platform"}'
|
||||
```
|
||||
|
||||
_Note: custom tags are not supported when using OIDC or web identity token
|
||||
authentication. In those flows, session tags are controlled by the identity
|
||||
provider's token claims._
|
||||
|
||||
### Session policies
|
||||
|
||||
Session policies are not required, but they allow you to limit the scope of the
|
||||
@@ -399,7 +458,7 @@ line.
|
||||
<summary>Inline session policy examples</summary>
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
inline-session-policy: '{"Version":"2012-10-17","Statement":[{"Sid":"Stmt1","Effect":"Allow","Action":"s3:List*","Resource":"*"}]}'
|
||||
```
|
||||
@@ -407,7 +466,7 @@ with:
|
||||
Or we can have a nicely formatted JSON as well:
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
inline-session-policy: >-
|
||||
{
|
||||
@@ -435,7 +494,7 @@ the role.
|
||||
<summary>Managed session policy examples</summary>
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
managed-session-policies: arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
|
||||
```
|
||||
@@ -443,7 +502,7 @@ with:
|
||||
And we can pass multiple managed policies likes this:
|
||||
|
||||
```yaml
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
managed-session-policies: |
|
||||
arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
|
||||
@@ -452,6 +511,12 @@ with:
|
||||
|
||||
</details>
|
||||
|
||||
### Custom STS endpoint
|
||||
|
||||
Use the `sts-endpoint` input to override the AWS STS endpoint URL. Most users
|
||||
should not set this option and instead let the SDK derive the correct endpoint
|
||||
from the specified region.
|
||||
|
||||
## OIDC Configuration Details
|
||||
|
||||
We recommend using
|
||||
@@ -483,7 +548,7 @@ specify the audience through the `audience` input:
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials for China region audience
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
audience: sts.amazonaws.com.cn
|
||||
aws-region: cn-northwest-1
|
||||
@@ -516,41 +581,65 @@ aws iam create-open-id-connect-provider \
|
||||
|
||||
### Claims and scoping permissions
|
||||
|
||||
To align with the Amazon IAM best practice of
|
||||
[granting least privilege][least-privilege],
|
||||
To align with the Amazon IAM best practice of [granting least
|
||||
privilege][least-privilege], the assume role policy document should contain a
|
||||
[`Condition`](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html)
|
||||
that restricts which workflows can assume the role. Without any condition, any
|
||||
GitHub user or repository could potentially assume the role.
|
||||
|
||||
GitHub provides a number of additional claims in the OIDC token that you can use
|
||||
in your IAM policies to scope down permissions. Early versions of this action
|
||||
only supported the `sub` and `aud` claims, but AWS IAM and GitHub have since
|
||||
added support for `sub` claim customization and a variety of additional
|
||||
claims ([1][gh-blog-oidc], [2][sub-claim-custom]).
|
||||
|
||||
> **Warning:** Avoid `ForAllValues:` in `Allow` statements. These operators
|
||||
> return true when the claim is absent or misspelled, which can lead to
|
||||
> unintended access. Instead, use `StringEquals` or `StringLike` operators to
|
||||
> check for specific claim values.
|
||||
|
||||
#### Immutable subject claims
|
||||
|
||||
Repositories created on github.com on or after 15 July 2026, and older
|
||||
repositories that have opted in, emit an [immutable `sub` claim][immutable-sub].
|
||||
This claim appends the permanent numeric ID of the organization and of the
|
||||
repository after each name, separated by `@`, so that a recycled org or
|
||||
repository name cannot be used to mint tokens matching a stale trust policy.
|
||||
For example:
|
||||
|
||||
```text
|
||||
# Legacy (mutable) sub claim
|
||||
repo:octo-org/octo-repo:ref:refs/heads/main
|
||||
|
||||
# Immutable sub claim
|
||||
repo:octo-org@123456/octo-repo@789012:ref:refs/heads/main
|
||||
```
|
||||
|
||||
If your trust policy matches the legacy name-only form and your repository emits
|
||||
the immutable claim, `AssumeRoleWithWebIdentity` fails with `Not authorized to
|
||||
perform sts:AssumeRoleWithWebIdentity`. To fix this, update the `sub` condition
|
||||
to the immutable form. You can find your repository's prefix in the Settings,
|
||||
or by following the token inspection steps below.
|
||||
|
||||
[least-privilege]:
|
||||
https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege
|
||||
the assume role policy document should contain a
|
||||
[`Condition`](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html)
|
||||
that specifies a subject (`sub`) allowed to assume the role.
|
||||
[GitHub also recommends](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#defining-trust-conditions-on-cloud-roles-using-oidc-claims)
|
||||
filtering for the correct audience (`aud`). See
|
||||
[AWS IAM documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-wif)
|
||||
on which claims you can filter for in your trust policies.
|
||||
[gh-blog-oidc]:
|
||||
https://aws.amazon.com/about-aws/whats-new/2026/01/aws-sts-supports-validation-identity-provider-claims/
|
||||
[sub-claim-custom]:
|
||||
https://docs.github.com/en/rest/actions/oidc?apiVersion=2026-03-10
|
||||
|
||||
Without a subject (`sub`) condition, any GitHub user or repository could
|
||||
potentially assume the role. The subject can be scoped to a GitHub organization
|
||||
and repository as shown in the CloudFormation template. However, scoping it down
|
||||
to your org and repo may cause the role assumption to fail in some cases. See
|
||||
[Example subject claims](https://docs.github.com/en/actions/reference/security/oidc#example-subject-claims)
|
||||
for specific details on what the subject value will be depending on your
|
||||
workflow. You can also
|
||||
[customize your subject claim](https://docs.github.com/en/actions/reference/security/oidc#customizing-the-token-claims)
|
||||
if you want full control over the information you can filter for in your trust
|
||||
policy. If you aren't sure what your subject (`sub`) key is, you can add the
|
||||
#### Inspecting the token
|
||||
|
||||
If you aren't sure what claim values your workflow is producing, the
|
||||
[`actions-oidc-debugger`](https://github.com/github/actions-oidc-debugger)
|
||||
action to your workflow to see the value of the subject (`sub`) key, as well as
|
||||
other claims.
|
||||
action will print the decoded JWT payload. Run it in a private repository
|
||||
only; the token itself is short-lived but the claim values may be sensitive.
|
||||
|
||||
Additional claim conditions can be added for higher specificity as explained in
|
||||
the
|
||||
[GitHub documentation][gh-oidc-hardening].
|
||||
See the GitHub [security-hardening guide][gh-oidc-hardening] for further
|
||||
discussion of trust conditions and threat modeling.
|
||||
|
||||
[gh-oidc-hardening]:
|
||||
https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
|
||||
Due to implementation details, not every OIDC claim is presently supported by
|
||||
IAM.
|
||||
|
||||
### Further information about OIDC
|
||||
|
||||
@@ -562,13 +651,64 @@ For further information on OIDC and GitHub Actions, please see:
|
||||
- [GitHub docs: Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)
|
||||
- [GitHub changelog: GitHub Actions: Secure cloud deployments with OpenID Connect](https://github.blog/changelog/2021-10-27-github-actions-secure-cloud-deployments-with-openid-connect/)
|
||||
|
||||
## Getting Credentials in AWS Self-Hosted Runners
|
||||
|
||||
If you are running GitHub Actions in a self-hosted runner using an AWS Service
|
||||
(such as Codebuild or EKS) and you have properly configured the service,
|
||||
credentials should be available by default; the AWS CLI will fetch credentials
|
||||
using the AWS_CONTAINER_CREDENTIALS_FULL_URI or
|
||||
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variables. However, you may
|
||||
still want to use this action if you need to export those credentials for use
|
||||
with other tools in your workflow. You may also want to use this action in
|
||||
scenarios where you need to use that 'default' role to assume another role.
|
||||
|
||||
To export credentials, simply run the action with `role-to-assume` set to the
|
||||
default role of the container.
|
||||
|
||||
To assume another role from the container's default role, use the
|
||||
`role-chaining: true` flag, so that the action fetches the default credentials
|
||||
from the environment before assuming the other role.
|
||||
|
||||
If you are using EKS Pod Identities and encountering an error related to the
|
||||
packed size of session tags, you must either run the action with
|
||||
`role-skip-session-tagging: true` to disable the tags set by the action, or
|
||||
[disable EKS session tagging][eks-disable-session-tagging] in the EKS settings
|
||||
to disable the tags that are automatically set by the EKS Pod Identity Service.
|
||||
Check the values of the action's session tags and the session tags that are
|
||||
added by EKS so you can keep the set of tags which is more useful to you.
|
||||
|
||||
[eks-disable-session-tagging]:
|
||||
https://docs.aws.amazon.com/eks/latest/userguide/pod-id-abac.html#pod-id-abac-tags
|
||||
|
||||
## Compatibility with non-GitHub Actions environments
|
||||
|
||||
This action has been sucessfully tested with
|
||||
Codeberg/[Forgejo Actions](https://forgejo.org/docs/next/user/actions/overview/)
|
||||
and should be generally compatible with any CI/CD environment that sets the
|
||||
correct `GITHUB_` environment variables. For use with Foregejo, please review
|
||||
the
|
||||
[runner differences with GitHub's action runners][forgejo-gh-differences].
|
||||
|
||||
[forgejo-gh-differences]:
|
||||
https://forgejo.org/docs/next/user/actions/github-actions/#known-list-of-differences
|
||||
The main difference to be aware of is that Forgejo uses the
|
||||
`enable-openid-connect` flag to enable OIDC instad of GitHub's `id-token: write`
|
||||
permission. Forgejo also uses a slightly different syntax for the workflow
|
||||
definition file, omitting some subkeys.
|
||||
|
||||
For OIDC use, the issuer name for the IAM IdP for GitHub Actions is
|
||||
`token.actions.githubusercontent.com`. For Forgejo Actions it is
|
||||
`[foregejo instance url]/api/actions`. As an example, Codeberg would use
|
||||
`codeberg.org/api/actions` as the issuer URL when configuring the IAM Identity
|
||||
Provider. The audience would still be `sts.amazonaws.com` by default.
|
||||
|
||||
## Examples
|
||||
|
||||
### AssumeRoleWithWebIdentity
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: arn:aws:iam::123456789100:role/my-github-actions-role
|
||||
@@ -584,13 +724,13 @@ environment variable and use it to assume the role
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: arn:aws:iam::123456789100:role/my-github-actions-role
|
||||
role-session-name: MySessionName
|
||||
- name: Configure other AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: arn:aws:iam::987654321000:role/my-second-role
|
||||
@@ -612,7 +752,7 @@ alternatively, the `TagSession` permission can be omitted if you are using the
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -633,7 +773,7 @@ like `role-to-assume: my-github-actions-role`.
|
||||
```yaml
|
||||
- name: Configure AWS Credentials 1
|
||||
id: creds
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
role-to-assume: arn:aws:iam::123456789100:role/my-github-actions-role
|
||||
@@ -642,7 +782,7 @@ like `role-to-assume: my-github-actions-role`.
|
||||
run: |
|
||||
aws sts get-caller-identity
|
||||
- name: Configure AWS Credentials 2
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-2
|
||||
aws-access-key-id: ${{ steps.creds.outputs.aws-access-key-id }}
|
||||
@@ -659,18 +799,28 @@ This example shows that you can reference the fetched credentials as outputs if
|
||||
the `aws-session-token` input in a situation where session tokens are fetched
|
||||
and passed to this action.
|
||||
|
||||
If you only want the credentials available as _step outputs_ and not exported to
|
||||
the environment (for example, on a self-hosted runner where you do not want the
|
||||
assumed-role credentials to shadow an existing EC2 instance profile), pair
|
||||
`output-credentials: true` with `output-env-credentials: false`. In that mode,
|
||||
the action does not run its post-credential SDK-pickup validation step, since
|
||||
the credentials were never written to the environment. The action still
|
||||
validates the resolved credentials by calling `sts:GetCallerIdentity` with the
|
||||
explicit credentials, so the `allowed-account-ids` check can be enforced if
|
||||
provided.
|
||||
|
||||
### Configure multiple AWS profiles in a single workflow
|
||||
|
||||
```yaml
|
||||
- name: Configure AWS Credentials for Dev
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: arn:aws:iam::111111111111:role/dev-role
|
||||
aws-profile: dev
|
||||
|
||||
- name: Configure AWS Credentials for Prod
|
||||
uses: aws-actions/configure-aws-credentials@v6.1.0
|
||||
uses: aws-actions/configure-aws-credentials@v6.2.3
|
||||
with:
|
||||
aws-region: us-west-2
|
||||
role-to-assume: arn:aws:iam::222222222222:role/prod-role
|
||||
@@ -691,8 +841,8 @@ and passed to this action.
|
||||
This example shows how to configure multiple named AWS profiles in a single
|
||||
workflow. When using the `aws-profile` input, credentials are written to
|
||||
`~/.aws/credentials` and `~/.aws/config` files, allowing you to reference
|
||||
different profiles using the `--profile` flag with AWS CLI, SDKs, CDK, and
|
||||
other tools.
|
||||
different profiles using the `--profile` flag with AWS CLI, SDKs, CDK, and other
|
||||
tools.
|
||||
|
||||
Each profile is independent and can authenticate to different AWS accounts or
|
||||
use different roles. This is particularly useful for multi-account deployments
|
||||
@@ -705,6 +855,7 @@ Starting with version 5.0.0, this action uses semantic-style release tags and
|
||||
|
||||
[immutable-releases]:
|
||||
https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases
|
||||
|
||||
A floating version tag (vN) is also provided for convenience: this tag will move
|
||||
to the latest major version (vN -> vN.2.1, vM -> vM.0.0, etc.).
|
||||
|
||||
|
||||
+42
-1225
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -1,4 +1,3 @@
|
||||
|
||||
name: '"Configure AWS Credentials" Action for GitHub Actions'
|
||||
description: Configures AWS credentials for use in subsequent steps in a GitHub Action workflow
|
||||
runs:
|
||||
@@ -101,6 +100,12 @@ inputs:
|
||||
action-timeout-s:
|
||||
required: false
|
||||
description: A global timeout in seconds for the action. When the timeout is reached, the action immediately exits. The default is to run without a timeout.
|
||||
custom-tags:
|
||||
description: Additional tags to apply to the assumed role session. Must be a JSON object provided as a string.
|
||||
required: false
|
||||
sts-endpoint:
|
||||
description: Custom STS endpoint URL. Use this to point to an STS-compatible API (e.g. MinIO, LocalStack) instead of the default AWS STS endpoint for the region.
|
||||
required: false
|
||||
|
||||
outputs:
|
||||
aws-account-id:
|
||||
|
||||
+955
-3220
File diff suppressed because it is too large
Load Diff
+25010
-19078
File diff suppressed because it is too large
Load Diff
Generated
+2466
-4700
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "configure-aws-credentials",
|
||||
"description": "A GitHub Action to configure AWS credentials",
|
||||
"version": "6.1.0",
|
||||
"version": "6.2.3",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "biome check --error-on-warnings ./src && markdownlint -i node_modules -i CHANGELOG.md '**/*.md'",
|
||||
"lint:fix": "biome check --write ./src && markdownlint -i node_modules -i CHANGELOG.md -f '**/*.md'",
|
||||
"lint": "biome check --error-on-warnings ./src ./test && markdownlint -i node_modules -i CHANGELOG.md '**/*.md'",
|
||||
"lint:fix": "biome check --write ./src ./test && markdownlint -i node_modules -i CHANGELOG.md -f '**/*.md'",
|
||||
"package": "esbuild src/index.ts --bundle --platform=node --target=node24 --outfile=dist/index.js && esbuild src/cleanup/index.ts --bundle --platform=node --target=node24 --outfile=dist/cleanup/index.js && npm run license",
|
||||
"test": "npm run lint && vitest run && npm run build",
|
||||
"clean": "del-cli coverage test-reports node_modules",
|
||||
@@ -17,26 +17,26 @@
|
||||
"organization": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.24",
|
||||
"@biomejs/biome": "2.4.10",
|
||||
"@smithy/property-provider": "^4.2.11",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.69",
|
||||
"@biomejs/biome": "2.5.8",
|
||||
"@smithy/property-provider": "^4.5.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"aws-sdk-client-mock": "^4.1.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"generate-license-file": "^4.1.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"generate-license-file": "^4.2.1",
|
||||
"json-schema": "^0.4.0",
|
||||
"markdownlint-cli": "^0.48.0",
|
||||
"memfs": "^4.57.1",
|
||||
"markdownlint-cli": "^0.49.1",
|
||||
"memfs": "^4.68.1",
|
||||
"standard-version": "^9.5.0",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^3.2.4"
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^2.0.2",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
"@smithy/node-http-handler": "^4.4.7",
|
||||
"proxy-agent": "^8.0.0"
|
||||
"@actions/core": "^3.0.1",
|
||||
"@aws-sdk/client-sts": "^3.1111.0",
|
||||
"@smithy/node-http-handler": "^4.11.0",
|
||||
"proxy-agent": "^8.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"aws",
|
||||
|
||||
+43
-37
@@ -3,15 +3,18 @@ import { STSClient } from '@aws-sdk/client-sts';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { ProxyAgent } from 'proxy-agent';
|
||||
import { errorMessage, getCallerIdentity } from './helpers';
|
||||
import { buildCustomUserAgent, errorMessage, getCallerIdentity } from './helpers';
|
||||
import { ProxyResolver } from './ProxyResolver';
|
||||
|
||||
const USER_AGENT = 'configure-aws-credentials-for-github-actions';
|
||||
if (!process.env.AWS_EXECUTION_ENV) {
|
||||
process.env.AWS_EXECUTION_ENV = 'GitHubActions';
|
||||
}
|
||||
|
||||
export interface CredentialsClientProps {
|
||||
region?: string;
|
||||
proxyServer?: string;
|
||||
noProxy?: string;
|
||||
stsEndpoint?: string;
|
||||
roleChaining: boolean;
|
||||
}
|
||||
|
||||
@@ -19,6 +22,7 @@ export class CredentialsClient {
|
||||
public region?: string;
|
||||
private _stsClient?: STSClient;
|
||||
private readonly requestHandler?: NodeHttpHandler;
|
||||
private readonly stsEndpoint?: string;
|
||||
private roleChaining?: boolean;
|
||||
|
||||
constructor(props: CredentialsClientProps) {
|
||||
@@ -41,61 +45,63 @@ export class CredentialsClient {
|
||||
httpAgent: handler,
|
||||
});
|
||||
}
|
||||
if (props.stsEndpoint) {
|
||||
this.stsEndpoint = props.stsEndpoint;
|
||||
}
|
||||
this.roleChaining = props.roleChaining;
|
||||
}
|
||||
|
||||
public get stsClient(): STSClient {
|
||||
if (!this._stsClient || this.roleChaining) {
|
||||
const config = { customUserAgent: USER_AGENT } as {
|
||||
customUserAgent: string;
|
||||
region?: string;
|
||||
requestHandler?: NodeHttpHandler;
|
||||
};
|
||||
if (this.region !== undefined) config.region = this.region;
|
||||
if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler;
|
||||
this._stsClient = new STSClient(config);
|
||||
this._stsClient = this.createStsClient();
|
||||
}
|
||||
return this._stsClient;
|
||||
}
|
||||
|
||||
// Builds an STS client using the action's configured region/endpoint/proxy. When explicit credentials are provided,
|
||||
// the client uses them directly instead of the SDK default credential provider chain.
|
||||
// This matters for validateAccountId.
|
||||
private createStsClient(credentials?: AwsCredentialIdentity): STSClient {
|
||||
return new STSClient({
|
||||
customUserAgent: buildCustomUserAgent(),
|
||||
...(this.region !== undefined && { region: this.region }),
|
||||
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
|
||||
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
|
||||
...(credentials !== undefined && { credentials }),
|
||||
});
|
||||
}
|
||||
|
||||
// Validates that the credentials the action will hand to subsequent steps actually work, and returns the resolved
|
||||
// caller identity (account + ARN). "Work" is proven by a sts:GetCallerIdentity call, which both confirms the
|
||||
// credentials are accepted by AWS and returns the identity for later checks and outputs to use.
|
||||
public async validateCredentials(
|
||||
credentials?: AwsCredentialIdentity,
|
||||
expectedAccessKeyId?: string,
|
||||
roleChaining?: boolean,
|
||||
expectedAccountIds?: string[],
|
||||
) {
|
||||
let credentials: AwsCredentialIdentity;
|
||||
try {
|
||||
credentials = await this.loadCredentials();
|
||||
if (!credentials.accessKeyId) {
|
||||
throw new Error('Access key ID empty after loading credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||
}
|
||||
if (expectedAccountIds && expectedAccountIds.length > 0 && expectedAccountIds[0] !== '') {
|
||||
let callerIdentity: Awaited<ReturnType<typeof getCallerIdentity>>;
|
||||
): Promise<Awaited<ReturnType<typeof getCallerIdentity>>> {
|
||||
if (!credentials) {
|
||||
let resolved: AwsCredentialIdentity;
|
||||
try {
|
||||
callerIdentity = await getCallerIdentity(this.stsClient);
|
||||
resolved = await this.loadCredentials();
|
||||
if (!resolved.accessKeyId) {
|
||||
throw new Error('Access key ID empty after loading credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Could not validate account ID of credentials: ${errorMessage(error)}`);
|
||||
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||
}
|
||||
if (!callerIdentity.Account || !expectedAccountIds.includes(callerIdentity.Account)) {
|
||||
throw new Error(
|
||||
`The account ID of the provided credentials (${
|
||||
callerIdentity.Account ?? 'unknown'
|
||||
}) does not match any of the expected account IDs: ${expectedAccountIds.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!roleChaining) {
|
||||
const actualAccessKeyId = credentials.accessKeyId;
|
||||
if (expectedAccessKeyId && expectedAccessKeyId !== actualAccessKeyId) {
|
||||
if (!roleChaining && expectedAccessKeyId && expectedAccessKeyId !== resolved.accessKeyId) {
|
||||
throw new Error(
|
||||
'Credentials loaded by the SDK do not match the expected access key ID configured by the action',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const client = credentials ? this.createStsClient(credentials) : this.stsClient;
|
||||
try {
|
||||
return await getCallerIdentity(client);
|
||||
} catch (error) {
|
||||
throw new Error(`Credentials could not be loaded, please check your action inputs: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCredentials() {
|
||||
|
||||
+126
-19
@@ -1,11 +1,10 @@
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
import type { AssumeRoleCommandInput, STSClient, Tag } from '@aws-sdk/client-sts';
|
||||
import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand } from '@aws-sdk/client-sts';
|
||||
import type { CredentialsClient } from './CredentialsClient';
|
||||
import { errorMessage, isDefined, sanitizeGitHubVariables } from './helpers';
|
||||
import { errorMessage, isDefined, readFileUtf8, sanitizeGitHubVariables } from './helpers';
|
||||
|
||||
async function assumeRoleWithOIDC(params: AssumeRoleCommandInput, client: STSClient, webIdentityToken: string) {
|
||||
delete params.Tags;
|
||||
@@ -36,13 +35,14 @@ async function assumeRoleWithWebIdentityTokenFile(
|
||||
const webIdentityTokenFilePath = path.isAbsolute(webIdentityTokenFile)
|
||||
? webIdentityTokenFile
|
||||
: path.join(workspace, webIdentityTokenFile);
|
||||
if (!fs.existsSync(webIdentityTokenFilePath)) {
|
||||
const webIdentityToken = readFileUtf8(webIdentityTokenFilePath);
|
||||
if (webIdentityToken === null) {
|
||||
throw new Error(`Web identity token file does not exist: ${webIdentityTokenFilePath}`);
|
||||
}
|
||||
core.info('Assuming role with web identity token file');
|
||||
try {
|
||||
const webIdentityToken = fs.readFileSync(webIdentityTokenFilePath, 'utf8');
|
||||
delete params.Tags;
|
||||
delete params.TransitiveTagKeys;
|
||||
const creds = await client.send(
|
||||
new AssumeRoleWithWebIdentityCommand({
|
||||
...params,
|
||||
@@ -61,6 +61,13 @@ async function assumeRoleWithCredentials(params: AssumeRoleCommandInput, client:
|
||||
const creds = await client.send(new AssumeRoleCommand({ ...params }));
|
||||
return creds;
|
||||
} catch (error) {
|
||||
if ((error as { name?: string })?.name === 'PackedPolicyTooLargeException') {
|
||||
core.info('Session tag size is too large; dropping droppable tags and retrying.');
|
||||
const droppableKeys = new Set(DROPPABLE_TAG_SOURCES.map((s) => s.key));
|
||||
params.Tags = params.Tags?.filter((tag) => !droppableKeys.has(tag.Key ?? ''));
|
||||
const creds = await client.send(new AssumeRoleCommand({ ...params }));
|
||||
return creds;
|
||||
}
|
||||
throw new Error(`Could not assume role with user credentials: ${errorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +85,99 @@ export interface assumeRoleParams {
|
||||
webIdentityToken?: string;
|
||||
inlineSessionPolicy?: string;
|
||||
managedSessionPolicies?: { arn: string }[];
|
||||
customTags?: string;
|
||||
}
|
||||
|
||||
const TAG_KEY_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]+$/u;
|
||||
const TAG_VALUE_REGEX = /^[\p{L}\p{Z}\p{N}_.:/=+\-@]*$/u;
|
||||
const MAX_TAG_KEY_LENGTH = 128;
|
||||
const MAX_TAG_VALUE_LENGTH = 256;
|
||||
const MAX_SESSION_TAGS = 50;
|
||||
|
||||
// Identity/audit primitives. Always emitted and cannot be dropped.
|
||||
const NON_DROPPABLE_TAG_SOURCES: ReadonlyArray<{ key: string; envVar: string }> = [
|
||||
{ key: 'Repository', envVar: 'GITHUB_REPOSITORY' },
|
||||
{ key: 'Workflow', envVar: 'GITHUB_WORKFLOW' },
|
||||
{ key: 'Action', envVar: 'GITHUB_ACTION' },
|
||||
{ key: 'Actor', envVar: 'GITHUB_ACTOR' },
|
||||
{ key: 'Commit', envVar: 'GITHUB_SHA' },
|
||||
{ key: 'Branch', envVar: 'GITHUB_REF' },
|
||||
];
|
||||
|
||||
// Convenience metadata. If the AssumeRole call fails due to compressed size of
|
||||
// session tags being too large, we will drop these tags and retry once.
|
||||
const DROPPABLE_TAG_SOURCES: ReadonlyArray<{ key: string; envVar: string }> = [
|
||||
{ key: 'EventName', envVar: 'GITHUB_EVENT_NAME' },
|
||||
{ key: 'BaseRef', envVar: 'GITHUB_BASE_REF' },
|
||||
{ key: 'HeadRef', envVar: 'GITHUB_HEAD_REF' },
|
||||
{ key: 'RunId', envVar: 'GITHUB_RUN_ID' },
|
||||
{ key: 'Job', envVar: 'GITHUB_JOB' },
|
||||
{ key: 'TriggeringActor', envVar: 'GITHUB_TRIGGERING_ACTOR' },
|
||||
];
|
||||
|
||||
const PROTECTED_TAG_KEYS = new Set<string>([
|
||||
'GitHub',
|
||||
...NON_DROPPABLE_TAG_SOURCES.map((s) => s.key),
|
||||
...DROPPABLE_TAG_SOURCES.map((s) => s.key),
|
||||
]);
|
||||
|
||||
export function parseAndValidateCustomTags(customTags: string, existingTags: Tag[]): Tag[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(customTags);
|
||||
} catch {
|
||||
throw new Error('custom-tags: input is not valid JSON');
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('custom-tags: input must be a JSON object (not an array or primitive)');
|
||||
}
|
||||
|
||||
const newTags: Tag[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' must be a string, number, or boolean (not an object or array)`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
if (key.length === 0 || key.length > MAX_TAG_KEY_LENGTH) {
|
||||
throw new Error(`custom-tags: key '${key}' must be between 1 and ${MAX_TAG_KEY_LENGTH} characters`);
|
||||
}
|
||||
if (stringValue.length > MAX_TAG_VALUE_LENGTH) {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
if (!TAG_KEY_REGEX.test(key)) {
|
||||
throw new Error(
|
||||
`custom-tags: key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||
);
|
||||
}
|
||||
if (stringValue.length > 0 && !TAG_VALUE_REGEX.test(stringValue)) {
|
||||
throw new Error(
|
||||
`custom-tags: value for key '${key}' contains invalid characters. Allowed: unicode letters, digits, spaces, and _.:/=+-@`,
|
||||
);
|
||||
}
|
||||
if (PROTECTED_TAG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`custom-tags: key '${key}' conflicts with a protected session tag set by this action and cannot be overridden`,
|
||||
);
|
||||
}
|
||||
|
||||
newTags.push({ Key: key, Value: stringValue });
|
||||
}
|
||||
|
||||
if (existingTags.length + newTags.length > MAX_SESSION_TAGS) {
|
||||
throw new Error(
|
||||
`custom-tags: total session tags (${existingTags.length + newTags.length}) would exceed the AWS limit of ${MAX_SESSION_TAGS}`,
|
||||
);
|
||||
}
|
||||
|
||||
return newTags;
|
||||
}
|
||||
|
||||
export async function assumeRole(params: assumeRoleParams) {
|
||||
@@ -94,6 +194,7 @@ export async function assumeRole(params: assumeRoleParams) {
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
customTags,
|
||||
} = { ...params };
|
||||
|
||||
// Load GitHub environment variables
|
||||
@@ -102,26 +203,32 @@ export async function assumeRole(params: assumeRoleParams) {
|
||||
throw new Error('Missing required environment variables. Are you running in GitHub Actions?');
|
||||
}
|
||||
|
||||
// Load role session tags
|
||||
const tagArray: Tag[] = [
|
||||
{ Key: 'GitHub', Value: 'Actions' },
|
||||
{ Key: 'Repository', Value: GITHUB_REPOSITORY },
|
||||
{ Key: 'Workflow', Value: sanitizeGitHubVariables(GITHUB_WORKFLOW) },
|
||||
{ Key: 'Action', Value: GITHUB_ACTION },
|
||||
{ Key: 'Actor', Value: sanitizeGitHubVariables(GITHUB_ACTOR) },
|
||||
{ Key: 'Commit', Value: GITHUB_SHA },
|
||||
];
|
||||
if (process.env.GITHUB_REF) {
|
||||
tagArray.push({
|
||||
Key: 'Branch',
|
||||
Value: sanitizeGitHubVariables(process.env.GITHUB_REF),
|
||||
});
|
||||
// Build session tags. Values are sanitized because the AWS tag value spec is more
|
||||
// restrictive than permissible characters in environment variables.
|
||||
const protectedTags: Tag[] = [{ Key: 'GitHub', Value: 'Actions' }];
|
||||
for (const { key, envVar } of NON_DROPPABLE_TAG_SOURCES) {
|
||||
const value = process.env[envVar];
|
||||
if (value) {
|
||||
protectedTags.push({ Key: key, Value: sanitizeGitHubVariables(value) });
|
||||
}
|
||||
}
|
||||
for (const { key, envVar } of DROPPABLE_TAG_SOURCES) {
|
||||
const value = process.env[envVar];
|
||||
if (value) {
|
||||
protectedTags.push({ Key: key, Value: sanitizeGitHubVariables(value) });
|
||||
}
|
||||
}
|
||||
|
||||
const parsedCustomTags: Tag[] = customTags ? parseAndValidateCustomTags(customTags, protectedTags) : [];
|
||||
|
||||
const tagArray: Tag[] = [...protectedTags, ...parsedCustomTags];
|
||||
|
||||
const tags = roleSkipSessionTagging ? undefined : tagArray;
|
||||
if (!tags) {
|
||||
core.debug('Role session tagging has been skipped.');
|
||||
} else {
|
||||
core.debug(`${tags.length} role session tags are being used.`);
|
||||
core.debug(`${tags.length} role session tags are being used:`);
|
||||
core.debug(JSON.stringify(tagArray));
|
||||
}
|
||||
|
||||
//only populate transitiveTagKeys array if user is actually using session tagging
|
||||
|
||||
+164
-7
@@ -1,11 +1,35 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
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 = '_';
|
||||
const SPECIAL_CHARS_REGEX = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]+/;
|
||||
const USER_AGENT_PREFIX = 'configure-aws-credentials-for-github-actions';
|
||||
const UA_FIELDS: ReadonlyArray<{ env: string; label: string; pattern: RegExp }> = [
|
||||
{ env: 'GITHUB_ACTION', label: 'action', pattern: /^[A-Za-z0-9_-]{1,128}$/ },
|
||||
{ env: 'GITHUB_RUN_ID', label: 'run_id', pattern: /^[0-9]{1,20}$/ },
|
||||
{ env: 'GITHUB_RUN_ATTEMPT', label: 'attempt', pattern: /^[0-9]{1,10}$/ },
|
||||
];
|
||||
|
||||
export function buildCustomUserAgent(): UserAgent {
|
||||
const tokens: UserAgent = [[USER_AGENT_PREFIX]];
|
||||
for (const { env, label, pattern } of UA_FIELDS) {
|
||||
const value = process.env[env];
|
||||
if (value === undefined) continue;
|
||||
if (pattern.test(value)) {
|
||||
tokens.push(['md', `${label}#${value}`]);
|
||||
} else {
|
||||
core.warning(`${env} has unexpected format; omitting from User-Agent`);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function translateEnvVariables() {
|
||||
const envVars = [
|
||||
@@ -127,9 +151,8 @@ export async function getCallerIdentity(client: STSClient): Promise<{ Account: s
|
||||
return result;
|
||||
}
|
||||
|
||||
// Obtains account ID from STS Client and sets it as output
|
||||
export async function exportAccountId(credentialsClient: CredentialsClient, maskAccountId?: boolean) {
|
||||
const identity = await getCallerIdentity(credentialsClient.stsClient);
|
||||
// Emits the account ID and ARN of an already-resolved caller identity as action outputs.
|
||||
export function exportAccountId(identity: { Account: string; Arn: string }, maskAccountId?: boolean) {
|
||||
const accountId = identity.Account;
|
||||
const arn = identity.Arn;
|
||||
if (maskAccountId) {
|
||||
@@ -141,6 +164,35 @@ export async function exportAccountId(credentialsClient: CredentialsClient, mask
|
||||
return accountId;
|
||||
}
|
||||
|
||||
// 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] === '') {
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Converts the STS Credentials shape (returned by AssumeRole and provided as action inputs) into
|
||||
// the AwsCredentialIdentity shape the SDK expects when credentials are supplied explicitly to a
|
||||
// client. Returns undefined if the access key ID or secret access key is missing.
|
||||
export function toCredentialIdentity(creds?: Partial<Credentials>): AwsCredentialIdentity | undefined {
|
||||
if (!creds?.AccessKeyId || !creds.SecretAccessKey) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
accessKeyId: creds.AccessKeyId,
|
||||
secretAccessKey: creds.SecretAccessKey,
|
||||
...(creds.SessionToken && { sessionToken: creds.SessionToken }),
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -189,6 +241,7 @@ export async function retryAndBackoff<T>(
|
||||
maxRetries = 12,
|
||||
retries = 0,
|
||||
base = 50,
|
||||
label?: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
@@ -200,20 +253,21 @@ export async function retryAndBackoff<T>(
|
||||
// It's retryable, so sleep and retry.
|
||||
const delay = Math.random() * (2 ** retries * base);
|
||||
const nextRetry = retries + 1;
|
||||
const opName = label ? ` ${label}` : '';
|
||||
|
||||
core.debug(
|
||||
`retryAndBackoff: attempt ${nextRetry} of ${maxRetries} failed: ${errorMessage(err)}. ` +
|
||||
core.info(
|
||||
`Retry${opName}: attempt ${nextRetry} of ${maxRetries} failed: ${errorMessage(err)}. ` +
|
||||
`Retrying after ${Math.floor(delay)}ms.`,
|
||||
);
|
||||
|
||||
await sleep(delay);
|
||||
|
||||
if (nextRetry >= maxRetries) {
|
||||
core.debug('retryAndBackoff: reached max retries; giving up.');
|
||||
core.info(`Retry${opName}: reached max retries (${maxRetries}); giving up.`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return await retryAndBackoff(fn, isRetryable, maxRetries, nextRetry, base);
|
||||
return await retryAndBackoff(fn, isRetryable, maxRetries, nextRetry, base, label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,3 +322,106 @@ export function getBooleanInput(name: string, options?: core.InputOptions & { de
|
||||
`Support boolean input list: \`true | True | TRUE | false | False | FALSE\``,
|
||||
);
|
||||
}
|
||||
|
||||
// O_NOFOLLOW is undefined on Windows. This sets it to 0 if it's not defined.
|
||||
const O_NOFOLLOW: number = (fs.constants as { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0;
|
||||
|
||||
export function isAllowListed(filePath: string): boolean {
|
||||
// Kubelet projects service-account tokens through a symlink chain
|
||||
// (token -> ..data/token, ..data -> ..<timestamp>/). The containing path is
|
||||
// kubelet-controlled, so we allow symlink-following reads of this fixed
|
||||
// location only.
|
||||
const KUBERNETES_TOKEN_PATH_REGEX = /^\/var\/run\/secrets\/[^/]+\/serviceaccount\/token$/;
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// No Kubernetes token paths on Windows
|
||||
return KUBERNETES_TOKEN_PATH_REGEX.test(path.posix.normalize(filePath));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSymlink(filePath: string): boolean {
|
||||
try {
|
||||
return fs.lstatSync(filePath).isSymbolicLink();
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Refuses if filePath or its parent directory is a symbolic link.
|
||||
function refuseSymlinkOnPath(filePath: string): void {
|
||||
const parent = path.dirname(filePath);
|
||||
if (parent !== filePath && isSymlink(parent)) {
|
||||
throw new Error(`Refusing ${filePath} (parent directory is a symbolic link)`);
|
||||
}
|
||||
if (isSymlink(filePath)) {
|
||||
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegularFile(fd: number, filePath: string): void {
|
||||
const stats = fs.fstatSync(fd);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`${filePath} (path is not a regular file)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ENOENT: file does not exist
|
||||
// ELOOP: too many symbolic links (from NOFOLLOW)
|
||||
|
||||
export function readFileUtf8(filePath: string): string | null {
|
||||
const allowSymlink = isAllowListed(filePath);
|
||||
if (!allowSymlink) {
|
||||
refuseSymlinkOnPath(filePath);
|
||||
}
|
||||
const openFlags = fs.constants.O_RDONLY | (allowSymlink ? 0 : O_NOFOLLOW);
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(filePath, openFlags);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return null;
|
||||
if (code === 'ELOOP') {
|
||||
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
assertRegularFile(fd, filePath);
|
||||
return fs.readFileSync(fd, 'utf-8');
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFileUtf8(filePath: string, content: string, mode = 0o600): void {
|
||||
refuseSymlinkOnPath(filePath);
|
||||
let fd: number;
|
||||
try {
|
||||
fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | O_NOFOLLOW, mode);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ELOOP') {
|
||||
throw new Error(`Refusing ${filePath} (path is a symbolic link)`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
assertRegularFile(fd, filePath);
|
||||
// openSync only applies mode on creation.
|
||||
// If the file already exists, we need to ensure the mode is correct.
|
||||
if (process.platform !== 'win32') {
|
||||
fs.fchmodSync(fd, mode);
|
||||
}
|
||||
fs.writeFileSync(fd, content);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function mkdir(dir: string, mode = 0o700): void {
|
||||
fs.mkdirSync(dir, { recursive: true, mode });
|
||||
if (isSymlink(dir)) {
|
||||
throw new Error(`Refusing ${dir} (path is a symbolic link)`);
|
||||
}
|
||||
}
|
||||
|
||||
+94
-58
@@ -10,8 +10,10 @@ import {
|
||||
exportRegion,
|
||||
getBooleanInput,
|
||||
retryAndBackoff,
|
||||
toCredentialIdentity,
|
||||
translateEnvVariables,
|
||||
unsetCredentials,
|
||||
validateAccountId,
|
||||
verifyKeys,
|
||||
} from './helpers';
|
||||
import { writeProfileFiles } from './profileManager';
|
||||
@@ -19,6 +21,7 @@ import { writeProfileFiles } from './profileManager';
|
||||
const DEFAULT_ROLE_DURATION = 3600; // One hour (seconds)
|
||||
const ROLE_SESSION_NAME = 'GitHubActions';
|
||||
const REGION_REGEX = /^[a-z0-9-]+$/g;
|
||||
const ROLE_SESSION_NAME_REGEX = /^[\w+=,.@-]*$/;
|
||||
|
||||
export async function run() {
|
||||
try {
|
||||
@@ -43,14 +46,15 @@ export async function run() {
|
||||
const roleSkipSessionTagging = getBooleanInput('role-skip-session-tagging', { required: false });
|
||||
const transitiveTagKeys = core.getMultilineInput('transitive-tag-keys', { required: false });
|
||||
const proxyServer = core.getInput('http-proxy', { required: false }) || process.env.HTTP_PROXY;
|
||||
const customTags = core.getInput('custom-tags', { required: false });
|
||||
const inlineSessionPolicy = core.getInput('inline-session-policy', { required: false });
|
||||
const managedSessionPolicies = core.getMultilineInput('managed-session-policies', { required: false }).map((p) => {
|
||||
return { arn: p };
|
||||
});
|
||||
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 });
|
||||
@@ -63,6 +67,7 @@ export async function run() {
|
||||
.map((s) => s.trim());
|
||||
const forceSkipOidc = getBooleanInput('force-skip-oidc', { required: false });
|
||||
const noProxy = core.getInput('no-proxy', { required: false });
|
||||
const stsEndpoint = core.getInput('sts-endpoint', { required: false });
|
||||
const globalTimeout = Number.parseInt(core.getInput('action-timeout-s', { required: false })) || 0;
|
||||
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
@@ -88,6 +93,9 @@ export async function run() {
|
||||
maxRetries = 1;
|
||||
}
|
||||
|
||||
const withRetry = <T>(fn: () => Promise<T>, label: string): Promise<T> =>
|
||||
retryAndBackoff(fn, !disableRetry, maxRetries, 0, 50, label);
|
||||
|
||||
// Logic to decide whether to attempt to use OIDC or not
|
||||
const useGitHubOIDCProvider = () => {
|
||||
if (forceSkipOidc) return false;
|
||||
@@ -124,15 +132,33 @@ export async function run() {
|
||||
throw new Error(`Region is not valid: ${region}`);
|
||||
}
|
||||
|
||||
if (roleSessionName.length < 2 || roleSessionName.length > 64) {
|
||||
throw new Error(
|
||||
`Role session name must be between 2 and 64 characters, got ${roleSessionName.length}: '${roleSessionName}'`,
|
||||
);
|
||||
}
|
||||
if (!roleSessionName.match(ROLE_SESSION_NAME_REGEX)) {
|
||||
throw new Error(
|
||||
`Role session name is not valid: '${roleSessionName}'. Must satisfy regular expression pattern: [\\w+=,.@-]*`,
|
||||
);
|
||||
}
|
||||
|
||||
exportRegion(region, outputEnvCredentials);
|
||||
|
||||
// Instantiate credentials client
|
||||
const clientProps: { region: string; proxyServer?: string; noProxy?: string; roleChaining: boolean } = {
|
||||
const clientProps: {
|
||||
region: string;
|
||||
proxyServer?: string;
|
||||
noProxy?: string;
|
||||
stsEndpoint?: string;
|
||||
roleChaining: boolean;
|
||||
} = {
|
||||
region,
|
||||
roleChaining,
|
||||
};
|
||||
if (proxyServer) clientProps.proxyServer = proxyServer;
|
||||
if (noProxy) clientProps.noProxy = noProxy;
|
||||
if (stsEndpoint) clientProps.stsEndpoint = stsEndpoint;
|
||||
const credentialsClient = new CredentialsClient(clientProps);
|
||||
let sourceAccountId: string;
|
||||
let webIdentityToken: string;
|
||||
@@ -152,13 +178,9 @@ export async function run() {
|
||||
// Else, export credentials provided as input
|
||||
if (useGitHubOIDCProvider()) {
|
||||
try {
|
||||
webIdentityToken = await retryAndBackoff(
|
||||
async () => {
|
||||
return core.getIDToken(audience);
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
webIdentityToken = await withRetry(async () => {
|
||||
return core.getIDToken(audience);
|
||||
}, 'getIDToken');
|
||||
} catch (error) {
|
||||
throw new Error(`getIDToken call failed: ${errorMessage(error)}`);
|
||||
}
|
||||
@@ -178,60 +200,80 @@ export async function run() {
|
||||
writeProfileFiles(awsProfile, { AccessKeyId, SecretAccessKey, SessionToken }, region, overwriteAwsProfile);
|
||||
}
|
||||
} else if (!webIdentityTokenFile && !roleChaining) {
|
||||
// Proceed only if credentials can be picked up
|
||||
await credentialsClient.validateCredentials(undefined, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
// Proceed only if credentials can be picked up. validateCredentials resolves the ambient
|
||||
// credentials via the SDK default chain, proves they work, and returns the caller identity.
|
||||
const identity = await withRetry(
|
||||
() => credentialsClient.validateCredentials(undefined, undefined, roleChaining),
|
||||
'validateCredentials',
|
||||
);
|
||||
// Enforce the allowed-account-ids guardrail unless a role will be assumed, in which case the
|
||||
// final account is validated after assumeRole (these ambient credentials are the source account).
|
||||
if (!roleToAssume) {
|
||||
validateAccountId(expectedAccountIds, identity.Account);
|
||||
}
|
||||
sourceAccountId = exportAccountId(identity, maskAccountId);
|
||||
}
|
||||
|
||||
if (AccessKeyId || roleChaining) {
|
||||
// Validate that the SDK can actually pick up credentials.
|
||||
// This validates cases where this action is using existing environment credentials,
|
||||
// and cases where the user intended to provide input credentials but the secrets inputs resolved to empty strings.
|
||||
await credentialsClient.validateCredentials(AccessKeyId, roleChaining, expectedAccountIds);
|
||||
sourceAccountId = await exportAccountId(credentialsClient, maskAccountId);
|
||||
// Validate that the credentials the action will use actually work, and resolve their identity.
|
||||
const resolutionCredentials =
|
||||
outputEnvCredentials || !AccessKeyId
|
||||
? undefined
|
||||
: toCredentialIdentity({ AccessKeyId, SecretAccessKey, SessionToken });
|
||||
const identity = await withRetry(
|
||||
() => credentialsClient.validateCredentials(resolutionCredentials, AccessKeyId, roleChaining),
|
||||
'validateCredentials',
|
||||
);
|
||||
// Enforce the allowed-account-ids guardrail unless a role will be assumed (the final account is
|
||||
// validated after assumeRole; these are the source credentials).
|
||||
if (!roleToAssume) {
|
||||
validateAccountId(expectedAccountIds, identity.Account);
|
||||
}
|
||||
sourceAccountId = identity.Account;
|
||||
if (outputEnvCredentials) {
|
||||
exportAccountId(identity, maskAccountId);
|
||||
}
|
||||
}
|
||||
if (customTags && (useGitHubOIDCProvider() || webIdentityTokenFile)) {
|
||||
core.warning(
|
||||
"'custom-tags' is set but will be ignored because session tags cannot be applied when using OIDC or web identity token authentication. " +
|
||||
'Tags are controlled by the identity provider token claims in these authentication flows.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get role credentials if configured to do so
|
||||
if (roleToAssume) {
|
||||
let roleCredentials: AssumeRoleCommandOutput;
|
||||
do {
|
||||
roleCredentials = await retryAndBackoff(
|
||||
async () => {
|
||||
return assumeRole({
|
||||
credentialsClient,
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDuration,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
transitiveTagKeys,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
});
|
||||
},
|
||||
!disableRetry,
|
||||
maxRetries,
|
||||
);
|
||||
roleCredentials = await withRetry(async () => {
|
||||
return assumeRole({
|
||||
credentialsClient,
|
||||
sourceAccountId,
|
||||
roleToAssume,
|
||||
roleExternalId,
|
||||
roleDuration,
|
||||
roleSessionName,
|
||||
roleSkipSessionTagging,
|
||||
transitiveTagKeys,
|
||||
webIdentityTokenFile,
|
||||
webIdentityToken,
|
||||
inlineSessionPolicy,
|
||||
managedSessionPolicies,
|
||||
customTags,
|
||||
});
|
||||
}, 'AssumeRole');
|
||||
} while (specialCharacterWorkaround && !verifyKeys(roleCredentials.Credentials));
|
||||
core.info(`Authenticated as assumedRoleId ${roleCredentials.AssumedRoleUser?.AssumedRoleId}`);
|
||||
exportCredentials(roleCredentials.Credentials, outputCredentials, outputEnvCredentials);
|
||||
// We need to validate the credentials in 2 of our use-cases
|
||||
// First: self-hosted runners. If the GITHUB_ACTIONS environment variable
|
||||
// is set to `true` then we are NOT in a self-hosted runner.
|
||||
// Second: Customer provided credentials manually (IAM User keys stored in GH Secrets)
|
||||
// If we are using a profile, don't validate credentials yet (since they most likely won't be in the environment).
|
||||
// Wait until after creds are written to the profile file to try validation.
|
||||
if ((!process.env.GITHUB_ACTIONS || AccessKeyId) && !awsProfile) {
|
||||
await credentialsClient.validateCredentials(
|
||||
roleCredentials.Credentials?.AccessKeyId,
|
||||
roleChaining,
|
||||
expectedAccountIds,
|
||||
);
|
||||
}
|
||||
// Validate the assumed-role credentials and resolve their identity.
|
||||
const identity = await withRetry(
|
||||
() => credentialsClient.validateCredentials(toCredentialIdentity(roleCredentials.Credentials)),
|
||||
'validateCredentials',
|
||||
);
|
||||
// Enforce the allowed-account-ids guardrail against the assumed (final) account.
|
||||
validateAccountId(expectedAccountIds, identity.Account);
|
||||
if (outputEnvCredentials) {
|
||||
await exportAccountId(credentialsClient, maskAccountId);
|
||||
exportAccountId(identity, maskAccountId);
|
||||
}
|
||||
|
||||
// Write profile files if profile mode is enabled
|
||||
@@ -241,14 +283,8 @@ export async function run() {
|
||||
}
|
||||
// If user provided IAM User Credentials and then we assumed a role, overwrite the profile file to add
|
||||
// the session token. (this only overwrites the profile within a single run of the action).
|
||||
// We then validate the credentials to make sure they work.
|
||||
if (AccessKeyId || !process.env.GITHUB_ACTIONS) {
|
||||
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, true);
|
||||
await credentialsClient.validateCredentials(
|
||||
roleCredentials.Credentials.AccessKeyId,
|
||||
roleChaining,
|
||||
expectedAccountIds,
|
||||
);
|
||||
} else {
|
||||
writeProfileFiles(awsProfile, roleCredentials.Credentials, region, overwriteAwsProfile);
|
||||
}
|
||||
|
||||
+6
-14
@@ -1,8 +1,8 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as core from '@actions/core';
|
||||
import type { Credentials } from '@aws-sdk/client-sts';
|
||||
import { mkdir, readFileUtf8, writeFileUtf8 } from './helpers';
|
||||
|
||||
/**
|
||||
* Parse an INI-format string into a nested object.
|
||||
@@ -87,10 +87,8 @@ export function getProfileFilePaths(): ProfileFilePaths {
|
||||
*/
|
||||
export function ensureAwsDirectoryExists(filePath: string): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
core.debug(`Creating directory: ${dir}`);
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
core.debug(`Ensuring directory exists: ${dir}`);
|
||||
mkdir(dir, 0o700);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,14 +125,8 @@ export function mergeProfileSection(
|
||||
data: Record<string, string>,
|
||||
overwriteAwsProfile: boolean,
|
||||
): void {
|
||||
let existingContent: Record<string, Record<string, string>> = {};
|
||||
|
||||
// Read existing file if it exists
|
||||
if (fs.existsSync(filePath)) {
|
||||
core.debug(`Reading existing file: ${filePath}`);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||
existingContent = parseIni(fileContent);
|
||||
}
|
||||
const fileContent = readFileUtf8(filePath);
|
||||
const existingContent: Record<string, Record<string, string>> = fileContent === null ? {} : parseIni(fileContent);
|
||||
|
||||
if (existingContent[sectionName] && !overwriteAwsProfile) {
|
||||
throw new Error(
|
||||
@@ -147,7 +139,7 @@ export function mergeProfileSection(
|
||||
const content = stringifyIni(existingContent);
|
||||
|
||||
core.debug(`Writing profile to ${filePath}`);
|
||||
fs.writeFileSync(filePath, content, { mode: 0o600 });
|
||||
writeFileUtf8(filePath, content, 0o600);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as core from '@actions/core';
|
||||
import { AssumeRoleWithWebIdentityCommand, GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
|
||||
import { mockClient } from 'aws-sdk-client-mock';
|
||||
import { fs, vol } from 'memfs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as helpers from '../src/helpers';
|
||||
import { run } from '../src/index';
|
||||
import mocks from './mockinputs.test';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@actions/core');
|
||||
|
||||
const mockedSTSClient = mockClient(STSClient);
|
||||
|
||||
describe('assumeRoleWithWebIdentityTokenFile', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockedSTSClient.reset();
|
||||
vol.reset();
|
||||
helpers.withsleep(() => Promise.resolve());
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.WEBIDENTITY_TOKEN_FILE_INPUTS));
|
||||
vi.mocked(core.getMultilineInput).mockReturnValue([]);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env = { ...mocks.envs };
|
||||
fs.mkdirSync('/home/github', { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
helpers.reset();
|
||||
});
|
||||
|
||||
it('refuses when the token file is a symlink and never calls STS', async () => {
|
||||
fs.mkdirSync('/etc', { recursive: true });
|
||||
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
||||
fs.symlinkSync('/etc/passwd', '/home/github/file.txt');
|
||||
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(expect.stringMatching(/Refusing .* \(.* symbolic link\)/));
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand)).toHaveLength(0);
|
||||
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
||||
});
|
||||
|
||||
it('preserves the existing missing-file error when the token file does not exist', async () => {
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Web identity token file does not exist'));
|
||||
expect(mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes token contents to STS when the file is regular', async () => {
|
||||
fs.writeFileSync('/home/github/file.txt', 'real-token');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
const calls = mockedSTSClient.commandCalls(AssumeRoleWithWebIdentityCommand);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.args[0].input.WebIdentityToken).toBe('real-token');
|
||||
});
|
||||
});
|
||||
+8
-13
@@ -5,20 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup } from '../src/cleanup';
|
||||
import mocks from './mockinputs.test';
|
||||
|
||||
vi.mock('@actions/core');
|
||||
|
||||
const mockedSTSClient = mockClient(STSClient);
|
||||
|
||||
describe('Configure AWS Credentials cleanup', {}, () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock state
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
mockedSTSClient.reset();
|
||||
// Mock GitHub Actions core functions
|
||||
vi.spyOn(core, 'exportVariable').mockImplementation((_n, _v) => {});
|
||||
vi.spyOn(core, 'setSecret').mockImplementation((_s) => {});
|
||||
vi.spyOn(core, 'setFailed').mockImplementation((_m) => {});
|
||||
vi.spyOn(core, 'setOutput').mockImplementation((_n, _v) => {});
|
||||
vi.spyOn(core, 'debug').mockImplementation((_m) => {});
|
||||
vi.spyOn(core, 'info').mockImplementation((_m) => {});
|
||||
vi.mocked(core.getInput).mockReturnValue('');
|
||||
process.env = {
|
||||
...mocks.envs,
|
||||
AWS_ACCESS_KEY_ID: 'CLEANUPTEST',
|
||||
@@ -39,7 +34,7 @@ describe('Configure AWS Credentials cleanup', {}, () => {
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', '');
|
||||
});
|
||||
it('also clears AWS_PROFILE when aws-profile was set', {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
||||
vi.mocked(core.getInput).mockImplementation((name: string) => {
|
||||
if (name === 'aws-profile') return 'my-profile';
|
||||
if (name === 'output-env-credentials') return 'true';
|
||||
return '';
|
||||
@@ -50,7 +45,7 @@ describe('Configure AWS Credentials cleanup', {}, () => {
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', '');
|
||||
});
|
||||
it('skips env cleanup when aws-profile is set without output-env-credentials', {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
||||
vi.mocked(core.getInput).mockImplementation((name: string) => {
|
||||
if (name === 'aws-profile') return 'my-profile';
|
||||
return '';
|
||||
});
|
||||
@@ -59,14 +54,14 @@ describe('Configure AWS Credentials cleanup', {}, () => {
|
||||
expect(core.exportVariable).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('handles errors', {}, () => {
|
||||
vi.spyOn(core, 'exportVariable').mockImplementationOnce(() => {
|
||||
vi.mocked(core.exportVariable).mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
});
|
||||
cleanup();
|
||||
expect(core.setFailed).toHaveBeenCalled();
|
||||
});
|
||||
it(`doesn't export credentials as empty env variables if asked not to`, {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.NO_ENV_CREDS_INPUTS));
|
||||
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.NO_ENV_CREDS_INPUTS));
|
||||
cleanup();
|
||||
expect(core.exportVariable).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
+171
-4
@@ -1,11 +1,16 @@
|
||||
import * as core from '@actions/core';
|
||||
import { fs, vol } from 'memfs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as helpers from '../src/helpers';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@actions/core');
|
||||
|
||||
describe('Configure AWS Credentials helpers', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.clearAllMocks();
|
||||
vol.reset();
|
||||
});
|
||||
it('removes brackets from GitHub Actor', {}, () => {
|
||||
const actor = 'actor[bot]';
|
||||
@@ -23,6 +28,29 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
await expect(helpers.retryAndBackoff(fn, false)).rejects.toMatch('i am not retryable');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('retries and logs with label at info level', {}, async () => {
|
||||
helpers.withsleep(() => Promise.resolve());
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('success');
|
||||
const result = await helpers.retryAndBackoff(fn, true, 3, 0, 50, 'TestOp');
|
||||
expect(result).toBe('success');
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: attempt 1 of 3 failed'));
|
||||
helpers.reset();
|
||||
});
|
||||
it('logs max retries reached with label', {}, async () => {
|
||||
helpers.withsleep(() => Promise.resolve());
|
||||
const fn = vi.fn().mockRejectedValue(new Error('persistent'));
|
||||
await expect(helpers.retryAndBackoff(fn, true, 2, 0, 50, 'TestOp')).rejects.toThrow('persistent');
|
||||
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry TestOp: reached max retries (2)'));
|
||||
helpers.reset();
|
||||
});
|
||||
it('retries without a label (backward compat)', {}, async () => {
|
||||
helpers.withsleep(() => Promise.resolve());
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error('transient')).mockResolvedValueOnce('ok');
|
||||
await helpers.retryAndBackoff(fn, true, 3);
|
||||
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry: attempt 1 of 3 failed'));
|
||||
helpers.reset();
|
||||
});
|
||||
it('can output creds when told to', {}, () => {
|
||||
vi.spyOn(core, 'setOutput').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'setSecret').mockImplementation(() => {});
|
||||
@@ -79,13 +107,13 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
it('handles getBooleanInput correctly', {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockReturnValue('true');
|
||||
expect(helpers.getBooleanInput('test')).toBe(true);
|
||||
|
||||
|
||||
vi.spyOn(core, 'getInput').mockReturnValue('false');
|
||||
expect(helpers.getBooleanInput('test')).toBe(false);
|
||||
|
||||
|
||||
vi.spyOn(core, 'getInput').mockReturnValue('');
|
||||
expect(helpers.getBooleanInput('test', { default: true })).toBe(true);
|
||||
|
||||
|
||||
vi.spyOn(core, 'getInput').mockReturnValue('invalid');
|
||||
expect(() => helpers.getBooleanInput('test')).toThrow();
|
||||
});
|
||||
@@ -97,4 +125,143 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
|
||||
});
|
||||
|
||||
describe('filesystem helpers', {}, () => {
|
||||
describe('isSymlink', {}, () => {
|
||||
it('returns true for a symlink', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/target', 'data');
|
||||
fs.symlinkSync('/dir/target', '/dir/link');
|
||||
expect(helpers.isSymlink('/dir/link')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a regular file', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/file', 'data');
|
||||
expect(helpers.isSymlink('/dir/file')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a missing path', {}, () => {
|
||||
expect(helpers.isSymlink('/nonexistent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFileUtf8', {}, () => {
|
||||
it('returns content for a regular file', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/file', 'hello');
|
||||
expect(helpers.readFileUtf8('/dir/file')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns null when the file does not exist', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
expect(helpers.readFileUtf8('/dir/missing')).toBe(null);
|
||||
});
|
||||
|
||||
it('refuses to read through a symlink at the target', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/secret', 'sensitive');
|
||||
fs.symlinkSync('/dir/secret', '/dir/link');
|
||||
expect(() => helpers.readFileUtf8('/dir/link')).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
});
|
||||
|
||||
it('refuses to read when the parent directory is a symlink', {}, () => {
|
||||
fs.mkdirSync('/real/.aws', { recursive: true });
|
||||
fs.writeFileSync('/real/.aws/credentials', 'data');
|
||||
fs.mkdirSync('/home', { recursive: true });
|
||||
fs.symlinkSync('/real/.aws', '/home/.aws');
|
||||
expect(() => helpers.readFileUtf8('/home/.aws/credentials')).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
});
|
||||
|
||||
it('refuses to read when the path is a directory', {}, () => {
|
||||
fs.mkdirSync('/dir/subdir', { recursive: true });
|
||||
expect(() => helpers.readFileUtf8('/dir/subdir')).toThrow(/not a regular file/);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'follows the kubelet projected-token symlink chain at /var/run/secrets/*/serviceaccount/token',
|
||||
() => {
|
||||
fs.mkdirSync('/var/run/secrets/eks.amazonaws.com/serviceaccount/..2026_05_28_00_00_00.123', {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
'/var/run/secrets/eks.amazonaws.com/serviceaccount/..2026_05_28_00_00_00.123/token',
|
||||
'jwt-token',
|
||||
);
|
||||
fs.symlinkSync('..2026_05_28_00_00_00.123', '/var/run/secrets/eks.amazonaws.com/serviceaccount/..data');
|
||||
fs.symlinkSync('..data/token', '/var/run/secrets/eks.amazonaws.com/serviceaccount/token');
|
||||
expect(helpers.readFileUtf8('/var/run/secrets/eks.amazonaws.com/serviceaccount/token')).toBe('jwt-token');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')('still refuses symlinks at lookalike paths outside the allowlist', () => {
|
||||
fs.mkdirSync('/var/run/secrets/eks.amazonaws.com/serviceaccount', { recursive: true });
|
||||
fs.writeFileSync('/var/run/secrets/eks.amazonaws.com/serviceaccount/secret', 'jwt-token');
|
||||
fs.symlinkSync(
|
||||
'/var/run/secrets/eks.amazonaws.com/serviceaccount/secret',
|
||||
'/var/run/secrets/eks.amazonaws.com/serviceaccount/token2',
|
||||
);
|
||||
expect(() => helpers.readFileUtf8('/var/run/secrets/eks.amazonaws.com/serviceaccount/token2')).toThrow(
|
||||
/Refusing .* \(.* symbolic link\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowListed', {}, () => {
|
||||
it.skipIf(process.platform === 'win32')('matches the canonical kubelet projected-token path', () => {
|
||||
expect(helpers.isAllowListed('/var/run/secrets/eks.amazonaws.com/serviceaccount/token')).toBe(true);
|
||||
expect(helpers.isAllowListed('/var/run/secrets/kubernetes.io/serviceaccount/token')).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects nested or unrelated paths', () => {
|
||||
expect(helpers.isAllowListed('/var/run/secrets/serviceaccount/token')).toBe(false);
|
||||
expect(helpers.isAllowListed('/var/run/secrets/a/b/serviceaccount/token')).toBe(false);
|
||||
expect(helpers.isAllowListed('/var/run/secrets/eks.amazonaws.com/serviceaccount/token2')).toBe(false);
|
||||
expect(helpers.isAllowListed('/etc/var/run/secrets/foo/serviceaccount/token')).toBe(false);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('normalizes path traversal attempts', () => {
|
||||
expect(helpers.isAllowListed('/var/run/secrets/foo/serviceaccount/../../../../etc/passwd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFileUtf8', {}, () => {
|
||||
it('writes content with the specified mode', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
helpers.writeFileUtf8('/dir/file', 'payload', 0o600);
|
||||
expect(fs.readFileSync('/dir/file', 'utf-8')).toBe('payload');
|
||||
expect(fs.statSync('/dir/file').mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('refuses to follow a symlink at the target and leaves the target file untouched', {}, () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/target', 'original');
|
||||
fs.symlinkSync('/dir/target', '/dir/link');
|
||||
expect(() => helpers.writeFileUtf8('/dir/link', 'attacker', 0o600)).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
expect(fs.readFileSync('/dir/target', 'utf-8')).toBe('original');
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('tightens mode on existing files', () => {
|
||||
fs.mkdirSync('/dir', { recursive: true });
|
||||
fs.writeFileSync('/dir/file', 'old', { mode: 0o644 });
|
||||
helpers.writeFileUtf8('/dir/file', 'new', 0o600);
|
||||
expect(fs.statSync('/dir/file').mode & 0o777).toBe(0o600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mkdir', {}, () => {
|
||||
it('is idempotent on a regular directory', {}, () => {
|
||||
helpers.mkdir('/some/nested/dir', 0o700);
|
||||
helpers.mkdir('/some/nested/dir', 0o700);
|
||||
expect(fs.statSync('/some/nested/dir').isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses when the target directory is a symlink', {}, () => {
|
||||
fs.mkdirSync('/real', { recursive: true });
|
||||
fs.mkdirSync('/home', { recursive: true });
|
||||
fs.symlinkSync('/real', '/home/.aws');
|
||||
expect(() => helpers.mkdir('/home/.aws', 0o700)).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+665
-89
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,46 @@ const inputs = {
|
||||
'aws-region': 'fake-region-1',
|
||||
'special-characters-workaround': 'true',
|
||||
},
|
||||
CUSTOM_TAGS_INVALID_JSON_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': 'not a json',
|
||||
},
|
||||
CUSTOM_TAGS_ARRAY_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': '[1, 2, 3]',
|
||||
},
|
||||
CUSTOM_TAGS_RESERVED_KEY_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ Repository: 'evil-repo' }),
|
||||
},
|
||||
CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ 'invalid{key}': 'value' }),
|
||||
},
|
||||
CUSTOM_TAGS_OBJECT_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'retry-max-attempts': '1',
|
||||
'custom-tags': JSON.stringify({ Environment: 'Production', Team: 'DevOps' }),
|
||||
},
|
||||
IAM_USER_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
@@ -43,6 +83,14 @@ const inputs = {
|
||||
'output-env-credentials': 'false',
|
||||
'output-credentials': 'true',
|
||||
},
|
||||
IAM_ASSUMEROLE_NO_ENV_INPUTS: {
|
||||
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
||||
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
||||
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
||||
'aws-region': 'fake-region-1',
|
||||
'output-env-credentials': 'false',
|
||||
'output-credentials': 'true',
|
||||
},
|
||||
};
|
||||
|
||||
const envs = {
|
||||
@@ -53,6 +101,13 @@ const envs = {
|
||||
GITHUB_SHA: 'MY-COMMIT-ID',
|
||||
GITHUB_WORKSPACE: '/home/github',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GITHUB_REF: 'refs/pull/42/merge',
|
||||
GITHUB_EVENT_NAME: 'pull_request',
|
||||
GITHUB_RUN_ID: '16412345678',
|
||||
GITHUB_JOB: 'build',
|
||||
GITHUB_BASE_REF: 'main',
|
||||
GITHUB_HEAD_REF: 'feature-branch',
|
||||
GITHUB_TRIGGERING_ACTOR: 'MY-USERNAME[bot]',
|
||||
};
|
||||
|
||||
const outputs = {
|
||||
|
||||
+197
-117
@@ -11,13 +11,14 @@ import {
|
||||
writeProfileFiles,
|
||||
} from '../src/profileManager';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@actions/core');
|
||||
|
||||
describe('Profile Manager', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mock('node:fs');
|
||||
vi.clearAllMocks();
|
||||
vol.reset();
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('parseIni', {}, () => {
|
||||
@@ -49,7 +50,7 @@ describe('Profile Manager', {}, () => {
|
||||
|
||||
it('guards against __proto__ section pollution', {}, () => {
|
||||
const result = parseIni('[__proto__]\npolluted=true\n[safe]\nkey=val\n');
|
||||
expect(result.__proto__).not.toHaveProperty('polluted');
|
||||
expect(Object.getPrototypeOf(result)).not.toHaveProperty('polluted');
|
||||
expect(result.safe).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
@@ -95,7 +96,10 @@ describe('Profile Manager', {}, () => {
|
||||
});
|
||||
|
||||
it('round-trips through parseIni', {}, () => {
|
||||
const data = { dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' }, 'profile prod': { region: 'us-west-2' } };
|
||||
const data = {
|
||||
dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' },
|
||||
'profile prod': { region: 'us-west-2' },
|
||||
};
|
||||
const roundTripped = parseIni(stringifyIni(data));
|
||||
expect(roundTripped).toEqual(data);
|
||||
});
|
||||
@@ -196,10 +200,15 @@ describe('Profile Manager', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -214,16 +223,26 @@ describe('Profile Manager', {}, () => {
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'devSecretKey',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'devSecretKey',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Add second profile
|
||||
mergeProfileSection(filePath, 'prod', {
|
||||
aws_access_key_id: 'AKIAPRODEXAMPLE',
|
||||
aws_secret_access_key: 'prodSecretKey',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'prod',
|
||||
{
|
||||
aws_access_key_id: 'AKIAPRODEXAMPLE',
|
||||
aws_secret_access_key: 'prodSecretKey',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -239,18 +258,28 @@ describe('Profile Manager', {}, () => {
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
aws_session_token: 'oldSessionToken'
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
aws_session_token: 'oldSessionToken',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Overwrite with new credentials
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'newSessionToken',
|
||||
}, true);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'newSessionToken',
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -265,17 +294,27 @@ describe('Profile Manager', {}, () => {
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create profile with session token
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
aws_session_token: 'old-token',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
aws_session_token: 'old-token',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Overwrite without session token
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA2',
|
||||
aws_secret_access_key: 'secret2',
|
||||
}, true);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'AKIA2',
|
||||
aws_secret_access_key: 'secret2',
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -290,10 +329,15 @@ describe('Profile Manager', {}, () => {
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -307,17 +351,31 @@ describe('Profile Manager', {}, () => {
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
}, false);
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Overwrite with new credentials
|
||||
expect(() => mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'sessionToken',
|
||||
}, false)).toThrow(`Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`);
|
||||
expect(() =>
|
||||
mergeProfileSection(
|
||||
filePath,
|
||||
'dev',
|
||||
{
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'sessionToken',
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toThrow(
|
||||
`Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -468,7 +526,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
),
|
||||
).toThrow('whitespace');
|
||||
});
|
||||
@@ -486,7 +544,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
expect(fs.existsSync('/custom/credentials')).toBe(true);
|
||||
@@ -501,7 +559,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
||||
@@ -517,12 +575,7 @@ describe('Profile Manager', {}, () => {
|
||||
'[personal]\naws_access_key_id=AKIAPERSONAL\naws_secret_access_key=personalSecret\naws_session_token=personalToken\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false,
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -540,17 +593,9 @@ describe('Profile Manager', {}, () => {
|
||||
it('preserves pre-existing config with extra keys', {}, () => {
|
||||
const configPath = getProfileFilePaths().config;
|
||||
fs.mkdirSync(require('node:path').dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
'[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n',
|
||||
);
|
||||
fs.writeFileSync(configPath, '[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n');
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIA', SecretAccessKey: 'secret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -566,17 +611,9 @@ describe('Profile Manager', {}, () => {
|
||||
it('preserves pre-existing default profile when writing a named profile', {}, () => {
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credsPath,
|
||||
'[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n',
|
||||
);
|
||||
fs.writeFileSync(credsPath, '[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n');
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-west-2',
|
||||
false
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-west-2', false);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
@@ -596,12 +633,7 @@ describe('Profile Manager', {}, () => {
|
||||
'# My important comment\n[personal]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8') as string;
|
||||
|
||||
@@ -635,12 +667,7 @@ describe('Profile Manager', {}, () => {
|
||||
|
||||
fs.mkdirSync('/custom-creds', { recursive: true });
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIA', SecretAccessKey: 'secret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
||||
|
||||
expect(fs.existsSync('/custom-creds/credentials')).toBe(true);
|
||||
// Config file should be at the default path (under homedir)
|
||||
@@ -657,7 +684,7 @@ describe('Profile Manager', {}, () => {
|
||||
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
@@ -672,28 +699,20 @@ describe('Profile Manager', {}, () => {
|
||||
// - LF line endings, trailing newline
|
||||
expect(credContent).toBe(
|
||||
'[dev]\n' +
|
||||
'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' +
|
||||
'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' +
|
||||
'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n',
|
||||
);
|
||||
expect(configContent).toBe(
|
||||
'[profile dev]\n' +
|
||||
'region = us-east-1\n',
|
||||
'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' +
|
||||
'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' +
|
||||
'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n',
|
||||
);
|
||||
expect(configContent).toBe('[profile dev]\n' + 'region = us-east-1\n');
|
||||
});
|
||||
|
||||
it('golden file for multi-profile output', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' }, 'us-east-1', false);
|
||||
writeProfileFiles(
|
||||
'prod',
|
||||
{ AccessKeyId: 'AKIAPROD', SecretAccessKey: 'prodSecret', SessionToken: 'prodToken' },
|
||||
'us-west-2',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
@@ -704,21 +723,82 @@ describe('Profile Manager', {}, () => {
|
||||
|
||||
expect(credContent).toBe(
|
||||
'[dev]\n' +
|
||||
'aws_access_key_id = AKIADEV\n' +
|
||||
'aws_secret_access_key = devSecret\n' +
|
||||
'\n' +
|
||||
'[prod]\n' +
|
||||
'aws_access_key_id = AKIAPROD\n' +
|
||||
'aws_secret_access_key = prodSecret\n' +
|
||||
'aws_session_token = prodToken\n',
|
||||
'aws_access_key_id = AKIADEV\n' +
|
||||
'aws_secret_access_key = devSecret\n' +
|
||||
'\n' +
|
||||
'[prod]\n' +
|
||||
'aws_access_key_id = AKIAPROD\n' +
|
||||
'aws_secret_access_key = prodSecret\n' +
|
||||
'aws_session_token = prodToken\n',
|
||||
);
|
||||
expect(configContent).toBe(
|
||||
'[profile dev]\n' +
|
||||
'region = us-east-1\n' +
|
||||
'\n' +
|
||||
'[profile prod]\n' +
|
||||
'region = us-west-2\n',
|
||||
'[profile dev]\n' + 'region = us-east-1\n' + '\n' + '[profile prod]\n' + 'region = us-west-2\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink hardening', {}, () => {
|
||||
const credsPath = '/home/user/.aws/credentials';
|
||||
const configPath = '/home/user/.aws/config';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE = credsPath;
|
||||
process.env.AWS_CONFIG_FILE = configPath;
|
||||
});
|
||||
|
||||
it('mergeProfileSection refuses when the credentials path is a symlink and leaves the target unchanged', {}, () => {
|
||||
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
||||
fs.mkdirSync('/etc', { recursive: true });
|
||||
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
||||
fs.symlinkSync('/etc/passwd', credsPath);
|
||||
|
||||
expect(() => mergeProfileSection(credsPath, 'dev', { aws_access_key_id: 'AKIA' }, true)).toThrow(
|
||||
/Refusing .* \(.* symbolic link\)/,
|
||||
);
|
||||
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
||||
});
|
||||
|
||||
it('mergeProfileSection refuses when the config path is a symlink', {}, () => {
|
||||
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
||||
fs.mkdirSync('/etc', { recursive: true });
|
||||
fs.writeFileSync('/etc/sensitive', 'do not overwrite');
|
||||
fs.symlinkSync('/etc/sensitive', configPath);
|
||||
|
||||
expect(() => mergeProfileSection(configPath, 'profile dev', { region: 'us-east-1' }, true)).toThrow(
|
||||
/Refusing .* \(.* symbolic link\)/,
|
||||
);
|
||||
expect(fs.readFileSync('/etc/sensitive', 'utf-8')).toBe('do not overwrite');
|
||||
});
|
||||
|
||||
it('ensureAwsDirectoryExists refuses when ~/.aws is a symlink', {}, () => {
|
||||
fs.mkdirSync('/real-target', { recursive: true });
|
||||
fs.mkdirSync('/home/user', { recursive: true });
|
||||
fs.symlinkSync('/real-target', '/home/user/.aws');
|
||||
|
||||
expect(() => ensureAwsDirectoryExists(credsPath)).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
});
|
||||
|
||||
it('writeProfileFiles refuses to overwrite a pre-existing symlink at the credentials path', {}, () => {
|
||||
fs.mkdirSync('/home/user/.aws', { recursive: true });
|
||||
fs.mkdirSync('/etc', { recursive: true });
|
||||
fs.writeFileSync('/etc/passwd', 'root:x:0:0::/root:/bin/sh');
|
||||
fs.symlinkSync('/etc/passwd', credsPath);
|
||||
|
||||
expect(() =>
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', true),
|
||||
).toThrow(/Refusing .* \(.* symbolic link\)/);
|
||||
|
||||
expect(fs.lstatSync(credsPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync('/etc/passwd', 'utf-8')).toBe('root:x:0:0::/root:/bin/sh');
|
||||
});
|
||||
|
||||
it('happy path still writes both files with mode 0o600 when no symlinks are present', {}, () => {
|
||||
writeProfileFiles('dev', { AccessKeyId: 'AKIA', SecretAccessKey: 'secret' }, 'us-east-1', false);
|
||||
|
||||
expect(fs.statSync(credsPath).mode & 0o777).toBe(0o600);
|
||||
expect(fs.statSync(configPath).mode & 0o777).toBe(0o600);
|
||||
expect(fs.lstatSync(credsPath).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(configPath).isSymbolicLink()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user