vinceamstoutz/symfony-security-auditor
A Symfony security auditor that scans your app's security configuration and flags misconfigurations, weak settings, and risky access rules. Get actionable reports to harden firewalls, authentication, and authorization before deployment.
This is an AI-driven security auditor: it indexes your repository, scrubs secrets and risky patterns, then runs attacker/reviewer model passes over route and controller code. It ships two ways — as a Symfony bundle (bin/console mcp:serve, bin/console audit:run) and as a standalone binary. From a Laravel codebase, use the standalone binary. Do not composer require it into the app; bundle mode is Symfony-kernel oriented, while the binary just needs a path to the repo.
Minimal path:
# 1. Point it at a provider. An OpenAI-compatible gateway is the least friction:
symfony-security-auditor init --provider=generic.internal --base-url=https://ai.internal/v1
# 2. Give it a credential (hidden prompt, or store it once):
symfony-security-auditor auth:set --env-var=AI_GATEWAY_KEY
symfony-security-auditor auth:status # tells you which key an audit would use, and where it came from
# 3. Verify wiring before spending tokens:
symfony-security-auditor doctor
# 4. First real run, on a legacy app, without breaking CI:
symfony-security-auditor audit:run --generate-baseline
Where to look first: doctor (it names the key that resolved and points at init --provider=<platform> when a bridge is missing), init --help (defaults are now documented), then audit:run --help.
Key resolution order is: exported environment variable > stored key > %env(file:VAR)% file reference. A run with nothing available now opens with No API key available. and names the ways out rather than a stack trace.
The first genuinely valuable use case on a Laravel app is not the LLM pass — it's a baseline run of the secret scrubber plus the LACKS_ACCESS_CHECK-class static findings, committed as a baseline so the team only ever sees new problems.
CI gate with a baseline. Generate once, commit it, then:
symfony-security-auditor audit:run \
--baseline=.symfony-security-auditor.baseline \
--fail-on-incomplete
--fail-on-incomplete exits 3 when any file could not be fully analyzed (including when combined with --generate-baseline). Without it you only get a warning — Audit incomplete: N file(s) could not be fully analyzed — on stderr when the report goes to stdout. Treat 3 as "fix the parser coverage or exclude the path", not as "findings".
GitHub Action. Use the fail-on-incomplete input and the new complete output to distinguish "clean" from "clean but blind". Save reports with --output and parse that file; the defusing of ::… / ##[ on runners applies to stdout-bound output only, and the github format plus --output reports are byte-identical to before.
Two-tier configuration. This is the main architectural concept in 1.21:
User config (~) |
Project .symfony-security-auditor.yaml (committed) |
|
|---|---|---|
| Model ids | full ids with options (?temperature=…) |
plain ids only |
cache, privacy, audit.custom_skills, scan.secret_scrubbing, scan.custom_risk_patterns |
allowed | refused |
audit.budget |
set | may tighten, may not loosen |
%env()%, %name%, %% |
allowed | refused |
| Section shape | any | maps only |
So: keep tuning, prompt overrides, custom risk patterns and budget ceilings in the user config; commit only the shared, boring model/scope decisions. The audit header now names the layered project file, which makes "why did it behave differently on the runner" answerable.
Provider indirection. provider: <platform>.<instance> selects a named instance of generic, openresponses, azure, bedrock, cache or failover. For Laravel teams the useful shapes are:
provider: generic.gateway # one OpenAI-compatible endpoint, any vendor behind it
# or
provider: generic.gateway
model: openrouter/auto # gateway routes; billing follows the answered model
and for air-gapped or cost-sensitive repos, init --provider=ollama now writes a keyless config with a required endpoint instead of one the next run cannot boot.
Bedrock without SDK plumbing. init configures Bedrock through Mantle routes (anthropic.* → messages, google.gemma-* → responses, everything else → completions), reads BEDROCK_API_KEY, and installs the bridge the route needs. --no-api-key switches to SigV4 signing, which is the right choice on an instance role.
Credentials as files, not env. %env(file:VAR)% reads the credential from the file VAR names — designed for Docker/Kubernetes secrets, systemd LoadCredential= and plain 0600 files. Combined with the owner-only credentials.json sitting beside the config file, this is the pattern to standardise on; an exposed (group/world-readable) credentials file is refused rather than used.
MCP for agents. symfony-security-auditor mcp:serve now exists in the standalone binary, so you can expose the auditor as a tool to an IDE/agent without a Symfony console. This is the cheapest way to get "audit this diff" in-editor.
Cost tracking. by_model still reports the configured model; the new additive billed_models key reports what the provider says actually answered. If you have a cost dashboard, read billed_models — with openrouter/auto, a failover platform, or an alias resolved to a dated release, by_model alone silently under-reports.
.symfony-security-auditor.yaml files that used %env()%, %name%, %-escapes, cache, privacy, audit.custom_skills, scan.secret_scrubbing, scan.custom_risk_patterns, or loosened audit.budget will now stop the run naming the offending key. Move those keys to the user config. Budget a migration commit before bumping the version in CI.model, attacker_model, reviewer_model and audit.escalation.cheap_model must be plain ids there; ?temperature=… belongs in the user config. Values or keys containing control characters, :: or ##[ are refused — the error names the key but deliberately does not echo the value, so don't expect to see the bad string in the log.2 is a configuration/init refusal (bare or mistyped instance, empty base URL, a model on Bedrock that doesn't name its vendor) — the message lists the configured instances. 3 is an incomplete audit. Don't fold them into a single "failed" bucket in your pipeline.--output, not stdout. On a GitHub Actions runner, reports, errors, progress lines, --show-scanned and audit:diff are defused so a runner can't read them as workflow commands. Only the github format and --output files are unchanged.access_control coverage is now honest — and narrower. A route covered only by a rule the production application never loads (when@test, a non-production config folder, a file nothing imports) is LACKS_ACCESS_CHECK again, and the attacker prompt labels that coverage as "read from the repository's YAML, not verified". For Laravel this matters differently: access_control is a Symfony concept, so route/middleware enforcement will read as uncovered. Scope the scan (or baseline those findings) rather than chasing them.SECRETKEY=, APIKEY=, DBPASS=, PGPASSWORD=) and PASS/PW suffixes (DB_PASS=, MYSQL_ROOT_PW=, pass: '…'), while deliberately leaving MONKEY, MAX_PW_LENGTH and BYPASS_CACHE alone. Unusual Laravel env names will still slip through — add scan.custom_risk_patterns in the user config.init is honest about what it can't do. For azure, cartesia, higgsfield, dockermodelrunner, lmstudio, transformersphp it installs the bridge, prints a ready-to-edit YAML block and exits 2 without writing; cache and failover are refused with a reason. Don't wrap init in a script that assumes success on exit 0 alone.init for Ollama. Configs written by older versions are keyless-less; the new writer emits a keyless config with a required endpoint. Old files may simply not boot.--base-url % handling is strict. An empty URL exits 2; a URL where every % begins a percent-encoded octet is written verbatim; any other % is refused. If you template URLs in a wrapper script, encode them first.auth:status and doctor show only a masked preview plus a fingerprint — if you lose the key, auth:set replaces it, you don't recover it. And init no longer prints a paste-ready export line, precisely so it doesn't land in shell history.doctor → auth:status → audit:run --show-scanned (remember it's defused on runners) → only then the model/provider. Most "it broke" reports are key resolution or provider-instance typos, both of which doctor names outright.How can I help you explore Laravel packages today?