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
- infection/infection: ^0.29
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Vulnerability Hunter for Symfony
Reports a flaw only once a request has proved it. Nothing it could not demonstrate reaches you.
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.
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. A key in ANTHROPIC_API_KEY or OPENAI_API_KEY
uses the paid API, which is what CI needs since nobody is signed in there. With
neither, it uses the Claude subscription you are already signed in to.
OLLAMA_HOST and OPENAI_BASE_URL point any of them elsewhere, so the GPU box
on your network, a self-hosted vLLM or a company gateway work without a line of
code here.
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
It adds nothing to the application it hunts. It reads a path, so an older project — Symfony 4, an unsupported PHP — is audited from a global install without touching its dependencies at all:
composer global require tanguychenier/vulnerability-hunter-symfony vulnhunt /path/to/that/old/app --dry-run
The map comes out first, and it is meant to be checked against the site you know you have:
8 attack-surface entries found.
44 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 44 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.
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.
vendor/bin/vulnhunt . --target http://127.0.0.1:8000 # 2 proven, exit 1 vendor/bin/vulnhunt . --target http://127.0.0.1:8000 --accept # decided: these two are known vendor/bin/vulnhunt . --target http://127.0.0.1:8000 # 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
function 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?
vendor/bin/vulnhunt . --recheck
closed 1772966f7718f56c Invoice lookup by id with no ownership check
still open 3ad902610ff8ca08 Admin invoice endpoint reachable unauthenticated
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
vendor/bin/vulnhunt . --emit-tests tests/Security
One failing functional 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.
On every push, not once a night
vendor/bin/vulnhunt . --since origin/main --target http://127.0.0.1:8000
Scoped to 3 of 48 entries, from 7 files changed since origin/main.
Scoped to the diff and answered from the cache for anything unchanged, a hunt takes seconds. It says out loud that it narrowed itself: silence would let an empty report read as a clean project.
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
44 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.
Some flaws need more than one request to show themselves — a login that accepts unlimited attempts, a webhook that can be replayed. Those get a bounded run rather than a single call: too few requests demonstrate nothing, and too many would be an outage rather than a proof.
Four rules 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 in the catalogue, and adding one is your decision, not ours.
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, 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. |
--accepted <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 changed since a git reference. | |
--fail-on <level> |
any | Only this severity and above stops the build. |
--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.
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 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
composer install make check # PHPStan level 9, the suite, then the binary itself make ci # exactly what the pipeline runs, mutation included make coverage # 92% of lines make mutation # 67% 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