Tj-actions/changed-files GitHub Action Compromised – used by over 23K repos
stepsecurity.io
stepsecurity.io
The affected repo has now been taken down, so I am writing this partly from memory, but I believe the scenario is:
1. An attacker had write access to the tj-actions/changed-files repo
2. The attacker chose to spoof a Renovate commit, in fact they spoofed the most recent commit in the same repo, which came from Renovate
3. Important: this spoofing of commits wasn't done to "trick" a maintainer into accepting any PR, instead it was just to obfuscate it a little. It was an orphan commit and not on top of main or any other branch
4. As you'd expect, the commit showed up as Unverified, although if we're being realistic, most people don't look at that or enforce signed commits only (the real bot signs its commits)
5. Kind of unrelated, but the "real" Renovate Bot - just like Dependabot presumably - then started proposing PRs to update the action, like it does any other outdated dependency
6. Some people had automerging of such updates enabled, but this is not Renovate's default behavior. Even without automerging, an action like this might be able to achieve its aim only with a PR, if it's run as part of PR builds
7. This incident has reminded that many people mistakenly assume that git tags are immutable, especially if they are in semver format. Although it's rare for such tags to be changed, they are not immutable by design
I'm not sure how this could exploited by just making a PR, unless you for some reason have secrets enabled for builds by unknown contributors, which obviously would be a mistake. Usually, only builds using secrets only run on certain branches which has a known contributor approving the code before it gets there.
> people mistakenly assume that git tags are immutable
If you're distributing a library on GitHub used by many other people/projects, then you really need to setup `protected branches` and `protected tags`, where you can prevent changes somewhat.
> I'm not sure how this could exploited by just making a PR, unless you for some reason have secrets enabled for builds by unknown contributors
In this context the renovate bot would be making the PR to a repo it had been installed on, making it a trusted contributor able to trigger CI builds on its PRs.IME, this will be more "learned" than "reminded". Many many people set up pipelines to build artefacts based on tags (e.g. a common practise being "on tag with some pattern, then build artefact:$tag") and are just surprised if you call out the flaws.
It's one of many practises adopted because everyone does it but without basic awareness of the tradeoffs. Semver is another similar case of inherited practise, where surprisingly many people seem to believe that labelling software with a particular string magically translates into hard guarantees about its behaviour.
This won't help in this case though, because a legitimate bot was tricked into working with a rogue commit; a tricked bot could as well sign a tag with a legitimate key.
"Immutable tags" of course exist, they are commit hashes, but they are uninformative :(
EDIT: seems GitHub has finally noticed (or started to care); just went to test this and auto merge has been seemingly disabled sitewide. Even though the setting is enabled, no option to automerge PRs shows up.
Seems I was right to worry!
EDIT2: We just tested this on GitLab's CI since they also have an auto-merge function and it appears they've done things correctly. Auto-merge enablement is only valid for the commit for which it was enabled; new pushes disable auto-merge. Much more sensible and secure.
It’s such an obvious attack vector, I’m pretty sure I tested GitLab soon after the feature initially rolled out.
Example: https://github.com/chains-project/maven-lockfile/pull/1111/f...
This appears to be governed by the `pinGitHubActionDigests` helper configured in `renovate.json`.
Time and time again, they either get hijacked and malicious code added, or the dev themselves suddenly decides to betray everyone's trust and inject malicious code (see: Moq), or they sell out to some company that changes the license to one where you have to pay hundreds of dollars to keep using it (e.g. the recent FluentAssertions debacle), or one of those happens to any of the packages' hundreds of dependencies.
Just take a look at eslint's dependency tree: https://npmgraph.js.org/?q=eslint
Can you really say you trust all of these?
I like this comment from u/mailto_devnull (https://www.reddit.com/r/node/comments/tg451e/do_not_use_nod...):
Where do I stand on the war? I stand with Ukraine.
Where do I stand on software supply chain issues? I stand with not fucking around with the software supply chain.And if you turn on devDependencies (top right), it goes from 85 to 1263.
That sort of idea seems increasingly like what we need because reputation based systems can be gamed too easily: i.e. there's no reason an action like this ever needed network access.
But I mentioned both of those things because [IMHO] they both fell prey to the same "humanity bug": specifying permissions for anything (source code, cloud security, databases, Kubernetes, ...) is a lot of trial and error, whereas {Effect: Allow, Action: ["*:*"]} always works and so they just drop a "TODO: tighten permissions" and go on to the next Jira
I had high hopes for the AWS feature "Make me an IAM Policy based on actual CloudTrail events" but it talks a bigger game than it walks
If I see a useful extension, I want to use is on GitHub. I fork it. Sometimes I make a bookmarklet with the code instead.
I keep most extensions off until I need to use them. Then, I enable them, use them, and turn them off again. I try to even keep Mac apps to a minimum.
It also has led to some bad but fun choices, like implementing POP3 and IMAP directly in my code, neither of which worked well but taught me a lot?
Btw, here's a site where you can inspect an extension's source code before you install it: https://robwu.nl/crxviewer/
Close to what you do, I started writing my own addon to replace a couple addons whose featureset I use only partially.
For example, when I use Chromium I want 1. to customize the New Tab page, and 2. to add a keyboard shortcut to pin/unpinTab. These two features are absolutely part of extensions, but in addition to the security risk I find them heavy (I don’t need the kitchen sink, just need 2 micro-features!). And so, I have my little personal addon with zero resource usage with just these two features. It’s tiny (20 lines of code!), git-versioned, and never changes / gets pwned. When I need an extra micro-feature, it’s easy enough to add it by searching addons docs, of asking an LLM.
I'm not saying you're wrong not to trust package managers and extensions but you're life is likely full of the same thing. The majority of apps are made from 3rd party libraries which are made of 3rd party libraries, etc.... At least on phones they update constantly, and every update is a chance to install more exploits.
The same is true for any devices that get updates like a Smart TV, router, printer, etc.... I mostly trust Apple, Microsoft, and Google to check their 3rd party dependencies, (mostly), but don't trust any other company - and yet I can't worry about it. Don't update and I don't get security vulnerabilities fixed. Do update and I take the chance that this latest update has a 3rd party exploit buried in a 3rd party library.
Most developer packages have much higher permission levels because they integrate it with your code without a clear separation of boundaries. This is why attackers now like to attack GitHub Actions because if you get access to secrets you can do a lot of damage.
It’s obviously more complicated than that.
Signed public builds don’t inherently mean jack. It highly depends on the underlying trust model.
—
Malicious actor: “we want to buy your browser extension, and your signing credentials”.
Plugin author: “Well, OK”.
—
Malicious actor: hijacks npm package and signs new release with new credentials
The vast majority of dependent project authors: at best, see a “new releaser” warning from their tooling, which is far from unusual for many dependencies. ignores After all, what are they going to do?.
—
Hacker News, as usual, loves to pretend it has all the answers to life’s problems, and the issue is that nobody has listened to them.
eh, it’s not just HN.
like, there’s no single technical/material solution to something as complex and widespread as humanity’s apparent base need to “get more stuff”. which is the root cause for acting maliciously — it’s just “getting more stuff” in a way that’s harmful to others.
but that won’t stop people from claiming that they can come up with a technical solution. whether that’s politicians, tech bros, HN commentators or that guy down the pub on a thursday evening.
—
that being said, signing software is better than doing nothing… so, a better way of phrasing it from the GP would probably have been it is a partial mitigation for the problem in some cases.
We need better capabilities. E.g. when I run `fd`, `rg` or similar such tool, why should it have Internet access?
IMHO, just eliminating Internet access for all tools (e.g. in a power mode), might fix this.
The second problem is that we have merged CI and CD. The production/release tokens should ideally not be on the same system as the ones doing regular CI. More users need access to CI (especially in the public case) than CD. For example, a similar one from a few months back https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-inj...
Indeed.
One can think of a few broad capabilities that will drastically reduce the attack surface.
1. Read-only access vs read-write 2. Access to only current directory and its sub-directories 3. Configurable Internet access
Docker mostly gets it right. I wish there was an easy way to run commands under Docker.
E.g.
If I am running `fd`
1. Mount current read-only directory to Docker without Internet access (and without access to local network or other processes) 2. Run `fd` 3. Print the results 4. Destroy the container
Systemd has a lot of neat sandboxing features [1] which aren't well known but can be very useful for this. You can get pretty far using systemd-run [2] in a script like this:
#!/bin/sh
uid="$(id -u)"
gid="$(id -g)"
cwd="$(pwd -P)"
sudo systemd-run --system --pty --same-dir --wait --collect --service-type=exec \
--uid="$uid" \
--gid="$gid" \
-p "TemporaryFileSystem=/:ro /tmp" \
-p "BindReadOnlyPaths=-/bin -/sbin -/usr/bin -/usr/sbin -/lib -/lib64 -/usr/lib -/usr/lib64 -/usr/libexec" \
-p "BindPaths=$cwd" \
-p "PrivateNetwork=true" \
-p "PrivateDevices=true" \
-p "PrivateIPC=true" \
-p "RestrictNamespaces=true" \
-p "RestrictSUIDSGID=true" \
-p "CapabilityBoundingSet=" \
"$@"
Which creates a blank filesystem with no network or device access and only bind mount the specified files.Unfortunately TemporaryFileSystem require running as a system instance of the service manager rather than per-user instance, so that will generally mean running as root (hence sudo). One approach is to create a suid binary that does the same without needing sudo.
[1] https://www.freedesktop.org/software/systemd/man/latest/syst...
[2] https://www.freedesktop.org/software/systemd/man/latest/syst...
You could also use bubblewrap [3] pretty similarly, and may not need to use sudo if unprivileged user namespaces are allowed by your kernel.
#!/bin/sh
cwd="$(pwd -P)"
bwrap --new-session --die-with-parent \
--tmpfs /tmp \
--ro-bind-try /bin /bin \
--ro-bind-try /sbin /sbin \
--ro-bind-try /usr/bin /usr/bin \
--ro-bind-try /usr/sbin /usr/sbin \
--ro-bind-try /lib /lib \
--ro-bind-try /lib64 /lib64 \
--ro-bind-try /usr/lib /usr/lib \
--ro-bind-try /usr/lib64 /usr/lib64 \
--ro-bind-try /usr/libexec /usr/libexec \
--bind "$cwd" "$cwd" \
--dev-bind /dev/null /dev/null \
--dev-bind /dev/zero /dev/zero \
--dev-bind /dev/random /dev/random \
--unshare-net \
--unshare-ipc \
--cap-drop ALL \
--chdir "$cwd" \
"$@"
[3] https://github.com/containers/bubblewrapIt might also be helpful to just use --unshare-all, and then whitelist things you actually need (--share-net, etc).
And even if they do, they should be controlled in a granular manner i.e. "package org.ourapp.net.aws can only do network and it can only ping *.aws.com".
Having finer-grained security model that is enforced at a kernel level (and is non-circumventable barring rootkits) is like 20 years overdue at this point.
Every single big org is dragging their feet.
Computers are fast enough where the overhead doesn’t feel like it’s there for what I do.
For development, I think Vagrant should make a comeback as one of the first things to setup in a repo/group of repos.
I’ve used Qubes several times for a week at a time over the last few years. It’s gotten better, but they really need someone to look at the user experience of it all for it to be a compelling option.
I’m regularly questioning myself if what I’m doing is making it less secure because I don’t understand exactly everything Qubes is doing. I know how all the pieces work individually (Xen, etc).
Outside of configuration, I believe I’d have to ditch any hope of running 3D-anything with any expectation of performance. That’s simply a non-starter as someone who has written off “nation-state actor targeting me, specifically” as something I can defend against.
And lastly, I’m deeply skeptical of anything that loudly wears the Snowden-badge-of-approval as that seems to follow grifts.
My main workstation is a Mac and I’m doing this on Parallels. Would Qubes probably be more secure? Maybe. But it comes at a massive usability hit.
Sure it’s less secure than full isolation, but full isolation is a real pain.
> The pledge() system call forces the current process into a restricted-service operating mode. A few subsets are available, roughly described as computation, memory management, read-write operations on file descriptors, opening of files, networking (and notably separate, DNS resolution). In general, these modes were selected by studying the operation of many programs using libc and other such interfaces, and setting promises or execpromises.
As far as I see its purpose is mostly a mitigation/self-defence for vulnerabilities in C-based apps, so basically limiting what happens once the attacker has exploited a vulnerability. Maybe it has other uses.
It could be used defending against bugs in the Node runtime itself, as you say, but as I understand vulnerabilities in the Node runtime itself are quite rare, so more fine-grained limitations could be implemented within itself.
At the very least, I think execpromises was added a year after the documentation that you linked, so it's worth looking into.
For CI/CD using something like ArgoCD let's you avoid giving CI direct access to prod - it still needs write access to a git repo, and ideally some read access to Argo to check if deployment succeeded but it limits the surface area.
https://wiki.archlinux.org/title/Bubblewrap
You do have to assemble isolation scripts by hand though, it's pretty low level. Here is a decent comment which closely aligns to what I'm using to isolate npm/pnpm/yarn/etc, I see no need to repeat it:
Interestingly, Firejail itself uses Harden-Runner in its GitHub Actions workflows! https://github.com/search?q=repo%3Anetblue30%2Ffirejail%20ha...
I'd love to say "just use Kubernetes and run Nexus as a service inside" but unfortunately Network Policies are seriously limited [1]...
[1] https://kubernetes.io/docs/concepts/services-networking/netw...
Yeah!! We really need to auto sandbox everything by default, like mobile OSes. Or the web.
People browse the web (well, except Richard Stallman) all the time, and run tons of wildly untrusted code, many of them malicious. And apart from zero days here and there, people don't pay much attention to it, and will happily enter any random website in the same machine they also store sensitive data.
At the same time, when I open a random project from Github on VSCode, it asks whether the project is "trusted". If not, it doesn't run the majority of features like LSP server. And why not? Because the OS doesn't sandbox stuff by default. It's maddening.
To help with things that can't be done in the sandbox, e.g. DNS lookups and opening new files, it provides the libcasper library which implements them using helper processes.
Not all utilities are sandboxed, but some are and hopefully more will be.
Linux recently added Landlock [1] which seems sort of similar, although it has rulesets and doesn't seem to block everything by default, as far as I can tell from quickly skimming the docs.
What you really want is something external and easily inspectable, such as systemd per-service security rules, or flatpak sandboxing. Not sure if FreeBSD has somethingike this.
I go out of my way to advocate for removing dependencies and pushing against small dependency introductions in a large ruby codebase. Some dependencies that suck and impose all sorts of costs, from funky ass idiosyncratic behavior or absurd file sizes (looking at you any google produced ruby library, especially the protocol buffer dependent libraries) are unavoidable, but I try to keep fellow engineers honest about introducing libraries that do things like determine the underlying os or whatever and push towards them just figuring that out themselves or, at the least, taking "inspiration" from the code in those libraries and reproducing behavior.
A nice side effect of AI agents and copilots is they can sometimes write "organic" code that does the same thing as third party libraries. Whether that's ethical, I don't know, but it works for me.
It's something I've been thinking about lately because I was diving into a lot of discussion from the early 90s regarding safe execution of (what was, at the time, called) "mobile code" - code that a possibly untrustworthy client would send to have executed on a remote server.
There's actually a lot of discussion still available from w3 thankfully, even though most of the papers are filled with references to dead links from various companies and universities.
It's weirdly something that a lot of smart people seemed to have thought about at the start of the World Wide Web which just fell off. Deno's permissions are the most interesting modern implementation of some of the ideas, but I think it still falls flat a bit. There's always the problem of "click yes to accept the terms" fatigue as well, especially when working in web development. It's quite reasonable for many packages one interacts with in web development to need network access, for example, so it's easy to imagine someone just saying "yup, makes sense" when a web-related package requests network access.
Also none of this even touches on the reality of so much code which exists to brutally impact a business need (or perceived need). Try telling your boss you need a week or two to audit every one of the thousands of packages for the report generator app.
The problem here is that people don't even bother to check the source code and run it blindly.
Not that every other platform in the world isn't theoretically vulnerable to the same sort of attack, but there's some deep-rooted culture in the javascript community that makes it especially vulnerable.
The charitable interpretation is "javascript evolves so fast!". The uncharitable interpretation is "they are still figuring it out!"
Either way, I deliberately keep my javascript on the client side.
I hope the irony is not completely lost on the fine folks at semgrep that the admittedly "overkill" suggested semgrep solution is exactly the type of pattern that leads to this sort of vulnerability: that of executing arbitrary code that is modifiable completely outside of one's own control.
Was it really a recent thing?
> Just take a look at eslint's dependency tree
Npm / node has always been extra problematic though. Where's the governance / validation on these packages? It's free for all.
The problem you're describing is an economical and a social one.
Currently, companies exploit maintainers of open source projects. There are rarely projects that make it due to their popularity, like webpack, when it comes to funding...but the actual state is that everyone that webpack is based on as a dependency didn't get a single buck for it, which is unfair, don't you think?
On top of sustainable funding, we need to change our workflows to reproducible build ecosystems that can also revert independent of git repositories. GitHub has become the almost single source of code for the planet, which is insane to even bet on from a risk assessment standpoint. But it's almost impossible to maintain your own registry or mirror of code in most ecosystems due to the sheer amount of transitive dependencies.
Take go mod vendor, for example. It's great to stick your dependencies but it comes with a lot of overhead work in case something like OPs scenario happens to its supply chain. And we need to account for that in our workflows.
Don't even talk about when the company gets a lawyer who knows what a software license is.
I say sustainable because it has been sustained, is increasing in quantity and quality, and reasonably seems to be continuing.
> companies exploit maintainers of open source projects Me giving something away and others taking what I give is not exploitation. Please don’t speak for others and claim people are exploited. One of the main tenets of gnu is to prevent exploitation.
Adding SSL was not bad, don't get me wrong. It's good that it's the default now. However. At one point it was sorta risky, and then it became required.
Like when your city becomes crime ridden enough that you have to lock your car when you go into the grocery store. Yeah you probably should have been locking it the whole time. what would it have really cost? But now you have to, because if you don't your car gets jacked. And that's not a great feeling.
There is something to be said for the Go proverb "a little copying is better than a little dependency", as well. If you want a simple function from a complicated library, you can probably copy it into your own codebase.
What a nice way to put it! Thanks for the mention and thanks for making me discover https://go-proverbs.github.io/ .
> But Lewis Ardern on our team wrote a Semgrep rule to find usages of tj-actions, which you can run locally (without sending code to the cloud) via: semgrep --config r/10Uz5qo/semgrep.tj-actions-compromised.
So "remote code you download from a repo automatically and run locally has been compromised, here run this remote code you download from a repo automatically and run locally to find it"
Abnormal behavior was to trust by default.
I think library/runtime makers aren't saying "let's make an official/blessed take on this thing that a large number of users are doing" as much as they should.
Popular libraries for a given runtime/language should be funded/bought/cloned by the runtime makers (e.g. MS for .NET, IBM/Oracle for Java) more than they are now.
I know someone will inevitably mention concerns about monopolies/anti-trust/"stifling innovation" but I don't really care. Sometimes you have to standardize some things to unlock new opportunities.
I think we will see security tools shifting towards "code" as the source of truth when making safety and security decision about 3rd party packages instead of relying only on known vulnerability databases.
Take a look at vet, we are working on active code analysis of OSS packages (+ transitive dependencies) to look for malicious code: https://github.com/safedep/vet
StepSecurity Harden-Runner detected this security incident by continuously monitoring outbound network calls from GitHub Actions workflows and generating a baseline of expected behaviors. When the compromised tj-actions/changed-files Action was executed, Harden-Runner flagged it due to an unexpected endpoint appearing in the network traffic—an anomaly that deviated from the established baseline. You can checkout the project here: https://github.com/step-security/harden-runner
The security industry tolerates self-promotion only to the extent that the threat research benefits everyone.
This is especially problematic with the rise of LLMs, I think. It's the kind of common task which is annoying enough, unique enough, and important enough that I'm sure there are a ton of GitHub actions that are generated from "I need to build and deploy this project from GitHub actions to production". I know, and do, know to manually run important things in actions related to ssh, keys, etc., but not everyone does.
I've never seen anything recommending specifying a specific commit hash or anything for GitHub actions. It's always just v1, v2, etc.
https://github.com/ossf/scorecard/blob/main/docs/checks.md#p...
- uses: RafaelGSS/bad-action@e20fd1d81b3f403df56f5f06e2aa9653a6a60763 # v1.0.1
(example from https://blog.rafaelgss.dev/why-you-should-pin-actions-by-com...)I'm quite possibly wrong, since I try to avoid them as much as I can, but I mean.. wow I hope I'm not.
See https://github.com/tj-actions/changed-files/tags
All the tags point to commit `^0e58ed8` https://github.com/tj-actions/changed-files/commit/0e58ed867...
Let's have a look at a random official GH provided action:
https://github.com/actions/checkout
It lists the following snippet:
`uses: actions/checkout@v4`
Almost everyone will just copy paste this snippet and call it a day. Most people don't think twice that v4 is a movable target that can be compromised.
In case of npm/yarn deps, one would often do the same, and copy paste `yarn install foobar`, but then when installing, npm/yarn would create a lockfile and pin the version. Whereas there's no "installer" CLI for GH actions that would pin the version for you, you just copy-paste and git push.
To make things better, ideally, the owners of actions would update the workflows which release a new version of the GH action, to make it update README snippet with the sha256 of the most recent release, so that it looks like
`uses: actions/checkout@abcdef9876543210` # v4.5.6
Since GitHub doesn't promote good defaults, it's not surprising that third-party maintainers do the same.
But yes I do think using tags is problematic. I think for one, GitHub should ban re-tagging. I can't think of a good reason for a maintainer to re-publish a tag to another commit without malicious intent. Otherwise they should provide a syntax to pin to both a tag and a commit, something like this:
`uses: actions/checkout@v4.5.6@abcdef9876543210`
The action should only work if both conditions are satisfied. This way you can still gain semantics version info (so things like dependabot can work to notify an update) but the commit is still pinned.
---
I do have to say though, these are all just band-aids on top of the actual issue. If you are actually using a dependency that is compromised, someone is going to get screwed. Are you really going to read through the commit and the source code to scan for suspicious stuff? I guess if someone else got screwed before you did they may report it, but it's still fundamentally an issue here. The simple answer is "don't use untrustworthy repositories" but that is hard to guarantee. Only real solution is to use as few dependencies as possible.
- uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
or - uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff # v2.18.0
It's a bit more manual work, but lepiej dmuchać na zimne (lit. it is better to blow on something cold), as the Polish proverb says.after this incident, I started pinning all my github workflows with hashes, like other folks here I guess :D But I quickly got tired of doing it manually so I put together this [0] quick and dirty script to handle it for me. It just updates all workflow files in a repo and can be also used as a pre-commit hook to catch any unpinned steps in the future. It’s nothing fancy (leveraging ls-remote), but it’s saved me some time, so I figured I’d share in case it helps someone else :)
[0] https://github.com/brokenpip3/pre-commit-hooks?tab=readme-ov...
Allowing bots to do that is going to make you vulnerable to such attacks anyway.
[1] https://github.com/brokenpip3/pre-commit-hooks/blob/f01df657...
-uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 +uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
and this PR gets run with privileges since it's from a user with write permissions.
Now, of course, if there are literally hundreds of dependencies to update every week, then a human isn't really going to go through each and make sure they look good, so that person just becomes a rubber-stamper, which doesn't help the situation. At that point the team should probably seriously evaluate if their tech stack is just utterly broken if they have that many dependencies.
And if you're new, and the repo aptly named, you may not realize that the action is just some random repo
I think it’s a failure of GitHub Actions that these third party actions are so widespread. If you search “GitHub actions how to ssh” the first result should be a page in the official documentation, instead you’ll find tens of examples using third party actions.
Right? My mental model of CI has always been "an automated sequence of commands in a well-defined environment". More or less an orchestrated series of bash scripts with extra sugar for reproducibility and parallelism.
Turning these into a web of vendor-locked, black-box "Actions" that someone else controls ... I dunno, it feels like a very pale imitation of the actual value of CI
I always fork my actions or at least use a commit hash.
Go also uses tags for module versioning, and while go.mod or package-lock.json stop this attack from reaching existing consumers, allowing remapping of all versions to the compromised one still expands the impact surface a lot. GitHub should offer a “immutable tags” setting for repos like these.
https://play.clickhouse.com/play?user=play#c2VsZWN0ICogZnJvb...
Actions taken by the threat actor at the time can be seen here:
https://play.clickhouse.com/play?user=play#c2VsZWN0ICogZnJvb...
The attacker was trying to compromise agentkit and found changed-files used in the repo so looked around. Found that it was using a bot with a PAT to release.
Totally possible the bot account had a weak password, and the maintainer said it didn't have 2FA.
They got the release bot PAT so they tried possibly quite an obvious vector that. They didn't need anything sophisticated or to exfil the credentials because agentkit is public.
It just so happened that it was detected before agentkit updated dependencies.
It's possible that with if thye had checked the dependabot config they could've timed it a bit better so that it's picked up in agentkit before being detected.
edit: Although, I don't think PATs are visible after they're generated?
https://play.clickhouse.com/play?user=play#c2VsZWN0ICogZnJvb...
eg. Target their NPM and PYPI tokens, so they can push compromised packages.
Semver notation rather than branches or tags is a great solution to this problem. Specify the version that want, let the package manager resolve it, and then periodically update all of your packages. It would also improve build stability.
uses: actions/checkout@v4
… you can use … uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683So you need to check the action.yml itself to see if it has a sha256 pinned (in the case it uses Docker).
In other words: you specify version 44, the attacker creates 44.1, you're still hosed.
All the tags point to commit `^0e58ed8` https://github.com/tj-actions/changed-files/commit/0e58ed867...
There is some latent concern that most git installations use SHA-1 hashes, as opposed to SHA-256. [0]
Also the trick of creating a branch that happens to be named the same as a revision, which then takes precedence for certain commands.
TIL; yikes! (and thanks)
[0]https://git-scm.com/book/ms/v2/Git-Tools-Signing-Your-Work
your build should always use hashes and not version tags of GHA's
Use a seperate system for deployments. That system must be hygienic.
This isn't foolproof but would make secrets dumping not too useful. Obviously an attack could still inject crap into your artefact. But you have more time and they need to target you. A general purpose exploit probably won't hurt as much.
> After some cleanup the changed-files (https://github.com/tj-actions/changed-files) action seems to be more work to remove. It would be awesome if it could be added to the allowlist
> Done. Allowed all versions of this action. Should I pin it to one version in the allowlist (won't be convenient if renovate updates this dependency)?
- uses: actions/checkout@v4
Github does advise people to harden their actions by referring to git commit hashes [2] but Github currently only supports SHA-1 as hashing algorithm. Creating collisions with this hashing algo will be more and more affordable and I'm afraid that we will see attacks using the hash collisions during my lifetime.
I wish that they will add support for SHA-256 soon and wrote product feedback regarding it here: https://github.com/orgs/community/discussions/154056
If this resonates with you please go and give it a thumbs up :)
[1]: https://github.com/actions/checkout?tab=readme-ov-file#usage
[2]: https://docs.github.com/en/actions/security-for-github-actio...
https://github.blog/news-insights/company-news/sha-1-collisi...
Is anyone aware of a git hook I could use to analyse my .github/workflows/*.yml files and replace git tags like "v4" with the current git commit hashes?
I think this would make it much safer to use 3rd party GitHub Actions.
Anyway here's the gist of a solution without any of the necessary checking that the files actually exist etc.
#!/bin/sh
for file in .github/workflows/*.yml; do
grep -E "uses:[[:space:]]+[A-Za-z0-9._-]+/[A-Za-z0-9._-]+@v[0-9]+" "$file" | while read -r line; do
repo=$(echo "$line" | sed -E 's/.*uses:[[:space:]]+([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)@v[0-9]+.*/\1/')
tag=$(echo "$line" | sed -E 's/.*@((v[0-9]+)).*/\1/')
commit_hash=$(git ls-remote "https://github.com/$repo.git" "refs/tags/$tag" | awk '{print $1}')
[ -n "$commit_hash" ] && sed -i.bak -E "s|(uses:[[:space:]]+$repo@)$tag|\1$commit_hash|g" "$file" && git add "$file" && rm -f "$file.bak"
done
done
exit 0$ git ls-remote "https://github.com/$repo.git" "refs/tags/$tag"
Even though the grep and sed are not very readable this was very useful way to avoid yet another tool!
I can put your fears on that account to rest. At current trajectory, that's not gonna happen.
While a collision has been successfully produced, that's a very far milestone away from creating a specific collision with a payload you actually want to deliver with reasonable size so any sanity check such as a multi GB file size wouldnt "accidentally" detect it through timeouts in CI or similar.
This is so far beyond our current technological capabilities and Moore's law hasn't been active for over a decade now. Sure, we've had astounding success in the GPU space, but that's still not even remotely close to the previous trajectory while on Moore's Law.
> This attack appears to have been conducted from a PAT token linked to @tj-actions-bot account to which "GitHub is not able to determine how this PAT was compromised."
> Account Security Enhancements
> * The password for the tj-actions-bot account has been updated.
> * Authentication has been upgraded to use a passkey for enhanced security.
> * The tj-actions-bot account role has been updated to ensure it has only the minimum necessary permissions.
> * GitHub proactively revoked the compromised Personal Access Token (PAT) and flagged the organization to prevent further exploitation.
https://github.com/tj-actions/changed-files/issues/2464#issu...
First of all, clearly Github can't answer for the developer how their bot's token was compromised, that's something the developer needs to find out. Instead they are repeating this statement like it's out of their hands.
But more concerningly, I don't believe the explanation is supported by the Github history which says the compromised commit was "authored" by Renovate and "pushed" by @jackton1. It's obvious how the first part was spoofed, but the second part is concerning as it indicates the @jackton1 account was compromised not @tj-actions-bot. If I'm missing something please let me know.
He probably just re-pushed the bad commit while trying to figure out how to fix this.
I find it very plausible that the bot token was compromised, not his user account token, as the attack was simply to push over the tags (which is something the automation bot would have access to do, as tag management is one of its functions)
1. tj-actions-bot PAT spoofs renovatebot commit with malicious code - probably by creating a new unprotected branch, pushing to it spoofing the renovatebot user, then deleting the branch, but we really don't know.
2. Attacker uses PAT to also update release tags, pointing them to the malicious commit, again spoofing renovatebot
3. jackton1 tries to restore older branch, and therefore pushes the commit again. The original commit wouldn't be referenced as pushed in any pull requests
This kind of auto dependency bump bots are more trouble than their worth. If your app works today, bumping random deps won’t make it work better in any meaningful sense in 95% of cases. With such a small upside, the downside of introducing larger attack surfaces, subtle breakages (despite semver), major breakages, and in the worst cases, compromises (whether it’s a compromised dep, or fake bot commits that people are trained to ignore) just completely outweighs the upside. You’re on the fast lane to compromises by using this kind of crap.
People should really learn from Go’s minimum version selection strategy.
I tend to stick to the official GitHub ones (actions/setup-python etc) plus the https://github.com/pypa/gh-action-pypi-publish one because I trust the maintainers to have good security habits.
If so, why did they use obfustucated code? Seems innocuous enough to load env into environment vars, and then later to dump all env vars as part of some debug routine. Eg. 'MYSQL env var not set, mysql integration will be unavailable. Current environment vars: ${dumpenv}'
The malicious commit only landed at 09:57 PDT today (March 14) in one specific action (out of a number that is quite popular). Maybe they were planning on coming back and doing proper exfil?
1. spoofed an account whose PRs were auto-merged (renovate[bot]) 2. found that `index.js` was marked as binary, and knew that GitHub is "helpful" (for the exploit), and hides diffs in the PR for that file by default 3. shoved the chunk of base64 wayyyy down the commit, so the maintainer had review fatigue by the time they scrolled. Having "memdump.py" in the commit in plaintext would certainly highlight the exploit more than the b64 string.
1. There are no deleted PRs or Issues on the repo (2461..2463 are all valid refs)
2. A legitimate `Renovate[Bot]` dep bump would have filed a PR. Last such PR was 5 days ago, and is presumably not the source for this. (I haven't gone through every dep change, but doesn't look like it).
3. That leaves us with the 0e58ed867 commit, which has to be a spoofed commit, since it doesn't belong to a branch and we don't have a corresponding PR(1). A complete takeover of the repo can result in a hanging commit (by deleting the renovate bump branch), but there must be a hanging PR-ref around. Since there isn't one:
4. All of the above points to a compromised account that has write access to the repo.
There is also the https://github.com/tj-actions-bot account, but unclear if it has write access.
Edit: gurchik's guess at https://github.com/tj-actions/changed-files/issues/2463#issu... seems more likely:
> 1. Fork the repository > > 2. Push compromised code to the fork > > 3. Update the tags in the parent repository to point to the SHA of the fork
I don't think that's possible.
Forks are a GitHub UI construct.
There would be two .git dirs so for all intents and purposes they're two repos that don't know about each other.
Locally you can't refer to a commit that's in a different dir...
a) you'd need write permissions for the original remote
b) even if you did, that'd push the commit to the original repo with the tags
At the moment the convention is to only specify the tag, which is not only a security issue as we see here, but may also cause workflows to break if an action author updates the action.
This all depends on your threat model and risk tolerance, it's not so much a GitHub problem. There will always be bad code that exists, especially on the largest open source code hosting platform. You defend against it because that's more realistic than trying to eradicate it.
Again, it all comes down to your risk tolerance. There's a certain level of trust built into these systems.
commit hashes are immutable, and your own commit history can serve as the lock file.
but if you're targeting a commit hash directly, it's already locked. Lock files are for mapping a version range to a real life version number. Lock files are useless if you pin the exact version for everything.
Another solution would be to implement immutable tags in git itself, but git upstream has so far been hostile to the whole concept of immutability in any part of git.
Feels like the same kind of problem as SQL injection, where everybody kinda knows about it and some people are actively aware and there are standard ways to avoid it but it still happens all the time anyway.
Might also be a good time to mention I'm really not a fan of YAML.
1] We took the public mirror from: https://code.forgejo.org/tj-actions/changed-files/src/tag/v4...
2] Undid the malicious code change: https://code.forgejo.org/tj-actions/changed-files/commit/0e5... - You can see the change here: https://github.com/trmlabs/changed-files/commit/8567847ee196...
3] Published under a v1 tag (since we can't vet historical releases and changes and didn't want folks to get confused)
If you want to contribute or report an issue, file a GH Issue or ping us at security@trmlabs.com
https://github.com/tj-actions/changed-files/tags?after=v35.9...
Most folks around the world signed off. B-squad probably left cleaning up remaining tasks or just fucking around with co-workers and pondering the weekend. Most GH actions run on a schedule (ie, backups of db, connecting to blob storage services).
Attacker(s) likely to extract plenty of secrets and exfil data before the alarms get triggered (if any) at companies.
The next data dumps are going to be wild.
I usually fork (or create my own) actions, as I do not trust the whole chain on GitHub. The marketplace does no enforcement. It is really based on trust you have in the 3rd-party... and I do not have this; as many actions have side-effects, or only operate on a specific runner OS, etc.
1. The maintainers could have used PRevent to immediately alert and block any PR containing malicious code, or easily configured it for detection in case of a direct push: https://github.com/apiiro/PRevent
2. Users could have used our malicious code detection ruleset to immediately detect and block it when scanning updates in all relevant CI/CD stages: https://github.com/apiiro/malicious-code-ruleset
3. For a better understanding of the detection, the malicious code falls precisely into the patterns presented in our research: https://apiiro.com/blog/guard-your-codebase-practical-steps-...
There is no way this would’ve prevented anything.
Add behavioral detection, and you get a strong layer of defense, even if attackers know about it. You still want defense in depth as always, of course.
Got this working with claude, would love if someone with more knowledge had thoughts:
```bash # Extract branch name from GITHUB_REF or GITHUB_HEAD_REF echo "current_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT # Determine if this is the default branch if [[ "${GITHUB_REF#refs/heads/}" == "${GITHUB_REF_NAME}" && "${GITHUB_REF_NAME}" == "${GITHUB_BASE_REF:-${GITHUB_REF_NAME}}" ]]; then echo "is_default=true" >> $GITHUB_OUTPUT else echo "is_default=false" >> $GITHUB_OUTPUT fi # Set default branch name echo "default_branch=${GITHUB_BASE_REF:-${GITHUB_REF_NAME}}" >> $GITHUB_OUTPUT ```
Definitely going through and pinning all my 3rd party actions to specific commits e.g. party/package@<commitsha>.
However, I think the GitHub SBOM features include GitHub Actions as dependencies, but that is merely a side-effect of their Dependabot heritage.
To support you in understanding what happened and recovering swiftly, we’re hosting an Office Hour:
Date: March 17, 2025 Time: 10:00 AM Pacific Time (PT) Add to your calendar: https://www.addevent.com/event/Tf25207322
Fortunately don’t have to deal with that shit anymore
Otherwise, if you continue to use it and it will run anytime there has been a push. Potentially on any branch, not just `main`! Depending on your GH config.
Unless you've blocked `tj-actions/changed-files` you're banking on the bad actor not coming back tonight and making malicious commit that exfils those secrets to pastebin.com.
You can whitelist
- all actions from a specific org (e.g. actions/*)
- a specific action (e.g. actions/setup-go)
- a specific version of a specific action (e.g. actions/setup-go@commit-sha)
Any workflow attempting to use actions outside of the whitelist will simply fail to start up.
- Create a trusted packages program, and mark trusted packages with a prominent badge. Package authors can apply to join the program, which will involve a review of their package and any subsequent updates. Ensure trusted packages can only depend on other trusted packages.
- Implement a capabilities model for package managers. I hear Deno is better in that respect.
- Have the package manager back-end use AI to continually review the packages. If anything suspicious is found, flag it and investigate manually.
- Require all packages to be name-spaced
github actions are name-spaced and that didn't help anything here...
We strongly advise replacing all instances of tj-actions/changed-files in your workflows with our secure alternative: https://github.com/step-security/changed-files
[1]: https://github.com/step-security/paths-filter [2]: https://github.com/step-security/paths-filter/blob/b251c10d0...
Thanks again for your timely detection and reporting!
1. You fix what version you're using to a fixed, immutable package. You receive no updates, no bug fixes, no security patches.
2. You follow a pointer to something like a API-compatible version, "latest" (#yolo) or ^5.0.0. You get bug fixes, security patches, but someone can push malicious updates.
Security types, IME, invariably want both: fix that package to a hash, so that we can't have a take over attack. But also we need to stay on top of updates, because we don't want to find out we have a decades old struct4j CVE buried in our codebase just waiting to be exploited.
So to accomplish "both", then we get into schemes like "fix the hashes … but we'll have a bot¹ update our dependency tree automatically". So like, #2, with more steps. Is anyone actually vetting that that update hash isn't going to compromise stuff? Hell no, no company is hiring that level of engineers; I'm lucky to have decent staffing for our primary concerns, reading the code in the dependency tree is out of the question.
And I'm sure in the coming days, security minded people will stampede in the general direction of #1. Stuff'll get fixed to hash, and stuff'll stop getting security patches.
IDK what the answer is, these seem pretty like fundamentally opposed forces of nature. The staffing problems aren't a technical problem, that's a capitalism problem, mostly in that there is very little to no penalty for a breach, so why would anyone hire the eng required to ensure the software works. There was hardly regulation in 2024, and any fines I did see regulatory bodies award are pittances, without fail. And, what regulation there was is now being actively dismantled.
There is some discussion of signed packages in this thread, and that's a helpful idea, I think, though I don't think it completely eliminates the problem: if the signing key is compromised, we're back to square one. The lay eng struggles with PKI.
¹While there is a bot of such nature (the renovate bot) somewhat tied up in this particular instance, I wouldn't over-focus on that bot, specifically; renovate, in particular, is not that relevant to the point I'm trying to make.
Maybe GitHub should have some kind of security setting a repo owner can make that locks-down things like old tags so after a certain time they can't be changed.
I’m getting to the point where I feel that library use at all should be frowned upon, unless it is your own library, with obvious exceptions for the most widely used things like encryption and authentication. None of these things are particularly difficult, people just don’t want to do them “oh noes my velocity”
People write the trusted, secure code, and people retire from that work, and new people need to come in and do the work. Inexperienced people are going to be writing code no matter what.
So, you are either saying that no one should write new libraries because a major security vulnerability could be introduced by their hands, or you are saying that all libraries should be written by hands which will not introduce a major security vulnerability, and neither of those is at all feasible.
What I'm saying is if you imagine a world where there is so much time to be wasted rewriting the same library a thousand times, you could try to imagine spending a small share of that time hardening the supply chain
If enough of these supply chain attacks keep happening, I’m going to become more and more of a hardliner about this. If we believe that we’re getting better as developers over time, on average, then surely this won’t be a problem in most cases.
Also, at no point have I been talking about anything like OpenSSL, or oauth libraries or anything that EVERYONE uses and needs. That doesn’t make sense. But a GitHub action that runs “git status”? I have a hard time telling someone to use any library for that, and that’s what the action this post is about does.
Also due to here being usually more than one distro, more people will look at the code & can spot the usptream getting rogue or getting compromised.
Yes now you have to trust the maintainer but that's sometimes easier.
Is there anyone here from GitHub that can help get this fixed?
This gist is pretty much the exact code, from the base64 encoded stuff. Looks like who ever put this in at least neafed the shell script.
https://github.com/tj-actions/changed-files?tab=readme-ov-fi...
I believe it's everything since around 10pm ET last night. I would consider any runs in the past 24 hours to be suspect.
It should be easy to do with thr Github CLI tool and some bash scripting.
Not sure how easy it'll be to parse the logs to look for a base64 string but it shouldn't be that complicated either.
So to avoid that you'd have to pin your GitHub Action to specific commits as outlined in this SO post: https://stackoverflow.com/a/78905195
This required compromising the entire repository, yes? It can't be explained as the maintainer being tricked into merging something malicious?
The [malicious commit is masquerading as a commit from Renovate](https://github.com/tj-actions/changed-files/commit/0e58ed867...)((https://github.com/apps/renovate) but it's not a `verified` commit (and so it's trivial for a bad actor to masquerade as them).
https://stackoverflow.com/questions/67609381/why-do-all-my-g...
Actions is a paid service but Microsoft probably replaced all the security teams with AI.
https://github.com/actions-marketplace-validations/tj-action...
This malicious code isn't hard to recognise... Surely someone can run an LLM over all code in GitHub and just ask it 'does this code looks like it's blatantly trying to hide some malicious functionality'?
Then review the output and you'll probably discover far more cases of this sort of thing.
The examples from the repo itself aren't helping to explain.
Why would anyone use this whole convoluted nodejs thing when `git diff-tree` exists?
I'm struggling to see a scenario where this isn't just part of some deliberately over complicated rube Goldberg setup.
Shitload of cardboard cto are pushing for "modern practice" to use whatever new version of whatever random dependency downloaded straight from internet.
Some persons asked why I don't like Ruff or UV for Python for example? You start a new job, first thing you have to do after installing a serious and safe Linux distribution like debian:
Curl whateverwebsite.com/ruff/download/latest | bash --blindly-execute --like-an-idiot
-> retrieving-random-dependancy1.tgz
-> executing-random-code...
And I don't speak about the current trend with "pre-commit" where a lot of persons are ok to have automatic downloads and execution on dev machines and ci, at each commit, of hundred of really totally random plugins from random places.
But this is a cto enforced decision to have this pre-commit for software quality...
And regarding pre-commit: It usually relies on external code, but it is not a requirement. Pre-commit can also be used to just run arbitrary commands and scripts defined within the pre-commit config using the values "system" or "script" for the "language" key.
https://docs.github.com/en/actions/security-for-github-actio...