# Run a Security Audit on a Symfony App

Yes. An agent runs your Symfony app in a sandbox, checks dependencies, firewalls, CSRF, authorization and escaping, and reports each finding with file and line.

[Run this on Rush](https://cloud.getrush.ai/run/security-audit/symfony/), in a Project that has your repository.

## 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`

```json
{
  "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"
    }
  ]
}
```

1. **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.
2. **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.
3. **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.

## 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.
