npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vulnerability-hunter-next

v0.2.1

Published

Hunts vulnerabilities in a Next.js app on your Claude subscription, and reports a finding only once an HTTP request has reproduced it.

Readme

Vulnerability Hunter for Next.js

Reports a flaw only once a request has proved it. Nothing it could not demonstrate reaches you.

CI npm License: MIT Node SARIF

What it does

It reads a Next.js application, finds the places a request can reach — route handlers, Server Actions, pages, middleware — and asks a model what looks wrong there. Then it turns each suspicion into a single HTTP request against your running application and sends it. What reproduced is reported with the request that proved it. What did not is dropped before you see it.

There is no confidence score to tune. A finding is in the report because a request went out and the application answered the way a vulnerable one answers.

Whichever model you already have will do. --local asks one running on your own machine through Ollama: no account, no key, no bill, and the source never leaves the machine — which is the first question anybody asks before pointing a tool at their employer's code. ANTHROPIC_API_KEY uses the paid API, which is what CI needs since nobody is signed in there; an sk- key that is not sk-ant- is read as OpenAI, so a team already on that vendor changes nothing but the variable. With neither, it uses the Claude subscription you are already signed in to. OPENAI_BASE_URL and OLLAMA_HOST point any of them elsewhere, which is what a self-hosted vLLM or a GPU box on the network needs.

Quick start

npm install --save-dev vulnerability-hunter-next

# See what would be hunted, and what it would cost. Nothing is sent.
npx vulnhunt . --dry-run

# Hunt against your running dev server. No key needed.
npx vulnhunt . --target http://localhost:3000

The map comes out first, and it is meant to be checked against the app you know you have:

4 attack-surface entries found.
39 rules selected.
8 model calls, about 12,110 input tokens.
Estimated cost: 0.15 EUR. Nothing was sent.

  route-handler  app/api/invoices/[id]/route.ts
                 DELETE|GET /api/invoices/[id]
  server-action  app/dashboard/actions.ts
                 actions updateEmail
  page           app/dashboard/page.tsx
                 ANY /dashboard
  middleware     middleware.ts
                 guards /dashboard/:path*

Choosing the rules

// vulnerability-hunter.config.mjs, at the root of your project

export default {
  // Hunt these and nothing else.
  only: ['missing-authorization', 'server-action-without-authorization', 'ssrf'],
  // Mute one without listing all the others.
  without: ['missing-rate-limiting'],
  // Add a class of flaw nobody shipped. Instructions are optional: the model
  // already knows most of them by name.
  with: { 'prompt-injection': 'User text must never reach the system prompt raw.' },
}

The selection reaches the prompt, not the report: a rule you removed costs nothing. And a rule you add works the same day — the model already knows most classes of flaw by name, so instructions are optional.

What it hunts that only Next.js has

A Server Action is a POST endpoint that looks like a function call, so anyone can call it and the button in your page is not a guard. A Client Component ships its whole content to the browser, so an environment value read there travels with it. A middleware matcher is a guard that is easy to write and easy to leave a hole in, and authorization enforced there alone leaves every missed path open.

Those three shapes are why a generic scanner misses the flaws that matter here:

| Rule | What it means | | --- | --- | | server-action-without-authorization | An action that never checks the session. It is a POST endpoint, not a private function. | | server-secret-reaching-the-client | An environment value read in a Client Component, or passed down as a prop. | | secret-in-public-env | A secret in a NEXT_PUBLIC_ variable, which is inlined into the bundle by design. | | bypassable-middleware | A matcher whose pattern leaves uncovered a path it was written to guard. | | middleware-only-authorization | Authorization in middleware alone, so the handler itself trusts everything. | | over-fetching-in-server-component | Whole records selected and passed to the client, so unused private fields travel with the page. | | unsafe-revalidation | A revalidate or purge endpoint reachable without a secret. | | image-optimizer-abuse | An image domain allowlist wide enough to turn the optimizer into an open proxy. |

39 ship for Next.js by default, and the full catalogue with their instructions is in src/domain/rules/rule.ts — one line each, readable in a minute.

Four more sit in the catalogue and stay out of the default set, because nothing this tool sends could demonstrate them: a race condition needs two requests in the same instant, and a workflow bypass needs a stateful path it does not walk. They are not wrong, they are unprovable here — and adding one is your decision.

Each rule carries what would count as having seen it. Most are settled by one request. A missing limit or a replayable webhook gets a bounded run instead — fewer than five requests would demonstrate nothing, more than a hundred would be an outage rather than a proof, and the first refusal ends the run because the application just defended itself.

A secret written in the source is settled by neither: no request can show it, so the evidence is the line, and the quoted line is looked for in the file before the finding is reported. A model that paraphrases is a model that invents.

What a report looks like

Scanned 4 attack-surface entries.

HIGH  Invoice readable without a session
  app/api/invoices/[id]/route.ts:4
  params.id reaches the database with no ownership check.
  proof     GET http://localhost:3000/api/invoices/1
  expected  responds 401 or 403 without a session
  observed  200 OK, 214 bytes

1 proven, 2 discarded after their proof did not reproduce.

The discarded count is printed even when it is large, and especially then: it is the only honest signal you have about the model behind the tool.

Making it a required check

A model does not answer the same thing twice, so a hunt on its own cannot guard a merge: it would fail a pull request that changed nothing. A committed baseline turns that around — what matters stops being what the model found and becomes the difference.

npx vulnhunt . --target http://localhost:3000            # 2 proven, exit 1
npx vulnhunt . --target http://localhost:3000 --accept   # decided: these two are known
npx vulnhunt . --target http://localhost:3000            # 2 already in the baseline, exit 0

vulnerability-hunter-baseline.json is committed next to your code. A finding keeps its identity across runs because it is keyed on the rule, the file and the symbol it sits in — not on the line number, which moves the first time somebody adds an import.

Only a finding nobody accepted stops a build. A flaw that has been fixed is announced rather than forgotten, because otherwise nobody prunes the file and a stale entry eventually swallows a real flaw in silence.

Did the fix work?

npx vulnhunt . --recheck
  closed       1772966f7718f56c  Invoice readable without a session
  still open   3ad902610ff8ca08  Server Action callable by anyone

1 closed, 1 still open, 0 could not be replayed.

The requests that proved each flaw are already in the baseline, so this replays them: no model is called, it is instant and free, and it answers the same way twice. A proof that could not run counts as neither closed nor open — the server was down is not the flaw is gone.

The test you keep

npx vulnhunt . --emit-tests tests/security

One failing test per proven finding, for you to commit. It fails while the flaw is open, passes once it is closed, and keeps passing afterwards — in your own suite, with no key, no model and no network. It outlives this tool.

Passing again

A model does not always give the same answer to the same question, so the hunt asks each one several times and keeps whatever any pass found. It stops when two passes in a row bring nothing new.

This costs seconds rather than money, which is why it is the default on a subscription. With ANTHROPIC_API_KEY set it asks once, because there a second pass is a second line on the bill.

In CI

- uses: tanguychenier/vulnerability-hunter-next@main
  with:
    api-key: ${{ secrets.ANTHROPIC_API_KEY }}
    target: http://localhost:3000
    sarif-file: hunt.sarif

- uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: hunt.sarif

Findings land in the Security tab and annotate the pull request diff, each one carrying the request that proved it. The build fails on exit 1 only when something was proven; a suspicion never fails a build.

Options

| Flag | Default | | | --- | --- | --- | | --target <url> | http://localhost:3000 | Running application used to prove findings. | | --format <fmt> | console | console, sarif, json or markdown. | | --out <file> | stdout | Write the report to a file. | | --model <name> | sonnet | Model to hunt with. | | --dry-run | off | Map the surface, price the hunt, call nothing. | | --local | off | Ask Ollama on this machine. Nothing leaves it. | | --accept | off | Write what this run proved into the baseline. | | --baseline <file> | vulnerability-hunter-baseline.json | Where the baseline lives. | | --recheck | off | Replay the accepted proofs. No model, instant, free. | | --emit-tests <dir> | | Write a failing test per proven finding. | | --since <ref> | | Hunt only what a branch changed. Guards a PR in seconds. | | --fail-on <level> | low | Level that stops the build. Everything is still reported. | | --no-cache | off | Ask again about code that has not changed. | | --allow-destructive | off | Send proofs that change state. Disposable servers only. | | --allow-remote-target | off | Hunt a target that is not local. Same warning. | | --version | | Print the installed version. | | --help | | Print the options and the exit codes. |

Exit codes: 0 nothing proven · 1 at least one proven finding · 2 the hunt could not run. An empty surface is 2, not 0: a run that read nothing is not a clean bill of health. Once a baseline exists, only a finding nobody accepted returns 1 — a flaw you already decided to live with does not stop the build, and a flaw that got fixed is announced rather than left to be noticed.

Answers are cached under node_modules/.cache/vulnerability-hunter, keyed by the question itself, so a file edited by one character is asked again and an untouched one is free. --no-cache turns that off.

Two things it refuses to do on its own

It will not hunt something that looks like a live site. The hunt sends requests designed to succeed, so a target that is not loopback, a private range or a bare container name is refused before anything is sent and before anything is paid. --allow-remote-target insists.

It will not send a proof that would change state. A model asked to demonstrate a missing guard on a purge route writes a request to that purge route, and on a real application that request is rows gone. So by default only reads go out, and nothing whose path announces destruction: purge, delete, truncate, drop, wipe, flush, reset. --allow-destructive sends them, on a server whose data you can afford to lose.

A refused proof is reported as unproven with its reason, never as a flaw that failed to reproduce. "We did not dare" and "the application held" are different facts, and reading the first as the second would hide a real flaw behind a reassuring report.

What it does not do

  • It does not replace dependency scanning. Dependabot and Snyk read your lockfile, this reads your code. They pair well.
  • It does not find every flaw. It finds what a request against a running application can demonstrate — one request, or the same one repeated when one shows nothing. A race needing two requests in the same instant, and a flaw needing a stateful path walked in order, stay out of reach: those rules are left out of the default set rather than reported on a proof that could not fail.
  • It does not hunt a production deployment. Point it at a development server with disposable data. It sends requests designed to succeed.

Contributing

npm install
npm run typecheck
npm test          # no network, no API key
npm run build

The architecture is hexagonal on purpose. src/domain knows nothing about HTTP, Next.js or any model vendor; src/application/ports names the questions; src/infrastructure answers them. Adding a framework means writing one ProjectReader; adding a model vendor means writing one ModelGateway. tests/unit/architecture.test.ts fails the build the moment a layer reaches outwards, so the rule is enforced rather than described.

Issues and pull requests are welcome, especially real applications where the surface mapper gets it wrong.

Licence

MIT © Tanguy Chénier