What the agent does
The agent reads how the app is put together before it judges anything. In a Symfony app shaped like symfony/demo, composer.json requires PHP 8.4 or newer and the Symfony 8 components (extra.symfony.require pins 8.1.*), with Doctrine ORM 3.5, DBAL 4, symfony/security-bundle, symfony/html-sanitizer, symfony/form, symfony/validator and league/commonmark; the dev set brings PHPUnit 11.5, PHPStan 2 with the Doctrine and Symfony extensions, PHP CS Fixer and dama/doctrine-test-bundle. .env sets APP_ENV=dev, leaves APP_SECRET empty for .env.local to fill, points DATABASE_URL at data/database.sqlite and MAILER_DSN at null://null. The lint.yaml workflow already runs composer audit, lint:container, lint:twig, doctrine:schema:validate and PHPStan, and tests.yaml runs vendor/bin/phpunit on PHP 8.4 and 8.5, so the audit starts from those commands and goes where they do not.
It then reads the security configuration line by line. config/packages/security.yaml hashes passwords with auto, loads users from the App\Entity\User entity by username, and has two firewalls: dev with security: false on ^/(_profiler|_wdt|assets|build)/, and main with lazy: true, form_login with enable_csrf: true, remember_me with a one-week lifetime signed by %kernel.secret%, and logout with enable_csrf: true. There is no access_control block; authorization lives in attributes, so the agent runs bin/console debug:router and checks every route against its controller: Admin\BlogController carries #[IsGranted(User::ROLE_ADMIN)] on the class and #[IsGranted('edit', subject: 'post')] and #[IsGranted('delete', subject: 'post')] on the mutating actions, resolved by src/Security/PostVoter.php, which grants only when the logged-in user is the post's author; BlogController::commentNew requires IS_AUTHENTICATED; UserController requires ROLE_USER on the class. admin_post_show relies on a denyAccessUnlessGranted call in the body rather than an attribute, which the report notes as a pattern to keep consistent.
Input and output get the same treatment. PostRepository::findBySearchQuery builds LIKE :t_<n> clauses with bound parameters for each term, so the report records it as parameterized and moves on. Comments and posts render through markdown_to_html|sanitize_html in templates/blog/post_show.html.twig, backed by the default sanitizer in config/packages/html_sanitizer.yaml with allow_safe_elements: true; the agent writes a comment containing a script tag and an onerror attribute through the running app and reads what the page serves back. The |raw filters it finds are on translated strings in templates/blog/about.html.twig and the error templates, which take no user input. config/packages/framework.yaml enables session, esi and fragments; the report explains that the fragment listener signs /_fragment URLs with kernel.secret, which makes the empty APP_SECRET in .env a deployment check rather than a code bug.
With the map complete the agent runs the app itself: composer install, the SQLite database from .env, and the built-in server, then exercises each finding with a real request. It tries the admin routes anonymously and as a non-author, posts a comment without the CSRF token, submits the login form with and without _csrf_token, and sends the markup payloads through the comment form. Every claim in the report is one request and one response, with the file and line it traces to. It also runs composer audit for advisories against composer.lock and reads the when@test block, which swaps the main firewall to http_basic with low-cost hashers, to confirm it applies to the test environment only. It changes no code and pushes nothing.
Start it with one API call
POST https://api.prix.dev/o/{org}/sessions with this body:
{
"agent": "claude",
"input": "Audit this Symfony app for security problems and write security-audit.md. Read composer.json, .env, config/packages/security.yaml, framework.yaml, csrf.yaml and html_sanitizer.yaml, every controller under src/Controller, src/Security/PostVoter.php, src/Repository/PostRepository.php and the templates under templates/blog first. Run composer install, then the app on localhost with the SQLite database from .env. Run composer audit. Run bin/console debug:router and check every route against its IsGranted attributes and voter. Test anonymous and non-author access to the admin routes, comment and login submissions without CSRF tokens, and markup in comment content through the sanitizer. For each finding give the file and line, one request that shows it against the running app, its severity and the fix. Note what was checked and found sound. Do not paste secrets. Change no code.",
"resources": [
{
"url": "https://github.com/your-org/your-app"
}
]
}- Get an API key. In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
- Send the request. POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
- Read what came back. Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.
What comes back
security-audit.md comes back as an artifact on the session, with a name, a mime_type and a url on api.prix.dev that downloads it with no key for about an hour; reading the session again returns a fresh link, and GET /o/{org}/sessions/{id}/artifacts/security-audit.md with the key works too. The report has a section per area: dependencies with the composer audit output, authentication and session settings from security.yaml, authorization with the route-by-route table from debug:router, CSRF, input handling, output escaping and the sanitizer, and deployment settings such as APP_SECRET and the fragments listener. Each finding carries a severity, the file and line, the request that demonstrated it and the fix; each area the agent found sound says what it checked.
A reviewer reads the authorization table first, because that is where a Symfony app without access_control is most likely to drift: every route under /admin and /profile should show an attribute or a voter beside it. They then reproduce one or two findings with the requests the report gives, against their own checkout, before planning fixes. The repository has no new commit; git status in the transcript is clean, and the transcript holds every command and HTTP exchange behind the report.
Run it on every release tag
When a release tag is pushed, your CI job sends this body to POST /o/{org}/sessions with resources naming the repository at that tag through a …/tree/<tag> URL, and a request_id, one UUID kept per tag, so a re-run of the job returns the same session instead of starting another audit. The Rush GitHub App has to be installed on the repository, or the resource is 422 RESOURCE_NOT_ACCESSIBLE.
A webhook_url on the API key delivers run.completed with session_url; your handler reads the session, downloads security-audit.md from its artifact url and attaches it to the release.
The use case this job follows, with its automation and trigger: Produce a report or file, no repo.
Why run it on Rush?
A security review of a Symfony app is reading plus proving: the config says enable_csrf: true, but only a request without the token shows the form rejects it. The agent installs the app in its own sandbox, runs it on localhost and makes those requests, so the report is built from responses, not from a reading of YAML. Nothing runs on your machine and no test traffic reaches a deployed environment.
Each finding points at a file and line and comes with the request that showed it, so a developer can fix it or dispute it without redoing the investigation. The transcript keeps composer audit, debug:router and every HTTP exchange, and the same prompt can audit several Symfony services at once, each in its own sandbox.
Frequently asked questions
Will it open a pull request with fixes?
No. The prompt says change no code, and the result is security-audit.md. Each finding includes the fix as text, with the file and line. A second run can apply one fix and open a pull request; keeping the audit separate means the report can be judged on its own and the fixes reviewed one at a time.
Does it need PHP 8.4 and the app running?
Yes. composer.json requires PHP 8.4 or newer and composer audit needs the lock file installed, so the agent installs the dependencies in its sandbox and starts the app with the SQLite DATABASE_URL from .env. Findings about CSRF, authorization and the sanitizer are shown with requests against that running app, which a static read of the configuration cannot do.
What does it do about the empty APP_SECRET in .env?
It reports it as a deployment check, not a vulnerability in the code. The committed .env leaves APP_SECRET empty on purpose for .env.local to fill, and the report explains what depends on it: the remember_me cookie signature and the fragments listener's signed URLs. The fix is to confirm the value is set where the app runs.
Will it flag the http_basic firewall in the test configuration?
It reads the when@test block in security.yaml, which swaps main to http_basic and lowers the hasher cost, and notes that both apply only when APP_ENV=test, as phpunit.dist.xml forces. That goes in the report as checked and sound, so a reader does not rediscover it.