symfony-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited symfony-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill is the Symfony/PHP-specific layer on top of secure-coding. Symfony has a sophisticated Security Component but plenty of foot-guns: firewall ordering, missing voters, Doctrine string-interpolation, Twig opt-outs, and PHP's enduring RCE classes (unserialize gadget chains, dynamic includes, type juggling).
Triggers on:
composer.json with symfony/* packages, config/packages/security.yaml, config/services.yaml, src/Controller/, src/Entity/, templates/*.twig, bin/console.security.yaml, voter classes, Doctrine repositories with raw SQL or DQL, Twig templates with |raw, or any controller that calls unserialize(), include $var, system(), or eval().security-review or api-security when Symfony is in the stack.secure-coding.api-security. Use this skill for the Symfony-specific implementation; that skill for the conceptual API layer.p/php) → sast-orchestrator.composer.lock → cve-triage.container-hardening..env, parameters.yaml, or external vault → secrets-scanner.Eight phases. Phase 1 (security.yaml) is where most production bugs sit; phase 5 (PHP RCE classes) is where Symfony skill diverges most from the other framework-specific skills.
Symfony's Security Component is configured in config/packages/security.yaml. The structure:
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity: { class: App\Entity\User, property: email }
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
entry_point: form_login
form_login: { ... }
logout: { ... }
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]Common foot-guns:
firewalls map top-down and applies the first match. A broad pattern early (e.g. pattern: ^/) eats subsequent firewalls. Specific patterns first, generic last.dev firewall has security: false for the profiler/asset paths. If somehow dev patterns leak into prod (mistyped pattern, copy-paste), parts of the site become unauthenticated. Cross-check the prod environment loads security.yaml without dev overrides.path: ^/api rule above a path: ^/api/admin, roles: ROLE_ADMIN rule means admin endpoints are only IS_AUTHENTICATED, not ROLE_ADMIN.IS_AUTHENTICATED_REMEMBERED < IS_AUTHENTICATED_FULLY < IS_AUTHENTICATED_ANONYMOUSLY. For sensitive actions use FULLY; remembered tokens are weaker (cookie-based, can be stolen without re-auth).ROLE_ADMIN: ROLE_USER (string instead of [ROLE_USER]) silently fails for ROLE_USER inheritance. Always use array notation.anonymous: ~ line in firewall config is intentional but worth flagging.Route-level access_control is coarse. Sensitive actions need voters.
final class PostVoter extends Voter
{
public const EDIT = 'POST_EDIT';
protected function supports(string $attribute, $subject): bool
{
return $attribute === self::EDIT && $subject instanceof Post;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) return false;
return $subject->getAuthor() === $user;
}
}Then in the controller:
#[IsGranted('POST_EDIT', subject: 'post')]
public function edit(Post $post): Response { ... }if ($post->getAuthor() === $user) inline. Works, but easy to forget on a new endpoint. The voter centralizes the rule.#[IsGranted] attribute (PHP 8+) or $this->denyAccessUnlessGranted() in older code. Pick one style and apply consistently.Doctrine parameterizes by default through DQL placeholders and QueryBuilder parameters. SQLi arises where you step out.
Safe:
$qb->where('u.email = :email')->setParameter('email', $email);
$conn->executeQuery('SELECT * FROM users WHERE email = ?', [$email]);Unsafe:
$qb->where("u.email = '{$email}'"); // string interpolation
$conn->executeQuery("SELECT * FROM users WHERE email = '$email'");
$qb->orderBy($request->query->get('sort')); // user-controlled columnSpecific patterns:
.order_by(request.GET.get('sort')). Allowlist column names.setParameter is preferred.? or :name placeholders + parameter array.Twig auto-escapes for the active context (HTML, JS, CSS, URL). XSS arises where you opt out.
|raw filter on user input as XSS.<script> blocks and applies JS escaping. But <script>var x = {{ data|json_encode|raw }}</script> is the safer pattern (JSON serializer escapes correctly, |raw here is intentional because json_encode already produced safe output).<a href="{{ url }}"> correctly by default for HTML quoting, but javascript: URLs in href bypass HTML escaping. Validate url server-side (allowlist http:, https:, mailto:).new \Twig\Markup($value, 'UTF-8') marks content as already-safe. If $value includes user input, that is XSS. Audit Twig\Markup instantiation.This is the phase where Symfony skill diverges most from the Java/Python/Ruby framework skills. PHP has several features that turn user input into code execution if mishandled.
`unserialize()` on user input — RCE via gadget chains:
__wakeup, __destruct, __toString, etc. magic methods.unserialize() input.json_decode (returns scalars/arrays only, no class instantiation) or a strictly-validated JSON schema.allowed_classes parameter (unserialize($data, ['allowed_classes' => false])) blocks class instantiation but is a brittle defense — json_decode is cleaner.unserialize(.`include` / `require` with user input — LFI/RFI:
include $_GET['template']; // LFI/RFI vector
include $base . $request->get('p'); // path traversal if 'p' contains ../allow_url_include should be Off in php.ini — historic mitigation against RFI but never the only defense.`system`, `exec`, `passthru`, `shell_exec`, backticks — command injection:
Process component (Symfony\Component\Process\Process) which escapes arguments correctly: $process = new Process(['ls', '-la', $userPath]); // arguments are NOT shell-interpreted
$process->run();new Process('ls -la ' . $userPath)) — that re-introduces shell interpretation.escapeshellarg() / escapeshellcmd() are platform-dependent and have edge cases; prefer the Process component's array form.`eval()` — direct code execution:
ExpressionLanguage) — sandboxed, no PHP code execution.Type juggling and loose comparisons:
== does type coercion: '0' == false is true, 'abc' == 0 is true (in PHP <8), '1abc' == 1 is true.=== (strict).in_array($needle, $haystack) defaults to loose comparison — pass true as third argument for strict._token field automatically. Validate via $this->isCsrfTokenValid('intention', $token) for forms not built with the Form Component (e.g. AJAX endpoints).stateless: true in the firewall config.config/packages/framework.yaml):cookie_secure: auto or true for HTTPS-onlycookie_httponly: truecookie_samesite: lax or strictgc_maxlifetime reasonable (e.g. 1 hour)name_strict_mode: true to prevent session-fixation via attacker-supplied session IDauto algorithm (Symfony picks bcrypt or argon2id based on what's available). Cost factor configurable; argon2id with memory-cost 65536+ is the current default..env is the public default, .env.local is gitignored secrets, .env.local.php is the compiled production cache (run composer dump-env prod).bin/console secrets:set): encrypts secrets per-environment with separate decryption keys. Production decryption key stored outside the repo..env or secrets vault.WebProfilerBundle exposes /_profiler with full request internals (DB queries, log entries, env vars). MUST be disabled in prod (framework.profiler.enabled: false and remove from bundles.php for prod env).app_dev.php to production by mistake. Should not be reachable.APP_ENV=prod APP_DEBUG=0 in production. Debug mode shows stack traces with secrets and code paths.framework.trusted_proxies and framework.trusted_hosts lock down which proxies and hosts the app trusts. Misconfig = host-header injection vector.Symfony CVE feed: https://symfony.com/blog/category/security-advisories is the canonical source. Always [verify] against the current advisory list before citing in a finding — Symfony patches in minor-release windows and the advisory list moves.
Recent CVE classes worth grepping for:
[verify against the advisory feed].[verify].[verify].Practical defense: stay within 1 minor release of latest. Track the security-advisories blog category; subscribe to the RSS feed.
Symfony-based CMSes (this skill applies, with CMS-specific overlays):
Layer 1: scope (security.yaml, all controllers with #[IsGranted] or auth checks, all Doctrine repositories with custom queries, all Twig templates using |raw, all calls to unserialize/include/system/eval walked through?), assumptions (firewall order verified, voter coverage on resource-modifying endpoints?), gaps (Profiler disabled in prod, debug off, secrets in vault?). Layer 2: Symfony CVE-IDs verified against https://symfony.com/blog/category/security-advisories (no fabricated IDs), Doctrine and Twig API names current for the version in use, CMS-specific role-system claims (Sulu, Ibexa) backed by their own docs.
Symfony security review — <app/module>
Symfony: <x.y.z> | PHP: <x.y> | CMS: <Sulu | Ibexa | Bolt | none (custom)>
security.yaml:
Firewall count: N
Firewall order audit: <clean | finding: pattern X eats Y>
access_control rules: N, ordered specific→generic? <yes/no>
role_hierarchy: <correct array notation | finding>
IS_AUTHENTICATED_FULLY on sensitive routes: <yes/no>
Voters and authorization:
Voter classes: N
Coverage on resource endpoints: <% with #[IsGranted] or denyAccessUnlessGranted>
Inline ownership checks (no voter): <list — refactor candidates>
Doctrine:
Raw DQL/SQL with string interpolation: <list>
orderBy with user input: <list, allowlist applied?>
expr()->literal usage: <count, replacement plan>
Twig:
|raw on user input: <list>
autoescape false blocks: <list>
Twig\Markup wrappers: <audit>
PHP RCE primitives:
unserialize() calls: <list, source of input>
include/require with $var: <list>
system/exec/passthru: <list, Process component used?>
eval(): <should be 0; if not, FINDING>
Strict comparison (===): <audit auth/token comparisons>
CSRF + session:
CSRF token validation on non-Form endpoints: <yes/no>
cookie_secure / httponly / samesite: <yes/no per setting>
Password algorithm: <auto | argon2id | bcrypt | weak>
Configuration:
Profiler in prod: <disabled | FINDING if enabled>
APP_DEBUG in prod: <0 | FINDING if 1>
Secrets in .env vs vault: <distribution>
Trusted proxies/hosts: <set | not set — header-injection risk>
Version + CVE check:
Symfony version: <on latest minor + N | drift>
Recent advisories: <0 unresolved | N pending — handoff to cve-triage>
CMS-specific (if applicable):
Sulu role system: <reviewed>
Ibexa policies: <reviewed>
Findings (severity-sorted, follow security-review format)
Verification-loop: ...~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.