Finding Unpinned and Unpinnable GitHub Actions
Finding Unpinned and Unpinnable GitHub Actions
share<br>share
May 19, 2026
butler<br>github<br>github-actions<br>supply-chain<br>unpinnable-actions<br>unpinned-actions
Contents
Supply Chain Attacks on GitHub
Butler to the Rescue
Install Butler & Download Workflows
Finding Unpinned Actions
Unpinned Samples
Finding Unpinnable Actions
Unpinnable Samples
Summary
Supply Chain Attacks on GitHub
I'll assume you are already aware of the compromises of tj-actions/changed-files, aquasecurity/trivy-action, and actions-cool/issues-helper.
Everything is on fire, and you have been assigned the task of "pinning all GitHub actions" across your organisation to prevent Supply Chain attacks via 3rd party actions.
Great, but where does one begin to tackle this task? We have 2 fronts we need address:
Unpinned Actions : These are actions that you call from your own workflows but reference them via a tag (@v1, @latest) or via a branch (@main) instead of a commit SHA.
Unpinnable Actions : These are actions that are called from 3rd party actions and do not reference a commit SHA. So you can pin the "parent" action but the "child" action is still using unpinned references.
Get in, I'll explain in the car.
Butler to the Rescue
Butler is a tool I wrote last year that was originally intended to be a security scanner for GitHub Actions, but as I was developing it I realised that I'd never be able to beat zizmor (if you haven't used it, you should). So I pivoted to an "insights" tool that will allow you to query your workflows from a sqlite database.
In this example we will use Butler to identify unpinned and unpinnable actions against the AWS GitHub Organisation.
Install Butler & Download Workflows
The first thing we need to do is create a GitHub API key (also known as GITHUB_TOKEN) and download all repos from AWS into a database:
# Create virtual environment<br>python3 -m venv venv<br>. venv/bin/activate
# Install requirements<br>pip3 install -r requirements.txt
# Set GITHUB_TOKEN<br>export GITHUB_TOKEN=ghp_....
# Download all AWS repos<br>python butler.py download --repo "aws" --database ./aws.db --threads 10 --all-repos --very-verbose
Once the download is complete, we need to "process" it in order to convert all workflows into something we can query:
python butler.py process --database ./aws.db --threads 10 --very-verbose
That's it, we are ready for action.
Butler already has built-in reports that you can use, but I thought that having targeted queries to identify these actions would be best.
Open aws.db with SQLiteBrowser and this is what it should look like:
Finding Unpinned Actions
To find all unpinned actions run the following query:
SELECT<br>-- Action.<br>o.name AS action_org_name,<br>r.name AS action_repo_name,<br>r.ref AS action_ref,<br>r.archive AS action_is_archived,<br>w.path AS action_path,<br>r.stars AS action_stars,<br>CONCAT(<br>'https://github.com/',<br>o.name,<br>'/',<br>r.name,<br>'/blob/',<br>r.ref_commit,<br>'/',<br>w.path<br>) AS action_url,
-- Parent workflow.<br>o2.name AS parent_org_name,<br>r2.name AS parent_repo_name,<br>r2.ref AS parent_ref,<br>r2.archive AS parent_is_archived,<br>w2.path AS parent_path,<br>CONCAT(<br>'https://github.com/',<br>o2.name,<br>'/',<br>r2.name,<br>'/blob/',<br>r2.ref_commit,<br>'/',<br>w2.path<br>) AS parent_url<br>FROM workflows w<br>JOIN repositories r ON r.id = w.repo_id<br>JOIN organisations o ON o.id = r.org_id<br>-- Where this action is being called from<br>JOIN workflow_relationships wr ON wr.child_id = w.id<br>-- Retrieve its parent workflow<br>JOIN workflows w2 ON w2.id = wr.parent_id<br>JOIN repositories r2 ON r2.id = w2.repo_id<br>JOIN organisations o2 ON o2.id = r2.org_id<br>WHERE<br>-- action = 2<br>w.type = 2<br>-- commit = 3<br>AND r.ref_type != 3<br>-- trusted orgs, including the org itself<br>AND LOWER(o.name) NOT IN('actions', 'github', 'advanced-security', 'aws')<br>-- Only where the parent workflow is the org itself.<br>AND LOWER(o2.name) IN('aws')<br>ORDER BY 1, 2, 3, 4, 5<br>Unpinned Samples
aws/aws-encryption-sdk-c/.github/workflows/clang-format.yml calls DoozyX/clang-format-lint-action@v0.17
aws/s2n-netbench/.github/workflows/ci.yml calls EmbarkStudios/cargo-deny-action@v1
aws/amazon-q-developer-cli/.github/workflows/rust.yml calls EmbarkStudios/cargo-deny-action@v2
aws/s2n-quic/.github/workflows/dependencies.yml calls EmbarkStudios/cargo-deny-action@v2
Finding Unpinnable Actions
Go find all unpinnable actions run the following query:
SELECT<br>-- Action.<br>o.name AS action_org_name,<br>r.name AS action_repo_name,<br>r.ref AS action_ref,<br>r.archive AS action_is_archived,<br>w.path AS action_path,<br>r.stars AS action_stars,<br>CONCAT(<br>'https://github.com/',<br>o.name,<br>'/',<br>r.name,<br>'/blob/',<br>r.ref_commit,<br>'/',<br>w.path<br>) AS action_url,
-- Action.<br>o2.name AS unpinnable_action_org_name,<br>r2.name AS unpinnable_action_repo_name,<br>r2.ref AS unpinnable_action_ref,<br>r2.archive AS unpinnable_action_is_archived,<br>w2.path AS unpinnable_action_path,<br>r2.stars AS unpinnable_action_stars,<br>CONCAT(<br>'https://github.com/',<br>o2.name,<br>'/',<br>r2.name,<br>'/blob/',<br>r2.ref_commit,<br>'/',<br>w2.path<br>) AS...