How it works¶
The planner is a loop over candidates, where every candidate is a concrete composer update
invocation. Nothing is inferred about what "ought" to resolve; Composer's solver decides.
composer.json + composer.lock
│
▼
dependency graph ─────► advisory matching ─────► findings
│ │
│ ▼
│ candidate generation
│ (ordered, least invasive first)
│ │
▼ ▼
for each candidate: Composer\Installer dry-run ──► new lock (in memory)
│
├── solver failed ──────────► rejected (with the solver's reason)
│
└── solved ──► re-match advisories on new lock
│
├── still vulnerable ──► rejected
└── clean ────────────► valid candidate
│
▼
ranking → plan
1. Dependency graph¶
The locked packages are loaded through Composer's Locker and wrapped in an
InstalledRepository, the same structure composer why walks. For each vulnerable package the
planner collects every path back to the root, so a package required by two parents produces two
paths, and a remediation that fixes one path but leaves the other blocking is rejected later by the
solver.
Root-controlled packages are those named in the project's require or require-dev.
2. Advisory matching¶
Advisories arrive through an AdvisoryProvider. Phase 0 uses the same Packagist advisory API that
composer audit uses, through Composer's own repository classes; if that @internal API breaks at
runtime, the lookup falls back to a composer audit --locked subprocess and the report says so (see
design decisions). Each advisory carries an
affected versions constraint; the locked version is tested against it with Composer's semver
implementation, so dev branches and aliases behave exactly as they do in Composer.
Composer does not consult replace declarations when matching advisories. The planner does: a
monorepo package such as symfony/symfony is checked against advisories for every package it
replaces.
3. Candidate generation¶
For a vulnerable package V with fixed range F, and A the nearest root-required ancestor on
each path, candidates are generated in this order:
| # | Command shape | When it wins |
|---|---|---|
| 1 | composer update V |
The lock file is simply stale; the existing constraints already permit a fixed version. |
| 2 | composer update V -w -m --with V:F |
The parent constraint permits a fixed version but a sibling dependency also has to move. |
| 3 | composer update A -W -m --with V:F |
The parent must move. Tried for the nearest A first, then further ancestors, then the union of ancestors when several parents block. |
| 4 | widen the root constraint of A to the next major, then composer update A -W -m |
No release within the current root constraint works. Tagged as a major change. |
| 5 | add V as a direct requirement with range F |
Advanced strategy, disabled by default: it takes ownership of a transitive package. |
--with V:F is a temporary constraint. Composer applies it by removing every version of V
outside F from the solver's pool, and it works for transitive packages since Composer 2.4. The
package must still be reachable through the allow list or -w/-W for the solver to change it.
Finding the lowest working parent version¶
composer update A -W -m keeps transitive packages where they are when possible, but it still
moves A itself to the newest version the root constraint allows. That is often more than the
smallest safe change: if A 1.1.0 already requires the fixed V, jumping to A 1.2.0 may drag
other packages along. After a parent update validates, the planner therefore probes downwards with
a temporary upper bound on A (--with 'A:>current,<newest') until the solve breaks or the
vulnerability comes back, and then pins the lowest version that worked:
composer update A:1.1.0 -W -m --with 'V:>=1.1.0'
The descent is bounded to a handful of solves per finding. Both the pinned command and the plain one are kept as candidates; ranking decides.
Sibling packages that pin the parent¶
Meta-package families pin each other exactly: shopware/administration requires shopware/core
6.4.15.1, drupal/core-dev requires drupal/core 10.3.1. Updating the parent alone then fails
with Composer's message "X is locked to version … and an update of this package was not
requested". The planner reads those names out of the solver output, adds them to the command and
retries, a bounded number of times, so the recommendation becomes
composer update shopware/core shopware/storefront shopware/administration … -W -m.
Simplifying the winning command¶
--with and -m exist to steer the solver during the search. Before a command is recommended,
the planner re-solves it without them (each separately, then both) and keeps the simplest spelling
that produces exactly the same lock: the same version, the same source and dist references, and the
same production/development classification for every package. A human ends up with composer update acme/app-framework:1.1.0
-W -m rather than the same command with a trailing constraint they would never have typed.
4. Validation¶
Each candidate is executed as a dry-run of Composer's Installer against a fresh in-memory
Composer instance with plugins and scripts disabled. A dry-run writes nothing and installs
nothing, but it does compute the full lock transaction, which the planner reads back. If the solver
fails, the candidate is rejected and the solver's explanation is kept for the report.
A successful solve is not enough. The planner re-runs advisory matching on the new lock, and a candidate that resolves but still contains an affected version is rejected.
When the in-process route fails for a reason that is neither a dependency conflict nor a network
error (an exception inside Composer's PHP API, typically a version incompatibility), the same
candidate is retried through a fallback that runs the real composer update … --no-install in a
scratch copy of the project and reads the written lock file. Both routes produce the same lock diff.
--solver=in-process or --solver=subprocess pins one route; the default auto is the fallback
chain. The subprocess uses the Composer binary that is running the plugin (or
REMEDIATE_COMPOSER_BINARY), so both routes solve with the same Composer release.
Whatever the route, a solve that fails for tool or network reasons is recorded as such. A finding without a verified fix is reported as "none" only when every solve completed; when a solve errored the outcome is "unknown" and the exit code is 3 (solver error) or 5 (network), never 2.
5. Ranking¶
Valid candidates are ordered by deterministic rules, in this order of precedence:
- no root constraint changes;
- no major-version changes;
- no pre-release versions (alpha, beta, RC, dev) among the targets;
- fewest changed packages;
- fewest direct dependency changes;
- fewest removals and additions;
- smallest total version movement, measured on the actual version numbers so a lower parent version wins a tie.
A weighted score may replace these rules once the fixture corpus provides evidence for the weights.
6. Multiple findings¶
Several advisories on the same package are planned together: the fixed range is the complement of the union of every advisory the source knows for that package (not only the ones hitting the locked version, so a newer release affected by a different advisory is never proposed), and a candidate is valid only when all of them are gone. Ignored advisories do not shrink the range. One package, one command.
Across packages, the planner looks for one command that fixes everything with as little change as
possible (global planning). The per-package winners are merged into a single command and validated
with one more solve, under the same acceptance rules as any candidate: no new advisories, no release
younger than the cooldown. When that merge does not fix everything, because the solver finds no
solution or because one finding's fix undoes another's, the search swaps in the next-ranked candidate
of a finding that is in the way and tries again, within a budget of ten further solves; among the
combinations that resolve it keeps the one fixing the most findings, then the smallest lock diff. A
combination that fixes everything is then shrunk: the contribution of each finding whose package a
sibling's fix already moves is dropped in turn and the rest re-verified, and the smaller command is
kept when it still fixes everything, which is common when a parent update covers a sibling's finding
(in the BookStack fixture, updating onelogin/php-saml moves robrichards/xmlseclibs past its
advisory, so the latter leaves the command). Finally the simplest spelling is re-matched
rather than assumed.
Every step is recorded. The summary lists the search ("per-package winners merged: … does not
resolve", "acme/b: candidate ranked 2 instead of 1: … fixes all, chosen", "without the command for
acme/a: … fixes 1 of 2") so a reader can see why the recommended command is what it is and why
smaller or different ones were not chosen; the JSON report carries the same list as
summary.combined_search. The summary then says either "you can fix all N findings with …", or "…
fixes k of N findings" when some advisories have no reachable fix, or lists the per-package commands
when no combination resolves.
7. Gating, baselines and cooldowns¶
The exit code encodes the outcome (0 clean, 1 fixable, 2 not fixable, 3 to 5 tool problems) and two options shape which findings count towards it:
--fail-on <severity>: findings whose advisories are all below the threshold are reported but do not affect the exit code. Unknown severities always count.--baseline <file>: findings listed in the file (advisory@packagekeys,advisory@package/targetfor a finding reached through a replaced or provided package) are reported, marked[baselined], and excluded from the exit code.--update-baselinewrites the current findings to the file. This is how a gate is introduced on a project with existing findings: accept today's state, fail on anything new, and delete entries as fixes land.
--min-release-age <days> rejects every candidate whose resulting lock contains a release published
within the last N days, or a release whose date is unknown (the guard never promises an age it
cannot prove). It applies to individual candidates and to the combined command alike. It is a
supply-chain cooldown: a version that appeared yesterday may be compromised or broken, and nothing
forces a security fix to be applied within hours. When every fix is too young the finding reports
"no verified remediation" with the young releases named.
--ignore-platform-req and --ignore-platform-reqs change what the solver accepts, so a command
verified under them is only reproduced by a command that carries them: the flags are repeated in
every recommended command. When a recommended command moves a package to a version that still
carries another (pre-existing) advisory, the report flags a blocking risk: Composer 2.10+
advisory blocking may refuse that update until the advisory is ignored in config.policy or
blocking is disabled.
A finding whose only fix requires widening a composer.json constraint is marked as constraint
drag: the report names the root requirement that blocks every fix within the current constraints.
Ignore entries (--ignore, config.audit.ignore, config.policy) that match nothing in the lock are
reported as stale so the configuration stays honest. When the advisory source is a database, upstream
records its build could not interpret are reported as coverage gaps for the affected locked packages
(see Advisory database). Composer's own scoping is respected: an
audit.ignore entry with apply: block or a policy.advisories entry with on-audit: false is an
install-time exception, not an audit-time one, and does not suppress a finding here; package rules
apply only within their constraint.
8. Pruning and budget¶
Two rules keep the number of solves small. Root-constraint widening is never tried once a valid candidate without root changes exists, because ranking rule 1 would discard it anyway. The parent descent is skipped when an existing valid candidate is already at least as good as the best result the descent could reach (two changed packages, no major change). Skipped candidates are listed in the report so the reasoning stays visible.
Two limits bound the work. --max-candidates (default 10) caps the generated candidates per
finding; --solve-budget (default 60) caps solver runs per finding across every phase: candidates,
conflict-driven expansion, parent descent and simplification; the last three solves are reserved
for simplifying the winner so a long descent on a worse parent cannot starve it. Every solve is counted, probes
included, and the report carries the count per finding and in total. When either limit cuts the
search short, a finding without a fix reads "none found within the search budget" rather than
"none": the search is bounded and does not prove that no fix exists. The dependency-path walk
itself uses Composer's recursive InstalledRepository::getDependents(); its path and depth caps
apply to the result, not to the cost of computing it, which on a very large lock is the same cost
composer why -r -t pays.
9. Output¶
Text output is the default:
CVE-2026-XXXXX
────────────────────────────────────────────────────────────
Affected
symfony/http-foundation 6.4.21
CVE-2026-XXXXX: <advisory title>
affected versions: >=6.4.0,<6.4.24
Introduced by
root
└── drupal/core-recommended 11.4.2
└── symfony/http-foundation 6.4.21 (requires 6.4.21)
Current state
Transitive dependency.
Recommended remediation
drupal/core-recommended 11.4.2 -> 11.4.3
symfony/http-foundation 6.4.21 -> 6.4.24
Composer validation
PASS 3 packages changed, 0 added, 0 removed, 0 root constraints changed
Expected changes
drupal/core 11.4.2 -> 11.4.3
drupal/core-recommended 11.4.2 -> 11.4.3
symfony/http-foundation 6.4.21 -> 6.4.24
Recommended command
composer update drupal/core-recommended -W -m --with 'symfony/http-foundation:>=6.4.24'
Other candidates
rejected: composer update symfony/http-foundation
resolves, but symfony/http-foundation ends at 6.4.21 which is still affected by CVE-2026-XXXXX
Three report formats exist and can be produced in one run. --format (default text, or none)
chooses what goes to standard output; --output writes a file whose format is inferred from its
extension and may be repeated:
composer remediate --output=report.html --output=report.json # text on stdout, two files
composer remediate --format=none --output=report.json # file only, quiet stdout
composer remediate --format=json | jq '.findings[].remediation.command'
- text: the human-readable plan above.
- html: a self-contained page (inline CSS, no scripts, no external resources) with a summary table, one section per finding, the dependency paths, the expected changes and every candidate that was tried or skipped. Suitable as a CI artifact.
- sarif: SARIF 2.1.0 for GitHub Code Scanning and other SARIF consumers. One rule per advisory
(with a numeric
security-severity), one result per vulnerable package located at its line incomposer.lock, the verified command in the result message. See CI integration. - cyclonedx: a CycloneDX 1.6 SBOM (
--output=sbom.cdx.json) listing every locked package as a component with apkg:composer/...purl and every advisory as a vulnerability affecting its component, with the verified command in the CycloneDXrecommendationfield. SBOM and VEX tooling can consume the plan directly. - gitlab: GitLab's dependency-scanning report (
--output=gl-dependency-scanning-report.json), declared asartifacts: reports: dependency_scanning:so findings appear in the merge request security widget and the vulnerability report, with the verified command as thesolution. - json: the same content for machines. Top-level keys:
schema_version,analysis_metadata(Composer and PHP versions, advisory source, hashes ofcomposer.jsonandcomposer.lock, timestamp),exit_code,warnings,findings[](package, advisories, the first ten dependency paths pluspaths_total,remediationwithstatus,command,summaryandchanges, and allcandidateswith their outcome) andunsolved_findings[]. The structure is published as a JSON Schema at schema/report.schema.json; every stored fixture report is validated against it in the test suite. Stored examples for every fixture live undertests/Fixture/third-party/<name>/reports/.