The dependency we vendored

A dependency advisory arrived for a package nobody on the team had heard of, four levels down from something we had installed to parse a date format. The thing we actually used was two hundred lines; everything else was its dependencies’ dependencies.

The symptom

$ npm ls some-obscure-package
[email protected]
└─┬ [email protected]
  └─┬ [email protected]
    └─┬ [email protected]
      └── [email protected]

$ npm ls --all date-format-utils | wc -l
41

$ wc -l node_modules/date-format-utils/dist/index.js
204

Two hundred lines of code, forty-one packages installed to deliver them, and an advisory in one of them that we could neither assess nor fix. The advisory turned out not to apply — the vulnerable function was never reached — and establishing that took an hour.

Why it happens

A small package with a large dependency tree is normal and invisible. Nothing in the install output distinguishes a package that brings one dependency from one that brings forty, and the tree is only inspected when something goes wrong in it.

The fix

Reading the two hundred lines

an hour, and what it contained:

  a date parser for one non-standard format     88 lines
  a formatter with eleven output styles         62
  a timezone conversion helper                  31
  argument validation                           23

what we used:
  parse()      the 88 lines
  formatIso()  4 lines of the 62

what the 41 dependencies were for:
  the timezone helper (a full tz database)
  eight of the eleven output styles
  a polyfill for a method that has been standard
    since 2017

Reading it is the step that makes the decision possible and it is the step nobody does, because a dependency is a thing you install rather than a thing you read. Two hundred lines is an hour; the same exercise on a two-thousand-line package would have ended differently and correctly.

The three things that make vendoring legitimate

/**
 * Adapted from date-format-utils v3.2.1
 * https://github.com/example/date-format-utils
 * Copyright (c) 2019 the date-format-utils authors
 * MIT — full text in vendor-licences/date-format-utils.txt
 *
 * Upstream commit: 8c1f4a7
 * Changes:
 *   - removed the timezone helper and its dependency
 *   - removed 8 of 11 output styles
 *   - added explicit types (the package ships none)
 */
export function parse(input: string): Date { /* ... */ }
  1  the licence, in full, in the repository. MIT
     requires the notice to be preserved and most
     people preserve a link.

  2  the upstream commit hash. without it there is no
     way to work out which later fixes apply.

  3  a test suite of our own. vendored code has no
     upstream tests protecting it, and writing them
     is how you find out whether you understood the
     two hundred lines.

we found two edge cases while writing (3) that the
original handled and we had removed.

The two edge cases are the argument for the test suite: removing eight output styles had also removed a branch the parser depended on for a leap-year case. Vendoring without tests is copying code you have not understood into a place where nobody will review it again.

The maintenance we took on

# a scheduled job that watches the upstream file
on:
  schedule: [{ cron: '0 6 * * 1' }]

jobs:
  check-upstream:
    steps:
      - run: |
          curl -s "https://api.github.com/repos/example/
          date-format-utils/commits?path=src/parse.js&since=
          $(cat vendor-src/.last-checked)" 
            | jq -r '.[] | "(.sha[0:7]) (.commit.message)"' 
            > upstream-changes.txt

          [ -s upstream-changes.txt ] && gh issue create 
            --title 'upstream changes in date-format-utils' 
            --body-file upstream-changes.txt

Watching one file rather than the repository keeps this quiet — the package has a hundred commits a year and four of them touch the parser. Over the following year it opened two issues, one of which was a genuine bug fix we applied and one of which was a change to a style we had removed.

When this is the wrong answer

do not vendor:

  anything with a security surface — crypto, parsers
    for untrusted input, authentication. you want the
    upstream's eyes and their disclosure process.
  anything that changes often. a fast-moving package
    vendored is a fork you did not mean to create.
  anything large enough that you cannot read it. the
    threshold here was 200 lines; 2,000 would have
    been a no.
  anything where the licence forbids it, obviously.

do vendor:
  small, stable, and something you have read.

The date parser is stable because the format it parses is a fixed specification from a supplier that has not changed since 2016. That stability is a property of the problem rather than of the package, and it is what makes this safe — a parser for something that evolves would be a fork with a maintenance schedule.

Verifying it worked

$ npm ls --all --omit=dev | wc -l
188                       # was 229

$ npm audit --omit=dev
found 0 vulnerabilities   # was 1 (unreachable)

$ npx vite build && ls -la dist/assets/*.js | awk '{print $5}'
181240                    # was 198104

$ npm test -- vendor-src/
  14 passing                # including the 2 edge cases

Forty-one fewer packages and seventeen kilobytes smaller, which are secondary — the primary outcome is that the next advisory in that subtree is not our problem, and the code we do run is code somebody here has read.

What this costs

Code we own that somebody else wrote and still maintains. Every upstream fix is now a decision rather than an update, and the weekly job that surfaces them will be ignored eventually — a bot issue that has been open for six months is the failure mode this arrangement produces.

It is also a precedent. Two hundred lines was clearly the right call and the next case will be four hundred, and the one after that will be a package somebody wants to vendor because upgrading it is annoying. The written criteria are the only defence, and they are a paragraph in a decision record rather than anything enforceable.