Search by

tansoftware / vulnerability-hunter-symfony

Security auditor for Symfony. Every finding ships with the HTTP request that proved it, so anyone can replay it. What does not reproduce is never reported.

Maintainers

Package info

github.com/tanguychenier/vulnerability-hunter-symfony

pkg:composer/tansoftware/vulnerability-hunter-symfony

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-09-06 17:45 UTC

This package is auto-updated.

Last update: 2026-09-06 17:49:09 UTC


README

Vulnerability Hunter for Symfony

Runs on the Claude subscription you already pay for. Reports only what it watched happen.

CI Packagist License: MIT PHP PHPStan Coverage MSI SARIF

The hunter scans four entry points, reports the one flaw it proved, and says how many suspicions it discarded.

What it does

It reads a Symfony application, finds the places a request can reach, 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.

It runs on the Claude subscription you are already signed in to, so a hunt costs nothing beyond your plan. --local asks a model running on your own machine through Ollama instead — no account, no key, and the source never leaves. Set ANTHROPIC_API_KEY to use the paid API, which is what CI needs since nobody is signed in there.

Quick start

composer require --dev tanguychenier/vulnerability-hunter-symfony

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

# Hunt against your running dev server. No key needed.
vendor/bin/vulnhunt . --target http://127.0.0.1:8000

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

8 attack-surface entries found.
48 rules selected.
16 model calls, about 31 220 input tokens.
Estimated cost: 0.31 EUR. Nothing was sent.

  access-control  config/packages/security.yaml
                  guards ^/admin/invoices
  route-handler   src/Api/ProxyController.php
                  GET /api/fetch
  route-handler   src/Controller/Admin/InvoiceController.php
                  GET /admin/invoices/{id}
  route-handler   src/Controller/ContactController.php
                  GET|POST /contact, /de/kontakt

Choosing the rules

<?php
// vulnerability-hunter.php, at the root of your project

use VulnerabilityHunter\Config\HunterConfig;

return HunterConfig::configure()
    // Hunt these and nothing else.
    ->only(['missing-authorization', 'broken-object-level-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' => 'The system prompt must never interpolate user text unescaped.']);

The selection reaches the prompt, not the report: on a real project, narrowing 48 rules to 3 halved the input tokens. And since new classes of attack are named constantly, a rule you add works the same day — the model already knows most of them by name, so instructions are optional.

What a report looks like

Scanned 2 attack-surface entries.

CRITICAL  Invoice readable without a session
  src/Controller/InvoiceController.php:15
  The show() action loads the invoice from the id with no voter and no #[IsGranted].
  proof     GET http://127.0.0.1:8000/admin/invoices/1
  expected  responds 401 or 403 without a session
  observed  200, 51 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.

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

GitLab, GitHub Actions and what fails the build: docs/ci.md.

- uses: tanguychenier/vulnerability-hunter-symfony@main
  with:
    api-key: ${{ secrets.ANTHROPIC_API_KEY }}
    target: http://127.0.0.1:8000
    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.

What it looks for

48 rules across seven families: who is allowed in, what the request is allowed to say, where the server can be made to go, what leaks back, how the business rules can be walked around, what protects the edges, and what the secrets are worth. The full list with its instructions is in src/Domain/Rule/RuleSet.php — one line each, readable in a minute.

Framework specifics are the point. A Symfony application declares where it can be reached: route attributes, docblock annotations left from Symfony 5, YAML, and access_control. Mapping that first is what keeps a hunt cheap, because the model reads the eight files that can be attacked rather than the eight hundred that cannot.

It reads, it never executes. The audited project is untrusted by definition, so booting its kernel to ask the router for its routes is not on the table.

Options

Flag Default
--target <url> http://localhost:8000 Running application used to prove findings.
--format <fmt> console console, sarif or json.
--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.
--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.

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 /admin/purge writes GET /admin/purge, 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.

Scanned 8 attack-surface entries.

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

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 one HTTP request against a running application can demonstrate. A race condition spread over three requests is out of reach, and the tool says so rather than guessing.
  • It does not hunt a production deployment. Point it at a development server with disposable data. It sends requests designed to succeed.

Contributing

composer install
make check        # PHPStan level 9, then the suite, then the binary itself
make coverage     # 92% of lines
make mutation     # 69% MSI, and infection.json5 says where the rest goes

make check runs the executable, not only the tests: a wrong namespace in bin/ once passed the whole suite and broke the only command anybody runs.

Everything runs in Docker if your PHP lacks an extension: make install && make check.

The architecture is hexagonal on purpose. src/Domain knows nothing about HTTP, Symfony or any model vendor; src/Application/Port names the questions; src/Infrastructure answers them. Adding a framework means writing one ProjectReader; adding a model vendor means writing one ModelGateway. tests/Unit/ArchitectureTest.php 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