Patch Your Runtime Fast, Take Your Dependencies Slow

In the last few weeks, we saw a run of critical security issues in PHP, GitLab, and Ruby. That came just a few months after the ChainDrop worm spread through npm.

With AI agents now writing code and installing packages alongside us, security has become our biggest concern. We cannot stop the next issue. But we can make sure it does less damage when it lands.

The rule we follow is simple: patch your runtime fast, take your dependencies slow. Below are the ten changes we made to put that rule into practice.

1. Move from Yarn to pnpm

We moved our JavaScript projects from Yarn to pnpm. Not for speed. For the defaults.

  • Release-age gate: minimumReleaseAge refuses versions younger than a set age, including transitive ones.
  • No install scripts by default: dependency preinstall and postinstall scripts do not run unless you allow the package.
  • Strict node_modules: code can only import what its package.json declares. No phantom dependencies.
  • Content-addressable store: every file is stored once by hash and checked against the lockfile integrity.
# pnpm-workspace.yaml
# Only install versions that are at least 21 days old.
# Value is in minutes: 21 * 24 * 60 = 30240
minimumReleaseAge: 30240

# Dependency build scripts (preinstall, install, postinstall)
# do not run unless a package is listed here.
allowBuilds:
  esbuild: true
  sharp: true
# CI and deploys: never resolve, only install what the lockfile says
pnpm install --frozen-lockfile

ChainDrop ran through a preinstall hook. With this setup, that script never runs, and a version published a few hours ago is never installed in the first place.

Failure mode: setting dangerouslyAllowAllBuilds: true because one package complained. Allow the one package. Never all of them.

2. Lock gems with checksums

Gemfile.lock pins versions. It does not prove the gem you download today is the gem you locked last month. Checksums do.

Bundler 2.6 added a CHECKSUMS section to the lockfile: a SHA-256 for every gem. On install, Bundler verifies each gem against it and aborts on a mismatch. Bundler 4 turns this on by default for new lockfiles. Existing apps need one command.

# Bundler 4 writes CHECKSUMS for new lockfiles by default.
# For an existing app (Bundler 2.6+):
bundle lock --add-checksums

# CI and deploys: fail if Gemfile and Gemfile.lock disagree
bundle config set --local frozen true
# Gemfile.lock
CHECKSUMS
  actionpack (8.1.0) sha256=3f1c...e9a2
  rack (3.2.1) sha256=8b7d...41c0
  rails (8.1.0) sha256=a04e...7d13

A lockfile diff in a pull request that was not about dependencies is a red flag. Treat it like one.

3. Wait 21 days before upgrading

Most malicious or broken releases are caught within days. ChainDrop was flagged within hours. So we do not install a new npm package or gem version until it is 21 days old. Let someone else find the problem first.

pnpm enforces this at install time (the minimumReleaseAge above). Dependabot enforces it for upgrade pull requests with cooldown.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "bundler"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 21
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 21

The exception: a security fix. Dependabot security updates already skip the cooldown. For pnpm, exempt the one version you reviewed, and remove the exemption once it ages past the window. pnpm audit --fix adds that exemption for you.

# pnpm-workspace.yaml
# Exception: a security fix we reviewed and need today.
minimumReleaseAgeExclude:
  - some-package@4.2.1

The window is for libraries, not runtimes. A PHP, Ruby, Node or GitLab security release goes out the same week, not three weeks later.

4. Scan dependencies and code, before commit and in CI

Two layers. A fast check on the laptop before every commit, and a full scan in CI that nobody can skip.

On the laptop, bundler-audit checks Gemfile.lock against the Ruby Advisory Database whenever the lockfile changes. --update pulls the latest advisories first.

# lefthook.yml
pre-commit:
  jobs:
    - name: bundler-audit
      glob: "Gemfile.lock"
      run: bundle exec bundle-audit check --update
    - name: gitleaks
      run: gitleaks git --pre-commit --redact --staged --verbose

Hooks can be skipped with --no-verify. So every pull request also runs one security workflow:

  • bundler-audit: known vulnerable gems.
  • Brakeman: security bugs in our own Rails code, like SQL injection, unsafe redirects and mass assignment. New Rails 7.2+ apps already ship with it.
  • pnpm audit --audit-level high --prod: known vulnerable npm packages that actually ship to users.
  • OSV-Scanner: reads both Gemfile.lock and pnpm-lock.yaml against OSV.dev, and fails only on vulnerabilities the pull request adds.
# .github/workflows/security.yml
name: Security scan
on: [pull_request]
permissions:
  contents: read

jobs:
  ruby:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: ruby/setup-ruby@26e5b7d197ebf60545ee9667bcbace0be11ccaec # v1.334.0
        with:
          bundler-cache: true
      - run: bundle exec bundle-audit check --update
      - run: bundle exec brakeman --no-pager

  js:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
      - run: pnpm audit --audit-level high --prod

  osv:
    permissions:
      actions: read
      contents: read
      security-events: write
    uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36 # v2.7.0

Every job is read-only except OSV, which needs security-events: write to post results to the Security tab. Actions are pinned to SHAs (more on that below).

Failure mode: a noisy scanner everyone learns to ignore. Triage once, record false positives with brakeman -I (it writes config/brakeman.ignore), and keep the build red only for real findings.

5. Block secrets before they are pushed

A leaked key in git history is a leaked key forever. Rotating it is the only fix. So we stop it at the door.

GitHub secret scanning with push protection rejects any push that contains a known secret format: cloud keys, API tokens, private keys. Turn it on per repo or for the whole organization.

# Settings → Security and quality → Advanced Security
#   → Secret Protection: Enable → Push protection: Enable
# Or with the gh CLI:
gh api -X PATCH repos/OWNER/REPO \
  -f "security_and_analysis[secret_scanning][status]=enabled" \
  -f "security_and_analysis[secret_scanning_push_protection][status]=enabled"

This is what a blocked push looks like. Nothing reaches the remote.

$ git push
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote:
remote: - GITHUB PUSH PROTECTION
remote:   —————————————————————————————————————————
remote:     Resolve the following violations before pushing again
remote:
remote:     - Push cannot contain secrets
remote:
remote:       —— Amazon AWS Access Key ID ——————————
remote:        locations:
remote:          - commit: 8728dbe67
remote:            path: config/initializers/aws.rb:4

Push protection runs on GitHub's side. The gitleaks hook in the lefthook config above catches the same mistake on the laptop, before the commit exists. AI agents write .env values into files more often than you would think; both layers catch that too.

Failure mode: clicking "It's used in tests" to get unblocked. Every bypass is a real decision. Make people request it, and review who does.

6. Put a named reviewer on lockfiles

A lockfile diff is hard to read and easy to approve. Two things fix that.

First, dependency review on every pull request. It reads the lockfile diff and fails the PR if it adds a dependency with a known vulnerability.

# .github/workflows/dependency-review.yml
name: Dependency review
on: [pull_request]
permissions:
  contents: read
jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          fail-on-severity: high

Second, CODEOWNERS. Any change to a lockfile or a workflow needs approval from a named owner, whether a person, Dependabot, or an AI agent made it.

# .github/CODEOWNERS
Gemfile.lock          @navio-labs/leads
pnpm-lock.yaml        @navio-labs/leads
.github/workflows/    @navio-labs/leads
.github/CODEOWNERS    @navio-labs/leads

CODEOWNERS only blocks merges when branch protection has "Require review from Code Owners" turned on. Turn it on.

Failure mode: an agent fixes a test, bumps 40 gems along the way, and the reviewer approves the green build. With CODEOWNERS, that PR waits for someone who reads the lockfile.

7. Fence in your AI coding agents

AI agents write a lot of our code now. They are fast, confident, and happy to run any command that makes a test pass. We treat them like a new contributor: clear rules, limited access, and every change reviewed.

  • Rules in the repo: an AGENTS.md with the dependency and security policy. Cursor, Claude Code and Codex all read it.
  • No lockfile or workflow changes without a human: the CODEOWNERS rule above applies to agent pull requests too.
  • No bundle update, pnpm update, --force, or new build-script approvals without asking first.
  • Sandboxed: agents run with dev and test credentials only. No production secrets, no prod database URL, no cloud admin profile.
  • Scoped tokens: a fine-grained GitHub token for one repo, with no admin or workflow access.
  • Same pipeline: agent pull requests go through the same CI, scans and review as anyone else's. Green CI is not a review.
## Security rules (AGENTS.md)
- Work on a branch and open a pull request. Never push to main.
- Never edit Gemfile.lock, pnpm-lock.yaml or .github/workflows/.
  If a change needs one, stop and ask.
- Never run `bundle update`, `pnpm update`, `--force` or
  `pnpm approve-builds` without approval.
- Never read .env, config/credentials or ~/.aws. Use the dev seeds.
- Never paste secrets, tokens or customer data into code or prompts.

A rules file is guidance. The model can still ignore it. Where the tool supports it, enforce the same rules in settings. In Claude Code, deny rules in a committed .claude/settings.json block the call outright:

// .claude/settings.json (committed, applies to the whole team)
{
  "permissions": {
    "deny": [
      "Bash(bundle update *)",
      "Bash(pnpm update *)",
      "Bash(git push --force *)",
      "Edit(./Gemfile.lock)",
      "Edit(./pnpm-lock.yaml)",
      "Edit(./.github/workflows/**)",
      "Read(./.env)",
      "Read(./.env.*)"
    ]
  }
}

Three layers: the rules file says what to do, the tool settings block the obvious mistakes, and CODEOWNERS catches whatever gets through.

Failure mode: an agent running on a laptop with the default AWS profile "just checks" something in production. Give it a dev profile or nothing.

8. Pin GitHub Actions to commit SHAs

uses: some/action@v4 runs whatever v4 points to today. Tags can be moved. If an action's maintainer is compromised, every workflow using the tag runs the attacker's code with your secrets. A full commit SHA cannot be moved.

# Before: a tag can be moved to point at any commit
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
# After: a full commit SHA, with the version as a comment
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0

The comment keeps it readable. Dependabot updates both the SHA and the comment, and the same 21-day cooldown applies.

# .github/dependabot.yml (add to updates:)
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 21

To make it a rule rather than a habit, turn on "Require actions to be pinned to a full-length commit SHA" under Settings → Actions → General. Unpinned workflows then fail.

9. Least-privilege tokens and OIDC

ChainDrop spread by stealing tokens from CI. So we give CI as little as possible to steal.

Every workflow starts read-only, and a job asks for write access only when it needs it. Set the repo default too: Settings → Actions → General → Workflow permissions → Read repository contents.

For cloud access we stopped storing AWS keys in GitHub secrets. With OIDC, the job asks AWS for credentials that last about an hour and are only valid for that role.

# .github/workflows/deploy.yml
permissions:
  contents: read          # default for every job

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write     # only this job can request an OIDC token
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: ap-south-1
      - run: ./bin/deploy

On the AWS side, the role's trust policy should only accept tokens from your repo and branch (the token.actions.githubusercontent.com:sub condition). Otherwise any repo can assume it.

Failure mode: permissions: write-all because one step needed to comment on a PR. Give that one job pull-requests: write and nothing else.

10. Rate limit every public endpoint

Most attack traffic today is automated. Credential stuffing, scraping, and AI agents probing every endpoint they can find. They are cheap to run and never get tired. Every public endpoint gets a limit: login, signup, password reset, OTP, search, and every API route.

Rails 7.2+ has this built in, per controller.

class SessionsController < ApplicationController
  rate_limit to: 5, within: 1.minute, only: :create
end

class Api::V1::BaseController < ApplicationController
  rate_limit to: 100, within: 1.minute,
             by: -> { current_api_key&.id || request.remote_ip }
end

For app-wide throttles, or older Rails, use Rack::Attack.

# config/initializers/rack_attack.rb
class Rack::Attack
  throttle("req/ip", limit: 300, period: 5.minutes) do |req|
    req.ip unless req.path.start_with?("/assets")
  end

  throttle("logins/email", limit: 5, period: 20.seconds) do |req|
    if req.path == "/login" && req.post?
      req.params["email"].to_s.downcase.presence
    end
  end
end

Back both with a shared cache store (Redis or Solid Cache), not memory, or each server counts on its own. Return a 429 and log every hit; a spike in 429s is an early signal.

Conclusion

None of this is new. What changed is how fast things break. A worm can poison hundreds of packages in an afternoon, and an unauthenticated RCE in your runtime can land on a Tuesday.

So: patch runtimes the week they ship. Make dependencies wait three weeks, verify them by checksum, scan them before every commit and in CI, and put a named reviewer on every lockfile change. Keep secrets out of the repo, fence in your AI agents, pin your Actions, give CI short-lived credentials, and rate limit everything public.

Small steps. Do them once, and they keep working.

Patch Your Runtime Fast, Take Your Dependencies Slow - Abhay Nikam