Symfony Security Triage
Decides how a finding is handled, not whether the code is wrong. It complements
symfony-security-review (which finds missing hardening) by making the disclosure
call on a report.
The three dispositions and the conventions that record them:
| Disposition |
Label |
Branch prefix |
Process |
| CVE |
Has CVE + severity |
cve-* |
Private fix, GHSA/CVE, credit, blog post, coordinated release |
| Public hardening |
none / Not a security issue |
harden-*, hardening-*, pin-*, fix-* |
Normal open PR, changelog, no embargo |
| Not a security issue |
Not a security issue / Won't fix |
n/a or fix-* |
Reply to reporter; optionally a doc/robustness PR |
This skill produces a recommendation. The final call belongs to the Symfony security
team; treat its output as a structured argument, and defer to symfony.com/security
for the authoritative "what is not a vulnerability" list.
Progress checklist
Confirmation rule
Whenever this skill says "Wait for confirmation", treat anything other than an
explicit affirmative as no: stop and ask the user how they want to proceed.
Step 0 — Establish the facts
Before classifying, pin down four things. Guessing any of them produces a wrong call.
- Reachability: is the vulnerable code on a path reachable from untrusted input in
a default configuration, or does it need opt-in/insecure config?
- Actor and precondition: what must the attacker already have? Unauthenticated and
remote is the worst case; "already controls the serialized bytes" or "already has the
app secret" usually means the precondition is itself game-over.
- Impact: RCE, auth/authz bypass, SSRF to internal, signature/secret bypass that
accepts forged input, XSS on a default-rendered surface, secret disclosure, open
redirect, session fixation, or "only" DoS / info of low value.
- Contract: is the component meant to defend this boundary (router,
HttpFoundation/HttpClient, Security, webhook verification, HtmlSanitizer, Mime
parsing), or is the unsafe behaviour documented as the caller's responsibility
(deserializing untrusted bytes, a trusted-channel feature such as ESI,
template_from_string)?
Reproduce if at all possible; an unreproducible report is not yet triable.
Step 1 — Disposition decision tree
Apply in order. The first matching bucket wins.
It is not a security issue if any of these hold
- Pure DoS / resource exhaustion. Generally excluded from the CVE pipeline. Fix as
hardening with a limit if cheap, but no CVE.
- Requires misuse contrary to documentation, with no default-config attack. The
component is not contracted to defend this (e.g. deserializing untrusted bytes through
an API documented as trusted-only; using a trusted-channel feature to reach internal hosts).
- Dev-only tooling (profiler, debug, web-profiler) manifesting only in a dev environment.
- Precondition is already game-over (attacker already holds the app secret, controls
the deserialized input contract, or has local/physical access).
- Not reproducible, or rooted in a third-party dependency outside Symfony's control.
- Not a realistic bypass (a comparison nuance with no working exploit, etc.).
It is a CVE only if all of these hold
- Default-reachable: exploitable against a default or documented-safe config.
- Expected actor: the attacker is at or below the trust level the boundary is
meant to enforce (typically unauthenticated/remote, or a lower-privileged user
escalating), with no game-over precondition.
- Contracted boundary: the component is meant to defend this (see Step 0.4).
- Real impact: RCE, auth/authz bypass, SSRF to internal, sanitizer/signature
bypass accepting forged input on a sensitive sink, stored/reflected XSS on a default
surface, secret disclosure, or open redirect with meaningful reach.
- Maintained: the vulnerable code ships in a maintained version.
Otherwise it is public hardening
A genuine improvement where a CVE condition fails. Typical shapes:
- Defense-in-depth on top of an existing control, or that only matters once another
bug already holds (an
__unserialize __toString trampoline guard needs a pre-existing
untrusted unserialize() entry to matter).
- Safer-default change where the old default was not a vulnerability under the
threat model (adding a webhook IP allowlist, pinning the HMAC algorithm).
- Limited impact or exposure even though a boundary is technically crossed (a forged
webhook against an opt-in endpoint with a user-configured secret injects only a
delivery-status event).
- Robustness improvements (input-length caps, broader sanitizer coverage).
Step 2 — Severity and affected branches
Severity (match the low/medium/high labels; CVSS is a sanity check, not the goal):
- high: unauthenticated RCE, auth bypass, full sanitizer bypass enabling stored XSS, SSRF reaching internal services.
- medium: reflected XSS, open redirect, signature bypass with bounded impact, info disclosure of non-trivial data.
- low: defense-in-depth, narrow-config or low-impact issues, most hardening.
Affected branches: find the oldest version where the vulnerable code exists, intersect
with maintained_versions from https://symfony.com/releases.json. Fix on the lowest
maintained affected branch, then merge up (see the symfony-merge-up skill). Record the
oldest exposure even if it predates maintained versions.
Step 3 — Route
State the recommendation as: disposition + severity + affected maintained branches +
the one-line rationale (which decision-tree conditions decided it), then route:
- CVE: name the branch
cve-<slug>-<branch>; apply Has CVE + severity; the fix is
prepared privately and goes through the coordinated-disclosure process (request a
GHSA/CVE, credit the reporter, prepare the security release and blog post). Do not
open a public PR or push to a public remote before release. Wait for confirmation
before any outward step.
- Public hardening: name the branch
harden-/hardening-/pin-/fix-<slug>; open a
normal PR with a CHANGELOG entry; use symfony-security-review to confirm the fix and
symfony-hardening-rule to add a durable gate where the class recurs.
- Not a security issue: draft a short, factual reply to the reporter explaining why
(cite the contract/threat-model reason), and optionally a doc clarification or
low-priority robustness PR. Apply
Not a security issue / Won't fix.
In every case, the fix follows TDD, component-scoped tests, no em-dashes,
no Claude/Anthropic credit, comments sparingly, no issue references in code.
Worked examples (abstracted patterns)
| Finding shape |
Disposition |
Deciding factor |
| SSRF filter (private-network client) bypassed in a default configuration |
CVE |
default control bypassed, unauthenticated reach |
HTML sanitizer lets a javascript: URL through on a default profile |
CVE |
sanitizer's core contract bypassed, stored XSS |
| URL generator emits a path that crosses a routing boundary by default |
CVE |
boundary crossed in default use |
| A signed transport decodes the payload before verifying its MAC |
CVE candidate, high |
pre-auth RCE if signing is meant to defend a malicious broker; confirm the trust model |
Webhook signature compared with !==, or the secret is ignored |
Hardening |
opt-in endpoint, user-configured secret, bounded impact |
__unserialize assigns a string property without a \Stringable guard |
Hardening |
needs a pre-existing untrusted unserialize() entry (game-over precondition) |
| Input-length cap / broader sanitizer coverage added |
Hardening |
robustness, not a default-exploitable bypass |
| Unbounded recursion / regex backtracking / cache growth on input |
Hardening, low |
pure DoS, excluded from the CVE pipeline |
| Deserializing bytes through an API documented as trusted-only |
Not a security issue |
documented contract, caller's responsibility |
| A trusted-channel feature (e.g. ESI/SSI) used to reach internal hosts |
Not a security issue |
trusted by design |
| Case-insensitive host allowlist with no working bypass |
Not a security issue |
not a realistic bypass |
Gotchas
- "Boundary crossed" does not imply CVE. Impact and exposure decide it. A forged
webhook against an opt-in, user-secret endpoint is hardening; an SSRF filter bypass in
default use is a CVE.
- DoS is the most common miscategorisation. Resource exhaustion is hardening/low,
not a CVE, even when trivially triggerable.
- Defense-in-depth tells. If exploiting the finding requires another, already-present
vulnerability or a leaked secret, it is hardening.
- Trust-model questions are for the maintainer. A signed-transport verify-order case
turns on whether the broker is trusted; surface the question, do not assume.
- Embargo discipline. Never name a CVE-bound finding, push a
cve-* branch, or open a
public PR for a CVE-class finding before the coordinated release. Wait for confirmation.
- Defer to authority.
symfony.com/security is the source of truth for what is not a
vulnerability; this skill encodes observed practice, not policy.
Error handling
- If reachability or trust model is unknown, say so and triage as needs-human-judgement;
do not force a disposition.
- Security reports are handled privately. Do not echo report contents into public
artifacts, commit messages, or branch names that leak the vulnerability before release.
- Never push to a public remote during CVE triage. Stop and hand back to the user.
Source: symfony/symfony — distributed by TomeVault.
1---2name: symfony-symfony-symfony3description: Symfony Security Triage4---56# Symfony Security Triage78Decides how a finding is handled, not whether the code is wrong. It complements9`symfony-security-review` (which finds missing hardening) by making the disclosure10call on a report.1112The three dispositions and the conventions that record them:1314| Disposition | Label | Branch prefix | Process |15|---|---|---|---|16| **CVE** | `Has CVE` + severity | `cve-*` | Private fix, GHSA/CVE, credit, blog post, coordinated release |17| **Public hardening** | none / `Not a security issue` | `harden-*`, `hardening-*`, `pin-*`, `fix-*` | Normal open PR, changelog, no embargo |18| **Not a security issue** | `Not a security issue` / `Won't fix` | n/a or `fix-*` | Reply to reporter; optionally a doc/robustness PR |1920This skill produces a recommendation. The final call belongs to the Symfony security21team; treat its output as a structured argument, and defer to `symfony.com/security`22for the authoritative "what is not a vulnerability" list.2324## Progress checklist2526- [ ] Step 0: Establish the facts (reproduce, scope, trust model)27- [ ] Step 1: Apply the disposition decision tree28- [ ] Step 2: Assign severity and affected maintained branches29- [ ] Step 3: Route (branch prefix, labels, next workflow, reporter reply)3031## Confirmation rule3233Whenever this skill says **"Wait for confirmation"**, treat anything other than an34explicit affirmative as **no**: stop and ask the user how they want to proceed.3536---3738## Step 0 — Establish the facts3940Before classifying, pin down four things. Guessing any of them produces a wrong call.41421. **Reachability**: is the vulnerable code on a path reachable from untrusted input in43 a default configuration, or does it need opt-in/insecure config?442. **Actor and precondition**: what must the attacker already have? Unauthenticated and45 remote is the worst case; "already controls the serialized bytes" or "already has the46 app secret" usually means the precondition is itself game-over.473. **Impact**: RCE, auth/authz bypass, SSRF to internal, signature/secret bypass that48 accepts forged input, XSS on a default-rendered surface, secret disclosure, open49 redirect, session fixation, or "only" DoS / info of low value.504. **Contract**: is the component meant to defend this boundary (router,51 HttpFoundation/HttpClient, Security, webhook verification, HtmlSanitizer, Mime52 parsing), or is the unsafe behaviour documented as the caller's responsibility53 (deserializing untrusted bytes, a trusted-channel feature such as ESI,54 `template_from_string`)?5556Reproduce if at all possible; an unreproducible report is not yet triable.5758## Step 1 — Disposition decision tree5960Apply in order. The first matching bucket wins.6162### It is **not a security issue** if any of these hold63- **Pure DoS / resource exhaustion.** Generally excluded from the CVE pipeline. Fix as64 hardening with a limit if cheap, but no CVE.65- **Requires misuse contrary to documentation**, with no default-config attack. The66 component is not contracted to defend this (e.g. deserializing untrusted bytes through67 an API documented as trusted-only; using a trusted-channel feature to reach internal hosts).68- **Dev-only tooling** (profiler, debug, web-profiler) manifesting only in a dev environment.69- **Precondition is already game-over** (attacker already holds the app secret, controls70 the deserialized input contract, or has local/physical access).71- **Not reproducible**, or rooted in a third-party dependency outside Symfony's control.72- **Not a realistic bypass** (a comparison nuance with no working exploit, etc.).7374### It is a **CVE** only if **all** of these hold751. **Default-reachable**: exploitable against a default or documented-safe config.762. **Expected actor**: the attacker is at or below the trust level the boundary is77 meant to enforce (typically unauthenticated/remote, or a lower-privileged user78 escalating), with no game-over precondition.793. **Contracted boundary**: the component is meant to defend this (see Step 0.4).804. **Real impact**: RCE, auth/authz bypass, SSRF to internal, sanitizer/signature81 bypass accepting forged input on a sensitive sink, stored/reflected XSS on a default82 surface, secret disclosure, or open redirect with meaningful reach.835. **Maintained**: the vulnerable code ships in a maintained version.8485### Otherwise it is **public hardening**86A genuine improvement where a CVE condition fails. Typical shapes:87- **Defense-in-depth** on top of an existing control, or that only matters once another88 bug already holds (an `__unserialize` `__toString` trampoline guard needs a pre-existing89 untrusted `unserialize()` entry to matter).90- **Safer-default change** where the old default was not a vulnerability under the91 threat model (adding a webhook IP allowlist, pinning the HMAC algorithm).92- **Limited impact or exposure** even though a boundary is technically crossed (a forged93 webhook against an opt-in endpoint with a user-configured secret injects only a94 delivery-status event).95- **Robustness** improvements (input-length caps, broader sanitizer coverage).9697## Step 2 — Severity and affected branches9899**Severity** (match the `low`/`medium`/`high` labels; CVSS is a sanity check, not the goal):100- **high**: unauthenticated RCE, auth bypass, full sanitizer bypass enabling stored XSS, SSRF reaching internal services.101- **medium**: reflected XSS, open redirect, signature bypass with bounded impact, info disclosure of non-trivial data.102- **low**: defense-in-depth, narrow-config or low-impact issues, most hardening.103104**Affected branches**: find the oldest version where the vulnerable code exists, intersect105with `maintained_versions` from `https://symfony.com/releases.json`. Fix on the lowest106maintained affected branch, then merge up (see the `symfony-merge-up` skill). Record the107oldest exposure even if it predates maintained versions.108109## Step 3 — Route110111State the recommendation as: **disposition + severity + affected maintained branches +112the one-line rationale (which decision-tree conditions decided it)**, then route:113114- **CVE**: name the branch `cve-<slug>-<branch>`; apply `Has CVE` + severity; the fix is115 prepared privately and goes through the coordinated-disclosure process (request a116 GHSA/CVE, credit the reporter, prepare the security release and blog post). Do **not**117 open a public PR or push to a public remote before release. **Wait for confirmation**118 before any outward step.119- **Public hardening**: name the branch `harden-`/`hardening-`/`pin-`/`fix-<slug>`; open a120 normal PR with a CHANGELOG entry; use `symfony-security-review` to confirm the fix and121 `symfony-hardening-rule` to add a durable gate where the class recurs.122- **Not a security issue**: draft a short, factual reply to the reporter explaining why123 (cite the contract/threat-model reason), and optionally a doc clarification or124 low-priority robustness PR. Apply `Not a security issue` / `Won't fix`.125126In every case, the fix follows TDD, component-scoped tests, no em-dashes,127no Claude/Anthropic credit, comments sparingly, no issue references in code.128129---130131## Worked examples (abstracted patterns)132133| Finding shape | Disposition | Deciding factor |134|---|---|---|135| SSRF filter (private-network client) bypassed in a default configuration | **CVE** | default control bypassed, unauthenticated reach |136| HTML sanitizer lets a `javascript:` URL through on a default profile | **CVE** | sanitizer's core contract bypassed, stored XSS |137| URL generator emits a path that crosses a routing boundary by default | **CVE** | boundary crossed in default use |138| A signed transport decodes the payload before verifying its MAC | **CVE candidate, high** | pre-auth RCE if signing is meant to defend a malicious broker; confirm the trust model |139| Webhook signature compared with `!==`, or the secret is ignored | **Hardening** | opt-in endpoint, user-configured secret, bounded impact |140| `__unserialize` assigns a string property without a `\Stringable` guard | **Hardening** | needs a pre-existing untrusted `unserialize()` entry (game-over precondition) |141| Input-length cap / broader sanitizer coverage added | **Hardening** | robustness, not a default-exploitable bypass |142| Unbounded recursion / regex backtracking / cache growth on input | **Hardening, low** | pure DoS, excluded from the CVE pipeline |143| Deserializing bytes through an API documented as trusted-only | **Not a security issue** | documented contract, caller's responsibility |144| A trusted-channel feature (e.g. ESI/SSI) used to reach internal hosts | **Not a security issue** | trusted by design |145| Case-insensitive host allowlist with no working bypass | **Not a security issue** | not a realistic bypass |146147## Gotchas148149- **"Boundary crossed" does not imply CVE.** Impact and exposure decide it. A forged150 webhook against an opt-in, user-secret endpoint is hardening; an SSRF filter bypass in151 default use is a CVE.152- **DoS is the most common miscategorisation.** Resource exhaustion is hardening/low,153 not a CVE, even when trivially triggerable.154- **Defense-in-depth tells.** If exploiting the finding requires another, already-present155 vulnerability or a leaked secret, it is hardening.156- **Trust-model questions are for the maintainer.** A signed-transport verify-order case157 turns on whether the broker is trusted; surface the question, do not assume.158- **Embargo discipline.** Never name a CVE-bound finding, push a `cve-*` branch, or open a159 public PR for a CVE-class finding before the coordinated release. **Wait for confirmation.**160- **Defer to authority.** `symfony.com/security` is the source of truth for what is not a161 vulnerability; this skill encodes observed practice, not policy.162163## Error handling164165- If reachability or trust model is unknown, say so and triage as needs-human-judgement;166 do not force a disposition.167- Security reports are handled privately. Do not echo report contents into public168 artifacts, commit messages, or branch names that leak the vulnerability before release.169- Never push to a public remote during CVE triage. Stop and hand back to the user.170171---172> Source: [symfony/symfony](https://github.com/symfony/symfony) — distributed by [TomeVault](https://tomevault.io).173<!-- tomevault:4.0:skill_md:2026-06-24 -->