# Gheist: stolen GitHub tokens dumping repository secrets through Actions logs

Detection and response pack. Built 26 Oct 2026. Status: **active campaign, no CVE**.
Source: public security research from October 2026, plus GitHub's
and AWS's own documentation. Section names in the provenance column refer to that research.

> **The KQL in `kql/` has NOT been run against a live Sentinel or Defender tenant.** Column names marked
> UNVERIFIED are guesses at how the GitHub connector stores raw audit-log fields. Check them with
> `getschema` before relying on any result.

## What happened

Since 15 Sep 2026 an automated tool has used stolen GitHub tokens (sources traced to infostealer logs and
tokens exposed in earlier supply-chain compromises) to plant a workflow in every repository each token can
reach. For each repository it:

1. creates a branch `ci-sync-` plus six characters;
2. commits `.github/workflows/sync-check.yml` with the message `ci: sync config` (unsigned, authored as the token owner);
3. the push triggers the workflow, which prints `env` and `toJSON(secrets)` **base32-encoded**, so GitHub's
   log masking (verbatim matches only) doesn't hide them. `permissions: {}` doesn't restrict the secrets context.
   Where the repo has deployment environments, a job matrix (`environment: ${{ matrix.environment }}`) also dumps
   environment-scoped (often production) secrets;
4. downloads the run log through the GitHub API and deletes the run within about a minute of it completing.
   API requests carry the user agent `gheist`.

There is no C2 server and no attacker domain: secrets leave through GitHub's own API. Over 13,500 planted
branches from over 850 accounts were recovered; about two thirds of those accounts were gone by 24 Oct.

On 19 Oct one variant ("Tool B") added a `LAFA====` separator and, hours later, a second job `sync-host`
(`runs-on: self-hosted`) for repos with a self-hosted runner. It writes `.gdmp_pat`, `.gdmp_patg`, `.gdmp_sweep`,
logs stages under `===== gdmp-pivot:` and harvests tokens, keys, `.env` files, Terraform state, shell history,
`/proc/*/environ`, the host filesystem via the Docker socket, AWS instance-role credentials, runner credential
files and the Kubernetes service-account token. The research calls this job buggy (wrong runner key file,
broken URL-credential regex, no GCP/Azure token collection).

**GitHub's approval hold.** Since 28 Jul 2026 GitHub holds potentially malicious workflow runs in **public**
repositories until a collaborator approves them (GitHub changelog, 2026-07-28). Every public Gheist run checked
sits at `action_required`. The hold does **not** apply to private repositories or GitHub Enterprise Server,
where runs completed and secrets were exposed. In enterprise orgs the tool cycled through several users'
and automation accounts' tokens within minutes, cloned private repos first, and hours later cloud credentials
from the same organisation were used to enumerate AWS Secrets Manager.

Not to be confused with a separate, earlier campaign that also abused GitHub workflows (different workflow files and its own exfil IP; that
IP is **not** a Gheist IOC and is deliberately left out).

## IOCs (verbatim only)

| # | Indicator | Type | Provenance (public research from October 2026) |
|---|-----------|------|-----------------------------------------------------|
| 1 | `gheist` | HTTP user agent (substring; full UA string not published) | Intro; "Indicators of compromise" |
| 2 | `^ci-sync-[0-9a-z]{6}$` (`ci-sync-` + six chars) | Branch name | "How the attack works"; "Indicators of compromise". Tool A: hex suffix; Tool B: base36 ("How the payload evolved") |
| 3 | `ci: sync config` | Commit message, adding only `.github/workflows/sync-check.yml` | "How the attack works"; "Indicators of compromise" |
| 4 | `.github/workflows/sync-check.yml`, workflow name `sync-check` | Planted file | "How the attack works" |
| 5 | `toJSON(secrets) }}' \| base32 -w 120` | Payload marker | "Indicators of compromise" |
| 6 | `===== gdmp-pivot:` | Job-log stage header | "How the payload evolved"; "Indicators of compromise" |
| 7 | `.gdmp_pat`, `.gdmp_patg`, `.gdmp_sweep` | Self-hosted runner helper files | "How the payload evolved"; "Indicators of compromise" |
| 8 | `f3da2b8b357dcda02fd455d314be2a95007516a6` | Workflow git blob SHA (plain) | "Indicators of compromise" |
| 9 | `d09535857a741bbb976bdc084a9a021713133291` | Workflow git blob SHA (timeout) | "Indicators of compromise" |
| 10 | `1dc5af9cce950aad1eb1546ecce8565b117540c4` | Workflow git blob SHA (separator) | "Indicators of compromise" |
| 11 | `c665b778b2d822453293003e7ca0bbb2cb08e39d` | Workflow git blob SHA (self-hosted job) | "Indicators of compromise" |
| 12 | `LAFA====` | Separator text in run logs | "How the payload evolved" (behavioural; not in the IOC list) |
| 13 | job `sync-host`, `runs-on: self-hosted` | Second job | "How the payload evolved" (behavioural) |

Environment-matrix variants have per-repository SHAs, so the SHA list is **not** exhaustive. Rely on the
branch, file, commit message and UA.

Independent check: rebuilding the published workflow text and hashing it as a git blob reproduces SHAs 8, 9 and
10 exactly (inert copies are in `tests/fixtures/payloads/`). SHA 11 can't be reproduced because the self-hosted
job's content isn't published. Treat it as **reported, not independently verified**.

### Discrepancies with the claims we were given, and unverified items

| Claim given | What the primary source says | Effect on this pack |
|-------------|------------------------------|---------------------|
| Runs "created then deleted within 60 s" | Deleted "within a minute of **completing**". Runs can take up to `timeout-minutes: 6`. | Primary detection is completed→deleted ≤60 s. Created→deleted ≤60 s kept as requested (KQL Q2b), but will miss longer runs. |
| "In one case" AWS Secrets Manager was hit | No count is given. It says that in enterprise orgs, hours later, cloud credentials **from the same organisation** were used. That isn't necessarily the OIDC session. | Same-session correlation (Sigma, KQL Q6a) plus an account-wide 24 h hunt labelled HYPOTHESIS (Q6b). |
| `ListSecrets` / `GetSecretValue` | API names not given ("enumerate AWS Secrets Manager"). | **INFERRED**; also covers `BatchGetSecretValue`/`DescribeSecret`. |
| `AssumeRoleWithWebIdentity` with a ci-sync subject | It says "role assumptions or token exchanges whose subject includes a `ci-sync-` branch". | API name **derived from GitHub OIDC / AWS IAM docs**, not the research. Only works with branch-based `sub` claims. |
| `gdmp-pivot` as a file or process on the runner | It's a header "in job logs". The helper files' location on disk isn't published. | Runner checker greps files (e.g. runner `_diag` logs) for it. The Defender/Sigma process rule is best-effort. |
| `.gdmp*` paths | Filenames only. | Checker searches runner roots and `$HOME` recursively by name. |
| Self-hosted variant date | 19 Oct, "hours after" the separator change. | Matches. |
| Full user-agent string | Only `gheist` published. | Substring match. |

Other UNVERIFIED items: every GitHub audit-log column name in the KQL other than the `GitHubAuditData`
parser columns; GitHub audit-log field names for Sigma (taken from GitHub's audit-log event docs, not from a live
export); Defender coverage of Linux runner `CommandLine`/file events; the `base32 -w 120` heuristic.

## Contents

```
README.md
kql/gheist-hunting.kql                      Sentinel/Defender hunting (Q1 to Q7). Not run against a live tenant.
sigma/                                      8 files, 12 rules: 6 standalone, 2 correlation, 4 correlation base rules; sigma check: 0 errors, 0 issues
  github_gheist_ci_sync_branch_workflow_run.yml   run on ci-sync-XXXXXX / sync-check
  github_gheist_user_agent.yml                    user_agent contains gheist
  github_gheist_run_deleted_after_completion.yml  correlation: completed -> deleted within 1m (repo, workflow_run_id)
  aws_gheist_oidc_assume_role_ci_sync.yml         AssumeRoleWithWebIdentity subject contains ci-sync-
  aws_gheist_oidc_then_secretsmanager.yml         correlation: that session -> Secrets Manager within 12h
  linux_gheist_runner_helper_files.yml            .gdmp_pat/.gdmp_patg/.gdmp_sweep created
  linux_gheist_runner_gdmp_pivot.yml              command line has gdmp-pivot/.gdmp_
  linux_gheist_runner_base32_secret_dump.yml      HEURISTIC base32 -w 120 from runner step script
scripts/check-gheist-repos.sh  scripts/Check-GheistRepos.ps1    repo/org checker (GitHub REST, GET only)
scripts/check-gheist-runner.sh scripts/Check-GheistRunner.ps1   local self-hosted runner checker
tests/run-tests.sh            full suite (shellcheck, bash tests, Pester, PSScriptAnalyzer, sigma check/convert, hygiene)
tests/test-bash.sh            bash checker tests (mocked curl, mocked filesystems)
tests/pester/                 Pester tests + mocked Invoke-WebRequest
tests/mocks/curl              fake curl; fails the test on any write flag
tests/fixtures/api/           routes.tsv + JSON bodies (clean, hits, pagination, held run, 401/403/404, rate limit, HTML, network error)
tests/fixtures/payloads/      inert .txt reconstructions of the published payloads
```

Note: this pack contains the IOC strings (rules, fixtures, scripts), so it will match its own content if you
scan it. The runner checkers skip their own filename.

## Checkers

All four are strictly read-only. They never print secret values and never download run logs.

Exit codes: **0 CLEAN**, **1 INDICATORS FOUND**, **2 UNKNOWN** (incomplete: e.g. 403/404 on a repo, rate limit,
unreadable path, no runner directory), **3 ERROR** (bad usage, missing token, 401, non-JSON response, network
failure, missing tool). Precedence: found > unknown > clean.

### Repo / org checker

```bash
export GITHUB_TOKEN=...            # never passed on the command line; sent to curl on stdin
./scripts/check-gheist-repos.sh --org my-org [--max-repos 500] [--run-pages 3] [--quiet]
./scripts/check-gheist-repos.sh --repo owner/repo1 --repo owner/repo2
./scripts/check-gheist-repos.sh --user someone
# GHES: export GITHUB_API_URL=https://ghe.example.com/api/v3
```
```powershell
$env:GITHUB_TOKEN = '...'
./scripts/Check-GheistRepos.ps1 -Org my-org [-MaxRepos 500] [-RunPages 3] [-ApiUrl ...] [-Quiet]
```

Token: use a **fine-grained personal access token or GitHub App installation token** with read-only repository
permissions **Metadata: Read**, **Contents: Read**, **Actions: Read**, on the repos or org to check. (A classic
PAT would need the `repo` scope for private repos, which also grants write. Avoid it.)

It only issues GET requests: `/orgs/{org}/repos`, `/users/{u}/repos`, `/repos/{r}`, `/repos/{r}/branches`,
`/repos/{r}/contents/.github/workflows?ref=` (default and ci-sync branches), `/repos/{r}/actions/runs`
(including `?branch=`). It flags `ci-sync-XXXXXX` branches, `sync-check.yml` or known blob SHAs in a workflows
directory, and runs on ci-sync branches or from `sync-check.yml`. Runs at `action_required` are reported as
**HELD FOR APPROVAL - do not approve**. Archived repos are skipped. A deleted branch or run is invisible to the
API, so CLEAN does not prove the repo was never hit. Use the audit-log queries too.

### Runner checker

```bash
./scripts/check-gheist-runner.sh [--path /extra/dir ...] [--no-defaults]
```
```powershell
./scripts/Check-GheistRunner.ps1 [-Path D:\runners ...] [-NoDefaults]
```
Defaults: `$RUNNER_HOME`, `$RUNNER_WORKSPACE`, `~/actions-runner`, `/home/runner`, `/opt/actions-runner`,
`/actions-runner`, `$HOME` (PowerShell adds `C:\actions-runner` and the user profile). It reports `.gdmp_*`
file paths, sizes, owners and mtimes (never contents), files containing `gdmp-pivot` or the payload marker
(count only), and workflow files whose git blob SHA matches. Run as the runner's service user, or root, for full
coverage. Permission errors give UNKNOWN.

## Response

1. **Rotate every secret** available to an affected repository: repository, organisation and **environment**
   secrets, plus anything in the runner's environment. Deleting the run or the branch **doesn't undo the exposure**.
2. **Revoke the tokens that pushed** the `ci: sync config` commits (the commit author is the token owner) and check
   those owners' devices for **infostealers**. Review automation/bot account tokens too.
3. If a ci-sync run executed on a **self-hosted runner**, treat the **host and its instance role/cloud identity as
   compromised**. Rotate the instance role, runner registration, Docker host, Kubernetes service-account
   tokens and any credentials on disk. Rebuild the host.
4. **Don't approve held `sync-check` runs** (`action_required`) and don't run code from `ci-sync` branches.
5. In AWS, review sessions from ci-sync OIDC subjects and any Secrets Manager access in the following hours (KQL Q5/Q6).
   Rotate the secrets read.
6. Preserve evidence (audit logs, CloudTrail) before you delete the branches. Then remove them.

## Hardening

- Require **reviewers on environments** holding production secrets.
- Scope **OIDC trust policies** to protected branches or environments (`sub` = `...:environment:prod` or
  `...:ref:refs/heads/main`), never `repo:org/*`.
- Prefer **GitHub Apps or short-lived fine-grained tokens** over personal access tokens. Enforce token expiry
  and org approval of fine-grained PATs.
- Run self-hosted runners as **ephemeral/JIT** hosts with **no Docker socket** and **no unrestricted metadata
  access** (IMDSv2, hop limit 1, or blocked). Use runner groups to restrict which repos can use them, and don't
  attach self-hosted runners to public repos.
- Protect `.github/workflows/` with **CODEOWNERS** and rulesets. Restrict who can create branches or push workflow
  files where feasible. Require signed commits to make unsigned token pushes stand out.
- Stream the GitHub audit log (including Git events and API request events) to your SIEM so these detections work.
- See GitHub's "Secure use reference": https://docs.github.com/en/actions/reference/security/secure-use

## References (primary, vendor-neutral)

- GitHub changelog 2026-07-28: holding potentially malicious workflow runs in public repositories
- https://docs.github.com/en/actions/reference/security/secure-use
- https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization
- https://docs.github.com/en/actions/reference/security/oidc
- https://docs.aws.amazon.com/IAM/latest/UserGuide/cloudtrail-integration.html
- Public research from October 2026 (campaign description and IOCs)

## Tests

`bash tests/run-tests.sh` (offline; curl and Invoke-WebRequest are mocked and any write attempt fails the test).
Last run 26 Oct 2026: see the RESULT line. Bash checker tests 68/68, Pester 40/40, PSScriptAnalyzer no
Error-severity findings, shellcheck clean, sigma check 0 errors/0 issues, kusto conversion of the 6 non-correlation rules.
