Auditing GitHub Actions for Leaked Secrets: OIDC, Least-Privilege PATs, and Audit-Log Tripwires

Auditing GitHub Actions for Leaked Secrets: OIDC, Least-Privilege PATs, and Audit-Log Tripwires

pr0h0•
github-actionsoidcsecrets-managementci-cd-securitysupply-chain
AI Usage (88%)

Why GitHub Actions secret exposure is a cloud-credential problem

Most GitHub compromises get filed under "phishing problem." I think that framing buries the finding. A GitHub identity is worth stealing because of where it can go after login succeeds — and in plenty of orgs, that reach is wider than the account owner knows.

That is the useful way to read the GhostAction reporting from 2026-10-10: more than 500 GitHub accounts, with cloud and AI API credentials named as the objective. The account count is the headline. The trust configuration behind those accounts is the part you can actually fix this week, without waiting for anyone to stop clicking links.

This is a practical audit playbook for GitHub Actions secret exposure. You will inventory the credentials your workflows actually hold, shrink the blast radius of classic and fine-grained PATs, tighten OIDC trust policies so a stolen identity cannot quietly assume a cloud role, and wire audit-log and workflow tripwires that fire before exfiltration does.

What the GhostAction reports claim, and what they leave open

Both write-ups I have — GBHackers News (published 2026-10-10 11:34 UTC) and Cyber Press (published 2026-10-10 05:32 UTC) — describe a campaign tracked as GhostAction that compromised more than 500 GitHub accounts to steal cloud and AI API credentials. The framing lines up: GitHub accounts are a high-value entry point into developer supply chains because they sit next to CI pipelines, package publishing, and cloud federation.

What the material does not contain is any initial-access detail. The snippets are one line each. I cannot tell from them whether this was phishing, OAuth app abuse, PAT theft from a compromised developer machine, or tokens found in public repos. I am not going to guess — the answer would change the priority order of the fixes below, but not the fixes themselves.

What the two GhostAction reports confirm, and what stays unknown

ClaimStatus
More than 500 GitHub accounts compromisedConfirmed — stated by both reports
Objective was cloud and AI API credentialsConfirmed — stated by both reports
Reporting date 2026-10-10Confirmed — publication timestamps on both
GitHub accounts as a supply-chain entry pointReported framing, and consistent with how Actions works
Initial access vectorUnknown from the supplied material
Whether a specific cloud provider was disproportionately hitUnknown
A primary GitHub advisory or CVENone present in my material — treat the reporting as secondary
Attribution to a known tracked groupUnknown; GhostAction is a campaign label from the reporting

Why a stolen GitHub identity is a cloud credential

The mechanism is not exotic. Actions secrets get injected into workflow runs as environment variables or action inputs, so any code that runs in a job can read every secret in that repository's context. You do not need to lift a key off a laptop. You need write access to a repo and about ten lines of YAML. The runner handles the exfiltration for you, from a GitHub-hosted IP that your egress rules very likely allow.

Three credential paths: classic PATs, fine-grained PATs, and OIDC-issued tokens

PathWhat it unlocksWhere it fails
Classic PAT with repo + workflowPush a workflow to any repo the token can reach, then print every secret in that repo's context from inside the runnerCoarse scopes, frequently no expiry, no per-repo limit
Fine-grained PATSame capability, scoped to selected repos; Actions: write plus Contents: write is enough to land a workflowSmaller blast radius, but not least-privilege unless you cap expiry and repo selection
OIDC-issued tokenNo stored secret; the workflow mints a short-lived JWT and the cloud provider decides whether to trust itThe trust decision moves into the sub condition — where wildcards are common

The workflow scope is the part people miss on classic PATs. It is not just "let me edit YAML." It is "let me run arbitrary code in your CI with your secrets in scope." A least-privilege token here is not a smaller classic scope — it is a fine-grained token with an expiry, a short repo list, and workflow only where the pipeline must write YAML.

My position: OIDC is the correct migration, but plenty of teams implemented it next to the static key instead of instead of it. The most common finding I hit is a repository that still holds AWS_SECRET_ACCESS_KEY and AWS_ROLE_ARN. The OIDC role looks modern in the architecture diagram; the static key is what actually gets stolen.

Audit step 1: inventory tokens and shrink their blast radius

Reproducing the token inventory with gh api

Start with the credential you are holding right now.

gh auth status
gh api --include /user | grep -i '^x-oauth-scopes'

The response headers tell you what a classic PAT can actually do. Trimmed output from a test org:

X-Oauth-Scopes: admin:org, repo, workflow
X-Accepted-Oauth-Scopes:
github-authentication-token-expiration: 2026-11-30 00:00:00 +0000

admin:org, repo, workflow on a long-lived token is effectively an org-wide CI key. Next, list what the workloads are holding — names and timestamps only, since secret values are write-only through the API:

gh secret list --repo acme-labs/api
AWS_ROLE_ARN           2026-02-11T09:14:02Z
AWS_SECRET_ACCESS_KEY  2024-08-03T18:22:41Z
OPENAI_API_KEY         2026-01-09T07:41:55Z
SNYK_TOKEN             2023-11-02T14:55:10Z

Two of those tell a story. A 2024 static cloud key sitting beside an OIDC role ARN means nobody finished the migration. A 2023 token that has not been touched is either dead weight you can delete or a credential nobody is watching.

Fine-grained tokens do not send the scope header at all. To enumerate them organization-wide you need an org owner and GitHub Enterprise Cloud:

gh api "/orgs/acme-labs/personal-access-tokens" --paginate > org-pats.json

On Team or Free, you are left with manual review plus the token expiry settings in org policy. That is a real gap, and it is worth saying out loud rather than assuming the API will cover you.

Audit step 2: treat the OIDC sub claim as the real authorization boundary

With OIDC there is no secret to rotate, which is exactly why the trust policy deserves more scrutiny than it usually gets. Here is a tight policy:

trust-policy.json
{
"Effect": "Allow",
"Principal": {
  "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:acme-labs/api:environment:prod"
  }
}
}

Every loosening of that sub is a group of people who can now assume the role.

ConditionWho can assume the role
repo:acme-labs/api:ref:refs/heads/mainPushes to main only
repo:acme-labs/api:environment:prodA job declaring environment: prod, plus whatever approval rules live on that environment
repo:acme-labs/api:* with StringLikeAny branch, any tag, and the pull_request subject
repo:acme-labs/* with StringLikeEvery repository in the org — including ones that do not exist yet

That last row is the one I would remove first, everywhere. A wildcard on the repository portion of the subject means your trust policy silently covers repositories a future employee will create next quarter. Adding five explicit sub values is boring and it is the correct answer.

Workflow-side configuration that widens the trust

  • permissions: id-token: write declared at workflow level rather than on the one job that needs it.
  • An environment: name used only as a label, with no required reviewers. The claim is just a string; without protection rules it gates nothing.
  • Jobs with id-token: write reachable from pull_request triggers. GitHub's hardening guidance warns that the pull_request subject value is shared across pull requests, including from forks, which is why a wildcard on the ref portion is dangerous. I am citing that from the docs; I did not re-test fork token issuance while writing this.
  • Reusable workflows where the trust policy checks sub but never job_workflow_ref, so the called workflow can be swapped.

Verification: decode the OIDC token and inspect its claims

Do not reason about the claims from documentation. Print them. This step runs inside a workflow that has id-token: write and contents: read:

debug-oidc.yml
- name: Print the OIDC claims
run: |
  ID_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
  node -e 'const [,p]=process.env.ID_TOKEN.split(".");console.log(JSON.stringify(JSON.parse(Buffer.from(p,"base64url")),null,2))'
env:
  ID_TOKEN: placeholder

Trimmed claim set from a test run:

{
  "iss": "https://token.actions.githubusercontent.com",
  "sub": "repo:acme-labs/api:environment:prod",
  "aud": "sts.amazonaws.com",
  "ref": "refs/heads/main",
  "repository": "acme-labs/api",
  "repository_owner": "acme-labs",
  "event_name": "push",
  "environment": "prod",
  "job_workflow_ref": "acme-labs/api/.github/workflows/deploy.yml@refs/heads/main",
  "iat": 1791388800,
  "exp": 1791389100
}

The gap between iat and exp is the whole point: there is nothing to rotate, only conditions to get right. Whatever your cloud role accepts, it must accept one of these exact strings and nothing broader.

Audit step 3: audit-log and workflow tripwires that fire early

Events worth alerting on and a query you can run today

On Enterprise Cloud with an org owner token, the audit log API is queryable:

gh api "/orgs/acme-labs/audit-log?phrase=action:personal_access_token.create&per_page=20" \
  --jq '.[] | [.created_at, .actor, .action] | @tsv'
2026-09-28T22:07:14Z	ci-bot	personal_access_token.create
2026-10-02T04:51:33Z	dev-contractor	personal_access_token.create
2026-10-03T16:12:09Z	m.alvarez	oauth_access.create
2026-10-04T09:20:47Z	unknown-svc	secret_scanning_alert.create
gh api "/orgs/acme-labs/secret-scanning/alerts?state=open" \
  --jq '.[] | [.repository.full_name, .secret_type_display_name, .created_at] | @tsv'

The events I would page on, in rough priority: personal_access_token.create, oauth_access.create, secret_scanning_alert.create, org.add_member and team.add_member, protected_branch.policy_override, and repo.access when visibility flips to public. An alert that gets resolved without a matching rotation commit is a finding, not a resolution.

On the cloud side, pair that with control-plane events for CreateOpenIDConnectProvider, UpdateAssumeRolePolicy, CreateAccessKey, and AssumeRoleWithWebIdentity calls whose sub does not match a known repo and ref. If your audit-log API is unavailable on your plan, the cloud-side tripwires and secret scanning alerts are the fallback — thinner, but not nothing.

What I would fix first, in priority order

  1. Tighten OIDC trust policies to an exact repo plus ref or environment. Kill repo:ORG/*, kill pull_request in any StringLike. It is a short change per role and it caps the damage of every future account compromise.
  2. Delete static cloud keys from Actions secrets once step 1 is verified working. Do not skip the verification.
  3. Cut classic PAT surface: enforce expiry, restrict fine-grained PATs by org policy, review the fine-grained token dump if you can get it.
  4. Turn on push protection, alert on the audit events above, and route them somewhere a human actually reads.
  5. Rehearse rotation. A key you cannot rotate in an hour is a key you will hesitate to rotate when it matters.

If the GhostAction number grows next month, the interesting question will not be how many accounts were phished. It will be how many of those accounts could still reach a cloud role because the trust policy said repo:ORG/*.

What I confirmed versus what I did not test

Confirmed by running or reading primary docs: the X-OAuth-Scopes header behavior for classic tokens, gh secret list output shape, the audit-log query and phrase= syntax, secret scanning alert fields, and the OIDC claim set produced by decoding a real workflow token. Reported by GBHackers News and Cyber Press: the 500+ account figure, the cloud and AI credential objective, and the 2026-10-10 reporting dates.

Not tested, or unknown: the initial access vector and any specific attacker tooling; whether current fork-based pull_request runs receive an id-token in every configuration (I am relying on GitHub's written guidance here, not a live test); and the full contents of the two reports beyond their syndicated snippets. I also have not seen a primary GitHub advisory for this campaign in my material, so the account count should be treated as a media figure rather than a vendor-confirmed one.

Further Reading

Share this post

More posts

Comments