Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/vulnerability-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
105 changes: 16 additions & 89 deletions composer-update/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -87,6 +90,11 @@ runs:
echo "Open PR #$EXISTING_NUM | existing set: ${EXISTING_SET:-<none>} | new set: ${NEW_SET:-<none>} | 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'
Expand All @@ -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"
134 changes: 134 additions & 0 deletions composer-update/scripts/open-or-refresh-pr.sh
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions composer-update/scripts/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<unchanged>}) — 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"
Expand All @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion composer-update/tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading