diff --git a/.github/workflows/security-scanning-and-remediation.yml b/.github/workflows/security-scanning-and-remediation.yml new file mode 100644 index 0000000..29b6aaa --- /dev/null +++ b/.github/workflows/security-scanning-and-remediation.yml @@ -0,0 +1,359 @@ +name: Create PRs to Remediate vulns + +# ============================================================ +# This workflow fetches open Dependabot alerts from GitHub and +# creates PRs for each alert with instructions for Devin AI. +# +# TOKEN REQUIREMENTS: +# - GITHUB_TOKEN: Default token with vulnerability-alerts:read for Dependabot alerts, +# and contents:write for creating branches and PRs +# - DEVIN_AI_PR_BOT_SLACK_TOKEN: For Slack notifications to Devin AI +# ============================================================ + +on: + workflow_dispatch: # Allow manual triggering + schedule: + # Run every night at 8pm EST (1am UTC next day) + - cron: "0 1 * * *" + +jobs: + create-dependabot-prs: + name: Check Dependabot alerts + # Skip this job when triggered by cron schedule + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Fetch Dependabot alerts + id: fetch-alerts + uses: actions/github-script@v8 + env: + ALERTS_FILE: ${{ runner.temp }}/dependabot-alerts.json + with: + script: | + const fs = require('fs'); + const owner = context.repo.owner; + const repo = context.repo.repo; + const alertsFile = process.env.ALERTS_FILE; + + // Fetch all open Dependabot alerts using the workflow's GITHUB_TOKEN + let alerts = []; + try { + const response = await github.rest.dependabot.listAlertsForRepo({ + owner, + repo, + state: 'open', + per_page: 100 + }); + alerts = response.data; + } catch (error) { + if (error.status === 403 || error.status === 404) { + console.log('ERROR: Unable to access Dependabot alerts. This is likely because:'); + console.log('1. Dependabot alerts are not enabled for this repository, OR'); + console.log('2. The GITHUB_TOKEN does not have the required `vulnerability-alerts: read` permission'); + console.log(''); + console.log('To fix: Enable Dependabot alerts in repo settings or ensure the workflow has appropriate permissions'); + fs.writeFileSync(alertsFile, '[]'); + core.setOutput('alerts_count', '0'); + return; + } + throw error; + } + + if (alerts.length === 0) { + console.log('No open Dependabot alerts found.'); + fs.writeFileSync(alertsFile, '[]'); + core.setOutput('alerts_count', '0'); + return; + } + + console.log(`Found ${alerts.length} open Dependabot alerts.`); + fs.writeFileSync(alertsFile, JSON.stringify(alerts)); + core.setOutput('alerts_count', String(alerts.length)); + + - name: Create PRs and send Slack notifications + id: create-prs + if: steps.fetch-alerts.outputs.alerts_count != '0' + uses: actions/github-script@v8 + env: + # ============================================================ + # DEVIN PROMPT CONFIGURATION + # Edit the prompt below to customize instructions for Devin + # ============================================================ + DEVIN_PROMPT: | + @devin-ai-integration Please resolve this Dependabot security alert. + + **Instructions:** + 1. Analyze the vulnerability and understand its impact + 2. Update the affected dependency to a secure version + 3. Ideally resolve this without using an override - prefer updating the dependency directly + 4. If an override is absolutely necessary, document why in the PR description + 5. Run tests to ensure the update doesn't break anything + 6. Push your fix to this PR branch and tag @davidkonigsberg for review + 7. Delete the scaffold file (.github/dependabot-alerts/alert-*.md) as part of your fix + + **Alert Details:** + ALERTS_FILE: ${{ runner.temp }}/dependabot-alerts.json + SLACK_TOKEN: ${{ secrets.DEVIN_AI_PR_BOT_SLACK_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const devinPrompt = process.env.DEVIN_PROMPT; + const slackToken = process.env.SLACK_TOKEN; + const slackChannelId = 'C0A23CZEFNF'; + const devinMention = '<@U088PL5FS3B>'; + const owner = context.repo.owner; + const repo = context.repo.repo; + const baseBranch = 'main'; + + // Sanitize text from upstream advisories so that @-mentions embedded + // in third-party descriptions do not auto-tag unrelated GitHub users. + const ALLOWED_MENTIONS = new Set(['devin-ai-integration', 'davidkonigsberg']); + const sanitizeMentions = (text) => { + if (text == null) return text; + return String(text).replace( + /(^|[^A-Za-z0-9`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38})?)/g, + (match, pre, handle) => + ALLOWED_MENTIONS.has(handle.toLowerCase()) + ? match + : `${pre}\`@${handle}\`` + ); + }; + + const alerts = JSON.parse(fs.readFileSync(process.env.ALERTS_FILE, 'utf8')); + if (alerts.length === 0) { + console.log('No alerts to process.'); + return; + } + + console.log(`Processing ${alerts.length} Dependabot alerts.`); + + // Fetch existing OPEN PRs to check for duplicates + const existingPRs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + per_page: 100 + }); + + // Create a set of alert numbers that already have open PRs + const existingAlertNumbers = new Set(); + const existingPackages = new Set(); + for (const pr of existingPRs) { + const alertMatch = pr.title.match(/\[Dependabot Alert #(\d+)\]/); + if (alertMatch) { + existingAlertNumbers.add(parseInt(alertMatch[1])); + const packageMatch = pr.title.match(/\[Dependabot Alert #\d+\] \w+: (.+) vulnerability/); + if (packageMatch) { + existingPackages.add(packageMatch[1]); + } + } + } + + console.log(`Found ${existingAlertNumbers.size} existing open PRs for Dependabot alerts.`); + console.log(`Found ${existingPackages.size} packages with existing open PRs: ${[...existingPackages].join(', ')}`); + + // Track packages we create PRs for in this run to avoid duplicates + const packagesWithNewPRs = new Set(); + + // Get the base branch ref for creating new branches + const baseRef = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${baseBranch}`, + }); + const baseCommitSha = baseRef.data.object.sha; + + const baseCommit = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: baseCommitSha, + }); + const baseTreeSha = baseCommit.data.tree.sha; + + // Helper function to send Slack notification + async function sendSlackNotification(prInfo) { + const message = `${devinMention} *New Dependabot Alert PR Created*\nNew PR created for Dependabot security alert. Please update the PR in the \`sync-openapi\` repo to address the dependabot alert "<${prInfo.alertUrl}|${prInfo.alertName}>". Follow the instructions already in the <${prInfo.url}|PR>.`; + + try { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${slackToken}` + }, + body: JSON.stringify({ + channel: slackChannelId, + text: message, + mrkdwn: true + }) + }); + + const result = await response.json(); + if (result.ok) { + console.log(`Sent Slack notification for PR #${prInfo.number}: ${prInfo.alertName}`); + } else { + console.error(`Failed to send Slack notification for PR #${prInfo.number}: ${result.error}`); + } + } catch (error) { + console.error(`Error sending Slack notification for PR #${prInfo.number}: ${error.message}`); + } + } + + // Create PRs for new alerts + let createdCount = 0; + let skippedDueToPackage = 0; + for (const alert of alerts) { + if (existingAlertNumbers.has(alert.number)) { + console.log(`PR already exists for alert #${alert.number}, skipping.`); + continue; + } + + const severity = alert.security_advisory?.severity || 'unknown'; + const packageName = alert.security_vulnerability?.package?.name || 'unknown package'; + + // Skip if there's already an open PR for this package + if (existingPackages.has(packageName) || packagesWithNewPRs.has(packageName)) { + console.log(`PR already exists for package "${packageName}" (alert #${alert.number}), skipping to avoid duplicate PRs.`); + skippedDueToPackage++; + continue; + } + const ecosystem = alert.security_vulnerability?.package?.ecosystem || 'unknown'; + const vulnerableVersionRange = alert.security_vulnerability?.vulnerable_version_range || 'unknown'; + const patchedVersions = alert.security_vulnerability?.first_patched_version?.identifier || 'No patch available'; + const cveId = alert.security_advisory?.cve_id || 'N/A'; + const ghsaId = alert.security_advisory?.ghsa_id || 'N/A'; + const summary = sanitizeMentions(alert.security_advisory?.summary) || 'No summary available'; + const description = sanitizeMentions(alert.security_advisory?.description) || 'No description available'; + const manifestPath = alert.dependency?.manifest_path || 'unknown'; + + const branchName = `dependabot-alert-${alert.number}-devin`; + const filePath = `.github/dependabot-alerts/alert-${alert.number}.md`; + + // Check if branch already exists + let branchExists = false; + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + branchExists = true; + } catch (e) { + if (e.status !== 404) throw e; + } + + if (branchExists) { + const prForBranch = existingPRs.find(pr => pr.head.ref === branchName); + if (prForBranch) { + console.log(`Skipping alert #${alert.number}: branch ${branchName} has an open PR #${prForBranch.number}`); + continue; + } + // No open PR for this branch, delete it so we can create a fresh one + console.log(`Deleting old branch ${branchName} from closed PR...`); + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + } + + const prTitle = `[Dependabot Alert #${alert.number}] ${severity.toUpperCase()}: ${packageName} vulnerability`; + + const alertContent = `${devinPrompt} + - **Package:** ${packageName} (${ecosystem}) + - **Severity:** ${severity.toUpperCase()} + - **Vulnerable versions:** ${vulnerableVersionRange} + - **Patched version:** ${patchedVersions} + - **CVE:** ${cveId} + - **GHSA:** ${ghsaId} + - **Manifest:** ${manifestPath} + + **Summary:** + ${summary} + + **Description:** + ${description} + + --- + [View Dependabot Alert](https://github.com/${owner}/${repo}/security/dependabot/${alert.number}) + `; + + try { + // Create a blob with the alert content + const blob = await github.rest.git.createBlob({ + owner, + repo, + content: alertContent, + encoding: 'utf-8', + }); + + // Create a tree with the new file + const tree = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseTreeSha, + tree: [ + { + path: filePath, + mode: '100644', + type: 'blob', + sha: blob.data.sha, + }, + ], + }); + + // Create a commit + const commit = await github.rest.git.createCommit({ + owner, + repo, + message: `[Dependabot Alert #${alert.number}] Scaffold PR for ${packageName}`, + tree: tree.data.sha, + parents: [baseCommitSha], + }); + + // Create the branch + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: commit.data.sha, + }); + + // Create the PR + const pr = await github.rest.pulls.create({ + owner, + repo, + title: prTitle, + head: branchName, + base: baseBranch, + body: alertContent, + draft: true, + }); + + console.log(`Created PR for alert #${alert.number}: ${packageName}`); + createdCount++; + + // Track this package so we don't create duplicate PRs + packagesWithNewPRs.add(packageName); + + // Send Slack notification + const alertUrl = `https://github.com/${owner}/${repo}/security/dependabot/${alert.number}`; + await sendSlackNotification({ + number: pr.data.number, + url: pr.data.html_url, + alertName: summary, + alertUrl + }); + } catch (error) { + console.error(`Failed to create PR for alert #${alert.number}: ${error.message}`); + } + } + + console.log(`Created ${createdCount} new PRs from Dependabot alerts.`); + if (skippedDueToPackage > 0) { + console.log(`Skipped ${skippedDueToPackage} alerts because PRs already exist for those packages.`); + }