Gate a pull request

The lockfile is the artefact worth gating. It is the file a model's suggestion ends up in, it is the file nobody reads in a diff of 4,000 lines, and it is the file that decides what actually gets installed.

$ ./target/release/stranger scan --fail-on high .

Exit 1 if anything at or above high is found, 0 otherwise, 2 if the run itself was broken.

GitHub Actions

name: supply chain
on: [pull_request]

jobs:
  stranger:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/checkout@v4
        with:
          repository: keirsalterego/stranger
          path: .stranger
      - run: make -C .stranger
      - run: ./.stranger/target/release/stranger scan --fail-on high .

No toolchain setup step, no cache step, no registry credentials. The build has nothing to fetch, so the job works on a runner with the network firewalled off.

Pick a threshold and mean it

The five rules occupy four severities, so the threshold decides what the gate is actually for.

thresholdcatches
criticalhallucinated names only
highplus install scripts, plus unconstrained pip requirements
mediumplus version drift, plus pip ranges
lowplus trivial packages

--fail-on high is the reasonable default: hallucinated names and code that runs at install time, without failing a build over is-number.

npm-xl demonstrates the difference — it has install scripts and drift but no hallucinated names:

$ ./target/release/stranger scan --fail-on critical fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
0

$ ./target/release/stranger scan --fail-on high fixtures/npm-xl.package-lock.json > /dev/null
$ echo $?
1

Note that --fail-on low will fail almost every real npm tree, because almost every real npm tree contains a micro-package. That is a statement about npm, not a bug, but it makes low unusable as a gate.

What the gate prints

$ ./target/release/stranger scan --fail-on high fixtures/poisoned.package-lock.json

  poisoned.package-lock.json 757 packages   (35 direct · 722 transitive)

  ⚠  HALLUCINATION RISK     3
     chalck@5.3.0             not in corpus · d=1 from "chalk" · root-only, no parent
     expres@4.18.2            not in corpus · d=1 from "express" · root-only, no parent
     lodahs@4.17.21           not in corpus · d=1 from "lodash" · root-only, no parent

  ⚠  INSTALL SCRIPTS        3     arbitrary code at install time

  ⚠  TRIVIAL                35    (4.6% of third-party)

  ⚠  VERSION DRIFT          55    same package at 2+ versions in one tree

  ·  UNPINNED               — no signal in this format

  risk 81/100    55ms    third-party deps used to compute this: 0
$ echo $?
1

The critical block is listed in full and the rest collapses to counts, which is the right shape for a CI log. Add -v if you want the whole thing in the job output; add -q to drop the header and risk line.

Colour is off automatically, because a CI log is not a TTY. If your runner renders ANSI and you want it, set CLICOLOR_FORCE=1.

Failing the build is not the only option

A finding is "no evidence this name is real", not proof of an attack. If your tree carries new packages often enough that a hard gate would cry wolf, post the report instead of blocking:

$ ./target/release/stranger scan --format json . > stranger.json

Exit code 0, machine-readable output, everything listed regardless of -v, and a human decides. The JSON output page has the field list, and False positives is worth reading before you choose which mode you want.

Do not gate on the risk score

risk is a band for the worst severity plus a saturating term for volume, and it is not calibrated against anything. Comparing today's score with yesterday's on the same project is meaningful; comparing two projects is meaningful only at the band, and a threshold on the number would be a number pretending to be a measurement.

--fail-on compares severities, which are at least defined, and it does not read risk at all. Gating on the band would be gating on --fail-on the long way round.

$ ./target/release/stranger scan --fail-on high fixtures/poisoned.package-lock.json; echo $?