diff --git a/.github/workflows/vulnerability-scan.yml b/.github/workflows/vulnerability-scan.yml index ac478f9..e19d9db 100644 --- a/.github/workflows/vulnerability-scan.yml +++ b/.github/workflows/vulnerability-scan.yml @@ -26,6 +26,14 @@ on: required: false description: 'GitHub App private key (PEM)' +# Serialize scans per repo so a manual workflow_dispatch can't race the nightly +# cron: two concurrent runs could both see "no open PR" and open duplicates, or +# both force-push the refresh branch. cancel-in-progress: false lets the running +# scan finish (it may be mid-PR) rather than truncating a push. +concurrency: + group: vuln-scan-${{ github.repository }} + cancel-in-progress: false + jobs: scan: name: Scan @@ -207,9 +215,16 @@ jobs: branch_prefix: "fix/vuln-update" - name: Google Chat Notification - # Runs last so pr_url from composer-update (new or deduped existing PR) + # Runs last so pr_url from composer-update (new or refreshed existing PR) # can be included as a "View PR" button when available. - if: failure() + # + # Suppressed when composer-update reported `skipped` — i.e. an open PR + # already covers exactly these findings and nothing changed. Without + # this the scan's exit-1-on-any-vuln would re-alert every night for a + # vuln that's already sitting in a PR awaiting review. Any other outcome + # (created / refreshed / none, or auto-update disabled → empty) still + # alerts, so genuinely new or unfixable findings are never silenced. + if: failure() && steps.update.outputs.outcome != 'skipped' env: GOOGLE_CHAT_FAUCET_WEBHOOK: ${{ secrets.GOOGLE_CHAT_FAUCET_WEBHOOK }} PR_URL: ${{ steps.update.outputs.pr_url }} diff --git a/composer-update/action.yml b/composer-update/action.yml index 21fa95b..db0bbfb 100644 --- a/composer-update/action.yml +++ b/composer-update/action.yml @@ -44,6 +44,9 @@ outputs: pr_url: description: 'URL of the open PR — newly created, or the existing one refreshed/deduped. Empty if no PR was opened or touched.' value: ${{ steps.create.outputs.pr_url || steps.check.outputs.pr_url }} + outcome: + description: 'What happened: created | refreshed | skipped | none. Lets callers suppress redundant notifications when nothing changed (skipped).' + value: ${{ steps.create.outputs.outcome || steps.check.outputs.outcome }} runs: using: 'composite' @@ -87,6 +90,11 @@ runs: echo "Open PR #$EXISTING_NUM | existing set: ${EXISTING_SET:-} | new set: ${NEW_SET:-} | mode=$MODE" fi echo "mode=$MODE" >> "$GITHUB_OUTPUT" + # skip is terminal here (the create step won't run), so record the + # outcome now; create/update outcomes are set by "Open or refresh PR". + if [ "$MODE" = "skip" ]; then + echo "outcome=skipped" >> "$GITHUB_OUTPUT" + fi - name: Update packages if: steps.check.outputs.mode != 'skip' @@ -104,98 +112,17 @@ runs: env: GH_TOKEN: ${{ inputs.token || github.token }} PR_BODY: ${{ inputs.pr_body }} + PR_TITLE: ${{ inputs.pr_title }} + PR_LABEL: ${{ inputs.pr_label }} + BRANCH_PREFIX: ${{ inputs.branch_prefix }} PACKAGES: ${{ inputs.packages }} MODE: ${{ steps.check.outputs.mode }} CHANGED: ${{ steps.update.outputs.changed }} EXISTING_NUM: ${{ steps.check.outputs.existing_num }} EXISTING_HEAD: ${{ steps.check.outputs.existing_head }} EXISTING_URL: ${{ steps.check.outputs.pr_url }} - run: | - # Full PR body = the scan-provided body (marker + findings table) plus - # the Version changes table the update step produced (if any). Body-file - # keeps backticks / $() in the markdown literal. - { - printf '%s' "$PR_BODY" - if [ -s /tmp/composer-update-versions.md ]; then - cat /tmp/composer-update-versions.md - fi - } > /tmp/composer-update-pr-body.md - - # create + no resolvable fix → there's nothing to open and no PR to - # refresh. (The unfixable finding still surfaces via the caller's alert.) - if [ "$MODE" = "create" ] && [ "$CHANGED" != "true" ]; then - echo "No update available and no existing PR — nothing to open" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - # Remove actions/checkout's credential config — it sets includeIf - # entries and extraheader with the GITHUB_TOKEN which overrides any - # other auth method we try to use. Must be cleared before pushing. - for key in $(git config --local --name-only --get-regexp '^includeIf\.gitdir:' 2>/dev/null); do - git config --local --unset-all "$key" 2>/dev/null || true - done - git config --local --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - - # Commit the working-tree fix. Title in subject, package list in body so - # `git blame` on composer.lock answers "why did this version change?". - commit_fix() { - git add composer.json composer.lock - { - printf '%s\n\n' "${{ inputs.pr_title }}" - echo "Updated packages:" - for pkg in $PACKAGES; do - echo "- $pkg" - done - } > /tmp/composer-update-commit.txt - git commit -F /tmp/composer-update-commit.txt - } - - if [ "$MODE" = "create" ]; then - BRANCH="${{ inputs.branch_prefix }}-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$BRANCH" - commit_fix - git push origin "$BRANCH" - # Ensure the dedup label exists (idempotent — no-ops if present). - gh label create "${{ inputs.pr_label }}" \ - --color FFAA00 \ - --description "Auto-generated dependency update PR" \ - 2>/dev/null || true - PR_URL=$(gh pr create \ - --title "${{ inputs.pr_title }}" \ - --label "${{ inputs.pr_label }}" \ - --body-file /tmp/composer-update-pr-body.md) - echo "Created PR: $PR_URL" - echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # MODE=update: refresh the existing PR in place so it always reflects - # the current findings, rather than opening a second PR or letting the - # original go stale. - if [ "$CHANGED" = "true" ]; then - # Force the PR's head branch to "base + current fix". We're on the - # freshly-updated checkout, so commit here and force-push to its head. - git checkout -b "refresh-$(date +%Y%m%d-%H%M%S)" - commit_fix - git push -f origin "HEAD:${EXISTING_HEAD}" - echo "Refreshed branch '$EXISTING_HEAD' on PR #$EXISTING_NUM" - else - echo "New findings have no resolvable fix — refreshing PR #$EXISTING_NUM body/comment only" - fi - - # Rewrite the body so the set marker tracks the latest findings (this is - # what lets an unchanged set hit `skip` next run instead of re-posting), - # and drop a timeline comment so reviewers see the change. Because this - # only runs in `update` mode (set genuinely changed), it can't recur - # daily on a static finding set. - gh pr edit "$EXISTING_NUM" --body-file /tmp/composer-update-pr-body.md - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - { - printf '## Findings changed — refreshed by [run #%s](%s)\n\n' "${{ github.run_number }}" "$RUN_URL" - printf '%s\n' "$PR_BODY" - } > /tmp/composer-update-comment.md - gh pr comment "$EXISTING_NUM" --body-file /tmp/composer-update-comment.md - echo "pr_url=$EXISTING_URL" >> "$GITHUB_OUTPUT" + REPO: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + RUN_NUMBER: ${{ github.run_number }} + RUN_ID: ${{ github.run_id }} + run: bash "$GITHUB_ACTION_PATH/scripts/open-or-refresh-pr.sh" diff --git a/composer-update/scripts/open-or-refresh-pr.sh b/composer-update/scripts/open-or-refresh-pr.sh new file mode 100755 index 0000000..8ed2a92 --- /dev/null +++ b/composer-update/scripts/open-or-refresh-pr.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# +# Open a new auto-update PR, or refresh the existing one in place. +# +# Extracted from action.yml so the create / refresh orchestration is testable +# (see tests/open-or-refresh-pr.test.sh). The skip decision is made upstream by +# decide_pr_action(); this script only handles MODE in {create, update} and is +# invoked when there is something to do. +# +# Inputs via env: +# MODE create | update +# CHANGED true | false (did the lock actually change?) +# PACKAGES space-separated flagged package names (for the commit body) +# PR_TITLE PR_LABEL BRANCH_PREFIX PR_BODY +# REPO SERVER_URL RUN_NUMBER RUN_ID (for the run link / auth rewrite) +# EXISTING_NUM EXISTING_HEAD EXISTING_URL (update mode: the PR to refresh) +# GH_TOKEN when set (with REPO), rewrite origin auth for the push +# GITHUB_OUTPUT step outputs (pr_url, outcome) +# Optional (defaulted; overridable for tests): +# VERSIONS_FILE default /tmp/composer-update-versions.md +# STILL_VULN_FILE default /tmp/composer-update-still-vulnerable.txt +# +# Outputs (to GITHUB_OUTPUT): +# pr_url the created/refreshed PR +# outcome created | refreshed | none +set -eo pipefail + +VERSIONS_FILE="${VERSIONS_FILE:-/tmp/composer-update-versions.md}" +STILL_VULN_FILE="${STILL_VULN_FILE:-/tmp/composer-update-still-vulnerable.txt}" +BODY_FILE="$(mktemp)" +COMMENT_FILE="$(mktemp)" +COMMIT_FILE="$(mktemp)" + +emit() { [ -n "${GITHUB_OUTPUT:-}" ] && echo "$1=$2" >> "$GITHUB_OUTPUT"; } + +# Assemble the PR body: scan-provided body + the Version changes table (if any) +# + (A) an explicit "still vulnerable" triage section so we never silently +# present a package as fixed when it isn't. +{ + printf '%s' "$PR_BODY" + if [ -s "$VERSIONS_FILE" ]; then + cat "$VERSIONS_FILE" + fi + if [ -s "$STILL_VULN_FILE" ]; then + echo "" + echo "### ⚠️ Still vulnerable after update — manual triage needed" + echo "" + echo "These flagged packages did not reach a safe version and are **not** fixed by this PR:" + echo "" + while IFS=$'\t' read -r pkg ver; do + [ -n "$pkg" ] && echo "- \`$pkg\` (now \`${ver:-unchanged}\`)" + done < "$STILL_VULN_FILE" + fi +} > "$BODY_FILE" + +# create + no resolvable fix → nothing to open, and no existing PR to refresh. +# (The unfixable finding still surfaces via the caller's alert.) +if [ "$MODE" = "create" ] && [ "$CHANGED" != "true" ]; then + echo "No update available and no existing PR — nothing to open" + emit outcome none + exit 0 +fi + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +# In CI, actions/checkout pins a tokened auth (includeIf + extraheader) that +# overrides our push credentials — clear it and point origin at the token. +# Skipped when GH_TOKEN is empty (tests push to a local origin as-is). +if [ -n "${GH_TOKEN:-}" ] && [ -n "${REPO:-}" ]; then + for key in $(git config --local --name-only --get-regexp '^includeIf\.gitdir:' 2>/dev/null); do + git config --local --unset-all "$key" 2>/dev/null || true + done + git config --local --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" +fi + +# Commit the working-tree fix. Title in subject, package list in body so +# `git blame` on composer.lock answers "why did this version change?". +commit_fix() { + git add composer.json composer.lock + { + printf '%s\n\n' "$PR_TITLE" + echo "Updated packages:" + for pkg in $PACKAGES; do + echo "- $pkg" + done + } > "$COMMIT_FILE" + git commit -F "$COMMIT_FILE" +} + +if [ "$MODE" = "create" ]; then + BRANCH="${BRANCH_PREFIX}-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$BRANCH" + commit_fix + git push origin "$BRANCH" + # Ensure the dedup label exists (idempotent — no-ops if present). + gh label create "$PR_LABEL" \ + --color FFAA00 \ + --description "Auto-generated dependency update PR" \ + 2>/dev/null || true + PR_URL=$(gh pr create --title "$PR_TITLE" --label "$PR_LABEL" --body-file "$BODY_FILE") + echo "Created PR: $PR_URL" + emit pr_url "$PR_URL" + emit outcome created + exit 0 +fi + +# MODE=update: refresh the existing PR in place so it always reflects the +# current findings, rather than opening a second PR or letting it go stale. +if [ "$CHANGED" = "true" ]; then + # Force the PR's head branch to "base + current fix": we're on the freshly + # updated checkout, so commit here and force-push to the existing head. + git checkout -b "refresh-$(date +%Y%m%d-%H%M%S)" + commit_fix + git push -f origin "HEAD:${EXISTING_HEAD}" + echo "Refreshed branch '$EXISTING_HEAD' on PR #$EXISTING_NUM" +else + echo "New findings have no resolvable fix — refreshing PR #$EXISTING_NUM body/comment only" +fi + +# Rewrite the body so the set marker tracks the latest findings (this is what +# lets an unchanged set hit `skip` next run instead of re-posting), and leave a +# timeline comment. Only runs in `update` mode (set genuinely changed), so it +# can't recur daily on a static finding set. +gh pr edit "$EXISTING_NUM" --body-file "$BODY_FILE" +{ + printf '## Findings changed — refreshed by [run #%s](%s/%s/actions/runs/%s)\n\n' \ + "$RUN_NUMBER" "$SERVER_URL" "$REPO" "$RUN_ID" + printf '%s\n' "$PR_BODY" +} > "$COMMENT_FILE" +gh pr comment "$EXISTING_NUM" --body-file "$COMMENT_FILE" +emit pr_url "$EXISTING_URL" +emit outcome refreshed diff --git a/composer-update/scripts/update.sh b/composer-update/scripts/update.sh index 44c1e87..bb7b4ba 100755 --- a/composer-update/scripts/update.sh +++ b/composer-update/scripts/update.sh @@ -311,6 +311,22 @@ VERSION_DIFF_COUNT=$(jq -r --slurpfile after /tmp/composer-update-after.json ' echo "Version changes detected: $VERSION_DIFF_COUNT" +# (A) Final safety net: never present a package as fixed if its resulting +# locked version is STILL inside the advisory's affected range. Catches a wrong +# min-safe constraint (see the `<=` 4-segment-hotfix bug) that composer's own +# audit-blocking didn't stop. Runs on every outcome — including the no-move case +# below — so open-or-refresh-pr.sh can surface unfixed packages in the PR body. +# is_still_vulnerable returns "no" when no vulns_json was supplied, so plain +# dependency-update runs are unaffected. +: > /tmp/composer-update-still-vulnerable.txt +for PACKAGE in $PACKAGES; do + FINAL_V=$(get_lock_version "$PACKAGE" composer.lock) + if [ "$(is_still_vulnerable "$PACKAGE" "$FINAL_V")" = "yes" ]; then + echo "::warning::$PACKAGE is still in its advisory's affected range (now ${FINAL_V:-}) — not a real fix, needs manual triage" + printf '%s\t%s\n' "$PACKAGE" "$FINAL_V" >> /tmp/composer-update-still-vulnerable.txt + fi +done + if [ "$VERSION_DIFF_COUNT" -eq 0 ]; then echo "composer.lock changed but no package versions moved — skipping PR" echo "changed=false" >> "$GITHUB_OUTPUT" @@ -319,6 +335,17 @@ if [ "$VERSION_DIFF_COUNT" -eq 0 ]; then exit 0 fi +# (E) Don't ship a lockfile that doesn't validate. A bad require/widen can leave +# composer.json inconsistent or out of sync with the lock; reject and revert +# rather than open a PR that breaks `composer install` on deploy. +if ! composer validate --no-check-all --no-check-publish --quiet >/tmp/composer-update-validate.log 2>&1; then + echo "::error::composer validate failed after update — reverting, no PR" + cat /tmp/composer-update-validate.log || true + git checkout -- composer.json composer.lock 2>/dev/null || true + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + { echo "" echo "## Version changes" diff --git a/composer-update/tests/lib.sh b/composer-update/tests/lib.sh index 919d698..c57cdab 100755 --- a/composer-update/tests/lib.sh +++ b/composer-update/tests/lib.sh @@ -121,7 +121,12 @@ new_project() { git add -A && git commit -qm init write_fake_composer "$dir/bin" "$composer_body" export PATH="$dir/bin:$PATH" - export GITHUB_ACTION_PATH="$SCRIPTS_DIR" + # GitHub sets GITHUB_ACTION_PATH to the action root (the dir holding + # action.yml); update.sh resolves its PHP helpers as + # "$GITHUB_ACTION_PATH/scripts/...". Point it at the action root, not scripts/, + # or those resolve to scripts/scripts/ (only hit once a test drives the PHP + # helpers through update.sh, i.e. with a non-empty VULNS_JSON). + export GITHUB_ACTION_PATH="$ACTION_DIR" export GITHUB_OUTPUT="$dir/github_output" : > "$GITHUB_OUTPUT" } diff --git a/composer-update/tests/open-or-refresh-pr.test.sh b/composer-update/tests/open-or-refresh-pr.test.sh new file mode 100644 index 0000000..e000db7 --- /dev/null +++ b/composer-update/tests/open-or-refresh-pr.test.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# +# Integration tests for scripts/open-or-refresh-pr.sh — the create / refresh +# orchestration extracted from action.yml. Drives the real script with a fake +# `gh` on PATH and a local bare repo as `origin`, so pushes / PR calls are +# observable without network or auth. +# +# Covers: create (opens a PR + pushes a branch), create-with-no-fix (no-op), +# update-with-change (force-pushes the existing head + edits + comments), +# update-without-change (edits/comments only, no force-push), and that the +# (A) still-vulnerable section is surfaced in the body. +set -uo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SCRIPT="$SCRIPTS_DIR/open-or-refresh-pr.sh" + +# Fake `gh`: log every invocation (and the contents of any --body-file) to +# $GH_LOG; echo a URL for `pr create`. +make_fake_gh() { + local bin="$1" + mkdir -p "$bin" + cat > "$bin/gh" <<'SH' +#!/usr/bin/env bash +{ + echo "ARGS: $*" + prev="" + for a in "$@"; do + if [ "$prev" = "--body-file" ] && [ -f "$a" ]; then + echo "BODY<<"; cat "$a"; echo; echo ">>BODY" + fi + prev="$a" + done +} >> "$GH_LOG" +if [ "$1" = "pr" ] && [ "$2" = "create" ]; then + echo "https://github.com/acme/repo/pull/777" +fi +exit 0 +SH + chmod +x "$bin/gh" +} + +# new_repo_with_origin — working repo on `master` with a bare `origin` already +# holding the base commit, a fake gh on PATH, and fresh GH_LOG / GITHUB_OUTPUT. +# Sets globals: WORK ORIGIN BIN GH_LOG OUT +new_repo_with_origin() { + WORK="$(mktmp)" + ORIGIN="$(mktmp)/origin.git" + BIN="$(mktmp)/bin" + GH_LOG="$(mktmp)/gh.log" + OUT="$(mktmp)/out" + make_fake_gh "$BIN" + : > "$GH_LOG" + : > "$OUT" + git init -q --bare "$ORIGIN" + git init -q "$WORK" + ( + cd "$WORK" + git config user.email t@e.com && git config user.name t + echo '{"require":{}}' > composer.json + echo '{"packages":[],"packages-dev":[]}' > composer.lock + git add -A && git commit -qm init + git branch -M master + git remote add origin "$ORIGIN" + git push -q origin master + ) +} + +# run_orchestrator — run the script in WORK with the given env (passed as +# KEY=VALUE args). Common env (auth off so pushes hit the local origin, fake gh +# on PATH, outputs captured) is supplied automatically. +run_orchestrator() { + ( + cd "$WORK" + export PATH="$BIN:$PATH" + export GH_TOKEN="" GH_LOG="$GH_LOG" GITHUB_OUTPUT="$OUT" + export REPO="acme/repo" SERVER_URL="https://github.com" RUN_NUMBER="5" RUN_ID="99" + export PR_TITLE="Update vulnerable packages" PR_LABEL="vuln-update" BRANCH_PREFIX="fix/vuln-update" + export VERSIONS_FILE="/dev/null" STILL_VULN_FILE="/dev/null" + env "$@" bash "$SCRIPT" + ) >/dev/null 2>&1 || true +} + +origin_refs() { git -C "$ORIGIN" for-each-ref --format='%(refname)'; } +origin_sha() { git -C "$ORIGIN" rev-parse "refs/heads/$1" 2>/dev/null; } + +echo "== create: opens a PR and pushes a fix branch ==" +new_repo_with_origin +( cd "$WORK"; echo '{"packages":[{"name":"vendor/x","version":"1.0.5"}],"packages-dev":[]}' > composer.lock ) +run_orchestrator MODE=create CHANGED=true PACKAGES=vendor/x PR_BODY='' +assert_contains "$(cat "$GH_LOG")" "pr create" "gh pr create was called" +assert_contains "$(cat "$OUT")" "outcome=created" "outcome=created" +assert_contains "$(cat "$OUT")" "pr_url=https://github.com/acme/repo/pull/777" "pr_url captured from gh" +assert_contains "$(origin_refs)" "refs/heads/fix/vuln-update" "a fix branch was pushed to origin" + +echo "== create + no resolvable fix: nothing opened ==" +new_repo_with_origin +run_orchestrator MODE=create CHANGED=false PACKAGES=vendor/x PR_BODY='x' +assert_not_contains "$(cat "$GH_LOG")" "pr create" "no PR created when nothing changed" +assert_contains "$(cat "$OUT")" "outcome=none" "outcome=none" + +echo "== update + change: force-pushes existing head, edits + comments ==" +new_repo_with_origin +( cd "$WORK" + git checkout -q -b fix/vuln-update-old + git commit -q --allow-empty -m "old fix" + git push -q origin fix/vuln-update-old + git checkout -q master + echo '{"packages":[{"name":"vendor/x","version":"1.0.6"}],"packages-dev":[]}' > composer.lock ) +OLD_SHA="$(origin_sha fix/vuln-update-old)" +run_orchestrator MODE=update CHANGED=true PACKAGES=vendor/x \ + EXISTING_NUM=42 EXISTING_HEAD=fix/vuln-update-old EXISTING_URL=https://github.com/acme/repo/pull/42 \ + PR_BODY='' +assert_eq "$([ "$(origin_sha fix/vuln-update-old)" != "$OLD_SHA" ] && echo moved || echo same)" "moved" "existing head was force-pushed" +assert_contains "$(cat "$GH_LOG")" "pr edit 42" "PR body was refreshed (gh pr edit)" +assert_contains "$(cat "$GH_LOG")" "pr comment 42" "a timeline comment was posted" +assert_contains "$(cat "$OUT")" "outcome=refreshed" "outcome=refreshed" + +echo "== update + no fix: edits/comments only, no force-push ==" +new_repo_with_origin +( cd "$WORK" + git checkout -q -b fix/vuln-update-old + git commit -q --allow-empty -m "old fix" + git push -q origin fix/vuln-update-old + git checkout -q master ) +OLD_SHA="$(origin_sha fix/vuln-update-old)" +run_orchestrator MODE=update CHANGED=false PACKAGES=vendor/x \ + EXISTING_NUM=42 EXISTING_HEAD=fix/vuln-update-old EXISTING_URL=https://github.com/acme/repo/pull/42 \ + PR_BODY='' +assert_eq "$(origin_sha fix/vuln-update-old)" "$OLD_SHA" "existing head untouched (no force-push)" +assert_contains "$(cat "$GH_LOG")" "pr edit 42" "body still refreshed so the marker stays current" +assert_contains "$(cat "$OUT")" "outcome=refreshed" "outcome=refreshed" + +echo "== (A) still-vulnerable packages surface in the PR body ==" +new_repo_with_origin +( cd "$WORK"; echo '{"packages":[{"name":"vendor/x","version":"1.0.5"}],"packages-dev":[]}' > composer.lock ) +STILL="$(mktmp)/still.txt"; printf 'vendor/z\t1.2.3\n' > "$STILL" +run_orchestrator MODE=create CHANGED=true PACKAGES=vendor/x \ + STILL_VULN_FILE="$STILL" PR_BODY='' +assert_contains "$(cat "$GH_LOG")" "Still vulnerable after update" "body has the triage section" +assert_contains "$(cat "$GH_LOG")" "vendor/z" "the unfixed package is listed in the body" + +finish diff --git a/composer-update/tests/update-orchestration.test.sh b/composer-update/tests/update-orchestration.test.sh index c578279..252f6af 100755 --- a/composer-update/tests/update-orchestration.test.sh +++ b/composer-update/tests/update-orchestration.test.sh @@ -107,4 +107,52 @@ assert_eq "$(get_lock_version vendor/safe composer.lock)" "1.0.5" "safe package assert_eq "$(json_constraint vendor/x)" "1.0.0" "no-widen (array form) left x pinned" assert_contains "$RUN_LOG" "skip vendor/x — listed in extra.vuln-scan.no-widen" "array-form no-widen honored" +echo "==================================================================" +echo "(A) still-vulnerable after update is flagged, not silently 'fixed'" +echo "==================================================================" +# Fake composer moves vendor/x to 1.0.5, but the advisory affects <=1.5.0 — so +# the bump lands INSIDE the affected range (the class of bug a wrong min-safe +# constraint causes). The tight/bulk path doesn't re-verify, so A's final guard +# must catch it and record it as not-actually-fixed. +new_project \ + '{"require":{"vendor/x":"^1.0"}}' \ + '{"packages":[{"name":"vendor/x","version":"1.0.0","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +case "$args" in + validate*) exit 0 ;; + update*vendor/x*) + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/x\").version)=\"1.0.5\"" composer.lock > "$tmp" && mv "$tmp" composer.lock + ;; +esac +exit 0' +ensure_semver_vendor "$PWD" +export PACKAGES="vendor/x" +run_update '[{"package":"vendor/x","affected":"<=1.5.0"}]' +assert_eq "$(get_lock_version vendor/x composer.lock)" "1.0.5" "package moved (a PR is still raised)" +assert_contains "$(cat "$GITHUB_OUTPUT")" "changed=true" "PR raised for the moved package" +assert_contains "$RUN_LOG" "still in its advisory's affected range" "warned it is not a real fix" +assert_contains "$(cat /tmp/composer-update-still-vulnerable.txt)" "vendor/x" "recorded in still-vulnerable list" + +echo "==================================================================" +echo "(E) a lockfile that fails composer validate is reverted, no PR" +echo "==================================================================" +new_project \ + '{"require":{"vendor/y":"^1.0"}}' \ + '{"packages":[{"name":"vendor/y","version":"1.0.0","require":{}}],"packages-dev":[]}' \ + ' +args="$*" +case "$args" in + validate*) echo "The lock file is not up to date" >&2; exit 1 ;; + update*vendor/y*) + tmp=$(mktemp); jq "(.packages[]|select(.name==\"vendor/y\").version)=\"1.0.5\"" composer.lock > "$tmp" && mv "$tmp" composer.lock + ;; +esac +exit 0' +export PACKAGES="vendor/y" +run_update "" +assert_eq "$(get_lock_version vendor/y composer.lock)" "1.0.0" "lock reverted after failed validate" +assert_contains "$RUN_LOG" "composer validate failed" "logged the validate failure" +assert_contains "$(cat "$GITHUB_OUTPUT")" "changed=false" "no PR when the result doesn't validate" + finish