diff --git a/.github/renovate-cve-config.js b/.github/renovate-cve-config.js new file mode 100644 index 000000000..d40a67eb8 --- /dev/null +++ b/.github/renovate-cve-config.js @@ -0,0 +1,48 @@ +// Renovate config for CVE Remediation Controller. Env: RENOVATE_TARGET_REPO, RENOVATE_BASE_BRANCH, RENOVATE_PACKAGE_NAME, RENOVATE_CVE_ID. + +const prBody = `## Security Update + +This PR fixes **${process.env.RENOVATE_CVE_ID || 'CVE'}** by updating \`${process.env.RENOVATE_PACKAGE_NAME || 'package'}\`. + +--- +*Created by [CVE Remediation Controller](https://github.com/project-codeflare/codeflare-operator/blob/main/.github/workflows/cve-controller.yml)* +`; + +module.exports = { + platform: 'github', + onboarding: false, + requireConfig: 'ignored', + + repositories: [process.env.RENOVATE_TARGET_REPO].filter(Boolean), + baseBranches: [process.env.RENOVATE_BASE_BRANCH].filter(Boolean), + enabledManagers: ['gomod'], + + packageRules: [ + { + matchPackagePatterns: [process.env.RENOVATE_PACKAGE_NAME].filter(Boolean), + enabled: true, + recreateWhen: 'always', + rebaseWhen: 'behind-base-branch', + prPriority: 99, + labels: ['security', 'cve-fix', process.env.RENOVATE_CVE_ID].filter(Boolean), + }, + { + matchPackagePatterns: ['*'], + excludePackagePatterns: [process.env.RENOVATE_PACKAGE_NAME].filter(Boolean), + enabled: false, + }, + ], + + prTitle: `fix(security): Update ${process.env.RENOVATE_PACKAGE_NAME || 'package'} [${process.env.RENOVATE_CVE_ID || 'CVE'}]`, + prBody: prBody, + branchPrefix: 'cve-fix/', + branchName: `cve-fix/${process.env.RENOVATE_CVE_ID || 'cve'}-${(process.env.RENOVATE_PACKAGE_NAME || 'pkg').split('/').pop()}`.toLowerCase().replace(/[^a-z0-9-/]/g, '-'), + + dependencyDashboard: false, + prHourlyLimit: 0, + prConcurrentLimit: 0, + branchConcurrentLimit: 0, + commitMessagePrefix: 'fix(security):', + postUpdateOptions: ['gomodTidy'], + logLevel: process.env.LOG_LEVEL || 'info', +}; diff --git a/.github/workflows/cve-controller.yml b/.github/workflows/cve-controller.yml new file mode 100644 index 000000000..7b90bbd94 --- /dev/null +++ b/.github/workflows/cve-controller.yml @@ -0,0 +1,234 @@ +# CVE Remediation Controller: scan stream branch for a CVE; if affected, open fix PR via Renovate, then recheck PR and close with comment if still vulnerable. +# Trigger: workflow_dispatch with cve_id and base_branch. Secret: CVE_CONTROLLER_PAT (repo scope). + +name: CVE Remediation Controller + +on: + workflow_dispatch: + inputs: + cve_id: + description: 'CVE identifier (e.g., CVE-2024-12345)' + required: true + type: string + base_branch: + description: 'Base branch to scan and target for fix (e.g., rhoai-2.16)' + required: true + type: string + repo_override: + description: 'E2E only: repo instead of prod' + required: false + type: string + force_recheck_fail: + description: 'E2E only: force recheck to fail' + required: false + type: string + +concurrency: + group: cve-operator-${{ github.event.inputs.repo_override || 'prod' }}-${{ github.event.inputs.cve_id }}-${{ github.event.inputs.base_branch }} + cancel-in-progress: false + +env: + TARGET_REPO: ${{ github.event.inputs.repo_override || 'red-hat-data-services/codeflare-operator' }} + CONFIG_REPO: ${{ github.event.inputs.repo_override || 'project-codeflare/codeflare-operator' }} + +jobs: + scan: + name: Scan for CVE + runs-on: ubuntu-latest + outputs: + vulnerable: ${{ steps.scan.outputs.vulnerable }} + package_name: ${{ steps.scan.outputs.package_name }} + fix_branch: ${{ steps.branch.outputs.fix_branch }} + + steps: + - name: Generate fix branch name + id: branch + run: | + CVE_ID="${{ github.event.inputs.cve_id }}" + BASE_BRANCH="${{ github.event.inputs.base_branch }}" + FIX_BRANCH="cve-fix/${CVE_ID}-${BASE_BRANCH}" + FIX_BRANCH=$(echo "$FIX_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9./-]/-/g') + echo "fix_branch=${FIX_BRANCH}" >> $GITHUB_OUTPUT + + - name: Create fix branch from base branch + run: | + FIX_BRANCH="${{ steps.branch.outputs.fix_branch }}" + BASE_BRANCH="${{ github.event.inputs.base_branch }}" + git clone https://x-access-token:${{ secrets.CVE_CONTROLLER_PAT }}@github.com/${{ env.TARGET_REPO }}.git repo + cd repo + git config user.email "cve-controller@github.com" + git config user.name "CVE Controller" + git fetch origin "${BASE_BRANCH}" + if git ls-remote --heads origin "${FIX_BRANCH}" | grep -q .; then + echo "Branch ${FIX_BRANCH} already exists" + else + git checkout -b "${FIX_BRANCH}" "origin/${BASE_BRANCH}" + git push origin "${FIX_BRANCH}" + fi + + - name: Checkout fix branch + uses: actions/checkout@v4 + with: + repository: ${{ env.TARGET_REPO }} + ref: ${{ steps.branch.outputs.fix_branch }} + token: ${{ secrets.CVE_CONTROLLER_PAT }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Scan source for CVE + id: scan + run: | + set +e + CVE_ID="${{ github.event.inputs.cve_id }}" + GOVOUT=$(govulncheck ./... 2>&1) + echo "$GOVOUT" + if echo "$GOVOUT" | grep -qi "${CVE_ID}"; then + echo "vulnerable=true" >> $GITHUB_OUTPUT + PKG=$(echo "$GOVOUT" | grep -A 10 -i "${CVE_ID}" | grep "Module:" | head -1 | sed 's/.*Module: //') + [ -z "$PKG" ] && PKG=$(echo "$GOVOUT" | grep "Module:" | head -1 | sed 's/.*Module: //') + echo "package_name=${PKG:-unknown}" >> $GITHUB_OUTPUT + else + echo "vulnerable=false" >> $GITHUB_OUTPUT + echo "package_name=" >> $GITHUB_OUTPUT + fi + + - name: Summary - No CVE detected + if: steps.scan.outputs.vulnerable == 'false' + run: echo "CVE ${{ github.event.inputs.cve_id }} not detected. Workflow complete." + + fix: + name: Create Fix PR + needs: scan + if: needs.scan.outputs.vulnerable == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout (Renovate config) + uses: actions/checkout@v4 + with: + repository: ${{ env.CONFIG_REPO }} + token: ${{ secrets.CVE_CONTROLLER_PAT }} + + - name: Run Renovate + uses: renovatebot/github-action@v46.0.1 + with: + configurationFile: .github/renovate-cve-config.js + token: ${{ secrets.CVE_CONTROLLER_PAT }} + env: + RENOVATE_TARGET_REPO: ${{ env.TARGET_REPO }} + RENOVATE_BASE_BRANCH: ${{ needs.scan.outputs.fix_branch }} + RENOVATE_PACKAGE_NAME: ${{ needs.scan.outputs.package_name }} + RENOVATE_CVE_ID: ${{ github.event.inputs.cve_id }} + RENOVATE_COMPONENT: codeflare-operator + RENOVATE_SCAN_TYPE: go + + recheck: + name: Recheck PR + needs: [fix, scan] + if: needs.scan.outputs.vulnerable == 'true' + runs-on: ubuntu-latest + outputs: + pr_number: ${{ steps.pr.outputs.number }} + head_ref: ${{ steps.pr.outputs.head_ref }} + steps: + - name: Find Renovate PR + id: pr + run: | + FIX_BRANCH="${{ needs.scan.outputs.fix_branch }}" + for i in $(seq 1 12); do + PR_JSON=$(gh pr list --repo ${{ env.TARGET_REPO }} --base "${FIX_BRANCH}" --state open --json number,headRefName --limit 1) + if [ -n "$PR_JSON" ] && [ "$PR_JSON" != "[]" ]; then + echo "number=$(echo "$PR_JSON" | jq -r '.[0].number')" >> $GITHUB_OUTPUT + echo "head_ref=$(echo "$PR_JSON" | jq -r '.[0].headRefName')" >> $GITHUB_OUTPUT + exit 0 + fi + sleep 10 + done + echo "number=" >> $GITHUB_OUTPUT + echo "head_ref=" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.CVE_CONTROLLER_PAT }} + + - name: Checkout PR branch + if: steps.pr.outputs.number != '' + uses: actions/checkout@v4 + with: + repository: ${{ env.TARGET_REPO }} + ref: ${{ steps.pr.outputs.head_ref }} + token: ${{ secrets.CVE_CONTROLLER_PAT }} + + - name: Setup Go + if: steps.pr.outputs.number != '' + uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + + - name: Install govulncheck + if: steps.pr.outputs.number != '' + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Recheck CVE on PR branch + if: steps.pr.outputs.number != '' + id: recheck + run: | + CVE_ID="${{ github.event.inputs.cve_id }}" + if [ "${{ github.event.inputs.force_recheck_fail }}" = "true" ]; then + echo "still_vulnerable=true" >> $GITHUB_OUTPUT + exit 0 + fi + set +e + GOVOUT=$(govulncheck ./... 2>&1) + echo "$GOVOUT" + if echo "$GOVOUT" | grep -qi "${CVE_ID}"; then + echo "still_vulnerable=true" >> $GITHUB_OUTPUT + else + echo "still_vulnerable=false" >> $GITHUB_OUTPUT + fi + + - name: Close PR - CVE still present + if: steps.pr.outputs.number != '' && steps.recheck.outputs.still_vulnerable == 'true' + run: | + gh pr close ${{ steps.pr.outputs.number }} --repo ${{ env.TARGET_REPO }} \ + --comment "Recheck failed: this change did not fix **${{ github.event.inputs.cve_id }}**. Please fix manually." + env: + GH_TOKEN: ${{ secrets.CVE_CONTROLLER_PAT }} + + lint: + name: Lint Renovate PR + needs: [recheck, scan] + if: needs.scan.outputs.vulnerable == 'true' && needs.recheck.outputs.pr_number != '' + runs-on: ubuntu-latest + container: + image: quay.io/opendatahub/pre-commit-go-toolchain:v0.2 + env: + XDG_CACHE_HOME: /cache + GOCACHE: /cache/go-build + GOMODCACHE: /cache/go-mod + PRE_COMMIT_HOME: /cache/pre-commit + volumes: + - /cache + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ env.TARGET_REPO }} + ref: ${{ needs.recheck.outputs.head_ref }} + token: ${{ secrets.CVE_CONTROLLER_PAT }} + + - name: Set Go + uses: actions/setup-go@v5 + with: + go-version-file: './go.mod' + + - name: Cache + uses: actions/cache@v4 + with: + path: /cache + key: ${{ runner.os }}-cache-${{ hashFiles('**/go.sum', '.pre-commit-config.yaml') }} + + - name: Run pre-commit checks + run: pre-commit run --show-diff-on-failure --color=always --all-files