Skip to content

Fix remediation for transitive pip dependency alerts#52

Open
groovecoder wants to merge 5 commits into
mainfrom
debug-remediate
Open

Fix remediation for transitive pip dependency alerts#52
groovecoder wants to merge 5 commits into
mainfrom
debug-remediate

Conversation

@groovecoder
Copy link
Copy Markdown
Member

Summary

  • When a pip transitive dependency (e.g. idna via requests) has no direct pin, detect the repo's lock tool (uv/poetry/pipenv) and run uv lock --upgrade-package (or equivalent) to create a bump PR. Previously the script gave up with action=noop.
  • Fix verdict extraction when CLAUDE_VERBOSE=true. The --output-format json wraps Claude's text in a JSON event array; the regex for VERDICT_JSON blocks never matched the escaped strings.
  • Fetch the patched version from the Dependabot alert API when ALERT_PATCHED_VERSION is empty (e.g. manual workflow dispatch).

Changes

  • scripts/post_alert_action.pydetect_pip_lock_tool() checks for lock files via the GitHub API. fetch_patched_version() fills in missing patched version from the alert. Also searches pyproject.toml for direct pins.
  • scripts/pip-lock-bump.sh — New script mirroring npm-bump.sh: commits lock file changes via API and opens a PR.
  • .github/workflows/investigate-security-alert.yml — New pip_lock_bump steps between npm bump and private fork sections.
  • scripts/extract_alert_verdict.py — Parses JSON session logs to extract assistant text before searching for verdict blocks.
  • scripts/alert_report.py — Added pip_lock_bump action label.

Test plan

groovecoder and others added 4 commits May 27, 2026 10:20
…tive deps

When create_bump_pr() can't find a dependency pin (e.g. idna via
requests), enable automated security fixes on the repo so Dependabot
will attempt the update. Also search pyproject.toml for pip pins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When CLAUDE_VERBOSE=true, run-claude.sh uses --output-format json.
The verdict text is inside a JSON string, so the regex for
```VERDICT_JSON blocks never matched. Now extract_alert_verdict.py
parses the JSON event array first and searches the assistant text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a pip transitive dependency has no direct pin, detect the repo's
lock tool (uv/poetry/pipenv) and run the upgrade in CI. Replaces the
request_dependabot_update fallback which did nothing for lock files.

New scripts/pip-lock-bump.sh mirrors the npm-bump.sh pattern: upgrade
the lock file, commit via API, and open a PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When ALERT_PATCHED_VERSION is empty (e.g. manual workflow dispatch),
post_alert_action.py now fetches it from the alert API before giving up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PATCHED_VERSION: ${{ steps.post-action.outputs.pip_version }}
ALERT_NUMBER: ${{ inputs.alert_number }}
REPO: ${{ inputs.target_repo }}
GH_TOKEN: ${{ steps.setup.outputs.token }}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment (non-blocking): Since this workflow passes GH_TOKEN to this step, verify that pip-lock-bump.sh makes no LLM calls.

Comment thread scripts/pip-lock-bump.sh
Comment on lines +77 to +120
COMMIT_SHA=$("${SCRIPT_DIR}/git-commit-api.sh" "$COMMIT_MSG" "$PARENT" "${CHANGED_FILES[@]}")

# Create branch ref
gh api "repos/${REPO}/git/refs" \
--method POST \
--field "ref=refs/heads/${BRANCH_NAME}" \
--field "sha=${COMMIT_SHA}" || {
echo "Branch ${BRANCH_NAME} already exists. Updating."
gh api "repos/${REPO}/git/refs/heads/${BRANCH_NAME}" \
--method PATCH \
--field "sha=${COMMIT_SHA}"
}

echo "Created branch ${BRANCH_NAME} with commit ${COMMIT_SHA}"

# Build PR body
SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}"
REPOSITORY="${GITHUB_REPOSITORY:-mozilla/blender}"
RUN_ID="${GITHUB_RUN_ID:-}"
if [ -n "$RUN_ID" ]; then
RUN_LINK="[BLEnder investigation](${SERVER_URL}/${REPOSITORY}/actions/runs/${RUN_ID})"
else
RUN_LINK="BLEnder investigation"
fi

PR_BODY="## Summary

Bumps **${PACKAGE}** to \`${PATCHED_VERSION:-latest}\` to resolve [Dependabot alert #${ALERT_NUMBER}](https://github.com/${REPO}/security/dependabot/${ALERT_NUMBER}).

This is a transitive dependency update via \`${PIP_LOCK_TOOL}\`. Only lock files changed.

---
*Created by ${RUN_LINK} via [BLEnder](https://github.com/mozilla/blender)*"

PR_TITLE="chore(deps): bump ${PACKAGE} to ${PATCHED_VERSION:-latest}"

gh pr create \
--repo "$REPO" \
--head "$BRANCH_NAME" \
--base "$DEFAULT_BRANCH" \
--title "$PR_TITLE" \
--body "$PR_BODY"

echo "PR created."
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question + suggestion (non-blocking): How often is this pattern repeated to create the commit and then create a PR? Consider if we should refactor this into another helper script like scripts/github-pr.sh ?

Comment thread tests/scripts/test_post_alert_action.py Outdated
Comment on lines +419 to +450
def test_uv_lock_detected(self):
repo = MagicMock()
repo.get_contents.return_value = MagicMock()
result = detect_pip_lock_tool(repo)
assert result is not None
tool, cmd = result
assert tool == "uv"
assert "uv lock" in cmd
repo.get_contents.assert_called_once_with("uv.lock")

def test_poetry_lock_detected(self):
repo = MagicMock()
repo.get_contents.side_effect = [Exception("404"), MagicMock()]
result = detect_pip_lock_tool(repo)
assert result is not None
assert result[0] == "poetry"

def test_pipfile_lock_detected(self):
repo = MagicMock()
repo.get_contents.side_effect = [
Exception("404"),
Exception("404"),
MagicMock(),
]
result = detect_pip_lock_tool(repo)
assert result is not None
assert result[0] == "pipenv"

def test_no_lock_file(self):
repo = MagicMock()
repo.get_contents.side_effect = Exception("404")
assert detect_pip_lock_tool(repo) is None
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change (blocking): These tests are odd, and make it obvious that detect_pip_lock_tool is also odd. It performs 3 get_contents calls to determine which pip lock tool file exists. But that's not at all obvious from these tests. Clarify these tests so someone reading the tests knows that detect_pip_lock_tool looks first for the uv file, then the poetry file, then the pipfile.

Comment thread tests/scripts/test_post_alert_action.py Outdated
Comment on lines +456 to +535
def test_pip_no_pin_with_uv_lock(
self, verdict_file, tmp_path, monkeypatch
):
verdict_file(SAMPLE_VERDICT)
mock_repo = MagicMock()
mock_repo.full_name = "owner/repo"
mock_repo.get_pulls.return_value = []

def get_contents_side_effect(path):
if path == "uv.lock":
return MagicMock()
raise Exception("404 Not Found")

mock_repo.get_contents.side_effect = get_contents_side_effect

output_file = str(tmp_path / "github_output")
open(output_file, "w").close()

summary_file = str(tmp_path / "step-summary.md")
for k, v in {
"GH_TOKEN": "fake",
"REPO": "owner/repo",
"ALERT_NUMBER": "2",
"ALERT_PACKAGE": "idna",
"ALERT_ECOSYSTEM": "pip",
"ALERT_SEVERITY": "high",
"ALERT_PATCHED_VERSION": "3.15",
"DRY_RUN": "false",
"DISMISS_UNAFFECTED": "false",
"GITHUB_STEP_SUMMARY": summary_file,
"GITHUB_OUTPUT": output_file,
}.items():
monkeypatch.setenv(k, v)

with patch("scripts.post_alert_action.Github") as mock_gh:
mock_gh.return_value.get_repo.return_value = mock_repo
main()

outputs = open(output_file).read()
assert "action=pip_lock_bump" in outputs
assert "pip_package=idna" in outputs
assert "pip_version=3.15" in outputs
assert "pip_lock_tool=uv" in outputs
content = open(summary_file).read()
assert "pip lock bump" in content.lower()

def test_pip_no_pin_no_lock_noop(
self, verdict_file, tmp_path, monkeypatch
):
verdict_file(SAMPLE_VERDICT)
mock_repo = MagicMock()
mock_repo.full_name = "owner/repo"
mock_repo.get_pulls.return_value = []
mock_repo.get_contents.side_effect = Exception("404 Not Found")

output_file = str(tmp_path / "github_output")
open(output_file, "w").close()

summary_file = str(tmp_path / "step-summary.md")
for k, v in {
"GH_TOKEN": "fake",
"REPO": "owner/repo",
"ALERT_NUMBER": "2",
"ALERT_PACKAGE": "idna",
"ALERT_ECOSYSTEM": "pip",
"ALERT_SEVERITY": "high",
"ALERT_PATCHED_VERSION": "3.15",
"DRY_RUN": "false",
"DISMISS_UNAFFECTED": "false",
"GITHUB_STEP_SUMMARY": summary_file,
"GITHUB_OUTPUT": output_file,
}.items():
monkeypatch.setenv(k, v)

with patch("scripts.post_alert_action.Github") as mock_gh:
mock_gh.return_value.get_repo.return_value = mock_repo
main()

outputs = open(output_file).read()
assert "action=noop" in outputs
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): quite a bit of duplicate boilerplate code here. refactor to consolidate into a single helper function that can be called from both tests.

Parametrize TestDetectPipLockTool to make priority order obvious
(uv > poetry > pipenv). Extract shared pip env setup into
_run_pip_main helper to reduce boilerplate in TestPipLockBumpFlow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant