Common questions about symfony-security-auditor — an AI-powered multi-agent security auditor for Symfony applications.
- About the project
- Comparisons
- Accuracy & False Positives
- Cost & Performance
- Privacy & Data Handling
- Model Selection
- Compatibility
- Integration & Workflow
- Customization
See also: Configuration · Architecture · CI Integration · Troubleshooting
It feeds your Symfony project through a three-stage AI pipeline:
- Ingestion — scans
.php,.twig,.yaml,.yml,.xmlfiles recursively. - Mapping — classifies files as Controllers, Entities, Voters, Forms, Repositories, Templates, Config; builds a route/firewall map.
- Audit — an adversarial Attacker agent hunts for vulnerabilities; a skeptical Reviewer agent validates each finding. Up to 3 iterations, stops earlier when no new findings emerge.
Output is a validated vulnerability report in your console, as JSON, or as SARIF for GitHub Code Scanning / GitLab Security Dashboard.
49 types across six categories (OWASP-aligned):
- Injection — SQL, command, LDAP, XPath, Twig, header.
- Broken Access Control — missing Voter, Voter bypass, role escalation, IDOR, missing CSRF.
- Logic Flaw — business logic, race condition, state machine bypass, price manipulation, insecure workflow.
- Symfony-Specific — mass assignment, insecure deserialization, unsafe parameter binding, misconfigured firewall, insecure redirect, exposed internal service.
- Data Exposure — sensitive data leak, log injection, path traversal, SSRF, XXE, open redirect.
- Cryptographic — weak crypto, insecure random, hardcoded secret.
Full enum:
Audit/Domain/Model/VulnerabilityType.php.
It is closer to AI-assisted SAST + business logic auditing than traditional
static analysis. It does not perform pure AST-based taint tracking — it gives
the LLM the source code (plus optional read_file / grep / list_files /
lookup_advisory tools) and asks it to reason about security.
It detects classes of bug pure SAST tools cannot see (missing authorization, broken business logic, multi-file attack chains) at the cost of nondeterministic output and a per-run LLM cost.
The auditor is actively developed. Output is validated by a Reviewer agent
before being included in the final report. A scheduled nightly CI job
alongside existing tools (PHPStan / Psalm / Dependabot) is the lowest-friction
default, but gating pull requests directly is also supported: scope the run to
changed files with --since, then gate on --fail-on / --min-score, with
baseline suppression (--baseline/--generate-baseline) so previously-accepted
findings don't fail CI. See CI Integration.
Where this auditor fits among the tools you already run:
| Concern | This auditor | PHPStan / Psalm | Psalm Security / Progpilot (SAST) | Dependabot / Snyk |
|---|---|---|---|---|
| Type bugs | ❌ | ✅ | partial | ❌ |
| Taint flow (SQLi, XSS) | ✅ | ❌ | ✅ | ❌ |
Missing #[IsGranted] / Voter |
✅ | ❌ | ❌ | ❌ |
| Business logic flaws | ✅ | ❌ | ❌ | ❌ |
| IDOR / mass assignment | ✅ | ❌ | partial | ❌ |
| Firewall misconfiguration | ✅ | ❌ | ❌ | ❌ |
| Cross-file attack chains | ✅ | ❌ | partial | ❌ |
| Dependency CVEs | ✅ (via lookup_advisory) |
❌ | ❌ | ✅ |
| OWASP Top 10 application-level coverage | ✅ | ❌ | partial | ❌ |
PHPStan and Psalm catch type errors and code-shape bugs. This auditor catches application-level logic flaws (missing authorization, mass assignment, business logic bugs). Use both — they cover different concerns.
Static SAST tools follow taint flows from sources to sinks. They are excellent for SQLi and XSS but cannot reason about:
- Whether a controller is missing
#[IsGranted]. - Whether a Voter implementation has a logic flaw.
- Whether a Form Type binds a privileged field (e.g.
isAdmin). - Whether a multi-step workflow allows state-machine bypass.
This auditor reasons about those. Run both for layered coverage.
Dependency scanners flag known CVEs in third-party packages (via
composer audit, GitHub Advisory Database, Snyk DB). This auditor focuses on
your own application code. The Attacker can call the lookup_advisory tool
(backed by composer audit) to enrich its analysis, but it's not a replacement
for a dedicated dependency scanner.
Use Dependabot/Renovate for CVE patching + this auditor for application-level flaws.
Snyk Code and Semgrep are commercial / open-source static SAST. They are
language-agnostic but not Symfony-aware — they don't understand Voters,
Firewalls, Forms, or #[IsGranted] attributes. This auditor's prompts encode
Symfony-specific knowledge and the Mapping stage builds a Symfony-aware project
model.
GitHub Copilot Code Review is generalist code review — not focused on security and not Symfony-aware.
No. We recommend the layered approach:
| Tool | Purpose |
|---|---|
| PHPStan / Psalm | Type bugs, dead code |
| Psalm Security / Progpilot | Taint-based SAST (SQLi, XSS) |
| Dependabot / Renovate / Snyk | CVE-known dependency vulnerabilities |
| symfony-security-auditor | Logic flaws, missing authorization, Symfony-specific |
Accuracy depends on the LLM model. Stronger models (Claude Opus, GPT-5.6, Gemini
3 Pro) produce fewer false positives and catch deeper flaws. The Reviewer agent
filters Attacker output — only reviewer_validated findings appear in the final
report.
Tune audit.min_confidence (default 0.6) to trade precision for recall. CI
gating: try 0.8. Discovery scan: try 0.3.
A finding the Attacker generates that turns out not to be exploitable. The
Reviewer rejects most of them. If you still see false positives in the final
report, raise min_confidence, or
file an issue
with a reproducer.
That's a harder problem — LLMs miss things. Options:
- Raise
audit.max_iterationsfrom3to5(more passes). - Switch to a stronger model.
- Drop
audit.min_confidenceto0.3and review unvalidated findings manually. - File an issue with the file/snippet — we update prompts based on real misses.
No. LLM output is nondeterministic by default. On models that accept it, set
temperature: 0.0 (or low like 0.1) in your model options to reduce variation
— the current Claude generation (Opus 5, Sonnet 5, Fable 5) rejects
temperature outright and is steered via effort/thinking instead (see Model
Selection below) — but identical input may still produce different findings
across runs. The cache (cache.enabled: true) makes a repeated run on
identical code deterministic — chunks with the same content hash are
short-circuited.
Roughly, for a medium Symfony app (~150 files), running once with Anthropic prompt caching enabled (default):
| Setup | Approx. cost per run |
|---|---|
| Claude Fable 5 only | $6 – $16 |
| Claude Opus only | $3 – $8 |
| Claude Opus + Haiku (split-model) | $0.50 – $2 |
| GPT-5.6 only | $3 – $8 |
| DeepSeek / Mistral / Ollama (local) | ~$0 / $0 |
Tips: set profile: fast (one attacker iteration, lean pre-scan, code slicing,
concurrent reviews), enable Anthropic prompt caching (cache_retention in
ai.yaml; default short already on), use cache.enabled for repeated CI
runs, use split-model in CI, run nightly not per-PR. See
CI → Managing LLM Costs.
30 seconds to several minutes depending on project size, model latency, and
audit.max_iterations. The Reviewer is run per-finding by default
(reviewer_batch_size: 1) — increase that to batch reviews and cut latency at
the risk of cross-talk between findings in the prompt.
By default, the Attacker groups files by feature
(audit.chunking.strategy: feature) — a controller together with its related
entity/repository/form/voter/templates, capped at 10 files per chunk — and
computes a content hash per chunk. Identical chunks (same files, same content)
skip the LLM entirely — cache.enabled: true (the default) gives ~80% cost
reduction on repeated CI runs of unchanged code.
Provider-side prompt caching stacks on top of that for a ~90%
input-token discount on prompts that share a long system message. It is
configured on the symfony/ai platform, not by the auditor itself: set
cache_retention (short/long) on the anthropic platform in ai.yaml
(default short already enables it); OpenAI and Gemini cache automatically. The
old cache.prompt_caching flag is deprecated since 1.7 and ignored.
Yes, by default. It sends file contents to the LLM provider you configure in
ai.yaml. Read the provider's data retention policy:
- Anthropic — data handling
- OpenAI — API data usage
- Google Gemini — data handling
- Mistral — data privacy
- Azure OpenAI — enterprise data residency
- AWS Bedrock — AWS data isolation
Yes. Use the Ollama platform and a locally pulled model:
# config/packages/ai.yaml
ai:
platform:
ollama:
endpoint: 'http://localhost:11434'
# config/packages/symfony_security_auditor.yaml
symfony_security_auditor:
model: 'llama3.3' # or any model from `ollama pull`No data leaves your machine. Add
privacy.offline_only: true to have
the auditor enforce that rather than rely on it: it drops the advisory feed (no
composer audit) and, in standalone mode, aborts before the audit boots if a
configured platform endpoint is not loopback or private-range.
Do not take the claim on trust — watch the wire. Set
privacy.offline_only: true so the
auditor refuses its own network calls (and, in standalone mode, refuses to boot
against a non-local platform endpoint), export SSA_NO_UPDATE_CHECK=1 to
silence the standalone release check, then capture traffic while an audit runs:
# Terminal 1 — capture everything that is not loopback and not your local LLM
sudo tcpdump -n -i any 'not host 127.0.0.1 and not port 11434'
# Terminal 2 — run the audit
SSA_NO_UPDATE_CHECK=1 symfony-security-auditor audit /path/to/projectExpect the capture to stay empty for the whole run. Two notes on reading it:
composer installand your editor make their own connections — run the audit in an otherwise idle shell so anything captured is attributable.- With
privacy.offline_onlyoff, an audit legitimately talks to your LLM provider and (throughcomposer audit) to the Packagist advisory feed, so a non-empty capture there is expected, not a leak. - This applies to the
auditcommand specifically. Standalone'sself-updateis a separate command that also refreshes the bundled pricing catalog fromsymfony/models-devunlessprivacy.offline_onlyis set — it is not covered by the capture above unless you run it too.
For a stricter guarantee, run the audit in a network namespace or container that has no route off the host at all except to the Ollama socket — if the auditor still completes, nothing it needs is remote.
Locally, only via LoggerInterface warnings if the Attacker / Reviewer fails to
parse JSON or the advisory feed fails to load — and only the error context,
not the source. The filesystem cache (cache.dir) stores LLM responses
keyed by content hash; no plaintext source code is written to the cache.
lookup_advisory shells out to composer audit --format=json --locked against
your composer.lock. The shell-out happens on the host machine — only the LLM
prompt receives the resulting CVE summaries, not your dependency list itself.
| Goal | Recommended setup |
|---|---|
| Most demanding | attacker_model: claude-fable-5 + reviewer_model: claude-haiku-4-5-20251001 |
| Highest accuracy | attacker_model: claude-opus-5 + reviewer_model: claude-opus-5 |
| Best cost/quality | attacker_model: claude-opus-5 + reviewer_model: claude-haiku-4-5-20251001 |
| Strong + cheaper | attacker_model: claude-sonnet-5 + reviewer_model: claude-haiku-4-5-20251001 |
| Cheapest paid | model: deepseek-chat or mistral-large-latest |
| Offline / free | model: llama3.3 via Ollama |
| Enterprise | Azure OpenAI / AWS Bedrock with split-model |
See Configuration → Split-Model Setup.
The Attacker has the harder job (discover vulnerabilities). The Reviewer's job (validate one finding at a time) is easier and benefits from a faster, cheaper model. Pairing Opus + Haiku saves ~20× on the Reviewer phase with no accuracy loss in practice.
The two agents want opposite trade-offs, so tune them independently with the split-model setup above plus per-agent model options (see Configuration → Model Options):
-
Attacker — favour capability and reasoning depth. It runs a long-horizon, recall-sensitive search across the whole codebase, so it benefits from a more capable model and higher reasoning effort. On models that expose an
effortoption (e.g. Claude Opus 5, Sonnet 5 and Fable 5, wherehigh/xhighare the sweet spot for this kind of work) or an adaptivethinkingoption, set it on the attacker:symfony_security_auditor: attacker_model: 'claude-opus-5?effort=high' reviewer_model: 'claude-haiku-4-5-20251001'
-
Reviewer — favour speed. It validates one finding against context you already hand it, so a faster, cheaper model at lower effort is usually enough; spend the budget on the attacker instead.
A note on recall: recent Claude models follow strict instructions literally, so at the finding stage you want coverage, not premature filtering — the reviewer is where findings are adjudicated and ranked. Forcing the attacker into a very low effort or an aggressively terse budget can suppress the multi-step reasoning that surfaces business-logic and access-control flaws, which is exactly the class of issue this tool exists to find.
Which option keys are accepted (
effort,thinking, …) depends on the provider and model behindsymfony/ai. Unknown options are passed straight through to the provider, so check your provider's documentation; the auditor does not validate them.
Yes. For max_tokens, use the dedicated max_output_tokens key — it defaults
to 4096 and avoids symfony/ai's built-in ~1000-token cap. This key currently
only takes effect for Claude/Anthropic-dialect models — see
Configuration → Top-level:
symfony_security_auditor:
max_output_tokens: 4096
# or, split per agent:
attacker_max_output_tokens: 8192
reviewer_max_output_tokens: 2048Other params (e.g. temperature) flow through the model name, using the
query-string syntax:
symfony_security_auditor:
model: 'claude-haiku-4-5-20251001?temperature=0.1'
model,attacker_model, andreviewer_modelare plain strings, so the expanded{name, options}mapping thatsymfony/ai-bundle's ownai.yamlaccepts for a model is not valid here — only the query-string form above works. See Configuration → Model Options.
Installing the bundle requires PHP 8.3+ in the host application (PHP 8.4 and 8.5 are covered by the CI matrix). The standalone binary bundles its own PHP runtime, so the PHP version of the audited project does not matter.
Installing the bundle requires Symfony 7.4+ or 8.x in the host
application — check composer.json for the authoritative constraint. The
standalone binary imposes no Symfony version on the project it audits.
Yes — use the standalone binary (or the
GitHub Action, which installs it for you). The auditor never executes the
audited project's code — it reads the sources — so a Symfony 4/5 app on an older
PHP can be audited without adding any dependency to it. One caveat: detection is
tuned for attribute- and YAML-era Symfony (#[Route], #[IsGranted],
security.yaml). On annotation-based apps (@Route, @IsGranted) the LLM
still reads the annotations in context, but the deterministic parsers that
enrich the mapping stage target attributes, so route and access-control mapping
may be less complete.
It targets Symfony specifically — the Mapping stage classifies Symfony Controllers, Voters, Forms, and Firewalls. Running it on a Laravel or Drupal project would still feed files to the LLM, but the Symfony-specific prompt context wouldn't apply, so accuracy drops. Not recommended.
.php, .twig, .yaml, .yml, .xml, plus the explicitly listed root dotenv
files (.env, .env.local, .env.dev, .env.test, .env.prod, .env.dist)
— the places where Symfony security-relevant code and committed secrets live.
Other extensions are skipped.
No. The scan is a strict allow-list: only the paths listed in
scan.included_paths are inspected, defaulting to src, config, templates,
public/index.php, and the root dotenv files (the Symfony Flex skeleton).
Anything outside — vendor/, node_modules/, var/, tests/, migrations/,
translations/, bin/, app/, root-level scripts, IDE folders, build
artefacts — is silently skipped. To prune a sub-tree inside an included path
(e.g. drop src/Migrations from the audit), tighten included_paths to the
specific sub-directories you want instead — e.g.:
symfony_security_auditor:
scan:
included_paths:
- 'src/Controller'
- 'src/Form'
- 'src/Voter'
- 'config'
- 'templates'
- 'public/index.php'composer audit covers vendor CVEs via the lookup_advisory tool.
Nightly scheduling is simplest — the multi-agent loop can take minutes, so a
full audit on every push adds latency. See CI Integration for
ready-to-copy GitHub Actions and GitLab CI templates, including scheduled SARIF
upload and a --since-scoped, --fail-on-gated pattern for teams that want the
audit to block pull requests directly.
You can — this is now a well-supported pattern. Scope the run to changed
files with --since (fast, and the cache stays warm) and gate on --fail-on /
--min-score, with --baseline to keep already-accepted findings from failing
the check. A full (non---since) audit on every PR is still costlier and slower
than a nightly run — reserve that for the scheduled job.
php bin/console audit:run --format sarif --output report.sarifThen upload via github/codeql-action/upload-sarif@v4. Findings appear in the
GitHub Security tab and as annotations on the diff. Restricted to collaborators
even on public repos. See CI → GitHub Actions.
GitLab natively parses SARIF when uploaded as a sast report artifact. See
CI → GitLab CI.
Yes. The CI doc covers four storage modes: GitHub Code Scanning (SARIF), private S3/GCS bucket, notification-only (Slack/email), and storage in a private repo via PAT.
Public-repo warning: do not store the JSON report as a public CI artifact. It advertises your attack surface. Use SARIF + Code Scanning or external private storage instead.
Yes. Add a case to Audit/Domain/Model/VulnerabilityType, extend category(),
owaspReference(), owaspReferenceUrl(), and cwe(), then update
AttackerPromptBuilder to mention the new type. See
Contributing → Common Tasks.
Yes. Implement Audit/Domain/Pipeline/StageInterface. Stages auto-register via
the symfony_security_auditor.pipeline_stage tag. See
Extending → Custom Pipeline Stage.
Yes. Implement Audit/Domain/Port/LLMClientInterface and alias it. See
Extending → Custom LLM Client.
Yes. Implement Audit/Domain/Port/AdvisoryDatabaseInterface and override the
alias in config/services.yaml. See
Configuration → Advisory Source.
Yes. Add a case to Command/OutputFormat, then a <Name>ReportRenderer class
implementing ReportRendererInterface and register it in config/services.php
— autoconfiguration wires it into ReportWriter, no match arm to edit. See
Extending → Custom Report Output.