Common problems running symfony-security-auditor and how to fix them. Found a new gotcha? Open an issue so we can document it.
- Standalone Binary Issues
- Installation & Setup
- Running the Audit
- LLM & Provider Errors
- Empty / Surprising Reports
- Performance & Cost
- Cache Issues
- Advisory (
composer audit) Issues - Tools (
read_file,grep,list_files,lookup_advisory) - CI Failures
- Dev / Quality Gate Failures
See also: FAQ · Configuration · CI Integration
Entries in this section apply only to the standalone binary (init,
self-update, doctor) — not to the Symfony bundle, which has no equivalent
commands.
doctor's "Configuration" and "API key" lines surface
StandaloneConfigLoader::load() failures directly:
-
No provider is configured — run "init".— noplatform:block inconfig.yaml; runinit. -
The environment variable "<VAR>", referenced by your config, is not set.(reported under theAPI keylabel) — export the%env(VAR)%variable yourplatform:block references. -
Config file "<path>" is not valid YAML: <detail>— fix the malformedconfig.yamlor.symfony-security-auditor.yamlat<path>. -
Cannot resolve the user configuration directory — set
$HOME, or setSYMFONY_SECURITY_AUDITOR_HOMEto a writable directory:Cannot resolve the user configuration directory: neither the relevant XDG base-directory variable nor $HOME is set.
Running audit/init directly without doctor first hits the same underlying
failures.
Two distinct "Provider bridge" failures:
Not installed — run "init" to download it.—<data-dir>/vendor/autoload.phpdoes not exist yet.Installed, but the audit cannot start with it: <reason>— the autoloader exists, butdoctoralso builds the container to confirm it actually boots, not just that the file is present. A bridge left over from a previously configured provider passes the file check but fails here, since the container needs the currently configured provider's classes, not whichever bridge happens to be installed. Re-runinitfor the current provider (--forceskips the overwrite prompt) to install the matching bridge.
Fixed as a security issue in 1.19.0. A per-project
.symfony-security-auditor.yaml ships with the audited repository, so letting
it contribute these keys allowed a malicious or compromised repository to
redirect your resolved API key — and every prompt, i.e. the source code — to an
endpoint of its choosing via platform:/provider:, or to point the SARIF
importer at an arbitrary file via scan.import_sarif. Both are now rejected
outright before the audit starts:
-
Declaring
platformand/orprovideraborts withProjectConfigPlatformOverrideException:The project config "<path>" declares "platform", but LLM connection settings are read from your user config only — a repository you audit must not be able to point your API credentials at another endpoint. Configure the platform in your user config instead. -
Declaring
scan.import_sarifaborts withProjectConfigScanOverrideExceptioncarrying the equivalent message for that key.
doctor reports the same message under a failed Configuration check.
Per-project overrides of audit settings (chunking strategy, fail_on, excluded
paths, …) are unaffected — move only platform/provider/scan.import_sarif
to your user config.yaml.
self-update exists only in the standalone binary. Failures:
-
Any platform other than Linux or macOS —
UnsupportedSelfUpdatePlatformException. There is no Windowsself-update; reinstall withinstall.ps1or download the new release asset directly:Self-update does not support the "Windows" / "<machine>" platform; download the binary for your platform from the releases page instead. -
Cannot reach GitHub —
SelfUpdateFailedException:Failed to download "<url>".(curl itself failed — offline, DNS, TLS) orCould not determine the latest released version from "<url>".(GitHub answered but without a usabletag_name— an API outage or rate limit). -
Checksum mismatch — the downloaded file is deleted and nothing is replaced; retry, or download the asset manually and verify its
.sha256yourself:Checksum verification failed for "<asset>"; the download was not trusted and has been discarded. -
Binary not writable:
The binary at "<path>" is not writable; re-run the update with the necessary permissions (e.g. sudo) or reinstall with the install script. -
Replacement failed mid-swap —
Failed to replace the binary at "<path>": <reason>.The new binary is moved into place as the command exits, not while it runs — the running process still loads classes from the archive being replaced — so this one surfaces afterUpdated from … to ….has already printed. The previous binary is left in place, so re-runningself-updateis safe.
init always runs composer require symfony/ai-<slug>-platform under the data
directory before it writes the config file. BridgeInstallationFailedException:
-
No
composerbinary reachable:Could not run composer to install the "<package>" provider bridge; is composer on the PATH? -
composer requireran but exited non-zero (no network, or the package does not exist for a misspelled--provider):Installing the "<package>" provider bridge failed: <composer's error output> -
Could not initialize a composer project in "<dir>": <reason>— the data directory has nocomposer.jsonyet and one could not be written there (permissions). -
The data directory or its
composer.jsonis a symlink —initrefuses to write through it:Refusing to initialize a composer project in "<dir>": the target or its manifest path is a symlink.
These surface directly from init itself — doctor's "Provider bridge" check
only inspects the result of a previous init run, so a failed installation
never shows up there.
symfony/ai-bundle isn't installed, or Composer's autoloader hasn't picked it
up yet. This is a Composer/autoload issue, not a config/bundles.php ordering
problem — AiBundle and SymfonySecurityAuditorBundle can be registered in
either order.
composer require symfony/ai-anthropic-platform # or any other bridge// config/bundles.php — either order works
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
VinceAmstoutz\SymfonySecurityAuditor\SymfonySecurityAuditorBundle::class => ['dev' => true, 'test' => true],audit:run aborts with this message when no
Symfony\AI\Platform\PlatformInterface service exists in the container. The
symfony/ai-bundle recipe ships config/packages/ai.yaml with every platform
commented out — uncomment one (e.g. anthropic) and set its API key. See
Configuration → Platform Configuration.
The service "security_auditor.attacker_client" has a dependency on a non-existent service "Symfony\AI\Platform\PlatformInterface"
Same root cause as above, surfaced at container compile time (cache:clear,
cache:warmup) by versions ≤ 1.7.0. Upgrade to 1.7.1 or later — the
container then compiles without a platform and the actionable error above is
raised only when an audit actually runs.
Same root cause as above — another ≤ 1.7.0 symptom. Since 1.7.1,
PlatformBinding's platform property (and every collaborator built from it) is
typed ?PlatformInterface, so a missing platform can no longer reach a
constructor as a hard type error. Upgrade to 1.7.1 or later, or verify
ai.yaml has a platform: block and the corresponding symfony/ai-*-platform
package is installed.
SymfonySecurityAuditorBundle is registered for dev and test only by
default. Run from those environments:
APP_ENV=dev bin/console audit:run /path/to/projectTo enable in prod, change config/bundles.php:
VinceAmstoutz\SymfonySecurityAuditor\SymfonySecurityAuditorBundle::class => ['all' => true],The project-path argument must point to a directory that exists. Use an
absolute path, or omit the argument to default to the current working directory.
The auditor walks the project for .php, .twig, .yaml, .yml, .xml files
inside scan.included_paths (default: src/, config/, templates/,
public/index.php, and the root dotenv files — the Symfony Flex skeleton). If
nothing is found, the path is wrong, the layout is non-standard, or
scan.respect_gitignore is filtering everything out. A log line
No included paths exist in project at warning level confirms the allow-list
resolved to nothing.
Exit code 1 is also used for:
- Invalid
project-pathargument. - The scan discovered no file to audit at all — a mistyped path, a
scan.included_pathsentry matching nothing, or an over-narrow--path— fails rather than reporting a hollow SAFE result. A--sincerun that finds no changed files still exits0. - The normalized score fell below
--min-score, if set. - Unhandled exception during pipeline execution (check stderr).
- Validator errors on the input (e.g.
--formatset to a value it does not support — see Configuration → Options).
Re-run with -v or -vv to see the underlying error.
Confirm the env var is exported in the same shell:
echo $ANTHROPIC_API_KEY # should not be emptyIn Docker, pass it through:
docker compose exec -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" php bin/console audit:runConfigure audit.rate_limit.requests_per_minute / input_tokens_per_minute /
output_tokens_per_minute to your provider tier's limits so the auditor
throttles proactively instead of hitting a 429. Otherwise, reduce concurrent
load:
- Lower
audit.max_iterations(default3) to1. - Raise
reviewer_batch_sizefrom1to5(fewer reviewer calls). - Use a split-model with a cheaper Reviewer (Haiku, DeepSeek, Mistral) — they have higher rate limits.
- Run nightly, not on every PR.
The model returned blank or non-JSON output. The chunk is skipped automatically
and logged at error level via LoggerInterface. The log entry includes a
content_preview field with the first 512 bytes of the response — inspect it to
see what the model actually emitted. Causes:
- Model context limit exceeded — lower
audit.max_tool_iterationsor split-model to a model with a larger context. - Model refused the prompt — try a different model (some smaller open-weight models refuse "hacking" prompts).
- Network timeout — retry; check the provider's status page.
The parser tolerates prose wrapped around a balanced JSON block (the model
sometimes ignores the "Return ONLY the JSON array" instruction when tools are
enabled); a residual JsonException: Syntax error therefore means the response
contains no recoverable JSON at all, not just chatty prose.
This error only arises with audit.structured_collection: false. In the default
(true) mode, findings come in via record_vulnerability tool calls that the
provider validates against the schema, so there is no JSON parsing on the agent
side and no JsonException can be raised. Switching to the default is the
simplest fix when the model repeatedly produces unparseable prose.
If it happens for every chunk, the model is unsuitable. Switch model.
Logged at warning level when the empty response is the very first LLM call in
the loop (no tool round happened yet); once at least one tool round has run, a
later empty response logs the same message at debug instead — grep by message
text rather than filtering on warning alone if the loop used tools first. Look
at the output_tokens field: if it sits near a multiple of ~1000 (e.g. 1971,
2000), the model is being truncated by symfony/ai's default
max_tokens = 1000 that ships with the Anthropic bridge. Set
max_output_tokens in the bundle config (default 4096 since this fix) — or
attacker_max_output_tokens / reviewer_max_output_tokens for per-agent
tuning:
symfony_security_auditor:
max_output_tokens: 4096
attacker_max_output_tokens: 8192 # optional, for chunks with many findings
reviewer_max_output_tokens: 2048 # optional, reviewer needs less headroomWhen raising the cap, raise audit.rate_limit.output_tokens_per_minute
proportionally — otherwise the output-tokens bucket becomes the binding
throttle.
When the provider reports why generation stopped (symfony/ai ≥ 0.11 exposes a
normalized finish reason), the auditor logs an explicit
LLM response was truncated by the output token limit warning — no output-token
forensics needed. A LLM response was suppressed by the provider content filter
warning likewise flags responses the provider filtered out.
Pull the model first:
ollama pull llama3.3Then verify with ollama list. The model name in
symfony_security_auditor.yaml must match exactly.
Diagnostic order:
- Lower
audit.min_confidencefrom0.6to0.3— borderline findings now pass to the Reviewer. - Inspect attacker output before review — temporarily decorate
ReviewerAgentto log all incoming candidates, including non-validated ones. - Raise
audit.max_iterationsto5— the loop stops early when no new findings emerge; a stronger pass can surface more. - Switch to a stronger model — Claude Opus and GPT-5.6 consistently outperform small models.
- Check the file actually got scanned — run with
-vvto see ingested file counts and chunk counts. scan.respect_gitignore: truesilently skips files in.gitignore. Set tofalseto include them.scan.max_file_size_kbdrops large files. Default512KB; raise if your project has bigger files.
- Raise
audit.min_confidencefrom0.6to0.8. - Switch Reviewer to a stronger model (counterintuitive — Reviewer needs accuracy, not speed).
- Inspect the LLM's
reviewer_notes(logged atdebuglevel in theVulnerability reviewedentry) — the Reviewer often explains why it accepted weak findings.
LLM output is nondeterministic by design. Set temperature: 0.0 (or 0.1)
on the model:
symfony_security_auditor:
model: 'claude-haiku-4-5-20251001?temperature=0.0'With temperature: 0.0 + cache.enabled: true, repeated runs on identical code
become deterministic.
The current Claude generation (Opus 4.7/4.8, Opus 5, Sonnet 5, Fable 5) no
longer accepts temperature and rejects a request that sets it — on those
models rely on cache.enabled: true alone for run-to-run stability.
Expected behavior on large projects. Mitigations:
- Use split-model — Opus Attacker + Haiku Reviewer cuts ~50% wall time.
- Raise
reviewer_batch_sizefrom1to5— fewer Reviewer round-trips. - Lower
audit.max_iterationsfrom3to1or2. - Tighten
scan.included_pathsto specific sub-directories — e.g. point it atsrc/Controller,src/Form,src/Voter,config,templatesso high-value surfaces are audited and infrastructure code is dropped. - Enable both caches:
cache.enabled: true(default) and Anthropic prompt caching viacache_retentioninai.yaml(defaultshortalready on).
Nothing special happens — and that is the problem. The pipeline is linear in the
number of files it keeps, so a 10 000-file repository is not "slow", it is
proportionally expensive: the scanner walks the tree once, drops everything
outside scan.included_paths and every file over scan.max_file_size_kb
(default 512), groups what remains into chunks, and spends at least one LLM
call per chunk per iteration. Triple that for the default
audit.max_iterations: 3, then add one reviewer call per surviving finding.
Measure before you spend: audit:run --dry-run reports the retained file count
and the estimated token/cost total for your repository and model without
making a single audit call. Treat that number as the decision input; wall-clock
and dollar figures quoted for other projects will not transfer.
To bring a repository of that size into a sane envelope, in the order that helps most:
- Audit a slice, not the monolith.
--path src/Controller --path src/Form(repeatable) or a tightenedscan.included_pathstargets the code that actually faces user input. On a monorepo, run one audit per bounded context. - Audit only what changed.
--since main(or any git ref) restricts the run to files touched since that ref, which is what you want on a PR — cost then tracks the diff, not the repository. - Use
profile: fast. One iteration, lean pre-scan (marker-free files are dropped), code slicing (large files are trimmed to security-relevant lines) and 4× attacker/reviewer concurrency. - Cap the run.
audit.budget.max_tokens/audit.budget.max_cost_usdabort mid-run and still emit the partial report with exit code2, so a misestimated scan cannot run away with your budget. - Keep the cache on.
cache.enabled: true(default) means the second and later runs pay only for chunks whose content changed.
- Confirm
scan.included_pathsmatches the deployable code surface. The default (the Flex skeleton plus root dotenv files) already skips every file outside the Symfony skeleton —vendor/,node_modules/,var/,tests/,migrations/,translations/,bin/, root scripts, IDE folders, build artefacts — without you having to enumerate them. - Trim further by tightening
scan.included_paths: droptemplates/orconfig/if you only want to audit PHP, or replacesrcwith a list of specific sub-directories (e.g.src/Controller,src/Form,src/Voter) to focus the audit on high-value security surfaces. - Confirm Anthropic prompt caching is on —
cache_retentioninai.yaml(defaultshort) gives a ~90% input-token discount on cached prompts. - Confirm
cache.enabled: true(default) — repeated chunks skip the LLM entirely. - Lower
audit.max_tool_iterationsfrom8to4or5— caps chatty tool-use loops on each chunk at the cost of less cross-file investigation. - Switch to a cheaper Reviewer (
reviewer_model: claude-haiku-4-5-20251001ordeepseek-chat). - Set a provider-side hard cap. See CI → Set a spend cap.
- Run weekly instead of nightly for large monorepos.
The cost estimate multiplies token counts by per-model prices from the
configured PricingProviderInterface (the bundled ModelsDevPricingProvider
reads prices from the symfony/models-dev catalog shipped in vendor/). When a
configured model (model, attacker_model, or reviewer_model) is absent from
that catalog — a typo, or a model symfony/ai supports but the catalog does not
list — its price resolves to 0.0 and the dry run now prints a stderr warning:
No published pricing for the configured model(s): <model>. The dry-run cost
estimate shows $0.00 for these. If you are running a local or self-hosted model
(e.g. Ollama, LM Studio), $0.00 is correct — you can ignore this notice.
Otherwise the name is likely a typo or an unlisted model: check it in your
symfony_security_auditor configuration against the models supported by your
symfony/ai platform.
Fix the model identifier if it is a typo. If the name is correct but missing
from the catalog, the token counts in the report are still accurate — only the
USD figure is unavailable. Run composer update symfony/models-dev to pull a
fresher catalog, or alias your own PricingProviderInterface implementation to
supply prices (see Extending).
Standalone binary: composer update does not apply — there is no
user-facing vendor/ or composer.json; the catalog is baked into the binary
at release-build time from whatever symfony/models-dev version that release's
CI resolved. The only way to get a newer catalog is self-update to a newer
release, and even that only carries whatever was current when that release was
built — there is no way to refresh the catalog independently of a release yet.
The cache is keyed by chunk content hash. If your fix changes the file's bytes, the cache key changes and the LLM is re-invoked. If you see stale findings, the file content didn't actually change — diff to confirm.
To force a full re-audit:
docker compose exec php bin/console cache:clear
rm -rf var/cache/dev/symfony_security_auditor/attackerAdjust the path to match cache.dir if you overrode it.
chown -R www-data:www-data var/cacheOr pick a writable directory:
symfony_security_auditor:
cache:
dir: '/tmp/symfony-security-auditor/cache'symfony_security_auditor:
cache:
enabled: falseAttackerCacheInterface is aliased to NullAttackerCache (and
ReviewerCacheInterface to NullReviewerCache) — every chunk, and every
reviewer verdict, hits the LLM.
Causes (each logs a warning via LoggerInterface, except the deliberate
offline_only case below):
composernot inPATH— install Composer 2.4+ on the audit host.composer.lockmissing — runcomposer installfirst; advisory data comes from the lockfile.- Malformed JSON output — corrupted
composer.lock. Regenerate it. - Process error — network failure to Packagist. Retry.
privacy.offline_only: true— the advisory feed is intentionally replaced by an empty in-memory database, socomposer auditnever runs; no warning is logged since this is configured behavior, not a failure.
When lookup_advisory returns empty, the audit continues without CVE data — no
audit failure.
Within a run it executes once and the result is cached for the lifetime of
the request. Across runs, with cache.enabled: true (default),
LockfileHashedAdvisoryCache also persists the JSON payload to disk for 24h,
keyed by a SHA-256 hash of composer.lock — an unchanged lockfile skips
composer audit entirely on the next run. If it's still the bottleneck, you can
pre-warm it before the audit or override AdvisoryDatabaseInterface with
InMemoryAdvisoryDatabase containing a baked snapshot.
Implement Audit/Domain/Port/AdvisoryDatabaseInterface:
# config/services.yaml
services:
VinceAmstoutz\SymfonySecurityAuditor\Audit\Domain\Port\AdvisoryDatabaseInterface:
alias: App\Security\MyCustomAdvisoryDatabaseSee Configuration → Advisory Source.
Verify audit.tools_enabled: true (the default). With tools disabled, the
Attacker uses LLMClientInterface::complete() (single-shot) instead of
completeWithTools().
Some models do not support tool/function calling — verify your provider's docs. Most major providers (Anthropic, OpenAI, Gemini, Mistral) do; some smaller Ollama models don't.
Lower audit.max_tool_iterations from the default 8. Once the cap is hit, the
Attacker is forced to commit to a final JSON answer.
See Advisory (composer audit) Issues above.
Both tools only search the files ProjectFileScanner already loaded into memory
during ingestion — neither touches the filesystem live. Causes:
- The file falls outside
scan.included_paths, is excluded byscan.respect_gitignore, or exceedsscan.max_file_size_kb. - The file, or one of its
scan.included_pathsancestors, is a symlink.ProjectFileScannerskips symlinks unconditionally regardless of where they point (logged asSkipped symlinked file/Skipped symlinked included path) — a symlink pointing back inside the project is skipped too, not just one pointing outside it. read_file'srelative_pathargument must matchProjectFile::relativePath()exactly (e.g.src/Controller/UserController.php). It has no absolute-path fallback — an absolute path never matches and returnsError: file "..." is not part of the audited project.
The workflow needs security-events: write permission:
permissions:
contents: read
security-events: writeUpload it as a sast report:
artifacts:
reports:
sast: gl-sast-report.sarifPath can be anything — GitLab parses the file. See CI → GitLab CI.
Common causes:
- API key secret not exposed to the job (check the workflow
envblock). - CI runner lacks Composer 2.4+ →
lookup_advisoryreports empty. composer.locknot committed →lookup_advisoryreports empty.- Different model name between local config and CI config.
Do not silence it. PHPStan suppressions (@phpstan-ignore-*, baseline) are
forbidden — see
CLAUDE.md → Never Silence Quality Gates.
Fix the underlying type issue. Genuine PHPStan false positives require a
tracking issue and a justification in the PR description.
A mutation survived your tests. Read the Infection log
(infection/infection.log) to see which mutator and which line. Add a test that
distinguishes the mutated behavior. Suppression annotations are forbidden.
Run bin/castor lint:fix to apply the changes. Both tools enforce the project
style — diverging styles get rejected in CI. If you genuinely need a deviation,
document the reason in the PR.
- Different PHP version — CI matrix runs 8.3, 8.4, 8.5; pin locally with Docker.
- Filesystem case sensitivity — Linux CI is case-sensitive; macOS is not.
- Random test order — Infection rewrites
phpunit.dist.xmlto forceexecutionOrder="defects,random"for its own runs; reproduce locally with--order-by=defects,random --random-order-seed=<seed>.