Skip to main content
NEW · Blast Radius: a field playbook for leaked API keys on AWS, GCP and Azure
Back to Blog

GhostAction Analysis: How a Malicious Workflow Collects Keys from Git History

A review of 100 public workflow samples and Uber repository events shows four collector versions. We examine deleted-key collection through Git patches, successful exits after transfer failure, and credential-based response priorities.

Ben Kim
Written by
Ben Kim
Published
15 min read3,258 words
Share:
GhostAction Analysis: How a Malicious Workflow Collects Keys from Git History

Incident overview

At 21:25:55 UTC on October 8, 2026, a GitHub Actions workflow named Security Audit entered the default branch of uber/athenadriver. Its code searches for API keys and tokens in the repository and prepares an HTTP POST to an external IP address. The search includes current files and commit patches containing lines where a key was added or deleted.

GitHub's public events record the commit entering the branch and the branch returning to its parent the following day. Workflows collected from 100 other repositories under the same commit-message and date filter also send collected values to that IP. Their bytes fall into four hash groups. These are 100 verified code samples, not 100 organizations with confirmed credential loss. The Uber case was collected separately.

Repository write access can affect what CI reads and where it sends data when it permits workflow changes. A key removed from a file can remain in history, and a workflow may also receive deployment secrets. An isolated replay placed synthetic deleted values into the outbound body. The scripts still exited successfully when the replacement transfer function returned a failure.

This report uses public source files, change records and isolated replay results to explain collection paths and response decisions. Actual malicious-run network records and credential-provider logs were not available. Live-key loss and compromise of other systems remain unconfirmed.

ItemIncident summary
Observed changeCredential-collection workflow using a security-audit name
Target path.github/workflows/security-audit.yml in public repositories
Case with linked change recordsUber default-branch update and return to the parent commit
Code reviewed100 distinct-repository samples, four SHA-256 groups; Uber collected separately
Collection pathsCurrent files, patches reachable through local Git refs, selected named secrets
Destination193.32.204[.]199, HTTP POST
Conditions for further impactExecution, access to values, outbound transfer, credential validity and permissions
UnresolvedInitial access, actual malicious execution and receipt, live-key loss, subsequent abuse, total scope

How a workflow change becomes credential collection

The malicious code is in a new workflow file, .github/workflows/security-audit.yml. It declares push and workflow_dispatch triggers, runs on ubuntu-latest, and invokes actions/checkout@v4 with fetch-depth: 0. The push trigger has no branch or path filters. These declarations describe when the code is intended to run. Execution records are needed to establish whether it ran.

GhostAction change, collection and transfer path. Public records establish the branch change; actual malicious execution and server receipt remain unconfirmed.

Workflow files contain the commands CI will execute. Changing one can add an external transfer alongside a repository search. Reviewing repository write access therefore also requires reviewing workflow approval, secrets supplied to the run and runner egress.

Change records in the Uber repository

The table separates sample commit dates from the Uber repository's public events. A Git committer date can be set by the author; it is insufficient evidence of when an attack or workflow execution happened. Times are shown in UTC and Korea Standard Time (KST).

UTCKSTRecord and basis
October 8, 13:12:51–22:56:26October 8, 22:12:51 to October 9, 07:56:26Earliest and latest committer dates in the 100 samples
October 8, 21:25:54October 9, 06:25:54Uber commit e3c0dfa… adds one workflow, 30 lines
October 8, 21:25:55October 9, 06:25:55PushEvent 23413193405: master moves from parent ed473510… to the malicious commit
October 9, 16:56:44October 10, 01:56:44PushEvent 23527033545: head moves from the malicious commit back to the parent
October 9, 16:56:44October 10, 01:56:44Public Pages run starts on the parent commit and concludes success
October 11 captureOctober 11 capturemaster points to the parent; default path-history query is empty; malicious file at fixed SHA returns HTTP 200

The first PushEvent names henrywoo as the actor; the restoration event names tyvsmith. Those are the accounts recorded by GitHub, not proof of who controlled them or how initial access was obtained. The commit API reports verified: false and reason: unsigned. An unsigned commit alone does not establish account compromise.

The second event's before value is the malicious commit and its head is that commit's parent. The captured default branch also points to the parent. Together, these records establish that the branch returned to its earlier state. They do not identify the restoration command or show whether every other ref and fork was cleaned up.

Four versions, different collection scopes

The 100 files form four groups by their byte hashes. The version present in a repository changes which secrets and history ranges need investigation.

VersionSamplesAWS context collection200,000-line history-input limitDirect Actions secrets references
A96PresentPresentNone
B1PresentPresentGHCR_TOKEN, GHCR_USERNAME
C2AbsentAbsentNone
D1AbsentAbsentFive FTP-related names

The separate Uber copy has the same file hash as A. B adds named-secret references to A. C sends git log output directly into the search pipeline and omits context collection. D adds FTP-related secret references to C.

D references FTP_PORT, FTP_PROTOCOL, FTP_SERVER, FTP_USERNAME and MYSECRETPASSWORD. A port, protocol or username is not sufficient to authenticate by itself. Investigation needs the values actually supplied, the destination and a valid authentication combination.

B and D reference selected secrets through ${{ secrets.NAME }}. The code does not enumerate every secret name automatically. Collection depends on those values being supplied during the run, so secret scope and any environment approval requirements matter. A and C still search files and history without direct secret references. An empty Actions secrets store is therefore insufficient grounds for a safe verdict.

How deleted keys and nearby values are collected

Deleted lines remain searchable

After checkout, A and B save git log output into a variable:

bash
gl=$(git log -p --all 2>/dev/null | head -200000)

The -p option prints added and deleted lines for each commit. A key absent from the current file can still be found in a deletion patch. --all includes commits reachable from local refs and HEAD, so values on another branch can be included when its ref is available. Unfetched refs and unreachable objects fall outside this command's reach.

C and D pipe git log -p --all directly into grep. The 200,000-line limit in A and B cannot be applied to all four versions. All four sort and deduplicate history matches, then retain up to 300 results. Current-file searches have no matching count limit, so 300 is not a limit on the entire outbound body. Exposure review needs the refs and patches available to the run, as well as its current files.

A synthetic key absent from the current file remains in a Git patch and reaches the collection body.

A regex warning does not establish collection failure

The patterns target AWS IDs, Git-hosting tokens, Google API keys, AI API keys and Slack or SendGrid formats. A and B also contain patterns for AWS secret-key and session-token assignments. Their current-file searches ignore case; C and D do not.

The OpenRouter and AWS-assignment patterns use (?:…), a PCRE-style noncapturing group, but the command invokes grep -E. GNU grep 3.11 issued the warning ? at start of expression. Collection still proceeded: synthetic OpenAI, Anthropic, OpenRouter and AWS ID inputs matched, while the synthetic AWS secret assignment did not match directly. These results are limited to the tested inputs and implementation.

Context brings adjacent values into the body

The ctx and hctx paths in A and B take two lines before and after an AWS ID. They retain up to 150 lines each from current files and history. A synthetic secret assignment immediately after an AWS ID entered the body through this path even though the direct pattern missed it.

C and D have no context collection. With the same synthetic input, they collected the AWS ID but omitted the adjacent secret assignment. Counting regex matches alone would miss part of A and B's collection scope. Nearby collected lines also need review. Whether they form a usable credential pair requires a separate provider-side check.

A failed transfer can still produce a successful exit

The transfer condition checks whether full is empty after removing spaces and newlines. All four versions retain REPO= information; A and B also retain AKIA_CTX_START and AKIA_CTX_END. Inputs without keys still pass the condition because repository information remains in the body.

The transfer command is curl -s -m 20 -X POST --data-binary "$full", followed by || true. That expression absorbs the failed command status. There is also no --fail option, so an HTTP error response alone is not treated as a curl failure.

The Uber copy does not specify shell. GitHub documents the default Linux command as bash -e {0}, without pipefail. A grep with no matches can return 1 while the pipeline succeeds because its final sort returns 0. The synthetic shell comparison continued past the empty result under bash -e; adding -e -o pipefail stopped the same assignment with exit code 1.

What the isolated replay placed in the body

Each version's run body was applied to the same synthetic inputs. Files were supplied read-only, and Git input was fixed to patches produced from a temporary repository with no remote. Actions secrets expressions were rendered as empty strings or synthetic values. A shell function replaced curl, captured its body and returned exit code 7. Container networking was disabled.

This experiment compares collection, body assembly and exit status. It does not reproduce an actual GitHub-hosted run reading real credentials or contacting the destination server.

Synthetic conditionABCD
No keys in files, history or named secretsTransfer function calledTransfer function calledTransfer function calledTransfer function called
Deleted OpenAI-format marker in historyIncludedIncludedIncludedIncluded
AWS ID on another branchIncludedIncludedIncludedIncluded
Secret assignment on the line after the AWS IDIncludedIncludedOmittedOmitted
Synthetic values supplied to named secretsNo referencesTwo values includedNo referencesFive values included
Transfer function returns 7Script exits 0Script exits 0Script exits 0Script exits 0

Four versions and three input conditions produced 12 executions. Captured bodies, byte counts, SHA-256 hashes and exit states are retained together. Separate command checks also showed that a marker on history-input line 200,001 was cut off and 301 unique matches were reduced to 300.

In all 12 isolated executions, the replacement transfer function returned 7 while the scripts exited 0. These are not actual server-transfer results.

Impact depends on the credential's permissions

Collection code alone does not establish a credential leak. Execution, outbound transfer, credential validity and later use each need evidence. The current findings support the following boundaries.

AssessmentEvidence availableCurrent finding
Collection and transfer codeOriginal files at 100 fixed SHAsCode captured
Uber default-branch update and restorationCommit, two PushEvents and captured headBranch returned to parent
Ability to collect deleted values and contextBodies captured from synthetic inputsConfirmed in the replay environment
Actual malicious workflow executionCurrent public run listingUnconfirmed
Server receipt and live-key lossActual body, network records and provider status unavailableUnconfirmed
Access, publication or API use in other systemsProvider and target-system audit records unavailableUnconfirmed

The public run listing for October 8–10 returned one pages build and deployment run on the parent commit. It concluded success, but it was not a Security Audit run on the malicious commit. Absence from the current listing does not establish absence of past execution.

Possible consequences by credential type

If an exposed value remains valid, its permissions determine the impact. The table connects possible consequences to records needed for investigation. Those consequences remain possible impacts, rather than findings of this incident.

Collection targetPossible impact if valid and permittedAdditional evidence
AWS ID with adjacent secret or session valuesAccess to or changes in permitted cloud resourcesKey status, policies and cloud audit records; an ID alone does not establish usable authentication
GitHub or GitLab token formatsAccess to permitted repositories or organizations; further changes with write permissionToken scope, accessible repositories and audit records
Direct GHCR_TOKEN referencePermitted container access or publicationValue actually supplied, package permissions and registry publication records
Direct FTP-related referencesServer file access or changes with a valid authentication combinationServer and account scope, authentication and file-change records
AI, Google, Slack or SendGrid key formatsAuthorized API use, data access or chargesKey restrictions, permissions and usage or access records

Prioritize credential validity and permissions over the number of matching strings. A publishing token requires review for further supply-chain changes. A read-only key requires review of the data it can access. Reuse of a credential across projects extends the investigation beyond the first repository.

Why deletion and a green run do not close the investigation

A deleted key can remain valid

When the value remains in Git history and the provider still accepts it, deleting the current-file copy leaves a usable credential exposed. Public-history exposure may predate the malicious CI run. Blocking the workflow and revoking potentially exposed keys require separate confirmation.

Secrets outside code still depend on execution controls

B and D insert named secrets into the body alongside search results. Moving a value out of source code does not protect it from a changed workflow that receives the value. Review which workflows can receive each secret, under which triggers and approvals. A and C retain history collection, so checking only direct secret references is insufficient.

CI success does not establish the state of a credential

Every replayed version exited 0 after the replacement transfer function failed. Key-free inputs also reached the transfer path. A successful exit does not establish absence of loss, and an outbound request alone does not establish theft of a live key. Link the execution's head SHA and original code to transfer content and provider status.

Restoration requires review of the earlier commit and run

Uber's default branch returned to the parent, but the malicious file remained available at its fixed SHA. A clean current branch is different evidence from an absence of past malicious changes. Use event before and head values and execution SHAs to reconstruct the earlier state. Continued object availability alone does not mean the workflow currently executes automatically.

Indicators and response priorities

IndicatorValue or behaviorScope
Destination193.32.204[.]199All four captured versions
Request path/?c=new or no explicit pathA/B and C/D respectively
File path.github/workflows/security-audit.ymlAll captured samples
Display nameSecurity AuditInsufficient for classification alone
Context markersAKIA_CTX_START, AKIA_CTX_ENDA and B only
Collection-transfer combinationGit patch search, multiple key patterns, body assembly and external POSTReview content across versions
Named-secret referencesTwo registry-related or five FTP-related namesB and D only

Connect workflow changes to runner egress when investigating. The IP is an indicator in these captured copies; it does not cover a collector that uses another destination. Depending on one filename or hash can miss another version. C and D send results to the same IP without the context markers.

Initial action by evidence available

What has been establishedPriority actionEvidence for the next decision
Suspicious workflow added; execution unknownBlock further execution, review and withdraw abused change access, preserve originals and eventsRun ID, head SHA, start time and organization audit records
Execution confirmed; body unavailableDetermine accessible files, refs and supplied secrets; prioritize decisions on active privileged credentialsOriginal version, runner networking, secret scope and provider status
Outbound request confirmed; live-key content unknownConnect request to run and determine content or accessible values; avoid treating request presence as confirmed live-key lossRequest body, proxy or runner records and credential validity
External exposure of an active credential confirmedRevoke and replace at the provider; investigate use within its permissionsAPI calls, logins and repository, package or server changes
Unauthorized use confirmedExtend investigation to affected systems and projects sharing the credentialAdditional access, permission changes and deployment records

If execution and access to a privileged active credential are established, do not wait for recovery of the exact body before deciding on revocation. A code copy alone is still insufficient to announce confirmed theft. Connect exposed values to their providers and responsible owners, then order response by access permissions.

Prevention requires reviewing workflow approvals and branch rules alongside account write access, secret-delivery conditions and runner egress. A read-only GITHUB_TOKEN does not itself stop transmission of values in files or separately supplied secrets. Check whether someone who can change a workflow can also reach the credentials supplied to its execution environment.

Conclusion: credential review continues after code recovery

GhostAction's collector assembles current-file matches, Git-history values and selected workflow secrets into one outbound body. Restoring the default branch does not invalidate values that an earlier run could access. Response needs to continue through identifying accessible credentials, checking whether they remain valid and determining which systems their permissions reach.

The public evidence establishes malicious code and change records; the isolated replay establishes collection behavior under controlled inputs. Actual loss and subsequent abuse require execution, network and provider records. Within those limits, the practical priority is clear: determine exposure scope and permissions while deciding which active privileged keys to revoke first.

Find the keys that remain in your environment

Starting an inventory only after an incident delays decisions about which credentials to revoke. Cremit detects credential exposure across connected sources such as GitHub, GitLab, Bitbucket, AWS S3, Slack and Jira. It brings discovery locations and verification results for supported credential types into an inventory. Supported AWS access-key and GCP API-key permission analysis adds context for response priorities.

Assign an owner to each finding and carry out revocation or replacement at the provider. Keep detected values separate from credentials whose current validity has been checked.

Start with 2 GB of free scanning each month. No credit card is required. To review source coverage and the workflow first, see Cremit secret scanning.

Scope and supporting evidence

The commit-search filter was "Add security audit workflow" with committer dates from October 8–9, 2026. The first page under GitHub's default relevance order supplied 100 SHAs. The workflow file at each fixed SHA returned HTTP 200, and all repository names were distinct. Search logic, body assembly and the POST destination were reviewed before grouping files by SHA-256. This is neither a random sample nor a campaign-wide census.

VersionFile SHA-256Size
A6d258db70677c99c08c9db37e002751eb0f0fb7c0cab2a4effd7c8fa8d115d191,837 bytes
Bd614306dd52bf3884157d9fcff682d7cdeb097e13da1527727a0e0c37bf9eabb1,995 bytes
C434b328d9d8c22b080fd945962323fe9452a6769c4d7bac44063b33abd8df17b1,239 bytes
Dc986e10092eed61478490f684a73e7e6f4cd98f419a88bf2b1eefcb2c1d359211,639 bytes

The Uber commit is e3c0dfa777d650d566aec11858ca2fd869b68ee6; its parent is ed473510065ed1101f60270e27f6601eb1f41cea. The commit API reports one added file, 30 additions and zero deletions. Public event and run responses were saved at capture time.

The sample index records repository, commit SHA, original URL, HTTP status, collection time, file hash, version and named-secret references. Replay used GNU grep 3.11 and Bash 5.2.37 in a network-disabled container, with a temporary Git 2.39.5 repository supplying patches. Captured bodies contain synthetic values only.

The search API's total message-match count is not a validated victim count. Sample proportions cannot be applied to all search results or private repositories. Public-event retention and return limits, unavailable run records and branch state at capture time restrict reconstruction of actual execution and loss. Initial-access and impact findings require additional GitHub audit records, runner networking, transfer bodies and provider logs.

Public original records

Command and platform documentation

Share it with your networkLinkedInX

Read next

Get the next one in your inbox

Monthly NHI security brief from Cremit. One email, high signal.

We never sell your email. Unsubscribe anytime.

GhostAction: Git History Credential Collection | Cremit