Skip to content

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.

(updated 2026-10-23)how to detect CI workflow tamperingworkflow tampering detectionunpinned GitHub Actions risk

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.

RuleWhat it assertsSeveritySignal type
CI-WF-SECRET-HARVESTthe workflow harvests credentials and posts them over the networkCRITICALCI_WORKFLOW_TAMPER
CI-WF-LOOKALIKE-SOURCEthe release job builds from a near-identical repository nameCRITICALCI_WORKFLOW_TAMPER
CI-WF-UNTRUSTED-CHECKOUTuntrusted pull-request code is checked out under pull_request_targetCRITICALCI_WORKFLOW_TAMPER
CI-WF-FORCE-PUSHthe workflow rewrites branch history, on the default branch or elsewhereHIGH / MEDIUMCI_WORKFLOW_TAMPER
CI-WF-UNPINNED-ACTIONan action is referenced by a mutable tagMEDIUMINSECURE_PIPELINE
CI-WF-CAMPAIGN-NAMEthe filename matches a mass-injection campaign nameLOWCI_WORKFLOW_TAMPER
Two kinds of rule, two kinds of claim

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, T1041

Three 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_ATTEMPT is 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?
Not by itself. What blocks a merge is content: a step that enumerates secrets and posts them, a release job pointed at a lookalike repository, an untrusted checkout under an untrusted trigger. Those are specific enough to fail a check with the line number attached. A generic rule about workflow edits will be disabled inside a month, and it deserves to be.
Is an unpinned action a vulnerability?
It is a hygiene property: a mutable tag can be moved to different code after you reviewed it, so the action you trust today is not necessarily the action that runs tomorrow. That is why it is reported at MEDIUM and surfaced at the point of change rather than treated as a live compromise.
Why seven days between the tamper and the leak?
Because the second half of the attack is often scheduled. Seven days covers a job that runs daily or weekly while staying short enough that two unrelated events in an active repository do not end up in one incident. Whatever window you choose, record it on the finding, or nobody can reconstruct the reasoning later.
What if the workflow is legitimate and the leak is not?
The finding still stands, because the incident is the capability. A legitimate release workflow that could read a secret which has since become readable elsewhere is exactly the blast radius you need to size, and the response is the same rotation list. The pair rule asserts reachability plus exposure, not intent.
Does this catch the case where nothing was exfiltrated yet?
That is what the campaign-level views are for. The GhostAction payloads were staged across hundreds of repositories before the credentials were used, so mass filename spread and identical malicious blobs are detected as campaign fingerprints — and the pair rule assumes a credential that is stockpiled today is spent at the attacker's convenience, not yours.

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.