DEV Community

Muhammad Nauman Hafeez
Muhammad Nauman Hafeez

Posted on

A Supply-Chain Attack Hit Our Repos. Here's How We Locked Down GitHub So It Can't Happen Again

This is the follow-up to my previous post: A Poisoned npm Package Infected Our Production Server, Here's How We Found and Removed It. If you haven't read that one, start there — it covers the incident itself. This post covers what we changed afterwards so it can't happen again.


A few weeks ago I spent three of the worst days of my (so far short) DevOps career hunting malware on a production server. A poisoned npm package had made it into one of our builds, and on top of that, an entire repository on our GitHub org had been force-replaced in a single commit — whole codebase swapped out, new package.json, new CI workflow, everything.

We cleaned the server. We rotated every credential. But when the adrenaline wore off, one question kept bothering me:

Why was it even possible for one push to replace an entire repository?

The answer was embarrassing: because nothing was stopping it. No branch protection. No required reviews. Anyone with write access could force-push to any branch, delete branches, and push straight to production-deploying branches. We'd been running on trust, and trust doesn't scale — especially not when supply-chain attacks are involved.

This post is about how we fixed that with GitHub rulesets. I'm about 2.5 years into DevOps, so this isn't a "grizzled veteran" take — it's a "we got burned and here's exactly what we did about it" take. Which, honestly, might be more useful.

The goal

After the incident, we defined what a "safe" repo looks like for us:

  1. No force pushes. History rewriting is how the attack rewrote our reality. Never again.
  2. No branch deletion. Deleting a branch shouldn't be a one-click way to destroy evidence.
  3. Every change goes through a pull request. No direct pushes to protected branches — even from admins.
  4. At least one approving review. A second pair of eyes on every change.
  5. Nobody bypasses the rules. Not admins. Not the org owner. Nobody.

That last one turned out to be the most interesting.

Why we chose rulesets over classic branch protection

GitHub has two mechanisms for this: the older branch protection rules and the newer rulesets. We went with rulesets, for a few reasons:

  • Layering: multiple rulesets can apply at once, and GitHub enforces the union of all of them. You can have a baseline ruleset plus a stricter one for release branches.
  • Better targeting: one ruleset can target all branches (~ALL), just the default branch (~DEFAULT), or name patterns like release/*.
  • Explicit bypass list: rulesets have a bypass list that is empty by default. Classic branch protection has the infamous "Do not allow bypassing the above settings" checkbox that everyone forgets to tick — which silently exempts admins.
  • Visibility: anyone with read access to the repo can see which rules apply. When a push gets rejected, the error message names the ruleset. No mystery.

Classic branch protection still works, but if you're setting things up fresh in 2026, rulesets are the better tool.

The ruleset we deployed

Here's the exact configuration we now apply to every repo in the org. The important top-level decisions:

  • Enforcement: Active (not "Evaluate" — evaluate mode only logs, it doesn't block)
  • Target branches: All branches (~ALL)
  • Bypass list: empty. This is the whole point.

The full JSON (you can import this directly — steps below):


json
{
  "name": "org-branch-guard",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": {
      "include": ["~ALL"],
      "exclude": []
    }
  },
  "bypass_actors": [],
  "rules": [
    { "type": "non_fast_forward" },
    { "type": "deletion" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": true,
        "require_code_owner_review": false,
        "automatic_copilot_code_review_enabled": false,
        "allowed_merge_methods": ["merge", "squash", "rebase"]
      }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)