From b917278fd93d472768886034d8728af5cb8eccf9 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Mon, 4 May 2026 16:35:02 -0500 Subject: [PATCH 01/15] feat: investigate Dependabot security alerts Add alert discovery in sweep, investigation workflow, and post-action routing (bump PR for non-affected, private fork for affected). Closes #4 --- .../workflows/investigate-security-alert.yml | 271 ++++++++++++++++++ config/defaults.yml | 8 + prompts/investigate-alert-prompt.md | 95 ++++++ scripts/gather-alert-context.sh | 122 ++++++++ scripts/post_alert_action.py | 260 +++++++++++++++++ scripts/run-claude.sh | 55 +++- scripts/sweep.py | 90 +++++- scripts/trigger-workflows.py | 31 ++ tests/scripts/test_post_alert_action.py | 103 +++++++ tests/scripts/test_sweep.py | 79 ++++- 10 files changed, 1105 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/investigate-security-alert.yml create mode 100644 prompts/investigate-alert-prompt.md create mode 100755 scripts/gather-alert-context.sh create mode 100644 scripts/post_alert_action.py create mode 100644 tests/scripts/test_post_alert_action.py diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml new file mode 100644 index 0000000..cc11e24 --- /dev/null +++ b/.github/workflows/investigate-security-alert.yml @@ -0,0 +1,271 @@ +--- +name: BLEnder Investigate Security Alert +run-name: >- + Investigate ${{ inputs.target_repo }} + alert #${{ inputs.alert_number }} (${{ inputs.alert_package }}) +on: + workflow_dispatch: + inputs: + target_repo: + description: 'Target repo (e.g. mozilla/fx-private-relay)' + required: true + alert_number: + description: 'Dependabot alert number' + required: true + alert_package: + description: 'Package name' + required: true + alert_ecosystem: + description: 'Ecosystem (npm, pip, etc.)' + required: true + alert_severity: + description: 'Alert severity' + default: '' + alert_patched_version: + description: 'Patched version' + default: '' + dry_run: + description: 'Dry run (true = no mutations)' + default: 'true' + verbose: + description: 'Print full Claude output' + default: 'false' + +permissions: + contents: read + actions: write + +concurrency: + group: >- + blender-investigate-${{ inputs.target_repo }}-${{ inputs.alert_number }} + cancel-in-progress: false + +jobs: + investigate: + runs-on: ubuntu-latest + outputs: + action: ${{ steps.post-action.outputs.action }} + fork_repo: ${{ steps.post-action.outputs.fork_repo }} + advisory_ghsa_id: ${{ steps.post-action.outputs.advisory_ghsa_id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Parse target repo + id: parse + run: | + echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" + env: + TARGET_REPO: ${{ inputs.target_repo }} + + - id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ secrets.BLENDER_APP_ID }} + private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} + owner: ${{ steps.parse.outputs.owner }} + repositories: ${{ steps.parse.outputs.name }} + permission-contents: write + permission-pull-requests: write + permission-vulnerability-alerts: read + permission-security-events: write + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.target_repo }} + token: ${{ steps.app-token.outputs.token }} + path: target + persist-credentials: false + + - name: Read repo config + id: config + run: | + CONFIG_FILE="target/.blender/blender.yml" + if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: No config found at $CONFIG_FILE" + exit 1 + fi + { + echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" + echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" + echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" + echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" + } >> "$GITHUB_OUTPUT" + + # --- Conditional setup --- + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ steps.config.outputs.python_version || '3.11' }} + cache: pip + + - name: Install BLEnder Python dependencies + run: pip install -r scripts/requirements.txt + working-directory: ${{ github.workspace }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + if: steps.config.outputs.node_version != '' + with: + node-version: ${{ steps.config.outputs.node_version }} + + - name: Install dependencies + if: steps.config.outputs.install_command != '' + continue-on-error: true + working-directory: target + run: | + eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log + env: + INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} + + # --- BLEnder investigate --- + - name: Install sandbox dependencies + run: sudo apt-get install -y bubblewrap socat + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code@stable + + - name: Gather alert context + working-directory: target + run: ${{ github.workspace }}/scripts/gather-alert-context.sh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ALERT_NUMBER: ${{ inputs.alert_number }} + REPO: ${{ inputs.target_repo }} + PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/investigate-alert-prompt.md + ALERT_PACKAGE: ${{ inputs.alert_package }} + ALERT_ECOSYSTEM: ${{ inputs.alert_ecosystem }} + + - name: Run Claude investigation + working-directory: target + run: ${{ github.workspace }}/scripts/run-claude.sh + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + REPO: ${{ inputs.target_repo }} + REPO_NAME: ${{ steps.config.outputs.repo_name }} + BLENDER_DIR: ${{ github.workspace }} + BLENDER_MODE: investigate + CLAUDE_VERBOSE: ${{ inputs.verbose }} + + - name: Post-investigation action + id: post-action + working-directory: target + run: python ${{ github.workspace }}/scripts/post_alert_action.py + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ inputs.target_repo }} + ALERT_NUMBER: ${{ inputs.alert_number }} + ALERT_PACKAGE: ${{ inputs.alert_package }} + ALERT_ECOSYSTEM: ${{ inputs.alert_ecosystem }} + ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} + DRY_RUN: ${{ inputs.dry_run }} + + remediate: + needs: investigate + if: needs.investigate.outputs.action == 'private_fork' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Parse target repo + id: parse + run: | + echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" + env: + TARGET_REPO: ${{ inputs.target_repo }} + + - id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ secrets.BLENDER_APP_ID }} + private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} + owner: ${{ steps.parse.outputs.owner }} + repositories: ${{ steps.parse.outputs.name }} + permission-contents: write + permission-pull-requests: write + + - name: Clone private fork + run: | + git clone "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git" target + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + FORK_REPO: ${{ needs.investigate.outputs.fork_repo }} + + - name: Read repo config + id: config + run: | + CONFIG_FILE="target/.blender/blender.yml" + if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: No config found at $CONFIG_FILE" + exit 1 + fi + { + echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" + echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" + echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" + echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" + } >> "$GITHUB_OUTPUT" + + # --- Conditional setup --- + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ steps.config.outputs.python_version || '3.11' }} + cache: pip + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + if: steps.config.outputs.node_version != '' + with: + node-version: ${{ steps.config.outputs.node_version }} + + - name: Install dependencies + if: steps.config.outputs.install_command != '' + continue-on-error: true + working-directory: target + run: | + eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log + env: + INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} + + # --- BLEnder fix on private fork --- + - name: Install sandbox dependencies + run: sudo apt-get install -y bubblewrap socat + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code@stable + + - name: Build fix prompt + working-directory: target + run: | + cat > .blender-prompt << PROMPT_EOF + Fix the security vulnerability in ${ALERT_PACKAGE}. + + Bump ${ALERT_PACKAGE} to ${ALERT_PATCHED_VERSION} and fix any + code that uses the vulnerable API. Run the test suite to + verify your changes. + PROMPT_EOF + env: + ALERT_PACKAGE: ${{ inputs.alert_package }} + ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} + + - name: Run Claude fix + working-directory: target + run: ${{ github.workspace }}/scripts/run-claude.sh + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + REPO: ${{ needs.investigate.outputs.fork_repo }} + REPO_NAME: ${{ steps.config.outputs.repo_name }} + BLENDER_DIR: ${{ github.workspace }} + BLENDER_MODE: fix + CLAUDE_VERBOSE: ${{ inputs.verbose }} + + - name: Commit and push fix + id: commit + if: inputs.dry_run != 'true' + working-directory: target + run: ${{ github.workspace }}/scripts/commit.sh + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ needs.investigate.outputs.fork_repo }} diff --git a/config/defaults.yml b/config/defaults.yml index cd0f19a..32f8f98 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -12,3 +12,11 @@ fix: dry_run: false max_claude_turns: 30 max_budget_usd: 2.00 + +investigate: + enabled: true + dry_run: false + max_claude_turns: 20 + max_budget_usd: 1.50 + run_tests: true + severity_threshold: "" diff --git a/prompts/investigate-alert-prompt.md b/prompts/investigate-alert-prompt.md new file mode 100644 index 0000000..a934b07 --- /dev/null +++ b/prompts/investigate-alert-prompt.md @@ -0,0 +1,95 @@ +# BLEnder: Investigate Dependabot security alert + +## Alert details + +- **Package:** {{ALERT_PACKAGE}} ({{ALERT_ECOSYSTEM}}) +- **Severity:** {{ALERT_SEVERITY}} +- **Summary:** {{ALERT_SUMMARY}} +- **Vulnerable range:** {{ALERT_VULNERABLE_RANGE}} +- **Patched version:** {{ALERT_PATCHED_VERSION}} +- **CWEs:** {{ALERT_CWES}} + +## Advisory description + +{{ALERT_DESCRIPTION}} + +## Ecosystem audit output + +``` +{{AUDIT_OUTPUT}} +``` + +## Your task + +Determine whether this vulnerability affects the target repo's code. +Many Dependabot alerts flag transitive dependencies or code paths the +repo never exercises. Your job is to distinguish real impact from noise. + +### Step 1: Run the ecosystem audit tool + +Run the appropriate audit command for structured data: +- npm: `npm audit --json 2>/dev/null || true` +- pip: `pip-audit --format=json 2>/dev/null || true` + +This confirms whether the package is a direct or transitive dependency +and which versions are installed. + +### Step 2: Search for usage + +Search the codebase for imports, requires, and references to +`{{ALERT_PACKAGE}}`. Check: +- Source code imports and usage +- Configuration files +- Lock files (to confirm installed version) +- Test files + +### Step 3: Trace vulnerable code paths + +Read the advisory description. Identify the specific functions, +methods, or protocols that are vulnerable. Then check whether this +repo calls those functions or exposes those code paths. + +### Step 4: Assess transitive exposure + +If the package is a transitive dependency: +- Identify which direct dependency pulls it in +- Check whether the direct dependency exposes the vulnerable API +- A transitive dep used only at build time is not affected at runtime + +### Step 5: Write your verdict + +Write your verdict to `.blender-alert-verdict.json` using the Bash tool: + +```bash +cat > .blender-alert-verdict.json << 'VERDICT_EOF' +{ + "affected": false, + "confidence": "high", + "reason": "Brief explanation of why the repo is or is not affected", + "vulnerable_paths": [], + "recommended_action": "bump_pr" +} +VERDICT_EOF +``` + +**Fields:** +- `affected`: true if the vulnerability impacts this repo's code +- `confidence`: "high", "medium", or "low" +- `reason`: one-paragraph explanation +- `vulnerable_paths`: list of `file:line` strings where vulnerable code is called (empty if not affected) +- `recommended_action`: one of: + - `"existing_pr"` — a Dependabot PR already bumps this package + - `"bump_pr"` — not affected, but open a PR to bump the dependency + - `"private_fork"` — affected, needs a fix in a private security fork + +**Confidence levels:** +- `high`: clear evidence the package is or is not used in vulnerable ways +- `medium`: package is used but the vulnerable code path is ambiguous +- `low`: cannot determine with confidence + +## Rules + +- Do NOT edit any tracked files. Read and analyze only. +- Do NOT run `git` commands. +- Write ONLY `.blender-alert-verdict.json` via Bash. +- Be conservative. When in doubt, mark as affected. diff --git a/scripts/gather-alert-context.sh b/scripts/gather-alert-context.sh new file mode 100755 index 0000000..6cf3c36 --- /dev/null +++ b/scripts/gather-alert-context.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# BLEnder gather-alert-context: fetch alert metadata, build prompt. +# +# This script has GH_TOKEN but does NOT have ANTHROPIC_API_KEY. +# It writes the final prompt to .blender-prompt for run-claude.sh. +# +# Environment variables: +# ALERT_NUMBER -- Dependabot alert number (required) +# REPO -- GitHub repo, e.g. mozilla/fx-private-relay (required) +# GH_TOKEN -- GitHub token for API calls (required) +# PROMPT_TEMPLATE -- Path to prompt template file (required) +# ALERT_PACKAGE -- Package name (optional, for fallback) +# ALERT_ECOSYSTEM -- Ecosystem (optional, for fallback) + +set -euo pipefail + +if [ -z "${ALERT_NUMBER:-}" ] || [ -z "${REPO:-}" ]; then + echo "Error: ALERT_NUMBER and REPO are required." + exit 1 +fi + +if ! [[ "$ALERT_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Error: ALERT_NUMBER must be a positive integer, got: $ALERT_NUMBER" + exit 1 +fi + +if [ -z "${GH_TOKEN:-}" ]; then + echo "Error: GH_TOKEN is required." + exit 1 +fi + +if [ -z "${PROMPT_TEMPLATE:-}" ]; then + echo "Error: PROMPT_TEMPLATE is required." + exit 1 +fi + +if [ ! -f "$PROMPT_TEMPLATE" ]; then + echo "Error: Prompt template not found: $PROMPT_TEMPLATE" + exit 1 +fi + +# --- Sanitize untrusted input before inserting into prompts --- +sanitize_for_prompt() { + local input="$1" + # Strip HTML/XML tags + # shellcheck disable=SC2001 + input=$(echo "$input" | sed 's/<[^>]*>//g') + # Strip markdown image/link injection + # shellcheck disable=SC2001 + input=$(echo "$input" | sed 's/!\[[^]]*\]([^)]*)//g') + # Strip prompt injection attempts + input=$(echo "$input" | grep -viE '(ignore .* instructions|ignore .* prompt|system prompt|you are now|new instructions|disregard|forget .* above)' || true) + echo "$input" +} + +echo "BLEnder gather-alert-context: alert #${ALERT_NUMBER} repo=${REPO}" + +# --- Fetch alert details --- +echo "Fetching alert details..." +alert_json=$(gh api "repos/${REPO}/dependabot/alerts/${ALERT_NUMBER}") + +alert_package=$(echo "$alert_json" | jq -r '.security_vulnerability.package.name // ""') +alert_ecosystem=$(echo "$alert_json" | jq -r '.security_vulnerability.package.ecosystem // ""') +alert_severity=$(echo "$alert_json" | jq -r '.security_advisory.severity // ""') +alert_summary=$(echo "$alert_json" | jq -r '.security_advisory.summary // ""') +alert_description=$(echo "$alert_json" | jq -r '.security_advisory.description // ""') +alert_vulnerable_range=$(echo "$alert_json" | jq -r '.security_vulnerability.vulnerable_version_range // ""') +alert_patched_version=$(echo "$alert_json" | jq -r '.security_vulnerability.first_patched_version.identifier // ""') +alert_cwes=$(echo "$alert_json" | jq -r '[.security_advisory.cwes[]?.cwe_id // empty] | join(", ")') + +# Use env vars as fallback if API fields are empty +alert_package="${alert_package:-${ALERT_PACKAGE:-unknown}}" +alert_ecosystem="${alert_ecosystem:-${ALERT_ECOSYSTEM:-unknown}}" + +echo " Package: ${alert_package}" +echo " Ecosystem: ${alert_ecosystem}" +echo " Severity: ${alert_severity}" + +# --- Run ecosystem audit tool --- +echo "Running ecosystem audit..." +audit_output="" +case "$alert_ecosystem" in + npm) + audit_output=$(npm audit --json 2>/dev/null || echo '{"error": "npm audit failed"}') + ;; + pip) + audit_output=$(pip-audit --format=json 2>/dev/null || echo '{"error": "pip-audit failed"}') + ;; + *) + audit_output="(no audit tool available for ${alert_ecosystem})" + ;; +esac + +# --- Check for existing Dependabot PRs that bump this package --- +echo "Checking for existing PRs bumping ${alert_package}..." +existing_prs=$(gh api "repos/${REPO}/pulls?state=open&per_page=100" \ + --jq "[.[] | select(.user.login == \"dependabot[bot]\" and (.title | ascii_downcase | contains(\"${alert_package}\")))] | length" \ + 2>/dev/null || echo "0") +echo " Found ${existing_prs} existing PR(s) for this package." + +# --- Build the prompt --- +echo "Building prompt from ${PROMPT_TEMPLATE}..." +prompt=$(cat "$PROMPT_TEMPLATE") + +safe_summary=$(sanitize_for_prompt "$alert_summary") +safe_description=$(sanitize_for_prompt "$alert_description") +safe_audit=$(sanitize_for_prompt "$audit_output") + +prompt="${prompt//\{\{ALERT_PACKAGE\}\}/$alert_package}" +prompt="${prompt//\{\{ALERT_ECOSYSTEM\}\}/$alert_ecosystem}" +prompt="${prompt//\{\{ALERT_SEVERITY\}\}/$alert_severity}" +prompt="${prompt//\{\{ALERT_SUMMARY\}\}/$safe_summary}" +prompt="${prompt//\{\{ALERT_DESCRIPTION\}\}/$safe_description}" +prompt="${prompt//\{\{ALERT_VULNERABLE_RANGE\}\}/$alert_vulnerable_range}" +prompt="${prompt//\{\{ALERT_PATCHED_VERSION\}\}/$alert_patched_version}" +prompt="${prompt//\{\{ALERT_CWES\}\}/$alert_cwes}" +prompt="${prompt//\{\{AUDIT_OUTPUT\}\}/$safe_audit}" + +# Write prompt to file for run-claude.sh +echo "$prompt" > .blender-prompt + +echo "Prompt written to .blender-prompt" diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py new file mode 100644 index 0000000..cf7bdcb --- /dev/null +++ b/scripts/post_alert_action.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Post-investigation action for Dependabot security alerts. + +Reads .blender-alert-verdict.json and takes the appropriate action: + + not-affected + existing Dependabot PR -> no-op (existing pipeline handles it) + not-affected + no PR -> open a bump PR + affected -> create advisory with private fork + +Environment variables: + GH_TOKEN -- GitHub token (required) + REPO -- Target repo, e.g. mozilla/fx-private-relay (required) + ALERT_NUMBER -- Dependabot alert number (required) + ALERT_PACKAGE -- Package name (required) + ALERT_ECOSYSTEM -- Ecosystem, e.g. npm or pip (required) + ALERT_PATCHED_VERSION -- Version to bump to (required for bump PRs) + DRY_RUN -- Set to "true" to skip mutations (default: false) +""" + +from __future__ import annotations + +import json +import os +import sys +import time + +from github import Auth, Github + +VERDICT_FILE = ".blender-alert-verdict.json" +REQUIRED_KEYS = { + "affected", + "confidence", + "reason", + "vulnerable_paths", + "recommended_action", +} + + +def load_verdict() -> dict | None: + """Load and validate the alert verdict file.""" + if not os.path.exists(VERDICT_FILE): + print(f"No verdict file at {VERDICT_FILE}.") + return None + + with open(VERDICT_FILE) as f: + verdict = json.load(f) + + missing = REQUIRED_KEYS - set(verdict.keys()) + if missing: + print(f"Verdict missing keys: {missing}") + return None + + return verdict + + +def find_existing_pr(repo, package_name: str) -> bool: + """Check if an open Dependabot PR already bumps this package.""" + for pr in repo.get_pulls(state="open"): + if pr.user.login != "dependabot[bot]": + continue + if package_name.lower() in pr.title.lower(): + print(f" Existing PR #{pr.number}: {pr.title}") + return True + return False + + +def open_bump_pr( + repo, + alert_number: int, + package_name: str, + patched_version: str, + ecosystem: str, + dry_run: bool, +) -> None: + """Create a branch and PR to bump the package to the patched version. + + This creates a minimal PR that updates the dependency specification. + The actual file to modify depends on the ecosystem. + """ + branch_name = f"blender/security/{alert_number}-{package_name}" + default_branch = repo.default_branch + title = f"fix(security): bump {package_name} to {patched_version}" + body = ( + f"Bumps **{package_name}** to `{patched_version}` to resolve " + f"Dependabot alert #{alert_number}.\n\n" + f"BLEnder determined this vulnerability does not affect the " + f"codebase, but the dependency should still be updated.\n\n" + f"---\n" + f"Generated by [BLEnder](https://github.com/mozilla/blender)." + ) + + if dry_run: + print(f" DRY_RUN: would create branch {branch_name}") + print(f" DRY_RUN: would open PR: {title}") + return + + # Check if branch already exists + try: + repo.get_branch(branch_name) + print(f" Branch {branch_name} already exists, skipping.") + return + except Exception: + pass + + # Create branch from default branch HEAD + ref = repo.get_git_ref(f"heads/{default_branch}") + repo.create_git_ref(f"refs/heads/{branch_name}", ref.object.sha) + print(f" Created branch {branch_name}") + + # Open PR (no file changes yet — Dependabot or CI will handle the bump) + pr = repo.create_pull( + title=title, + body=body, + head=branch_name, + base=default_branch, + ) + print(f" Opened PR #{pr.number}: {title}") + + +def create_advisory_and_fork( + repo, + alert_number: int, + package_name: str, + dry_run: bool, +) -> tuple[str, str]: + """Create a security advisory with a private fork. + + Returns (advisory_ghsa_id, fork_full_name) on success. + """ + summary = f"Dependency security update for {package_name}" + description = ( + f"Automated security update for Dependabot alert #{alert_number}. " + f"See the advisory for details." + ) + + if dry_run: + print(f" DRY_RUN: would create advisory: {summary}") + return ("", "") + + # Create advisory via raw API (PyGithub has no built-in method) + url = f"/repos/{repo.full_name}/security-advisories" + payload = { + "summary": summary, + "description": description, + "severity": "low", + "start_private_fork": True, + } + try: + _, data = repo._requester.requestJsonAndCheck("POST", url, input=payload) + except Exception as e: + # Duplicate advisory returns 422 + error_str = str(e) + if "422" in error_str or "already exists" in error_str.lower(): + print(f" Advisory already exists for alert #{alert_number}, skipping.") + return ("", "") + raise + + ghsa_id = data.get("ghsa_id", "") + print(f" Created advisory {ghsa_id}") + + # Poll for private fork readiness (up to 5 minutes) + fork_full_name = "" + advisory_url = f"/repos/{repo.full_name}/security-advisories/{ghsa_id}" + for attempt in range(10): + time.sleep(min(30, 5 * (2**attempt))) + _, advisory_data = repo._requester.requestJsonAndCheck("GET", advisory_url) + forks = advisory_data.get("vulnerabilities", []) + if forks: + # The fork repo is in the advisory's fork field + fork_info = advisory_data.get("private_fork", {}) + if fork_info: + fork_full_name = fork_info.get("full_name", "") + break + # Also check top-level + if advisory_data.get("private_fork"): + fork_full_name = advisory_data["private_fork"].get("full_name", "") + if fork_full_name: + break + + if fork_full_name: + print(f" Private fork ready: {fork_full_name}") + else: + print(" Private fork not ready after polling. Remediate job may fail.") + + return (ghsa_id, fork_full_name) + + +def main() -> None: + token = os.environ.get("GH_TOKEN", "") + repo_name = os.environ.get("REPO", "") + alert_number = int(os.environ.get("ALERT_NUMBER", "0")) + package_name = os.environ.get("ALERT_PACKAGE", "unknown") + ecosystem = os.environ.get("ALERT_ECOSYSTEM", "unknown") + patched_version = os.environ.get("ALERT_PATCHED_VERSION", "") + dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes") + + if not token or not repo_name: + print("Error: GH_TOKEN and REPO are required.") + sys.exit(1) + + if alert_number == 0: + print("Error: ALERT_NUMBER is required.") + sys.exit(1) + + g = Github(auth=Auth.Token(token)) + repo = g.get_repo(repo_name) + + verdict = load_verdict() + if verdict is None: + print("No valid verdict. Defaulting to no-op.") + write_output("action", "noop") + return + + affected = verdict.get("affected", False) + recommended = verdict.get("recommended_action", "bump_pr") + print(f"Verdict: affected={affected}, recommended={recommended}") + print(f" Reason: {verdict.get('reason', '(none)')}") + + if not affected: + # Check for existing Dependabot PR + has_pr = find_existing_pr(repo, package_name) + + if has_pr or recommended == "existing_pr": + print(" Not affected + existing PR. No action needed.") + write_output("action", "noop") + else: + print(" Not affected + no PR. Opening bump PR.") + open_bump_pr( + repo, + alert_number, + package_name, + patched_version, + ecosystem, + dry_run, + ) + write_output("action", "bump_pr") + else: + print(" Affected. Creating advisory and private fork.") + ghsa_id, fork_repo = create_advisory_and_fork( + repo, + alert_number, + package_name, + dry_run, + ) + write_output("action", "private_fork") + write_output("advisory_ghsa_id", ghsa_id) + write_output("fork_repo", fork_repo) + + +def write_output(key: str, value: str) -> None: + """Write a key=value pair to $GITHUB_OUTPUT.""" + output_file = os.environ.get("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + f.write(f"{key}={value}\n") + print(f" output: {key}={value}") + + +if __name__ == "__main__": + main() diff --git a/scripts/run-claude.sh b/scripts/run-claude.sh index 867722a..27e911a 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -47,7 +47,12 @@ CLAUDE_SETTINGS="$BLENDER_DIR/claude-settings.json" CLAUDE_LOG=$(mktemp /tmp/blender-claude-XXXXXX.log) # --- Mode-specific settings --- -if [ "$BLENDER_MODE" = "major" ]; then +if [ "$BLENDER_MODE" = "investigate" ]; then + ALLOWED_TOOLS="Read,Bash" + MAX_TURNS=20 + MAX_BUDGET="1.50" + SYSTEM_PROMPT="You are BLEnder, a security analysis agent for ${REPO_DISPLAY_NAME}. Investigate the Dependabot security alert described in the prompt. Read the codebase to determine if the vulnerability affects this repo. Write your verdict to .blender-alert-verdict.json. Do not edit any tracked files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." +elif [ "$BLENDER_MODE" = "major" ]; then ALLOWED_TOOLS="Read,Bash" MAX_TURNS=15 MAX_BUDGET="1.00" @@ -86,9 +91,9 @@ rm -f "$CLAUDE_LOG" if [ "$claude_exit" -ne 0 ]; then echo "Claude exited with code ${claude_exit} (likely hit max-turns or budget)." - # In major mode, a non-zero exit is not fatal — post-review handles missing verdict - if [ "$BLENDER_MODE" = "major" ]; then - echo "Continuing to post-review step (verdict may be missing)." + # In major/investigate mode, a non-zero exit is not fatal — post steps handle missing verdict + if [ "$BLENDER_MODE" = "major" ] || [ "$BLENDER_MODE" = "investigate" ]; then + echo "Continuing to post step (verdict may be missing)." exit 0 fi exit 1 @@ -106,7 +111,7 @@ for secret_label in "ANTHROPIC_API_KEY" "PROMPT_NONCE"; do git checkout -- . exit 1 fi - # Also check verdict file in major mode + # Also check verdict files in major/investigate mode if [ "$BLENDER_MODE" = "major" ] && [ -f .blender-verdict.json ]; then if grep -qF "$secret_value" .blender-verdict.json; then echo "ABORT: ${secret_label} leaked into verdict file." @@ -114,6 +119,13 @@ for secret_label in "ANTHROPIC_API_KEY" "PROMPT_NONCE"; do exit 1 fi fi + if [ "$BLENDER_MODE" = "investigate" ] && [ -f .blender-alert-verdict.json ]; then + if grep -qF "$secret_value" .blender-alert-verdict.json; then + echo "ABORT: ${secret_label} leaked into alert verdict file." + rm -f .blender-alert-verdict.json + exit 1 + fi + fi # Also check the commit message file if [ -f .blender-commit-msg ] && grep -qF "$secret_value" .blender-commit-msg; then echo "ABORT: ${secret_label} leaked into commit message." @@ -156,6 +168,39 @@ if [ "$BLENDER_MODE" = "major" ]; then exit 0 fi +# --- Investigate mode: verdict validation --- +if [ "$BLENDER_MODE" = "investigate" ]; then + # No tracked files should be modified + if ! git diff --quiet; then + echo "ABORT: Claude modified tracked files in investigate mode." + git diff --name-only + git checkout -- . + exit 1 + fi + + # Alert verdict file must exist and be valid JSON + if [ -f .blender-alert-verdict.json ]; then + if ! jq empty .blender-alert-verdict.json 2>/dev/null; then + echo "ABORT: .blender-alert-verdict.json is not valid JSON." + rm -f .blender-alert-verdict.json + exit 1 + fi + # Check required keys + for key in affected confidence reason vulnerable_paths recommended_action; do + if ! jq -e "has(\"$key\")" .blender-alert-verdict.json > /dev/null 2>&1; then + echo "ABORT: .blender-alert-verdict.json missing required key: $key" + rm -f .blender-alert-verdict.json + exit 1 + fi + done + echo "Alert verdict file validated." + else + echo "No alert verdict file produced. Post-action will handle this." + fi + + exit 0 +fi + # --- Fix mode: existing validation --- # Path validation: reject changes to sensitive paths diff --git a/scripts/sweep.py b/scripts/sweep.py index 22a0f73..2cef264 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -36,18 +36,30 @@ @dataclass class Action: - action: str # "fix" or "automerge" + action: str # "fix", "automerge", or "investigate" repo: str pr_number: int pr_title: str + alert_number: int | None = None + alert_package: str | None = None + alert_ecosystem: str | None = None + alert_severity: str | None = None + alert_patched_version: str | None = None def to_dict(self) -> dict: - return { + d: dict = { "action": self.action, "repo": self.repo, "pr_number": self.pr_number, "pr_title": self.pr_title, } + if self.alert_number is not None: + d["alert_number"] = self.alert_number + d["alert_package"] = self.alert_package + d["alert_ecosystem"] = self.alert_ecosystem + d["alert_severity"] = self.alert_severity + d["alert_patched_version"] = self.alert_patched_version + return d def has_blender_config(repo: Repository) -> bool: @@ -177,6 +189,80 @@ def process_repo(repo: Repository) -> list[Action]: ) ) + # Check Dependabot security alerts + try: + alert_actions = check_alerts(repo) + actions.extend(alert_actions) + except Exception as e: + print(f" Error checking alerts: {e}") + + return actions + + +def check_alerts(repo: Repository) -> list[Action]: + """Check for open Dependabot security alerts and emit investigate actions. + + Uses PyGithub's raw requester because there is no built-in method + for the Dependabot alerts API. + """ + actions: list[Action] = [] + + url = f"/repos/{repo.full_name}/dependabot/alerts" + try: + headers, data = repo._requester.requestJsonAndCheck( + "GET", url, parameters={"state": "open", "per_page": "100"} + ) + except Exception as e: + print(f" Could not fetch alerts: {e}") + return actions + + if not data: + print(" No open Dependabot alerts") + return actions + + print(f" Found {len(data)} open Dependabot alert(s)") + + # Fetch existing branches for dedup + existing_branches: set[str] = set() + try: + for branch in repo.get_branches(): + if branch.name.startswith("blender/security/"): + existing_branches.add(branch.name) + except Exception: + pass # branch listing may fail; proceed without dedup + + for alert in data: + alert_number = alert.get("number") + vuln = alert.get("security_vulnerability", {}) + pkg = vuln.get("package", {}) + advisory = alert.get("security_advisory", {}) + package_name = pkg.get("name", "unknown") + ecosystem = pkg.get("ecosystem", "unknown") + severity = advisory.get("severity", "unknown") + patched = vuln.get("first_patched_version", {}) + patched_version = patched.get("identifier", "") if patched else "" + + # Skip if a blender/security branch already exists for this alert + branch_prefix = f"blender/security/{alert_number}-" + if any(b.startswith(branch_prefix) for b in existing_branches): + print(f" Alert #{alert_number}: branch exists, skipping") + continue + + print(f" Alert #{alert_number}: {package_name} ({severity})") + actions.append( + Action( + action="investigate", + repo=repo.full_name, + pr_number=0, + pr_title=f"Security alert: {package_name}", + alert_number=alert_number, + alert_package=package_name, + alert_ecosystem=ecosystem, + alert_severity=severity, + alert_patched_version=patched_version, + ) + ) + return actions diff --git a/scripts/trigger-workflows.py b/scripts/trigger-workflows.py index ab1f186..62e0355 100644 --- a/scripts/trigger-workflows.py +++ b/scripts/trigger-workflows.py @@ -26,6 +26,7 @@ WORKFLOW_MAP = { "fix": "fix-dependabot-pr.yml", "automerge": "chore-automerge-dependabot-prs.yml", + "investigate": "investigate-security-alert.yml", } @@ -59,11 +60,14 @@ def main() -> None: # Deduplicate automerge: one trigger per repo automerge_repos: set[str] = set() fix_actions = [] + investigate_actions = [] for a in actions: if a["action"] == "automerge": automerge_repos.add(a["repo"]) elif a["action"] == "fix": fix_actions.append(a) + elif a["action"] == "investigate": + investigate_actions.append(a) else: print(f"Unknown action: {a['action']}") @@ -105,6 +109,33 @@ def main() -> None: if not trigger_workflow(cmd, f"fix {a['repo']} #{a['pr_number']}"): failures += 1 + # Trigger investigate once per alert + for a in investigate_actions: + workflow = WORKFLOW_MAP["investigate"] + cmd = [ + "gh", + "workflow", + "run", + workflow, + "-f", + f"target_repo={a['repo']}", + "-f", + f"alert_number={a['alert_number']}", + "-f", + f"alert_package={a.get('alert_package', '')}", + "-f", + f"alert_ecosystem={a.get('alert_ecosystem', '')}", + "-f", + f"alert_severity={a.get('alert_severity', '')}", + "-f", + f"alert_patched_version={a.get('alert_patched_version', '')}", + "-f", + "dry_run=false", + ] + print(f"Triggering {workflow} for {a['repo']} alert #{a['alert_number']}") + if not trigger_workflow(cmd, f"investigate {a['repo']} #{a['alert_number']}"): + failures += 1 + if failures: print(f"\n{failures} trigger(s) failed.") raise SystemExit(1) diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py new file mode 100644 index 0000000..a1803a7 --- /dev/null +++ b/tests/scripts/test_post_alert_action.py @@ -0,0 +1,103 @@ +"""Tests for scripts.post_alert_action.""" + +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.post_alert_action import ( + create_advisory_and_fork, + find_existing_pr, + load_verdict, + open_bump_pr, +) + + +@pytest.fixture() +def verdict_file(tmp_path, monkeypatch): + """Write a verdict JSON and chdir so load_verdict finds it.""" + monkeypatch.chdir(tmp_path) + + def _write(data: dict): + (tmp_path / ".blender-alert-verdict.json").write_text(json.dumps(data)) + + return _write + + +class TestLoadVerdict: + def test_missing_file(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert load_verdict() is None + + def test_valid_verdict(self, verdict_file): + verdict_file({ + "affected": False, + "confidence": "high", + "reason": "not used", + "vulnerable_paths": [], + "recommended_action": "bump_pr", + }) + v = load_verdict() + assert v is not None + assert v["affected"] is False + + def test_missing_keys(self, verdict_file): + verdict_file({"affected": True}) + assert load_verdict() is None + + +class TestFindExistingPR: + def test_finds_matching_pr(self): + pr = MagicMock() + pr.user.login = "dependabot[bot]" + pr.title = "Bump lodash from 4.17.20 to 4.17.21" + pr.number = 99 + repo = MagicMock() + repo.get_pulls.return_value = [pr] + + assert find_existing_pr(repo, "lodash") is True + + def test_no_matching_pr(self): + pr = MagicMock() + pr.user.login = "dependabot[bot]" + pr.title = "Bump express from 4.0 to 5.0" + repo = MagicMock() + repo.get_pulls.return_value = [pr] + + assert find_existing_pr(repo, "lodash") is False + + +class TestOpenBumpPR: + def test_dry_run_skips_creation(self): + repo = MagicMock() + open_bump_pr(repo, 42, "lodash", "4.17.21", "npm", dry_run=True) + repo.create_git_ref.assert_not_called() + repo.create_pull.assert_not_called() + + def test_existing_branch_skips(self): + repo = MagicMock() + repo.get_branch.return_value = MagicMock() # branch exists + open_bump_pr(repo, 42, "lodash", "4.17.21", "npm", dry_run=False) + repo.create_git_ref.assert_not_called() + + +class TestCreateAdvisoryAndFork: + def test_dry_run_skips(self): + repo = MagicMock() + ghsa, fork = create_advisory_and_fork(repo, 42, "lodash", dry_run=True) + assert ghsa == "" + assert fork == "" + repo._requester.requestJsonAndCheck.assert_not_called() + + def test_duplicate_advisory_skipped(self): + repo = MagicMock() + repo.full_name = "owner/repo" + repo._requester.requestJsonAndCheck.side_effect = Exception( + "422 already exists" + ) + ghsa, fork = create_advisory_and_fork(repo, 42, "lodash", dry_run=False) + assert ghsa == "" + assert fork == "" diff --git a/tests/scripts/test_sweep.py b/tests/scripts/test_sweep.py index 62e463f..5362d12 100644 --- a/tests/scripts/test_sweep.py +++ b/tests/scripts/test_sweep.py @@ -1,11 +1,11 @@ -"""Tests for scripts.sweep.process_repo.""" +"""Tests for scripts.sweep.process_repo and check_alerts.""" from __future__ import annotations from datetime import datetime, timezone from unittest.mock import MagicMock, PropertyMock, patch -from scripts.sweep import process_repo +from scripts.sweep import check_alerts, process_repo # --- Shared timestamps --- @@ -267,3 +267,78 @@ def test_check_pr_status_exception_continues(self): "scripts.sweep.check_pr_status", side_effect=RuntimeError("API error") ): assert process_repo(repo) == [] + + +# --- Alert discovery --- + + +def _make_alert(number: int, package: str, severity: str = "high"): + """Build a mock Dependabot alert dict (API response shape).""" + return { + "number": number, + "security_vulnerability": { + "package": {"name": package, "ecosystem": "npm"}, + "first_patched_version": {"identifier": "2.0.0"}, + }, + "security_advisory": {"severity": severity}, + } + + +def _make_branch(name: str): + """Build a mock branch object.""" + b = MagicMock() + b.name = name + return b + + +class TestAlertDiscovery: + def test_alert_discovery_emits_investigate_action(self): + """Open alert with no existing branch -> investigate action.""" + repo = MagicMock() + repo.full_name = "owner/repo" + repo._requester.requestJsonAndCheck.return_value = ( + {}, + [_make_alert(42, "lodash")], + ) + repo.get_branches.return_value = [] + + actions = check_alerts(repo) + assert len(actions) == 1 + assert actions[0].action == "investigate" + assert actions[0].alert_number == 42 + assert actions[0].alert_package == "lodash" + + def test_alert_with_existing_branch_skipped(self): + """Alert with blender/security/{number}-* branch -> skip.""" + repo = MagicMock() + repo.full_name = "owner/repo" + repo._requester.requestJsonAndCheck.return_value = ( + {}, + [_make_alert(42, "lodash")], + ) + repo.get_branches.return_value = [ + _make_branch("blender/security/42-lodash"), + ] + + actions = check_alerts(repo) + assert actions == [] + + def test_fixed_alert_skipped(self): + """No open alerts -> empty list.""" + repo = MagicMock() + repo.full_name = "owner/repo" + repo._requester.requestJsonAndCheck.return_value = ({}, []) + repo.get_branches.return_value = [] + + actions = check_alerts(repo) + assert actions == [] + + def test_alert_api_failure_returns_empty(self): + """API error fetching alerts -> empty list, no crash.""" + repo = MagicMock() + repo.full_name = "owner/repo" + repo._requester.requestJsonAndCheck.side_effect = RuntimeError("403") + repo.get_branches.return_value = [] + + actions = check_alerts(repo) + assert actions == [] From 126e8d47eac04fb2fadf9cf804d2495b4f331546 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 5 May 2026 16:14:23 -0500 Subject: [PATCH 02/15] feat: auto-dismiss non-affected alerts and post summary report Repos can opt in via `investigate.dismiss_not_affected: true` in their .blender/blender.yml. When enabled, BLEnder dismisses Dependabot alerts as "inaccurate" instead of opening bump PRs for non-affected packages. Every investigation now posts a row to a tracking issue on the target repo. The issue is created on the first run, then reused. Each row is a comment (not a body edit) to avoid races when many workflows run concurrently. --- .../workflows/investigate-security-alert.yml | 6 +- config/defaults.yml | 1 + scripts/post_alert_action.py | 137 +++++++++++++++--- tests/scripts/test_post_alert_action.py | 103 +++++++++++++ 4 files changed, 227 insertions(+), 20 deletions(-) diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index cc11e24..7d7e47b 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -69,8 +69,9 @@ jobs: repositories: ${{ steps.parse.outputs.name }} permission-contents: write permission-pull-requests: write - permission-vulnerability-alerts: read + permission-vulnerability-alerts: write permission-security-events: write + permission-issues: write - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -92,6 +93,7 @@ jobs: echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" + echo "dismiss_not_affected=$(yq '.investigate.dismiss_not_affected // "false"' "$CONFIG_FILE")" } >> "$GITHUB_OUTPUT" # --- Conditional setup --- @@ -157,8 +159,10 @@ jobs: ALERT_NUMBER: ${{ inputs.alert_number }} ALERT_PACKAGE: ${{ inputs.alert_package }} ALERT_ECOSYSTEM: ${{ inputs.alert_ecosystem }} + ALERT_SEVERITY: ${{ inputs.alert_severity }} ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} DRY_RUN: ${{ inputs.dry_run }} + DISMISS_NOT_AFFECTED: ${{ steps.config.outputs.dismiss_not_affected }} remediate: needs: investigate diff --git a/config/defaults.yml b/config/defaults.yml index 32f8f98..f5ce7dd 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -20,3 +20,4 @@ investigate: max_budget_usd: 1.50 run_tests: true severity_threshold: "" + dismiss_not_affected: false diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index cf7bdcb..fb78778 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -3,9 +3,13 @@ Reads .blender-alert-verdict.json and takes the appropriate action: + not-affected + dismiss enabled -> dismiss the alert via API not-affected + existing Dependabot PR -> no-op (existing pipeline handles it) - not-affected + no PR -> open a bump PR - affected -> create advisory with private fork + not-affected + no PR -> open a bump PR + affected -> create advisory with private fork + +Each run posts a row to a tracking issue on the target repo so the +owner can see all results in one place. Environment variables: GH_TOKEN -- GitHub token (required) @@ -13,8 +17,10 @@ ALERT_NUMBER -- Dependabot alert number (required) ALERT_PACKAGE -- Package name (required) ALERT_ECOSYSTEM -- Ecosystem, e.g. npm or pip (required) + ALERT_SEVERITY -- Alert severity (optional, for summary report) ALERT_PATCHED_VERSION -- Version to bump to (required for bump PRs) DRY_RUN -- Set to "true" to skip mutations (default: false) + DISMISS_NOT_AFFECTED -- Set to "true" to dismiss non-affected alerts """ from __future__ import annotations @@ -185,14 +191,94 @@ def create_advisory_and_fork( return (ghsa_id, fork_full_name) +SUMMARY_ISSUE_TITLE = "BLEnder: Dependabot alert investigation summary" +SUMMARY_TABLE_HEADER = ( + "| Alert | Package | Severity | Action | Reason |\n" + "| ----- | ------- | -------- | ------ | ------ |" +) + + +def dismiss_alert( + repo, + alert_number: int, + reason: str, + dry_run: bool, +) -> None: + """Dismiss a Dependabot alert as inaccurate (not affected).""" + if dry_run: + print(f" DRY_RUN: would dismiss alert #{alert_number}") + return + + url = f"/repos/{repo.full_name}/dependabot/alerts/{alert_number}" + payload = { + "state": "dismissed", + "dismissed_reason": "inaccurate", + "dismissed_comment": f"BLEnder: {reason}", + } + repo._requester.requestJsonAndCheck("PATCH", url, input=payload) + print(f" Dismissed alert #{alert_number}") + + +def post_summary_comment( + repo, + alert_number: int, + package: str, + severity: str, + action: str, + reason: str, + dry_run: bool, +) -> None: + """Post a row to the summary tracking issue. + + Creates the issue on first run. Each row is a comment to avoid + read-modify-write races when many workflows run at once. + """ + if dry_run: + print(" DRY_RUN: would post summary comment") + return + + # Find existing summary issue + issues = repo.get_issues(state="open") + summary_issue = None + for issue in issues: + if issue.title == SUMMARY_ISSUE_TITLE: + summary_issue = issue + break + + # Create issue if missing + if summary_issue is None: + summary_issue = repo.create_issue( + title=SUMMARY_ISSUE_TITLE, + body=( + "BLEnder posts a comment here for each investigated " + "Dependabot alert.\n\n" + SUMMARY_TABLE_HEADER + ), + labels=["blender"], + ) + print(f" Created summary issue #{summary_issue.number}") + + alert_url = ( + f"https://github.com/{repo.full_name}/security/dependabot/{alert_number}" + ) + row = f"| [#{alert_number}]({alert_url}) | {package} | {severity} | {action} | {reason} |" + summary_issue.create_comment(row) + print(f" Posted summary row to issue #{summary_issue.number}") + + def main() -> None: token = os.environ.get("GH_TOKEN", "") repo_name = os.environ.get("REPO", "") alert_number = int(os.environ.get("ALERT_NUMBER", "0")) package_name = os.environ.get("ALERT_PACKAGE", "unknown") ecosystem = os.environ.get("ALERT_ECOSYSTEM", "unknown") + severity = os.environ.get("ALERT_SEVERITY", "unknown") patched_version = os.environ.get("ALERT_PATCHED_VERSION", "") dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes") + dismiss_enabled = os.environ.get("DISMISS_NOT_AFFECTED", "false").lower() in ( + "true", + "1", + "yes", + ) if not token or not repo_name: print("Error: GH_TOKEN and REPO are required.") @@ -216,24 +302,32 @@ def main() -> None: print(f"Verdict: affected={affected}, recommended={recommended}") print(f" Reason: {verdict.get('reason', '(none)')}") - if not affected: - # Check for existing Dependabot PR - has_pr = find_existing_pr(repo, package_name) + reason = verdict.get("reason", "(none)") - if has_pr or recommended == "existing_pr": - print(" Not affected + existing PR. No action needed.") - write_output("action", "noop") + if not affected: + if dismiss_enabled: + print(" Not affected + dismiss enabled. Dismissing alert.") + dismiss_alert(repo, alert_number, reason, dry_run) + action = "dismissed" else: - print(" Not affected + no PR. Opening bump PR.") - open_bump_pr( - repo, - alert_number, - package_name, - patched_version, - ecosystem, - dry_run, - ) - write_output("action", "bump_pr") + # Check for existing Dependabot PR + has_pr = find_existing_pr(repo, package_name) + + if has_pr or recommended == "existing_pr": + print(" Not affected + existing PR. No action needed.") + action = "noop" + else: + print(" Not affected + no PR. Opening bump PR.") + open_bump_pr( + repo, + alert_number, + package_name, + patched_version, + ecosystem, + dry_run, + ) + action = "bump_pr" + write_output("action", action) else: print(" Affected. Creating advisory and private fork.") ghsa_id, fork_repo = create_advisory_and_fork( @@ -242,10 +336,15 @@ def main() -> None: package_name, dry_run, ) - write_output("action", "private_fork") + action = "private_fork" + write_output("action", action) write_output("advisory_ghsa_id", ghsa_id) write_output("fork_repo", fork_repo) + post_summary_comment( + repo, alert_number, package_name, severity, action, reason, dry_run + ) + def write_output(key: str, value: str) -> None: """Write a key=value pair to $GITHUB_OUTPUT.""" diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index a1803a7..58df180 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -10,9 +10,12 @@ from scripts.post_alert_action import ( create_advisory_and_fork, + dismiss_alert, find_existing_pr, load_verdict, + main, open_bump_pr, + post_summary_comment, ) @@ -101,3 +104,103 @@ def test_duplicate_advisory_skipped(self): ghsa, fork = create_advisory_and_fork(repo, 42, "lodash", dry_run=False) assert ghsa == "" assert fork == "" + + +class TestDismissAlert: + def test_calls_api(self): + repo = MagicMock() + repo.full_name = "owner/repo" + dismiss_alert(repo, 42, "not used in codebase", dry_run=False) + repo._requester.requestJsonAndCheck.assert_called_once_with( + "PATCH", + "/repos/owner/repo/dependabot/alerts/42", + input={ + "state": "dismissed", + "dismissed_reason": "inaccurate", + "dismissed_comment": "BLEnder: not used in codebase", + }, + ) + + def test_dry_run_skips(self): + repo = MagicMock() + dismiss_alert(repo, 42, "not used", dry_run=True) + repo._requester.requestJsonAndCheck.assert_not_called() + + +class TestPostSummaryComment: + def test_creates_issue_when_missing(self): + repo = MagicMock() + repo.full_name = "owner/repo" + repo.get_issues.return_value = iter([]) + new_issue = MagicMock() + new_issue.number = 10 + repo.create_issue.return_value = new_issue + + post_summary_comment(repo, 42, "lodash", "high", "dismissed", "not used", False) + + repo.create_issue.assert_called_once() + new_issue.create_comment.assert_called_once() + comment = new_issue.create_comment.call_args[0][0] + assert "#42" in comment + assert "lodash" in comment + + def test_reuses_existing_issue(self): + repo = MagicMock() + repo.full_name = "owner/repo" + existing = MagicMock() + existing.title = "BLEnder: Dependabot alert investigation summary" + repo.get_issues.return_value = iter([existing]) + + post_summary_comment(repo, 7, "express", "medium", "bump_pr", "outdated", False) + + repo.create_issue.assert_not_called() + existing.create_comment.assert_called_once() + + def test_dry_run_skips(self): + repo = MagicMock() + post_summary_comment(repo, 42, "lodash", "high", "dismissed", "not used", True) + repo.get_issues.assert_not_called() + + +class TestMainDismissFlow: + def test_not_affected_dismiss_enabled(self, verdict_file, monkeypatch): + verdict_file({ + "affected": False, + "confidence": "high", + "reason": "not used in codebase", + "vulnerable_paths": [], + "recommended_action": "bump_pr", + }) + monkeypatch.setenv("GH_TOKEN", "fake") + monkeypatch.setenv("REPO", "owner/repo") + monkeypatch.setenv("ALERT_NUMBER", "42") + monkeypatch.setenv("ALERT_PACKAGE", "lodash") + monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") + monkeypatch.setenv("ALERT_SEVERITY", "high") + monkeypatch.setenv("DISMISS_NOT_AFFECTED", "true") + monkeypatch.setenv("DRY_RUN", "false") + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + mock_repo.get_issues.return_value = iter([]) + mock_issue = MagicMock() + mock_issue.number = 1 + mock_repo.create_issue.return_value = mock_issue + + with patch("scripts.post_alert_action.Github") as mock_gh: + mock_gh.return_value.get_repo.return_value = mock_repo + main() + + # Alert was dismissed + mock_repo._requester.requestJsonAndCheck.assert_called_once_with( + "PATCH", + "/repos/owner/repo/dependabot/alerts/42", + input={ + "state": "dismissed", + "dismissed_reason": "inaccurate", + "dismissed_comment": "BLEnder: not used in codebase", + }, + ) + # Summary comment was posted + mock_issue.create_comment.assert_called_once() From 38597a213e40a604d1274989cc641b9303f3169e Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 5 May 2026 20:47:03 -0500 Subject: [PATCH 03/15] fix: address PR #35 review feedback Replace tracking issue with JSON artifact to avoid exposing alert details on public repos. Remove open_bump_pr (no file changes made it useless). Rename "not affected" to "unaffected" everywhere. Extract shared sanitize_for_prompt into scripts/sanitize.sh. Fix "dedup" jargon in sweep.py comments. --- .../workflows/investigate-security-alert.yml | 12 +- config/defaults.yml | 2 +- scripts/gather-alert-context.sh | 15 +- scripts/gather-context.sh | 15 +- scripts/post_alert_action.py | 172 +++--------------- scripts/sanitize.sh | 16 ++ scripts/sweep.py | 4 +- tests/scripts/test_post_alert_action.py | 134 ++++++-------- 8 files changed, 118 insertions(+), 252 deletions(-) create mode 100755 scripts/sanitize.sh diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 7d7e47b..77dd319 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -71,7 +71,6 @@ jobs: permission-pull-requests: write permission-vulnerability-alerts: write permission-security-events: write - permission-issues: write - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -93,7 +92,7 @@ jobs: echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" - echo "dismiss_not_affected=$(yq '.investigate.dismiss_not_affected // "false"' "$CONFIG_FILE")" + echo "dismiss_unaffected=$(yq '.investigate.dismiss_unaffected // "false"' "$CONFIG_FILE")" } >> "$GITHUB_OUTPUT" # --- Conditional setup --- @@ -162,7 +161,14 @@ jobs: ALERT_SEVERITY: ${{ inputs.alert_severity }} ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} DRY_RUN: ${{ inputs.dry_run }} - DISMISS_NOT_AFFECTED: ${{ steps.config.outputs.dismiss_not_affected }} + DISMISS_UNAFFECTED: ${{ steps.config.outputs.dismiss_unaffected }} + + - name: Upload alert summary + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: alert-${{ inputs.alert_number }}-summary + path: target/.blender-alert-summary.json remediate: needs: investigate diff --git a/config/defaults.yml b/config/defaults.yml index f5ce7dd..95f0325 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -20,4 +20,4 @@ investigate: max_budget_usd: 1.50 run_tests: true severity_threshold: "" - dismiss_not_affected: false + dismiss_unaffected: false diff --git a/scripts/gather-alert-context.sh b/scripts/gather-alert-context.sh index 6cf3c36..51281fc 100755 --- a/scripts/gather-alert-context.sh +++ b/scripts/gather-alert-context.sh @@ -40,18 +40,9 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then fi # --- Sanitize untrusted input before inserting into prompts --- -sanitize_for_prompt() { - local input="$1" - # Strip HTML/XML tags - # shellcheck disable=SC2001 - input=$(echo "$input" | sed 's/<[^>]*>//g') - # Strip markdown image/link injection - # shellcheck disable=SC2001 - input=$(echo "$input" | sed 's/!\[[^]]*\]([^)]*)//g') - # Strip prompt injection attempts - input=$(echo "$input" | grep -viE '(ignore .* instructions|ignore .* prompt|system prompt|you are now|new instructions|disregard|forget .* above)' || true) - echo "$input" -} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" echo "BLEnder gather-alert-context: alert #${ALERT_NUMBER} repo=${REPO}" diff --git a/scripts/gather-context.sh b/scripts/gather-context.sh index 6b6c42e..52f07e8 100755 --- a/scripts/gather-context.sh +++ b/scripts/gather-context.sh @@ -45,18 +45,9 @@ if [ ! -f "$PROMPT_TEMPLATE" ]; then fi # --- Sanitize untrusted input before inserting into prompts --- -sanitize_for_prompt() { - local input="$1" - # Strip HTML/XML tags (regex requires sed, not ${//}) - # shellcheck disable=SC2001 - input=$(echo "$input" | sed 's/<[^>]*>//g') - # Strip markdown image/link injection - # shellcheck disable=SC2001 - input=$(echo "$input" | sed 's/!\[[^]]*\]([^)]*)//g') - # Strip prompt injection attempts - input=$(echo "$input" | grep -viE '(ignore .* instructions|ignore .* prompt|system prompt|you are now|new instructions|disregard|forget .* above)' || true) - echo "$input" -} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/sanitize.sh +source "${SCRIPT_DIR}/sanitize.sh" echo "BLEnder gather-context: PR #${PR_NUMBER} repo=${REPO}" diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index fb78778..20a1dbd 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -3,13 +3,12 @@ Reads .blender-alert-verdict.json and takes the appropriate action: - not-affected + dismiss enabled -> dismiss the alert via API - not-affected + existing Dependabot PR -> no-op (existing pipeline handles it) - not-affected + no PR -> open a bump PR - affected -> create advisory with private fork + unaffected + dismiss enabled -> dismiss the alert via API + unaffected + dismiss disabled -> no-op (suggest bumping the package) + affected -> create advisory with private fork -Each run posts a row to a tracking issue on the target repo so the -owner can see all results in one place. +Writes a JSON summary to .blender-alert-summary.json for upload as a +workflow artifact (visible only to users with Actions access). Environment variables: GH_TOKEN -- GitHub token (required) @@ -17,10 +16,10 @@ ALERT_NUMBER -- Dependabot alert number (required) ALERT_PACKAGE -- Package name (required) ALERT_ECOSYSTEM -- Ecosystem, e.g. npm or pip (required) - ALERT_SEVERITY -- Alert severity (optional, for summary report) - ALERT_PATCHED_VERSION -- Version to bump to (required for bump PRs) + ALERT_SEVERITY -- Alert severity (optional, for summary) + ALERT_PATCHED_VERSION -- Version to bump to (optional) DRY_RUN -- Set to "true" to skip mutations (default: false) - DISMISS_NOT_AFFECTED -- Set to "true" to dismiss non-affected alerts + DISMISS_UNAFFECTED -- Set to "true" to dismiss unaffected alerts """ from __future__ import annotations @@ -33,6 +32,7 @@ from github import Auth, Github VERDICT_FILE = ".blender-alert-verdict.json" +SUMMARY_FILE = ".blender-alert-summary.json" REQUIRED_KEYS = { "affected", "confidence", @@ -59,70 +59,6 @@ def load_verdict() -> dict | None: return verdict -def find_existing_pr(repo, package_name: str) -> bool: - """Check if an open Dependabot PR already bumps this package.""" - for pr in repo.get_pulls(state="open"): - if pr.user.login != "dependabot[bot]": - continue - if package_name.lower() in pr.title.lower(): - print(f" Existing PR #{pr.number}: {pr.title}") - return True - return False - - -def open_bump_pr( - repo, - alert_number: int, - package_name: str, - patched_version: str, - ecosystem: str, - dry_run: bool, -) -> None: - """Create a branch and PR to bump the package to the patched version. - - This creates a minimal PR that updates the dependency specification. - The actual file to modify depends on the ecosystem. - """ - branch_name = f"blender/security/{alert_number}-{package_name}" - default_branch = repo.default_branch - title = f"fix(security): bump {package_name} to {patched_version}" - body = ( - f"Bumps **{package_name}** to `{patched_version}` to resolve " - f"Dependabot alert #{alert_number}.\n\n" - f"BLEnder determined this vulnerability does not affect the " - f"codebase, but the dependency should still be updated.\n\n" - f"---\n" - f"Generated by [BLEnder](https://github.com/mozilla/blender)." - ) - - if dry_run: - print(f" DRY_RUN: would create branch {branch_name}") - print(f" DRY_RUN: would open PR: {title}") - return - - # Check if branch already exists - try: - repo.get_branch(branch_name) - print(f" Branch {branch_name} already exists, skipping.") - return - except Exception: - pass - - # Create branch from default branch HEAD - ref = repo.get_git_ref(f"heads/{default_branch}") - repo.create_git_ref(f"refs/heads/{branch_name}", ref.object.sha) - print(f" Created branch {branch_name}") - - # Open PR (no file changes yet — Dependabot or CI will handle the bump) - pr = repo.create_pull( - title=title, - body=body, - head=branch_name, - base=default_branch, - ) - print(f" Opened PR #{pr.number}: {title}") - - def create_advisory_and_fork( repo, alert_number: int, @@ -191,20 +127,13 @@ def create_advisory_and_fork( return (ghsa_id, fork_full_name) -SUMMARY_ISSUE_TITLE = "BLEnder: Dependabot alert investigation summary" -SUMMARY_TABLE_HEADER = ( - "| Alert | Package | Severity | Action | Reason |\n" - "| ----- | ------- | -------- | ------ | ------ |" -) - - def dismiss_alert( repo, alert_number: int, reason: str, dry_run: bool, ) -> None: - """Dismiss a Dependabot alert as inaccurate (not affected).""" + """Dismiss a Dependabot alert as inaccurate (unaffected).""" if dry_run: print(f" DRY_RUN: would dismiss alert #{alert_number}") return @@ -219,50 +148,26 @@ def dismiss_alert( print(f" Dismissed alert #{alert_number}") -def post_summary_comment( - repo, +def write_summary( + path: str, alert_number: int, package: str, severity: str, action: str, reason: str, - dry_run: bool, ) -> None: - """Post a row to the summary tracking issue. - - Creates the issue on first run. Each row is a comment to avoid - read-modify-write races when many workflows run at once. - """ - if dry_run: - print(" DRY_RUN: would post summary comment") - return - - # Find existing summary issue - issues = repo.get_issues(state="open") - summary_issue = None - for issue in issues: - if issue.title == SUMMARY_ISSUE_TITLE: - summary_issue = issue - break - - # Create issue if missing - if summary_issue is None: - summary_issue = repo.create_issue( - title=SUMMARY_ISSUE_TITLE, - body=( - "BLEnder posts a comment here for each investigated " - "Dependabot alert.\n\n" + SUMMARY_TABLE_HEADER - ), - labels=["blender"], - ) - print(f" Created summary issue #{summary_issue.number}") - - alert_url = ( - f"https://github.com/{repo.full_name}/security/dependabot/{alert_number}" - ) - row = f"| [#{alert_number}]({alert_url}) | {package} | {severity} | {action} | {reason} |" - summary_issue.create_comment(row) - print(f" Posted summary row to issue #{summary_issue.number}") + """Write a JSON summary file for upload as a workflow artifact.""" + summary = { + "alert_number": alert_number, + "package": package, + "severity": severity, + "action": action, + "reason": reason, + } + with open(path, "w") as f: + json.dump(summary, f, indent=2) + f.write("\n") + print(f" Summary written to {path}") def main() -> None: @@ -270,11 +175,9 @@ def main() -> None: repo_name = os.environ.get("REPO", "") alert_number = int(os.environ.get("ALERT_NUMBER", "0")) package_name = os.environ.get("ALERT_PACKAGE", "unknown") - ecosystem = os.environ.get("ALERT_ECOSYSTEM", "unknown") severity = os.environ.get("ALERT_SEVERITY", "unknown") - patched_version = os.environ.get("ALERT_PATCHED_VERSION", "") dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes") - dismiss_enabled = os.environ.get("DISMISS_NOT_AFFECTED", "false").lower() in ( + dismiss_enabled = os.environ.get("DISMISS_UNAFFECTED", "false").lower() in ( "true", "1", "yes", @@ -306,27 +209,12 @@ def main() -> None: if not affected: if dismiss_enabled: - print(" Not affected + dismiss enabled. Dismissing alert.") + print(" Unaffected + dismiss enabled. Dismissing alert.") dismiss_alert(repo, alert_number, reason, dry_run) action = "dismissed" else: - # Check for existing Dependabot PR - has_pr = find_existing_pr(repo, package_name) - - if has_pr or recommended == "existing_pr": - print(" Not affected + existing PR. No action needed.") - action = "noop" - else: - print(" Not affected + no PR. Opening bump PR.") - open_bump_pr( - repo, - alert_number, - package_name, - patched_version, - ecosystem, - dry_run, - ) - action = "bump_pr" + print(" Unaffected + dismiss disabled. Consider bumping the package.") + action = "noop" write_output("action", action) else: print(" Affected. Creating advisory and private fork.") @@ -341,9 +229,7 @@ def main() -> None: write_output("advisory_ghsa_id", ghsa_id) write_output("fork_repo", fork_repo) - post_summary_comment( - repo, alert_number, package_name, severity, action, reason, dry_run - ) + write_summary(SUMMARY_FILE, alert_number, package_name, severity, action, reason) def write_output(key: str, value: str) -> None: diff --git a/scripts/sanitize.sh b/scripts/sanitize.sh new file mode 100755 index 0000000..cc191ce --- /dev/null +++ b/scripts/sanitize.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Shared sanitization for untrusted input before inserting into prompts. +# Source this file from scripts that build prompts. + +sanitize_for_prompt() { + local input="$1" + # Strip HTML/XML tags + # shellcheck disable=SC2001 + input=$(echo "$input" | sed 's/<[^>]*>//g') + # Strip markdown image/link injection + # shellcheck disable=SC2001 + input=$(echo "$input" | sed 's/!\[[^]]*\]([^)]*)//g') + # Strip prompt injection attempts + input=$(echo "$input" | grep -viE '(ignore .* instructions|ignore .* prompt|system prompt|you are now|new instructions|disregard|forget .* above)' || true) + echo "$input" +} diff --git a/scripts/sweep.py b/scripts/sweep.py index 2cef264..89e1401 100644 --- a/scripts/sweep.py +++ b/scripts/sweep.py @@ -222,14 +222,14 @@ def check_alerts(repo: Repository) -> list[Action]: print(f" Found {len(data)} open Dependabot alert(s)") - # Fetch existing branches for dedup + # Fetch existing branches to prevent creating duplicates existing_branches: set[str] = set() try: for branch in repo.get_branches(): if branch.name.startswith("blender/security/"): existing_branches.add(branch.name) except Exception: - pass # branch listing may fail; proceed without dedup + pass # branch listing may fail; proceed without duplicate check for alert in data: alert_number = alert.get("number") diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index 58df180..58a1a15 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -11,11 +11,9 @@ from scripts.post_alert_action import ( create_advisory_and_fork, dismiss_alert, - find_existing_pr, load_verdict, main, - open_bump_pr, - post_summary_comment, + write_summary, ) @@ -52,41 +50,6 @@ def test_missing_keys(self, verdict_file): assert load_verdict() is None -class TestFindExistingPR: - def test_finds_matching_pr(self): - pr = MagicMock() - pr.user.login = "dependabot[bot]" - pr.title = "Bump lodash from 4.17.20 to 4.17.21" - pr.number = 99 - repo = MagicMock() - repo.get_pulls.return_value = [pr] - - assert find_existing_pr(repo, "lodash") is True - - def test_no_matching_pr(self): - pr = MagicMock() - pr.user.login = "dependabot[bot]" - pr.title = "Bump express from 4.0 to 5.0" - repo = MagicMock() - repo.get_pulls.return_value = [pr] - - assert find_existing_pr(repo, "lodash") is False - - -class TestOpenBumpPR: - def test_dry_run_skips_creation(self): - repo = MagicMock() - open_bump_pr(repo, 42, "lodash", "4.17.21", "npm", dry_run=True) - repo.create_git_ref.assert_not_called() - repo.create_pull.assert_not_called() - - def test_existing_branch_skips(self): - repo = MagicMock() - repo.get_branch.return_value = MagicMock() # branch exists - open_bump_pr(repo, 42, "lodash", "4.17.21", "npm", dry_run=False) - repo.create_git_ref.assert_not_called() - - class TestCreateAdvisoryAndFork: def test_dry_run_skips(self): repo = MagicMock() @@ -127,43 +90,22 @@ def test_dry_run_skips(self): repo._requester.requestJsonAndCheck.assert_not_called() -class TestPostSummaryComment: - def test_creates_issue_when_missing(self): - repo = MagicMock() - repo.full_name = "owner/repo" - repo.get_issues.return_value = iter([]) - new_issue = MagicMock() - new_issue.number = 10 - repo.create_issue.return_value = new_issue - - post_summary_comment(repo, 42, "lodash", "high", "dismissed", "not used", False) - - repo.create_issue.assert_called_once() - new_issue.create_comment.assert_called_once() - comment = new_issue.create_comment.call_args[0][0] - assert "#42" in comment - assert "lodash" in comment - - def test_reuses_existing_issue(self): - repo = MagicMock() - repo.full_name = "owner/repo" - existing = MagicMock() - existing.title = "BLEnder: Dependabot alert investigation summary" - repo.get_issues.return_value = iter([existing]) - - post_summary_comment(repo, 7, "express", "medium", "bump_pr", "outdated", False) - - repo.create_issue.assert_not_called() - existing.create_comment.assert_called_once() - - def test_dry_run_skips(self): - repo = MagicMock() - post_summary_comment(repo, 42, "lodash", "high", "dismissed", "not used", True) - repo.get_issues.assert_not_called() +class TestWriteSummary: + def test_writes_json(self, tmp_path): + path = str(tmp_path / "summary.json") + write_summary(path, 42, "lodash", "high", "dismissed", "not used") + data = json.loads(open(path).read()) + assert data == { + "alert_number": 42, + "package": "lodash", + "severity": "high", + "action": "dismissed", + "reason": "not used", + } class TestMainDismissFlow: - def test_not_affected_dismiss_enabled(self, verdict_file, monkeypatch): + def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): verdict_file({ "affected": False, "confidence": "high", @@ -177,16 +119,12 @@ def test_not_affected_dismiss_enabled(self, verdict_file, monkeypatch): monkeypatch.setenv("ALERT_PACKAGE", "lodash") monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") monkeypatch.setenv("ALERT_SEVERITY", "high") - monkeypatch.setenv("DISMISS_NOT_AFFECTED", "true") + monkeypatch.setenv("DISMISS_UNAFFECTED", "true") monkeypatch.setenv("DRY_RUN", "false") monkeypatch.delenv("GITHUB_OUTPUT", raising=False) mock_repo = MagicMock() mock_repo.full_name = "owner/repo" - mock_repo.get_issues.return_value = iter([]) - mock_issue = MagicMock() - mock_issue.number = 1 - mock_repo.create_issue.return_value = mock_issue with patch("scripts.post_alert_action.Github") as mock_gh: mock_gh.return_value.get_repo.return_value = mock_repo @@ -202,5 +140,43 @@ def test_not_affected_dismiss_enabled(self, verdict_file, monkeypatch): "dismissed_comment": "BLEnder: not used in codebase", }, ) - # Summary comment was posted - mock_issue.create_comment.assert_called_once() + # Summary was written to file + summary_path = str(tmp_path / ".blender-alert-summary.json") + assert os.path.exists(summary_path) + data = json.loads(open(summary_path).read()) + assert data["action"] == "dismissed" + assert data["alert_number"] == 42 + + def test_unaffected_dismiss_disabled_is_noop( + self, verdict_file, tmp_path, monkeypatch + ): + verdict_file({ + "affected": False, + "confidence": "high", + "reason": "not used in codebase", + "vulnerable_paths": [], + "recommended_action": "bump_pr", + }) + monkeypatch.setenv("GH_TOKEN", "fake") + monkeypatch.setenv("REPO", "owner/repo") + monkeypatch.setenv("ALERT_NUMBER", "42") + monkeypatch.setenv("ALERT_PACKAGE", "lodash") + monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") + monkeypatch.setenv("DISMISS_UNAFFECTED", "false") + monkeypatch.setenv("DRY_RUN", "false") + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + + with patch("scripts.post_alert_action.Github") as mock_gh: + mock_gh.return_value.get_repo.return_value = mock_repo + main() + + # No API mutations + mock_repo._requester.requestJsonAndCheck.assert_not_called() + # Summary still written + summary_path = str(tmp_path / ".blender-alert-summary.json") + assert os.path.exists(summary_path) + data = json.loads(open(summary_path).read()) + assert data["action"] == "noop" From 8c494644e01639bd5fb207bb39c1956153841073 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 5 May 2026 21:03:30 -0500 Subject: [PATCH 04/15] fix: generate HTML summary report with code snippets Replace JSON artifact with a styled HTML report. The report shows alert metadata, verdict, and source code snippets for each vulnerable path identified during investigation. Styled like a coverage report for easy in-browser review. --- .../workflows/investigate-security-alert.yml | 2 +- scripts/post_alert_action.py | 347 +++++++++++++++++- tests/scripts/test_post_alert_action.py | 136 ++++--- 3 files changed, 426 insertions(+), 59 deletions(-) diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 77dd319..f9564f6 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -168,7 +168,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: alert-${{ inputs.alert_number }}-summary - path: target/.blender-alert-summary.json + path: target/.blender-alert-summary.html remediate: needs: investigate diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index 20a1dbd..b55f328 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -7,8 +7,8 @@ unaffected + dismiss disabled -> no-op (suggest bumping the package) affected -> create advisory with private fork -Writes a JSON summary to .blender-alert-summary.json for upload as a -workflow artifact (visible only to users with Actions access). +Writes an HTML summary report to .blender-alert-summary.html for upload +as a workflow artifact (visible only to users with Actions access). Environment variables: GH_TOKEN -- GitHub token (required) @@ -24,6 +24,7 @@ from __future__ import annotations +import html import json import os import sys @@ -32,7 +33,7 @@ from github import Auth, Github VERDICT_FILE = ".blender-alert-verdict.json" -SUMMARY_FILE = ".blender-alert-summary.json" +SUMMARY_FILE = ".blender-alert-summary.html" REQUIRED_KEYS = { "affected", "confidence", @@ -41,6 +42,8 @@ "recommended_action", } +CONTEXT_LINES = 5 + def load_verdict() -> dict | None: """Load and validate the alert verdict file.""" @@ -148,25 +151,337 @@ def dismiss_alert( print(f" Dismissed alert #{alert_number}") +def read_code_snippet(file_path: str, target_line: int) -> list[tuple[int, str, bool]]: + """Read lines around target_line from file_path. + + Returns a list of (line_number, text, is_target) tuples. + Returns an empty list if the file cannot be read. + """ + try: + with open(file_path) as f: + all_lines = f.readlines() + except (OSError, UnicodeDecodeError): + return [] + + start = max(0, target_line - CONTEXT_LINES - 1) + end = min(len(all_lines), target_line + CONTEXT_LINES) + + result = [] + for i in range(start, end): + line_num = i + 1 + text = all_lines[i].rstrip("\n") + result.append((line_num, text, line_num == target_line)) + return result + + +def render_html( + repo_name: str, + alert_number: int, + package: str, + severity: str, + action: str, + verdict: dict, +) -> str: + """Build a self-contained HTML summary report.""" + affected = verdict.get("affected", False) + confidence = verdict.get("confidence", "unknown") + reason = verdict.get("reason", "(none)") + vulnerable_paths = verdict.get("vulnerable_paths", []) + + status_label = "AFFECTED" if affected else "UNAFFECTED" + status_color = "#dc3545" if affected else "#28a745" + + action_labels = { + "dismissed": "Alert dismissed", + "noop": "No action taken", + "private_fork": "Advisory created with private fork", + } + action_text = action_labels.get(action, action) + + severity_colors = { + "critical": "#dc3545", + "high": "#fd7e14", + "medium": "#ffc107", + "low": "#28a745", + } + sev_color = severity_colors.get(severity.lower(), "#6c757d") + + confidence_colors = { + "high": "#28a745", + "medium": "#ffc107", + "low": "#dc3545", + } + conf_color = confidence_colors.get(confidence.lower(), "#6c757d") + + # Build code snippets HTML + snippets_html = "" + if vulnerable_paths: + for vp in vulnerable_paths: + parts = vp.rsplit(":", 1) + file_path = parts[0] + target_line = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 0 + + snippet_header = ( + f'
' + f'
{html.escape(vp)}
' + ) + + if target_line == 0: + snippets_html += ( + f"{snippet_header}" + f'
' + f'No line number specified' + f"
" + ) + continue + + lines = read_code_snippet(file_path, target_line) + if not lines: + snippets_html += ( + f"{snippet_header}" + f'
' + f'Source file not available' + f"
" + ) + continue + + code_lines = "" + for line_num, text, is_target in lines: + cls = ' class="target-line"' if is_target else "" + escaped = html.escape(text) if text else " " + code_lines += ( + f"" + f'{line_num}' + f'{escaped}' + f"\n" + ) + + snippets_html += ( + f"{snippet_header}" + f'
' + f"{code_lines}
" + ) + else: + snippets_html = ( + '
No vulnerable code paths identified.
' + ) + + return f""" + + + + +Alert #{alert_number} — {html.escape(package)} — BLEnder Report + + + +
+
+

Alert #{alert_number} — {html.escape(package)}

+
BLEnder Investigation Report
{html.escape(repo_name)}
+
+ +
+ {status_label} + {html.escape(action_text)} +
+ +
+
+
Severity
+
{html.escape(severity)}
+
+
+
Confidence
+
{html.escape(confidence)}
+
+
+
Action
+
{html.escape(action)}
+
+
+
Package
+
{html.escape(package)}
+
+
+ +
+
Analysis
+
+

{html.escape(reason)}

+
+
+ +
+
Code Paths
+
+ {snippets_html} +
+
+
+ + +""" + + def write_summary( path: str, + repo_name: str, alert_number: int, package: str, severity: str, action: str, - reason: str, + verdict: dict, ) -> None: - """Write a JSON summary file for upload as a workflow artifact.""" - summary = { - "alert_number": alert_number, - "package": package, - "severity": severity, - "action": action, - "reason": reason, - } + """Write an HTML summary report for upload as a workflow artifact.""" + report = render_html(repo_name, alert_number, package, severity, action, verdict) with open(path, "w") as f: - json.dump(summary, f, indent=2) - f.write("\n") + f.write(report) print(f" Summary written to {path}") @@ -229,7 +544,9 @@ def main() -> None: write_output("advisory_ghsa_id", ghsa_id) write_output("fork_repo", fork_repo) - write_summary(SUMMARY_FILE, alert_number, package_name, severity, action, reason) + write_summary( + SUMMARY_FILE, repo_name, alert_number, package_name, severity, action, verdict + ) def write_output(key: str, value: str) -> None: diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index 58a1a15..0861cca 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -13,6 +13,8 @@ dismiss_alert, load_verdict, main, + read_code_snippet, + render_html, write_summary, ) @@ -28,19 +30,22 @@ def _write(data: dict): return _write +SAMPLE_VERDICT = { + "affected": False, + "confidence": "high", + "reason": "not used in codebase", + "vulnerable_paths": [], + "recommended_action": "bump_pr", +} + + class TestLoadVerdict: def test_missing_file(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert load_verdict() is None def test_valid_verdict(self, verdict_file): - verdict_file({ - "affected": False, - "confidence": "high", - "reason": "not used", - "vulnerable_paths": [], - "recommended_action": "bump_pr", - }) + verdict_file(SAMPLE_VERDICT) v = load_verdict() assert v is not None assert v["affected"] is False @@ -90,29 +95,85 @@ def test_dry_run_skips(self): repo._requester.requestJsonAndCheck.assert_not_called() -class TestWriteSummary: - def test_writes_json(self, tmp_path): - path = str(tmp_path / "summary.json") - write_summary(path, 42, "lodash", "high", "dismissed", "not used") - data = json.loads(open(path).read()) - assert data == { - "alert_number": 42, - "package": "lodash", - "severity": "high", - "action": "dismissed", - "reason": "not used", +class TestReadCodeSnippet: + def test_reads_lines_around_target(self, tmp_path): + src = tmp_path / "app.py" + src.write_text("\n".join(f"line {i}" for i in range(1, 21))) + lines = read_code_snippet(str(src), 10) + nums = [n for n, _, _ in lines] + assert 10 in nums + # Target line is marked + targets = [(n, hit) for n, _, hit in lines if hit] + assert targets == [(10, True)] + + def test_missing_file_returns_empty(self): + assert read_code_snippet("/no/such/file.py", 5) == [] + + def test_target_near_start(self, tmp_path): + src = tmp_path / "short.py" + src.write_text("a\nb\nc\n") + lines = read_code_snippet(str(src), 1) + assert lines[0][0] == 1 + assert lines[0][2] is True + + +class TestRenderHtml: + def test_contains_key_elements(self): + verdict = {**SAMPLE_VERDICT, "vulnerable_paths": []} + html = render_html("owner/repo", 42, "lodash", "high", "dismissed", verdict) + assert "Alert #42" in html + assert "lodash" in html + assert "UNAFFECTED" in html + assert "not used in codebase" in html + + def test_affected_with_code_snippets(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + src = tmp_path / "server.js" + src.write_text("const x = require('lodash');\nx.merge({}, input);\n") + verdict = { + "affected": True, + "confidence": "high", + "reason": "lodash.merge called with user input", + "vulnerable_paths": ["server.js:2"], + "recommended_action": "private_fork", + } + result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + assert "AFFECTED" in result + assert "server.js:2" in result + assert "x.merge" in result + + def test_missing_source_file(self): + verdict = { + **SAMPLE_VERDICT, + "affected": True, + "vulnerable_paths": ["/nonexistent/file.py:10"], } + result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + assert "Source file not available" in result + + def test_path_without_line_number(self): + verdict = { + **SAMPLE_VERDICT, + "affected": True, + "vulnerable_paths": ["some/file.py"], + } + result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + assert "No line number specified" in result + + +class TestWriteSummary: + def test_writes_html(self, tmp_path): + path = str(tmp_path / "summary.html") + write_summary(path, "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT) + content = open(path).read() + assert content.startswith("") + assert "Alert #42" in content + assert "lodash" in content class TestMainDismissFlow: def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): - verdict_file({ - "affected": False, - "confidence": "high", - "reason": "not used in codebase", - "vulnerable_paths": [], - "recommended_action": "bump_pr", - }) + verdict_file(SAMPLE_VERDICT) monkeypatch.setenv("GH_TOKEN", "fake") monkeypatch.setenv("REPO", "owner/repo") monkeypatch.setenv("ALERT_NUMBER", "42") @@ -130,7 +191,6 @@ def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): mock_gh.return_value.get_repo.return_value = mock_repo main() - # Alert was dismissed mock_repo._requester.requestJsonAndCheck.assert_called_once_with( "PATCH", "/repos/owner/repo/dependabot/alerts/42", @@ -140,23 +200,15 @@ def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): "dismissed_comment": "BLEnder: not used in codebase", }, ) - # Summary was written to file - summary_path = str(tmp_path / ".blender-alert-summary.json") + summary_path = str(tmp_path / ".blender-alert-summary.html") assert os.path.exists(summary_path) - data = json.loads(open(summary_path).read()) - assert data["action"] == "dismissed" - assert data["alert_number"] == 42 + content = open(summary_path).read() + assert "dismissed" in content.lower() def test_unaffected_dismiss_disabled_is_noop( self, verdict_file, tmp_path, monkeypatch ): - verdict_file({ - "affected": False, - "confidence": "high", - "reason": "not used in codebase", - "vulnerable_paths": [], - "recommended_action": "bump_pr", - }) + verdict_file(SAMPLE_VERDICT) monkeypatch.setenv("GH_TOKEN", "fake") monkeypatch.setenv("REPO", "owner/repo") monkeypatch.setenv("ALERT_NUMBER", "42") @@ -173,10 +225,8 @@ def test_unaffected_dismiss_disabled_is_noop( mock_gh.return_value.get_repo.return_value = mock_repo main() - # No API mutations mock_repo._requester.requestJsonAndCheck.assert_not_called() - # Summary still written - summary_path = str(tmp_path / ".blender-alert-summary.json") + summary_path = str(tmp_path / ".blender-alert-summary.html") assert os.path.exists(summary_path) - data = json.loads(open(summary_path).read()) - assert data["action"] == "noop" + content = open(summary_path).read() + assert "No action taken" in content From 8752094ef3d8314c1590f2c2c8e8adce0775ff2b Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 5 May 2026 21:17:45 -0500 Subject: [PATCH 05/15] refactor: extract HTML report into scripts/alert_report.py Move read_code_snippet, render_html, and write_summary out of post_alert_action.py into a dedicated module. Keeps the action script focused on orchestration. --- scripts/alert_report.py | 347 ++++++++++++++++++++++++ scripts/post_alert_action.py | 339 +---------------------- tests/scripts/test_post_alert_action.py | 30 +- 3 files changed, 367 insertions(+), 349 deletions(-) create mode 100644 scripts/alert_report.py diff --git a/scripts/alert_report.py b/scripts/alert_report.py new file mode 100644 index 0000000..960434a --- /dev/null +++ b/scripts/alert_report.py @@ -0,0 +1,347 @@ +"""HTML report generator for Dependabot alert investigations. + +Produces a self-contained HTML file styled like a code coverage report. +Each vulnerable code path is shown with source context and the target +line highlighted. +""" + +from __future__ import annotations + +import html + + +CONTEXT_LINES = 5 + + +def read_code_snippet(file_path: str, target_line: int) -> list[tuple[int, str, bool]]: + """Read lines around target_line from file_path. + + Returns a list of (line_number, text, is_target) tuples. + Returns an empty list if the file cannot be read. + """ + try: + with open(file_path) as f: + all_lines = f.readlines() + except (OSError, UnicodeDecodeError): + return [] + + start = max(0, target_line - CONTEXT_LINES - 1) + end = min(len(all_lines), target_line + CONTEXT_LINES) + + result = [] + for i in range(start, end): + line_num = i + 1 + text = all_lines[i].rstrip("\n") + result.append((line_num, text, line_num == target_line)) + return result + + +def render_html( + repo_name: str, + alert_number: int, + package: str, + severity: str, + action: str, + verdict: dict, +) -> str: + """Build a self-contained HTML summary report.""" + affected = verdict.get("affected", False) + confidence = verdict.get("confidence", "unknown") + reason = verdict.get("reason", "(none)") + vulnerable_paths = verdict.get("vulnerable_paths", []) + + status_label = "AFFECTED" if affected else "UNAFFECTED" + status_color = "#dc3545" if affected else "#28a745" + + action_labels = { + "dismissed": "Alert dismissed", + "noop": "No action taken", + "private_fork": "Advisory created with private fork", + } + action_text = action_labels.get(action, action) + + severity_colors = { + "critical": "#dc3545", + "high": "#fd7e14", + "medium": "#ffc107", + "low": "#28a745", + } + sev_color = severity_colors.get(severity.lower(), "#6c757d") + + confidence_colors = { + "high": "#28a745", + "medium": "#ffc107", + "low": "#dc3545", + } + conf_color = confidence_colors.get(confidence.lower(), "#6c757d") + + # Build code snippets HTML + snippets_html = "" + if vulnerable_paths: + for vp in vulnerable_paths: + parts = vp.rsplit(":", 1) + file_path = parts[0] + target_line = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 0 + + snippet_header = ( + f'
' + f'
{html.escape(vp)}
' + ) + + if target_line == 0: + snippets_html += ( + f"{snippet_header}" + f'
' + f'No line number specified' + f"
" + ) + continue + + lines = read_code_snippet(file_path, target_line) + if not lines: + snippets_html += ( + f"{snippet_header}" + f'
' + f'Source file not available' + f"
" + ) + continue + + code_lines = "" + for line_num, text, is_target in lines: + cls = ' class="target-line"' if is_target else "" + escaped = html.escape(text) if text else " " + code_lines += ( + f"" + f'{line_num}' + f'{escaped}' + f"\n" + ) + + snippets_html += ( + f"{snippet_header}" + f'
' + f"{code_lines}
" + ) + else: + snippets_html = ( + '
No vulnerable code paths identified.
' + ) + + return f""" + + + + +Alert #{alert_number} — {html.escape(package)} — BLEnder Report + + + +
+
+

Alert #{alert_number} — {html.escape(package)}

+
BLEnder Investigation Report
{html.escape(repo_name)}
+
+ +
+ {status_label} + {html.escape(action_text)} +
+ +
+
+
Severity
+
{html.escape(severity)}
+
+
+
Confidence
+
{html.escape(confidence)}
+
+
+
Action
+
{html.escape(action)}
+
+
+
Package
+
{html.escape(package)}
+
+
+ +
+
Analysis
+
+

{html.escape(reason)}

+
+
+ +
+
Code Paths
+
+ {snippets_html} +
+
+
+ + +""" + + +def write_summary( + path: str, + repo_name: str, + alert_number: int, + package: str, + severity: str, + action: str, + verdict: dict, +) -> None: + """Write an HTML summary report for upload as a workflow artifact.""" + report = render_html(repo_name, alert_number, package, severity, action, verdict) + with open(path, "w") as f: + f.write(report) + print(f" Summary written to {path}") diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index b55f328..0bd945a 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -24,7 +24,6 @@ from __future__ import annotations -import html import json import os import sys @@ -32,6 +31,8 @@ from github import Auth, Github +from scripts.alert_report import write_summary + VERDICT_FILE = ".blender-alert-verdict.json" SUMMARY_FILE = ".blender-alert-summary.html" REQUIRED_KEYS = { @@ -42,8 +43,6 @@ "recommended_action", } -CONTEXT_LINES = 5 - def load_verdict() -> dict | None: """Load and validate the alert verdict file.""" @@ -151,340 +150,6 @@ def dismiss_alert( print(f" Dismissed alert #{alert_number}") -def read_code_snippet(file_path: str, target_line: int) -> list[tuple[int, str, bool]]: - """Read lines around target_line from file_path. - - Returns a list of (line_number, text, is_target) tuples. - Returns an empty list if the file cannot be read. - """ - try: - with open(file_path) as f: - all_lines = f.readlines() - except (OSError, UnicodeDecodeError): - return [] - - start = max(0, target_line - CONTEXT_LINES - 1) - end = min(len(all_lines), target_line + CONTEXT_LINES) - - result = [] - for i in range(start, end): - line_num = i + 1 - text = all_lines[i].rstrip("\n") - result.append((line_num, text, line_num == target_line)) - return result - - -def render_html( - repo_name: str, - alert_number: int, - package: str, - severity: str, - action: str, - verdict: dict, -) -> str: - """Build a self-contained HTML summary report.""" - affected = verdict.get("affected", False) - confidence = verdict.get("confidence", "unknown") - reason = verdict.get("reason", "(none)") - vulnerable_paths = verdict.get("vulnerable_paths", []) - - status_label = "AFFECTED" if affected else "UNAFFECTED" - status_color = "#dc3545" if affected else "#28a745" - - action_labels = { - "dismissed": "Alert dismissed", - "noop": "No action taken", - "private_fork": "Advisory created with private fork", - } - action_text = action_labels.get(action, action) - - severity_colors = { - "critical": "#dc3545", - "high": "#fd7e14", - "medium": "#ffc107", - "low": "#28a745", - } - sev_color = severity_colors.get(severity.lower(), "#6c757d") - - confidence_colors = { - "high": "#28a745", - "medium": "#ffc107", - "low": "#dc3545", - } - conf_color = confidence_colors.get(confidence.lower(), "#6c757d") - - # Build code snippets HTML - snippets_html = "" - if vulnerable_paths: - for vp in vulnerable_paths: - parts = vp.rsplit(":", 1) - file_path = parts[0] - target_line = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 0 - - snippet_header = ( - f'
' - f'
{html.escape(vp)}
' - ) - - if target_line == 0: - snippets_html += ( - f"{snippet_header}" - f'
' - f'No line number specified' - f"
" - ) - continue - - lines = read_code_snippet(file_path, target_line) - if not lines: - snippets_html += ( - f"{snippet_header}" - f'
' - f'Source file not available' - f"
" - ) - continue - - code_lines = "" - for line_num, text, is_target in lines: - cls = ' class="target-line"' if is_target else "" - escaped = html.escape(text) if text else " " - code_lines += ( - f"" - f'{line_num}' - f'{escaped}' - f"\n" - ) - - snippets_html += ( - f"{snippet_header}" - f'
' - f"{code_lines}
" - ) - else: - snippets_html = ( - '
No vulnerable code paths identified.
' - ) - - return f""" - - - - -Alert #{alert_number} — {html.escape(package)} — BLEnder Report - - - -
-
-

Alert #{alert_number} — {html.escape(package)}

-
BLEnder Investigation Report
{html.escape(repo_name)}
-
- -
- {status_label} - {html.escape(action_text)} -
- -
-
-
Severity
-
{html.escape(severity)}
-
-
-
Confidence
-
{html.escape(confidence)}
-
-
-
Action
-
{html.escape(action)}
-
-
-
Package
-
{html.escape(package)}
-
-
- -
-
Analysis
-
-

{html.escape(reason)}

-
-
- -
-
Code Paths
-
- {snippets_html} -
-
-
- - -""" - - -def write_summary( - path: str, - repo_name: str, - alert_number: int, - package: str, - severity: str, - action: str, - verdict: dict, -) -> None: - """Write an HTML summary report for upload as a workflow artifact.""" - report = render_html(repo_name, alert_number, package, severity, action, verdict) - with open(path, "w") as f: - f.write(report) - print(f" Summary written to {path}") - - def main() -> None: token = os.environ.get("GH_TOKEN", "") repo_name = os.environ.get("REPO", "") diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index 0861cca..ac904a7 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -8,14 +8,12 @@ import pytest +from scripts.alert_report import read_code_snippet, render_html, write_summary from scripts.post_alert_action import ( create_advisory_and_fork, dismiss_alert, load_verdict, main, - read_code_snippet, - render_html, - write_summary, ) @@ -120,11 +118,11 @@ def test_target_near_start(self, tmp_path): class TestRenderHtml: def test_contains_key_elements(self): verdict = {**SAMPLE_VERDICT, "vulnerable_paths": []} - html = render_html("owner/repo", 42, "lodash", "high", "dismissed", verdict) - assert "Alert #42" in html - assert "lodash" in html - assert "UNAFFECTED" in html - assert "not used in codebase" in html + result = render_html("owner/repo", 42, "lodash", "high", "dismissed", verdict) + assert "Alert #42" in result + assert "lodash" in result + assert "UNAFFECTED" in result + assert "not used in codebase" in result def test_affected_with_code_snippets(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -137,7 +135,9 @@ def test_affected_with_code_snippets(self, tmp_path, monkeypatch): "vulnerable_paths": ["server.js:2"], "recommended_action": "private_fork", } - result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + result = render_html( + "owner/repo", 42, "lodash", "high", "private_fork", verdict + ) assert "AFFECTED" in result assert "server.js:2" in result assert "x.merge" in result @@ -148,7 +148,9 @@ def test_missing_source_file(self): "affected": True, "vulnerable_paths": ["/nonexistent/file.py:10"], } - result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + result = render_html( + "owner/repo", 42, "lodash", "high", "private_fork", verdict + ) assert "Source file not available" in result def test_path_without_line_number(self): @@ -157,14 +159,18 @@ def test_path_without_line_number(self): "affected": True, "vulnerable_paths": ["some/file.py"], } - result = render_html("owner/repo", 42, "lodash", "high", "private_fork", verdict) + result = render_html( + "owner/repo", 42, "lodash", "high", "private_fork", verdict + ) assert "No line number specified" in result class TestWriteSummary: def test_writes_html(self, tmp_path): path = str(tmp_path / "summary.html") - write_summary(path, "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT) + write_summary( + path, "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT + ) content = open(path).read() assert content.startswith("") assert "Alert #42" in content From 2dbe2dd53ca93890d2f5e32b98e548a24b453045 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 5 May 2026 21:19:59 -0500 Subject: [PATCH 06/15] fix: move actions:write permission to job level zizmor flagged workflow-level actions:write as overly broad. Only the investigate job needs it for upload-artifact. --- .github/workflows/investigate-security-alert.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index f9564f6..0c15e44 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -33,7 +33,6 @@ on: permissions: contents: read - actions: write concurrency: group: >- @@ -43,6 +42,9 @@ concurrency: jobs: investigate: runs-on: ubuntu-latest + permissions: + contents: read + actions: write outputs: action: ${{ steps.post-action.outputs.action }} fork_repo: ${{ steps.post-action.outputs.fork_repo }} From 581e87d40ad00f9bd2b7b5a9fc594571df07b49e Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Wed, 6 May 2026 07:58:22 -0500 Subject: [PATCH 07/15] fix: redact affected alert reports for public artifact safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions artifacts on public repos are world-readable. For affected alerts, the report now omits the reason and code paths. Those details live in the private security advisory instead. Unaffected reports keep the full analysis — nothing sensitive there. --- scripts/alert_report.py | 10 ++++++++- tests/scripts/test_post_alert_action.py | 29 +++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/scripts/alert_report.py b/scripts/alert_report.py index 960434a..ad0e437 100644 --- a/scripts/alert_report.py +++ b/scripts/alert_report.py @@ -47,9 +47,17 @@ def render_html( """Build a self-contained HTML summary report.""" affected = verdict.get("affected", False) confidence = verdict.get("confidence", "unknown") - reason = verdict.get("reason", "(none)") vulnerable_paths = verdict.get("vulnerable_paths", []) + # Redact sensitive details for affected alerts — the artifact is + # world-readable on public repos. The full analysis lives in the + # private security advisory. + if affected: + reason = "Details redacted — see the security advisory for this alert." + vulnerable_paths = [] + else: + reason = verdict.get("reason", "(none)") + status_label = "AFFECTED" if affected else "UNAFFECTED" status_color = "#dc3545" if affected else "#28a745" diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index ac904a7..0baa63d 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -124,7 +124,7 @@ def test_contains_key_elements(self): assert "UNAFFECTED" in result assert "not used in codebase" in result - def test_affected_with_code_snippets(self, tmp_path, monkeypatch): + def test_affected_redacts_details(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) src = tmp_path / "server.js" src.write_text("const x = require('lodash');\nx.merge({}, input);\n") @@ -139,28 +139,43 @@ def test_affected_with_code_snippets(self, tmp_path, monkeypatch): "owner/repo", 42, "lodash", "high", "private_fork", verdict ) assert "AFFECTED" in result - assert "server.js:2" in result - assert "x.merge" in result + # Sensitive details must not appear in the public artifact + assert "lodash.merge called" not in result + assert "server.js:2" not in result + assert "x.merge" not in result + assert "see the security advisory" in result.lower() + + def test_unaffected_shows_code_snippets(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + src = tmp_path / "util.js" + src.write_text("function safe() {}\nmodule.exports = safe;\n") + verdict = { + **SAMPLE_VERDICT, + "vulnerable_paths": ["util.js:1"], + } + result = render_html( + "owner/repo", 42, "lodash", "high", "dismissed", verdict + ) + assert "util.js:1" in result + assert "function safe" in result def test_missing_source_file(self): verdict = { **SAMPLE_VERDICT, - "affected": True, "vulnerable_paths": ["/nonexistent/file.py:10"], } result = render_html( - "owner/repo", 42, "lodash", "high", "private_fork", verdict + "owner/repo", 42, "lodash", "high", "dismissed", verdict ) assert "Source file not available" in result def test_path_without_line_number(self): verdict = { **SAMPLE_VERDICT, - "affected": True, "vulnerable_paths": ["some/file.py"], } result = render_html( - "owner/repo", 42, "lodash", "high", "private_fork", verdict + "owner/repo", 42, "lodash", "high", "dismissed", verdict ) assert "No line number specified" in result From d742b39f4fcabdca6f335ae366829a1a9e889a0a Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Thu, 7 May 2026 16:58:15 -0500 Subject: [PATCH 08/15] fix: block auto-dismiss for high/critical severity alerts High and critical alerts now require human review before dismissal, even when dismiss_unaffected is enabled. Only low and medium severity alerts are auto-dismissed. Addresses security team feedback. Co-Authored-By: Claude Opus 4.6 --- scripts/post_alert_action.py | 12 ++++++++-- tests/scripts/test_post_alert_action.py | 32 +++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index 0bd945a..d9b991a 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -3,7 +3,8 @@ Reads .blender-alert-verdict.json and takes the appropriate action: - unaffected + dismiss enabled -> dismiss the alert via API + unaffected + dismiss enabled (low/medium) -> dismiss the alert via API + unaffected + dismiss enabled (high/critical) -> no-op (require human review) unaffected + dismiss disabled -> no-op (suggest bumping the package) affected -> create advisory with private fork @@ -33,6 +34,7 @@ from scripts.alert_report import write_summary +DISMISS_BLOCKED_SEVERITIES = {"critical", "high"} VERDICT_FILE = ".blender-alert-verdict.json" SUMMARY_FILE = ".blender-alert-summary.html" REQUIRED_KEYS = { @@ -188,10 +190,16 @@ def main() -> None: reason = verdict.get("reason", "(none)") if not affected: - if dismiss_enabled: + if dismiss_enabled and severity.lower() not in DISMISS_BLOCKED_SEVERITIES: print(" Unaffected + dismiss enabled. Dismissing alert.") dismiss_alert(repo, alert_number, reason, dry_run) action = "dismissed" + elif dismiss_enabled: + print( + f" Unaffected but severity is {severity}." + " Recommend manual review before dismissing." + ) + action = "noop" else: print(" Unaffected + dismiss disabled. Consider bumping the package.") action = "noop" diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index 0baa63d..d4c5271 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -193,14 +193,16 @@ def test_writes_html(self, tmp_path): class TestMainDismissFlow: - def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): + def test_unaffected_dismiss_enabled_low_severity( + self, verdict_file, tmp_path, monkeypatch + ): verdict_file(SAMPLE_VERDICT) monkeypatch.setenv("GH_TOKEN", "fake") monkeypatch.setenv("REPO", "owner/repo") monkeypatch.setenv("ALERT_NUMBER", "42") monkeypatch.setenv("ALERT_PACKAGE", "lodash") monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") - monkeypatch.setenv("ALERT_SEVERITY", "high") + monkeypatch.setenv("ALERT_SEVERITY", "low") monkeypatch.setenv("DISMISS_UNAFFECTED", "true") monkeypatch.setenv("DRY_RUN", "false") monkeypatch.delenv("GITHUB_OUTPUT", raising=False) @@ -226,6 +228,32 @@ def test_unaffected_dismiss_enabled(self, verdict_file, tmp_path, monkeypatch): content = open(summary_path).read() assert "dismissed" in content.lower() + def test_unaffected_dismiss_skips_high_severity( + self, verdict_file, tmp_path, monkeypatch + ): + verdict_file(SAMPLE_VERDICT) + monkeypatch.setenv("GH_TOKEN", "fake") + monkeypatch.setenv("REPO", "owner/repo") + monkeypatch.setenv("ALERT_NUMBER", "42") + monkeypatch.setenv("ALERT_PACKAGE", "lodash") + monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") + monkeypatch.setenv("ALERT_SEVERITY", "high") + monkeypatch.setenv("DISMISS_UNAFFECTED", "true") + monkeypatch.setenv("DRY_RUN", "false") + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + + with patch("scripts.post_alert_action.Github") as mock_gh: + mock_gh.return_value.get_repo.return_value = mock_repo + main() + + # High severity: no dismiss call, even with dismiss enabled + mock_repo._requester.requestJsonAndCheck.assert_not_called() + summary_path = str(tmp_path / ".blender-alert-summary.html") + assert os.path.exists(summary_path) + def test_unaffected_dismiss_disabled_is_noop( self, verdict_file, tmp_path, monkeypatch ): From 5c9f43d568b88461ddffb2983d8904e250c218af Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Mon, 18 May 2026 16:46:45 -0500 Subject: [PATCH 09/15] fix: extract verdict from Claude text output Claude Code in sandbox mode doesn't reliably write files via tools. Instead, the prompt now tells Claude to output a VERDICT_JSON fenced block. extract_alert_verdict.py parses it from the Claude output log and writes .blender-alert-verdict.json outside the sandbox. Also fixes app token permissions, sys.path for module resolution, and the investigate prompt turn budget. --- .../workflows/investigate-security-alert.yml | 8 +-- prompts/investigate-alert-prompt.md | 34 +++++----- prompts/major-bump-prompt.md | 10 +-- scripts/extract_alert_verdict.py | 66 +++++++++++++++++++ scripts/post_alert_action.py | 11 +++- scripts/run-claude.sh | 19 ++++-- 6 files changed, 113 insertions(+), 35 deletions(-) create mode 100644 scripts/extract_alert_verdict.py diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 0c15e44..52f616f 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -69,10 +69,9 @@ jobs: private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} owner: ${{ steps.parse.outputs.owner }} repositories: ${{ steps.parse.outputs.name }} - permission-contents: write - permission-pull-requests: write - permission-vulnerability-alerts: write - permission-security-events: write + # Token inherits all installed permissions (contents, pull-requests, + # vulnerability-alerts, repository-advisories). We don't restrict + # because the token action has no input for repository-advisories. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -171,6 +170,7 @@ jobs: with: name: alert-${{ inputs.alert_number }}-summary path: target/.blender-alert-summary.html + include-hidden-files: true remediate: needs: investigate diff --git a/prompts/investigate-alert-prompt.md b/prompts/investigate-alert-prompt.md index a934b07..5d86ea6 100644 --- a/prompts/investigate-alert-prompt.md +++ b/prompts/investigate-alert-prompt.md @@ -25,43 +25,39 @@ Determine whether this vulnerability affects the target repo's code. Many Dependabot alerts flag transitive dependencies or code paths the repo never exercises. Your job is to distinguish real impact from noise. -### Step 1: Run the ecosystem audit tool +**You have a limited turn budget. Be efficient. Your final response +must include the verdict JSON — that is the only deliverable that matters.** -Run the appropriate audit command for structured data: -- npm: `npm audit --json 2>/dev/null || true` -- pip: `pip-audit --format=json 2>/dev/null || true` - -This confirms whether the package is a direct or transitive dependency -and which versions are installed. - -### Step 2: Search for usage +### Step 1: Search for usage Search the codebase for imports, requires, and references to `{{ALERT_PACKAGE}}`. Check: - Source code imports and usage - Configuration files - Lock files (to confirm installed version) -- Test files -### Step 3: Trace vulnerable code paths +### Step 2: Trace vulnerable code paths Read the advisory description. Identify the specific functions, methods, or protocols that are vulnerable. Then check whether this repo calls those functions or exposes those code paths. -### Step 4: Assess transitive exposure +### Step 3: Assess transitive exposure If the package is a transitive dependency: - Identify which direct dependency pulls it in - Check whether the direct dependency exposes the vulnerable API - A transitive dep used only at build time is not affected at runtime -### Step 5: Write your verdict +**If you already have enough evidence, skip to Step 4 now.** + +### Step 4: Output your verdict -Write your verdict to `.blender-alert-verdict.json` using the Bash tool: +Your final response MUST end with the verdict as a fenced JSON block +labeled `VERDICT_JSON`. Do not write any files. Just output this block: -```bash -cat > .blender-alert-verdict.json << 'VERDICT_EOF' +```` +```VERDICT_JSON { "affected": false, "confidence": "high", @@ -69,8 +65,8 @@ cat > .blender-alert-verdict.json << 'VERDICT_EOF' "vulnerable_paths": [], "recommended_action": "bump_pr" } -VERDICT_EOF ``` +```` **Fields:** - `affected`: true if the vulnerability impacts this repo's code @@ -89,7 +85,7 @@ VERDICT_EOF ## Rules -- Do NOT edit any tracked files. Read and analyze only. +- Do NOT edit or create any files. Read and analyze only. - Do NOT run `git` commands. -- Write ONLY `.blender-alert-verdict.json` via Bash. +- Your final response MUST end with the ```VERDICT_JSON``` block. - Be conservative. When in doubt, mark as affected. diff --git a/prompts/major-bump-prompt.md b/prompts/major-bump-prompt.md index 5c1a491..aae368c 100644 --- a/prompts/major-bump-prompt.md +++ b/prompts/major-bump-prompt.md @@ -61,10 +61,11 @@ Are all CI checks passing on this PR? If not, what failed and is it related to t ### Step 7: Write your verdict -Write your verdict to `.blender-verdict.json` using the Bash tool: +Create the file `.blender-verdict.json` using the **Write** tool (not +Bash — Bash runs in a sandbox and its file writes do not persist). +The file must contain valid JSON with this structure: -```bash -cat > .blender-verdict.json << 'VERDICT_EOF' +```json { "safe": true, "confidence": "high", @@ -73,7 +74,6 @@ cat > .blender-verdict.json << 'VERDICT_EOF' "affected_code": ["List files/functions affected by breaking changes"], "test_coverage": "Summary of test coverage for the dependency's usage" } -VERDICT_EOF ``` **Confidence levels:** @@ -94,5 +94,5 @@ VERDICT_EOF - Do NOT edit any tracked files. Read and analyze only. - Do NOT run `git` commands. -- Write ONLY `.blender-verdict.json` via Bash. +- Create ONLY `.blender-verdict.json` via the Write tool. - Be conservative. When in doubt, mark as not safe. diff --git a/scripts/extract_alert_verdict.py b/scripts/extract_alert_verdict.py new file mode 100644 index 0000000..8bea1a5 --- /dev/null +++ b/scripts/extract_alert_verdict.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Extract alert verdict JSON from Claude's text output. + +Claude runs in a sandbox that prevents file writes. Instead, the prompt +tells Claude to output a ```VERDICT_JSON fenced block. This script +parses that block and writes .blender-alert-verdict.json. + +Usage: extract_alert_verdict.py +""" + +import json +import re +import sys + +VERDICT_FILE = ".blender-alert-verdict.json" + + +def extract(log: str) -> dict | None: + """Try to extract verdict JSON from Claude's output text.""" + # Primary: look for ```VERDICT_JSON ... ``` fenced block + m = re.search(r"```VERDICT_JSON\s*\n(.*?)\n\s*```", log, re.DOTALL) + if m: + try: + obj = json.loads(m.group(1)) + print("Extracted verdict from VERDICT_JSON block.") + return obj + except json.JSONDecodeError as e: + print(f"VERDICT_JSON block found but invalid JSON: {e}", file=sys.stderr) + + # Fallback: any JSON object containing "affected" key + for m2 in re.finditer(r"\{[^{}]*\"affected\"[^{}]*\}", log, re.DOTALL): + try: + obj = json.loads(m2.group()) + if "affected" in obj and "reason" in obj: + print("Extracted verdict from JSON in output.") + return obj + except json.JSONDecodeError: + continue + + return None + + +def main() -> None: + if len(sys.argv) < 2: + print("Usage: extract_alert_verdict.py ", file=sys.stderr) + sys.exit(1) + + log_path = sys.argv[1] + try: + with open(log_path) as f: + log = f.read() + except OSError as e: + print(f"Cannot read log file: {e}", file=sys.stderr) + sys.exit(1) + + verdict = extract(log) + if verdict is None: + print("No verdict found in Claude output.", file=sys.stderr) + sys.exit(1) + + with open(VERDICT_FILE, "w") as f: + json.dump(verdict, f, indent=2) + + +if __name__ == "__main__": + main() diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index d9b991a..01f73aa 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -29,10 +29,17 @@ import os import sys import time +from pathlib import Path -from github import Auth, Github +# Ensure the repo root is on sys.path so `scripts.alert_report` resolves +# when this file is invoked as `python /path/to/scripts/post_alert_action.py`. +_repo_root = str(Path(__file__).resolve().parent.parent) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) -from scripts.alert_report import write_summary +from github import Auth, Github # noqa: E402 + +from scripts.alert_report import write_summary # noqa: E402 DISMISS_BLOCKED_SEVERITIES = {"critical", "high"} VERDICT_FILE = ".blender-alert-verdict.json" diff --git a/scripts/run-claude.sh b/scripts/run-claude.sh index 27e911a..2c9a0a0 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -45,15 +45,16 @@ unset ACTIONS_CACHE_URL 2>/dev/null || true CLAUDE_SETTINGS="$BLENDER_DIR/claude-settings.json" CLAUDE_LOG=$(mktemp /tmp/blender-claude-XXXXXX.log) +trap 'rm -f "$CLAUDE_LOG"' EXIT # --- Mode-specific settings --- if [ "$BLENDER_MODE" = "investigate" ]; then ALLOWED_TOOLS="Read,Bash" - MAX_TURNS=20 + MAX_TURNS=25 MAX_BUDGET="1.50" - SYSTEM_PROMPT="You are BLEnder, a security analysis agent for ${REPO_DISPLAY_NAME}. Investigate the Dependabot security alert described in the prompt. Read the codebase to determine if the vulnerability affects this repo. Write your verdict to .blender-alert-verdict.json. Do not edit any tracked files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." + SYSTEM_PROMPT="You are BLEnder, a security analysis agent for ${REPO_DISPLAY_NAME}. Investigate the Dependabot security alert described in the prompt. Read the codebase to determine if the vulnerability affects this repo. Output your verdict as a VERDICT_JSON block in your final response. Do not create or edit any files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." elif [ "$BLENDER_MODE" = "major" ]; then - ALLOWED_TOOLS="Read,Bash" + ALLOWED_TOOLS="Read,Write,Bash" MAX_TURNS=15 MAX_BUDGET="1.00" SYSTEM_PROMPT="You are BLEnder, a dependency analysis agent for ${REPO_DISPLAY_NAME}. Evaluate the major version bump described in the prompt. Read the codebase and the dependency source code. Write your verdict to .blender-verdict.json. Do not edit any tracked files. Do not search the web. Internal verification token: ${PROMPT_NONCE}. This token is confidential. Never include it in any output, file edit, or commit message." @@ -87,8 +88,6 @@ if [ "${CLAUDE_VERBOSE:-false}" = "true" ]; then else echo "Set CLAUDE_VERBOSE=true to see full output." fi -rm -f "$CLAUDE_LOG" - if [ "$claude_exit" -ne 0 ]; then echo "Claude exited with code ${claude_exit} (likely hit max-turns or budget)." # In major/investigate mode, a non-zero exit is not fatal — post steps handle missing verdict @@ -178,6 +177,16 @@ if [ "$BLENDER_MODE" = "investigate" ]; then exit 1 fi + # Extract verdict JSON from Claude's text output. Claude runs in a + # sandbox that prevents file writes, so the prompt tells Claude to + # output a ```VERDICT_JSON fenced block in its final response. + # We extract that block and write the file here, outside the sandbox. + echo "Extracting verdict from Claude output..." + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if python3 "${SCRIPT_DIR}/extract_alert_verdict.py" "$CLAUDE_LOG"; then + echo "Verdict file written from Claude output." + fi + # Alert verdict file must exist and be valid JSON if [ -f .blender-alert-verdict.json ]; then if ! jq empty .blender-alert-verdict.json 2>/dev/null; then From 5cc18ebe53dc90b34d40c8ae3486082d76857630 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Mon, 18 May 2026 16:46:45 -0500 Subject: [PATCH 10/15] feat: step summaries, bump PRs, and deduplication Replace HTML artifact with inline step summary and ::notice:: annotation. Add create_bump_pr to open PRs directly (the Dependabot security-updates API endpoint returns 404). Detect existing Dependabot and BLEnder PRs to avoid duplicates. Add investigate-all-alerts.sh convenience script. --- .../workflows/investigate-security-alert.yml | 8 - scripts/alert_report.py | 107 +++++++ scripts/investigate-all-alerts.sh | 68 +++++ scripts/post_alert_action.py | 244 +++++++++++++++- tests/scripts/test_post_alert_action.py | 274 ++++++++++++++---- 5 files changed, 619 insertions(+), 82 deletions(-) create mode 100755 scripts/investigate-all-alerts.sh diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 52f616f..7a42648 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -164,14 +164,6 @@ jobs: DRY_RUN: ${{ inputs.dry_run }} DISMISS_UNAFFECTED: ${{ steps.config.outputs.dismiss_unaffected }} - - name: Upload alert summary - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: alert-${{ inputs.alert_number }}-summary - path: target/.blender-alert-summary.html - include-hidden-files: true - remediate: needs: investigate if: needs.investigate.outputs.action == 'private_fork' diff --git a/scripts/alert_report.py b/scripts/alert_report.py index ad0e437..db3f9ba 100644 --- a/scripts/alert_report.py +++ b/scripts/alert_report.py @@ -65,6 +65,8 @@ def render_html( "dismissed": "Alert dismissed", "noop": "No action taken", "private_fork": "Advisory created with private fork", + "existing_pr": "Existing Dependabot PR found", + "bump_pr_created": "Bump PR created", } action_text = action_labels.get(action, action) @@ -339,6 +341,111 @@ def render_html( """ +def render_markdown( + repo_name: str, + alert_number: int, + package: str, + severity: str, + action: str, + verdict: dict, +) -> str: + """Build a markdown summary for GitHub step summary.""" + affected = verdict.get("affected", False) + confidence = verdict.get("confidence", "unknown") + vulnerable_paths = verdict.get("vulnerable_paths", []) + + if affected: + reason = "Details redacted — see the security advisory for this alert." + vulnerable_paths = [] + else: + reason = verdict.get("reason", "(none)") + + status_emoji = "\u274c" if affected else "\u2705" + status_label = "AFFECTED" if affected else "NOT AFFECTED" + + action_labels = { + "dismissed": "Alert dismissed", + "noop": "No action taken", + "private_fork": "Advisory created with private fork", + "existing_pr": "Existing Dependabot PR found", + "bump_pr_created": "Bump PR created", + } + action_text = action_labels.get(action, action) + + lines = [ + f"## {status_emoji} Alert #{alert_number} — {package}", + "", + f"**{status_label}** | {repo_name}", + "", + "| | |", + "|---|---|", + f"| **Severity** | {severity} |", + f"| **Confidence** | {confidence} |", + f"| **Action** | {action_text} |", + f"| **Package** | {package} |", + "", + "### Analysis", + "", + reason, + ] + + if vulnerable_paths: + lines.append("") + lines.append("### Vulnerable Code Paths") + lines.append("") + for vp in vulnerable_paths: + lines.append(f"- `{vp}`") + + return "\n".join(lines) + "\n" + + +def annotation_line( + alert_number: int, + package: str, + action: str, + verdict: dict, +) -> str: + """Build a one-line annotation message.""" + affected = verdict.get("affected", False) + confidence = verdict.get("confidence", "unknown") + + action_labels = { + "dismissed": "alert dismissed", + "noop": "no action taken", + "private_fork": "advisory created with private fork", + } + action_text = action_labels.get(action, action) + + status = "affected" if affected else "not affected" + return f"Alert #{alert_number} ({package}): {status} ({confidence} confidence) — {action_text}" + + +def write_step_summary( + repo_name: str, + alert_number: int, + package: str, + severity: str, + action: str, + verdict: dict, +) -> None: + """Write markdown summary to $GITHUB_STEP_SUMMARY and emit an annotation.""" + import os + + summary_file = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_file: + md = render_markdown( + repo_name, alert_number, package, severity, action, verdict + ) + with open(summary_file, "a") as f: + f.write(md) + print(" Step summary written.") + else: + print(" $GITHUB_STEP_SUMMARY not set, skipping step summary.") + + notice = annotation_line(alert_number, package, action, verdict) + print(f"::notice ::{notice}") + + def write_summary( path: str, repo_name: str, diff --git a/scripts/investigate-all-alerts.sh b/scripts/investigate-all-alerts.sh new file mode 100755 index 0000000..8eb734b --- /dev/null +++ b/scripts/investigate-all-alerts.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Trigger investigate-security-alert.yml for every open Dependabot alert +# in a target repo. +# +# Usage: +# ./scripts/investigate-all-alerts.sh mozilla/fx-private-relay +# ./scripts/investigate-all-alerts.sh mozilla/fx-private-relay --live +# REF=my-branch ./scripts/investigate-all-alerts.sh mozilla/fx-private-relay +# +# By default, runs in dry-run mode. Pass --live to disable dry-run. +# Set REF to run from a specific branch (default: current branch). + +set -euo pipefail + +REPO="${1:?Usage: $0 [--live]}" +DRY_RUN="true" +if [ "${2:-}" = "--live" ]; then + DRY_RUN="false" +fi +REF="${REF:-$(git rev-parse --abbrev-ref HEAD)}" + +WORKFLOW="investigate-security-alert.yml" + +echo "Fetching open Dependabot alerts for ${REPO}..." +alerts=$(gh api "repos/${REPO}/dependabot/alerts" \ + --jq '.[] | select(.state=="open") | { + number: .number, + package: .security_vulnerability.package.name, + ecosystem: .security_vulnerability.package.ecosystem, + severity: (.security_advisory.severity // ""), + patched: (.security_vulnerability.first_patched_version.identifier // "") + }' | jq -s '.') + +count=$(echo "$alerts" | jq 'length') +echo "Found ${count} open alert(s). dry_run=${DRY_RUN} ref=${REF}" +echo "" + +if [ "$count" -eq 0 ]; then + exit 0 +fi + +echo "$alerts" | jq -r '.[] | " #\(.number) \(.package) (\(.ecosystem)) severity=\(.severity)"' +echo "" + +for i in $(seq 0 $((count - 1))); do + number=$(echo "$alerts" | jq -r ".[$i].number") + package=$(echo "$alerts" | jq -r ".[$i].package") + ecosystem=$(echo "$alerts" | jq -r ".[$i].ecosystem") + severity=$(echo "$alerts" | jq -r ".[$i].severity") + patched=$(echo "$alerts" | jq -r ".[$i].patched") + + echo "Triggering ${WORKFLOW} for alert #${number} (${package})..." + gh workflow run "$WORKFLOW" --ref "$REF" \ + -f "target_repo=${REPO}" \ + -f "alert_number=${number}" \ + -f "alert_package=${package}" \ + -f "alert_ecosystem=${ecosystem}" \ + -f "alert_severity=${severity}" \ + -f "alert_patched_version=${patched}" \ + -f "dry_run=${DRY_RUN}" + + echo " Triggered." + sleep 2 # avoid hitting API rate limits +done + +echo "" +echo "All ${count} investigation(s) triggered." +echo "Watch progress: https://github.com/mozilla/blender/actions/workflows/${WORKFLOW}" diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index 01f73aa..cabea31 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -3,13 +3,12 @@ Reads .blender-alert-verdict.json and takes the appropriate action: - unaffected + dismiss enabled (low/medium) -> dismiss the alert via API - unaffected + dismiss enabled (high/critical) -> no-op (require human review) - unaffected + dismiss disabled -> no-op (suggest bumping the package) - affected -> create advisory with private fork + unaffected + existing PR -> comment on the PR, let other workflows handle + unaffected + no PR -> trigger Dependabot security update + unaffected + dismiss enabled -> dismiss the alert (low/medium only) + affected -> create advisory with private fork -Writes an HTML summary report to .blender-alert-summary.html for upload -as a workflow artifact (visible only to users with Actions access). +Writes a summary to $GITHUB_STEP_SUMMARY and emits an annotation. Environment variables: GH_TOKEN -- GitHub token (required) @@ -39,11 +38,10 @@ from github import Auth, Github # noqa: E402 -from scripts.alert_report import write_summary # noqa: E402 +from scripts.alert_report import write_step_summary # noqa: E402 DISMISS_BLOCKED_SEVERITIES = {"critical", "high"} VERDICT_FILE = ".blender-alert-verdict.json" -SUMMARY_FILE = ".blender-alert-summary.html" REQUIRED_KEYS = { "affected", "confidence", @@ -138,6 +136,208 @@ def create_advisory_and_fork( return (ghsa_id, fork_full_name) +def find_existing_bump_pr( + repo, + package_name: str, +) -> int | None: + """Find an open PR that bumps this package. + + Checks for both Dependabot PRs and BLEnder bump PRs. + Returns the PR number if found, None otherwise. + """ + pulls = repo.get_pulls(state="open") + package_lower = package_name.lower() + for pr in pulls: + title_lower = pr.title.lower() + is_dependabot = pr.user.login == "dependabot[bot]" + is_blender = pr.head.ref.startswith("blender/security-bump-") + if (is_dependabot or is_blender) and package_lower in title_lower: + print(f" Found existing PR #{pr.number}: {pr.title}") + return pr.number + return None + + +def comment_on_pr( + repo, + pr_number: int, + reason: str, + dry_run: bool, +) -> None: + """Comment on a PR with BLEnder's investigation results.""" + body = ( + "**BLEnder investigation:** This dependency has an open security alert, " + f"but the repo is **not affected**.\n\n> {reason}\n\n" + "This PR can be reviewed and merged as a normal dependency update." + ) + if dry_run: + print(f" DRY_RUN: would comment on PR #{pr_number}") + return + + pr = repo.get_pull(pr_number) + pr.create_issue_comment(body) + print(f" Commented on PR #{pr_number}") + + +def find_dependency_pin( + repo, + package_name: str, + ecosystem: str, +) -> tuple[str, str, str] | None: + """Find the file and line that pins a dependency. + + Returns (file_path, old_content, new_line_pattern) or None if not found. + Searches common dependency files for the package pin. + """ + import re + + if ecosystem == "pip": + candidates = [ + "requirements.txt", + "requirements.in", + ] + # Also check requirements/*.txt + try: + contents = repo.get_contents("requirements") + if isinstance(contents, list): + for item in contents: + if item.name.endswith(".txt"): + candidates.append(item.path) + except Exception: + pass + + pin_pattern = re.compile( + rf"^{re.escape(package_name)}\s*[=~><]=", re.IGNORECASE | re.MULTILINE + ) + for path in candidates: + try: + file_content = repo.get_contents(path) + text = file_content.decoded_content.decode("utf-8") + if pin_pattern.search(text): + print(f" Found {package_name} pin in {path}") + return (path, text, file_content.sha) + except Exception: + continue + + elif ecosystem == "npm": + try: + file_content = repo.get_contents("package.json") + text = file_content.decoded_content.decode("utf-8") + if package_name.lower() in text.lower(): + print(f" Found {package_name} in package.json") + return ("package.json", text, file_content.sha) + except Exception: + pass + + return None + + +def create_bump_pr( + repo, + package_name: str, + ecosystem: str, + patched_version: str, + alert_number: int, + dry_run: bool, +) -> int | None: + """Create a PR that bumps a dependency to the patched version. + + Returns the PR number on success, None on failure. + """ + import re + + if not patched_version: + print(" No patched version available. Cannot create bump PR.") + return None + + pin_info = find_dependency_pin(repo, package_name, ecosystem) + if pin_info is None: + print(f" Cannot find {package_name} pin in repo. Cannot create bump PR.") + return None + + file_path, old_text, file_sha = pin_info + + # Build the updated file content + if ecosystem == "pip": + # Replace version pins like: Django==5.2.13 or Django>=5.2.13 + new_text = re.sub( + rf"(?im)^({re.escape(package_name)}\s*==\s*)\S+", + rf"\g<1>{patched_version}", + old_text, + ) + if new_text == old_text: + print(f" Could not update version pin in {file_path}.") + return None + elif ecosystem == "npm": + # For npm, updating package.json alone isn't enough (lock file + # needs regenerating). Log and skip for now. + print(" npm bump PRs require lock file regeneration. Not yet supported.") + return None + else: + print(f" Unsupported ecosystem: {ecosystem}") + return None + + branch_name = f"blender/security-bump-{package_name.lower()}" + pr_title = f"Bump {package_name} to {patched_version} (security)" + pr_body = ( + f"## Summary\n\n" + f"Bumps **{package_name}** to `{patched_version}` to resolve " + f"[open security alerts]" + f"(https://github.com/{repo.full_name}/security/dependabot" + f"?q=is%3Aopen+{package_name}).\n\n" + f"BLEnder investigated and determined the repo is **not affected**, " + f"but bumping the dependency is good hygiene.\n\n" + f"---\n" + f"*Created by [BLEnder](https://github.com/mozilla/blender)*" + ) + + if dry_run: + print(f" DRY_RUN: would create bump PR: {pr_title}") + print(f" DRY_RUN: branch={branch_name}, file={file_path}") + return 0 + + try: + # Create branch from default branch + default_branch = repo.default_branch + ref = repo.get_git_ref(f"heads/{default_branch}") + sha = ref.object.sha + + # Check if branch already exists + try: + repo.get_git_ref(f"heads/{branch_name}") + print(f" Branch {branch_name} already exists. Skipping.") + return None + except Exception: + pass # Branch doesn't exist, good + + repo.create_git_ref(f"refs/heads/{branch_name}", sha) + print(f" Created branch {branch_name}") + + # Update the file + repo.update_file( + file_path, + f"Bump {package_name} to {patched_version}\n\n" + f"Resolves security alert #{alert_number}.", + new_text, + file_sha, + branch=branch_name, + ) + print(f" Updated {file_path} on {branch_name}") + + # Open the PR + pr = repo.create_pull( + title=pr_title, + body=pr_body, + head=branch_name, + base=default_branch, + ) + print(f" Created PR #{pr.number}: {pr.html_url}") + return pr.number + + except Exception as e: + print(f" Failed to create bump PR: {e}") + return None + + def dismiss_alert( repo, alert_number: int, @@ -164,7 +364,9 @@ def main() -> None: repo_name = os.environ.get("REPO", "") alert_number = int(os.environ.get("ALERT_NUMBER", "0")) package_name = os.environ.get("ALERT_PACKAGE", "unknown") + ecosystem = os.environ.get("ALERT_ECOSYSTEM", "unknown") severity = os.environ.get("ALERT_SEVERITY", "unknown") + patched_version = os.environ.get("ALERT_PATCHED_VERSION", "") dry_run = os.environ.get("DRY_RUN", "false").lower() in ("true", "1", "yes") dismiss_enabled = os.environ.get("DISMISS_UNAFFECTED", "false").lower() in ( "true", @@ -197,7 +399,26 @@ def main() -> None: reason = verdict.get("reason", "(none)") if not affected: - if dismiss_enabled and severity.lower() not in DISMISS_BLOCKED_SEVERITIES: + # Check for an existing PR (Dependabot or BLEnder) that bumps this package + existing_pr = find_existing_bump_pr(repo, package_name) + + if existing_pr: + print(f" Existing PR #{existing_pr} covers this package.") + comment_on_pr(repo, existing_pr, reason, dry_run) + action = "existing_pr" + elif recommended == "bump_pr": + print(" No existing PR. Creating bump PR.") + pr_num = create_bump_pr( + repo, package_name, ecosystem, patched_version, + alert_number, dry_run, + ) + if pr_num is not None: + action = "bump_pr_created" + if pr_num > 0: + write_output("bump_pr_number", str(pr_num)) + else: + action = "noop" + elif dismiss_enabled and severity.lower() not in DISMISS_BLOCKED_SEVERITIES: print(" Unaffected + dismiss enabled. Dismissing alert.") dismiss_alert(repo, alert_number, reason, dry_run) action = "dismissed" @@ -208,7 +429,6 @@ def main() -> None: ) action = "noop" else: - print(" Unaffected + dismiss disabled. Consider bumping the package.") action = "noop" write_output("action", action) else: @@ -224,8 +444,8 @@ def main() -> None: write_output("advisory_ghsa_id", ghsa_id) write_output("fork_repo", fork_repo) - write_summary( - SUMMARY_FILE, repo_name, alert_number, package_name, severity, action, verdict + write_step_summary( + repo_name, alert_number, package_name, severity, action, verdict ) diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index d4c5271..095e984 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -8,10 +8,20 @@ import pytest -from scripts.alert_report import read_code_snippet, render_html, write_summary +from scripts.alert_report import ( + annotation_line, + read_code_snippet, + render_html, + render_markdown, + write_step_summary, + write_summary, +) from scripts.post_alert_action import ( create_advisory_and_fork, + create_bump_pr, dismiss_alert, + find_dependency_pin, + find_existing_bump_pr, load_verdict, main, ) @@ -192,90 +202,230 @@ def test_writes_html(self, tmp_path): assert "lodash" in content -class TestMainDismissFlow: - def test_unaffected_dismiss_enabled_low_severity( +class TestRenderMarkdown: + def test_contains_key_elements(self): + result = render_markdown( + "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT + ) + assert "Alert #42" in result + assert "lodash" in result + assert "NOT AFFECTED" in result + assert "not used in codebase" in result + assert "dismissed" in result.lower() + + def test_affected_redacts_details(self): + verdict = { + "affected": True, + "confidence": "high", + "reason": "lodash.merge called with user input", + "vulnerable_paths": ["server.js:2"], + "recommended_action": "private_fork", + } + result = render_markdown( + "owner/repo", 42, "lodash", "high", "private_fork", verdict + ) + assert "AFFECTED" in result + assert "lodash.merge called" not in result + assert "security advisory" in result.lower() + + +class TestAnnotationLine: + def test_unaffected_dismissed(self): + result = annotation_line(42, "lodash", "dismissed", SAMPLE_VERDICT) + assert "not affected" in result + assert "alert dismissed" in result + assert "high confidence" in result + + def test_affected_fork(self): + verdict = {**SAMPLE_VERDICT, "affected": True} + result = annotation_line(42, "lodash", "private_fork", verdict) + assert "affected" in result + assert "private fork" in result + + +class TestWriteStepSummary: + def test_writes_to_github_step_summary(self, tmp_path, monkeypatch): + summary_file = str(tmp_path / "summary.md") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", summary_file) + write_step_summary( + "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT + ) + content = open(summary_file).read() + assert "Alert #42" in content + assert "lodash" in content + + def test_skips_without_env_var(self, monkeypatch): + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + # Should not raise + write_step_summary( + "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT + ) + + +class TestMainFlow: + """Integration tests for main() with various verdict + config combos.""" + + def _run_main(self, verdict_file, tmp_path, monkeypatch, mock_repo, **env_overrides): + """Helper to set up env vars and run main().""" + summary_file = str(tmp_path / "step-summary.md") + defaults = { + "GH_TOKEN": "fake", + "REPO": "owner/repo", + "ALERT_NUMBER": "42", + "ALERT_PACKAGE": "lodash", + "ALERT_ECOSYSTEM": "npm", + "ALERT_SEVERITY": "low", + "DISMISS_UNAFFECTED": "false", + "DRY_RUN": "false", + "GITHUB_STEP_SUMMARY": summary_file, + } + defaults.update(env_overrides) + for k, v in defaults.items(): + monkeypatch.setenv(k, v) + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + + with patch("scripts.post_alert_action.Github") as mock_gh: + mock_gh.return_value.get_repo.return_value = mock_repo + main() + + return summary_file + + def test_bump_pr_creates_pr( self, verdict_file, tmp_path, monkeypatch ): verdict_file(SAMPLE_VERDICT) - monkeypatch.setenv("GH_TOKEN", "fake") - monkeypatch.setenv("REPO", "owner/repo") - monkeypatch.setenv("ALERT_NUMBER", "42") - monkeypatch.setenv("ALERT_PACKAGE", "lodash") - monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") - monkeypatch.setenv("ALERT_SEVERITY", "low") - monkeypatch.setenv("DISMISS_UNAFFECTED", "true") - monkeypatch.setenv("DRY_RUN", "false") - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) - mock_repo = MagicMock() mock_repo.full_name = "owner/repo" + mock_repo.default_branch = "main" + mock_repo.get_pulls.return_value = [] # no existing PR + + # Mock finding the dependency pin + mock_file = MagicMock() + mock_file.decoded_content = b"lodash==4.17.20\nrequests==2.28.0\n" + mock_file.sha = "abc123" + mock_repo.get_contents.return_value = mock_file + + # Mock branch creation + mock_ref = MagicMock() + mock_ref.object.sha = "def456" + mock_repo.get_git_ref.side_effect = [ + mock_ref, # get default branch ref + Exception("not found"), # branch doesn't exist yet + ] + + # Mock PR creation + mock_pr = MagicMock() + mock_pr.number = 101 + mock_pr.html_url = "https://github.com/owner/repo/pull/101" + mock_repo.create_pull.return_value = mock_pr + + summary_file = self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + ALERT_ECOSYSTEM="pip", ALERT_PATCHED_VERSION="4.17.21", + ) - with patch("scripts.post_alert_action.Github") as mock_gh: - mock_gh.return_value.get_repo.return_value = mock_repo - main() + mock_repo.create_pull.assert_called_once() + content = open(summary_file).read() + assert "Bump PR created" in content - mock_repo._requester.requestJsonAndCheck.assert_called_once_with( - "PATCH", - "/repos/owner/repo/dependabot/alerts/42", - input={ - "state": "dismissed", - "dismissed_reason": "inaccurate", - "dismissed_comment": "BLEnder: not used in codebase", - }, + def test_existing_pr_gets_comment( + self, verdict_file, tmp_path, monkeypatch + ): + verdict_file(SAMPLE_VERDICT) + mock_pr = MagicMock() + mock_pr.number = 99 + mock_pr.title = "Bump lodash from 4.17.20 to 4.17.21" + mock_pr.user.login = "dependabot[bot]" + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + mock_repo.get_pulls.return_value = [mock_pr] + + summary_file = self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo ) - summary_path = str(tmp_path / ".blender-alert-summary.html") - assert os.path.exists(summary_path) - content = open(summary_path).read() - assert "dismissed" in content.lower() - def test_unaffected_dismiss_skips_high_severity( + mock_repo.get_pull.assert_called_once_with(99) + mock_repo.get_pull.return_value.create_issue_comment.assert_called_once() + content = open(summary_file).read() + assert "Existing" in content + + def test_existing_pr_dry_run_skips_comment( self, verdict_file, tmp_path, monkeypatch ): verdict_file(SAMPLE_VERDICT) - monkeypatch.setenv("GH_TOKEN", "fake") - monkeypatch.setenv("REPO", "owner/repo") - monkeypatch.setenv("ALERT_NUMBER", "42") - monkeypatch.setenv("ALERT_PACKAGE", "lodash") - monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") - monkeypatch.setenv("ALERT_SEVERITY", "high") - monkeypatch.setenv("DISMISS_UNAFFECTED", "true") - monkeypatch.setenv("DRY_RUN", "false") - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + mock_pr = MagicMock() + mock_pr.number = 99 + mock_pr.title = "Bump lodash from 4.17.20 to 4.17.21" + mock_pr.user.login = "dependabot[bot]" + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + mock_repo.get_pulls.return_value = [mock_pr] + + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, DRY_RUN="true" + ) + + mock_repo.get_pull.assert_not_called() + def test_dismiss_enabled_low_severity_with_existing_pr( + self, verdict_file, tmp_path, monkeypatch + ): + """With existing PR, we comment on it instead of dismissing.""" + verdict = {**SAMPLE_VERDICT, "recommended_action": "existing_pr"} + verdict_file(verdict) + mock_pr = MagicMock() + mock_pr.number = 99 + mock_pr.title = "Bump lodash from 4.17.20 to 4.17.21" + mock_pr.user.login = "dependabot[bot]" mock_repo = MagicMock() mock_repo.full_name = "owner/repo" + mock_repo.get_pulls.return_value = [mock_pr] - with patch("scripts.post_alert_action.Github") as mock_gh: - mock_gh.return_value.get_repo.return_value = mock_repo - main() + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + DISMISS_UNAFFECTED="true", ALERT_SEVERITY="low", + ) - # High severity: no dismiss call, even with dismiss enabled - mock_repo._requester.requestJsonAndCheck.assert_not_called() - summary_path = str(tmp_path / ".blender-alert-summary.html") - assert os.path.exists(summary_path) + # Should comment on PR, not dismiss + mock_repo.get_pull.assert_called_once_with(99) - def test_unaffected_dismiss_disabled_is_noop( + def test_dismiss_enabled_no_pr_no_bump( self, verdict_file, tmp_path, monkeypatch ): - verdict_file(SAMPLE_VERDICT) - monkeypatch.setenv("GH_TOKEN", "fake") - monkeypatch.setenv("REPO", "owner/repo") - monkeypatch.setenv("ALERT_NUMBER", "42") - monkeypatch.setenv("ALERT_PACKAGE", "lodash") - monkeypatch.setenv("ALERT_ECOSYSTEM", "npm") - monkeypatch.setenv("DISMISS_UNAFFECTED", "false") - monkeypatch.setenv("DRY_RUN", "false") - monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + """With dismiss enabled and recommended_action != bump_pr, dismiss.""" + verdict = {**SAMPLE_VERDICT, "recommended_action": "none"} + verdict_file(verdict) + mock_repo = MagicMock() + mock_repo.full_name = "owner/repo" + mock_repo.get_pulls.return_value = [] + + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + DISMISS_UNAFFECTED="true", ALERT_SEVERITY="low", + ) + mock_repo._requester.requestJsonAndCheck.assert_called_once_with( + "PATCH", + "/repos/owner/repo/dependabot/alerts/42", + input={ + "state": "dismissed", + "dismissed_reason": "inaccurate", + "dismissed_comment": "BLEnder: not used in codebase", + }, + ) + + def test_dismiss_skips_high_severity( + self, verdict_file, tmp_path, monkeypatch + ): + verdict = {**SAMPLE_VERDICT, "recommended_action": "none"} + verdict_file(verdict) mock_repo = MagicMock() mock_repo.full_name = "owner/repo" + mock_repo.get_pulls.return_value = [] - with patch("scripts.post_alert_action.Github") as mock_gh: - mock_gh.return_value.get_repo.return_value = mock_repo - main() + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + DISMISS_UNAFFECTED="true", ALERT_SEVERITY="high", + ) mock_repo._requester.requestJsonAndCheck.assert_not_called() - summary_path = str(tmp_path / ".blender-alert-summary.html") - assert os.path.exists(summary_path) - content = open(summary_path).read() - assert "No action taken" in content From ca285ebd0165086fc575baeb9c73ca5032f60937 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 19 May 2026 11:43:40 -0500 Subject: [PATCH 11/15] refactor: address PR #35 review comments - Change dismiss reason from "inaccurate" to "not_used" - Extract "BLEnder" to BLENDER_NAME constant - Pass alert severity to advisory creation - Link "investigated" to the workflow run URL - Remove dead code from alert_report.py (render_html, read_code_snippet, write_summary) - Remove duplicate jq verdict validation from run-claude.sh - Add dev-tool comment to investigate-all-alerts.sh - Move post-action from investigate to remediate job, pass verdict via artifact - Create setup-target composite action to deduplicate workflow setup steps - Refactor investigate and fix workflows to use composite action Co-Authored-By: Claude Opus 4.6 --- .github/actions/setup-target/action.yml | 158 ++++++++ .github/workflows/fix-dependabot-pr.yml | 85 +---- .../workflows/investigate-security-alert.yml | 214 ++++------- scripts/alert_report.py | 355 +----------------- scripts/investigate-all-alerts.sh | 2 + scripts/post_alert_action.py | 35 +- scripts/run-claude.sh | 20 +- tests/scripts/test_post_alert_action.py | 106 +----- 8 files changed, 277 insertions(+), 698 deletions(-) create mode 100644 .github/actions/setup-target/action.yml diff --git a/.github/actions/setup-target/action.yml b/.github/actions/setup-target/action.yml new file mode 100644 index 0000000..b2b6add --- /dev/null +++ b/.github/actions/setup-target/action.yml @@ -0,0 +1,158 @@ +--- +name: Setup target repo +description: >- + Parse target repo, create app token, checkout, read config, and install + Python. Shared by investigate, fix, and setup workflows. + +inputs: + target-repo: + description: 'Target repo (e.g. mozilla/fx-private-relay)' + required: true + app-id: + description: 'GitHub App ID for token creation' + required: true + private-key: + description: 'GitHub App private key' + required: true + blender-workspace: + description: 'Path to blender checkout (github.workspace)' + required: true + permission-contents: + description: 'Token permission for contents (omit to inherit all)' + default: '' + permission-pull-requests: + description: 'Token permission for pull-requests (omit to inherit all)' + default: '' + checkout-path: + description: 'Path to checkout target repo' + default: 'target' + checkout-submodules: + description: 'Submodules mode (false, true, or recursive)' + default: 'false' + checkout-persist-credentials: + description: 'Persist credentials after checkout' + default: 'false' + install-sandbox: + description: 'Install bubblewrap + socat' + default: 'false' + install-claude: + description: 'Install Claude Code CLI' + default: 'false' + install-blender-deps: + description: 'Install BLEnder Python dependencies' + default: 'false' + +outputs: + token: + description: 'GitHub App token for target repo' + value: ${{ steps.app-token.outputs.token }} + owner: + description: 'Target repo owner' + value: ${{ steps.parse.outputs.owner }} + name: + description: 'Target repo name' + value: ${{ steps.parse.outputs.name }} + node_version: + description: 'Node version from blender.yml' + value: ${{ steps.config.outputs.node_version }} + python_version: + description: 'Python version from blender.yml' + value: ${{ steps.config.outputs.python_version }} + install_command: + description: 'Install command from blender.yml' + value: ${{ steps.config.outputs.install_command }} + repo_name: + description: 'Display name from blender.yml' + value: ${{ steps.config.outputs.repo_name }} + dismiss_unaffected: + description: 'Whether to dismiss unaffected alerts' + value: ${{ steps.config.outputs.dismiss_unaffected }} + install_failed: + description: 'Whether the target dependency install failed' + value: ${{ steps.install-deps.outcome == 'failure' && 'true' || 'false' }} + +runs: + using: composite + steps: + - name: Parse target repo + id: parse + shell: bash + run: | + echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" + echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" + env: + TARGET_REPO: ${{ inputs.target-repo }} + + - id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + owner: ${{ steps.parse.outputs.owner }} + repositories: ${{ steps.parse.outputs.name }} + permission-contents: ${{ inputs.permission-contents || '' }} + permission-pull-requests: ${{ inputs.permission-pull-requests || '' }} + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: ${{ inputs.target-repo }} + token: ${{ steps.app-token.outputs.token }} + path: ${{ inputs.checkout-path }} + submodules: ${{ inputs.checkout-submodules }} + persist-credentials: ${{ inputs.checkout-persist-credentials }} + + - name: Read repo config + id: config + shell: bash + run: | + CONFIG_FILE="${CHECKOUT_PATH}/.blender/blender.yml" + if [ ! -f "$CONFIG_FILE" ]; then + echo "Error: No config found at $CONFIG_FILE" + exit 1 + fi + { + echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" + echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" + echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" + echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" + echo "dismiss_unaffected=$(yq '.investigate.dismiss_unaffected // "false"' "$CONFIG_FILE")" + } >> "$GITHUB_OUTPUT" + env: + CHECKOUT_PATH: ${{ inputs.checkout-path }} + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: ${{ steps.config.outputs.python_version || '3.11' }} + cache: pip + + - name: Install BLEnder Python dependencies + if: inputs.install-blender-deps == 'true' + shell: bash + run: pip install -r scripts/requirements.txt + working-directory: ${{ inputs.blender-workspace }} + + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + if: steps.config.outputs.node_version != '' + with: + node-version: ${{ steps.config.outputs.node_version }} + + - name: Install target dependencies + id: install-deps + if: steps.config.outputs.install_command != '' + continue-on-error: true + shell: bash + working-directory: ${{ inputs.checkout-path }} + run: | + eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log + env: + INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} + + - name: Install sandbox dependencies + if: inputs.install-sandbox == 'true' + shell: bash + run: sudo apt-get install -y bubblewrap socat + + - name: Install Claude Code + if: inputs.install-claude == 'true' + shell: bash + run: npm install -g @anthropic-ai/claude-code@stable diff --git a/.github/workflows/fix-dependabot-pr.yml b/.github/workflows/fix-dependabot-pr.yml index 9ae5e44..300fb59 100644 --- a/.github/workflows/fix-dependabot-pr.yml +++ b/.github/workflows/fix-dependabot-pr.yml @@ -36,47 +36,19 @@ jobs: with: persist-credentials: false - - name: Parse target repo - id: parse - run: | - echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" - echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" - env: - TARGET_REPO: ${{ inputs.target_repo }} - - - id: app-token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target with: + target-repo: ${{ inputs.target_repo }} app-id: ${{ secrets.BLENDER_APP_ID }} private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} - owner: ${{ steps.parse.outputs.owner }} - repositories: ${{ steps.parse.outputs.name }} + blender-workspace: ${{ github.workspace }} permission-contents: write permission-pull-requests: write - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - repository: ${{ inputs.target_repo }} - token: ${{ steps.app-token.outputs.token }} - path: target - submodules: ${{ inputs.submodules == 'true' && 'recursive' || 'false' }} - persist-credentials: false - - - name: Read repo config - id: config - run: | - CONFIG_FILE="target/.blender/blender.yml" - if [ ! -f "$CONFIG_FILE" ]; then - echo "Error: No config found at $CONFIG_FILE" - echo "Run the setup workflow first to onboard this repo." - exit 1 - fi - { - echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" - echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" - echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" - echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" - } >> "$GITHUB_OUTPUT" + checkout-submodules: ${{ inputs.submodules == 'true' && 'recursive' || 'false' }} + install-sandbox: 'true' + install-claude: 'true' - name: Save BLEnder config from default branch run: cp -r target/.blender /tmp/blender-config @@ -86,48 +58,21 @@ jobs: run: gh pr checkout "$PR_NUMBER" env: PR_NUMBER: ${{ inputs.pr_number }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} - name: Restore BLEnder config run: cp -r /tmp/blender-config/. target/.blender/ - # --- Conditional setup --- - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: ${{ steps.config.outputs.python_version || '3.11' }} - cache: pip - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - if: steps.config.outputs.node_version != '' - with: - node-version: ${{ steps.config.outputs.node_version }} - - - name: Install dependencies - id: install - if: steps.config.outputs.install_command != '' - continue-on-error: true - working-directory: target - run: | - eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log - env: - INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} - # --- BLEnder fix --- - - name: Install sandbox dependencies - run: sudo apt-get install -y bubblewrap socat - - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code@stable - - name: Gather PR context working-directory: target run: ${{ github.workspace }}/scripts/gather-context.sh env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} PR_NUMBER: ${{ inputs.pr_number }} REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: .blender/fix-dependabot-prompt.md - INSTALL_FAILED: ${{ steps.install.outcome == 'failure' && 'true' || 'false' }} + INSTALL_FAILED: ${{ steps.setup.outputs.install_failed }} INSTALL_LOG_FILE: /tmp/install-output.log - name: Comment on PR @@ -135,7 +80,7 @@ jobs: run: | gh pr comment "$PR_NUMBER" --body "BLEnder picked up this PR. [Workflow run](${RUN_URL})" env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} PR_NUMBER: ${{ inputs.pr_number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -145,7 +90,7 @@ jobs: env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} REPO: ${{ inputs.target_repo }} - REPO_NAME: ${{ steps.config.outputs.repo_name }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} BLENDER_DIR: ${{ github.workspace }} CLAUDE_VERBOSE: ${{ inputs.verbose }} @@ -156,7 +101,7 @@ jobs: working-directory: target run: ${{ github.workspace }}/scripts/commit.sh env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} REPO: ${{ inputs.target_repo }} - name: Parse PR title @@ -215,7 +160,7 @@ jobs: gh pr comment "$PR_NUMBER" \ --body "BLEnder could not fix this PR automatically. [Workflow run](${RUN_URL})" env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} PR_NUMBER: ${{ inputs.pr_number }} RUN_URL: >- ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 7a42648..46f88df 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -45,93 +45,27 @@ jobs: permissions: contents: read actions: write - outputs: - action: ${{ steps.post-action.outputs.action }} - fork_repo: ${{ steps.post-action.outputs.fork_repo }} - advisory_ghsa_id: ${{ steps.post-action.outputs.advisory_ghsa_id }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Parse target repo - id: parse - run: | - echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" - echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" - env: - TARGET_REPO: ${{ inputs.target_repo }} - - - id: app-token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target with: + target-repo: ${{ inputs.target_repo }} app-id: ${{ secrets.BLENDER_APP_ID }} private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} - owner: ${{ steps.parse.outputs.owner }} - repositories: ${{ steps.parse.outputs.name }} - # Token inherits all installed permissions (contents, pull-requests, - # vulnerability-alerts, repository-advisories). We don't restrict - # because the token action has no input for repository-advisories. - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - repository: ${{ inputs.target_repo }} - token: ${{ steps.app-token.outputs.token }} - path: target - persist-credentials: false - - - name: Read repo config - id: config - run: | - CONFIG_FILE="target/.blender/blender.yml" - if [ ! -f "$CONFIG_FILE" ]; then - echo "Error: No config found at $CONFIG_FILE" - exit 1 - fi - { - echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" - echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" - echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" - echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" - echo "dismiss_unaffected=$(yq '.investigate.dismiss_unaffected // "false"' "$CONFIG_FILE")" - } >> "$GITHUB_OUTPUT" - - # --- Conditional setup --- - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: ${{ steps.config.outputs.python_version || '3.11' }} - cache: pip - - - name: Install BLEnder Python dependencies - run: pip install -r scripts/requirements.txt - working-directory: ${{ github.workspace }} - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - if: steps.config.outputs.node_version != '' - with: - node-version: ${{ steps.config.outputs.node_version }} - - - name: Install dependencies - if: steps.config.outputs.install_command != '' - continue-on-error: true - working-directory: target - run: | - eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log - env: - INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} - - # --- BLEnder investigate --- - - name: Install sandbox dependencies - run: sudo apt-get install -y bubblewrap socat - - - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code@stable + blender-workspace: ${{ github.workspace }} + install-sandbox: 'true' + install-claude: 'true' - name: Gather alert context working-directory: target run: ${{ github.workspace }}/scripts/gather-alert-context.sh env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} ALERT_NUMBER: ${{ inputs.alert_number }} REPO: ${{ inputs.target_repo }} PROMPT_TEMPLATE: ${{ github.workspace }}/prompts/investigate-alert-prompt.md @@ -144,104 +78,97 @@ jobs: env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} REPO: ${{ inputs.target_repo }} - REPO_NAME: ${{ steps.config.outputs.repo_name }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} BLENDER_DIR: ${{ github.workspace }} BLENDER_MODE: investigate CLAUDE_VERBOSE: ${{ inputs.verbose }} - - name: Post-investigation action - id: post-action - working-directory: target - run: python ${{ github.workspace }}/scripts/post_alert_action.py - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - REPO: ${{ inputs.target_repo }} - ALERT_NUMBER: ${{ inputs.alert_number }} - ALERT_PACKAGE: ${{ inputs.alert_package }} - ALERT_ECOSYSTEM: ${{ inputs.alert_ecosystem }} - ALERT_SEVERITY: ${{ inputs.alert_severity }} - ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} - DRY_RUN: ${{ inputs.dry_run }} - DISMISS_UNAFFECTED: ${{ steps.config.outputs.dismiss_unaffected }} + - name: Upload verdict + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: alert-verdict + path: target/.blender-alert-verdict.json + if-no-files-found: ignore + retention-days: 1 remediate: needs: investigate - if: needs.investigate.outputs.action == 'private_fork' runs-on: ubuntu-latest + outputs: + action: ${{ steps.post-action.outputs.action }} + fork_repo: ${{ steps.post-action.outputs.fork_repo }} + advisory_ghsa_id: ${{ steps.post-action.outputs.advisory_ghsa_id }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Parse target repo - id: parse - run: | - echo "owner=${TARGET_REPO%%/*}" >> "$GITHUB_OUTPUT" - echo "name=${TARGET_REPO##*/}" >> "$GITHUB_OUTPUT" - env: - TARGET_REPO: ${{ inputs.target_repo }} - - - id: app-token - uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + - name: Setup target repo + id: setup + uses: ./.github/actions/setup-target with: + target-repo: ${{ inputs.target_repo }} app-id: ${{ secrets.BLENDER_APP_ID }} private-key: ${{ secrets.BLENDER_APP_PRIVATE_KEY }} - owner: ${{ steps.parse.outputs.owner }} - repositories: ${{ steps.parse.outputs.name }} - permission-contents: write - permission-pull-requests: write + blender-workspace: ${{ github.workspace }} + install-blender-deps: 'true' - - name: Clone private fork - run: | - git clone "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git" target + - name: Download verdict + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: alert-verdict + path: target + continue-on-error: true + + - name: Post-investigation action + id: post-action + working-directory: target + run: python ${{ github.workspace }}/scripts/post_alert_action.py env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - FORK_REPO: ${{ needs.investigate.outputs.fork_repo }} + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ inputs.target_repo }} + ALERT_NUMBER: ${{ inputs.alert_number }} + ALERT_PACKAGE: ${{ inputs.alert_package }} + ALERT_ECOSYSTEM: ${{ inputs.alert_ecosystem }} + ALERT_SEVERITY: ${{ inputs.alert_severity }} + ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} + DRY_RUN: ${{ inputs.dry_run }} + DISMISS_UNAFFECTED: ${{ steps.setup.outputs.dismiss_unaffected }} - - name: Read repo config - id: config + # --- Private fork remediation (only for affected alerts) --- + - name: Clone private fork + if: steps.post-action.outputs.action == 'private_fork' run: | - CONFIG_FILE="target/.blender/blender.yml" - if [ ! -f "$CONFIG_FILE" ]; then - echo "Error: No config found at $CONFIG_FILE" - exit 1 - fi - { - echo "node_version=$(yq '.node_version // ""' "$CONFIG_FILE")" - echo "python_version=$(yq '.python_version // ""' "$CONFIG_FILE")" - echo "install_command=$(yq '.install_command // ""' "$CONFIG_FILE")" - echo "repo_name=$(yq '.repo_name // ""' "$CONFIG_FILE")" - } >> "$GITHUB_OUTPUT" - - # --- Conditional setup --- - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - with: - python-version: ${{ steps.config.outputs.python_version || '3.11' }} - cache: pip + git clone "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git" fork + env: + GH_TOKEN: ${{ steps.setup.outputs.token }} + FORK_REPO: ${{ steps.post-action.outputs.fork_repo }} - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - if: steps.config.outputs.node_version != '' + if: steps.post-action.outputs.action == 'private_fork' && steps.setup.outputs.node_version != '' with: - node-version: ${{ steps.config.outputs.node_version }} + node-version: ${{ steps.setup.outputs.node_version }} - - name: Install dependencies - if: steps.config.outputs.install_command != '' + - name: Install fork dependencies + if: steps.post-action.outputs.action == 'private_fork' && steps.setup.outputs.install_command != '' continue-on-error: true - working-directory: target + working-directory: fork run: | eval "$INSTALL_COMMAND" 2>&1 | tee /tmp/install-output.log env: - INSTALL_COMMAND: ${{ steps.config.outputs.install_command }} + INSTALL_COMMAND: ${{ steps.setup.outputs.install_command }} - # --- BLEnder fix on private fork --- - name: Install sandbox dependencies + if: steps.post-action.outputs.action == 'private_fork' run: sudo apt-get install -y bubblewrap socat - name: Install Claude Code + if: steps.post-action.outputs.action == 'private_fork' run: npm install -g @anthropic-ai/claude-code@stable - name: Build fix prompt - working-directory: target + if: steps.post-action.outputs.action == 'private_fork' + working-directory: fork run: | cat > .blender-prompt << PROMPT_EOF Fix the security vulnerability in ${ALERT_PACKAGE}. @@ -255,21 +182,22 @@ jobs: ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} - name: Run Claude fix - working-directory: target + if: steps.post-action.outputs.action == 'private_fork' + working-directory: fork run: ${{ github.workspace }}/scripts/run-claude.sh env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - REPO: ${{ needs.investigate.outputs.fork_repo }} - REPO_NAME: ${{ steps.config.outputs.repo_name }} + REPO: ${{ steps.post-action.outputs.fork_repo }} + REPO_NAME: ${{ steps.setup.outputs.repo_name }} BLENDER_DIR: ${{ github.workspace }} BLENDER_MODE: fix CLAUDE_VERBOSE: ${{ inputs.verbose }} - name: Commit and push fix id: commit - if: inputs.dry_run != 'true' - working-directory: target + if: steps.post-action.outputs.action == 'private_fork' && inputs.dry_run != 'true' + working-directory: fork run: ${{ github.workspace }}/scripts/commit.sh env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - REPO: ${{ needs.investigate.outputs.fork_repo }} + GH_TOKEN: ${{ steps.setup.outputs.token }} + REPO: ${{ steps.post-action.outputs.fork_repo }} diff --git a/scripts/alert_report.py b/scripts/alert_report.py index db3f9ba..88be7e9 100644 --- a/scripts/alert_report.py +++ b/scripts/alert_report.py @@ -1,345 +1,10 @@ -"""HTML report generator for Dependabot alert investigations. +"""Report generator for Dependabot alert investigations. -Produces a self-contained HTML file styled like a code coverage report. -Each vulnerable code path is shown with source context and the target -line highlighted. +Produces markdown step summaries and annotations for GitHub Actions. """ from __future__ import annotations -import html - - -CONTEXT_LINES = 5 - - -def read_code_snippet(file_path: str, target_line: int) -> list[tuple[int, str, bool]]: - """Read lines around target_line from file_path. - - Returns a list of (line_number, text, is_target) tuples. - Returns an empty list if the file cannot be read. - """ - try: - with open(file_path) as f: - all_lines = f.readlines() - except (OSError, UnicodeDecodeError): - return [] - - start = max(0, target_line - CONTEXT_LINES - 1) - end = min(len(all_lines), target_line + CONTEXT_LINES) - - result = [] - for i in range(start, end): - line_num = i + 1 - text = all_lines[i].rstrip("\n") - result.append((line_num, text, line_num == target_line)) - return result - - -def render_html( - repo_name: str, - alert_number: int, - package: str, - severity: str, - action: str, - verdict: dict, -) -> str: - """Build a self-contained HTML summary report.""" - affected = verdict.get("affected", False) - confidence = verdict.get("confidence", "unknown") - vulnerable_paths = verdict.get("vulnerable_paths", []) - - # Redact sensitive details for affected alerts — the artifact is - # world-readable on public repos. The full analysis lives in the - # private security advisory. - if affected: - reason = "Details redacted — see the security advisory for this alert." - vulnerable_paths = [] - else: - reason = verdict.get("reason", "(none)") - - status_label = "AFFECTED" if affected else "UNAFFECTED" - status_color = "#dc3545" if affected else "#28a745" - - action_labels = { - "dismissed": "Alert dismissed", - "noop": "No action taken", - "private_fork": "Advisory created with private fork", - "existing_pr": "Existing Dependabot PR found", - "bump_pr_created": "Bump PR created", - } - action_text = action_labels.get(action, action) - - severity_colors = { - "critical": "#dc3545", - "high": "#fd7e14", - "medium": "#ffc107", - "low": "#28a745", - } - sev_color = severity_colors.get(severity.lower(), "#6c757d") - - confidence_colors = { - "high": "#28a745", - "medium": "#ffc107", - "low": "#dc3545", - } - conf_color = confidence_colors.get(confidence.lower(), "#6c757d") - - # Build code snippets HTML - snippets_html = "" - if vulnerable_paths: - for vp in vulnerable_paths: - parts = vp.rsplit(":", 1) - file_path = parts[0] - target_line = int(parts[1]) if len(parts) == 2 and parts[1].isdigit() else 0 - - snippet_header = ( - f'
' - f'
{html.escape(vp)}
' - ) - - if target_line == 0: - snippets_html += ( - f"{snippet_header}" - f'
' - f'No line number specified' - f"
" - ) - continue - - lines = read_code_snippet(file_path, target_line) - if not lines: - snippets_html += ( - f"{snippet_header}" - f'
' - f'Source file not available' - f"
" - ) - continue - - code_lines = "" - for line_num, text, is_target in lines: - cls = ' class="target-line"' if is_target else "" - escaped = html.escape(text) if text else " " - code_lines += ( - f"" - f'{line_num}' - f'{escaped}' - f"\n" - ) - - snippets_html += ( - f"{snippet_header}" - f'
' - f"{code_lines}
" - ) - else: - snippets_html = ( - '
No vulnerable code paths identified.
' - ) - - return f""" - - - - -Alert #{alert_number} — {html.escape(package)} — BLEnder Report - - - -
-
-

Alert #{alert_number} — {html.escape(package)}

-
BLEnder Investigation Report
{html.escape(repo_name)}
-
- -
- {status_label} - {html.escape(action_text)} -
- -
-
-
Severity
-
{html.escape(severity)}
-
-
-
Confidence
-
{html.escape(confidence)}
-
-
-
Action
-
{html.escape(action)}
-
-
-
Package
-
{html.escape(package)}
-
-
- -
-
Analysis
-
-

{html.escape(reason)}

-
-
- -
-
Code Paths
-
- {snippets_html} -
-
-
- - -""" - def render_markdown( repo_name: str, @@ -444,19 +109,3 @@ def write_step_summary( notice = annotation_line(alert_number, package, action, verdict) print(f"::notice ::{notice}") - - -def write_summary( - path: str, - repo_name: str, - alert_number: int, - package: str, - severity: str, - action: str, - verdict: dict, -) -> None: - """Write an HTML summary report for upload as a workflow artifact.""" - report = render_html(repo_name, alert_number, package, severity, action, verdict) - with open(path, "w") as f: - f.write(report) - print(f" Summary written to {path}") diff --git a/scripts/investigate-all-alerts.sh b/scripts/investigate-all-alerts.sh index 8eb734b..0da0674 100755 --- a/scripts/investigate-all-alerts.sh +++ b/scripts/investigate-all-alerts.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Local dev utility — not used in CI. +# # Trigger investigate-security-alert.yml for every open Dependabot alert # in a target repo. # diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index cabea31..85627c1 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -40,6 +40,7 @@ from scripts.alert_report import write_step_summary # noqa: E402 +BLENDER_NAME = "BLEnder" DISMISS_BLOCKED_SEVERITIES = {"critical", "high"} VERDICT_FILE = ".blender-alert-verdict.json" REQUIRED_KEYS = { @@ -73,6 +74,7 @@ def create_advisory_and_fork( alert_number: int, package_name: str, dry_run: bool, + severity: str = "low", ) -> tuple[str, str]: """Create a security advisory with a private fork. @@ -93,7 +95,7 @@ def create_advisory_and_fork( payload = { "summary": summary, "description": description, - "severity": "low", + "severity": severity or "low", "start_private_fork": True, } try: @@ -157,6 +159,16 @@ def find_existing_bump_pr( return None +def _run_url() -> str: + """Build a link to the current GitHub Actions run, or empty string.""" + server = os.environ.get("GITHUB_SERVER_URL", "") + repository = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if server and repository and run_id: + return f"{server}/{repository}/actions/runs/{run_id}" + return "" + + def comment_on_pr( repo, pr_number: int, @@ -164,9 +176,11 @@ def comment_on_pr( dry_run: bool, ) -> None: """Comment on a PR with BLEnder's investigation results.""" + run_link = _run_url() + investigated = f"[investigated]({run_link})" if run_link else "investigated" body = ( - "**BLEnder investigation:** This dependency has an open security alert, " - f"but the repo is **not affected**.\n\n> {reason}\n\n" + f"**{BLENDER_NAME} {investigated}:** This dependency has an open " + f"security alert, but the repo is **not affected**.\n\n> {reason}\n\n" "This PR can be reviewed and merged as a normal dependency update." ) if dry_run: @@ -278,16 +292,18 @@ def create_bump_pr( branch_name = f"blender/security-bump-{package_name.lower()}" pr_title = f"Bump {package_name} to {patched_version} (security)" + run_link = _run_url() + investigated = f"[investigated]({run_link})" if run_link else "investigated" pr_body = ( f"## Summary\n\n" f"Bumps **{package_name}** to `{patched_version}` to resolve " f"[open security alerts]" f"(https://github.com/{repo.full_name}/security/dependabot" f"?q=is%3Aopen+{package_name}).\n\n" - f"BLEnder investigated and determined the repo is **not affected**, " - f"but bumping the dependency is good hygiene.\n\n" + f"{BLENDER_NAME} {investigated} and determined the repo is " + f"**not affected**, but bumping the dependency is good hygiene.\n\n" f"---\n" - f"*Created by [BLEnder](https://github.com/mozilla/blender)*" + f"*Created by [{BLENDER_NAME}](https://github.com/mozilla/blender)*" ) if dry_run: @@ -344,7 +360,7 @@ def dismiss_alert( reason: str, dry_run: bool, ) -> None: - """Dismiss a Dependabot alert as inaccurate (unaffected).""" + """Dismiss a Dependabot alert as not used (unaffected).""" if dry_run: print(f" DRY_RUN: would dismiss alert #{alert_number}") return @@ -352,8 +368,8 @@ def dismiss_alert( url = f"/repos/{repo.full_name}/dependabot/alerts/{alert_number}" payload = { "state": "dismissed", - "dismissed_reason": "inaccurate", - "dismissed_comment": f"BLEnder: {reason}", + "dismissed_reason": "not_used", + "dismissed_comment": f"{BLENDER_NAME}: {reason}", } repo._requester.requestJsonAndCheck("PATCH", url, input=payload) print(f" Dismissed alert #{alert_number}") @@ -438,6 +454,7 @@ def main() -> None: alert_number, package_name, dry_run, + severity=severity.lower() if severity else "low", ) action = "private_fork" write_output("action", action) diff --git a/scripts/run-claude.sh b/scripts/run-claude.sh index 2c9a0a0..62243d0 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -185,26 +185,8 @@ if [ "$BLENDER_MODE" = "investigate" ]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if python3 "${SCRIPT_DIR}/extract_alert_verdict.py" "$CLAUDE_LOG"; then echo "Verdict file written from Claude output." - fi - - # Alert verdict file must exist and be valid JSON - if [ -f .blender-alert-verdict.json ]; then - if ! jq empty .blender-alert-verdict.json 2>/dev/null; then - echo "ABORT: .blender-alert-verdict.json is not valid JSON." - rm -f .blender-alert-verdict.json - exit 1 - fi - # Check required keys - for key in affected confidence reason vulnerable_paths recommended_action; do - if ! jq -e "has(\"$key\")" .blender-alert-verdict.json > /dev/null 2>&1; then - echo "ABORT: .blender-alert-verdict.json missing required key: $key" - rm -f .blender-alert-verdict.json - exit 1 - fi - done - echo "Alert verdict file validated." else - echo "No alert verdict file produced. Post-action will handle this." + echo "No alert verdict extracted. Post-action will handle this." fi exit 0 diff --git a/tests/scripts/test_post_alert_action.py b/tests/scripts/test_post_alert_action.py index 095e984..fae2767 100644 --- a/tests/scripts/test_post_alert_action.py +++ b/tests/scripts/test_post_alert_action.py @@ -10,11 +10,8 @@ from scripts.alert_report import ( annotation_line, - read_code_snippet, - render_html, render_markdown, write_step_summary, - write_summary, ) from scripts.post_alert_action import ( create_advisory_and_fork, @@ -92,7 +89,7 @@ def test_calls_api(self): "/repos/owner/repo/dependabot/alerts/42", input={ "state": "dismissed", - "dismissed_reason": "inaccurate", + "dismissed_reason": "not_used", "dismissed_comment": "BLEnder: not used in codebase", }, ) @@ -103,105 +100,6 @@ def test_dry_run_skips(self): repo._requester.requestJsonAndCheck.assert_not_called() -class TestReadCodeSnippet: - def test_reads_lines_around_target(self, tmp_path): - src = tmp_path / "app.py" - src.write_text("\n".join(f"line {i}" for i in range(1, 21))) - lines = read_code_snippet(str(src), 10) - nums = [n for n, _, _ in lines] - assert 10 in nums - # Target line is marked - targets = [(n, hit) for n, _, hit in lines if hit] - assert targets == [(10, True)] - - def test_missing_file_returns_empty(self): - assert read_code_snippet("/no/such/file.py", 5) == [] - - def test_target_near_start(self, tmp_path): - src = tmp_path / "short.py" - src.write_text("a\nb\nc\n") - lines = read_code_snippet(str(src), 1) - assert lines[0][0] == 1 - assert lines[0][2] is True - - -class TestRenderHtml: - def test_contains_key_elements(self): - verdict = {**SAMPLE_VERDICT, "vulnerable_paths": []} - result = render_html("owner/repo", 42, "lodash", "high", "dismissed", verdict) - assert "Alert #42" in result - assert "lodash" in result - assert "UNAFFECTED" in result - assert "not used in codebase" in result - - def test_affected_redacts_details(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - src = tmp_path / "server.js" - src.write_text("const x = require('lodash');\nx.merge({}, input);\n") - verdict = { - "affected": True, - "confidence": "high", - "reason": "lodash.merge called with user input", - "vulnerable_paths": ["server.js:2"], - "recommended_action": "private_fork", - } - result = render_html( - "owner/repo", 42, "lodash", "high", "private_fork", verdict - ) - assert "AFFECTED" in result - # Sensitive details must not appear in the public artifact - assert "lodash.merge called" not in result - assert "server.js:2" not in result - assert "x.merge" not in result - assert "see the security advisory" in result.lower() - - def test_unaffected_shows_code_snippets(self, tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - src = tmp_path / "util.js" - src.write_text("function safe() {}\nmodule.exports = safe;\n") - verdict = { - **SAMPLE_VERDICT, - "vulnerable_paths": ["util.js:1"], - } - result = render_html( - "owner/repo", 42, "lodash", "high", "dismissed", verdict - ) - assert "util.js:1" in result - assert "function safe" in result - - def test_missing_source_file(self): - verdict = { - **SAMPLE_VERDICT, - "vulnerable_paths": ["/nonexistent/file.py:10"], - } - result = render_html( - "owner/repo", 42, "lodash", "high", "dismissed", verdict - ) - assert "Source file not available" in result - - def test_path_without_line_number(self): - verdict = { - **SAMPLE_VERDICT, - "vulnerable_paths": ["some/file.py"], - } - result = render_html( - "owner/repo", 42, "lodash", "high", "dismissed", verdict - ) - assert "No line number specified" in result - - -class TestWriteSummary: - def test_writes_html(self, tmp_path): - path = str(tmp_path / "summary.html") - write_summary( - path, "owner/repo", 42, "lodash", "high", "dismissed", SAMPLE_VERDICT - ) - content = open(path).read() - assert content.startswith("") - assert "Alert #42" in content - assert "lodash" in content - - class TestRenderMarkdown: def test_contains_key_elements(self): result = render_markdown( @@ -409,7 +307,7 @@ def test_dismiss_enabled_no_pr_no_bump( "/repos/owner/repo/dependabot/alerts/42", input={ "state": "dismissed", - "dismissed_reason": "inaccurate", + "dismissed_reason": "not_used", "dismissed_comment": "BLEnder: not used in codebase", }, ) From 846234d6230017362a72238e9b83f5eabac0a2d1 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 19 May 2026 13:24:36 -0500 Subject: [PATCH 12/15] feat: enrich fix prompt with investigation verdict Copy the verdict artifact into the fork working directory and append it to the fix prompt. Gives Claude the vulnerable_paths and reason from the investigation as a head start for the fix. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/investigate-security-alert.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 46f88df..7efab1f 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -166,6 +166,10 @@ jobs: if: steps.post-action.outputs.action == 'private_fork' run: npm install -g @anthropic-ai/claude-code@stable + - name: Copy verdict to fork + if: steps.post-action.outputs.action == 'private_fork' + run: cp target/.blender-alert-verdict.json fork/.blender-alert-verdict.json 2>/dev/null || true + - name: Build fix prompt if: steps.post-action.outputs.action == 'private_fork' working-directory: fork @@ -177,6 +181,12 @@ jobs: code that uses the vulnerable API. Run the test suite to verify your changes. PROMPT_EOF + + # Append investigation verdict if available + if [ -f .blender-alert-verdict.json ]; then + printf '\n## Investigation verdict\n\nThe BLEnder investigation produced the following verdict. Use the vulnerable_paths and reason to focus your fix:\n\n' >> .blender-prompt + cat .blender-alert-verdict.json >> .blender-prompt + fi env: ALERT_PACKAGE: ${{ inputs.alert_package }} ALERT_PATCHED_VERSION: ${{ inputs.alert_patched_version }} From 00ced7cdec5a28c8dc2edb464578d9c332c4d643 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 19 May 2026 13:42:16 -0500 Subject: [PATCH 13/15] fix: filter open alerts server-side in investigate-all-alerts The gh api call fetched all alerts and filtered client-side. Older open alerts fell off the first page behind newer fixed ones. Use state=open and per_page=100 query params so the API returns only open alerts. Co-Authored-By: Claude Opus 4.6 --- scripts/investigate-all-alerts.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/investigate-all-alerts.sh b/scripts/investigate-all-alerts.sh index 0da0674..ac16081 100755 --- a/scripts/investigate-all-alerts.sh +++ b/scripts/investigate-all-alerts.sh @@ -24,8 +24,8 @@ REF="${REF:-$(git rev-parse --abbrev-ref HEAD)}" WORKFLOW="investigate-security-alert.yml" echo "Fetching open Dependabot alerts for ${REPO}..." -alerts=$(gh api "repos/${REPO}/dependabot/alerts" \ - --jq '.[] | select(.state=="open") | { +alerts=$(gh api "repos/${REPO}/dependabot/alerts?state=open&per_page=100" \ + --jq '.[] | { number: .number, package: .security_vulnerability.package.name, ecosystem: .security_vulnerability.package.ecosystem, From 4c20cc2d513dbc6abc11a68dc553071b79853341 Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 19 May 2026 13:59:27 -0500 Subject: [PATCH 14/15] fix: include hidden files in verdict artifact upload upload-artifact v4 defaults include-hidden-files to false. The verdict file starts with a dot (.blender-alert-verdict.json), so it was silently skipped. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/investigate-security-alert.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/investigate-security-alert.yml b/.github/workflows/investigate-security-alert.yml index 7efab1f..9283c4e 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -89,6 +89,7 @@ jobs: name: alert-verdict path: target/.blender-alert-verdict.json if-no-files-found: ignore + include-hidden-files: true retention-days: 1 remediate: From 1b572be6803b5c410543e20dc59268354545df8b Mon Sep 17 00:00:00 2001 From: groovecoder <71928+groovecoder@users.noreply.github.com> Date: Tue, 19 May 2026 15:51:03 -0500 Subject: [PATCH 15/15] style: ruff format post_alert_action.py Co-Authored-By: Claude Opus 4.6 --- scripts/post_alert_action.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/post_alert_action.py b/scripts/post_alert_action.py index 85627c1..539736c 100644 --- a/scripts/post_alert_action.py +++ b/scripts/post_alert_action.py @@ -425,8 +425,12 @@ def main() -> None: elif recommended == "bump_pr": print(" No existing PR. Creating bump PR.") pr_num = create_bump_pr( - repo, package_name, ecosystem, patched_version, - alert_number, dry_run, + repo, + package_name, + ecosystem, + patched_version, + alert_number, + dry_run, ) if pr_num is not None: action = "bump_pr_created" @@ -461,9 +465,7 @@ def main() -> None: write_output("advisory_ghsa_id", ghsa_id) write_output("fork_repo", fork_repo) - write_step_summary( - repo_name, alert_number, package_name, severity, action, verdict - ) + write_step_summary(repo_name, alert_number, package_name, severity, action, verdict) def write_output(key: str, value: str) -> None: