Update the relevant section in the same commit/PR whenever the project evolves.
Rules scoped to specific paths live in .claude/rules/ — update them too. Never
leave this file describing a state that no longer exists.
symfony-security-auditor — AI-powered multi-agent security auditor for
Symfony applications. Distributed as a Symfony bundle (symfony-bundle
package type). Uses a dual-agent attacker/reviewer loop backed by symfony/ai
to detect vulnerabilities and produce structured reports.
Always check
composer.jsonfor authoritative dependency versions — never rely on version numbers written here.
| Layer | Technology |
|---|---|
| Language | PHP (see composer.json → require.php) |
| Framework | Symfony (see composer.json) |
| LLM | symfony/ai (provider-agnostic: Anthropic, OpenAI, Mistral, Ollama, …) |
| Packaging | symfony-bundle + Flex recipe; standalone self-contained native binary (box + static-php-cli micro) for Linux/macOS/Windows |
| Tests | PHPUnit (Unit / Integration / EndToEnd); 100% line coverage enforced via the custom MinimumLineCoverageExtension (tools/PHPUnit/); the report-only ergebnis/phpunit-slow-test-detector surfaces tests over its maximum-duration (500 ms), and SlowTestGuardExtension (tools/PHPUnit/) derives its gate from that same threshold — read straight from the detector's config, single source of truth — multiplying it by GUARD_HEADROOM_FACTOR (3, i.e. 1500 ms) before failing the run, because one wall-clock sample on a shared CI runner can push a 3 ms test past a 500 ms bar; the report still surfaces everything over 500 ms and per-test #[MaximumDuration] overrides replace the bar outright — which is why overrides only ever raise it (every one in the suite is 4000 or 8000 ms): an override below the shared bar opts that test out of the headroom and turns one unlucky wall-clock sample into a red build |
| Mutation | Infection (100% MSI required); infection.json5 sets testFrameworkOptions: "--no-extensions" because Infection decides kill-vs-escape by parsing PHPUnit's stdout, and ergebnis/phpunit-agent-reporter replaces that stdout with a JSON report whenever it detects a coding-agent environment variable (CLAUDECODE, AI_AGENT, CURSOR_AGENT, …) — Infection then reads every mutant as killed and reports 100% MSI unconditionally, so a run inside an agent session silently proves nothing. --no-extensions also keeps the coverage and slow-test gates out of per-mutant runs, where they never belonged |
| Static analysis | PHPStan max + phpstan-strict-rules + custom rules (FinalRule, MaxParameterCountRule, NoEmptyCatchRule, NoSilencingErrorHandlerRule, ForbiddenTestAttributeRule, SprintfOverConcatRule — in tools/PHPStan/) + symplify/spaze rules + Rector |
| Layer conformance | deptrac (DDD layer rules + the SymfonyProfile framework boundary — deptrac.yaml) |
| Complexity | tomasvotruba/cognitive-complexity (function ≤ 7, class ≤ 40) |
| Dead code | rector/swiss-knife (check-commented-code, check-conflicts) |
| Style | PHP CS Fixer (@PER-CS3x0, @Symfony rulesets) |
| CI/CD security | zizmor (static analysis for GitHub Actions — scans .github/workflows/ + action.yml) |
bin/castor up| Task | Command |
|---|---|
| Install dependencies | bin/castor up (runs docker compose up --wait) |
| Stop containers | bin/castor down |
| Lint (check only) | bin/castor lint |
| Lint + auto-fix | bin/castor lint:fix |
| Markdown check only | bin/castor lint:docs (fast pre-push check) |
| Run PHP tests | docker compose exec php vendor/bin/phpunit |
| Run mutation tests | bin/castor lint (its Infection step mirrors CI's flags — see below) |
| Score detection quality | bin/castor eval (audits a ground-truth fixture, reports precision/recall) |
| Symfony console | docker compose exec php bin/console <command> |
bin/castor lint runs sequentially: Prettier (check) → Markdown lint
(markdownlint-cli2) → Composer Normalize → PHP CS Fixer → Rector → PHPStan (max,
500M) → Deptrac (DDD layers) → Swiss Knife (commented-code + merge-conflict
scan) → Install script tests (tests/Shell/install_script_test.sh) → PHPUnit →
Infection. bin/castor lint:fix auto-fixes steps 1–3 (Prettier, Markdown lint,
Composer Normalize); the remaining steps are check-only.
Run Infection through bin/castor lint, never as a bare bin/infection.
The task's PHPUnit step emits the
--coverage-clover/--coverage-xml/--log-junit set that its Infection step
then reuses via
--coverage=build/coverage --skip-initial-tests --min-msi=100 --min-covered-msi=100,
matching .github/workflows/ci.yaml; invoking bin/infection with ad-hoc flags
has twice reported a green 100% MSI on a commit CI then rejected. Note that a
single green run is still not proof of CI parity: Infection rewrites
phpunit.dist.xml to force executionOrder="defects,random" while disabling
result caching, so test order is effectively random per invocation — see #275.
Commit messages are validated separately in CI via
commitlint (commitlint.config.js) — see
Commit Messages.
src/
Audit/
Domain/ # Pure PHP — no framework, no I/O
Configuration/ # Typed config VOs (BundleConfiguration, AuditProfile, LLMConfiguration, PrivacyConfiguration, CustomAttackerSkill, …)
Model/ # Value objects and enums (Vulnerability [+ `of()` factory + CodeLocation/VulnerabilityClassification/VulnerabilityNarrative], SymfonyMapping [+ `of()` + ProjectFileInventory/AccessControlMap], AuditReport [+ ReportIdentity], ExecutiveSummary (stakeholder view: risk level + severity/type/file distributions), ProjectFile, ProjectFileType [+ `archetype()`], SurfaceArchetype (framework-neutral file shape), FrameworkVocabulary (framework name/template language/idiomatic fixes for the synthesizer prompts), ProjectFileTypeClassifier, CvssEstimate (heuristic CVSS v4.0 per finding), RouteAccessControl, VoterCapability, FormBinding, TokenUsageSnapshot, VulnerabilityHydrationResult, VulnerabilityDropReason, ReviewerFeedback/AcceptedFindingFeedback, …) — public factories use `of()`; the wide `create()` is `@deprecated`
Exception/ # Domain exceptions (LLMProviderException, GitChangedFilesUnavailableException, InvalidCodeLocationException, InvalidVulnerabilityClassificationException)
Pipeline/ # PipelineInterface, StageInterface, CoverageRecorderInterface (ports)
Port/ # Cross-layer ports (LLMClientInterface, BatchCapableLLMClientInterface, ToolBatchCapableLLMClientInterface, LLMResponse, *PromptBuilderInterface, ProjectFileScannerInterface, AttackerCacheInterface, ContextAwareAttackerCacheInterface, ReviewerCacheInterface, AdvisoryDatabaseInterface, SecretScrubberInterface, TokenEstimatorInterface, PricingProviderInterface, RateLimiterInterface, ProgressReporterInterface, StaticPreScannerInterface, CodeSlicerInterface, ControllerAccessControlParserInterface, VoterCapabilityParserInterface, FormBindingParserInterface, SecurityConfigParserInterface, GitChangedFilesResolverInterface, ReviewerFeedbackProviderInterface, AttackerSkillPromptRendererInterface) + null-object port defaults (NullStaticPreScanner, NullReviewerFeedbackProvider, NullCodeSlicer, NullControllerAccessControlParser, NullVoterCapabilityParser, NullFormBindingParser, NullSecurityConfigParser, NullProgressReporter — all `@internal`)
Tool/ # ToolInterface, ToolDefinition, ToolRegistry, ToolRegistryFactoryInterface
Application/ # Orchestration — no I/O, depends only on Domain
UseCase/ # RunAuditUseCase, EstimateAuditCostUseCase (entry points)
Pipeline/ # AuditPipeline + Stage/{IngestionStage, MappingStage, DependencyExpansionStage, AuditStage, PoCSynthesisStage, FixSynthesisStage}
Agent/ # AttackerAgent (+ AttackerLlmCollaborators, AttackerScanCollaborators, AttackerAnalysisSettings, AttackerAnalysisRequest, RiskMarkerIndex, AttackerContextPromptRenderer, Chunk/{ChunkContext, ChunkContextFactory, AttackerChunkCache, ChunkCoverageRecorder, ChunkFindingProgress, SequentialChunkAnalyzer, ConcurrentChunkAnalyzer, StructuredVulnerabilityCollectionSession}), ReviewerAgent (+ ReviewerAgentCollaborators, ReviewerModeConfiguration, Review/{VerdictApplier, BatchVerdictApplier, ReviewOutcomeRecorder, ReviewerVerdictCache, CodeContextResolver, SequentialReviewAnalyzer, StructuredReviewAnalyzer, ConcurrentReviewAnalyzer, ConcurrentStructuredReviewAnalyzer, BatchReviewAnalyzer, ReviewBatchSettings, ReviewCacheBuckets, CachePartition, ConcurrentReviewBatch, StructuredReviewCollectionSession}), EscalatingAttackerAgent, AuditOrchestrator (+ AuditLoopSettings), VulnerabilityFactory, VulnerabilityCollector, RecordVulnerabilityToolFactoryInterface, ReviewCollector, RecordReviewToolFactoryInterface, PoCSynthesizer, FixSynthesizer, Chunking/{ChunkingStrategy, FileChunker}
Infrastructure/ # I/O adapters
LLM/ # SymfonyAiLLMClient (ctor takes PlatformBinding + PlatformRequestConfig + PlatformResilienceConfig + PlatformAccountingConfig; builds RetryingPlatformInvoker, SequentialToolLoop, BatchWindowResolver, ToolConversationWavefront, PlatformResultExtractor, PlatformOptionsFactory, PlatformToolsMapper, PromptTokenEstimator), RetryPolicy (+ BackoffSchedule, RateLimitBackoff, Exception/InvalidRetryConfigurationException), TransientFailureClassifier, TokenEstimator/{ProviderTokenEstimatorInterface, ResolvingTokenEstimator, CharacterRatioCounter, AnthropicTokenEstimator, OpenAiTokenEstimator, GeminiTokenEstimator, MistralTokenEstimator, LlamaTokenEstimator, DeepSeekTokenEstimator, MiniMaxTokenEstimator}, Delay/, RateLimit/{NullRateLimiter, TokenBucketRateLimiter, RetryAfterHeaderParser}
FileSystem/ # ProjectFileScanner, RegexSecretScrubber, NullSecretScrubber
Scan/ # RegexStaticPreScanner, SarifImportingPreScanner (merges scan.import_sarif SARIF results as risk markers), RegexCodeSlicer, PhpParserControllerAccessControlParser, PhpParserVoterCapabilityParser, PhpParserFormBindingParser, SymfonyYamlSecurityConfigParser
Diff/ # ProcessGitChangedFilesResolver (git diff for --since)
Prompt/ # AttackerPromptBuilder (+ SymfonyMappingContextRenderer, NumberedFileContextRenderer, Skill/{AttackerSkillInterface, AttackerSkillRegistry, one *AttackerSkill per attack surface, ConfiguredAttackerSkill for config-driven audit.custom_skills}), ReviewerPromptBuilder (+ Reviewer/{ReviewerPromptSectionsInterface, ReviewerPromptSections, ReviewerMessageRendererInterface, ReviewerMessageRenderer, ReviewerFeedbackHolder})
Cache/ # FilesystemAttackerCache, NullAttackerCache, FilesystemReviewerCache, NullReviewerCache
Advisory/ # ComposerAuditAdvisoryDatabase (default) + LockfileHashedAdvisoryCache (TTL-bounded lockfile-hash cache in front of it, wired when cache.enabled), DeferredAdvisoryDatabase (lazy wrapper), InMemoryAdvisoryDatabase (fallback), ComposerAuditRunnerInterface + SymfonyProcessComposerAuditRunner
Pricing/ # ModelsDevPricingProvider (default), ModelPrice
Progress/ # ConsoleProgressReporter (decorated TTY), PlainProgressReporter (CI/non-TTY), LoggerProgressReporter, ProgressReporterHolder, ProgressContext, AuditOverviewLine
Tool/ # ReadFileTool, GrepTool, ListFilesTool, LookupAdvisoryTool, SymfonyToolRegistryFactory, RecordVulnerabilityTool, RecordVulnerabilityToolFactory, RecordReviewTool, RecordReviewToolFactory
Report/ # ReportRendererInterface (format/render) + one class per format ({Console,ExecutiveSummary,Json,Sarif,Html,Markdown,Junit,GithubAnnotations,GithubComment}ReportRenderer) + MarkdownTextEscaper (shared Markdown-injection defenses) + DistributionBarChart/ChartBar (inline-SVG charts in the HTML report) + ReportPackage + TemplateLoader; + Template/*.txt + *.html stubs
Command/ # AuditCommand (Symfony Console: audit:run, alias audit) + AuditCommandInput, AuditPresenter, ConsoleBanner (the identity banner, also rendered by StandaloneApplication for every non-audit command), ReportWriter, AuditExitCodeResolver, ExitCode enum, AuditCommandHelp, OutputFormat enum (console|executive|json|sarif|html|markdown|junit|github|github-comment), Baseline (accepted-finding suppression); DiffCommand (audit:diff — compares two JSON reports by finding fingerprint) + ReportDiffer, ReportDiff/DiffFinding, DiffPresenter, DiffOutputFormat enum (console|json); TrendCommand (audit:trend — tracks finding counts across two or more JSON reports) + ReportTrendAnalyzer, ReportTrend/TrendPoint, TrendPresenter, TrendOutputFormat enum (console|json)
SymfonySecurityAuditorBundle.php # Bundle class (configure + loadExtension)
tests/Phpunit/
Unit/ # Isolated class tests (stub/mock collaborators)
Integration/ # Wire real classes, no LLM calls
EndToEnd/ # Full pipeline, uses stub LLM client
tests/Shell/ # POSIX shell tests (install_script_test.sh — covers install.sh)
config/services.php # DI wiring for all bundle services
docs/
architecture.md # Layer overview, data flow, domain model details
configuration.md # Bundle config reference
cost-and-performance.md # Profiles, split-model, concurrency, caching, budgets, rate limits
extending.md # Extension point guide
ci.md # CI pipeline documentation
diagrams.md # Mermaid diagrams
faq.md # Common questions: cost, accuracy, comparisons, model picks, privacy
troubleshooting.md # Empty reports, LLM errors, advisory issues, cache, CI failures
Strict DDD layering under src/Audit/. Infrastructure never leaks into Domain
or Application.
Command → Application → Domain ← Infrastructure (implements ports)
LLMClientInterface is the sole seam between Application and LLM I/O.
AttackerAgent and ReviewerAgent never import any symfony/ai type directly.
Dual-agent loop (up to 3 iterations, stops earlier when no new findings):
- (optional)
StaticPreScannertags files with deterministic risk markers; (optional)CodeSlicertrims large files to security-relevant lines AttackerAgent— chunks files (defaultfeaturestrategy: a controller with its entity/repository/form/voter/templates together;typefor the legacy priority window; API Platform#[ApiResource]classes classify asapi_resourcewith their own skill block), injects markers + prior-iteration findings, calls LLM. By default (audit.structured_collection: true), findings come in throughrecord_vulnerabilitytool calls validated by the provider against the tool's JSON schema; with the flag off, the attacker parses a JSON array from the response. Withaudit.attacker_max_concurrent> 1 (thefastprofile sets 4) and a tool-batch-capable client, cache-miss chunks are analyzed concurrently. OptionalEscalatingAttackerAgentruns a cheap model first and only escalates flagged files to the expensive model.- Filter — confidence ≥ 0.6
ReviewerAgent— validates each finding, may adjust severity. By default (audit.reviewer_structured_collection: true), verdicts come in through schema-enforcedrecord_reviewtool calls; the explicit opt-inreviewer_tools_enabledkeeps the JSON path, andreviewer_max_concurrent1 reviews findings concurrently (structured when the client supports tool batching, JSON otherwise). Verdicts are cached across runs (
FilesystemReviewerCache) whencache.enabledis on; every review mode — concurrent and batched (reviewer_batch_size > 1) alike — serves cached verdicts first and dispatches/batches only the misses.- Deduplicate → persist to
AuditContext
After the loop, the optional PoCSynthesisStage runs (concrete reproduction
artifacts for high-severity findings).
Full details: docs/architecture.md
Minimal:
symfony_security_auditor:
model: 'claude-opus-5'One-knob preset (fast | balanced | thorough; explicit keys always win):
symfony_security_auditor:
profile: 'fast'Split-model (larger attacker, faster reviewer):
symfony_security_auditor:
attacker_model: 'claude-opus-5'
reviewer_model: 'claude-haiku-4-5-20251001'Swapping LLM providers requires only config/packages/ai.yaml changes — no code
changes.
Full reference: docs/configuration.md
Format: <type>[optional scope]: <description> —
Conventional Commits
| Type | When |
|---|---|
feat |
New user-facing feature |
fix |
Bug fix |
refactor |
Neither fix nor feature |
test |
Adding/fixing tests |
docs |
Documentation only |
chore |
Maintenance/tooling |
build |
Build system/deps |
ci |
CI configuration |
perf |
Performance improvement |
Common scopes: agent, pipeline, domain, llm, command, bundle,
standalone, scan, deps, ci, rate-limit, release. Breaking changes:
feat!: with BREAKING CHANGE: footer.
Fill in .github/PULL_REQUEST_TEMPLATE.md
and keep the top of the PR short:
- Title: 50 characters or fewer, same Conventional Commits format as a commit subject.
## Summary: 500 characters or fewer. State the user-visible outcome and stop. Per-finding walkthroughs, verification tables and reviewer notes belong in their own sections further down — the summary is the part everyone reads, so it must stay skimmable.
Always squash-merge, never rebase-merge. Every PR becomes exactly one commit on its base branch. Rebase-merging replays each of the PR's commits individually — with a fresh SHA apiece, even when nothing about them changed.
Exception: the chore: release X.Y.Z PR that merges <N>.x into main.
Use a regular merge (a merge commit) there instead, never squash and never
rebase. main is supposed to end up with the exact same commit SHAs as <N>.x
— that's how every release before this one actually happened (PR #226, for
1.18.0). Squashing collapses <N>.x's commits into one new SHA that doesn't
exist on <N>.x, so the two branches permanently diverge in commit identity and
need a cherry-pick-back reconciliation after every single release.
Rebase-merging is worse: it replays every commit <N>.x has accumulated since
it last diverged from main with a fresh SHA apiece — which is how PR #305
quietly turned a one-commit release into ~40 replayed commits landing on main
and broke Commit Lint (see
Branches & maintenance).
Seven jobs must all pass before merging: Prettier Check (markdown
formatting) → Markdown Lint (markdownlint-cli2 semantics) → Commit Lint
(commitlint, conventional commits) → Lint (Composer Normalize, PHP CS Fixer,
Rector, PHPStan max, Deptrac, Swiss Knife, composer audit, install-script
shell tests) → zizmor (GitHub Actions security scan via
zizmorcore/zizmor-action, SARIF
uploaded to Code Scanning) → Tests + Mutation (PHPUnit matrix on PHP
8.3/8.4/8.5 × Symfony 7.4/8.0/8.1 with 100% coverage, then Infection 100% MSI;
coverage uploads to Codecov and the mutation report uploads to the Stryker
dashboard via Infection's stryker logger — the badge tracks main, and
same-repo branches publish their own report).
Details: docs/ci.md
This project is itself a security tool — it must not ship the vulnerability
classes it hunts. Command and code execution is therefore banned at the
static-analysis level: phpstan.dist.neon explicitly includes: the
spaze/phpstan-disallowed-calls disallowed-execution-calls.neon ruleset,
forbidding raw execution sinks (exec, shell_exec, system, passthru,
proc_open, popen, pcntl_exec, backtick operator, eval). eval is
double-locked — also forbidden via the ForbiddenNodeRule Eval_ entry.
This ban is a deliberate manual opt-in, not a freebie. Although
phpstan/extension-installer is installed, it only auto-loads the package's
extension.neon, which registers the rule engine with every disallowed*
array empty (zero bans by default). The curated
disallowed-execution-calls.neon set is wired in by hand on line 2 of
phpstan.dist.neon — delete that line and the ban silently disappears with no
error. Keep it.
Consequences for contributors:
- All subprocess work routes through Symfony
Process(e.g.ProcessGitChangedFilesResolver,SymfonyProcessComposerAuditRunner), never a raw exec call —Processdoes not invoke a shell by default, so there is no argument-interpolation command-injection surface. - Never satisfy a disallowed-call error with an
allowIn/exclusion entry; route throughProcessinstead. Suppressing this gate is covered by the Never Silence Quality Gates rule.
The same "don't ship what we hunt" posture applies to the project's own GitHub
Actions: a dedicated zizmor job scans .github/workflows/ and action.yml
on every push and pull request, and every third-party uses: — including
zizmor-action itself — is pinned to a commit SHA (never a mutable tag) for the
same reason Process is required over raw exec: a moving reference is an
unreviewed-code-execution surface. These pins aren't manually-maintained dead
weight — the existing github-actions entry in .github/dependabot.yaml
recognizes the # vX.Y.Z trailing-comment convention and opens a PR bumping
both the SHA and the comment whenever a pinned action releases. Both
dependabot.yaml update entries also carry a 7-day cooldown
(default-days: 7), so Dependabot never opens a bump PR for a release younger
than a week — the window in which a compromised or yanked supply-chain release
is typically caught — which also clears zizmor's dependabot-cooldown audit.
Before implementing: state assumptions explicitly, surface tradeoffs, present multiple interpretations rather than picking silently. If unclear, stop and ask.
Minimum code that solves the problem. No speculative features, no abstractions for single-use code, no error handling for impossible scenarios.
Touch only what the request requires. Don't improve adjacent code. Match existing style. If you notice unrelated dead code, mention it — don't delete it. Remove only imports/variables that YOUR changes made unused.
Transform tasks into verifiable goals. For multistep tasks, state a brief plan with a verify step for each.
Never bypass static analysis or mutation testing via suppression annotations or config opt-outs. Forbidden (non-exhaustive):
- PHPStan —
@phpstan-ignore,@phpstan-ignore-line,@phpstan-ignore-next-line,ignoreErrorsentries inphpstan.dist.neon, baseline files. - Infection —
@infection-ignore-all,@infection-ignore-all-for, per-mutatorignoreentries ininfection.json5,ignoreSourceCodeByRegex. - PHPUnit / coverage —
@codeCoverageIgnore*,@requires/markTestSkippedused to dodge a failing test,@groupused to exclude from CI. - PHP CS Fixer / Rector —
@phpcs:ignore,// @phpstan-ignore,\Rector\Skip, blanket--no-check.
If a tool flags something, fix the underlying code. Genuine exceptions (a real false positive, a library bug) require a PR-description justification and a linked issue tracking removal — never silent suppression.
The project follows Semantic Versioning 2.0.0 and, for its
PHP API surface, the
Symfony Backward Compatibility promise
(@internal code is exempt). Treat every public-API element as load-bearing:
configuration keys (and their defaults), the audit:run command (and its
audit alias) arguments/options/exit codes, JSON and SARIF output schemas,
Domain ports under src/Audit/Domain/Port/ (including
AdvisoryDatabaseInterface), Domain models/enums/exceptions, RunAuditUseCase,
and the Bundle class. A change that removes or alters any of these is a MAJOR
and requires a deprecation cycle.
Internal classes (@internal PHPDoc tag) — concrete agents, pipeline stages,
infrastructure adapters, Command collaborators — may be refactored freely in a
MINOR. When you add a class that is not an extension point, add the
@internal tag. When you add a public configuration key, list it in
docs/versioning.md and add it to resources/schema.json (the JSON Schema that
powers editor autocompletion for symfony_security_auditor.yaml).
Canonical policy: docs/versioning.md.
Rules scoped to specific paths live in .claude/rules/:
changelog.md— classify every change (major/minor/patch or Unreleased) and updateCHANGELOG.mdin the same commit; release-notes format.ddd-layers.md— dependency direction across layers.domain-models.md— immutability, copy-on-write, deterministic IDs insrc/Audit/Domain/**.llm-seam.md—LLMClientInterfaceboundary between Application andsymfony/ai.php-classes.md—final readonly, interfaces/SOLID, single responsibility, Symfony components insrc/**.testing.md— TDD red/green/refactor, stub vs mock, suite layout, mutation score.no-comments.md— no multi-line comment blocks; comments signal poorly-written code; fix the code instead.