# 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

Source: https://socket.dev/glossary/pull-request

Last updated: 2026-11-06

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](https://socket.dev/glossary/fork). The base branch is where the change lands, usually `main`.

[Git](https://socket.dev/glossary/git) itself has no pull request object. The name comes from the email workflow that predates GitHub. The [`git request-pull` manual](https://git-scm.com/docs/git-request-pull) 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](https://docs.github.com/en/rest/pulls/pulls), 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](https://socket.dev/blog/hardening-github-actions-against-outside-threats) 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.

| Method | What lands on the base branch | PR commit SHAs kept | Use when |
| --- | --- | --- | --- |
| Merge commit | A merge commit plus every pull request commit | Yes | Original SHAs must survive, or the branch is long-lived |
| Squash and merge | One new commit containing the whole change | No | Default for most repositories: one pull request, one revertable commit |
| Rebase and merge | Each pull request commit, recreated on top of the base, with no merge commit | No, commits get new SHAs | Linear 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](https://socket.dev/glossary/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](https://socket.dev/glossary/lockfile) change is 841 lines, one entry per package in the resolved tree, including every [transitive dependency](https://socket.dev/glossary/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.

> Adding express 4.21.2 to an empty npm project: 1 line of package.json is shown in the pull request diff, 841 lines of package-lock.json are collapsed by default, and 27,836 lines of JavaScript in node\_modules never appear 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](https://docs.github.com/en/repositories/working-with-files/managing-files/customizing-how-changed-files-appear-on-github) says files marked `linguist-generated` are hidden from diffs by default. [Linguist's rules](https://github.com/github-linguist/linguist/blob/main/lib/linguist/generated.rb) 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](https://socket.dev/blog/axios-npm-package-compromised), ["Lockfiles make existing installs deterministic. They do not make new installs safe."](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) 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](https://socket.dev/glossary/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\_request | pull\_request\_target |
| --- | --- | --- |
| Workflow definition comes from | The pull request's merge commit, so a contributor can edit it | The base repository's default branch |
| Secrets on fork pull requests | None, other than GITHUB\_TOKEN | The repository's secrets |
| GITHUB\_TOKEN on fork pull requests | Read-only | The repository default, read and write in older organizations |
| Cache scope | refs/pull/N/merge, which base builds can't restore | The default branch's scope, now read-only by default |
| Safe for | Building and testing untrusted code | Labeling, 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](https://securitylab.github.com/resources/github-actions-preventing-pwn-requests). Production workflows kept making the mistake anyway.

> Anatomy of a pwn request in five steps: a fork pull request with attacker-controlled fields triggers a pull\_request\_target workflow that runs with the base repository's secrets, the workflow executes pull request content, attacker code steals a token or poisons the cache, and a malicious release ships under the project's name. Examples: Ultralytics December 2024, Nx August 2025, TanStack May 2026.

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](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning) 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](https://socket.dev/blog/nx-supply-chain-attack-investigation-github-actions-workflow-exploit) 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](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) 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](https://socket.dev/blog/github-actions-checkout-blocks-pull-request-target-checkouts). On December 2, 2026, it will [block `pull_request_target` workflows in public repositories by default](https://socket.dev/blog/github-to-block-pull-request-target) 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](https://socket.dev/glossary/postinstall-scripts) ran by default in npm until [npm v12 shipped in July 2026 with `allowScripts` off](https://socket.dev/blog/npm-12). 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](https://socket.dev/blog/ai-agent-open-source-malware), 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](https://socket.dev/blog/ai-agent-lands-prs-in-major-oss-projects-targets-maintainers-via-cold-outreach), 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](https://socket.dev/glossary/automated-dependency-updates) 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](https://socket.dev/blog/hardening-github-actions-against-outside-threats), long enough for any pipeline resolving fresh versions to adopt them.

A [dependency cooldown](https://socket.dev/glossary/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](https://docs.socket.dev/docs/socket-for-github-pull-request-scanning) 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>`](https://docs.socket.dev/docs/ignoring-pull-request-alerts).

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.
