In today’s post, we look at action pinning, one of the profound mitigations against supply chain attacks in the GitHub Actions ecosystem. It turns out, though, that action pinning comes with a downside — a pitfall we call "unpinnable actions" that allows attackers to execute code in GitHub Actions workflows.
As we discussed in the previous blog post, Third-Party GitHub Actions: Effects of an Opt-Out Permission Model, the permissive nature of GitHub Actions workflows is prevalent throughout the open-source community and private projects on GitHub. Today, we expand the discussion to cover actions pinning, and a serious caution we’d advise you to keep on your radar.
Action Pinning
GitHub Actions offers a powerful way to automate your software development workflow, including running tests, linting code, deploying applications and more. When using a third-party GitHub action, it’s important to follow GitHub's recommendation to pin actions to a specific commit SHA. This practice ensures consistent use of the action’s version, helping to prevent supply chain attacks involving the introduction of malicious code into external software used by your project — in this case, a third-party action. By pinning your actions to a specific commit SHA, you can guarantee the use of a version you previously audited and approved.
“Pinning an action to a full length commit SHA is currently the only way to use an action as an immutable release. Pinning to a particular SHA helps mitigate the risk of a bad actor adding a backdoor to the action's repository, as they would need to generate a SHA-1 collision for a valid Git object payload.” — GitHub
Pinning the action to a particular SHA also serves the important recommendation of CICD-SEC-9: Improper Artifact Integrity Validation from the OWASP Top 10 CI/CD Security Risks to ensure integrity across the pipeline.
When a user pins an action to a full commit hash, the GitHub Actions pipeline downloads a snapshot of the action as a tarball containing all commits up until the pinned commit.
Attackers may attempt to compromise the third-party GitHub action — via command injection in its CI workflow, repojacking or theft of a developer’s credentials — and push malicious code to the action. Should the attacker succeed, it won’t affect any workflow that consumes the action pinned to a full commit hash, as seen in figure 1.
So pinning an action to a full commit hash protects us from this type of supply chain attack, right?
As we discover in our research, no, this assumption is wrong.
How Attackers Abuse Unpinnable Actions
Let’s review actions that, even if pinned, can introduce new code to your pipeline, allowing an attacker to execute malicious code and cause serious damage. This can occur with what we call “unpinnable actions”.
To understand the concept, let’s review the following workflow as an example.
We have a workflow named CI that uses a third-party action named pyupio/safety. The action is pinned to a full commit hash, meaning that in each run the workflow should fetch the exact same code from the action’s repository.
When we head to the action’s hosting repository, we can see that this action pulls the latest tag of a docker image named pyupio/safety-v2-beta. This image will be run and used as the workflow’s execution environment.
Docker images use a pinning concept that relies on a docker image digest. The action pulls the latest tag — but it’s mutable and subject to change. While the developer expects a pinned action to consistently execute the exact same code on the same environment, that’s not the case. The action’s code on GitHub can’t be changed, but the container image can look completely different per execution.
Attackers who manage to compromise the pyupio/safety-v2-beta container image repository can push a new malicious version of the image, overwriting the latest tag. The action will pull the altered tag, regardless of whether the action's code is pinned by the workflow. The malicious image will then be used inside the workflow, leading to code execution in the CI agent, setting the path for the attackers to compromise the repository and the production environment.
Let’s explore three types of actions — Docker container, composite and JavaScript — to see how attackers bypass action pinning.
Docker Container Actions
Docker containers can be used to run actions in a specific environment configuration, which proves useful for actions requiring a specific operating system, tool version or dependency. The docker image can be pulled from a registry, as seen in figure 4, or built on the fly by the runner, given a Dockerfile.
But the attack scenario depicted in figure 4 isn’t limited to pulling Docker images. It also works when supplying a Dockerfile to the runner, another type of unpinnable Docker action.
As you might infer from figure 5, which shows part of the Docker build process, installations of unlocked Python packages using pip install and fetching external resources via the wget command without verifying its checksum defies the expected security of actions pinning.
Composite Actions
Composite actions suit low-complexity needs by allowing writing Bash commands directly in the action’s yaml file, as well as calling other actions. If not carefully implemented, though, composite actions can too easily break the concept of action pinning. For example, see the code snippet in figure 6 showing an action definition file.
This action uses another third-party action — BrianPugh/install-micropython, using the v1.05 tag. But use of this tag doesn’t follow the requirement of pinning against a full commit hash. A workflow that calls the composite action using a commit hash, in other words, remains open to code execution should attackers compromise the BrianPugh/install-micropython action and overwrite tags in use.
An action.yaml file that includes a bash script gives us another example of an unpinnable composite action. If attackers alter the number-to-text NPM package, which is used by the action without locking its version, they can execute code in workflows using this pinned action.
JavaScript Actions
Compared to the other types of actions, JavaScript actions, or more commonly TypeScript, are a little harder to break the expected security of action pinning, given that JavaScript actions don’t have a process of packages installations in runtime. But they can still fetch outside resources at runtime, so you should consider them unpinnable actions.
In the JavaScript action snippet seen in figure 8, notice that the code downloads an external script (jQuery) without verifying its checksum. This means that a new version could overwrite the script, and the action would automatically use the new version.
Unpinnable Actions in the GitHub Ecosystem
The GitHub Actions ecosystem, mainly based on third-party actions created by various organizations and independent contributors, serves a vast number of private and public repositories running highly sensitive CI/CD workflows. Many organizations using these workflows pin their actions, likely believing this both prevents attackers from compromising these third-party actions and prevents unauthorized access to their source code or CI secrets.
Intrigued by the prevalence of this issue, we sought to determine the percentage of unpinnable actions listed in the GitHub Marketplace. First compiling a list of the 1,000 top starred actions on GitHub Marketplace, we then developed a tool to analyze the code of the actions (yaml and Dockerfiles only) using a set of rules.
We learned that 32% of the actions in the top starred list were, in fact, unpinnable. This discovery implies that if you pin actions used by your workflows, there’s a high chance the pinning doesn’t provide the protection you think it does. Attackers could still have inroads to run malicious code in your pipeline.
Our findings were significant enough to compel us to conduct an additional inquiry. This time we analyzed the top-starred public open-source projects, focusing on projects that pin their actions. Of approximately 6,000 workflows used in 2,000 projects, we discovered that 67% of the projects pinned unpinnable actions.
While we analyzed public repositories, it’s important to note that private repositories far outnumber public repositories and also use third-party actions — all of which are exposed to the same attack technique that bypasses action pinning. With private repositories, however, the stakes are high, as the malicious action could even steal the code.
What Should You Do?
Hard fact – action pinning is not the security measure we wanted it to be. While it guarantees that the action's code (stored in its hosting repository) can’t change, it doesn’t guarantee immutability of the action's dependencies and external resources. Container images, binaries, other actions — a whole pipeline dependency tree unprotected by action pinning — if compromised by attackers, will lead to malicious code executed in your workflow.
Comprehensive pinning, in other words, is challenging to achieve. Version control systems (VCS) don’t currently offer a holistic solution to protect against attackers executing malicious code in CI pipelines through compromised actions.
5 Best Practices to Protect Against Compromised Third-Party Actions
1. As with all security domains, the base of protective measures is visibility. Achieve a full continuous mapping of all actions in use, as well as the various actions and dependencies used by each action. This will effectively allow identifying all “unpinnable actions” and deciding on appropriate security measures for each.
2. While pinning an action is far from a holistic measure, it does provide protection from a direct compromise of the action’s repo. We recommend pinning all actions in use within your organization.
3. Implement strict PBAC (Pipeline-Based Access Controls) in your CI workflows by limiting permissions and access of the CI agent to significantly reduce the impact of an attack involving the successful execution of code in the CI.
4. If you still want to use an “unpinnable action”, exercise the option to fork the action's repository and lock the dependencies to create a safer, immutable version of the action.
5. If you create actions on your own, make them “pinnable”: Make sure that each dependency and external resource in use by the action is locked to a specific immutable version.
Learn More
Actions warrant diligence. Invest extra diligence when it comes to understanding and identifying the risks stemming from the GitHub Actions ecosystem, as they could significantly impact both private and public repositories, as well as the entire CI/CD pipeline.
If you haven’t discovered the Prisma Cloud advantage, take it for a free 30-day test drive.