{
	"id": "023da913-101e-4762-b8d5-d019d56ee3ab",
	"created_at": "2026-04-06T00:12:18.466835Z",
	"updated_at": "2026-04-10T03:24:30.233628Z",
	"deleted_at": null,
	"sha1_hash": "d82d3d59c3d63463818fb5eeec7dcabe9b6b18ec",
	"title": "Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 107755,
	"plain_text": "Keeping your GitHub Actions and workflows secure Part 1:\r\nPreventing pwn requests\r\nBy jarlob\r\nPublished: 2021-08-03 · Archived: 2026-04-05 16:54:11 UTC\r\nThis post is the first in a series of posts about GitHub Actions security. Part 2, Part 3, Part 4\r\nSecure your workflows with CodeQL: You can enable CodeQL for GitHub Actions to identify and fix the\r\npatterns described in this post.\r\nIn this article, we’ll discuss some common security malpractices for GitHub Actions and workflows, and how to\r\nbest avoid them. Our examples are based on real-world GitHub workflow implementation vulnerabilities the\r\nGitHub Security Lab has reported to maintainers.\r\nGitHub workflows can be triggered through a wide variety of repository events. This includes events related to\r\nincoming pull requests (PR). There exists a potentially dangerous misuse of the pull_request_target workflow\r\ntrigger that may lead to malicious PR authors (i.e. attackers) being able to obtain repository write permissions or\r\nstealing repository secrets.\r\nTL;DR: Combining pull_request_target workflow trigger with an explicit checkout of an untrusted PR is a\r\ndangerous practice that may lead to repository compromise.\r\nAny automated processing of PRs from an external fork is potentially dangerous and such PRs should be treated\r\nlike untrusted input. It is common CI/CD practice to ensure that when a new PR is submitted that it does not break\r\nthe build for your project, that no functionality regressions are introduced, and that tests are passing. But when\r\noperating on untrusted PRs, such automated behavior can leave your repository exposed to abuse if you’re not\r\ncareful.\r\nSince, by definition, a PR supplies code to any build or test logic in place for your project, attackers can achieve\r\narbitrary code execution in a workflow runner operating on a malicious PR in a variety of ways. They may submit\r\nmalicious changes to the existing build scripts like make or powershell files or redefine the build script in the\r\npackage.json file. They can simply write their payload as a new test that will be run with others. They can\r\nachieve code execution even before the actual build happens. Npm packages for example may have custom\r\npreinstall and postinstall scripts, so running npm install would already trigger any malicious code if\r\nthe attackers added a new package reference. Any modern build orchestration is complex enough to have multiple\r\ncode injection points.\r\nThis is why you should never checkout and build PRs from untrusted sources on a local machine without carefully\r\nexamining the code for the PR.\r\nDue to the dangers inherent to automatic processing of PRs from forks, GitHub’s standard pull_request\r\nworkflow trigger by default prevents write permissions and secrets access to the target repository. However, in\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 1 of 7\n\nsome scenarios such access is needed to properly process the PR. To this end the pull_request_target\r\nworkflow trigger was introduced.\r\nLike how the introduction of Cross-Origin Resource Sharing (CORS) in the browser security model allowed a\r\nweb site developer to relax the default Same Origin Policy (SOP), the introduction of pull_request_target\r\ntrigger allowed a workflow writer to relax some restrictions to a target repository and must be used carefully. The\r\nmain differences between the two triggers are:\r\n1. Workflows triggered via pull_request_target have write permission to the target repository. They also\r\nhave access to target repository secrets. The same is true for workflows triggered on pull_request from a\r\nbranch in the same repository, but not from external forks. The reasoning behind the latter is that it is safe\r\nto share the repository secrets if the user creating the PR has write permission to the target repository\r\nalready.\r\n2. pull_request_target runs in the context of the target repository of the PR, rather than in the merge\r\ncommit. This means the standard checkout action uses the target repository to prevent accidental usage of\r\nthe user supplied code.\r\nThese safeguards enable granting the pull_request_target additional permissions. The reason to introduce the\r\npull_request_target trigger was to enable workflows to label PRs (e.g. needs review ) or to comment on the\r\nPR. The intent is to use the trigger for PRs that do not require dangerous processing, say building or running the\r\ncontent of the PR.\r\nTogether with the pull_request_target , a new trigger workflow_run was introduced to enable scenarios that\r\nrequire building the untrusted code and also need write permissions to update the PR with e.g. code coverage\r\nresults or other test results. To do this in a secure manner, the untrusted code must be handled via the\r\npull_request trigger so that it is isolated in an unprivileged environment. The workflow processing the PR\r\nshould then store any results like code coverage or failed/passed tests in artifacts and exit. The following\r\nworkflow then starts on workflow_run where it is granted write permission to the target repository and access to\r\nrepository secrets, so that it can download the artifacts and make any necessary modifications to the repository or\r\ninteract with third party services that require repository secrets (e.g. API tokens).\r\nBelow is an example of the intended usage in which the results of an unprivileged pull_request workflow are\r\ncombined with a privileged workflow to leave a comment in response to a received PR:\r\nReceivePR.yml\r\nname: Receive PR\r\n# read-only repo token\r\n# no access to secrets\r\non:\r\n pull_request:\r\njobs:\r\n build:\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 2 of 7\n\nruns-on: ubuntu-latest\r\n steps:\r\n - uses: actions/checkout@v2\r\n # imitation of a build process\r\n - name: Build\r\n run: /bin/bash ./build.sh\r\n - name: Save PR number\r\n run: |\r\n mkdir -p ./pr\r\n echo ${{ github.event.number }} \u003e ./pr/NR\r\n - uses: actions/upload-artifact@v2\r\n with:\r\n name: pr\r\n path: pr/\r\nCommentPR.yml\r\nname: Comment on the pull request\r\n# read-write repo token\r\n# access to secrets\r\non:\r\n workflow_run:\r\n workflows: [\"Receive PR\"]\r\n types:\r\n - completed\r\njobs:\r\n upload:\r\n runs-on: ubuntu-latest\r\n if: \u003e\r\n github.event.workflow_run.event == 'pull_request' \u0026\u0026\r\n github.event.workflow_run.conclusion == 'success'\r\n steps:\r\n - name: 'Download artifact'\r\n uses: actions/github-script@v3.1.0\r\n with:\r\n script: |\r\n var artifacts = await github.actions.listWorkflowRunArtifacts({\r\n owner: context.repo.owner,\r\n repo: context.repo.repo,\r\n run_id: ${{github.event.workflow_run.id }},\r\n });\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 3 of 7\n\nvar matchArtifact = artifacts.data.artifacts.filter((artifact) =\u003e {\r\n return artifact.name == \"pr\"\r\n })[0];\r\n var download = await github.actions.downloadArtifact({\r\n owner: context.repo.owner,\r\n repo: context.repo.repo,\r\n artifact_id: matchArtifact.id,\r\n archive_format: 'zip',\r\n });\r\n var fs = require('fs');\r\n fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data));\r\n - run: unzip pr.zip\r\n - name: 'Comment on PR'\r\n uses: actions/github-script@v3\r\n with:\r\n github-token: ${{ secrets.GITHUB_TOKEN }}\r\n script: |\r\n var fs = require('fs');\r\n var issue_number = Number(fs.readFileSync('./NR'));\r\n await github.issues.createComment({\r\n owner: context.repo.owner,\r\n repo: context.repo.repo,\r\n issue_number: issue_number,\r\n body: 'Everything is OK. Thank you for the PR!'\r\n });\r\nThis example saves the PR number as a workflow artifact, but can be easily extended to pass code coverage\r\nmessages or similar PR artifacts in the same way.\r\nNote that:\r\n1. The example above comments on the PR with the help of github-script instead of a more direct PR\r\ndriven action. This is because not all actions can be used from workflow_run if they expect to find all\r\ntheir needed information in the context object. The workflow_run context is different from the\r\npull_request context and it doesn’t contain, for example, the PR number. Such actions need to be\r\nupdated to accept optional explicit input parameters to provide what is missing in the workflow_run\r\ncontext.\r\n2. Incoming data from artifacts is potentially untrusted. When used in a safe manner, like reading PR numbers\r\nor reading a code coverage text to comment on the PR, it is safe to use such untrusted data in the privileged\r\nworkflow context. However if the artifacts were, for example, binaries built from an untrusted PR, it would\r\nbe a security vulnerability to run them in the privileged workflow_run workflow context. Artifacts\r\nresulting from untrusted PR data are themselves untrusted and should be treated as such when handled in\r\nprivileged contexts.\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 4 of 7\n\nAs you can see, the usage of two workflows and passing around workflow artifacts introduces some overhead. If\r\nyour workflow scenario simply requires commenting on the PR, but does not require a check out of the modified\r\ncode, using pull_request_target is a logical shortcut.\r\nUnfortunately some repository workflows take this a step further and use pull_request_target with an explicit\r\nPR checkout, for example:\r\n# INSECURE. Provided as an example only.\r\non:\r\n pull_request_target\r\njobs:\r\n build:\r\n name: Build and test\r\n runs-on: ubuntu-latest\r\n steps:\r\n - uses: actions/checkout@v2\r\n with:\r\n ref: ${{ github.event.pull_request.head.sha }}\r\n - uses: actions/setup-node@v1\r\n - run: |\r\n npm install\r\n npm build\r\n - uses: completely/fakeaction@v2\r\n with:\r\n arg1: ${{ secrets.supersecret }}\r\n - uses: fakerepo/comment-on-pr@v1\r\n with:\r\n message: |\r\n Thank you!\r\nThe potentially untrusted code is being run during npm install or npm build as the build scripts and\r\nreferenced packages are controlled by the author of the PR.\r\nHaving said that, mixing pull_request_target with an explicit PR checkout is not always vulnerable. The\r\nworkflow may, for example:\r\nReformat and commit the code\r\nCheckout both base and head repositories and generate a diff\r\nRun grep on the checked out source.\r\nGenerally speaking, when the PR contents are treated as passive data, i.e. not in a position of influence over the\r\nbuild/testing process, it is safe. But the repository owners must be extra careful not to trigger any script that may\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 5 of 7\n\noperate on PR controlled contents like in the case of npm install .\r\nTo remediate the issue, repository owners could:\r\nAvoid using pull_request_target if the workflow doesn’t need write repository permissions and doesn’t\r\nuse any repository secrets. They can simply use the pull_request trigger instead.\r\nAssign repository privileges only where needed explicitly through pull_request and workflow_run as\r\nin our previous example.\r\nAdd a condition to the pull_request_target to run only if a certain label is assigned the PR, like safe\r\nto test that indicates the PR has been vetted by someone with write privileges to the target repository.\r\nNote that this kind of label based verification is still prone to a race condition in which the attacker may\r\npush new changes after the workflow was approved (labeled), but has not started yet. As such this approach\r\nshould only be used as a temporary solution, until a proper fix from the options above is applied. Since\r\nexternal users do not have the permission to assign labels, this effectively requires repository owners to\r\nmanually review changes first and is also prone to human error.\r\n # Only as a temporary fix.\r\n on:\r\n pull_request_target:\r\n types: [labeled]\r\n jobs:\r\n build:\r\n name: Build and test\r\n runs-on: ubuntu-latest\r\n if: contains(github.event.pull_request.labels.*.name, 'safe to test')\r\nNote that there is an important “gotcha” to any remediation put in place for a vulnerable workflow. All PRs that\r\nwere opened before a fix was made to the vulnerable workflow will use the version of the workflow as it existed\r\nat the time the PR was opened. That means that if there is a pending PR, any updates to the PR may still abuse the\r\nvulnerable workflow. It is advisable to either close or rebase such PRs if untrusted commits may be added to them\r\nafter a vulnerable workflow is fixed.\r\nYou may ask yourself: if the pull_request_target workflow only checks out and builds the PR, i.e. runs\r\nuntrusted code but doesn’t reference any secrets, is it still vulnerable?\r\nYes it is, because a workflow triggered on pull_request_target still has the read/write repository token in\r\nmemory that is potentially available to any running program. If the workflow uses actions/checkout and does\r\nnot pass the optional parameter persist-credentials as false, it makes it even worse. The default for the\r\nparameter is true . It means that in any subsequent steps any running code can simply read the stored repository\r\ntoken from the disk. If you don’t need a repository write access or secrets, just stick to the pull_request trigger.\r\nWe have also noticed a pattern that has a vulnerable intent of use, however due to misunderstanding of\r\npull_request_target ends up being a broken, but not vulnerable, workflow. In these cases, repositories\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 6 of 7\n\nswitched to using the pull_request_target trigger, but use the default values for the actions/checkout action,\r\nfor example:\r\n# The workflow is broken. DO NOT use it in production.\r\non: [push, pull_request_target]\r\njobs:\r\n build:\r\n runs-on: ubuntu-latest\r\n steps:\r\n - name: Checkout\r\n uses: actions/checkout@v2\r\n - name: Build and test\r\n run: /bin/bash ./build.sh \u0026\u0026 /bin/bash ./runtests.sh\r\n - name: Report\r\n if: failure() \u0026\u0026 github.event.action != 'push'\r\n with: fancy/commenter@v1\r\n message: |\r\n Some checks have failed.\r\nThis doesn’t do what the authors most likely intended. In case of a PR, it builds the latest changeset from the\r\ntarget repository. The workflow is broken in the sense that it does not actually build the PR, but luckily is not\r\nvulnerable, since there is no explicit checkout of the actual PR contents or unsafe handling of those contents.\r\nConclusion\r\nIn this post we’ve examined some of the common issues when processing untrusted PR input to your GitHub\r\nworkflows. It is important to understand the various privilege levels that pull_request , pull_request_target ,\r\nand workflow_run afford the code processing the incoming PR. Be careful when using pull_request_target\r\nand only use it when you actually need the privileged context of the target repo available in your workflow,\r\nespecially when combined with explicit handling of the contents of an untrusted PR.\r\nThis post is the first in a series of posts about GitHub Actions security. Read the next post\r\nSource: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nhttps://securitylab.github.com/resources/github-actions-preventing-pwn-requests/\r\nPage 7 of 7",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"MITRE"
	],
	"references": [
		"https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/"
	],
	"report_names": [
		"github-actions-preventing-pwn-requests"
	],
	"threat_actors": [
		{
			"id": "d90307b6-14a9-4d0b-9156-89e453d6eb13",
			"created_at": "2022-10-25T16:07:23.773944Z",
			"updated_at": "2026-04-10T02:00:04.746188Z",
			"deleted_at": null,
			"main_name": "Lead",
			"aliases": [
				"Casper",
				"TG-3279"
			],
			"source_name": "ETDA:Lead",
			"tools": [
				"Agentemis",
				"BleDoor",
				"Cobalt Strike",
				"CobaltStrike",
				"RbDoor",
				"RibDoor",
				"Winnti",
				"cobeacon"
			],
			"source_id": "ETDA",
			"reports": null
		},
		{
			"id": "aa73cd6a-868c-4ae4-a5b2-7cb2c5ad1e9d",
			"created_at": "2022-10-25T16:07:24.139848Z",
			"updated_at": "2026-04-10T02:00:04.878798Z",
			"deleted_at": null,
			"main_name": "Safe",
			"aliases": [],
			"source_name": "ETDA:Safe",
			"tools": [
				"DebugView",
				"LZ77",
				"OpenDoc",
				"SafeDisk",
				"TypeConfig",
				"UPXShell",
				"UsbDoc",
				"UsbExe"
			],
			"source_id": "ETDA",
			"reports": null
		}
	],
	"ts_created_at": 1775434338,
	"ts_updated_at": 1775791470,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/d82d3d59c3d63463818fb5eeec7dcabe9b6b18ec.pdf",
		"text": "https://archive.orkl.eu/d82d3d59c3d63463818fb5eeec7dcabe9b6b18ec.txt",
		"img": "https://archive.orkl.eu/d82d3d59c3d63463818fb5eeec7dcabe9b6b18ec.jpg"
	}
}