tanguychenier / 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.
Package info
github.com/tanguychenier/vulnerability-hunter-symfony
pkg:composer/tanguychenier/vulnerability-hunter-symfony
Requires
- php: >=8.2
- ext-json: *
- symfony/http-client: ^6.4 || ^7.0
Requires (Dev)
- ext-dom: *
- friendsofphp/php-cs-fixer: ^3.60
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Vulnerability Hunter for Symfony
Runs on the Claude subscription you already pay for. Reports only what it watched happen.
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. Set ANTHROPIC_API_KEY to use the paid API instead,
which is what CI needs since nobody is signed in there.
Quick start
composer require --dev tansoftware/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. |
--allow-destructive |
off | Send proofs that change state. Disposable servers only. |
--allow-remote-target |
off | Hunt a target that is not local. Same warning. |
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