On March 14, 2025, every version tag of the widely used tj-actions/changed-files GitHub Action started pointing at malicious code. The action dumped CI runner memory straight into public workflow logs, exposing PATs, npm tokens, AWS keys, and RSA keys across more than 23,000 repositories in a single weekend. CISA issued an advisory the same week; researchers at Wiz, StepSecurity, and Unit42 traced the root cause upstream to a prior compromise of reviewdog/action-setup@v1, tracked separately as CVE-2025-30154. Six months later, a different failure mode hit npm: the Shai-Hulud worm, first observed September 15, 2025, compromised 500+ packages whose post-install scripts harvested developer credentials, exfiltrated them to attacker-controlled GitHub repos, and then auto-published new malicious versions using any npm publish token it found lying in the environment — the first genuinely self-propagating npm attack. A second wave in November 2025 hit 796 more packages with over 20 million combined weekly downloads. Both incidents share a structural lesson: by the time you find out, the compromise has already propagated. Detection speed matters less than what you do in the first hour after confirmation. This is that playbook.
What should you revoke first, and why does order matter?
Revoke anything the compromised code could have read or used, starting with whatever had the widest blast radius, not whatever's easiest to rotate. In tj-actions, the exposed material was CI runner memory dumped to logs — meaning any secret available to that workflow run was compromised, not just one credential. Unit42's and Wiz's post-incident writeups both stress treating every secret referenced in the affected workflow as burned, not just the one the attacker seemed to target. In Shai-Hulud, the pivot secret was the npm publish token itself, because a stolen token let the worm publish new malicious versions under a legitimate maintainer's identity — so revoking API keys without also revoking publish tokens leaves the propagation vector open. Order of operations: kill CI/CD tokens and cloud credentials with write or deploy scope first, then registry publish tokens, then read-only or scoped-down keys. A secret with narrow scope that was merely present is lower priority than one with broad scope that was actively used.
Why is rotation not the same as revocation?
Revocation stops a credential from working; rotation replaces it so your pipelines keep functioning — and skipping straight to "we rotated it" without first revoking the old value leaves a window where both credentials are valid. The tj-actions case is instructive here because the exfiltration channel was public CI logs: once a secret has been printed to a log an attacker could plausibly have scraped, GitHub's advisory guidance and StepSecurity's remediation notes both treat that secret as fully disclosed regardless of whether anyone has evidence of misuse yet — rotating it later, after cleanup, is not sufficient. For npm publish tokens specifically, rotation must be paired with registry-side review, because a token that published packages during the exposure window needs those specific package versions pulled or deprecated, not just a new token issued going forward. Treat rotation as step two of a two-step process, never a standalone fix.
How do you scope the blast radius of a compromised package or action?
Scoping means answering three questions with evidence, not assumption: which of your builds pulled the compromised version, during what window, and what did that build have access to. For a compromised GitHub Action, check workflow run history for every job that referenced the affected tag or SHA during the exposure window — tj-actions' malicious window was narrow (hours) precisely because the attacker rewrote tags rather than publishing a new version, so timestamp precision matters. For an npm package, check lockfiles across every service and branch, not just main, since Shai-Hulud's self-propagation meant a transitive dependency three levels deep could carry it. Safeguard's safeguard malware history command exists for exactly this step: when Eagle retroactively reclassifies a package as malicious, it surfaces when a now-known-bad version was installed or deployed, closing the "did we actually have this" question in minutes instead of a grep-everything scramble.
What does auditing after containment actually involve?
Post-containment audit means verifying the attacker didn't leave anything behind, not just confirming the bad package is gone. Check for newly created API tokens, SSH keys, or OAuth grants under any identity whose credentials were exposed — Shai-Hulud's worm behavior included publishing under compromised maintainer accounts, so a clean npm audit result doesn't rule out a persistence mechanism sitting in account settings. Review CI logs from the exposure window for outbound network calls to unfamiliar hosts; the original tj-actions compromise was first caught by exactly this signal, when StepSecurity's Harden-Runner flagged an unexpected outbound call to gist.githubusercontent.com. Confirm no downstream artifact — a container image, a build, a published package — got produced using the compromised dependency and shipped before containment. If it did, that artifact needs its own recall notice, not just a silent patch.
What single change would have prevented the tj-actions vector specifically?
Pinning GitHub Actions to an immutable commit SHA instead of a mutable version tag would have prevented the tj-actions compromise outright, because the attack worked by rewriting what an existing tag pointed to — a SHA reference can't be silently repointed. This is a concrete, auditable control you can check for today: grep your .github/workflows/*.yml files for any uses: line that references a tag (@v4, @main) rather than a 40-character commit hash. GitHub's own advisory and CISA's guidance after the incident both recommended SHA-pinning as the primary mitigation, alongside restricting workflow permissions to the minimum scope a job actually needs. The equivalent control for npm and PyPI is install-time evaluation before a package lands in your tree at all, rather than trusting a version range to keep resolving to something safe.
How Safeguard helps
Safeguard's Package Firewall sits on the install path for npm and pip as a proxy, evaluating typosquat similarity, dependency-confusion risk, and behavioral signals on every fetch — including transitive ones — before a package reaches disk, with quarantine and audit modes so a suspicious package can be held rather than silently installed. Behind that, Eagle classifies every npm, PyPI, and other ecosystem publish and retroactively re-scores the historical corpus, so when a package like the ones in the Shai-Hulud waves gets a new classification, safeguard malware history --package <name> --since <date> tells you immediately whether and when it entered your environment — turning the audit step from a multi-day forensic exercise into a direct query. Neither replaces revoking and rotating your own secrets, but both shrink the time between "a dependency was compromised" and "we know exactly where."