.agents/skills/symfony-security-review/SKILL.md
Finds hardening that is missing or wrong, grounded in code. It runs in two modes:
src/ tree, for one or all families.Both modes run two passes: first reason about the change's trust boundaries from first
principles (to catch novel issues), then check it against the hardening-invariant families
catalogued in hardening-families.md. The catalogue is a checklist
of known classes, not the search space.
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.
When auditing the whole src/ tree or a large multi-file component, fan Step 1 and Step 4
out to subagents: one per family, or one per file-batch, each returning findings that point
at a concrete file and line (no speculative findings leaking in through parallelism). Keep
the rest in the main loop: aggregation, de-duplication, the completeness check, and the
report are synthesis and must see every finding at once. For a single PR or a small diff,
run every step inline; subagents are pure overhead there.
Review a change. Resolve the diff and the changed non-test files:
# A public PR
gh pr diff <n> --repo symfony/symfony
# The current branch against its base (e.g. 6.4)
git diff <base>...HEAD --stat
Audit a target. Take a component path (e.g. src/Symfony/Component/Mailer)
or the whole src/ tree. There is no diff; the "changed files" are the target files.
In both modes, build the file list and drop tests (*/Tests/*). Hardening
lives in the implementation; tests are checked separately in Step 3.
State the resolved mode and scope back to the user in one line before continuing.
This pass finds what the catalogue does not list. Do it before mapping to families, and do not let the anchors narrow it. For each file in scope, reason as an attacker, independent of any known family:
Record every input-to-sink path with a non-trivial worst case as a candidate finding, whether or not it matches a catalogued family. An unguarded path from untrusted input to a dangerous sink is a finding even if no anchor names it. This step uses no greps by design; it is meant to see sinks the dictionary misses.
Now apply the catalogue as a checklist, to confirm no known class slipped past Step 1. Treat each anchor's listed APIs as seed examples of an abstract role (an inbound authenticator, a secret comparison, a deserialization entry, an XML/URL sink, a file or process sink); extend to anything in scope that plays that role, including classes the grep does not name.
For each file in scope, decide which families it touches using the grep anchors in
hardening-families.md. Run the anchors against the scope, not
the whole tree, when reviewing a change:
# Example: which families does this branch touch?
git diff <base>...HEAD --name-only | grep -v /Tests/ > /tmp/scope.txt
grep -lf <(printf 'extends AbstractRequestParser\nfunction __unserialize\nhash_hmac(\nvalidateOnParse\nloadXML(\nescapeshellarg(\npreg_match') $(cat /tmp/scope.txt) 2>/dev/null
Carry forward both Step 1's boundary findings and the families with a sink in scope; they proceed to Step 4. List them.
These encode the already-shipped invariants. Run them first; anything they catch needs no manual argument.
Hardening-test convention (tokenizer only, always runs locally):
php .github/sa-tools/check-hardening-tests.php
Fails if a concrete AbstractRequestParser lacks a RejectWebhookException test,
or a class with __unserialize() and a string property lacks a __toString
gadget test. Accepted gaps live in its ALLOWLIST const.
Custom PHPStan rules (HardenedComparisonRule, UnserializeToStringTrampolineRule,
UnserializeMissingAllowedClassesRule). In CI these run base-vs-PR through
phpstan-diff.php, which only fails on errors new to the PR. To reproduce
locally you need PHPStan installed (it is not in composer.json; CI installs it
ad hoc). The source of truth for a rule's logic is a RuleTestCase, not an
ad-hoc phpstan analyse run:
# Only meaningful if phpstan is installed in the project
./vendor/bin/phpstan analyse --error-format=json --no-progress \
--autoload-file=.github/sa-tools/rules/bootstrap.php
Do not trust raw phpstan analyse counts for these rules: the result cache
plus parallel workers make them nondeterministic (a rule can report 0 then N for
the same input). See the gotchas.
For each in-scope family, apply its invariant from hardening-families.md.
The catalog gives, per family: the grep anchor, the invariant, the automated
coverage (if any), and the decision boundary.
Work the sinks, not the families in the abstract: for every sink site the anchor found, answer the family's check question. If the answer is "no guard / wrong guard", it is a candidate finding; confirm it is not excluded by the decision boundary before reporting.
Output a table, highest severity first:
| Location | Family | Severity | Invariant at risk | Suggested hardening | Hardening test |
|---|---|---|---|---|---|
Component/.../Foo.php:NN | webhook-verify | High | signature compared with !== | hash_equals() | extend parser reject test |
Severity rubric (match the corpus, not CVSS theatre):
When a finding moves on to symfony-security-triage, Critical and High both map to its
high label; Medium and Low map to medium and low.
For each real finding, state whether a hardening regression test is required so
check-hardening-tests.php (or a component test) keeps it from being dropped later.
Completeness check (before you finalise). State explicitly: is there an untrusted input, a sink, a deserialization, an authentication, or a trust boundary in scope that matched no anchor and was not already raised in Step 1? If so, reason about it from scratch before reporting. A clean family sweep is not a clean review.
The table holds findings from both passes: the Step 1 boundary pass (Family column = the
vulnerability class, or novel) and the Step 4 family review.
Separate confirmed findings from needs-human-judgement ones. Do not inflate.
This follows the house conventions:
./phpunit src/Symfony/Component/<Name> (never the whole suite).symfony-hardening-rule skill.Co-Authored-By, no Claude/Anthropic credit. Comments sparingly,
and do not reference issue numbers in code or tests.$secret that disables webhook
verification is documented behaviour, not a bug. A literal allowed_classes on
a first-party cache file was ruled not-a-security-issue. Pure DoS / memory
exhaustion is generally outside the CVE pipeline (treat as Low). The catalog
lists these; respect them or you will cry wolf.__unserialize(), int/float/bool
coercion throws TypeError without calling __toString. Do not flag non-string slots.RuleTestCase, and rely on the CI
base-vs-PR diff (phpstan-diff.php), not local counts.