diff --git a/.github/actions/setup-target/action.yml b/.github/actions/setup-target/action.yml new file mode 100644 index 0000000..c996577 --- /dev/null +++ b/.github/actions/setup-target/action.yml @@ -0,0 +1,166 @@ +--- +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: '' + permission-vulnerability-alerts: + description: 'Token permission for vulnerability-alerts (omit to inherit all)' + default: '' + permission-security-events: + description: 'Token permission for security-events (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 || '' }} + permission-vulnerability-alerts: ${{ inputs.permission-vulnerability-alerts || '' }} + permission-security-events: ${{ inputs.permission-security-events || '' }} + + - 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 899a17c..8562305 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,49 +58,22 @@ 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 id: gather 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: Upload gathered context @@ -153,7 +98,7 @@ jobs: ) gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$BODY" env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.setup.outputs.token }} PR_NUMBER: ${{ inputs.pr_number }} REPO: ${{ inputs.target_repo }} @@ -162,7 +107,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 }} @@ -172,7 +117,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 }} @@ -183,7 +128,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 @@ -242,7 +187,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 0c15e44..7753663 100644 --- a/.github/workflows/investigate-security-alert.yml +++ b/.github/workflows/investigate-security-alert.yml @@ -45,94 +45,31 @@ 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 }} - permission-contents: write - permission-pull-requests: write - permission-vulnerability-alerts: write - 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")" - 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 }} + # Omit explicit permissions — token inherits all installed + # permissions (contents, pull-requests, vulnerability-alerts, + # repository-advisories). We can't restrict because the token + # action has no input for repository-advisories. + 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 @@ -145,111 +82,102 @@ 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 alert summary - if: always() + - name: Upload verdict uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: alert-${{ inputs.alert_number }}-summary - path: target/.blender-alert-summary.html + name: alert-verdict + path: target/.blender-alert-verdict.json + if-no-files-found: ignore + include-hidden-files: true + 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: 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 - 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}. @@ -258,26 +186,33 @@ 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 }} - 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/config/defaults.yml b/config/defaults.yml index 40a6fd3..b192ff3 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -12,3 +12,12 @@ 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: "" + dismiss_unaffected: false diff --git a/prompts/investigate-alert-prompt.md b/prompts/investigate-alert-prompt.md new file mode 100644 index 0000000..5d86ea6 --- /dev/null +++ b/prompts/investigate-alert-prompt.md @@ -0,0 +1,91 @@ +# 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. + +**You have a limited turn budget. Be efficient. Your final response +must include the verdict JSON — that is the only deliverable that matters.** + +### 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) + +### 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 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 + +**If you already have enough evidence, skip to Step 4 now.** + +### Step 4: Output your verdict + +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: + +```` +```VERDICT_JSON +{ + "affected": false, + "confidence": "high", + "reason": "Brief explanation of why the repo is or is not affected", + "vulnerable_paths": [], + "recommended_action": "bump_pr" +} +``` +```` + +**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 or create any files. Read and analyze only. +- Do NOT run `git` commands. +- 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 ac2aebd..227d4b2 100644 --- a/prompts/major-bump-prompt.md +++ b/prompts/major-bump-prompt.md @@ -51,10 +51,11 @@ Are all CI checks passing on this PR? If not, what failed and is it related to t ### Step 5: 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", @@ -63,7 +64,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:** @@ -84,5 +84,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/alert_report.py b/scripts/alert_report.py new file mode 100644 index 0000000..88be7e9 --- /dev/null +++ b/scripts/alert_report.py @@ -0,0 +1,111 @@ +"""Report generator for Dependabot alert investigations. + +Produces markdown step summaries and annotations for GitHub Actions. +""" + +from __future__ import annotations + + +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}") 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/gather-alert-context.sh b/scripts/gather-alert-context.sh new file mode 100755 index 0000000..51281fc --- /dev/null +++ b/scripts/gather-alert-context.sh @@ -0,0 +1,113 @@ +#!/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 --- +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}" + +# --- 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/gather-context.sh b/scripts/gather-context.sh index b434508..f311bec 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/investigate-all-alerts.sh b/scripts/investigate-all-alerts.sh new file mode 100755 index 0000000..ac16081 --- /dev/null +++ b/scripts/investigate-all-alerts.sh @@ -0,0 +1,70 @@ +#!/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. +# +# 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?state=open&per_page=100" \ + --jq '.[] | { + 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 new file mode 100644 index 0000000..539736c --- /dev/null +++ b/scripts/post_alert_action.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Post-investigation action for Dependabot security alerts. + +Reads .blender-alert-verdict.json and takes the appropriate action: + + 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 a summary to $GITHUB_STEP_SUMMARY and emits an annotation. + +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_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_UNAFFECTED -- Set to "true" to dismiss unaffected alerts +""" + +from __future__ import annotations + +import json +import os +import sys +import time +from pathlib import Path + +# 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 github import Auth, Github # noqa: E402 + +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 = { + "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 create_advisory_and_fork( + repo, + alert_number: int, + package_name: str, + dry_run: bool, + severity: str = "low", +) -> 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": severity or "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 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 _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, + reason: str, + 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 = ( + 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: + 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)" + 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_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_NAME}](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, + reason: str, + dry_run: bool, +) -> None: + """Dismiss a Dependabot alert as not used (unaffected).""" + 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": "not_used", + "dismissed_comment": f"{BLENDER_NAME}: {reason}", + } + repo._requester.requestJsonAndCheck("PATCH", url, input=payload) + print(f" Dismissed alert #{alert_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_UNAFFECTED", "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)')}") + + reason = verdict.get("reason", "(none)") + + if not affected: + # 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" + elif dismiss_enabled: + print( + f" Unaffected but severity is {severity}." + " Recommend manual review before dismissing." + ) + action = "noop" + else: + action = "noop" + write_output("action", action) + else: + print(" Affected. Creating advisory and private fork.") + ghsa_id, fork_repo = create_advisory_and_fork( + repo, + alert_number, + package_name, + dry_run, + severity=severity.lower() if severity else "low", + ) + action = "private_fork" + write_output("action", action) + 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) + + +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 4f3c585..72aa220 100755 --- a/scripts/run-claude.sh +++ b/scripts/run-claude.sh @@ -45,10 +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" = "major" ]; then +if [ "$BLENDER_MODE" = "investigate" ]; then ALLOWED_TOOLS="Read,Bash" + 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. 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,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." @@ -98,13 +104,11 @@ 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 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 @@ -122,7 +126,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." @@ -130,6 +134,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." @@ -172,6 +183,31 @@ 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 + + # 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." + else + echo "No alert verdict extracted. Post-action will handle this." + fi + + exit 0 +fi + # --- Fix mode: existing validation --- # Path validation: reject changes to sensitive paths 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 22a0f73..89e1401 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 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 duplicate check + + 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..fae2767 --- /dev/null +++ b/tests/scripts/test_post_alert_action.py @@ -0,0 +1,329 @@ +"""Tests for scripts.post_alert_action.""" + +from __future__ import annotations + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.alert_report import ( + annotation_line, + render_markdown, + write_step_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, +) + + +@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 + + +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(SAMPLE_VERDICT) + 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 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 == "" + + +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": "not_used", + "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 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) + 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", + ) + + mock_repo.create_pull.assert_called_once() + content = open(summary_file).read() + assert "Bump PR created" in content + + 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 + ) + + 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) + 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] + + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + DISMISS_UNAFFECTED="true", ALERT_SEVERITY="low", + ) + + # Should comment on PR, not dismiss + mock_repo.get_pull.assert_called_once_with(99) + + def test_dismiss_enabled_no_pr_no_bump( + self, verdict_file, tmp_path, monkeypatch + ): + """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": "not_used", + "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 = [] + + self._run_main( + verdict_file, tmp_path, monkeypatch, mock_repo, + DISMISS_UNAFFECTED="true", ALERT_SEVERITY="high", + ) + + mock_repo._requester.requestJsonAndCheck.assert_not_called() 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 == []