Securing GitHub Actions: Identifying, mitigating, and detecting vulnerabilities in CI/CD workflows

What are Github Actions?
Before we dive into the juicy bits about misconfigurations, let’s set the stage: What exactly are GitHub Actions?
GitHub Actions are a powerhouse for developers—a CI/CD tool that automates testing, building, and deployment of applications. But that’s not all. It’s like your all-in-one Swiss Army knife for automating tasks like labelling issues in your repo. These workflows run on GitHub’s virtual machines (supporting Linux, macOS, and Windows), or you can host them on your own hardware (if you like being fancy).
When GitHub Actions goes rogue: The impact of misconfiguration
Think of GitHub Actions as your loyal helper. Now imagine if you taught it to hold your keys… but forgot to lock the safe. Misconfigurations can turn this powerful tool into a gateway for attackers.
Missteps can lead to remote code execution (RCE), exposing sensitive secrets like API keys or allowing malicious code to sneak into your repositories. For example:
- User-controlled inputs like github.event.issue.title can be manipulated to inject commands.
- Even with GitHub’s secret redaction, crafty attackers can bypass safeguards and steal sensitive information.

The stakes? Catastrophic—especially if attackers upload malicious artifacts or modify your repository in ways that could compromise your systems or users.
The anatomy of vulnerable GitHub Actions
Let’s dissect some common vulnerabilities, starting with command/code injection. This sneaky issue often arises from improper use of GitHub contexts, environment variables, or even third-party actions.
Command/code injection
In GitHub Actions, the ${{ }} syntax can be used to substitute values such as GitHub contexts, environment variables, workflow inputs and so on. But if it is used improperly, it could lead to command/code injection within the workflow. Such as when it is being used under the run operation, where it allows shell commands to be executed. As the value within ${{ }} syntax will be evaluated and substituted before the commands/codes are executed, it could lead to a command injection.
Here are some examples of a workflow that is vulnerable to command/code injection:
Example 1: Command injection via Github contexts
name: Vulnerable Workflow
on:
on:
issues:
types: [opened]
jobs:
basic_injection:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
echo "ISSUE TITLE: ${{github.event.issue.title}}"
echo "ISSUE DESCRIPTION: ${{github.event.issue.body}}"
In this workflow, there are two command injection points, which are ${{github.event.issue.title}} and ${{github.event.issue.body}} as both of these contexts are being called under run. As user inputs are used unsanitised, it allows attackers to sneak in commands like ls -la (which can obviously be substituted with something much more malicious).


As shown in the demo command injection above, the command ls -la was executed even though the value came from the user/Github context.
Example 2: Command injection via environment variables
Below is a sample workflow that is vulnerable to command injection via Github Actions’s environment variable.
name: Vulnerable Workflow
on:
issues:
types: [opened]
jobs:
env_injection:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- env:
TITLE: ${{github.event.issue.title}}
DESCRIPTION: ${{github.event.issue.title}}
run: |
echo "ISSUE TITLE: ${{ env.TITLE }}"
echo "ISSUE DESCRIPTION: ${{ env.DESCRIPTION }}"
Example 3: Code injection via actions/GitHub script
Below is a sample workflow that is vulnerable to code injection via actions/github-script.
name: Vulnerable Script Execution
on:
issues:
jobs:
code_injection:
runs-on: ubuntu-latest
steps:
- name: Demo Code Injection
env:
test_env: ${{github.event.issue.body}}
uses: actions/github-script@v6
with:
script: |
console.log("${{ env.test_env }}")


Pull request target and workflow run
Triggers like pull_request_target and workflow_run bring their own baggage. They grant elevated privileges, such as access to secrets and read/write tokens, making them ripe for abuse if improperly handled.
Pull request target
With the normal pull_request trigger, PR triggers from external forked repositories would have fewer privileges such as not being able to access workflow secrets and only having read-access tokens to the repository. On top of that, in most cases, first-time contributors usually would require approval from the repository owner before the GitHub Actions is triggered. This is done so to prevent attackers from pushing and executing malicious/untrusted code in the case where workflow is checking out to the PR’s code.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
But in the case of pull_request_target, any workflow triggered by it would be run in the context of the base branch. As the base branch is considered trusted, the workflow will be able to bypass the repository owner’s approval and also be executed with higher privileges such as access to secrets and a read/write access token to the repository.
In such cases, code execution can be achieved by leveraging existing build files such as Makefile, bash scripts, PowerShell script or build scripts within npm’s package.json (i.e. preinstall or postinstall)
Here’s an example of exploiting a vulnerable pull_request_target workflow which will be taking advantage of npm’s preinstall script functionality:
name: Demo Pull Request Target Vulnerability
on: [pull_request_target]
jobs:
demo-action:
runs-on: ubuntu-latest
env:
DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run Script
run: |
npm install
Here’s the file structure of the project, which is a simple node project.

The attacker forks the repository, makes changes to the package.json and creates a PR to trigger the workflow:

Workflow is executed and the attacker changes are being executed leading to secrets being disclosed in the GitHub Actions output. (As mentioned earlier, GitHub Actions redact secrets by default, but it can be bypassed by reversing the sequence of the secret)


Workflow run
Similar to pull_request_target, workflow_run also has higher privileges when it is triggered. This means it could access secrets that are defined within the workflow file. If the workflow_run workflow file has any value that is user-controllable, it could lead to code/command injection regardless of the first trigger, be it a pull request or submitting an issue.
In this example, the workflow_run will be triggered by a pull request, where the pull request action would be uploading an artifact (least privilege) and the workflow run trigger will be downloading the artifact to perform some actions on it (higher privilege).
One important thing to note is that the workflow_run’s workflow would still have higher privileges even if the previous workflow had lower privileges.
Pull request workflow file:
name: Demo Pull Request
on:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Save PR number
run: |
mkdir -p ./pr
echo ${{ github.event.number }} > ./pr/NR
- uses: actions/upload-artifact@v4
with:
name: 'pr-${{github.run_id}}'
path: pr/
Workflow run workflow file:
name: Demo Workflow Run
on:
workflow_run:
workflows:
- "Demo Pull Request"
types:
- completed
env:
DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
jobs:
demo-workflow-run:
runs-on: ubuntu-latest
steps:
- name: Download artifact
id: download-artifact
uses: actions/download-artifact@v4
with:
run-id: ${{ github.event.workflow_run.id }}
name: 'pr-${{ github.event.workflow_run.id }}'
path: pr
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch Artifact Information
id: artifact_info
run: |
echo "event_number=$(cat ./pr/NR)" >> $GITHUB_OUTPUT
- name: Perform Some Action
uses: actions/github-script@v6
with:
script: |
console.log("${{ steps.artifact_info.outputs.event_number }}")
The attacker tampers with the artifact that is being uploaded by modifying the Demo pull request workflow file,

Pull request trigger will upload the tampered artifact,

The workflow runs triggers, downloads the tampered artifact and processes it. As the artifact has been tampered with, code injection occurs in the workflow and the secret is leaked.


Mitigation: Making GitHub Actions safer
The good news? These vulnerabilities can be mitigated with simple yet effective strategies.
Command/code injection
Treat all user-provided data with suspicion, and declare the user-controllable value as a GitHub Actions environment variable. The above example did show that the usage of Github Actions’s environment variable could lead to command/code injection, but that is only the case if the ${{ }} syntax is used. To sanitise the user input simply use the language’s native way of accessing the environment variable.
For example, in shell, to access environment variables, call the variable as such $SomeEnvVar and for javascript, it would be with process.env.SomeEnvVar. In this case, the user input would just be treated as a string instead of a command or code.
Here’s an example of a command injection fix for the run operation:
name: Demo fix vulnerable workflow
on:
issues:
types: [opened]
jobs:
fix_injection_demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- env:
ENVTITLE: ${{github.event.issue.title}}
ENVDESCRIPTION: ${{github.event.issue.body}}
run: |
echo "ISSUE TITLE: $ENVTITLE"
echo "ISSUE DESCRIPTION: $ENVDESCRIPTION"
As the user input is sanitised, the injected command is no longer getting executed.


Here’s an example of a code injection fix for actions/github-script:
name: Demo Fix Command Injection
on:
issues:
env:
DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
jobs:
demo-fix:
runs-on: ubuntu-latest
steps:
- name: Demo Fix Injection
env:
test_env: ${{github.event.issue.body}}
uses: actions/github-script@v6
with:
script: |
console.log(process.env.test_env)
As the user input is sanitised, the injected code is no longer getting executed and the declared secret is no longer leaked.


Pull_request_target and workflow_run
As for pull_request_target and workflow_run, the mitigation for these is a bit more tricky as there are proper use cases for these triggers. With that being said, here are a few things that can be taken into consideration to mitigate any misconfiguration:
- Limit privileges:
a. Avoid using pull_request_target unless absolutely necessary instead opt for pull_request. This would ensure that external users cannot access any of the workflow secrets in the case of a command/code injection vulnerability is present.
b. Configure GitHub Action Workflow’s token with the principle of least privilege using permission - more on Github Action permission
- Be mindful of what you run:
Never checkout untrusted code with elevated permissions (i.e. pull_request_target or workflow_run)
a. ${{ github.event.pull_request.head.sha }}
b. ${{ github.event.workflow_run.head_sha }}
This allows the attacker to run arbitrary code with elevated access to the workflow file as demonstrated earlier.
Detection: Spotting vulnerable workflows
Automated tools can be your best friend here. Build a script to scan workflow files for:
Command/code injection
Anything under the run operation can be extracted by the script for further processing.
Once the script under the run operation has been extracted, it can then be searched via regex to pick up any vulnerable patterns for, i.e., anything that matches ${{ some.github.contexts }}.
To reduce false positive findings and noises, here are list of GitHub contexts to look out when looking for potential command injection within a workflow file - ref
github.event.issue.title
github.event.issue.body
github.event.pull_request.title
github.event.pull_request.body
github.event.comment.body
github.event.review.body
github.event.pages.*.page_name
github.event.commits.*.message
github.event.head_commit.message
github.event.head_commit.author.email
github.event.head_commit.author.name
github.event.commits.*.author.email
github.event.commits.*.author.name
github.event.pull_request.head.ref
github.event.pull_request.head.label
github.event.pull_request.head.repo.default_branch
github.head_ref
For detecting command injection via environment variables, the detection logic will be the same as before, with just the detection pattern of ${{ env.* }}.
The same goes for code injection via actions/github-script, with the difference that instead of a run operation, a script should be extracted.
Run operations under composite actions can also be vulnerable. As composite actions do not have a standard location, the detection script would need to scan recursively to pick up all composite actions that are called within the main workflow file.
Pull_request_target and workflow_run
As for the detection of misconfigured pull_request_target and workflow_run, if the detection is solely dependent on the usage of the trigger, this would most likely generate a lot of false positive findings as there are proper use cases for these triggers.
To reduce false positives, the detection pattern could include actions other than pull_request_target and workflow_run triggers. Examples of these additional actions could be the actions/checkout and actions/download-artifact, as an attacker could tamper with these actions if not configured properly.
Implementing an exception/ignore list for this detection would be beneficial, as the exploitability of this misconfiguration is very dependent on the workflow’s use case. For example, using workflow_run along with actions/download-artifact does not necessarily mean that it is vulnerable if the downloaded artifact can’t be tampered with.
Implementation: Staying ahead of the curve
One approach involves deploying a detection script that runs as part of a scheduled cron job. This script fetches workflow files from our GitHub repositories, scans them for any vulnerable patterns, and raises alerts if any misconfigurations or risks are detected. This proactive monitoring helps maintain the integrity and security of our CI/CD pipelines.

Additionally, implement custom GitHub Actions that flag misconfigurations in real-time and pair them with the repository ruleset to ensure every pull request adheres to security guidelines.

Conclusion
GitHub Actions can be your secret weapon for automation, but like any powerful tool, it demands respect and care. By understanding the risks, implementing mitigations, and staying vigilant, you can ensure your CI/CD pipelines remain secure.
Let’s make your workflows not just functional but fortified.