CI integration¶
composer remediate is designed to run as a gate. It needs only composer.json and
composer.lock, changes nothing, and reports through its exit code and its report files.
Exit codes and what to gate on¶
| Exit | Meaning | Typical gate policy |
|---|---|---|
0 |
No known vulnerabilities in the lock | pass |
1 |
Vulnerabilities found and a verified fix exists | fail: the fix is one command away, apply it |
2 |
At least one vulnerability has no verified fix, and every solve completed | fail or warn, see below |
3 |
Tool error (no lock file, bad option, or a solver error left a finding's outcome unknown) | fail, fix the pipeline |
4 |
Advisory data unavailable (no database and no repository provides advisories, a malformed snapshot, a database refused as unverified or too old), or the source could not read records about locked packages and --accept-coverage-gaps was not given |
fail or retry, this is infrastructure |
5 |
Package metadata could not be fetched during solving | fail or retry, this is infrastructure |
Exit 2 is only returned when the planner actually finished its search. A broken tool or an
unreachable repository never reads as "no fix exists": those paths end in 3, 4 or 5, so the
actionable policy below cannot pass a job because the planner failed. A finding whose search hit
--max-candidates or --solve-budget still exits 2 but is labelled "none found within the search
budget" in every report.
Two sensible policies:
- Strict: anything other than
0fails the job. Simple, and the right default for applications that must not ship known vulnerabilities. - Actionable: fail on
1(a verified fix exists and should be applied), report2as a warning (no fix exists yet; failing the build would block every deploy until upstream releases one), fail on3to5because the tool did not do its job. Record accepted risks withconfig.audit.ignoreincomposer.jsonso exit2stays meaningful.
Whichever you choose, keep the HTML or JSON report as a build artifact: the report says exactly what to run.
Installing in CI¶
The plugin is installed globally so it does not touch the project's composer.json:
composer global config --no-plugins allow-plugins.hexblot/composer-remediate true
composer global require hexblot/composer-remediate:0.11.0
Pin an exact version, as above, and move it deliberately: before 1.0 a minor release may change exit codes, report fields or how the advisory database is chosen (see compatibility). The examples below leave the version out for brevity.
Requirements: PHP 8.1+ and Composer 2.4+ (2.7+ for --minimal-changes). In CI the project under
analysis is usually your own; when it is not (a fork, a third-party dependency review), run
composer-remediate instead of composer remediate so none of that project's plugins execute. Solving needs package
metadata from your configured repositories, so cache Composer's cache directory between runs
(composer config --global cache-dir prints it; usually ~/.composer/cache or
~/.cache/composer). Private repositories and auth.json work as they do for composer update.
GitHub Actions¶
name: dependency-remediation
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1-5' # advisories arrive without commits; check on a schedule too
jobs:
remediate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
tools: composer:v2
# Composer's cache directory also holds the advisory database (remediate/advisories.sqlite), so
# this one cache entry keeps it between runs; each run then confirms it with one small request
# and downloads only when the published database moved. Pass --database-path to keep it elsewhere.
- uses: actions/cache@v4
with:
path: ~/.cache/composer
key: composer-${{ hashFiles('composer.lock') }}
restore-keys: composer-
- run: |
composer global config --no-plugins allow-plugins.hexblot/composer-remediate true
composer global require hexblot/composer-remediate
- name: Plan remediation
id: plan
run: |
set +e
composer remediate --no-dev --output=remediation-report.html --output=remediation-report.json
echo "code=$?" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
if: always()
with:
name: remediation-report
path: remediation-report.*
- name: Gate
run: |
case "${{ steps.plan.outputs.code }}" in
0) echo "No known vulnerabilities." ;;
1) echo "::error::Vulnerable dependencies with a verified fix. See the remediation report."; exit 1 ;;
2) echo "::warning::Vulnerable dependencies without a verified fix yet. See the remediation report." ;;
*) echo "::error::composer remediate failed (exit ${{ steps.plan.outputs.code }})."; exit 1 ;;
esac
Switch the 2) branch to exit 1 for the strict policy. Add --fail-on high to the
composer remediate call to let low and medium findings pass without affecting the exit code
(unknown severities always count). To surface the recommended command in the job summary:
jq -r '.summary.combined_command // "no verified fix"' remediation-report.json >> "$GITHUB_STEP_SUMMARY"
GitHub Code Scanning¶
--output=results.sarif writes a SARIF 2.1.0 file: one rule per advisory with a
security-severity score, one result per vulnerable package pointing at its line in composer.lock,
and the verified command in the message. Upload it and findings appear in the repository's Security
tab and as pull request annotations:
- run: composer remediate --no-dev --output=results.sarif --output=remediation-report.html || true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
category: composer-remediate
The || true keeps the upload step reachable; gate on the exit code in a separate step as shown
above (or capture it with set +e as in the full example).
SBOM with remediation¶
--output=sbom.cdx.json writes a CycloneDX 1.6 SBOM of the whole lock with every advisory attached
as a vulnerability and the verified command in its recommendation. Attach it as an artifact or feed
it to Dependency-Track, Grype or any other CycloneDX consumer.
One pull request with the fixes already applied¶
The action shipped with this repository plans, applies and opens a single pull request carrying every fix it could verify, rather than one pull request per package. It reuses one branch, so a later run updates that pull request instead of adding another.
name: remediate
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: write # push the branch
pull-requests: write # open or update the pull request
jobs:
remediate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
tools: composer:v2
- uses: hexblot/composer-remediate@v0.7.0
with:
args: --no-dev --fail-on high
What it does, in order: plans and writes a JSON report; stops when nothing has a verified fix; creates
or resets the branch; runs the fixes with --apply --apply-no-install, so composer.lock moves and
vendor/ is left alone; stops if neither composer.json nor composer.lock changed; commits, pushes,
and opens or updates the pull request. The description says which advisories closed, which survived
and with what fix, what ran, and what the applying run warned about. It is rendered by
composer remediate:pr-body, which you can run yourself on any two JSON reports.
| Input | Default | Notes |
|---|---|---|
args |
empty | Further composer remediate arguments. The report format and the apply options are set by the action. |
branch |
remediate/advisories |
One branch, reused, so there is one pull request. |
base |
the default branch | What the pull request targets. |
install |
false |
true runs the update with an install, touching vendor/. A pull request normally carries only the manifest and the lock. |
token |
GITHUB_TOKEN |
A pull request opened with the default token does not start other workflows. Supply an app or personal token when the pull request must run your CI. |
version |
* |
Constraint for the plugin itself, for pinning the tool. |
labels |
empty | Comma-separated, applied on every run, so a label added later reaches the pull request already open. |
draft |
false |
Applies when the pull request is created. One already open is left as it is: whether it is ready for review is your decision, not a scheduled run's. |
title, commit-message, working-directory, skip-install |
Outputs: opened, pull-request, exit-code-before, exit-code-after.
The branch is the action's. It is rebuilt from the base branch on every run, because the fix is planned against what is on the base now, so a commit pushed to that branch by hand is replaced by the next run. Keep manual work on a branch of your own.
A fix that would edit composer.json, such as widening a root constraint, is reported but not applied:
--apply refuses those without --apply-root-constraints, and a bot that rewrites your requirements
unattended is a different proposition from one that moves a lock. Run those by hand.
GitLab CI¶
remediate:
stage: test
image: composer:2
cache:
key: composer-$CI_COMMIT_REF_SLUG
paths: [.composer-cache] # package metadata and the advisory database (remediate/advisories.sqlite)
variables:
COMPOSER_CACHE_DIR: $CI_PROJECT_DIR/.composer-cache
before_script:
- composer global config --no-plugins allow-plugins.hexblot/composer-remediate true
- composer global require hexblot/composer-remediate
script:
- set +e
- composer remediate --no-dev --output=remediation-report.html --output=remediation-report.json
- code=$?
- set -e
- |
case "$code" in
0) echo "No known vulnerabilities." ;;
1) echo "Vulnerable dependencies with a verified fix; see the report."; exit 1 ;;
2) echo "Vulnerable dependencies without a verified fix yet; see the report."; exit 0 ;;
*) echo "composer remediate failed (exit $code)."; exit 1 ;;
esac
artifacts:
when: always
paths: [remediation-report.html, remediation-report.json]
expire_in: 30 days
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == "schedule"
Use allow_failure: { exit_codes: [2] } instead of the exit 0 branch if you prefer GitLab to show
the job as passed-with-warnings.
GitLab security dashboard¶
Add --output=gl-dependency-scanning-report.json and declare it as a dependency-scanning report;
the findings then show in the merge request security widget and the project's vulnerability report,
each with the verified command as its solution:
script:
- composer remediate --no-dev --output=gl-dependency-scanning-report.json --output=remediation-report.html || true
artifacts:
when: always
paths: [remediation-report.html]
reports:
dependency_scanning: gl-dependency-scanning-report.json
Introducing a gate on an existing project¶
Existing findings would fail the first pipeline. Accept them once, commit the baseline, and fail only on new ones:
composer remediate --baseline=.composer-remediate-baseline.json --update-baseline # once, commit the file
composer remediate --baseline=.composer-remediate-baseline.json # in CI
Remove entries from the file as you apply the recommended commands; the gate tightens as you go.
Custom gates on the JSON report¶
The JSON report makes finer policies possible without parsing text:
# fail only on high or critical advisories that have a verified fix
jq -e '[.findings[] | select(.remediation.status == "verified")
| select(any(.advisories[]; .severity == "high" or .severity == "critical"))] | length == 0' \
remediation-report.json
# print the one command that fixes everything (or as much as possible)
jq -r '.summary | if .combined_command then
"\(.combined_command) (fixes \(.combined_fixes)/\(.combined_total))" else "no verified fix" end' \
remediation-report.json
# stop an unattended apply for review when a fix lets code run that could not run before:
# a package becoming a composer-plugin, or gaining autoload.files entries or binaries
jq -e '.summary.packages_running_new_code == 0' remediation-report.json
# the conflict entries to add to composer.json, so a later update cannot resolve back below the fix
jq -r '[.findings[].remediation.conflict_entries // [] | .[]]
| unique_by(.package)
| map(" \"\(.package)\": \"\(.constraint)\"") | join(",\n")
| if . == "" then "no --with constraint to make permanent" else "\"conflict\": {\n" + . + "\n}" end' \
remediation-report.json
Fields worth knowing: exit_code, summary.combined_command, summary.combined_fixes,
summary.combined_total, summary.packages_running_new_code,
findings[].remediation.status (verified or none), findings[].remediation.command,
findings[].remediation.conflict_entries, findings[].remediation.capability_changes,
findings[].remediation.no_fix_within_locked_major, findings[].advisories[].severity,
unsolved_findings[].
Tips¶
- Run on a schedule as well as on pushes: advisories are published without any change to your repository.
--no-devgates production dependencies only; drop it to include tooling.- Accepted risks belong in
composer.json(config.audit.ignore, orconfig.policy.advisories.ignoreon Composer 2.10+) so the same decision applies tocomposer auditand to this tool. - The advisory database lives in Composer's cache directory by default, so caching that directory
(as both recipes do) keeps it between runs. To keep it somewhere else, or to share one file between
jobs, pass
--database-path=<file>and cache that file. Each run confirms the copy against the published database with one small request and downloads only when it moved; when the network is down the copy is used and the report says how old it is. Add--database-max-age=48to fail instead once a copy that cannot be confirmed is older than two days, or runcomposer remediate:db-build --if-stalefirst to build from the sources when the published database cannot be reached. See Advisory database. - For air-gapped runners,
--offlinewith the database already at its path (or--offline --advisories-file=<snapshot>) works with a warm Composer cache.