Fix remediation for transitive pip dependency alerts#52
Conversation
…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 }} |
There was a problem hiding this comment.
comment (non-blocking): Since this workflow passes GH_TOKEN to this step, verify that pip-lock-bump.sh makes no LLM calls.
| 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." |
There was a problem hiding this comment.
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 ?
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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>
Summary
idnaviarequests) has no direct pin, detect the repo's lock tool (uv/poetry/pipenv) and runuv lock --upgrade-package(or equivalent) to create a bump PR. Previously the script gave up withaction=noop.CLAUDE_VERBOSE=true. The--output-format jsonwraps Claude's text in a JSON event array; the regex forVERDICT_JSONblocks never matched the escaped strings.ALERT_PATCHED_VERSIONis empty (e.g. manual workflow dispatch).Changes
scripts/post_alert_action.py—detect_pip_lock_tool()checks for lock files via the GitHub API.fetch_patched_version()fills in missing patched version from the alert. Also searchespyproject.tomlfor direct pins.scripts/pip-lock-bump.sh— New script mirroringnpm-bump.sh: commits lock file changes via API and opens a PR..github/workflows/investigate-security-alert.yml— Newpip_lock_bumpsteps 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— Addedpip_lock_bumpaction label.Test plan
investigate-security-alertworkflow ondebug-remediatetargetingmozilla/blenderalert ci: add linting workflow and fix shellcheck violations #2 (idna) — should produce a bump PR updating uv.lockCLAUDE_VERBOSE=true) extracts the verdictruff check scripts/andpytest tests/pass (CI covers this)