permit

A CLI and GitHub Action that reads a dependency upgrade the way a security reviewer would. It asks one question: what can the new code do that the old code couldn't? Anything new blocks the pull request until a person allows it.

Most supply-chain attacks on npm arrive as an ordinary-looking version bump, and a lockfile diff only shows version numbers. permit fetches the versions the lockfile changed, parses their code, and subtracts what the package could already do. Opening a network connection, running an install script or reading the environment is a capability the upgrade has to be granted.

View on GitHub What it detects

Plays the six stages below in order, about 80 seconds. Scroll or press Esc to stop.

Language
TypeScript, tsc --strict, Node 20 or newer
Tests
36 passing with node:test, all offline
Dependencies
acorn and acorn-walk, pinned
Detects
10 capabilities, from install-script to unanalyzable
Runs as
A CLI, and a composite GitHub Action
Lockfiles
npm package-lock.json, versions 2 and 3
License
MIT
permit diff before.json after.json --fixtures ../../fixtures/packages 1 package version added or changed; 1 with something new, 1 blocked by policy ua-sniff 0.7.28 -> 0.7.29 NEW install-script package.json preinstall: node preinstall.js NEW network preinstall.js:5 requires 'https' NEW exec preinstall.js:7 requires 'child_process' NEW fs-write preinstall.js:13 calls fs createWriteStream() NEW env preinstall.js:10 reads process.env

Real runs of permit 0.1.0, output verbatim; permit stands for node dist/src/cli.js. The first three tabs are the repository's own examples, which use small synthetic packages shaped like the event-stream and ua-parser-js incidents and do nothing when run. The last tab upgrades esbuild and node-fetch against the live npm registry.

1Read both lockfiles, strictly

permit compares two package-lock.json files, the base branch's and the pull request's. It reads only the packages section, which lists what actually lands in node_modules.

  • Every package version installed after but not before is looked at. A name that existed before at another version is changed.
  • A name that is new to the tree is new, and everything it can do counts as new, because a fresh transitive dependency is how most attacks arrive.
  • Only lockfile versions 2 and 3. A v1 lockfile, a malformed entry or a version that isn't semver is an error that names the entry, with exit code 2.
  • Workspace links and workspace packages are local code, not downloads, and are skipped.

The event-stream example, entry by entry

    Lockfiles permit refuses Illustrative input

    The rows come from examples/event-stream/before.json and after.json in the repository, and the outcome line is permit's own summary for them. The two broken lockfiles were made for this page, a v1 file and the ua-sniff example with its version set to latest; the refusals and exit codes are real.

    2Fetch the bytes npm would install

    Each changed version is downloaded, along with every version of that package installed before, from the URL the lockfile resolved to, or the registry's usual one. The tarball must match the lockfile's integrity hash, so the code analysed is the code npm would install.

    • A hash mismatch is an error, never a warning.
    • Unpacking writes regular files only. Symlinks and hard links are skipped, and an entry whose path would leave the package directory makes the whole tarball refused.
    • Downloads run eight at a time, with three attempts on a network error or a 5xx and none on a 4xx. Good tarballs are cached by name, version and hash in $XDG_CACHE_HOME/permit, or ~/.cache/permit when that is unset. A refused tarball leaves nothing in the cache.
    • A package that can't be fetched or read doesn't pass. It is reported as unanalyzable and blocks like any other gain.

    Four upgrades from a local registry Illustrative input

    PackageDownloadIntegrityUnpackProfile
    Requests, first run
    7
    Requests, run again
    3
    Escaping entries written
    0
    The packages are made up, packed with npm pack and served by python3 -m http.server on 127.0.0.1. For this page, glob-lite 0.2.9 was rebuilt after the lockfile recorded its hash, tick-ms 2.1.3 carries an entry named package/../../.bashrc, and pad-start was removed from the server. The output and the request counts are real, from the server's own log. The second run asked only for the three that failed, because a failure is never cached; the cache held only the four good tarballs.

    3Parse the code, don't grep it

    Every .js, .cjs and .mjs file is parsed with acorn and walked. A comment or a string that mentions child_process isn't a use of it, and a use that never spells the word is still found.

    • require and process are followed through aliases such as var r = require, because that is how obfuscated payloads hide them.
    • What can't be resolved is reported, not dropped: a require() of a computed name is dynamic-require, and any computed property of process counts as env.
    • Package-level checks too: install scripts in package.json, a binding.gyp, and ELF, Mach-O, PE or .node binaries by their magic bytes.
    • A file that doesn't parse, or one over 4 MB, is unanalyzable. Each capability keeps the file and line where it was first seen.

    In the repository's event-stream example the network code sits inside an encrypted string, so network can't be seen. An aliased require of a decoded name and a computed read of process can, and that is enough to block it.

    Two files, a grep and permit Illustrative input

    grep -nE "child_process|https|eval|process\.env"
    5 lines matched, 4 of them in comments or strings
    permit scan pkg
    4 capabilities, all in loud.js; 3 on lines the grep didn't match
    Matched by grepFound by permit
    Both files were written for this page. The grep pattern is the obvious one, run on the two files as shown. permit's findings are its real output for a package holding both files; on quiet.js alone it prints no capabilities found.

    4Subtract what was already there

    The gains are the new version's capabilities minus everything any previously installed version of that package had. A package that always used the network, or always ran an install script, is not news. A patch release that starts to is.

    • The subtraction is per capability, not per file or line, so moving code around inside a package doesn't raise anything.
    • A changed package also lists its new dependencies, which then appear as new packages in their own right.
    • Any gain the policy doesn't allow blocks: permit exits 1 and prints the line to add to permit.json. It never edits the policy itself.

    Before, after, and what is new

      capHad it beforecapAlready there, not newcapGained: blocks unless allowed
      Every set is real permit scan output for that version. esbuild and node-fetch were fetched from the npm registry; the other three are the repository's synthetic fixtures. The terminal lines are the end of the ua-sniff example's report, verbatim.

      AllowedAbove this line permit reads, fetches and subtracts, and any new capability blocks the merge. Below it, a gain a person allowed by name passes, and is still printed.

      5A person allows it, by name

      permit.json in the repository root lists, per package, the capabilities a person has reviewed and expects. "*" applies to every package. Only what is listed passes.

      • An allowed gain is still printed, marked allowed, so the review trail stays in every report.
      • Allowing network doesn't allow env. Each capability is granted on its own.
      • A misspelt capability or an unknown key is an error with exit code 2, not a silent allow or a silent deny.

      The monday example under four policies

      
                    

      exit1

      The first two policies are the repository's: none, and examples/monday/permit.json. The narrow and reviewed files, and the misspelt one, were written for this page Illustrative input. The last two runs used --base main in a scratch Git repository whose main branch holds the old lockfile. Every report and exit code is real.

      6Run it on every pull request

      As a GitHub Action, permit compares the base branch's lockfile with the pull request's, writes the report to the job summary, and fails the check when anything new isn't allowed. Inputs: base, lockfile and policy.

      • permit diff --base <ref> reads the old lockfile with git show, so the same check runs locally before a push.
      • The action builds permit from its own source with npm ci, then runs the same diff --base.
      • The repository runs its own action on its own lockfile in CI, as a user would.

      The workflow, and CI on the repository

      # .github/workflows/permit.yml
      on: pull_request
      jobs:
        permit:
          runs-on: ubuntu-latest
          steps:
            - uses: actions/checkout@v4
              with:
                fetch-depth: 0
            - uses: ahmed-hashim-pro/permit@main

      CI run 38476393699 on fa44f604 jobs, all passed

        The workflow is the README's. Job names, results and durations come from GitHub's API for that run, rounded to whole seconds. The terminal is a local run of what the self-check job runs on a push to main: the repository's lockfile at HEAD against its working tree.

        Measured, not assumed

        A real upgrade against the live npm registry, and the cost of checking it. Run on one Linux machine; the README's own figures for the same upgrade were 54 s cold and 1 s warm.

        A real upgrade, one block

        esbuild 0.17.19 to 0.18.20 and node-fetch 2.6.7 to 3.3.2 change 29 package versions. permit fetched and profiled 53, both sides.

        29 changed27 gained nothing1 blocked

        esbuild's install script and binaries were already there in 0.17, and node-fetch already used http. The one block is node-domexception, which publishes an editor history file that doesn't parse. permit can't know that is harmless, so it says so.

        Cold, then cached

        Wall time for the same diff, with an empty cache and then again.

        Empty cache76.3 s
        Cached1.8 s

        The cold run is mostly esbuild's binaries for every platform: 22 platform packages, each fetched at both versions. The cache came to 556 MB.

        The suite runs offline

        npm test compiles with tsc, then runs the 36 tests. Three runs in a row:

        1.38 s1.39 s1.39 s

        That is node:test's own duration. Most of it is one test: the retry check waits out the real backoff, 250 ms and then 1 s, for 1.25 s in all.

        36 tests, no network

        Counted with npm test, which runs tsc -p . and then node --test dist/test/*.test.js: 36 tests, all passing. The download and cache test starts a local HTTP server inside the test, and the retry test swaps in a fetch that fails on cue.

          Weighted toward what an attacker would try

          • Analyser: 26 snippets, one per detection route, and code that must not trigger: comments, strings, look-alikes such as obj.writeFileSync.
          • Property test: 500 files assembled at random from snippets with known capabilities, mixed with harmless code and decoys. The detected set must equal the union exactly.
          • Sources: a tar writer in the tests makes pax long names, symlinks, hard links and path-traversal entries, and a refused tarball must leave nothing in the cache; a real npm pack tarball; SRI hashes; download, cache, 404, and retry on a network error and on 503.
          • Lockfiles and policy: nested, scoped and aliased packages, workspace links, and every class of malformed input.
          • Diff and CLI: the three examples end to end, a capability any earlier version had, an unfetchable package, exit codes 0, 1 and 2, --json, and --base against a temporary Git repository.

          CI runs prettier, tsc --strict and the tests on Node 20 and 22, checks that examples/ matches its generator, and runs the action on the repository's own lockfile.

          Deliberately missing

          A guarantee
          It is static analysis and can be evaded, for example by building require from globalThis["req" + "uire"]. It raises the cost of a quiet upgrade; it doesn't replace --ignore-scripts or reviewing what it flags.
          Call paths
          Capabilities are per package. A package that uses https only in a CLI you never call still has network; allow it once.
          Other lockfiles
          npm only, for now. No yarn, pnpm or bun. Packages from git or local paths can't be fetched as tarballs and are reported as unanalyzable.
          A vulnerability database
          It says what a version can newly do, not whether it has a known CVE. That is npm audit's job.

          Run it

          git clone https://github.com/ahmed-hashim-pro/permit
          cd permit
          npm ci && npm run build
          npm test            # 36 tests, offline
          
          # The examples, offline:
          cd examples/ua-sniff
          node ../../dist/src/cli.js diff before.json after.json \
            --fixtures ../../fixtures/packages
          cd ../..
          
          # Your project, against the real registry:
          node dist/src/cli.js diff --base origin/main

          Node 20 or newer. The examples need no network. Against the registry, tarballs are cached in ~/.cache/permit (or under $XDG_CACHE_HOME), so the second run of the same diff is fast.