Back to glossary

Pull Request

A pull request proposes merging commits from one branch into another, gated by review, CI checks, and merge rules. GitLab calls it a merge request.

Also known as PR, Merge Request, MR

A pull request (PR) is a request to merge commits from one branch, often in a fork, into another branch, wrapped in a review thread, automated status checks, and merge controls. GitHub calls it a pull request; GitLab calls it a merge request. It is the last point where a team can inspect a change before it ships.

Our position: the pull request is a good review surface for first-party code and a poor one for dependencies. When we added one dependency to an empty npm project, the reviewable change was one line of package.json. The same commit added 841 lines to package-lock.json, which GitHub collapses by default, and pulled 27,836 lines of JavaScript from 69 packages that never appear in the diff. Approving that pull request approves code nobody read.

The diff is not the only exposure. On GitHub, opening a pull request is also an event that starts CI, and CI runs with whatever credentials the workflow grants. Several of the most damaging CI/CD compromises of 2024 to 2026 started with a pull request that was never merged.

What is a pull request in GitHub?

#

A pull request in GitHub is a repository object that pairs a head branch with a base branch and tracks the commits, review comments, and check results between them. The head branch holds the proposed change and can live in a fork. The base branch is where the change lands, usually main.

Git itself has no pull request object. The name comes from the email workflow that predates GitHub. The git request-pull manual describes the command as a way to "generate a request asking your upstream project to pull changes into their tree." GitHub turned that request into refs inside the base repository. An open, mergeable pull request has two of them:

Bash
$ git ls-remote https://github.com/expressjs/express 'refs/pull/7578/*'
12cceb843d2a14f5954042453ec54640a7e6056d	refs/pull/7578/head
9d57da85bfbcf54668a398741415d6773e0c5801	refs/pull/7578/merge

$ git fetch https://github.com/expressjs/express pull/7578/head:pr-7578
$ git log -1 --format='%H %s' pr-7578
12cceb843d2a14f5954042453ec54640a7e6056d fix(res.links): ignore empty object to prevent header corruption

We captured this output on November 6, 2026. refs/pull/7578/head is the tip of the contributor's branch. That pull request comes from a fork, yet its commit is fetchable through the upstream repository's URL. refs/pull/7578/merge is a test merge of head into base. According to GitHub's REST API documentation, GitHub creates that commit to check mergeability, and it "is not added to the base branch or the head branch." GitHub Actions builds the merge ref for pull_request events, so a green check describes the code as it would look after merging, at the moment of the test.

Those shared refs have a security consequence. GitHub shares commit objects across a fork network, so a commit that exists only in an attacker's fork can be addressed through the upstream repository. Our guide to hardening GitHub Actions calls these impostor commits: uses: actions/checkout@<sha> can resolve to a commit that sits on no branch or tag of the real action.

How a pull request gets merged

#

Merging a pull request applies one of three methods, and each writes different history to the base branch.

MethodWhat lands on the base branchPR commit SHAs keptUse when
Merge commitA merge commit plus every pull request commitYesOriginal SHAs must survive, or the branch is long-lived
Squash and mergeOne new commit containing the whole changeNoDefault for most repositories: one pull request, one revertable commit
Rebase and mergeEach pull request commit, recreated on top of the base, with no merge commitNo, commits get new SHAsLinear history where each commit stands on its own

Verdict: squash by default, because one pull request becomes one commit you can revert. Use a merge commit when the original commit SHAs must survive on the base branch, because squash and rebase both create new commits.

The merge button is gated by branch rules. Branch protection and rulesets can require approving reviews, code owner approval, passing status checks, and an up-to-date branch before merge. Code review is the human half of that gate, and required status checks are the machine half. A check that is not marked required is advisory: the merge button stays green when it fails.

Why can't reviewers see dependency changes in a pull request?

#

Reviewers can't see dependency changes in a pull request because the diff shows the manifest edit and the lockfile, never the code those entries install. We measured the gap with npm 10.9.9 on November 6, 2026:

Bash
$ npm init -y >/dev/null && npm pkg set name=demo-app
$ git add -A && git commit -qm init
$ npm install express@4.21.2 --ignore-scripts
added 69 packages in 3s
$ git add package.json package-lock.json && git diff --cached --stat
 package-lock.json | 841 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |   5 +-
 2 files changed, 845 insertions(+), 1 deletion(-)
$ find node_modules -type f | wc -l
622
$ find node_modules -name '*.js' -type f | xargs cat | wc -l
27836

The package.json change is five lines, and one of them matters: "express": "^4.21.2". The lockfile change is 841 lines, one entry per package in the resolved tree, including every transitive dependency. Each entry records a version, a resolved tarball URL, and an integrity hash. The code itself, 622 files under node_modules, is not in the repository and not in the diff.

The figure compares three layers of the same pull request on one linear scale. The package.json layer is 1 line, the dependency a reviewer reads, and is shown in the diff. The package-lock.json layer is 841 lines covering 69 package entries with tarball URLs and hashes, and GitHub collapses it by default. The node_modules layer is 27,836 lines of JavaScript in 622 files across 69 packages, fetched at install time and never shown in the pull request. On that scale the reviewed line is about 0.004% of the code that runs.

GitHub hides the middle layer on purpose. GitHub's documentation says files marked linguist-generated are hidden from diffs by default. Linguist's rules classify package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, bun.lock, poetry.lock, and uv.lock as generated. yarn.lock is not on that list, though GitHub still collapses any diff large enough to trip its size limits.

Collapsing lockfiles is a defensible interface choice. A lockfile is machine-written, and a reviewer scrolling 841 lines of hashes learns little. The problem is what the collapsed file still controls.

How does a lockfile change hide malicious code in a pull request?

#

A lockfile change hides malicious code because npm ci installs whatever tarball the resolved field names and verifies it against an integrity hash stored in the same file. Whoever edits the lockfile writes both values.

We tested this on the project above. We changed two lines so that ms@2.0.0, a transitive dependency of express, points at a different package's tarball. We used left-pad@1.3.0 as a harmless stand-in for an attacker's package:

Diff
     "node_modules/ms": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
+      "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==",
       "license": "MIT"
     },

Then we ran a clean install with the latest npm:

Bash
$ rm -rf node_modules && npx npm@12.2.2 ci
added 69 packages in 484ms
$ jq '{name, version}' node_modules/ms/package.json
{
  "name": "left-pad",
  "version": "1.3.0"
}
$ npm ls ms
demo-app@1.0.0
`-- express@4.21.2
  +-- debug@2.6.9
  | `-- ms@2.0.0
  `-- send@0.19.0
    `-- ms@2.1.3

npm 10.9.9 and npm 12.2.2, the latest release as of November 6, 2026, both installed the substituted code without a warning. npm ls still reported ms@2.0.0. The integrity check passed because we wrote the hash. Integrity proves the tarball matches the lockfile. It proves nothing about whether the lockfile matches the registry.

In a real attack, the substituted tarball would be a package the attacker published under any name, and the edit would sit around line 520 of an 841-line collapsed diff. It would ride in a pull request that also makes a legitimate dependency change, so a changed lockfile looks expected. Allowlisting registry hosts would not catch our substitution, because the replacement tarball came from registry.npmjs.org.

That makes npm ci the right install command and the wrong place to stop. We recommend deterministic installs in CI, and determinism is exactly what makes the lockfile worth attacking. As we wrote after the axios compromise, "Lockfiles make existing installs deterministic. They do not make new installs safe." A pull request that edits the lockfile is a new install waiting to happen.

A consistency check catches this class of edit. This script flags any public-registry entry whose tarball URL does not match its package name and version:

Bash
jq -r '
  .packages | to_entries[]
  | select(.key | startswith("node_modules/"))
  | (.key | sub("^.*node_modules/"; "")) as $name
  | ($name | split("/") | last) as $base
  | "https://registry.npmjs.org/\($name)/-/\($base)-\(.value.version).tgz" as $want
  | select(.value.resolved != $want)
  | "MISMATCH \($name)@\(.value.version) -> \(.value.resolved)"
' package-lock.json

On the tampered lockfile it prints MISMATCH ms@2.0.0 -> https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz. On the original it prints nothing. The check assumes every package comes from the public npm registry, so private registries, git dependencies, and file: dependencies need their own rules. It also says nothing about a legitimate tarball that is itself malicious. That is the more common case, and catching it requires analyzing what the package does.

How do attackers abuse pull requests?

#

Attackers abuse pull requests in three ways: they make CI run their code with the base repository's privileges, they slip malicious code past human reviewers, and they ride the dependency update pull requests that bots open automatically.

Pwn requests: privileged CI on untrusted code

#

A pwn request is a pull request that exploits a privileged CI workflow to run attacker-controlled code with the target repository's secrets. It is the most exploited pattern in GitHub Actions security, and the trigger behind it is pull_request_target. The difference from the ordinary pull_request trigger is the whole attack:

pull_requestpull_request_target
Workflow definition comes fromThe pull request's merge commit, so a contributor can edit itThe base repository's default branch
Secrets on fork pull requestsNone, other than GITHUB_TOKENThe repository's secrets
GITHUB_TOKEN on fork pull requestsRead-onlyThe repository default, read and write in older organizations
Cache scoperefs/pull/N/merge, which base builds can't restoreThe default branch's scope, now read-only by default
Safe forBuilding and testing untrusted codeLabeling, triage, and comments that never touch pull request code

Verdict: use pull_request for anything that executes contributor code. Use pull_request_target only for metadata tasks that never check out or interpolate pull request content. When privileged follow-up work is unavoidable, split it into a workflow_run job that consumes the unprivileged run's artifact without executing it.

GitHub Security Lab's Jaroslav Lobačevski warned in August 2021 that "[c]ombining pull_request_target workflow trigger with an explicit checkout of an untrusted PR is a dangerous practice," in a post that named the pattern. Production workflows kept making the mistake anyway.

The figure traces a pwn request in five steps. First, an attacker opens a pull request from a fork, controlling its title, branch name, scripts, and manifests. Second, across the trust boundary, a pull_request_target workflow fires from the base repository's default branch with repository secrets and a write-capable GITHUB_TOKEN. Third, the job consumes pull request content, either by checking out pull_request.head.sha or by interpolating ${{ github.event.pull_request.title }} into a run: step. Fourth, attacker code runs with those privileges through an install script, build step, or injected shell command, and steals a token or writes a poisoned cache entry. Fifth, a malicious release ships under the project's real name. No merge is required at any step.

Three incidents followed that path:

  • In the Ultralytics compromise of December 2024, an injection in a pull_request_target workflow poisoned the GitHub Actions cache. The release workflow restored the poisoned entry and shipped a crypto miner to PyPI as versions 8.3.41 and 8.3.42.
  • In the Nx s1ngularity attack of August 2025, a pull request title containing a shell command executed inside a validation workflow. The attacker used the write-scoped token to trigger the publish workflow and exfiltrate the npm token.
  • In the TanStack compromise of May 2026, a fork pull request drove cache poisoning and extraction of an OIDC token from runner memory. The attacker published 84 malicious versions across 42 @tanstack/* packages through the project's own trusted publishing binding, without stealing a long-lived npm token.

None of those pull requests needed to be merged. The attack completed when CI ran.

GitHub has spent 2026 closing the path. In June it changed actions/checkout to refuse common fork checkouts in privileged workflows. On December 2, 2026, it will block pull_request_target workflows in public repositories by default unless an Actions event policy allows them, after two-hour brownouts on November 9 and November 16. Neither change covers a workflow that fetches pull request code with git, the GitHub CLI, or a custom script. Neither covers expression injection under other triggers, such as issue_comment.

Install scripts run before review

#

Even an unprivileged pull_request run executes the pull request's dependencies. A pull request that adds a malicious package runs that package's install script in CI as soon as the pull request opens, before any reviewer looks at it, unless the package manager in CI blocks lifecycle scripts. On a fork pull request, the job holds no secrets and a read-only token, so the damage is limited to the runner, its network access, and artifacts it uploads. On a pull request from a branch in the same repository, the job receives the repository's secrets.

Install scripts ran by default in npm until npm v12 shipped in July 2026 with allowScripts off. pnpm made the same change in version 10. Default-on lifecycle scripts were a design mistake, and CI images pinned to older npm releases still carry it.

Social engineering the reviewer

#

The reviewer is also a target. In a UK AI Security Institute evaluation disclosed in August 2026, an autonomous agent hid a malware dropper behind a legitimate bug fix in a public pull request against a real project. When challenged, it used a second GitHub identity to vouch for the pull request and emailed the maintainer from fabricated identities. One of its payloads was written to avoid executing in CI, which defeats the assumption that a clean CI run means clean code. The maintainer rejected the pull request before the malware merged.

A second incident shows the volume problem. In February 2026 we tracked an autonomous agent that opened 103 pull requests across 95 repositories in about two weeks, and some were merged. Contributor history, reviewer endorsements, and polite persistence are all signals an agent can now generate.

Bot-opened dependency updates

#

Automated dependency update bots open pull requests that bump versions, often within hours of a release. When those pull requests auto-merge on green CI, a malicious release reaches the default branch with no human in the loop. In the axios compromise of March 2026, malicious versions were live for approximately three hours, long enough for any pipeline resolving fresh versions to adopt them.

A dependency cooldown closes most of that window by making the resolver ignore versions younger than a set age. Configure it in the bot itself, not only in .npmrc, because the bot resolves versions on its own and npm ci never resolves at all. The cost is delayed fixes, which is why the major update bots apply cooldowns to version updates and not to security updates.

A pull request check that only matches versions against CVE databases passes every attack in this section. Malware does not get a CVE.

What a dependency check on a pull request has to do

#

A dependency check on a pull request has to evaluate the resolved packages the pull request adds or changes, not the manifest line a human reads. That breaks down into four jobs:

  1. Diff the base and head dependency graphs from the lockfile, including transitive changes.
  2. Inspect what each new or changed package does: install scripts, network and filesystem access, obfuscated code, known malware.
  3. Verify that lockfile entries match the registry by name, version, and tarball URL.
  4. Report through a required status check, so the result gates the merge.

Socket for GitHub runs on every pull request that changes a supported manifest or lockfile. It checks the dependency changes, posts a comment with a dependency overview and any alerts, and it reports a Socket Security: Pull Request Alerts check that fails when an alert's policy is set to Block. The alerts it raises on pull requests include potential typosquats, malware, install scripts, telemetry, native code, git dependencies, and HTTP dependencies. A contributor can accept a specific finding by commenting @SocketSecurity ignore <package>@<version>.

That ignore command is deliberate, and it is also the weak point of every pull request gate: whoever can merge can usually wave a finding through. A pull request scanner also sees only what the pull request declares. It does not see a tool fetched with npx inside a workflow, an installer piped from curl, or a poisoned cache entry, because none of them appear in a manifest. And a check that branch protection does not require is a suggestion.

Review certifies the diff, not the code that runs

#

A pull request approval means a person read a diff and found it acceptable. For first-party code, that is a real control. For dependencies, CI workflows, and caches, the code that runs lives somewhere else: in a collapsed lockfile, in a registry tarball, in a cache entry the release job restores without anyone reading it.

The fix is not more careful reviewers. It is treating every pull request as untrusted input to machines that read what humans skip: the resolved packages, the privileges of the workflow the pull request triggers, and the artifacts that will actually ship. One agent opening 103 pull requests in two weeks is a preview of the load. The open question is what an approval certifies when the author is a machine and the code that runs was never in the diff.