Why a workflow change is not a finding on its own
Every organisation writes a rule that fires when a workflow file changes, and almost every one of those rules is off within a month. A file change is not evidence. The finding is the pair: tampering, then a credential in motion.
Two signals that mean nothing alone
Start with what is being claimed, because the difference between a hygiene rule and a correlation rule is the whole article. A workflow file changed. An action is pinned by tag instead of by commit SHA.
The first is a file edit. Engineering teams edit workflows several times a week — to add a cache step, to move a Node version, to fix a matrix. Nothing in a path-and-timestamp view separates the routine edit from the malicious one, and even a malicious edit has no consequence until a job runs with credentials in scope.
The second is a property of a file rather than an event. It is real risk and it is present in nearly every repository you own, which means there is no moment to respond to. You cannot triage a standing condition; you can only decide who is allowed to ignore it.
What GhostAction actually did, in order
In October 2026 attackers used stolen maintainer credentials to push a workflow called security-audit.yml into 94 repositories, then more than 340 across 817 accounts. The workflow looked like routine automation and behaved like a burglar with a moving van.
- It dumped the repository's named secrets and queried the Actions secrets API.
- It searched the working tree for 13 credential patterns — cloud keys, registry publish tokens, AI provider keys — and then walked the entire git history for the same patterns, which is how it found secrets that had been committed and deleted.
- It sent the results to a hard-coded IP address over plain HTTP, and dropped a second workflow as a fallback.
- It persisted on developer machines through a fake font file, a VS Code task and a five-minute cron.
- 3,325 secrets left, and no packages were published with them. The credentials were stockpiled for later.
That last line is the reason to care about the pairing rather than the workflow. A stockpiled credential is a future incident, and the only cheap moment to act is while you can still say which secrets the job could read. Read the campaign in full at GhostAction.
A change is a file. Tamper is a behaviour.
This is why the tamper signal is not emitted by a diff on .github/workflows/**. It is emitted when the workflow content does something that has a purpose only in an attack: enumerate secrets and post them somewhere, hunt credential formats through git history, hard-code a key, pipe a download into a shell, decode a base64 blob beside network egress, check out untrusted pull-request code under an untrusted trigger, or point a release job at a repository whose name is one character away from yours.
| Rule | What it asserts | Severity | Signal type |
|---|---|---|---|
| CI-WF-SECRET-HARVEST | the workflow harvests credentials and posts them over the network | CRITICAL | CI_WORKFLOW_TAMPER |
| CI-WF-LOOKALIKE-SOURCE | the release job builds from a near-identical repository name | CRITICAL | CI_WORKFLOW_TAMPER |
| CI-WF-UNTRUSTED-CHECKOUT | untrusted pull-request code is checked out under pull_request_target | CRITICAL | CI_WORKFLOW_TAMPER |
| CI-WF-FORCE-PUSH | the workflow rewrites branch history, on the default branch or elsewhere | HIGH / MEDIUM | CI_WORKFLOW_TAMPER |
| CI-WF-UNPINNED-ACTION | an action is referenced by a mutable tag | MEDIUM | INSECURE_PIPELINE |
| CI-WF-CAMPAIGN-NAME | the filename matches a mass-injection campaign name | LOW | CI_WORKFLOW_TAMPER |
The signal types are the point of the table. One says this workflow is doing something hostile; the other says this repository has a habit. Collapsing them into a single finding stream is how a real detection gets buried under a style guide.
The same file also produces campaign-level findings, because a single security-audit.yml is noise while the same filename in three or more repositories is the GhostAction fingerprint — and the identical workflow blob in three or more repositories is mass injection with the hash to prove it. Content that matches nothing but appears identically in ten or more repositories is a MEDIUM worth a look, because that is what a quiet template rollout looks like too.
The join: tampering plus a credential in motion
Now the correlation. The rule matches a CI_WORKFLOW_TAMPER signal and then looks for SECRET_LEAKED or SECRET_EXFILTRATION_ATTEMPT on the same repository inside seven days. It emits one risk with the attack chain attached: both signal ids, both signal types, and the measured duration between them.
# One entity (the repository), ordered, bounded.
step 1 CI_WORKFLOW_TAMPER any severity; tier 1 escalates CRITICAL and HIGH
step 2 SECRET_LEAKED | SECRET_EXFILTRATION_ATTEMPT within 7 days of step 1
emit RULE-T2-014 CRITICAL score computed at signalCount = 2
attackChain: [{signalId, type, description} x2, durationMs]
techniques: T1195.002, T1552.001, T1041Three decisions inside that rule are worth copying if you build your own.
- The join is keyed on the repository, not on the tenant. A workflow change in one repository and a leaked secret in another are two unrelated facts, and merging them produces an incident nobody can act on.
- The window is written onto the finding. Seven days is long enough to catch a scheduled exfiltration job and short enough that ordinary activity in a busy repository does not land in the same incident. A finding that does not record its window cannot be re-derived later.
SECRET_EXFILTRATION_ATTEMPTis a first-class half. If the rule requires a completed leak, then your detection only completes after the data is gone. Accepting a blocked attempt means the response starts while egress is still failing.
What the chain gives you at the end is not a verdict about a file. It is a rotation list with a reason: these are the secrets the tampered job could read, this is the commit that added it, this is the actor, and here is the point in time when something started moving them.
Why the hygiene rule is opt-in
Unpinned-action reporting lives behind opts.includeHygiene, and the reason is not squeamishness about severity. It is that the scheduled organisation-wide scan already receives unpinned third-party actions from a separate supply-chain surface, so running the check in both places reports the same repository twice and teaches reviewers to skip rows.
The pre-merge check run sets the flag. That is the only place the finding is cheap: the diff is in front of the author, the fix is one line, and the merge button is the enforcement. A hygiene rule belongs wherever there is a diff, an author and a decision — not in a queue with forty thousand rows and no owner.
Three things to get right if you build the join
- Check that your signals carry a stable repository identifier. Correlation on a display name breaks the first time a repository is renamed or transferred, which is also the tactic.
- Define what leaked means before you need the answer. A value readable outside its intended audience is the useful definition — committed, logged, or present in history an outsider cloned. If your definition requires proof of exfiltration, every incident closes late.
- Decide who materially matters on the credential side. A workflow that can read a production deploy key is a different incident from a workflow that can read a repository-scoped read token, and the pair rule should say which it found.
The last one is not a threshold you can guess. Pull the credential inventory that belongs to the pipeline before you tune anything, because the plausible blast radius is the only thing that distinguishes a noisy pair from an urgent one.
More on the surrounding design: CI/CD security and supply chain security.
Questions
Should a workflow change block a merge?
Is an unpinned action a vulnerability?
Why seven days between the tamper and the leak?
What if the workflow is legitimate and the leak is not?
Does this catch the case where nothing was exfiltrated yet?
Run it in your own account
One Docker Compose stack, your datastores, read-only credentials into your clouds. The Community edition is free to run in production.