mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-01 05:45:06 +09:00
Compare commits
264 Commits
v6.1.0
...
kellertk/audit
| Author | SHA1 | Date | |
|---|---|---|---|
| a1829d0ba1 | |||
| 4b8b5e37b4 | |||
| a05dfa82bd | |||
| 378a941623 | |||
| 82408b69eb | |||
| 1f2d3ed486 | |||
| ed5da29eb9 | |||
| 7fdbbb8968 | |||
| 3852440c21 | |||
| c16f89bdf4 | |||
| 9c362eeba7 | |||
| d5f8da8822 | |||
| 2db24970cf | |||
| 58c3b6e457 | |||
| 2630a7c30d | |||
| 9cb796df96 | |||
| 9def7f603e | |||
| f5c3a50aab | |||
| 64cf66c882 | |||
| af68a47c78 | |||
| 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,66 @@ 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, older
|
||||
repositories that have opted in, and older repositories that have been renamed
|
||||
since 15 July 2026, 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 +652,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 +725,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 +753,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 +774,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 +783,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 +800,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 +842,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 +856,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
+25176
-19120
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
# Examples
|
||||
|
||||
## [federated-setup](./federated-setup/README.md)
|
||||
|
||||
The directory contains templates for setting up the `configure-aws-credentials`
|
||||
federation between your GitHub Organization/repository and your AWS account.
|
||||
|
||||
## [cfn-deploy-example](./cfn-deploy-example/README.md)
|
||||
|
||||
Repository example uses aws-action `configure-aws-credentials` with OIDC
|
||||
federation template
|
||||
[github-actions-oidc-federation-and-role](./github-actions-oidc-federation-and-role.yml).
|
||||
Example demonstrates a repository that deploys AWS CloudFormation template using
|
||||
cfn-deploy GitHub Action.
|
||||
@@ -1,15 +0,0 @@
|
||||
name: 'compliance'
|
||||
## run ci testing on all push events
|
||||
on: [push]
|
||||
jobs:
|
||||
## Guard rule set
|
||||
sast-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: grolston/guard-action@main
|
||||
with:
|
||||
data_directory: './cloudformation/' ## change to your template directory
|
||||
rule_set: 'FedRAMP-Moderate'
|
||||
show_summary: 'all'
|
||||
output_format: 'single-line-summary'
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
env:
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
AWS_DEFAULT_OUTPUT: json
|
||||
|
||||
jobs:
|
||||
deploy-cfn:
|
||||
name: deploy
|
||||
runs-on: ubuntu-latest
|
||||
# These permissions are needed to interact with GitHub’s OIDC Token endpoint.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
## the following creates an ARN based on the values entered into github secrets
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_DEPLOY_ROLE }}
|
||||
role-session-name: myGitHubActions
|
||||
- name: Deploy EC2 Bastion
|
||||
uses: aws-actions/aws-cloudformation-github-deploy@v1.3.0
|
||||
with:
|
||||
name: myEC2bastion
|
||||
## change to path to template in your github repo
|
||||
template: cloudformation/ec2-bastion.yml
|
||||
capabilities: CAPABILITY_IAM, CAPABILITY_NAMED_IAM
|
||||
no-fail-on-empty-changeset: "1"
|
||||
## parameter set in github secrets
|
||||
parameter-overrides: "pVpc=${{ secrets.VPC_ID }},pSubnet=${{ secrets.SUBNET_ID }}"
|
||||
@@ -1,24 +0,0 @@
|
||||
# cfn-deploy example
|
||||
|
||||
Example uses aws-action `configure-aws-credentials` with OIDC federation. Prior
|
||||
to using this example project, the user needs to deploy the
|
||||
[github-actions-oidc-federation-and-role](../federated-setup/github-actions-oidc-federation-and-role.yml)
|
||||
template in the AWS account they want to deploy the CloudFormation template
|
||||
into. Specify the GitHub Organization name, repository name, and the specific
|
||||
branch you want to deploy on.
|
||||
|
||||
Within the [github/workflows](./.github/workflows/) directory there is a
|
||||
[compliance.yml](./.github/workflows/compliance.yml) and a
|
||||
[deploy.yml](./.github/workflows/deploy.yml). The deploy.yml file leverages the
|
||||
aws-action `configure-aws-credentials` and accesses GitHub Action Secrets for
|
||||
some of the variables. The compliance.yml runs static application security
|
||||
testing using cfn-guard.
|
||||
|
||||
To use the example you will need to set the following GitHub Action Secrets:
|
||||
|
||||
| Secret Key | Used With | Description |
|
||||
| --------------- | -------------------------------- | ---------------------------------------- |
|
||||
| AWS_ACCOUNT_ID | configure-aws-credentials | The AWS account ID |
|
||||
| AWS_DEPLOY_ROLE | configure-aws-credentials | The name of the IAM role |
|
||||
| VPC_ID | aws-cloudformation-github-deploy | VPC ID the EC2 Bastion is deployed to |
|
||||
| SUBNET_ID | aws-cloudformation-github-deploy | Subnet ID the EC2 Bastion is deployed to |
|
||||
@@ -1,150 +0,0 @@
|
||||
---
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: EC2 bastion for latest AWS Linux 2 EC2 deployment
|
||||
Metadata:
|
||||
AWS::CloudFormation::Interface:
|
||||
ParameterGroups:
|
||||
- Label:
|
||||
default: "EC2 Configuration"
|
||||
Parameters:
|
||||
- pTagNameValue
|
||||
- pOperatingSystem
|
||||
- pInstanceType
|
||||
- pVolumeSize
|
||||
- pEbsDeleteOnTermination
|
||||
- Label:
|
||||
default: "Network Configuration"
|
||||
Parameters:
|
||||
- pVpc
|
||||
- pSubnet
|
||||
ParameterLabels:
|
||||
pOperatingSystem:
|
||||
default: "Operating System"
|
||||
pInstanceType:
|
||||
default: "Instance Type"
|
||||
pTagNameValue:
|
||||
default: "EC2 Name"
|
||||
pVolumeSize:
|
||||
default: "Volume Size"
|
||||
pEbsDeleteOnTermination:
|
||||
default: "Delete EBS Volume on Termination"
|
||||
pSubnet:
|
||||
default: "Subnet"
|
||||
pVpc:
|
||||
default: "VPC"
|
||||
Parameters:
|
||||
pSubnet:
|
||||
Description: The subnet to launch the instance in to. It must be part of the VPC chosen above.
|
||||
Type: AWS::EC2::Subnet::Id
|
||||
pVpc:
|
||||
Description: The VPC to launch the EC2 instance in to.
|
||||
Type: AWS::EC2::VPC::Id
|
||||
pOperatingSystem:
|
||||
Type: "AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>"
|
||||
Default: "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-ebs"
|
||||
pInstanceType:
|
||||
Description: Desired Instance Size
|
||||
Type: String
|
||||
Default: t3.small
|
||||
AllowedValues:
|
||||
- t3.small
|
||||
- t3.medium
|
||||
- t3.nano
|
||||
pTagNameValue:
|
||||
Description: "Required: Enter the tag name you'd like applied to the instance. Tag Name gives the name to the EC2 instance."
|
||||
Type: String
|
||||
MinLength: 1
|
||||
Default: "myBastion"
|
||||
pVolumeSize:
|
||||
Description:
|
||||
Enter the number of GBs you want your volume to be. The minimum value
|
||||
is 8 GBs
|
||||
Type: Number
|
||||
Default: 50
|
||||
MinValue: 8
|
||||
pEbsDeleteOnTermination:
|
||||
Description: "Specify if the EBS volume should be deleted if EC2 is deleted."
|
||||
Type: String
|
||||
Default: true
|
||||
AllowedValues:
|
||||
- true
|
||||
- false
|
||||
Rules:
|
||||
SubnetInVPC:
|
||||
Assertions:
|
||||
- Assert: !EachMemberIn
|
||||
- !ValueOfAll
|
||||
- AWS::EC2::Subnet::Id
|
||||
- VpcId
|
||||
- !RefAll "AWS::EC2::VPC::Id"
|
||||
AssertDescription: All subnets must in the VPC
|
||||
Resources:
|
||||
rSecurityGroupDefault:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: !Sub "Default SG for SC Product ${pTagNameValue} "
|
||||
VpcId: !Ref pVpc
|
||||
SecurityGroupEgress:
|
||||
- Description: Outbound unrestricted traffic
|
||||
IpProtocol: "-1"
|
||||
CidrIp: 0.0.0.0/0
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: !Ref pTagNameValue
|
||||
rLinuxEc2:
|
||||
Type: AWS::EC2::Instance
|
||||
Metadata:
|
||||
guard:
|
||||
SuppressedRules:
|
||||
- 'EC2_INSTANCE_DETAILED_MONITORING_ENABLED'
|
||||
Properties:
|
||||
ImageId: !Ref pOperatingSystem
|
||||
IamInstanceProfile: !Ref rec2InstanceProfile
|
||||
Monitoring: false
|
||||
InstanceType: !Ref pInstanceType
|
||||
EbsOptimized: true
|
||||
SourceDestCheck: true
|
||||
SubnetId: !Ref pSubnet
|
||||
SecurityGroupIds:
|
||||
- !Ref rSecurityGroupDefault
|
||||
BlockDeviceMappings:
|
||||
- DeviceName: "/dev/xvda"
|
||||
Ebs:
|
||||
VolumeSize: !Ref pVolumeSize
|
||||
DeleteOnTermination: !Ref pEbsDeleteOnTermination
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: !Ref pTagNameValue
|
||||
UserData:
|
||||
Fn::Base64:
|
||||
yum update -y
|
||||
## Instance Profiles
|
||||
## EC2 IAM Roles
|
||||
rEc2Role:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: !Sub "ec2-role-${AWS::StackName}"
|
||||
AssumeRolePolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal:
|
||||
Service: [ec2.amazonaws.com]
|
||||
Action: ['sts:AssumeRole']
|
||||
Path: /
|
||||
ManagedPolicyArns:
|
||||
- !Sub 'arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore'
|
||||
- !Sub 'arn:${AWS::Partition}:iam::aws:policy/CloudWatchAgentServerPolicy'
|
||||
rec2InstanceProfile:
|
||||
Type: AWS::IAM::InstanceProfile
|
||||
Properties:
|
||||
InstanceProfileName: !Sub "ec2-profile-${AWS::StackName}"
|
||||
Path: /
|
||||
Roles:
|
||||
- !Ref rEc2Role
|
||||
Outputs:
|
||||
oLinuxEc2InstanceId:
|
||||
Description: Resource ID of the newly created EC2 instance
|
||||
Value: !Ref rLinuxEc2
|
||||
oLinuxEc2PrivateIP:
|
||||
Description: Private IP Address for EC2
|
||||
Value: !GetAtt rLinuxEc2.PrivateIp
|
||||
@@ -1,11 +0,0 @@
|
||||
# federated-setup
|
||||
|
||||
## [github-action-oidc-federation](./github-actions-oidc-federation.yml)
|
||||
|
||||
Setup of the OIDC federation between your GitHub Organization/repository and
|
||||
your AWS account.
|
||||
|
||||
## [github-actions-oidc-federation-and-role](./github-actions-oidc-federation-and-role.yml)
|
||||
|
||||
Setup of the OIDC federation between your GitHub Organization/repository and
|
||||
your AWS account along with a role that only executes on specific branch.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: Github Actions configuration - OIDC IAM IdP and associated role CI/CD
|
||||
|
||||
Parameters:
|
||||
|
||||
GitHubOrganization:
|
||||
Type: String
|
||||
Description: This is the root organization or personal account where repos are stored (Case Sensitive)
|
||||
|
||||
RepositoryName:
|
||||
Type: String
|
||||
Description: The repo(s) these roles will have access to. (Use * for all org or personal repos)
|
||||
Default: "*"
|
||||
|
||||
BranchName:
|
||||
Type: String
|
||||
Description: Name of the git branch to to trust. (Use * for all branches)
|
||||
Default: "*"
|
||||
|
||||
RoleName:
|
||||
Type: String
|
||||
Description: Name the Role
|
||||
|
||||
UseExistingProvider:
|
||||
Type: String
|
||||
Description: "Only one GitHub Provider can exists. Choose yes if one is already present in account"
|
||||
Default: "no"
|
||||
AllowedValues:
|
||||
- "yes"
|
||||
- "no"
|
||||
|
||||
Conditions:
|
||||
|
||||
CreateProvider: !Equals ["no", !Ref UseExistingProvider]
|
||||
|
||||
Resources:
|
||||
|
||||
IdpGitHubOidc:
|
||||
Type: AWS::IAM::OIDCProvider
|
||||
Condition: CreateProvider
|
||||
Properties:
|
||||
Url: https://token.actions.githubusercontent.com
|
||||
ClientIdList:
|
||||
- sts.amazonaws.com
|
||||
- !Sub https://github.com/${GitHubOrganization}/${RepositoryName}
|
||||
ThumbprintList:
|
||||
- 6938fd4d98bab03faadb97b34396831e3780aea1
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: !Sub ${RoleName}-OIDC-Provider
|
||||
|
||||
RoleGithubActions:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
RoleName: !Ref RoleName
|
||||
AssumeRolePolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action: sts:AssumeRoleWithWebIdentity
|
||||
Principal:
|
||||
Federated: !If
|
||||
- CreateProvider
|
||||
- !Ref IdpGitHubOidc
|
||||
- !Sub arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com
|
||||
Condition:
|
||||
StringLike:
|
||||
token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrganization}/${RepositoryName}:ref:refs/heads/${BranchName}
|
||||
ManagedPolicyArns:
|
||||
## edit the managed policy to give least privileges
|
||||
- !Sub arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess
|
||||
|
||||
Outputs:
|
||||
|
||||
IdpGitHubOidc:
|
||||
Condition: CreateProvider
|
||||
Description: "ARN of Github OIDC Provider"
|
||||
Value: !GetAtt IdpGitHubOidc.Arn
|
||||
|
||||
RoleGithubActionsARN:
|
||||
Description: "CICD Role for GitHub Actions"
|
||||
Value: !GetAtt RoleGithubActions.Arn
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: Github Actions configuration - OIDC IAM IdP Federation
|
||||
|
||||
Parameters:
|
||||
|
||||
GitHubOrganization:
|
||||
Type: String
|
||||
Description: This is the root organization or personal account where repos are stored (Case Sensitive)
|
||||
Default: ""
|
||||
|
||||
RepositoryName:
|
||||
Type: String
|
||||
Description: The repo(s) these roles will have access to. (Use * for all org or personal repos)
|
||||
Default: "*"
|
||||
|
||||
RoleName:
|
||||
Type: String
|
||||
Description: Name the Role
|
||||
Default: ""
|
||||
|
||||
|
||||
Resources:
|
||||
|
||||
IdpGitHubOidc:
|
||||
Type: AWS::IAM::OIDCProvider
|
||||
Properties:
|
||||
Url: https://token.actions.githubusercontent.com
|
||||
ClientIdList:
|
||||
- sts.amazonaws.com
|
||||
- !Sub https://github.com/${GitHubOrganization}/${RepositoryName}
|
||||
ThumbprintList:
|
||||
- 6938fd4d98bab03faadb97b34396831e3780aea1
|
||||
Tags:
|
||||
- Key: Name
|
||||
Value: !Sub ${RoleName}-OIDC-Provider
|
||||
|
||||
|
||||
Outputs:
|
||||
|
||||
IdpGitHubOidc:
|
||||
Description: "ARN of Github OIDC Provider"
|
||||
Value: !GetAtt IdpGitHubOidc.Arn
|
||||
Generated
+2467
-4700
File diff suppressed because it is too large
Load Diff
+19
-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,27 @@
|
||||
"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.70",
|
||||
"@biomejs/biome": "2.5.10",
|
||||
"@smithy/property-provider": "^4.5.2",
|
||||
"@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.1116.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.63",
|
||||
"@smithy/node-http-handler": "^4.11.3",
|
||||
"proxy-agent": "^8.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"aws",
|
||||
|
||||
+55
-41
@@ -1,17 +1,21 @@
|
||||
import { info } from '@actions/core';
|
||||
import { STSClient } from '@aws-sdk/client-sts';
|
||||
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import { NodeHttpHandler } from '@smithy/node-http-handler';
|
||||
import { ProxyAgent } from 'proxy-agent';
|
||||
import { errorMessage, getCallerIdentity } from './helpers';
|
||||
import { buildCustomUserAgent, errorMessage, getCallerIdentity, maskProxyCredentials } 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 +23,7 @@ export class CredentialsClient {
|
||||
public region?: string;
|
||||
private _stsClient?: STSClient;
|
||||
private readonly requestHandler?: NodeHttpHandler;
|
||||
private readonly stsEndpoint?: string;
|
||||
private roleChaining?: boolean;
|
||||
|
||||
constructor(props: CredentialsClientProps) {
|
||||
@@ -27,6 +32,7 @@ export class CredentialsClient {
|
||||
}
|
||||
if (props.proxyServer) {
|
||||
info('Configuring proxy handler for STS client');
|
||||
maskProxyCredentials(props.proxyServer);
|
||||
const proxyOptions: { httpProxy: string; httpsProxy: string; noProxy?: string } = {
|
||||
httpProxy: props.proxyServer,
|
||||
httpsProxy: props.proxyServer,
|
||||
@@ -41,67 +47,75 @@ 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() {
|
||||
const config = {} as { requestHandler?: NodeHttpHandler };
|
||||
if (this.requestHandler !== undefined) config.requestHandler = this.requestHandler;
|
||||
const client = new STSClient(config);
|
||||
return client.config.credentials();
|
||||
// Previously we constructed a new client, but that picks up the default provider chain including the endpoint.
|
||||
// Explicitly calling the default provider chain allows us to pass in the endpoint and region as well as the
|
||||
// proxy config.
|
||||
return defaultProvider({
|
||||
clientConfig: {
|
||||
...(this.region !== undefined && { region: this.region }),
|
||||
...(this.stsEndpoint !== undefined && { endpoint: this.stsEndpoint }),
|
||||
...(this.requestHandler !== undefined && { requestHandler: this.requestHandler }),
|
||||
},
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+187
-23
@@ -1,11 +1,34 @@
|
||||
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 { CredentialsClient } from './CredentialsClient';
|
||||
import type { AwsCredentialIdentity } from '@aws-sdk/types';
|
||||
import type { UserAgent } from '@smithy/types';
|
||||
|
||||
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 +150,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 +163,56 @@ 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 {
|
||||
const allowedAccountIds = expectedAccountIds?.filter((id) => id !== '') ?? [];
|
||||
if (allowedAccountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!account || !allowedAccountIds.includes(account)) {
|
||||
// Account IDs are deliberately omitted: this error reaches the job log before any mask exists.
|
||||
throw new Error('The account ID of the provided credentials does not match any of the allowed account IDs');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }),
|
||||
};
|
||||
}
|
||||
|
||||
// Registers any userinfo embedded in a proxy URL as secrets so it is masked in job logs.
|
||||
// First the literal proxy string, then any username/password components if parseable.
|
||||
// If the username/password is percent-encoded, the decoded form is also masked.
|
||||
export function maskProxyCredentials(proxyServer: string): void {
|
||||
core.setSecret(proxyServer);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(proxyServer);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (const part of [url.username, url.password]) {
|
||||
if (!part) continue;
|
||||
core.setSecret(part);
|
||||
try {
|
||||
const decoded = decodeURIComponent(part);
|
||||
if (decoded !== part) core.setSecret(decoded);
|
||||
} catch (_) {
|
||||
// malformed percent-encoding; the raw form is already masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags have a more restrictive set of acceptable characters than GitHub environment variables can.
|
||||
// This replaces anything not conforming to the tag restrictions by inverting the regular expression.
|
||||
// See the AWS documentation for constraint specifics https://docs.aws.amazon.com/STS/latest/APIReference/API_Tag.html.
|
||||
@@ -189,6 +261,7 @@ export async function retryAndBackoff<T>(
|
||||
maxRetries = 12,
|
||||
retries = 0,
|
||||
base = 50,
|
||||
label?: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
@@ -200,20 +273,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);
|
||||
await sleep(delay);
|
||||
|
||||
return await retryAndBackoff(fn, isRetryable, maxRetries, nextRetry, base, label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,19 +301,6 @@ export function isDefined<T>(i: T | undefined | null): i is T {
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
export async function areCredentialsValid(credentialsClient: CredentialsClient) {
|
||||
const client = credentialsClient.stsClient;
|
||||
try {
|
||||
const identity = await client.send(new GetCallerIdentityCommand({}));
|
||||
if (identity.Account) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like core.getBooleanInput, but respects the required option.
|
||||
*
|
||||
@@ -268,3 +329,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)`);
|
||||
}
|
||||
}
|
||||
|
||||
+105
-61
@@ -3,15 +3,17 @@ import type { AssumeRoleCommandOutput } from '@aws-sdk/client-sts';
|
||||
import { assumeRole } from './assumeRole';
|
||||
import { CredentialsClient } from './CredentialsClient';
|
||||
import {
|
||||
areCredentialsValid,
|
||||
errorMessage,
|
||||
exportAccountId,
|
||||
exportCredentials,
|
||||
exportRegion,
|
||||
getBooleanInput,
|
||||
getCallerIdentity,
|
||||
retryAndBackoff,
|
||||
toCredentialIdentity,
|
||||
translateEnvVariables,
|
||||
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,23 +132,49 @@ 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;
|
||||
|
||||
//if the user wants to attempt to use existing credentials, check if we have some already
|
||||
if (useExistingCredentials) {
|
||||
const validCredentials = await areCredentialsValid(credentialsClient);
|
||||
if (validCredentials) {
|
||||
const identity = await (async () => {
|
||||
try {
|
||||
return await getCallerIdentity(credentialsClient.stsClient);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (identity) {
|
||||
// The allowed-account-ids guardrail applies to reused credentials too.
|
||||
validateAccountId(expectedAccountIds, identity.Account);
|
||||
core.notice('Pre-existing credentials are valid. No need to generate new ones.');
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
return;
|
||||
@@ -152,13 +186,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 +208,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 +291,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);
|
||||
}
|
||||
|
||||
+13
-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.
|
||||
@@ -53,8 +53,15 @@ export function parseIni(iniData: string): Record<string, Record<string, string>
|
||||
export function stringifyIni(data: Record<string, Record<string, string>>): string {
|
||||
const sections: string[] = [];
|
||||
for (const [sectionName, sectionData] of Object.entries(data)) {
|
||||
if (/[\r\n]/.test(sectionName)) {
|
||||
throw new Error('INI section names must not contain newline characters');
|
||||
}
|
||||
const lines: string[] = [`[${sectionName}]`];
|
||||
for (const [key, value] of Object.entries(sectionData)) {
|
||||
// A newline in a key or value would inject arbitrary INI lines (e.g. credential_process).
|
||||
if (/[\r\n]/.test(key) || /[\r\n]/.test(value)) {
|
||||
throw new Error('INI keys and values must not contain newline characters');
|
||||
}
|
||||
lines.push(`${key} = ${value}`);
|
||||
}
|
||||
sections.push(lines.join('\n'));
|
||||
@@ -87,10 +94,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 +132,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 +146,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,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@aws-sdk/credential-provider-node', () => ({
|
||||
defaultProvider: vi.fn(() => async () => ({ accessKeyId: 'AKIA', secretAccessKey: 'secret' })),
|
||||
}));
|
||||
|
||||
import { defaultProvider } from '@aws-sdk/credential-provider-node';
|
||||
import { CredentialsClient } from '../src/CredentialsClient';
|
||||
|
||||
describe('CredentialsClient', {}, () => {
|
||||
it('pins ambient credential resolution to the configured region and STS endpoint', {}, async () => {
|
||||
const client = new CredentialsClient({
|
||||
region: 'eu-west-1',
|
||||
stsEndpoint: 'https://sts.example.com',
|
||||
roleChaining: false,
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to call private method
|
||||
await (client as any).loadCredentials();
|
||||
expect(defaultProvider).toHaveBeenCalledWith({
|
||||
clientConfig: expect.objectContaining({ region: 'eu-west-1', endpoint: 'https://sts.example.com' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('omits unset client config values from ambient credential resolution', {}, async () => {
|
||||
const client = new CredentialsClient({ region: 'eu-west-1', roleChaining: false });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to call private method
|
||||
await (client as any).loadCredentials();
|
||||
expect(defaultProvider).toHaveBeenLastCalledWith({ clientConfig: { region: 'eu-west-1' } });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+210
-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,182 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
|
||||
});
|
||||
|
||||
describe('maskProxyCredentials', {}, () => {
|
||||
it('masks username and password embedded in a proxy URL', {}, () => {
|
||||
helpers.maskProxyCredentials('http://user:secretpass@proxy.example.com:8080');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('user');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('secretpass');
|
||||
});
|
||||
|
||||
it('masks both encoded and decoded forms of the credentials', {}, () => {
|
||||
helpers.maskProxyCredentials('http://user:p%40ss@proxy.example.com:8080');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('p%40ss');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('p@ss');
|
||||
});
|
||||
|
||||
it('masks the whole value even without embedded credentials or when unparseable', {}, () => {
|
||||
helpers.maskProxyCredentials('http://proxy.example.com:8080');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('http://proxy.example.com:8080');
|
||||
helpers.maskProxyCredentials('not a url');
|
||||
expect(core.setSecret).toHaveBeenCalledWith('not a url');
|
||||
// no username/password parts, so exactly one mask per call
|
||||
expect(core.setSecret).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAccountId', {}, () => {
|
||||
it('enforces the allow-list even when the first element is empty', {}, () => {
|
||||
expect(() => helpers.validateAccountId(['', '999999999999'], '111111111111')).toThrow(/does not match/);
|
||||
});
|
||||
|
||||
it('passes an allowed account despite empty entries in the list', {}, () => {
|
||||
expect(() => helpers.validateAccountId(['', '111111111111'], '111111111111')).not.toThrow();
|
||||
});
|
||||
|
||||
it('skips validation only when no non-empty entries exist', {}, () => {
|
||||
expect(() => helpers.validateAccountId(undefined, '111111111111')).not.toThrow();
|
||||
expect(() => helpers.validateAccountId([], '111111111111')).not.toThrow();
|
||||
expect(() => helpers.validateAccountId([''], '111111111111')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filesystem helpers', {}, () => {
|
||||
describe('isSymlink', {}, () => {
|
||||
it('returns true for a symlink', {}, () => {
|
||||
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\)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+709
-91
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 = {
|
||||
|
||||
+231
-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);
|
||||
});
|
||||
@@ -110,6 +114,22 @@ describe('Profile Manager', {}, () => {
|
||||
const result = stringifyIni({ dev: {} });
|
||||
expect(result).toBe('[dev]\n');
|
||||
});
|
||||
|
||||
it('rejects values containing newlines', {}, () => {
|
||||
expect(() =>
|
||||
stringifyIni({ dev: { aws_session_token: 'token\n[injected]\ncredential_process = evil' } }),
|
||||
).toThrow('must not contain newline characters');
|
||||
});
|
||||
|
||||
it('rejects keys containing newlines', {}, () => {
|
||||
expect(() => stringifyIni({ dev: { 'key\ninjected': 'val' } })).toThrow('must not contain newline characters');
|
||||
});
|
||||
|
||||
it('rejects section names containing newlines', {}, () => {
|
||||
expect(() => stringifyIni({ 'dev\r\n[injected]': { key: 'val' } })).toThrow(
|
||||
'must not contain newline characters',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProfileName', {}, () => {
|
||||
@@ -196,10 +216,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 +239,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 +274,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 +310,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 +345,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 +367,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);
|
||||
@@ -365,6 +439,24 @@ describe('Profile Manager', {}, () => {
|
||||
expect(configParsed['profile dev'].region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('refuses to write credentials containing newlines instead of injecting profiles', {}, () => {
|
||||
expect(() =>
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
SessionToken: 'token\n[injected]\ncredential_process = evil-command',
|
||||
},
|
||||
'us-east-1',
|
||||
false,
|
||||
),
|
||||
).toThrow('must not contain newline characters');
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
expect(fs.existsSync(credsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses correct section naming for default profile', {}, () => {
|
||||
writeProfileFiles(
|
||||
'default',
|
||||
@@ -468,7 +560,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
),
|
||||
).toThrow('whitespace');
|
||||
});
|
||||
@@ -486,7 +578,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
expect(fs.existsSync('/custom/credentials')).toBe(true);
|
||||
@@ -501,7 +593,7 @@ describe('Profile Manager', {}, () => {
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
||||
@@ -517,12 +609,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 +627,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 +645,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 +667,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 +701,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 +718,7 @@ describe('Profile Manager', {}, () => {
|
||||
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
false,
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
@@ -672,28 +733,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 +757,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