Recently, there was another security incident with GitHub Actions. This time, an attacker managed to modify the tj-actions/changed-files
action. After the change, the action printed secrets to the logs which the attacker (and anyone else) could then scrape. More specifically, not only the most recent version, but "most versions of tj-actions/changed-files
" were affected. For example,
- uses: tj-actions/changed-files@46
was affected because under the hood this pointed to tj-actions/[email protected]
, which the attacker modified (see the warning in the 46.0.1 release notes).
However, even users who explicitly pinned the version to an older version were affected. For example,
- uses: tj-actions/[email protected]
was also modified by the attacker (see the warning in the 46.0.0 release notes). These incidents are not new, it was discussed in 2023 and I learned about a mitigation in 2024 thanks to Sascha Mann.
The mitigation is to pin the version to an explicit commit hash. After the attack, changed-files
has now updated the README and asks users to do this:
- uses: tj-actions/changed-files@6cb76d07bee4c9772c6882c06c37837bf82a04d3 # v46
Pinning is a lot safer, but I personally dislike this approach since it doesn't specify the version. That's why it is very common to see the version number specified in the comment, as is done here. The problem with this approach is that the comment can now become out of sync with the actual version.
Another problem is that even the hash does not guarantee that the code executed by the action is the same. In theory, the action itself could pull code from another location and run that instead. On the one hand, this might sound a bit far fetched because it's in general problematic if an action pulls code from another location during runtime. On the other hand, if the action uses lots of dependencies, then it is hard to verify that none of the dependencies do this.
In an attempt to mitigate these problems, I wrote a tool is inspired by the approach typically taken by build systems. In these systems, the maintainers allow binaries from other places to be included, but only if they specify a SHA-256 hash. For example, in NixOS the Kyocera printer driver is downloaded from their website, but the maintainers have verified the SHA-256 hash.
src = fetchzip {
url = "https://usa.kyoceradocumentsolutions.com/content/dam/kdc/kdag/downloads/technical/executables/drivers/kyoceradocumentsolutions/us/en/Kyocera_Linux_PPD_Ver_${version}.tar.gz";
sha256 = "11znnlkfssakml7w80gxlz1k59f3nvhph91fkzzadnm9i7a8yjal";
};
For people unfamiliar with SHA-256 hashes, they are a way to verify that the file you download is exactly the same as the one the maintainers intended. SHA-256 is a cryptographic hash function that takes a file as input and produces a fixed-length hash. If the hash of the downloaded file matches the expected hash provided by the maintainers, we can be highly confident that the file has not been tampered with. This is because SHA-256 is currently considered collision-resistant, making it computationally infeasible for an attacker to modify the file in a way that results in the same hash.
This is the approach I took for my tool called "just an installer": jas
. It is primarly meant to be used with GitHub Actions as a reliable way to install binary dependencies.
For example, to install typos
, you can now write the following:
jobs:
typos:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- run: cargo install --debug [email protected]
- run: >
jas install
--gh crate-ci/[email protected]
--sha f683c2abeaff70379df7176110100e18150ecd17a4b9785c32908aca11929993
--gh-token ${{ secrets.GITHUB_TOKEN }}
- run: typos .
This will install jas
via cargo install
and then use it to download the typos
binary from the GitHub release and install it.
There are a few things to note here. You might notice that the cargo install --debug [email protected]
does not specify a hash. This is indeed not ideal yet, although it is a much lower risk than depending on GitHub Actions. You can verify the published code by running
curl -L 'https://crates.io/api/v1/crates/jas/0.2.0/download' | tar -zxf -
this includes the source code as well as a Cargo.lock
file with checksums for the dependencies. Unlike GitHub Releases, crates.io does not support modifying files after a release. Furthermore, in the jas repository, each night an audit is run to check for security vulnerabilities in the dependencies. Also, most of the dependencies that jas uses are also used inside Rust's bootstrapping phase so they should have a high security standard. The other dependencies are clap
, flate2
, hex
, and ureq
. These are well known crates so hopefully issues are caught early. Of course, if you have any suggestions for improvements, please let me know. In the long run, I hope to find an even better way to install the jas
binary. (Maybe distribute via Debian packages?)
You might have also noticed that I added the --debug
flag to the cargo install
command. This is to reduce compilation time.
Finally, I added the --gh-token
flag to pass the GITHUB_TOKEN
to the jas
binary. This is to avoid rate limiting when requesting which assets are available on a GitHub release. Locally, it should not be needed because you can make 60 requests per hour per IP address. It is also possible to avoid having to pass in the token by manually specifying the URL:
jas install \
--url https://github.com/crate-ci/typos/releases/download/v1.31.1/typos-v1.31.1-x86_64-unknown-linux-musl.tar.gz \
--sha f683c2abeaff70379df7176110100e18150ecd17a4b9785c32908aca11929993
If you now wonder how normal GitHub Actions avoid the problem of being rate limited, the answer is that they receive the GITHUB_TOKEN
by default. From the docs:
An action can access the
GITHUB_TOKEN
through thegithub.token
context even if the workflow does not explicitly pass theGITHUB_TOKEN
to the action. As a good security practice, you should always make sure that actions only have the minimum access they require by limiting the permissions granted to theGITHUB_TOKEN
.
This is for example why
permissions:
contents: write
jobs:
deploy:
steps:
- uses: JamesIves/github-pages-deploy-action@v4
can publish to GitHub Pages without needing to pass the GITHUB_TOKEN
to the action.
Finally something about the running time. In practice this tool takes about 30 seconds to compile and then can install binaries in a matter of seconds. For instance, running
- run: sudo apt-get install -y ffmpeg
takes about 25 seconds. With jas,
jas install
--url https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-6.0.1-amd64-static.tar.xz
--sha 28268bf402f1083833ea269331587f60a242848880073be8016501d864bd07a5
takes about 10 seconds (thanks to John Van Sickle for hosting the static binaries). This approach has the additional benefit that it is very clear which version of ffmpeg is being used.
My hope is that this tool turns into a more reliable way to install binaries inside GitHub Actions. I'm currently using this tool in my workflows and hope it is useful for you too. If you have any feedback or suggestions, please let me know in the repository.