-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Add GitHub security advisory alerts workflow #3087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| name: GitHub Security Advisory Alerts | ||
|
|
||
| on: | ||
| schedule: | ||
| # Nightly at 09:00 UTC | ||
| - cron: "0 9 * * *" | ||
| workflow_dispatch: {} | ||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: github-security-advisory-alerts | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| send-alert: | ||
| environment: ai-bots | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - name: Create GitHub App token | ||
| id: app-token | ||
| uses: actions/create-github-app-token@v2 | ||
| with: | ||
| app-id: ${{ vars.KEPPO_GITHUB_APP_ID }} | ||
| private-key: ${{ secrets.KEPPO_GITHUB_APP_PRIVATE_KEY }} | ||
| # No permission-* scoping: repository_advisories is not yet | ||
| # supported by create-github-app-token, so we inherit all | ||
| # permissions from the App installation. | ||
|
|
||
| - name: Checkout repository | ||
| uses: actions/checkout@v5 | ||
| with: | ||
| fetch-depth: 1 | ||
| persist-credentials: false | ||
|
|
||
| - name: Setup Node | ||
| uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: 22 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The workflow installs Node 22, but the repo’s engines requirement is Node >=24. This can break the alert script or future dependencies that assume 24+. Use Node 24 in the workflow to align with the project’s supported runtime. Prompt for AI agents |
||
|
|
||
| - name: Send advisory alert email | ||
| env: | ||
| GITHUB_API_URL: ${{ github.api_url }} | ||
| GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| GITHUB_REPOSITORY: ${{ github.repository }} | ||
| SECURITY_ADVISORY_ALERT_EMAILS: ${{ vars.SECURITY_ADVISORY_ALERT_EMAILS }} | ||
| GITHUB_RUN_ID: ${{ github.run_id }} | ||
| GITHUB_SERVER_URL: ${{ github.server_url }} | ||
| MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} | ||
| MAILGUN_DOMAIN: ${{ vars.MAILGUN_DOMAIN }} | ||
| MAILGUN_FROM_EMAIL: ${{ vars.MAILGUN_FROM_EMAIL }} | ||
| run: node scripts/github-security-advisory-alert.mjs | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| import fs from "node:fs/promises"; | ||
|
|
||
| const GITHUB_API_VERSION = "2022-11-28"; | ||
| const MAILGUN_API_BASE_URL = "https://api.mailgun.net/v3"; | ||
| const ADVISORY_STATES = ["triage", "draft"]; | ||
|
|
||
| const requireEnv = (name) => { | ||
| const value = process.env[name]?.trim(); | ||
| if (!value) { | ||
| throw new Error(`Missing required environment variable: ${name}`); | ||
| } | ||
| return value; | ||
| }; | ||
|
|
||
| const parseRecipients = (value) => { | ||
| const seen = new Set(); | ||
| const recipients = []; | ||
|
|
||
| for (const entry of value.split(",")) { | ||
| const email = entry.trim(); | ||
| if (!email || seen.has(email)) { | ||
| continue; | ||
| } | ||
| seen.add(email); | ||
| recipients.push(email); | ||
| } | ||
|
|
||
| if (recipients.length === 0) { | ||
| throw new Error( | ||
| "SECURITY_ADVISORY_ALERT_EMAILS must contain at least one email address", | ||
| ); | ||
| } | ||
|
|
||
| return recipients; | ||
| }; | ||
|
Comment on lines
+15
to
+35
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function can be written more concisely using modern JavaScript features. Using a const parseRecipients = (value) => {
const recipients = [
...new Set(
value
.split(",")
.map((email) => email.trim())
.filter(Boolean),
),
];
if (recipients.length === 0) {
throw new Error(
"SECURITY_ADVISORY_ALERT_EMAILS must contain at least one email address",
);
}
return recipients;
}; |
||
|
|
||
| const getNextPageUrl = (linkHeader) => { | ||
| if (!linkHeader) { | ||
| return null; | ||
| } | ||
|
|
||
| for (const part of linkHeader.split(",")) { | ||
| const match = part.match(/<([^>]+)>;\s*rel="next"/); | ||
| if (match) { | ||
| return match[1]; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| }; | ||
|
|
||
| const readResponseBody = async (response) => { | ||
| const text = await response.text(); | ||
| return text.trim().slice(0, 500); | ||
| }; | ||
|
|
||
| const fetchAdvisoryCount = async ({ apiBaseUrl, repository, token, state }) => { | ||
| let nextUrl = new URL( | ||
| `${apiBaseUrl}/repos/${repository}/security-advisories`, | ||
| ); | ||
| nextUrl.searchParams.set("state", state); | ||
| nextUrl.searchParams.set("per_page", "100"); | ||
|
|
||
| let total = 0; | ||
|
|
||
| while (nextUrl) { | ||
| const response = await fetch(nextUrl, { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| Accept: "application/vnd.github+json", | ||
| "X-GitHub-Api-Version": GITHUB_API_VERSION, | ||
| }, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const body = await readResponseBody(response); | ||
| throw new Error( | ||
| `Failed to list ${state} security advisories: ${response.status} ${body}`, | ||
| ); | ||
| } | ||
|
|
||
| const advisories = await response.json(); | ||
| if (!Array.isArray(advisories)) { | ||
| throw new Error(`Unexpected ${state} advisories response shape`); | ||
| } | ||
|
|
||
| total += advisories.length; | ||
|
|
||
| const nextPage = getNextPageUrl(response.headers.get("link")); | ||
| nextUrl = nextPage ? new URL(nextPage) : null; | ||
| } | ||
|
|
||
| return total; | ||
| }; | ||
|
|
||
| const escapeHtml = (value) => | ||
| value | ||
| .replaceAll("&", "&") | ||
| .replaceAll("<", "<") | ||
| .replaceAll(">", ">") | ||
| .replaceAll('"', """) | ||
| .replaceAll("'", "'"); | ||
|
|
||
| const appendStepSummary = async (summary) => { | ||
| const path = process.env.GITHUB_STEP_SUMMARY; | ||
| if (!path) { | ||
| return; | ||
| } | ||
| await fs.appendFile(path, `${summary}\n`, "utf8"); | ||
| }; | ||
|
|
||
| const sendMailgunEmail = async ({ | ||
| apiKey, | ||
| domain, | ||
| from, | ||
| recipients, | ||
| subject, | ||
| text, | ||
| html, | ||
| }) => { | ||
| const response = await fetch(`${MAILGUN_API_BASE_URL}/${domain}/messages`, { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Basic ${Buffer.from(`api:${apiKey}`).toString("base64")}`, | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| }, | ||
| body: new URLSearchParams({ | ||
| from, | ||
| to: recipients.join(","), | ||
| subject, | ||
| text, | ||
| html, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const body = await readResponseBody(response); | ||
| throw new Error( | ||
| `Failed to send advisory email: ${response.status} ${body}`, | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| const main = async () => { | ||
| const token = requireEnv("GITHUB_TOKEN"); | ||
| const repository = requireEnv("GITHUB_REPOSITORY"); | ||
| const mailgunApiKey = requireEnv("MAILGUN_API_KEY"); | ||
| const mailgunDomain = requireEnv("MAILGUN_DOMAIN"); | ||
| const fromEmail = requireEnv("MAILGUN_FROM_EMAIL"); | ||
| const recipients = parseRecipients( | ||
| requireEnv("SECURITY_ADVISORY_ALERT_EMAILS"), | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM | code-health Mailgun env vars eagerly validated before advisory count check All environment variables including This means a misconfigured or expired Mailgun secret will cause the nightly workflow to fail even when there are zero advisories to report. 💡 Suggestion: Move the Mailgun and recipient env var validation to after the |
||
| const githubApiBaseUrl = | ||
| process.env.GITHUB_API_URL?.trim() || "https://api.github.com"; | ||
| const githubServerUrl = | ||
| process.env.GITHUB_SERVER_URL?.trim() || "https://github.com"; | ||
| const runId = process.env.GITHUB_RUN_ID?.trim(); | ||
|
|
||
| const advisoryCounts = Object.fromEntries( | ||
| await Promise.all( | ||
| ADVISORY_STATES.map(async (state) => [ | ||
| state, | ||
| await fetchAdvisoryCount({ | ||
| repository, | ||
| token, | ||
| state, | ||
| apiBaseUrl: githubApiBaseUrl, | ||
| }), | ||
| ]), | ||
| ), | ||
| ); | ||
| const totalCount = ADVISORY_STATES.reduce( | ||
| (sum, state) => sum + advisoryCounts[state], | ||
| 0, | ||
| ); | ||
|
|
||
| const triageUrl = `${githubServerUrl}/${repository}/security/advisories?state=triage`; | ||
| const draftUrl = `${githubServerUrl}/${repository}/security/advisories?state=draft`; | ||
| const runUrl = runId | ||
| ? `${githubServerUrl}/${repository}/actions/runs/${runId}` | ||
| : null; | ||
|
|
||
| await appendStepSummary(`Repository: \`${repository}\``); | ||
| await appendStepSummary(`Triage advisories: ${advisoryCounts.triage}`); | ||
| await appendStepSummary(`Draft advisories: ${advisoryCounts.draft}`); | ||
|
Comment on lines
+184
to
+185
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The script hardcodes the 'triage' and 'draft' states when generating the summary and email content. This is inconsistent with the data fetching logic, which dynamically uses the To improve maintainability and robustness, you should generate these report sections dynamically by iterating over For example, for the step summary: for (const state of ADVISORY_STATES) {
const capitalizedState = state.charAt(0).toUpperCase() + state.slice(1);
await appendStepSummary(`${capitalizedState} advisories: ${advisoryCounts[state]}`);
}A similar approach should be applied to generate the lists and links in the text and HTML email bodies. |
||
| await appendStepSummary( | ||
| `Total open advisories in triage/draft: ${totalCount}`, | ||
| ); | ||
|
|
||
| if (totalCount === 0) { | ||
| console.log( | ||
| `No open triage or draft security advisories found for ${repository}.`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const subject = `[ALERT] You have ${totalCount} GitHub security advisories open for ${repository}`; | ||
| const textLines = [ | ||
| `Repository: ${repository}`, | ||
| "", | ||
| `Open GitHub security advisories in triage/draft: ${totalCount}`, | ||
| `Triage: ${advisoryCounts.triage}`, | ||
| `Draft: ${advisoryCounts.draft}`, | ||
| "", | ||
| "Review advisories:", | ||
| `Triage: ${triageUrl}`, | ||
| `Draft: ${draftUrl}`, | ||
| ]; | ||
|
|
||
| if (runUrl) { | ||
| textLines.push("", `Workflow run: ${runUrl}`); | ||
| } | ||
|
|
||
| const html = ` | ||
| <!doctype html> | ||
| <html> | ||
| <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;line-height:1.5;"> | ||
| <h2 style="margin-bottom:12px;">GitHub security advisory alert</h2> | ||
| <p style="margin:0 0 12px;"><strong>Repository:</strong> ${escapeHtml(repository)}</p> | ||
| <p style="margin:0 0 12px;"> | ||
| Open GitHub security advisories in <code>triage</code>/<code>draft</code>: <strong>${totalCount}</strong> | ||
| </p> | ||
| <ul style="margin:0 0 16px;padding-left:20px;"> | ||
| <li>Triage: ${advisoryCounts.triage}</li> | ||
| <li>Draft: ${advisoryCounts.draft}</li> | ||
| </ul> | ||
| <p style="margin:0 0 8px;"><a href="${escapeHtml(triageUrl)}">Review triage advisories</a></p> | ||
| <p style="margin:0 0 8px;"><a href="${escapeHtml(draftUrl)}">Review draft advisories</a></p> | ||
| ${runUrl ? `<p style="margin:16px 0 0;">Workflow run: <a href="${escapeHtml(runUrl)}">${escapeHtml(runUrl)}</a></p>` : ""} | ||
| </body> | ||
| </html> | ||
| `.trim(); | ||
|
|
||
| await sendMailgunEmail({ | ||
| apiKey: mailgunApiKey, | ||
| domain: mailgunDomain, | ||
| from: fromEmail, | ||
| recipients, | ||
| subject, | ||
| text: textLines.join("\n"), | ||
| html, | ||
| }); | ||
|
|
||
| console.log( | ||
| `Sent GitHub security advisory alert for ${repository} to ${recipients.length} recipient(s).`, | ||
| ); | ||
| }; | ||
|
|
||
| await main(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The workflow pins Node.js to v22, but the repo declares
engines.nodeas>=24and CI initializes Node usingnode-version-file: package.json. This workflow should follow the same source of truth (or at least use a Node version compatible with the engine constraint) to avoid subtle runtime differences and future breakage when scripts start relying on Node 24+ features.