Under your control
Agents / Supply chain and CI / github-workflow-trust
Open source · Apache-2.0

github-workflow-trust

Find GitHub Actions workflows that run untrusted code with credentials, and resolve what those credentials reach.

Run it
$ cynative --agent github-workflow-trust

The file the binary runs.

This is the whole agent, unabridged, exactly as it ships.

Agent file · built-in · github-workflow-trust.md
---
description: Find GitHub Actions workflows that run untrusted code with credentials, and resolve what those credentials reach.
---

Research which GitHub Actions workflows in this organization execute code an outsider controls while holding the repository's credentials. Take which organization that is from the credential's own organization listing rather than asking for it: the listing names the organizations the token reaches, and where it names more than one, report each of them. Take the repositories from the organization's own repository listing a few at a time, and take every repository fact the reads below need from that repository's own record rather than from that listing: a listing entry is the repository's whole record at several kilobytes each, so a page asked for a whole organization at once carries far more record than any of the reads below use.

Scan every workflow file for four things, taking the file list from the repository's own workflow listing and each file's own content from a contents read on the path that listing gives, which is where the content is and is one call per file. That listing also names the workflows GitHub generates rather than the ones the repository carries, and a generated one has no file at the path it names, so read content only for a path inside the workflow directory. First, a fork-reachable trigger combined with a checkout of untrusted code: pull_request_target with a ref of github.event.pull_request.head.sha, github.event.pull_request.head.ref or a merge ref, or a repository of github.event.pull_request.head.repo.full_name whether or not an explicit ref accompanies it, since checkout then resolves that ref, or absent one that repository's own default branch, inside the fork the attacker controls, or workflow_run with a ref of github.event.workflow_run.head_sha, or with a repository of github.event.workflow_run.head_repository.full_name whether or not an explicit ref accompanies it, since checkout then resolves that ref, or absent one that repository's own default branch, inside the fork the attacker controls, while a bare ref of github.event.workflow_run.head_branch with no such repository resolves inside this repository rather than inside the fork it names. Second, an attacker-controlled expression interpolated into a run block, where it is substituted before the shell parses the command - github.event.pull_request.title, github.event.pull_request.body, github.head_ref and github.event.pull_request.head.ref or .head.label under pull_request_target, github.event.issue.title or github.event.issue.body under the issues trigger, github.event.comment.body, github.event.issue.title or github.event.issue.body under the issue_comment trigger, which are fork-reachable in their own right because anyone who can open an issue or comment can set them, and github.event.workflow_run.head_branch or github.event.workflow_run.display_title under the workflow_run trigger, fork-reachable because the branch name and title of the run that triggered it come from the fork that ran it. The same expressions interpolated into the script input of an actions/github-script step reach the job's credentials the same way, since GitHub substitutes them into the script text before the JavaScript runs. One of these expressions assigned to a workflow, job or step env value carries the same taint into every step that sees the variable, so a run block or a script input carrying ${{ env.<name> }} for such a variable is the same substitution under another name, and a run block that hands the variable itself back to a shell or an interpreter to parse as code, through eval, a -c argument to bash or sh, or a -e argument to an interpreter, reaches the job's credentials the same way. A variable referenced as $<name> inside quotes and read as data is the mitigation rather than the finding; it is the re-parsing that makes it one. Third, an action published outside this organization and outside the actions and github namespaces referenced by anything other than a commit SHA; a uses value naming a path inside the repository names a composite action manifest whose pin this does not judge, so count that reference rather than judging what it pins. Fourth, a job targeting a self-hosted runner. All four are properties of the same file text, so evaluate them on the same pass that fetches it, because a separate pass per condition costs the whole enumeration again. The first two are defined on the triggers they name, so a workflow whose own triggers are none of pull_request_target, workflow_run, issues or issue_comment cannot meet either: take the trigger set from the file text first and judge those two over the workflows that carry one of those triggers, while the remaining two are judged over every workflow.

Where a workflow carrying one of these triggers calls a local composite action with uses: ./ naming a path inside the repository, read that action's manifest from a directory listing of the action's path, which answers with whichever manifest file the action carries, and judge its run steps against the same attacker-controlled expressions directly, and against the matching inputs.<name> expression where the caller's with: passes one of these expressions as that action's input, since a composite action runs inside the calling job's own context and reaches what that job holds whether the taint arrives as a bare event expression or through its own input, and where that manifest itself calls another local composite action the same way, follow it too, judging that nested action's run steps against its own inputs.<name> where the calling action's with: passes one of these expressions or its own tainted input, keeping a visited set of the manifests already read so a cycle does not repeat one, and judging a manifest again where a different call path reaches it with a different tainted input, since the visited set exists to stop a cycle within one path rather than to remember every path a manifest was already judged on. Where a workflow carrying one of these triggers calls a local reusable workflow with uses: ./ naming a path inside the workflow directory, judge that reusable workflow's own run steps, already read as part of this same scan, against the same attacker-controlled expressions directly, and against the matching inputs.<name> expression where the caller passes one of these expressions as a job's with: input, since the called workflow runs within the caller's own run and carries both the caller's event context and the caller's own GITHUB_TOKEN whether or not secrets: forwards anything else, and where that reusable workflow itself calls another local reusable workflow the same way, follow it too, judging that nested workflow's run steps against the same attacker-controlled expressions directly, and against its own inputs.<name> where the calling workflow's with: passes one of these expressions or its own tainted input, keeping a visited set of the reusable workflows already judged so a cycle does not repeat one, and judging a reusable workflow again where a different call path reaches it with a different tainted input, since the visited set exists to stop a cycle within one path rather than to remember every path a workflow was already judged on.

Read whether the repository accepts pull requests from forks, and read the secret scanning, push protection and immutable release states per repository. Read whether immutable releases is enabled from the repository's own immutable-releases setting, not from any one release: that setting governs every release made while it is on, while a release object carries only that release's own immutable value at the moment it was published, and the setting can move past that value in either direction afterward. Read whether the repository publishes releases at all from one release rather than its whole history, since one release at any time answers that question and a release entry runs to tens of kilobytes. Immutable releases belong with the rest: a mutable release can be republished under the same name after the review that approved it, which is the same failure as an action pinned to a tag rather than a SHA - what runs is not what was approved.

A denied, unreachable, partial or empty read is not a clean result: name the resource and the field you could not read and mark it unresolved rather than reporting clean, and name the bound beside the finding. Where every read above is denied, the report is that list of unresolved reads.

Where nothing meets the question above, say so in the report's first sentence and before any count or inventory, naming the objects it asks about rather than referring to them, and say there which of three answers it is: they are absent, or they are present and clean, or they were not read. An enumeration that answered with nothing still answered, and only a read that did not complete is unread.

Stop here if no workflow combines a fork-reachable trigger with a checkout of untrusted code, no attacker-controlled expression appears inside a run step, a script input of an actions/github-script step, or a local composite or reusable workflow's own run step reached that way, directly or through an env variable carrying one, every action published outside this organization and outside the actions and github namespaces is pinned to a SHA, and no self-hosted runner is registered to a repository that accepts pull requests from forks. Report the workflow count split by which of the fork-reachable triggers named above each uses, the checkout steps naming an untrusted ref as a count, the count of run steps and script inputs interpolating one of the attacker-controlled expressions named above, including those reached through a local composite action or a local reusable workflow, directly or through its own inputs or through an env variable carrying one, the count of uses references naming a local composite action manifest whose pin this did not judge, the count of repositories that publish releases, the secret scanning, push protection and immutable release states as counts of repositories in each state, the count of repositories the enumeration covered, and which of the workflow, action and runner enumerations above answered and which did not, naming each one that did not rather than counting it as zero, and end.

Only for the workflows that are not clean:

Trace each one from the trigger an outsider reaches to what the job holds when it runs, at the file and line where it happens. The secrets are the ones the workflow file itself references, since the organization's own Actions secret and variable inventory needs an administrator and the ceiling does not reach it.

Read the permissions block and the repository's default token permission and any id-token: write with the cloud role the workflow assumes. Where the cloud connector cannot resolve the role, the role name alone is the answer. Resolve it against the account's own authorization details for roles, which return every role with its trust policy and its attached policies in one answer rather than needing a read per name: a role the workflow names and the account does not hold is absent from that answer rather than a read that did not answer.

A self-hosted runner on a repository that accepts fork pull requests carries state between jobs, so what one job leaves behind the next one finds.

Secret scanning or push protection disabled is the detection state for a finding here, and a repository publishing releases without immutable releases belongs against the workflow that publishes them.

Report an unpinned action as intentional where the evidence supports it: an action published by this organization, or one in the actions or github namespace. Name the evidence. A tag-referenced action from outside those namespaces with no such evidence is not intentional.

Order findings by risk, most consequential first.

Call shapes a run has proven:

Repository contents: GET /repos/{owner}/{repo}/contents/{directory} for the file list, and GET /repos/{owner}/{repo}/contents/{path} for one file's content, on api.github.com. Either answers with or without an Accept of application/vnd.github.raw+json.

Workflows and runners: GET /repos/{owner}/{repo}/actions/workflows, GET /repos/{owner}/{repo}/actions/runners, GET /repos/{owner}/{repo}/actions/permissions and GET /repos/{owner}/{repo}/actions/permissions/workflow, on api.github.com.

Release state: GET /repos/{owner}/{repo}/releases?per_page=1 for whether a repository publishes releases, and GET /repos/{owner}/{repo}/immutable-releases for the repository's own immutable-releases setting, on api.github.com.

Identity and organizations: GET /user and GET /user/orgs on api.github.com.

Organization repositories: GET /orgs/{org}/repos?per_page=4&page={n} on api.github.com.

IAM: GET /?Action=GetAccountAuthorizationDetails&Filter.member.1=Role&Version=2010-05-08 and GET /?Action={Operation}&Version=2010-05-08, on iam.amazonaws.com.

Copy and edit.

A file in ~/.cynative/agents/ wins over a built-in of the same name. Give your copy a distinct name to keep both.

$ cynative agents show github-workflow-trust > ~/.cynative/agents/my-github-workflow-trust.md

An agent supplies text only. Approvals, connector authorization and the read-only ceilings are enforced at every tool call regardless of what the agent says, and the LLM can neither introduce nor select an agent.

Previous github-unpatched-dependencies All 45 agents Next aws-supply-chain
Cynative