From 2e89e97ee0063840b8bcf86fbde6891ff06e8a3c Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Sun, 29 Mar 2026 12:17:58 -0400 Subject: [PATCH 1/6] chore: added a .gitignore --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19a37ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# OS +.DS_Store +Thumbs.db + +# Dev tooling +.cursor +.claude +claude/* +.worktrees/ From 1944e5935c4b571d51b1beb7f138b6aad55190e9 Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Mon, 30 Mar 2026 09:49:06 -0400 Subject: [PATCH 2/6] feat: implemented global dependabot workflows --- .github/workflows/dependabot_alert.yml | 93 +++++++++++ .github/workflows/dependabot_pr.yml | 206 +++++++++++++++++++++++++ README.md | 177 ++++++++++++++++++++- 3 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/dependabot_alert.yml create mode 100644 .github/workflows/dependabot_pr.yml diff --git a/.github/workflows/dependabot_alert.yml b/.github/workflows/dependabot_alert.yml new file mode 100644 index 0000000..9ad6ca5 --- /dev/null +++ b/.github/workflows/dependabot_alert.yml @@ -0,0 +1,93 @@ +# .github/workflows/dependabot_alert.yml +# +# Phase 1 of the Dependabot lifecycle: alert created. +# Posts a Slack message and creates a Linear ticket, both keyed by GHSA ID +# so downstream workflows can find and update them. +# +# Inputs fall back to org-level variables when not provided by the caller. + +name: Dependabot Alert +on: + workflow_call: + inputs: + slack-channel-id: + required: false + type: string + linear-team-id: + required: false + type: string + linear-project-id: + required: false + type: string + secrets: + slack-bot-token: + required: false + linear-api-key: + required: false + +permissions: + security-events: read + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Extract alert details + id: alert + uses: actions/github-script@v7 + with: + script: | + const alert = context.payload.alert + const severity = alert.security_vulnerability.severity + const pkg = alert.dependency.package.name + const ecosystem = alert.dependency.package.ecosystem + const summary = alert.security_advisory.summary + const url = alert.html_url + const cve = alert.security_advisory.cve_id || 'N/A' + const ghsa = alert.security_advisory.ghsa_id + const repo = context.payload.repository.full_name + + core.setOutput('severity', severity) + core.setOutput('package', pkg) + core.setOutput('ecosystem', ecosystem) + core.setOutput('summary', summary) + core.setOutput('url', url) + core.setOutput('cve', cve) + core.setOutput('ghsa', ghsa) + core.setOutput('repo', repo) + + - name: Post to Slack + if: inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '' + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.slack-bot-token }} + arguments: | + channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + text: "🔒 *Dependabot alert* in `${{ steps.alert.outputs.repo }}`\n*Severity:* ${{ steps.alert.outputs.severity }}\n*Package:* `${{ steps.alert.outputs.package }}` (${{ steps.alert.outputs.ecosystem }})\n*CVE:* ${{ steps.alert.outputs.cve }}\n*GHSA:* ${{ steps.alert.outputs.ghsa }}\n*Summary:* ${{ steps.alert.outputs.summary }}\n${{ steps.alert.outputs.url }}" + + - name: Create Linear ticket + if: inputs.linear-team-id != '' || vars.LINEAR_AMERA_TEAM_ID != '' + run: | + curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "[$SEVERITY] $GHSA: Vulnerability in $PACKAGE" \ + --arg desc "A **$SEVERITY** severity vulnerability was detected in \`$PACKAGE\` ($ECOSYSTEM).\n\n**GHSA:** $GHSA\n**CVE:** $CVE\n**Summary:** $SUMMARY\n**Repo:** $REPO\n**Alert:** $ALERT_URL\n\nIf Dependabot opens a fix PR it will be triaged automatically. If no PR appears, manual intervention is required." \ + --arg team "$LINEAR_TEAM_ID" \ + --arg project "$LINEAR_PROJECT_ID" \ + '{ query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { url } } }", variables: { input: { title: $title, description: $desc, teamId: $team, projectId: $project } } }' + )" + env: + LINEAR_API_KEY: ${{ secrets.linear-api-key }} + LINEAR_TEAM_ID: ${{ inputs.linear-team-id || vars.LINEAR_AMERA_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ inputs.linear-project-id || vars.LINEAR_SOC2_COMPLIANCE_PROJECT_ID }} + SEVERITY: ${{ steps.alert.outputs.severity }} + PACKAGE: ${{ steps.alert.outputs.package }} + ECOSYSTEM: ${{ steps.alert.outputs.ecosystem }} + CVE: ${{ steps.alert.outputs.cve }} + GHSA: ${{ steps.alert.outputs.ghsa }} + SUMMARY: ${{ steps.alert.outputs.summary }} + REPO: ${{ steps.alert.outputs.repo }} + ALERT_URL: ${{ steps.alert.outputs.url }} diff --git a/.github/workflows/dependabot_pr.yml b/.github/workflows/dependabot_pr.yml new file mode 100644 index 0000000..e4f6f28 --- /dev/null +++ b/.github/workflows/dependabot_pr.yml @@ -0,0 +1,206 @@ +# .github/workflows/dependabot_pr.yml +# +# Phases 2 & 3 of the Dependabot lifecycle: PR opened and PR merged. +# Uses the GHSA ID from fetch-metadata (alert-lookup) to find and update +# the Slack thread and Linear ticket created by dependabot_alert. +# +# Inputs fall back to org-level variables when not provided by the caller. + +name: Dependabot PR +on: + workflow_call: + inputs: + slack-channel-id: + required: false + type: string + linear-team-id: + required: false + type: string + secrets: + slack-bot-token: + required: false + linear-api-key: + required: false + gh-app-id: + required: true + gh-app-private-key: + required: true + +permissions: + contents: write + pull-requests: write + +jobs: + triage: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.gh-app-id }} + private-key: ${{ secrets.gh-app-private-key }} + + - name: Fetch metadata + id: metadata + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ steps.app-token.outputs.token }} + alert-lookup: true + + # ── PR Opened ───────────────────────────────────────── + - name: Enable auto-merge + if: >- + github.event.action != 'closed' + && steps.metadata.outputs.update-type != 'version-update:semver-major' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Label as major + if: >- + github.event.action != 'closed' + && steps.metadata.outputs.update-type == 'version-update:semver-major' + run: gh pr edit "$PR_URL" --add-label "major-update" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── Find Slack thread by GHSA ID (used by both opened and merged) ── + - name: Find Slack thread + if: (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') && steps.metadata.outputs.ghsa-id != '' + id: slack-thread + run: | + for ATTEMPT in 1 2 3; do + RESPONSE=$(curl -s -H "Authorization: Bearer $SLACK_TOKEN" \ + "https://slack.com/api/conversations.history?channel=$CHANNEL&limit=200") + + TS=$(echo "$RESPONSE" | jq -r --arg ghsa "$GHSA_ID" \ + '[.messages[] | select(.text | contains($ghsa))] | first | .ts // empty') + + if [ -n "$TS" ]; then + echo "thread_ts=$TS" >> "$GITHUB_OUTPUT" + echo "Found Slack thread: $TS" + exit 0 + fi + + echo "Attempt $ATTEMPT: thread not found, retrying in $((ATTEMPT * 10))s..." + sleep $((ATTEMPT * 10)) + done + + echo "No matching Slack thread found after 3 attempts" + env: + SLACK_TOKEN: ${{ secrets.slack-bot-token }} + CHANNEL: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + GHSA_ID: ${{ steps.metadata.outputs.ghsa-id }} + + # ── Slack: PR opened notification ────────────────────── + - name: Slack — PR opened + if: >- + github.event.action != 'closed' + && (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.slack-bot-token }} + arguments: | + channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} + text: "📦 Dependabot opened a fix PR: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }} (${{ steps.metadata.outputs.update-type }})" + + # ── Find and update Linear ticket by GHSA ID ─────────── + - name: Find Linear ticket + if: >- + github.event.action != 'closed' + && (inputs.linear-team-id != '' || vars.LINEAR_AMERA_TEAM_ID != '') + && steps.metadata.outputs.ghsa-id != '' + id: linear-ticket + run: | + RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg ghsa "$GHSA_ID" \ + --arg team "$LINEAR_TEAM_ID" \ + '{ + query: "query($filter: IssueFilterInput) { issues(filter: $filter) { nodes { id identifier url } } }", + variables: { + filter: { + team: { id: { eq: $team } }, + title: { contains: $ghsa }, + state: { type: { neq: "completed" } } + } + } + }' + )") + + ISSUE_ID=$(echo "$RESPONSE" | jq -r '.data.issues.nodes[0].id // empty') + IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issues.nodes[0].identifier // empty') + + echo "issue_id=$ISSUE_ID" >> "$GITHUB_OUTPUT" + echo "identifier=$IDENTIFIER" >> "$GITHUB_OUTPUT" + + if [ -n "$IDENTIFIER" ]; then + echo "Found Linear ticket: $IDENTIFIER" + else + echo "No matching Linear ticket found for $GHSA_ID" + fi + env: + LINEAR_API_KEY: ${{ secrets.linear-api-key }} + LINEAR_TEAM_ID: ${{ inputs.linear-team-id || vars.LINEAR_AMERA_TEAM_ID }} + GHSA_ID: ${{ steps.metadata.outputs.ghsa-id }} + + - name: Comment on Linear ticket + if: >- + github.event.action != 'closed' + && steps.linear-ticket.outputs.issue_id != '' + run: | + curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg id "$ISSUE_ID" \ + --arg body "Dependabot opened a fix PR: $PR_URL\n\n**Update type:** $UPDATE_TYPE\n**Version:** $PREV_VERSION → $NEW_VERSION" \ + '{ + query: "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }", + variables: { input: { issueId: $id, body: $body } } + }' + )" + env: + LINEAR_API_KEY: ${{ secrets.linear-api-key }} + ISSUE_ID: ${{ steps.linear-ticket.outputs.issue_id }} + PR_URL: ${{ github.event.pull_request.html_url }} + UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }} + PREV_VERSION: ${{ steps.metadata.outputs.previous-version }} + NEW_VERSION: ${{ steps.metadata.outputs.new-version }} + + - name: Inject Linear ticket into PR body + if: >- + github.event.action != 'closed' + && steps.linear-ticket.outputs.identifier != '' + run: | + CURRENT_BODY=$(gh pr view "$PR_URL" --json body -q .body) + gh pr edit "$PR_URL" --body "${CURRENT_BODY} + +Fixes $IDENTIFIER" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IDENTIFIER: ${{ steps.linear-ticket.outputs.identifier }} + + # ── PR Merged ────────────────────────────────────────── + - name: Slack — PR merged + if: >- + github.event.action == 'closed' + && github.event.pull_request.merged == true + && (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.slack-bot-token }} + arguments: | + channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} + text: "✅ Vulnerability resolved. PR merged: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` updated from ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }}" diff --git a/README.md b/README.md index 792e004..8d23b0f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,178 @@ # .github -Organization-level GitHub configuration for Amera, including PR templates and contribution guidelines. +Organization-level GitHub configuration for Amera, including PR templates, contribution guidelines, and reusable workflows. + +## Workflows + +Two reusable workflows that together cover the full Dependabot vulnerability lifecycle. They use the **GHSA ID** (GitHub Security Advisory ID) as a shared key to maintain Slack thread continuity and update Linear tickets across events. + +**Overview** +```mermaid +graph LR + V[Vulnerability Detected] --> A[dependabot_alert] + A -->|"Slack + Linear ticket"| Team[Team Notified] + D[Dependabot Opens PR] --> P[dependabot_pr] + P -->|"patch/minor"| AutoMerge["Auto-merge enabled\n(awaits human approval)"] + P -->|"major"| ManualReview["Label + Slack + Linear ticket"] +``` + +**Detailed** +```mermaid +graph TD + subgraph alert_phase [1 - Alert Created] + A1[dependabot_alert] --> A2["Post Slack message\n(includes GHSA ID)"] + A1 --> A3["Create Linear ticket\n(GHSA ID in title)"] + end + + subgraph pr_opened [2 - PR Opened] + B1["dependabot_pr\n(opened)"] --> B2["fetch-metadata\n(alert-lookup: true)"] + B2 --> B3[Get ghsa-id] + B3 --> B4["Find Slack thread by GHSA ID\n(retry with backoff)"] + B4 --> B5[Reply in thread] + B3 --> B6[Find Linear ticket by GHSA ID] + B6 --> B7[Comment on ticket] + B6 --> B8["Inject 'Fixes AMR-123'\ninto PR body"] + end + + subgraph pr_merged [3 - PR Merged] + C1["dependabot_pr\n(closed+merged)"] --> C2[Reply in Slack thread: resolved] + C1 --> C3["Linear auto-closes ticket\n(via PR body keyword)"] + end + + alert_phase --> pr_opened + pr_opened --> pr_merged +``` + +### Prerequisites + +**GitHub App** — required for `alert-lookup: true` in `fetch-metadata`, which gives us the GHSA ID to link alerts to PRs. + +1. Create a GitHub App in the `amera-apps` org with **Dependabot alerts: Read-only** permission +2. Install it on all repos +3. Store as org-level secrets: `AMERABOT_APP_ID` and `AMERABOT_APP_PRIVATE_KEY` + +**Slack bot scopes** — the bot needs `chat:write` (already required) plus `channels:history` (public channels) or `groups:history` (private channels) for thread lookup. + +**Org secrets** — sensitive credentials, set at the org level so all repos inherit them: + +| Secret | Description | +|---|---| +| `SLACK_BOT_TOKEN` | Slack bot token (`chat:write` + `channels:history` scopes) | +| `LINEAR_API_KEY` | Linear API key for ticket creation and search | +| `AMERABOT_APP_ID` | GitHub App ID | +| `AMERABOT_APP_PRIVATE_KEY` | GitHub App private key | + +**Org variables** — non-sensitive defaults. All workflow inputs fall back to these when not explicitly provided by the caller, so most repos don't need to pass them. + +| Variable | Description | +|---|---| +| `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Default Slack channel for Dependabot notifications | +| `LINEAR_AMERA_TEAM_ID` | Default Linear team for vulnerability tickets | +| `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Default Linear project for vulnerability tickets | + +Repos can override any default by passing the corresponding input in the caller workflow. + +### Dependabot Alert + +[`.github/workflows/dependabot_alert.yml`](.github/workflows/dependabot_alert.yml) + +**Phase 1:** Fires when a vulnerability alert is created. Posts a Slack message and creates a Linear ticket, both containing the GHSA ID so downstream workflows can find them. + +| Input | Required | Fallback variable | Description | +|---|---|---|---| +| `slack-channel-id` | No | `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel ID | +| `linear-team-id` | No | `LINEAR_AMERA_TEAM_ID` | Linear team ID | +| `linear-project-id` | No | `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Linear project ID | + +| Secret | Required | Description | +|---|---|---| +| `slack-bot-token` | No | Slack bot token | +| `linear-api-key` | No | Linear API key | + +#### Usage + +Minimal — uses org variable defaults: + +```yaml +# .github/workflows/dependabot_alert.yml +name: Dependabot Alert +on: + dependabot_alert: + types: [created] + +jobs: + notify: + uses: amera-apps/.github/.github/workflows/dependabot_alert.yml@main + secrets: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} +``` + +With overrides: + +```yaml +jobs: + notify: + uses: amera-apps/.github/.github/workflows/dependabot_alert.yml@main + with: + slack-channel-id: C9999999999 + linear-team-id: different-team-id + linear-project-id: different-project-id + secrets: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} +``` + +### Dependabot PR + +[`.github/workflows/dependabot_pr.yml`](.github/workflows/dependabot_pr.yml) + +**Phases 2 & 3:** Handles PR opened and PR merged events. + +**On PR opened:** + +- Enables auto-merge for patch/minor updates (waits for human approval + CI) +- Labels major updates with `major-update` +- Finds the Slack thread by GHSA ID (with retry + backoff) and replies in-thread +- Finds the Linear ticket by GHSA ID, adds a comment, and injects `Fixes AMR-123` into the PR body + +**On PR merged:** + +- Replies in the Slack thread confirming the vulnerability is resolved +- Linear auto-closes the ticket via the `Fixes AMR-123` keyword in the PR body + +| Input | Required | Fallback variable | Description | +|---|---|---|---| +| `slack-channel-id` | No | `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel ID | +| `linear-team-id` | No | `LINEAR_AMERA_TEAM_ID` | Linear team ID | + +| Secret | Required | Description | +|---|---|---| +| `slack-bot-token` | No | Slack bot token | +| `linear-api-key` | No | Linear API key | +| `gh-app-id` | Yes | GitHub App ID for alert-lookup | +| `gh-app-private-key` | Yes | GitHub App private key | + +#### Usage + +Minimal — uses org variable defaults: + +```yaml +# .github/workflows/dependabot_pr.yml +name: Dependabot PR +on: + pull_request: + types: [opened, closed] + +jobs: + triage: + uses: amera-apps/.github/.github/workflows/dependabot_pr.yml@main + permissions: + contents: write + pull-requests: write + secrets: + slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + linear-api-key: ${{ secrets.LINEAR_API_KEY }} + gh-app-id: ${{ secrets.AMERABOT_APP_ID }} + gh-app-private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} +``` From f12af86aa9c729ba73a3e7f04b1f27661c4fffa7 Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Mon, 30 Mar 2026 09:51:59 -0400 Subject: [PATCH 3/6] fix: correct slack body field and an indentation issue --- .github/workflows/dependabot_alert.yml | 2 +- .github/workflows/dependabot_pr.yml | 8 ++++---- README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependabot_alert.yml b/.github/workflows/dependabot_alert.yml index 9ad6ca5..3cbdcc9 100644 --- a/.github/workflows/dependabot_alert.yml +++ b/.github/workflows/dependabot_alert.yml @@ -62,7 +62,7 @@ jobs: with: method: chat.postMessage token: ${{ secrets.slack-bot-token }} - arguments: | + payload: | channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} text: "🔒 *Dependabot alert* in `${{ steps.alert.outputs.repo }}`\n*Severity:* ${{ steps.alert.outputs.severity }}\n*Package:* `${{ steps.alert.outputs.package }}` (${{ steps.alert.outputs.ecosystem }})\n*CVE:* ${{ steps.alert.outputs.cve }}\n*GHSA:* ${{ steps.alert.outputs.ghsa }}\n*Summary:* ${{ steps.alert.outputs.summary }}\n${{ steps.alert.outputs.url }}" diff --git a/.github/workflows/dependabot_pr.yml b/.github/workflows/dependabot_pr.yml index e4f6f28..bc3e0e9 100644 --- a/.github/workflows/dependabot_pr.yml +++ b/.github/workflows/dependabot_pr.yml @@ -105,7 +105,7 @@ jobs: with: method: chat.postMessage token: ${{ secrets.slack-bot-token }} - arguments: | + payload: | channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} text: "📦 Dependabot opened a fix PR: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }} (${{ steps.metadata.outputs.update-type }})" @@ -183,8 +183,8 @@ jobs: run: | CURRENT_BODY=$(gh pr view "$PR_URL" --json body -q .body) gh pr edit "$PR_URL" --body "${CURRENT_BODY} - -Fixes $IDENTIFIER" + + Fixes ${IDENTIFIER}" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -200,7 +200,7 @@ Fixes $IDENTIFIER" with: method: chat.postMessage token: ${{ secrets.slack-bot-token }} - arguments: | + payload: | channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} text: "✅ Vulnerability resolved. PR merged: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` updated from ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }}" diff --git a/README.md b/README.md index 8d23b0f..b499dfa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Organization-level GitHub configuration for Amera, including PR templates, contribution guidelines, and reusable workflows. -## Workflows +## Dependabot Workflows Two reusable workflows that together cover the full Dependabot vulnerability lifecycle. They use the **GHSA ID** (GitHub Security Advisory ID) as a shared key to maintain Slack thread continuity and update Linear tickets across events. From 96ffdf1359328e943ef19eef05548af9fefabb26 Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Mon, 30 Mar 2026 16:59:19 -0400 Subject: [PATCH 4/6] feat: added codeartifact token refresh workflow and dependabot template sync for python repos workflow --- .github/dependabot-template.yml | 30 +++ .../workflows/refresh_codeartifact_token.yml | 63 +++++ .github/workflows/sync_dependabot_config.yml | 225 ++++++++++++++++++ README.md | 106 ++++++++- 4 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 .github/dependabot-template.yml create mode 100644 .github/workflows/refresh_codeartifact_token.yml create mode 100644 .github/workflows/sync_dependabot_config.yml diff --git a/.github/dependabot-template.yml b/.github/dependabot-template.yml new file mode 100644 index 0000000..8989038 --- /dev/null +++ b/.github/dependabot-template.yml @@ -0,0 +1,30 @@ +# Dependabot configuration synced from amera-apps/.github. +# Do not edit in individual repos — changes will be overwritten +# by the sync_dependabot_config workflow. + +version: 2 + +registries: + codeartifact: + type: python-index + url: https://amera-artifacts-371568547021.d.codeartifact.us-east-1.amazonaws.com/pypi/amera-python/simple/ + username: aws + password: ${{ secrets.CA_TOKEN }} + +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + registries: + - codeartifact + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/refresh_codeartifact_token.yml b/.github/workflows/refresh_codeartifact_token.yml new file mode 100644 index 0000000..3c21246 --- /dev/null +++ b/.github/workflows/refresh_codeartifact_token.yml @@ -0,0 +1,63 @@ +# .github/workflows/refresh_codeartifact_token.yml +# +# Rotates the AWS CodeArtifact authorization token and stores it as an +# org-level Dependabot secret. Runs every 10 hours to stay within the +# 12-hour token lifetime, ensuring Dependabot always has valid credentials +# when resolving private packages. + +name: Refresh CodeArtifact Token + +on: + schedule: + - cron: '0 */10 * * *' + workflow_dispatch: + +jobs: + refresh: + runs-on: + group: aws + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AMERABOT_APP_ID }} + private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} + owner: amera-apps + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Get CodeArtifact token + id: ca + shell: bash + run: | + set -euo pipefail + TOKEN="$(aws codeartifact get-authorization-token \ + --domain amera-artifacts \ + --domain-owner ${{ vars.AWS_OWNER_ID }} \ + --region "${{ vars.AWS_REGION }}" \ + --query authorizationToken --output text)" + if [[ -z "${TOKEN:-}" || "${TOKEN}" == "None" ]]; then + echo "Failed to fetch CodeArtifact token." >&2 + aws sts get-caller-identity || true + exit 1 + fi + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Update org Dependabot secret + shell: bash + run: | + echo "$CA_TOKEN" | gh secret set CA_TOKEN \ + --org amera-apps \ + --app dependabot \ + --visibility all + echo "Dependabot secret CA_TOKEN updated" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + CA_TOKEN: ${{ steps.ca.outputs.token }} diff --git a/.github/workflows/sync_dependabot_config.yml b/.github/workflows/sync_dependabot_config.yml new file mode 100644 index 0000000..2bfe6c2 --- /dev/null +++ b/.github/workflows/sync_dependabot_config.yml @@ -0,0 +1,225 @@ +# .github/workflows/sync_dependabot_config.yml +# +# ❗❗ ONLY works in repos with a pyproject.toml in the root +# +# Ensures every org repo with a CodeArtifact-backed pyproject.toml has the +# correct .github/dependabot.yml. Opens a PR (rather than pushing directly) +# to comply with branch protection rules requiring review before merge. +# +# Posts a Slack summary and creates a Linear ticket listing all PRs opened. + +name: Sync Dependabot Config + +on: + schedule: + - cron: '0 11 * * *' + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AMERABOT_APP_ID }} + private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} + owner: amera-apps + + - name: Sync dependabot config to repos + id: sync + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const fs = require('fs') + const org = 'amera-apps' + const branch = 'chore/sync-dependabot-config' + const targetPath = '.github/dependabot.yml' + const prTitle = 'chore: sync dependabot config from org template' + + // ── Skip list ──────────────────────────────────────────────────── + // Add repo names here to exclude them from syncing, + // even if they have a CodeArtifact-backed pyproject.toml. + const skipRepos = [] + + const template = fs.readFileSync('.github/dependabot-template.yml', 'utf8') + const templateB64 = Buffer.from(template).toString('base64') + + const repos = await github.paginate(github.rest.repos.listForOrg, { + org, + type: 'all', + per_page: 100 + }) + + const opened = [] + const skipped = [] + const upToDate = [] + const errors = [] + + for (const repo of repos) { + const name = repo.name + if (repo.archived || name === '.github') continue + if (skipRepos.includes(name)) { + skipped.push(name) + continue + } + + try { + // Check for pyproject.toml with codeartifact source + let pyproject + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: 'pyproject.toml' + }) + pyproject = Buffer.from(data.content, 'base64').toString('utf8') + } catch { + continue + } + + if (!pyproject.includes('codeartifact')) continue + + // Check existing dependabot.yml + let existingContent = null + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: targetPath + }) + existingContent = Buffer.from(data.content, 'base64').toString('utf8') + } catch { + // File doesn't exist yet + } + + if (existingContent === template) { + upToDate.push(name) + continue + } + + // Check for an existing open PR from a previous run + const { data: existingPRs } = await github.rest.pulls.list({ + owner: org, + repo: name, + head: `${org}:${branch}`, + state: 'open' + }) + + if (existingPRs.length > 0) { + core.info(`${name}: open sync PR already exists — ${existingPRs[0].html_url}`) + continue + } + + // Get default branch SHA to branch from + const { data: refData } = await github.rest.git.getRef({ + owner: org, + repo: name, + ref: `heads/${repo.default_branch}` + }) + + // Create or update the sync branch + try { + await github.rest.git.updateRef({ + owner: org, + repo: name, + ref: `heads/${branch}`, + sha: refData.object.sha, + force: true + }) + } catch { + await github.rest.git.createRef({ + owner: org, + repo: name, + ref: `refs/heads/${branch}`, + sha: refData.object.sha + }) + } + + // Get existing file SHA on the branch (needed for update) + let fileSha = null + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: targetPath, + ref: branch + }) + fileSha = data.sha + } catch { + // File doesn't exist on this branch yet + } + + await github.rest.repos.createOrUpdateFileContents({ + owner: org, + repo: name, + path: targetPath, + message: prTitle, + content: templateB64, + branch, + ...(fileSha && { sha: fileSha }) + }) + + const { data: pr } = await github.rest.pulls.create({ + owner: org, + repo: name, + title: prTitle, + body: 'Synced from the org-level [`dependabot-template.yml`](https://github.com/amera-apps/.github/blob/main/.github/dependabot-template.yml).\n\nThis configures Dependabot with CodeArtifact registry credentials and enables weekly updates for pip, Docker, and GitHub Actions ecosystems.', + head: branch, + base: repo.default_branch + }) + + opened.push({ name, url: pr.html_url }) + core.info(`${name}: opened PR — ${pr.html_url}`) + } catch (err) { + errors.push({ name, error: err.message }) + core.warning(`${name}: ${err.message}`) + } + } + + const summary = { opened, skipped, upToDate, errors } + core.setOutput('summary', JSON.stringify(summary)) + core.setOutput('opened_count', opened.length) + + core.info(`Done. Opened: ${opened.length}, Up-to-date: ${upToDate.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`) + + - name: Post Slack summary + if: >- + always() + && (vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + text: "🔄 *Dependabot config sync* completed.\n${{ fromJSON(steps.sync.outputs.summary).opened.length > 0 && format('*PRs opened:*\n{0}', join(fromJSON(steps.sync.outputs.summary).opened.*.url, '\n')) || 'All repos are up to date — no PRs opened.' }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>" + + - name: Create Linear ticket + if: >- + always() + && fromJSON(steps.sync.outputs.opened_count) > 0 + && (vars.LINEAR_AMERA_TEAM_ID != '') + run: | + SUMMARY='${{ steps.sync.outputs.summary }}' + PR_LIST=$(echo "$SUMMARY" | jq -r '.opened[] | "- [\(.name)](\(.url))"' ) + + curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "Dependabot config sync — ${{ fromJSON(steps.sync.outputs.opened_count) }} PRs to review" \ + --arg desc "The weekly Dependabot config sync opened PRs that need review:\n\n${PR_LIST}\n\nThese PRs sync \`.github/dependabot.yml\` from the [org template](https://github.com/amera-apps/.github/blob/main/.github/dependabot-template.yml)." \ + --arg team "$LINEAR_TEAM_ID" \ + --arg project "$LINEAR_PROJECT_ID" \ + '{ query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { url } } }", variables: { input: { title: $title, description: $desc, teamId: $team, projectId: $project } } }' + )" + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ vars.LINEAR_AMERA_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ vars.LINEAR_SOC2_COMPLIANCE_PROJECT_ID }} diff --git a/README.md b/README.md index b499dfa..59b0d24 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,29 @@ Organization-level GitHub configuration for Amera, including PR templates, contr ## Dependabot Workflows -Two reusable workflows that together cover the full Dependabot vulnerability lifecycle. They use the **GHSA ID** (GitHub Security Advisory ID) as a shared key to maintain Slack thread continuity and update Linear tickets across events. +Four workflows that cover the full Dependabot vulnerability lifecycle plus the infrastructure that keeps it working with private CodeArtifact packages. **Overview** ```mermaid -graph LR - V[Vulnerability Detected] --> A[dependabot_alert] - A -->|"Slack + Linear ticket"| Team[Team Notified] - D[Dependabot Opens PR] --> P[dependabot_pr] - P -->|"patch/minor"| AutoMerge["Auto-merge enabled\n(awaits human approval)"] - P -->|"major"| ManualReview["Label + Slack + Linear ticket"] +graph TD + subgraph infra [Infrastructure] + Refresh["refresh_codeartifact_token\n(every 10h)"] -->|"rotates"| CASecret["Org Dependabot secret:\nCA_TOKEN"] + Sync["sync_dependabot_config\n(weekly)"] -->|"opens PRs"| DYml["dependabot.yml\n(per repo)"] + Sync -->|"reads"| Template["dependabot-template.yml"] + end + + subgraph lifecycle [Vulnerability Lifecycle] + V[Vulnerability Detected] --> A[dependabot_alert] + A -->|"Slack + Linear ticket"| Team[Team Notified] + D[Dependabot Opens PR] --> P[dependabot_pr] + P -->|"patch/minor"| AutoMerge["Auto-merge enabled\n(awaits human approval)"] + P -->|"major"| ManualReview["Label + Slack + Linear ticket"] + end + + DYml -->|"uses"| CASecret ``` -**Detailed** +**Vulnerability lifecycle (detailed)** ```mermaid graph TD subgraph alert_phase [1 - Alert Created] @@ -45,9 +55,13 @@ graph TD ### Prerequisites -**GitHub App** — required for `alert-lookup: true` in `fetch-metadata`, which gives us the GHSA ID to link alerts to PRs. +**GitHub App (AMERABOT)** — used by all workflows for elevated permissions. -1. Create a GitHub App in the `amera-apps` org with **Dependabot alerts: Read-only** permission +1. Create a GitHub App in the `amera-apps` org with these permissions: + - **Dependabot alerts:** Read-only (for `alert-lookup` in `fetch-metadata`) + - **Organization Dependabot secrets:** Read and write (for `refresh_codeartifact_token`) + - **Contents:** Read and write (for `sync_dependabot_config` to create branches and commit files) + - **Pull requests:** Read and write (for `sync_dependabot_config` to open PRs) 2. Install it on all repos 3. Store as org-level secrets: `AMERABOT_APP_ID` and `AMERABOT_APP_PRIVATE_KEY` @@ -61,6 +75,10 @@ graph TD | `LINEAR_API_KEY` | Linear API key for ticket creation and search | | `AMERABOT_APP_ID` | GitHub App ID | | `AMERABOT_APP_PRIVATE_KEY` | GitHub App private key | +| `AWS_ACCESS_KEY_ID` | IAM user for CodeArtifact token generation | +| `AWS_SECRET_ACCESS_KEY` | IAM user for CodeArtifact token generation | + +The AWS IAM user should have minimal permissions: `codeartifact:GetAuthorizationToken` and `sts:GetServiceLinkedRoleDeletionStatus`. **Org variables** — non-sensitive defaults. All workflow inputs fall back to these when not explicitly provided by the caller, so most repos don't need to pass them. @@ -69,6 +87,8 @@ graph TD | `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Default Slack channel for Dependabot notifications | | `LINEAR_AMERA_TEAM_ID` | Default Linear team for vulnerability tickets | | `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Default Linear project for vulnerability tickets | +| `AWS_REGION` | AWS region for CodeArtifact (`us-east-1`) | +| `AWS_OWNER_ID` | AWS account ID / domain owner for CodeArtifact (`371568547021`) | Repos can override any default by passing the corresponding input in the caller workflow. @@ -176,3 +196,69 @@ jobs: gh-app-id: ${{ secrets.AMERABOT_APP_ID }} gh-app-private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} ``` + +### CodeArtifact Token Refresh + +[`.github/workflows/refresh_codeartifact_token.yml`](.github/workflows/refresh_codeartifact_token.yml) + +Dependabot needs access to the private CodeArtifact registry to resolve packages like `amera-core` and `amera-workflow`. CodeArtifact tokens expire after 12 hours, so this workflow rotates the token every 10 hours and stores it as an org-level Dependabot secret (`CA_TOKEN`). + +```mermaid +graph LR + Cron["Schedule\n(every 10h)"] --> WF[refresh_codeartifact_token] + WF -->|"AWS creds"| CA[CodeArtifact] + CA -->|"12h token"| WF + WF -->|"gh secret set"| Secret["Org Dependabot secret:\nCA_TOKEN"] + Secret -->|"read by"| DB["Dependabot\n(all repos)"] +``` + +Runs on the `aws` self-hosted runner group (AWS CLI is pre-installed). Uses `gh secret set --org --app dependabot` to update the secret without manual encryption. + +The workflow also supports `workflow_dispatch` for manual runs if a token needs immediate rotation. + +### Dependabot Config Sync + +[`.github/workflows/sync_dependabot_config.yml`](.github/workflows/sync_dependabot_config.yml) + +Dependabot requires a `.github/dependabot.yml` in each repo — there's no way to inherit it at the org level. This workflow maintains a single template ([`.github/dependabot-template.yml`](.github/dependabot-template.yml)) and syncs it to all repos that need it. + +```mermaid +graph TD + Cron["Schedule\n(Monday 9am UTC)"] --> Sync[sync_dependabot_config] + Sync -->|"reads"| Template["dependabot-template.yml\n(this repo)"] + Sync -->|"for each repo"| Check{"Has pyproject.toml\nwith codeartifact?"} + Check -->|"yes + out of date"| PR["Open PR:\nchore/sync-dependabot-config"] + Check -->|"no or up-to-date"| Skip[Skip] + PR --> Slack["Slack summary"] + PR --> Linear["Linear ticket\n(if PRs opened)"] +``` + +**How it works:** + +1. Lists all repos in the org +2. For each non-archived repo, checks if `pyproject.toml` exists and references `codeartifact` +3. Compares the repo's `.github/dependabot.yml` to the template — skips if already matching +4. Skips if an open sync PR already exists from a previous run +5. Creates a branch, commits the template, and opens a PR +6. After processing all repos, posts a Slack summary and creates a Linear ticket listing the PRs + +PRs are opened (not direct pushes) to comply with branch protection rules requiring at least one approving review. + +#### Skipping repos + +Some repos may need a custom `dependabot.yml` or should be excluded entirely. Add them to the `skipRepos` array at the top of the `actions/github-script` block in `sync_dependabot_config.yml`: + +```javascript +const skipRepos = ['some-special-repo', 'another-exception'] +``` + +Skipped repos appear in the workflow run log for auditability. + +#### Updating the template + +To change the Dependabot config across all repos: + +1. Edit [`.github/dependabot-template.yml`](.github/dependabot-template.yml) in this repo +2. Merge to `main` +3. Wait for the next scheduled sync (Monday 9am UTC) or trigger manually via `workflow_dispatch` +4. Review and merge the PRs opened in each repo From 44e7b6cb938900ba65a77d5908c1b419ef1a19cc Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Tue, 31 Mar 2026 10:38:35 -0400 Subject: [PATCH 5/6] feat: moved dependabot alert/pr logic into a worker rather than a gh action --- .github/workflows/dependabot_alert.yml | 93 ------- .github/workflows/dependabot_pr.yml | 206 ---------------- .gitignore | 4 + README.md | 230 ++++++++---------- workers/dependabot/package.json | 17 ++ workers/dependabot/src/handlers/alert.ts | 54 ++++ .../dependabot/src/handlers/pull-request.ts | 171 +++++++++++++ workers/dependabot/src/index.ts | 51 ++++ workers/dependabot/src/lib/github.ts | 149 ++++++++++++ workers/dependabot/src/lib/linear.ts | 102 ++++++++ workers/dependabot/src/lib/slack.ts | 57 +++++ workers/dependabot/src/lib/verify.ts | 49 ++++ workers/dependabot/src/types/env.ts | 11 + workers/dependabot/src/types/github.ts | 78 ++++++ workers/dependabot/tsconfig.json | 16 ++ workers/dependabot/wrangler.toml | 8 + 16 files changed, 864 insertions(+), 432 deletions(-) delete mode 100644 .github/workflows/dependabot_alert.yml delete mode 100644 .github/workflows/dependabot_pr.yml create mode 100644 workers/dependabot/package.json create mode 100644 workers/dependabot/src/handlers/alert.ts create mode 100644 workers/dependabot/src/handlers/pull-request.ts create mode 100644 workers/dependabot/src/index.ts create mode 100644 workers/dependabot/src/lib/github.ts create mode 100644 workers/dependabot/src/lib/linear.ts create mode 100644 workers/dependabot/src/lib/slack.ts create mode 100644 workers/dependabot/src/lib/verify.ts create mode 100644 workers/dependabot/src/types/env.ts create mode 100644 workers/dependabot/src/types/github.ts create mode 100644 workers/dependabot/tsconfig.json create mode 100644 workers/dependabot/wrangler.toml diff --git a/.github/workflows/dependabot_alert.yml b/.github/workflows/dependabot_alert.yml deleted file mode 100644 index 3cbdcc9..0000000 --- a/.github/workflows/dependabot_alert.yml +++ /dev/null @@ -1,93 +0,0 @@ -# .github/workflows/dependabot_alert.yml -# -# Phase 1 of the Dependabot lifecycle: alert created. -# Posts a Slack message and creates a Linear ticket, both keyed by GHSA ID -# so downstream workflows can find and update them. -# -# Inputs fall back to org-level variables when not provided by the caller. - -name: Dependabot Alert -on: - workflow_call: - inputs: - slack-channel-id: - required: false - type: string - linear-team-id: - required: false - type: string - linear-project-id: - required: false - type: string - secrets: - slack-bot-token: - required: false - linear-api-key: - required: false - -permissions: - security-events: read - -jobs: - notify: - runs-on: ubuntu-latest - steps: - - name: Extract alert details - id: alert - uses: actions/github-script@v7 - with: - script: | - const alert = context.payload.alert - const severity = alert.security_vulnerability.severity - const pkg = alert.dependency.package.name - const ecosystem = alert.dependency.package.ecosystem - const summary = alert.security_advisory.summary - const url = alert.html_url - const cve = alert.security_advisory.cve_id || 'N/A' - const ghsa = alert.security_advisory.ghsa_id - const repo = context.payload.repository.full_name - - core.setOutput('severity', severity) - core.setOutput('package', pkg) - core.setOutput('ecosystem', ecosystem) - core.setOutput('summary', summary) - core.setOutput('url', url) - core.setOutput('cve', cve) - core.setOutput('ghsa', ghsa) - core.setOutput('repo', repo) - - - name: Post to Slack - if: inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '' - uses: slackapi/slack-github-action@v2 - with: - method: chat.postMessage - token: ${{ secrets.slack-bot-token }} - payload: | - channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} - text: "🔒 *Dependabot alert* in `${{ steps.alert.outputs.repo }}`\n*Severity:* ${{ steps.alert.outputs.severity }}\n*Package:* `${{ steps.alert.outputs.package }}` (${{ steps.alert.outputs.ecosystem }})\n*CVE:* ${{ steps.alert.outputs.cve }}\n*GHSA:* ${{ steps.alert.outputs.ghsa }}\n*Summary:* ${{ steps.alert.outputs.summary }}\n${{ steps.alert.outputs.url }}" - - - name: Create Linear ticket - if: inputs.linear-team-id != '' || vars.LINEAR_AMERA_TEAM_ID != '' - run: | - curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n \ - --arg title "[$SEVERITY] $GHSA: Vulnerability in $PACKAGE" \ - --arg desc "A **$SEVERITY** severity vulnerability was detected in \`$PACKAGE\` ($ECOSYSTEM).\n\n**GHSA:** $GHSA\n**CVE:** $CVE\n**Summary:** $SUMMARY\n**Repo:** $REPO\n**Alert:** $ALERT_URL\n\nIf Dependabot opens a fix PR it will be triaged automatically. If no PR appears, manual intervention is required." \ - --arg team "$LINEAR_TEAM_ID" \ - --arg project "$LINEAR_PROJECT_ID" \ - '{ query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { url } } }", variables: { input: { title: $title, description: $desc, teamId: $team, projectId: $project } } }' - )" - env: - LINEAR_API_KEY: ${{ secrets.linear-api-key }} - LINEAR_TEAM_ID: ${{ inputs.linear-team-id || vars.LINEAR_AMERA_TEAM_ID }} - LINEAR_PROJECT_ID: ${{ inputs.linear-project-id || vars.LINEAR_SOC2_COMPLIANCE_PROJECT_ID }} - SEVERITY: ${{ steps.alert.outputs.severity }} - PACKAGE: ${{ steps.alert.outputs.package }} - ECOSYSTEM: ${{ steps.alert.outputs.ecosystem }} - CVE: ${{ steps.alert.outputs.cve }} - GHSA: ${{ steps.alert.outputs.ghsa }} - SUMMARY: ${{ steps.alert.outputs.summary }} - REPO: ${{ steps.alert.outputs.repo }} - ALERT_URL: ${{ steps.alert.outputs.url }} diff --git a/.github/workflows/dependabot_pr.yml b/.github/workflows/dependabot_pr.yml deleted file mode 100644 index bc3e0e9..0000000 --- a/.github/workflows/dependabot_pr.yml +++ /dev/null @@ -1,206 +0,0 @@ -# .github/workflows/dependabot_pr.yml -# -# Phases 2 & 3 of the Dependabot lifecycle: PR opened and PR merged. -# Uses the GHSA ID from fetch-metadata (alert-lookup) to find and update -# the Slack thread and Linear ticket created by dependabot_alert. -# -# Inputs fall back to org-level variables when not provided by the caller. - -name: Dependabot PR -on: - workflow_call: - inputs: - slack-channel-id: - required: false - type: string - linear-team-id: - required: false - type: string - secrets: - slack-bot-token: - required: false - linear-api-key: - required: false - gh-app-id: - required: true - gh-app-private-key: - required: true - -permissions: - contents: write - pull-requests: write - -jobs: - triage: - if: github.actor == 'dependabot[bot]' - runs-on: ubuntu-latest - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.gh-app-id }} - private-key: ${{ secrets.gh-app-private-key }} - - - name: Fetch metadata - id: metadata - uses: dependabot/fetch-metadata@v2 - with: - github-token: ${{ steps.app-token.outputs.token }} - alert-lookup: true - - # ── PR Opened ───────────────────────────────────────── - - name: Enable auto-merge - if: >- - github.event.action != 'closed' - && steps.metadata.outputs.update-type != 'version-update:semver-major' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Label as major - if: >- - github.event.action != 'closed' - && steps.metadata.outputs.update-type == 'version-update:semver-major' - run: gh pr edit "$PR_URL" --add-label "major-update" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # ── Find Slack thread by GHSA ID (used by both opened and merged) ── - - name: Find Slack thread - if: (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') && steps.metadata.outputs.ghsa-id != '' - id: slack-thread - run: | - for ATTEMPT in 1 2 3; do - RESPONSE=$(curl -s -H "Authorization: Bearer $SLACK_TOKEN" \ - "https://slack.com/api/conversations.history?channel=$CHANNEL&limit=200") - - TS=$(echo "$RESPONSE" | jq -r --arg ghsa "$GHSA_ID" \ - '[.messages[] | select(.text | contains($ghsa))] | first | .ts // empty') - - if [ -n "$TS" ]; then - echo "thread_ts=$TS" >> "$GITHUB_OUTPUT" - echo "Found Slack thread: $TS" - exit 0 - fi - - echo "Attempt $ATTEMPT: thread not found, retrying in $((ATTEMPT * 10))s..." - sleep $((ATTEMPT * 10)) - done - - echo "No matching Slack thread found after 3 attempts" - env: - SLACK_TOKEN: ${{ secrets.slack-bot-token }} - CHANNEL: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} - GHSA_ID: ${{ steps.metadata.outputs.ghsa-id }} - - # ── Slack: PR opened notification ────────────────────── - - name: Slack — PR opened - if: >- - github.event.action != 'closed' - && (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') - uses: slackapi/slack-github-action@v2 - with: - method: chat.postMessage - token: ${{ secrets.slack-bot-token }} - payload: | - channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} - thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} - text: "📦 Dependabot opened a fix PR: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }} (${{ steps.metadata.outputs.update-type }})" - - # ── Find and update Linear ticket by GHSA ID ─────────── - - name: Find Linear ticket - if: >- - github.event.action != 'closed' - && (inputs.linear-team-id != '' || vars.LINEAR_AMERA_TEAM_ID != '') - && steps.metadata.outputs.ghsa-id != '' - id: linear-ticket - run: | - RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n \ - --arg ghsa "$GHSA_ID" \ - --arg team "$LINEAR_TEAM_ID" \ - '{ - query: "query($filter: IssueFilterInput) { issues(filter: $filter) { nodes { id identifier url } } }", - variables: { - filter: { - team: { id: { eq: $team } }, - title: { contains: $ghsa }, - state: { type: { neq: "completed" } } - } - } - }' - )") - - ISSUE_ID=$(echo "$RESPONSE" | jq -r '.data.issues.nodes[0].id // empty') - IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issues.nodes[0].identifier // empty') - - echo "issue_id=$ISSUE_ID" >> "$GITHUB_OUTPUT" - echo "identifier=$IDENTIFIER" >> "$GITHUB_OUTPUT" - - if [ -n "$IDENTIFIER" ]; then - echo "Found Linear ticket: $IDENTIFIER" - else - echo "No matching Linear ticket found for $GHSA_ID" - fi - env: - LINEAR_API_KEY: ${{ secrets.linear-api-key }} - LINEAR_TEAM_ID: ${{ inputs.linear-team-id || vars.LINEAR_AMERA_TEAM_ID }} - GHSA_ID: ${{ steps.metadata.outputs.ghsa-id }} - - - name: Comment on Linear ticket - if: >- - github.event.action != 'closed' - && steps.linear-ticket.outputs.issue_id != '' - run: | - curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n \ - --arg id "$ISSUE_ID" \ - --arg body "Dependabot opened a fix PR: $PR_URL\n\n**Update type:** $UPDATE_TYPE\n**Version:** $PREV_VERSION → $NEW_VERSION" \ - '{ - query: "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }", - variables: { input: { issueId: $id, body: $body } } - }' - )" - env: - LINEAR_API_KEY: ${{ secrets.linear-api-key }} - ISSUE_ID: ${{ steps.linear-ticket.outputs.issue_id }} - PR_URL: ${{ github.event.pull_request.html_url }} - UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }} - PREV_VERSION: ${{ steps.metadata.outputs.previous-version }} - NEW_VERSION: ${{ steps.metadata.outputs.new-version }} - - - name: Inject Linear ticket into PR body - if: >- - github.event.action != 'closed' - && steps.linear-ticket.outputs.identifier != '' - run: | - CURRENT_BODY=$(gh pr view "$PR_URL" --json body -q .body) - gh pr edit "$PR_URL" --body "${CURRENT_BODY} - - Fixes ${IDENTIFIER}" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IDENTIFIER: ${{ steps.linear-ticket.outputs.identifier }} - - # ── PR Merged ────────────────────────────────────────── - - name: Slack — PR merged - if: >- - github.event.action == 'closed' - && github.event.pull_request.merged == true - && (inputs.slack-channel-id != '' || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') - uses: slackapi/slack-github-action@v2 - with: - method: chat.postMessage - token: ${{ secrets.slack-bot-token }} - payload: | - channel: ${{ inputs.slack-channel-id || vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} - thread_ts: ${{ steps.slack-thread.outputs.thread_ts || '' }} - text: "✅ Vulnerability resolved. PR merged: ${{ github.event.pull_request.html_url }}\n`${{ steps.metadata.outputs.dependency-names }}` updated from ${{ steps.metadata.outputs.previous-version }} → ${{ steps.metadata.outputs.new-version }}" diff --git a/.gitignore b/.gitignore index 19a37ba..6c2bbd8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ Thumbs.db .claude claude/* .worktrees/ + +# Node / Cloudflare Workers +node_modules/ +.wrangler/ diff --git a/README.md b/README.md index 59b0d24..137947d 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,58 @@ # .github -Organization-level GitHub configuration for Amera, including PR templates, contribution guidelines, and reusable workflows. +Organization-level GitHub configuration for Amera, including PR templates, reusable workflows, and the Dependabot webhook worker. -## Dependabot Workflows +## Dependabot Automation -Four workflows that cover the full Dependabot vulnerability lifecycle plus the infrastructure that keeps it working with private CodeArtifact packages. +Automated vulnerability lifecycle management across all org repos, combining a Cloudflare Worker for real-time event handling with GitHub Actions workflows for infrastructure maintenance. **Overview** ```mermaid graph TD - subgraph infra [Infrastructure] - Refresh["refresh_codeartifact_token\n(every 10h)"] -->|"rotates"| CASecret["Org Dependabot secret:\nCA_TOKEN"] - Sync["sync_dependabot_config\n(weekly)"] -->|"opens PRs"| DYml["dependabot.yml\n(per repo)"] - Sync -->|"reads"| Template["dependabot-template.yml"] + subgraph webhook ["Org Webhook → CF Worker"] + GH["GitHub Events"] --> Worker["dependabot-webhook\n(Cloudflare Worker)"] + Worker -->|"dependabot_alert.created"| AlertHandler[Alert Handler] + Worker -->|"pull_request.opened"| PRHandler[PR Handler] + Worker -->|"pull_request.closed+merged"| MergeHandler[Merge Handler] end - subgraph lifecycle [Vulnerability Lifecycle] - V[Vulnerability Detected] --> A[dependabot_alert] - A -->|"Slack + Linear ticket"| Team[Team Notified] - D[Dependabot Opens PR] --> P[dependabot_pr] - P -->|"patch/minor"| AutoMerge["Auto-merge enabled\n(awaits human approval)"] - P -->|"major"| ManualReview["Label + Slack + Linear ticket"] + subgraph actions ["Slack + Linear + GitHub API"] + AlertHandler -->|"post"| Slack["Slack\n(GHSA-keyed thread)"] + AlertHandler -->|"create ticket"| Linear["Linear\n(GHSA in title)"] + PRHandler -->|"reply in thread"| Slack + PRHandler -->|"comment on ticket"| Linear + PRHandler -->|"auto-merge / label"| GitHubAPI[GitHub API] + MergeHandler -->|"reply: resolved"| Slack end - DYml -->|"uses"| CASecret + subgraph infra [Infrastructure Workflows] + Refresh["refresh_codeartifact_token\n(every 10h)"] -->|"rotates"| CASecret["Org Dependabot secret:\nCA_TOKEN"] + Sync["sync_dependabot_config\n(daily)"] -->|"opens PRs"| DYml["dependabot.yml\n(per repo)"] + end ``` **Vulnerability lifecycle (detailed)** ```mermaid graph TD subgraph alert_phase [1 - Alert Created] - A1[dependabot_alert] --> A2["Post Slack message\n(includes GHSA ID)"] + A1["dependabot_alert webhook"] --> A2["Post Slack message\n(includes GHSA ID)"] A1 --> A3["Create Linear ticket\n(GHSA ID in title)"] end subgraph pr_opened [2 - PR Opened] - B1["dependabot_pr\n(opened)"] --> B2["fetch-metadata\n(alert-lookup: true)"] - B2 --> B3[Get ghsa-id] - B3 --> B4["Find Slack thread by GHSA ID\n(retry with backoff)"] - B4 --> B5[Reply in thread] - B3 --> B6[Find Linear ticket by GHSA ID] - B6 --> B7[Comment on ticket] - B6 --> B8["Inject 'Fixes AMR-123'\ninto PR body"] + B1["pull_request webhook\n(sender: dependabot)"] --> B2["Look up GHSA ID\n(via Dependabot alerts API)"] + B2 --> B3["Find Slack thread by GHSA ID"] + B3 --> B4[Reply in thread] + B2 --> B5[Find Linear ticket by GHSA ID] + B5 --> B6[Comment on ticket] + B5 --> B7["Inject 'Fixes AMR-123'\ninto PR body"] + B1 --> B8{"Update type?"} + B8 -->|"patch/minor"| B9[Enable auto-merge] + B8 -->|"major"| B10["Add 'major-update' label"] end subgraph pr_merged [3 - PR Merged] - C1["dependabot_pr\n(closed+merged)"] --> C2[Reply in Slack thread: resolved] + C1["pull_request webhook\n(closed+merged)"] --> C2[Reply in Slack thread: resolved] C1 --> C3["Linear auto-closes ticket\n(via PR body keyword)"] end @@ -55,24 +62,49 @@ graph TD ### Prerequisites -**GitHub App (AMERABOT)** — used by all workflows for elevated permissions. +**GitHub App (AMERABOT)** — used by the Worker for GitHub API calls (auto-merge, labels, alert lookup, PR edits) and by workflows for elevated permissions. 1. Create a GitHub App in the `amera-apps` org with these permissions: - - **Dependabot alerts:** Read-only (for `alert-lookup` in `fetch-metadata`) + - **Dependabot alerts:** Read-only - **Organization Dependabot secrets:** Read and write (for `refresh_codeartifact_token`) - - **Contents:** Read and write (for `sync_dependabot_config` to create branches and commit files) - - **Pull requests:** Read and write (for `sync_dependabot_config` to open PRs) + - **Contents:** Read and write (for `sync_dependabot_config`) + - **Pull requests:** Read and write (for `sync_dependabot_config` and the Worker) 2. Install it on all repos -3. Store as org-level secrets: `AMERABOT_APP_ID` and `AMERABOT_APP_PRIVATE_KEY` +3. Note the **installation ID** from `https://github.com/organizations/amera-apps/settings/installations` + +**Org webhook** — delivers `dependabot_alert` and `pull_request` events to the Worker. + +1. Go to org Settings → Webhooks → Add webhook +2. Payload URL: `https://dependabot-webhook..workers.dev` +3. Content type: `application/json` +4. Secret: a strong random string (same value stored as `GITHUB_WEBHOOK_SECRET` in the Worker) +5. Events: select **Dependabot alerts** and **Pull requests** + +**Slack bot scopes** — `chat:write` plus `channels:history` (public) or `groups:history` (private) for thread lookup. + +**Worker secrets** — set via `wrangler secret put` in `workers/dependabot/`: + +| Secret | Description | +|---|---| +| `GITHUB_WEBHOOK_SECRET` | Shared secret for webhook signature verification | +| `GITHUB_APP_ID` | AMERABOT app ID | +| `GITHUB_APP_PRIVATE_KEY` | AMERABOT private key (PEM format) | +| `GITHUB_INSTALLATION_ID` | AMERABOT installation ID (numeric) | +| `SLACK_BOT_TOKEN` | Slack bot token | +| `LINEAR_API_KEY` | Linear API key | + +**Worker variables** — set in [`workers/dependabot/wrangler.toml`](workers/dependabot/wrangler.toml) under `[vars]`: -**Slack bot scopes** — the bot needs `chat:write` (already required) plus `channels:history` (public channels) or `groups:history` (private channels) for thread lookup. +| Variable | Description | +|---|---| +| `SLACK_CHANNEL_ID` | Slack channel for Dependabot notifications | +| `LINEAR_TEAM_ID` | Linear team for vulnerability tickets | +| `LINEAR_PROJECT_ID` | Linear project for vulnerability tickets | -**Org secrets** — sensitive credentials, set at the org level so all repos inherit them: +**Org secrets** (for GitHub Actions workflows only): | Secret | Description | |---|---| -| `SLACK_BOT_TOKEN` | Slack bot token (`chat:write` + `channels:history` scopes) | -| `LINEAR_API_KEY` | Linear API key for ticket creation and search | | `AMERABOT_APP_ID` | GitHub App ID | | `AMERABOT_APP_PRIVATE_KEY` | GitHub App private key | | `AWS_ACCESS_KEY_ID` | IAM user for CodeArtifact token generation | @@ -80,121 +112,53 @@ graph TD The AWS IAM user should have minimal permissions: `codeartifact:GetAuthorizationToken` and `sts:GetServiceLinkedRoleDeletionStatus`. -**Org variables** — non-sensitive defaults. All workflow inputs fall back to these when not explicitly provided by the caller, so most repos don't need to pass them. +**Org variables** (for GitHub Actions workflows only): | Variable | Description | |---|---| -| `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Default Slack channel for Dependabot notifications | -| `LINEAR_AMERA_TEAM_ID` | Default Linear team for vulnerability tickets | -| `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Default Linear project for vulnerability tickets | +| `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel (used by `sync_dependabot_config`) | +| `LINEAR_AMERA_TEAM_ID` | Linear team (used by `sync_dependabot_config`) | +| `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Linear project (used by `sync_dependabot_config`) | | `AWS_REGION` | AWS region for CodeArtifact (`us-east-1`) | -| `AWS_OWNER_ID` | AWS account ID / domain owner for CodeArtifact (`371568547021`) | - -Repos can override any default by passing the corresponding input in the caller workflow. +| `AWS_OWNER_ID` | AWS account ID / domain owner (`371568547021`) | -### Dependabot Alert +### Dependabot Webhook Worker -[`.github/workflows/dependabot_alert.yml`](.github/workflows/dependabot_alert.yml) +[`workers/dependabot/`](workers/dependabot/) -**Phase 1:** Fires when a vulnerability alert is created. Posts a Slack message and creates a Linear ticket, both containing the GHSA ID so downstream workflows can find them. +A Cloudflare Worker that receives GitHub org-level webhooks and handles the full Dependabot vulnerability lifecycle in real-time. No per-repo workflow callers needed. -| Input | Required | Fallback variable | Description | -|---|---|---|---| -| `slack-channel-id` | No | `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel ID | -| `linear-team-id` | No | `LINEAR_AMERA_TEAM_ID` | Linear team ID | -| `linear-project-id` | No | `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Linear project ID | +**Events handled:** -| Secret | Required | Description | +| Event | Action | What happens | |---|---|---| -| `slack-bot-token` | No | Slack bot token | -| `linear-api-key` | No | Linear API key | - -#### Usage - -Minimal — uses org variable defaults: - -```yaml -# .github/workflows/dependabot_alert.yml -name: Dependabot Alert -on: - dependabot_alert: - types: [created] - -jobs: - notify: - uses: amera-apps/.github/.github/workflows/dependabot_alert.yml@main - secrets: - slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} - linear-api-key: ${{ secrets.LINEAR_API_KEY }} -``` - -With overrides: - -```yaml -jobs: - notify: - uses: amera-apps/.github/.github/workflows/dependabot_alert.yml@main - with: - slack-channel-id: C9999999999 - linear-team-id: different-team-id - linear-project-id: different-project-id - secrets: - slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} - linear-api-key: ${{ secrets.LINEAR_API_KEY }} -``` +| `dependabot_alert` | `created` | Posts Slack message + creates Linear ticket (both keyed by GHSA ID) | +| `pull_request` | `opened` (by Dependabot) | Enables auto-merge (patch/minor) or labels as major, replies in Slack thread, comments on Linear ticket, injects `Fixes AMR-123` into PR body | +| `pull_request` | `closed` + merged (by Dependabot) | Replies in Slack thread confirming resolution | -### Dependabot PR +All other events are acknowledged with 200 OK and ignored. -[`.github/workflows/dependabot_pr.yml`](.github/workflows/dependabot_pr.yml) +#### Development -**Phases 2 & 3:** Handles PR opened and PR merged events. - -**On PR opened:** - -- Enables auto-merge for patch/minor updates (waits for human approval + CI) -- Labels major updates with `major-update` -- Finds the Slack thread by GHSA ID (with retry + backoff) and replies in-thread -- Finds the Linear ticket by GHSA ID, adds a comment, and injects `Fixes AMR-123` into the PR body - -**On PR merged:** +```bash +cd workers/dependabot +npm install +wrangler dev +``` -- Replies in the Slack thread confirming the vulnerability is resolved -- Linear auto-closes the ticket via the `Fixes AMR-123` keyword in the PR body +#### Deployment -| Input | Required | Fallback variable | Description | -|---|---|---|---| -| `slack-channel-id` | No | `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel ID | -| `linear-team-id` | No | `LINEAR_AMERA_TEAM_ID` | Linear team ID | +```bash +cd workers/dependabot +wrangler deploy -| Secret | Required | Description | -|---|---|---| -| `slack-bot-token` | No | Slack bot token | -| `linear-api-key` | No | Linear API key | -| `gh-app-id` | Yes | GitHub App ID for alert-lookup | -| `gh-app-private-key` | Yes | GitHub App private key | - -#### Usage - -Minimal — uses org variable defaults: - -```yaml -# .github/workflows/dependabot_pr.yml -name: Dependabot PR -on: - pull_request: - types: [opened, closed] - -jobs: - triage: - uses: amera-apps/.github/.github/workflows/dependabot_pr.yml@main - permissions: - contents: write - pull-requests: write - secrets: - slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }} - linear-api-key: ${{ secrets.LINEAR_API_KEY }} - gh-app-id: ${{ secrets.AMERABOT_APP_ID }} - gh-app-private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} +# Set secrets (one-time, or when rotating) +wrangler secret put GITHUB_WEBHOOK_SECRET +wrangler secret put GITHUB_APP_ID +wrangler secret put GITHUB_APP_PRIVATE_KEY +wrangler secret put GITHUB_INSTALLATION_ID +wrangler secret put SLACK_BOT_TOKEN +wrangler secret put LINEAR_API_KEY ``` ### CodeArtifact Token Refresh @@ -224,7 +188,7 @@ Dependabot requires a `.github/dependabot.yml` in each repo — there's no way t ```mermaid graph TD - Cron["Schedule\n(Monday 9am UTC)"] --> Sync[sync_dependabot_config] + Cron["Schedule\n(daily 11:00 UTC)"] --> Sync[sync_dependabot_config] Sync -->|"reads"| Template["dependabot-template.yml\n(this repo)"] Sync -->|"for each repo"| Check{"Has pyproject.toml\nwith codeartifact?"} Check -->|"yes + out of date"| PR["Open PR:\nchore/sync-dependabot-config"] @@ -260,5 +224,5 @@ To change the Dependabot config across all repos: 1. Edit [`.github/dependabot-template.yml`](.github/dependabot-template.yml) in this repo 2. Merge to `main` -3. Wait for the next scheduled sync (Monday 9am UTC) or trigger manually via `workflow_dispatch` +3. Wait for the next scheduled sync or trigger manually via `workflow_dispatch` 4. Review and merge the PRs opened in each repo diff --git a/workers/dependabot/package.json b/workers/dependabot/package.json new file mode 100644 index 0000000..a386175 --- /dev/null +++ b/workers/dependabot/package.json @@ -0,0 +1,17 @@ +{ + "name": "dependabot-webhook", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4", + "typescript": "^5", + "wrangler": "^4" + }, + "dependencies": { + "universal-github-app-jwt": "^2" + } +} diff --git a/workers/dependabot/src/handlers/alert.ts b/workers/dependabot/src/handlers/alert.ts new file mode 100644 index 0000000..596a169 --- /dev/null +++ b/workers/dependabot/src/handlers/alert.ts @@ -0,0 +1,54 @@ +import type { DependabotAlertEvent } from '../types/github' +import type { Env } from '../types/env' +import * as slack from '../lib/slack' +import * as linear from '../lib/linear' + +/** + * Handles a `dependabot_alert` webhook with action `created`. + * Posts a Slack message and creates a Linear ticket, both keyed by GHSA ID + * so the PR handler can find and update them later. + */ +export async function handleAlert(event: DependabotAlertEvent, env: Env): Promise { + const { alert, repository } = event + const severity = alert.security_vulnerability.severity + const pkg = alert.dependency.package.name + const ecosystem = alert.dependency.package.ecosystem + const ghsa = alert.security_advisory.ghsa_id + const cve = alert.security_advisory.cve_id ?? 'N/A' + const summary = alert.security_advisory.summary + const url = alert.html_url + const repo = repository.full_name + + const slackText = [ + `🔒 *Dependabot alert* in \`${repo}\``, + `*Severity:* ${severity}`, + `*Package:* \`${pkg}\` (${ecosystem})`, + `*CVE:* ${cve}`, + `*GHSA:* ${ghsa}`, + `*Summary:* ${summary}`, + url + ].join('\n') + + const linearDesc = [ + `A **${severity}** severity vulnerability was detected in \`${pkg}\` (${ecosystem}).`, + '', + `**GHSA:** ${ghsa}`, + `**CVE:** ${cve}`, + `**Summary:** ${summary}`, + `**Repo:** ${repo}`, + `**Alert:** ${url}`, + '', + 'If Dependabot opens a fix PR it will be triaged automatically. If no PR appears, manual intervention is required.' + ].join('\n') + + await Promise.all([ + slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText), + linear.createIssue( + env.LINEAR_API_KEY, + env.LINEAR_TEAM_ID, + env.LINEAR_PROJECT_ID, + `[${severity}] ${ghsa}: Vulnerability in ${pkg}`, + linearDesc + ) + ]) +} diff --git a/workers/dependabot/src/handlers/pull-request.ts b/workers/dependabot/src/handlers/pull-request.ts new file mode 100644 index 0000000..85ed5b1 --- /dev/null +++ b/workers/dependabot/src/handlers/pull-request.ts @@ -0,0 +1,171 @@ +import type { PullRequestEvent } from '../types/github' +import type { Env } from '../types/env' +import * as github from '../lib/github' +import * as slack from '../lib/slack' +import * as linear from '../lib/linear' + +/** + * Handles a Dependabot pull request that was just opened. + * Enables auto-merge (or labels as major), notifies Slack in the existing + * alert thread, comments on the Linear ticket, and injects the Linear + * identifier into the PR body for auto-close on merge. + */ +export async function handlePROpened(event: PullRequestEvent, env: Env): Promise { + const { pull_request: pr, repository } = event + const owner = repository.owner.login + const repo = repository.name + const { dependencyName, updateType, prevVersion, newVersion } = parsePRBody(pr.title, pr.body) + + const token = await github.getInstallationToken( + env.GITHUB_APP_ID, + env.GITHUB_APP_PRIVATE_KEY, + env.GITHUB_INSTALLATION_ID + ) + + const ghsaId = await findGhsaForDependency(token, owner, repo, dependencyName) + + if (updateType === 'major') { + await github.addLabel(token, owner, repo, pr.number, 'major-update') + } else { + await github.enableAutoMerge(token, pr.node_id) + } + + let threadTs: string | undefined + if (ghsaId) { + threadTs = await slack.findThread(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, ghsaId) + } + + const slackText = [ + `📦 Dependabot opened a fix PR: ${pr.html_url}`, + `\`${dependencyName}\` ${prevVersion} → ${newVersion} (${updateType})` + ].join('\n') + + await slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText, threadTs) + + if (ghsaId) { + const issue = await linear.findIssue(env.LINEAR_API_KEY, env.LINEAR_TEAM_ID, ghsaId) + + if (issue) { + await linear.addComment( + env.LINEAR_API_KEY, + issue.id, + [ + `Dependabot opened a fix PR: ${pr.html_url}`, + '', + `**Update type:** ${updateType}`, + `**Version:** ${prevVersion} → ${newVersion}` + ].join('\n') + ) + + const currentBody = pr.body ?? '' + await github.updatePRBody( + token, + owner, + repo, + pr.number, + `${currentBody}\n\nFixes ${issue.identifier}` + ) + } + } +} + +/** + * Handles a Dependabot pull request that was merged. + * Posts a resolution message in the Slack thread. + */ +export async function handlePRMerged(event: PullRequestEvent, env: Env): Promise { + const { pull_request: pr, repository } = event + const owner = repository.owner.login + const repo = repository.name + const { dependencyName, prevVersion, newVersion } = parsePRBody(pr.title, pr.body) + + const token = await github.getInstallationToken( + env.GITHUB_APP_ID, + env.GITHUB_APP_PRIVATE_KEY, + env.GITHUB_INSTALLATION_ID + ) + + const ghsaId = await findGhsaForDependency(token, owner, repo, dependencyName) + + let threadTs: string | undefined + if (ghsaId) { + threadTs = await slack.findThread(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, ghsaId) + } + + const slackText = [ + `✅ Vulnerability resolved. PR merged: ${pr.html_url}`, + `\`${dependencyName}\` updated from ${prevVersion} → ${newVersion}` + ].join('\n') + + await slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText, threadTs) +} + +interface PRMetadata { + dependencyName: string + updateType: string + prevVersion: string + newVersion: string +} + +/** + * Extracts dependency metadata from the Dependabot PR title and body. + * Dependabot titles follow: "Bump from to " + */ +function parsePRBody(title: string, body: string | null): PRMetadata { + const bumpMatch = title.match(/^Bump (.+) from (\S+) to (\S+)/) + if (bumpMatch) { + const isMajor = isMajorBump(bumpMatch[2], bumpMatch[3]) + return { + dependencyName: bumpMatch[1], + prevVersion: bumpMatch[2], + newVersion: bumpMatch[3], + updateType: isMajor ? 'major' : 'minor/patch' + } + } + + const updateMatch = title.match(/^Update (.+) requirement from .* to (.*)/) + if (updateMatch) { + return { + dependencyName: updateMatch[1], + prevVersion: '', + newVersion: updateMatch[2], + updateType: 'minor/patch' + } + } + + return { + dependencyName: title, + prevVersion: '', + newVersion: '', + updateType: 'unknown' + } +} + +/** Determines if a version bump is a semver major change */ +function isMajorBump(from: string, to: string): boolean { + const fromMajor = from.replace(/^v/, '').split('.')[0] + const toMajor = to.replace(/^v/, '').split('.')[0] + return fromMajor !== toMajor +} + +/** + * Finds the GHSA ID associated with a dependency by looking up + * open Dependabot alerts for the repository. + */ +async function findGhsaForDependency( + token: string, + owner: string, + repo: string, + dependencyName: string +): Promise { + try { + const alerts = await github.listDependabotAlerts(token, owner, repo) + const match = alerts.find( + (a) => a.dependency.package.name === dependencyName + ) + return match?.security_advisory.ghsa_id + } catch { + console.error(`Failed to look up alerts for ${owner}/${repo}`) + return undefined + } +} diff --git a/workers/dependabot/src/index.ts b/workers/dependabot/src/index.ts new file mode 100644 index 0000000..73787c2 --- /dev/null +++ b/workers/dependabot/src/index.ts @@ -0,0 +1,51 @@ +import type { Env } from './types/env' +import type { DependabotAlertEvent, PullRequestEvent } from './types/github' +import { verifyWebhookSignature } from './lib/verify' +import { handleAlert } from './handlers/alert' +import { handlePROpened, handlePRMerged } from './handlers/pull-request' + +export default { + async fetch(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }) + } + + const signature = request.headers.get('X-Hub-Signature-256') + const eventType = request.headers.get('X-GitHub-Event') + const body = await request.text() + + const valid = await verifyWebhookSignature(env.GITHUB_WEBHOOK_SECRET, body, signature) + if (!valid) { + return new Response('Invalid signature', { status: 401 }) + } + + const payload = JSON.parse(body) + + try { + if (eventType === 'dependabot_alert' && payload.action === 'created') { + await handleAlert(payload as DependabotAlertEvent, env) + return new Response('Alert handled', { status: 200 }) + } + + if (eventType === 'pull_request' && payload.sender?.login === 'dependabot[bot]') { + const event = payload as PullRequestEvent + + if (event.action === 'opened') { + await handlePROpened(event, env) + return new Response('PR opened handled', { status: 200 }) + } + + if (event.action === 'closed' && event.pull_request.merged) { + await handlePRMerged(event, env) + return new Response('PR merged handled', { status: 200 }) + } + } + + return new Response('OK', { status: 200 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error(`Handler error: ${message}`) + return new Response(`Internal error: ${message}`, { status: 500 }) + } + } +} diff --git a/workers/dependabot/src/lib/github.ts b/workers/dependabot/src/lib/github.ts new file mode 100644 index 0000000..068281a --- /dev/null +++ b/workers/dependabot/src/lib/github.ts @@ -0,0 +1,149 @@ +import { getToken } from 'universal-github-app-jwt' +import type { DependabotAlert } from '../types/github' + +const API = 'https://api.github.com' + +/** + * Generates a GitHub App installation access token by creating a JWT + * from the app credentials and exchanging it for a scoped token. + */ +export async function getInstallationToken( + appId: string, + privateKey: string, + installationId: string +): Promise { + const { token: jwt } = await getToken({ + id: appId, + privateKey + }) + + const res = await fetch( + `${API}/app/installations/${installationId}/access_tokens`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'amera-dependabot-worker' + } + } + ) + + if (!res.ok) { + throw new Error(`Failed to get installation token: ${res.status} ${await res.text()}`) + } + + const data = await res.json<{ token: string }>() + return data.token +} + +/** + * Enables auto-merge (squash) on a pull request via the GraphQL API. + * Uses GraphQL because the REST API does not support enabling auto-merge. + */ +export async function enableAutoMerge( + token: string, + pullRequestNodeId: string +): Promise { + const res = await fetch(`${API}/graphql`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'amera-dependabot-worker' + }, + body: JSON.stringify({ + query: `mutation($prId: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $prId, mergeMethod: SQUASH }) { + pullRequest { autoMergeRequest { enabledAt } } + } + }`, + variables: { prId: pullRequestNodeId } + }) + }) + + if (!res.ok) { + throw new Error(`Failed to enable auto-merge: ${res.status} ${await res.text()}`) + } +} + +/** Adds a label to an issue or pull request */ +export async function addLabel( + token: string, + owner: string, + repo: string, + issueNumber: number, + label: string +): Promise { + const res = await fetch( + `${API}/repos/${owner}/${repo}/issues/${issueNumber}/labels`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'User-Agent': 'amera-dependabot-worker' + }, + body: JSON.stringify({ labels: [label] }) + } + ) + + if (!res.ok) { + throw new Error(`Failed to add label: ${res.status} ${await res.text()}`) + } +} + +/** Updates the body of a pull request */ +export async function updatePRBody( + token: string, + owner: string, + repo: string, + prNumber: number, + body: string +): Promise { + const res = await fetch( + `${API}/repos/${owner}/${repo}/pulls/${prNumber}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'User-Agent': 'amera-dependabot-worker' + }, + body: JSON.stringify({ body }) + } + ) + + if (!res.ok) { + throw new Error(`Failed to update PR body: ${res.status} ${await res.text()}`) + } +} + +/** + * Lists open Dependabot alerts for a repository. + * Used to find the GHSA ID associated with a Dependabot PR. + */ +export async function listDependabotAlerts( + token: string, + owner: string, + repo: string +): Promise { + const res = await fetch( + `${API}/repos/${owner}/${repo}/dependabot/alerts?state=open&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'amera-dependabot-worker' + } + } + ) + + if (!res.ok) { + throw new Error(`Failed to list alerts: ${res.status} ${await res.text()}`) + } + + return res.json() +} diff --git a/workers/dependabot/src/lib/linear.ts b/workers/dependabot/src/lib/linear.ts new file mode 100644 index 0000000..7133309 --- /dev/null +++ b/workers/dependabot/src/lib/linear.ts @@ -0,0 +1,102 @@ +const API = 'https://api.linear.app/graphql' + +interface LinearIssue { + id: string + identifier: string + url: string +} + +/** Creates a Linear issue and returns its ID, identifier (e.g. AMR-123), and URL */ +export async function createIssue( + apiKey: string, + teamId: string, + projectId: string, + title: string, + description: string +): Promise { + const res = await fetch(API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey + }, + body: JSON.stringify({ + query: `mutation($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { id identifier url } + } + }`, + variables: { + input: { title, description, teamId, projectId } + } + }) + }) + + const data = await res.json<{ + data?: { issueCreate?: { success: boolean; issue: LinearIssue } } + }>() + + return data.data?.issueCreate?.issue +} + +/** + * Finds an open Linear issue whose title contains the given GHSA ID. + * Scoped to a specific team. + */ +export async function findIssue( + apiKey: string, + teamId: string, + ghsaId: string +): Promise { + const res = await fetch(API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey + }, + body: JSON.stringify({ + query: `query($filter: IssueFilterInput) { + issues(filter: $filter) { + nodes { id identifier url } + } + }`, + variables: { + filter: { + team: { id: { eq: teamId } }, + title: { contains: ghsaId }, + state: { type: { neq: 'completed' } } + } + } + }) + }) + + const data = await res.json<{ + data?: { issues?: { nodes: LinearIssue[] } } + }>() + + return data.data?.issues?.nodes?.[0] +} + +/** Adds a comment to a Linear issue */ +export async function addComment( + apiKey: string, + issueId: string, + body: string +): Promise { + await fetch(API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey + }, + body: JSON.stringify({ + query: `mutation($input: CommentCreateInput!) { + commentCreate(input: $input) { success } + }`, + variables: { + input: { issueId, body } + } + }) + }) +} diff --git a/workers/dependabot/src/lib/slack.ts b/workers/dependabot/src/lib/slack.ts new file mode 100644 index 0000000..caa2040 --- /dev/null +++ b/workers/dependabot/src/lib/slack.ts @@ -0,0 +1,57 @@ +const API = 'https://slack.com/api' + +interface SlackMessage { + ts: string + text: string +} + +/** Posts a message to a Slack channel, optionally as a thread reply */ +export async function postMessage( + token: string, + channel: string, + text: string, + threadTs?: string +): Promise { + const body: Record = { channel, text } + if (threadTs) body.thread_ts = threadTs + + const res = await fetch(`${API}/chat.postMessage`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + const data = await res.json<{ ok: boolean; ts?: string; error?: string }>() + if (!data.ok) { + console.error(`Slack postMessage failed: ${data.error}`) + return undefined + } + + return data.ts +} + +/** + * Searches recent channel messages for one containing the given text. + * Returns the message `ts` (thread ID) if found, undefined otherwise. + */ +export async function findThread( + token: string, + channel: string, + searchText: string +): Promise { + const res = await fetch( + `${API}/conversations.history?channel=${channel}&limit=200`, + { + headers: { Authorization: `Bearer ${token}` } + } + ) + + const data = await res.json<{ ok: boolean; messages?: SlackMessage[] }>() + if (!data.ok || !data.messages) return undefined + + const match = data.messages.find((m) => m.text.includes(searchText)) + return match?.ts +} diff --git a/workers/dependabot/src/lib/verify.ts b/workers/dependabot/src/lib/verify.ts new file mode 100644 index 0000000..07e9750 --- /dev/null +++ b/workers/dependabot/src/lib/verify.ts @@ -0,0 +1,49 @@ +/** + * Verifies a GitHub webhook signature using HMAC-SHA256 via the Web Crypto API. + * Returns true if the signature in the header matches the computed signature. + */ +export async function verifyWebhookSignature( + secret: string, + payload: string, + signatureHeader: string | null +): Promise { + if (!signatureHeader) return false + + const encoder = new TextEncoder() + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + + const signatureBytes = await crypto.subtle.sign( + 'HMAC', + key, + encoder.encode(payload) + ) + + const expected = `sha256=${Array.from(new Uint8Array(signatureBytes)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')}` + + return timingSafeEqual(expected, signatureHeader) +} + +/** Constant-time string comparison to prevent timing attacks */ +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + + const encoder = new TextEncoder() + const aBuf = encoder.encode(a) + const bBuf = encoder.encode(b) + + let result = 0 + for (let i = 0; i < aBuf.length; i++) { + result |= aBuf[i] ^ bBuf[i] + } + + return result === 0 +} diff --git a/workers/dependabot/src/types/env.ts b/workers/dependabot/src/types/env.ts new file mode 100644 index 0000000..4628a79 --- /dev/null +++ b/workers/dependabot/src/types/env.ts @@ -0,0 +1,11 @@ +export interface Env { + GITHUB_WEBHOOK_SECRET: string + GITHUB_APP_ID: string + GITHUB_APP_PRIVATE_KEY: string + GITHUB_INSTALLATION_ID: string + SLACK_BOT_TOKEN: string + SLACK_CHANNEL_ID: string + LINEAR_API_KEY: string + LINEAR_TEAM_ID: string + LINEAR_PROJECT_ID: string +} diff --git a/workers/dependabot/src/types/github.ts b/workers/dependabot/src/types/github.ts new file mode 100644 index 0000000..0fe71b8 --- /dev/null +++ b/workers/dependabot/src/types/github.ts @@ -0,0 +1,78 @@ +export interface DependabotAlertEvent { + action: 'created' | 'dismissed' | 'fixed' | 'reintroduced' | 'reopened' + alert: { + number: number + state: string + html_url: string + dependency: { + package: { + name: string + ecosystem: string + } + manifest_path: string + } + security_advisory: { + ghsa_id: string + cve_id: string | null + summary: string + severity: 'low' | 'medium' | 'high' | 'critical' + } + security_vulnerability: { + severity: 'low' | 'medium' | 'high' | 'critical' + first_patched_version: { identifier: string } | null + vulnerable_version_range: string + } + } + repository: Repository + organization?: { login: string } + sender: { login: string } +} + +export interface PullRequestEvent { + action: 'opened' | 'closed' | 'synchronize' | 'reopened' | 'edited' | 'labeled' | 'unlabeled' + number: number + pull_request: { + number: number + html_url: string + title: string + body: string | null + merged: boolean + state: 'open' | 'closed' + head: { ref: string; sha: string } + base: { ref: string } + user: { login: string } + node_id: string + } + repository: Repository + organization?: { login: string } + sender: { login: string } +} + +interface Repository { + name: string + full_name: string + owner: { login: string } +} + +export interface DependabotAlert { + number: number + state: string + html_url: string + dependency: { + package: { + name: string + ecosystem: string + } + manifest_path: string + } + security_advisory: { + ghsa_id: string + cve_id: string | null + summary: string + severity: string + } + security_vulnerability: { + severity: string + first_patched_version: { identifier: string } | null + } +} diff --git a/workers/dependabot/tsconfig.json b/workers/dependabot/tsconfig.json new file mode 100644 index 0000000..fa023cb --- /dev/null +++ b/workers/dependabot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/workers/dependabot/wrangler.toml b/workers/dependabot/wrangler.toml new file mode 100644 index 0000000..0cd058f --- /dev/null +++ b/workers/dependabot/wrangler.toml @@ -0,0 +1,8 @@ +name = "dependabot-webhook" +main = "src/index.ts" +compatibility_date = "2025-01-01" + +[vars] +SLACK_CHANNEL_ID = "" +LINEAR_TEAM_ID = "" +LINEAR_PROJECT_ID = "" From 9e33afdc147ad12306a1f16b904317c629a7fc31 Mon Sep 17 00:00:00 2001 From: Nauras Jabari Date: Tue, 31 Mar 2026 17:21:08 -0400 Subject: [PATCH 6/6] feat: removed cf dependabot worker (migrated to infra repo as a lambda) --- .gitignore | 4 - README.md | 82 ++------- workers/dependabot/package.json | 17 -- workers/dependabot/src/handlers/alert.ts | 54 ------ .../dependabot/src/handlers/pull-request.ts | 171 ------------------ workers/dependabot/src/index.ts | 51 ------ workers/dependabot/src/lib/github.ts | 149 --------------- workers/dependabot/src/lib/linear.ts | 102 ----------- workers/dependabot/src/lib/slack.ts | 57 ------ workers/dependabot/src/lib/verify.ts | 49 ----- workers/dependabot/src/types/env.ts | 11 -- workers/dependabot/src/types/github.ts | 78 -------- workers/dependabot/tsconfig.json | 16 -- workers/dependabot/wrangler.toml | 8 - 14 files changed, 14 insertions(+), 835 deletions(-) delete mode 100644 workers/dependabot/package.json delete mode 100644 workers/dependabot/src/handlers/alert.ts delete mode 100644 workers/dependabot/src/handlers/pull-request.ts delete mode 100644 workers/dependabot/src/index.ts delete mode 100644 workers/dependabot/src/lib/github.ts delete mode 100644 workers/dependabot/src/lib/linear.ts delete mode 100644 workers/dependabot/src/lib/slack.ts delete mode 100644 workers/dependabot/src/lib/verify.ts delete mode 100644 workers/dependabot/src/types/env.ts delete mode 100644 workers/dependabot/src/types/github.ts delete mode 100644 workers/dependabot/tsconfig.json delete mode 100644 workers/dependabot/wrangler.toml diff --git a/.gitignore b/.gitignore index 6c2bbd8..19a37ba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,3 @@ Thumbs.db .claude claude/* .worktrees/ - -# Node / Cloudflare Workers -node_modules/ -.wrangler/ diff --git a/README.md b/README.md index 137947d..1aa6736 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # .github -Organization-level GitHub configuration for Amera, including PR templates, reusable workflows, and the Dependabot webhook worker. +Organization-level GitHub configuration for Amera, including PR templates, reusable workflows, and Dependabot automation. ## Dependabot Automation -Automated vulnerability lifecycle management across all org repos, combining a Cloudflare Worker for real-time event handling with GitHub Actions workflows for infrastructure maintenance. +Automated vulnerability lifecycle management across all org repos, combining an [AWS Lambda webhook handler](https://github.com/amera-apps/infra/tree/main/aws/lambda/dependabot) for real-time event handling with GitHub Actions workflows for infrastructure maintenance. **Overview** ```mermaid graph TD - subgraph webhook ["Org Webhook → CF Worker"] - GH["GitHub Events"] --> Worker["dependabot-webhook\n(Cloudflare Worker)"] - Worker -->|"dependabot_alert.created"| AlertHandler[Alert Handler] - Worker -->|"pull_request.opened"| PRHandler[PR Handler] - Worker -->|"pull_request.closed+merged"| MergeHandler[Merge Handler] + subgraph webhook ["Org Webhook → AWS Lambda"] + GH["GitHub Events"] --> Lambda["dependabot-webhook\n(AWS Lambda)"] + Lambda -->|"dependabot_alert.created"| AlertHandler[Alert Handler] + Lambda -->|"pull_request.opened"| PRHandler[PR Handler] + Lambda -->|"pull_request.closed+merged"| MergeHandler[Merge Handler] end subgraph actions ["Slack + Linear + GitHub API"] @@ -62,45 +62,26 @@ graph TD ### Prerequisites -**GitHub App (AMERABOT)** — used by the Worker for GitHub API calls (auto-merge, labels, alert lookup, PR edits) and by workflows for elevated permissions. +**GitHub App (AMERABOT)** — used by the Lambda for GitHub API calls (auto-merge, labels, alert lookup, PR edits) and by workflows for elevated permissions. 1. Create a GitHub App in the `amera-apps` org with these permissions: - **Dependabot alerts:** Read-only - **Organization Dependabot secrets:** Read and write (for `refresh_codeartifact_token`) - **Contents:** Read and write (for `sync_dependabot_config`) - - **Pull requests:** Read and write (for `sync_dependabot_config` and the Worker) + - **Pull requests:** Read and write (for `sync_dependabot_config` and the Lambda) 2. Install it on all repos 3. Note the **installation ID** from `https://github.com/organizations/amera-apps/settings/installations` -**Org webhook** — delivers `dependabot_alert` and `pull_request` events to the Worker. +**Org webhook** — delivers `dependabot_alert` and `pull_request` events to the Lambda. 1. Go to org Settings → Webhooks → Add webhook -2. Payload URL: `https://dependabot-webhook..workers.dev` +2. Payload URL: the Lambda's function URL or API Gateway endpoint 3. Content type: `application/json` -4. Secret: a strong random string (same value stored as `GITHUB_WEBHOOK_SECRET` in the Worker) +4. Secret: a strong random string (same value stored as `GITHUB_WEBHOOK_SECRET` in the Lambda) 5. Events: select **Dependabot alerts** and **Pull requests** **Slack bot scopes** — `chat:write` plus `channels:history` (public) or `groups:history` (private) for thread lookup. -**Worker secrets** — set via `wrangler secret put` in `workers/dependabot/`: - -| Secret | Description | -|---|---| -| `GITHUB_WEBHOOK_SECRET` | Shared secret for webhook signature verification | -| `GITHUB_APP_ID` | AMERABOT app ID | -| `GITHUB_APP_PRIVATE_KEY` | AMERABOT private key (PEM format) | -| `GITHUB_INSTALLATION_ID` | AMERABOT installation ID (numeric) | -| `SLACK_BOT_TOKEN` | Slack bot token | -| `LINEAR_API_KEY` | Linear API key | - -**Worker variables** — set in [`workers/dependabot/wrangler.toml`](workers/dependabot/wrangler.toml) under `[vars]`: - -| Variable | Description | -|---|---| -| `SLACK_CHANNEL_ID` | Slack channel for Dependabot notifications | -| `LINEAR_TEAM_ID` | Linear team for vulnerability tickets | -| `LINEAR_PROJECT_ID` | Linear project for vulnerability tickets | - **Org secrets** (for GitHub Actions workflows only): | Secret | Description | @@ -122,44 +103,9 @@ The AWS IAM user should have minimal permissions: `codeartifact:GetAuthorization | `AWS_REGION` | AWS region for CodeArtifact (`us-east-1`) | | `AWS_OWNER_ID` | AWS account ID / domain owner (`371568547021`) | -### Dependabot Webhook Worker - -[`workers/dependabot/`](workers/dependabot/) - -A Cloudflare Worker that receives GitHub org-level webhooks and handles the full Dependabot vulnerability lifecycle in real-time. No per-repo workflow callers needed. +### Dependabot Webhook Handler -**Events handled:** - -| Event | Action | What happens | -|---|---|---| -| `dependabot_alert` | `created` | Posts Slack message + creates Linear ticket (both keyed by GHSA ID) | -| `pull_request` | `opened` (by Dependabot) | Enables auto-merge (patch/minor) or labels as major, replies in Slack thread, comments on Linear ticket, injects `Fixes AMR-123` into PR body | -| `pull_request` | `closed` + merged (by Dependabot) | Replies in Slack thread confirming resolution | - -All other events are acknowledged with 200 OK and ignored. - -#### Development - -```bash -cd workers/dependabot -npm install -wrangler dev -``` - -#### Deployment - -```bash -cd workers/dependabot -wrangler deploy - -# Set secrets (one-time, or when rotating) -wrangler secret put GITHUB_WEBHOOK_SECRET -wrangler secret put GITHUB_APP_ID -wrangler secret put GITHUB_APP_PRIVATE_KEY -wrangler secret put GITHUB_INSTALLATION_ID -wrangler secret put SLACK_BOT_TOKEN -wrangler secret put LINEAR_API_KEY -``` +The webhook handler is deployed as an AWS Lambda. Source, configuration, and deployment instructions live in [`infra/aws/lambda/dependabot/`](https://github.com/amera-apps/infra/tree/main/aws/lambda/dependabot). ### CodeArtifact Token Refresh diff --git a/workers/dependabot/package.json b/workers/dependabot/package.json deleted file mode 100644 index a386175..0000000 --- a/workers/dependabot/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "dependabot-webhook", - "private": true, - "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4", - "typescript": "^5", - "wrangler": "^4" - }, - "dependencies": { - "universal-github-app-jwt": "^2" - } -} diff --git a/workers/dependabot/src/handlers/alert.ts b/workers/dependabot/src/handlers/alert.ts deleted file mode 100644 index 596a169..0000000 --- a/workers/dependabot/src/handlers/alert.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { DependabotAlertEvent } from '../types/github' -import type { Env } from '../types/env' -import * as slack from '../lib/slack' -import * as linear from '../lib/linear' - -/** - * Handles a `dependabot_alert` webhook with action `created`. - * Posts a Slack message and creates a Linear ticket, both keyed by GHSA ID - * so the PR handler can find and update them later. - */ -export async function handleAlert(event: DependabotAlertEvent, env: Env): Promise { - const { alert, repository } = event - const severity = alert.security_vulnerability.severity - const pkg = alert.dependency.package.name - const ecosystem = alert.dependency.package.ecosystem - const ghsa = alert.security_advisory.ghsa_id - const cve = alert.security_advisory.cve_id ?? 'N/A' - const summary = alert.security_advisory.summary - const url = alert.html_url - const repo = repository.full_name - - const slackText = [ - `🔒 *Dependabot alert* in \`${repo}\``, - `*Severity:* ${severity}`, - `*Package:* \`${pkg}\` (${ecosystem})`, - `*CVE:* ${cve}`, - `*GHSA:* ${ghsa}`, - `*Summary:* ${summary}`, - url - ].join('\n') - - const linearDesc = [ - `A **${severity}** severity vulnerability was detected in \`${pkg}\` (${ecosystem}).`, - '', - `**GHSA:** ${ghsa}`, - `**CVE:** ${cve}`, - `**Summary:** ${summary}`, - `**Repo:** ${repo}`, - `**Alert:** ${url}`, - '', - 'If Dependabot opens a fix PR it will be triaged automatically. If no PR appears, manual intervention is required.' - ].join('\n') - - await Promise.all([ - slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText), - linear.createIssue( - env.LINEAR_API_KEY, - env.LINEAR_TEAM_ID, - env.LINEAR_PROJECT_ID, - `[${severity}] ${ghsa}: Vulnerability in ${pkg}`, - linearDesc - ) - ]) -} diff --git a/workers/dependabot/src/handlers/pull-request.ts b/workers/dependabot/src/handlers/pull-request.ts deleted file mode 100644 index 85ed5b1..0000000 --- a/workers/dependabot/src/handlers/pull-request.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { PullRequestEvent } from '../types/github' -import type { Env } from '../types/env' -import * as github from '../lib/github' -import * as slack from '../lib/slack' -import * as linear from '../lib/linear' - -/** - * Handles a Dependabot pull request that was just opened. - * Enables auto-merge (or labels as major), notifies Slack in the existing - * alert thread, comments on the Linear ticket, and injects the Linear - * identifier into the PR body for auto-close on merge. - */ -export async function handlePROpened(event: PullRequestEvent, env: Env): Promise { - const { pull_request: pr, repository } = event - const owner = repository.owner.login - const repo = repository.name - const { dependencyName, updateType, prevVersion, newVersion } = parsePRBody(pr.title, pr.body) - - const token = await github.getInstallationToken( - env.GITHUB_APP_ID, - env.GITHUB_APP_PRIVATE_KEY, - env.GITHUB_INSTALLATION_ID - ) - - const ghsaId = await findGhsaForDependency(token, owner, repo, dependencyName) - - if (updateType === 'major') { - await github.addLabel(token, owner, repo, pr.number, 'major-update') - } else { - await github.enableAutoMerge(token, pr.node_id) - } - - let threadTs: string | undefined - if (ghsaId) { - threadTs = await slack.findThread(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, ghsaId) - } - - const slackText = [ - `📦 Dependabot opened a fix PR: ${pr.html_url}`, - `\`${dependencyName}\` ${prevVersion} → ${newVersion} (${updateType})` - ].join('\n') - - await slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText, threadTs) - - if (ghsaId) { - const issue = await linear.findIssue(env.LINEAR_API_KEY, env.LINEAR_TEAM_ID, ghsaId) - - if (issue) { - await linear.addComment( - env.LINEAR_API_KEY, - issue.id, - [ - `Dependabot opened a fix PR: ${pr.html_url}`, - '', - `**Update type:** ${updateType}`, - `**Version:** ${prevVersion} → ${newVersion}` - ].join('\n') - ) - - const currentBody = pr.body ?? '' - await github.updatePRBody( - token, - owner, - repo, - pr.number, - `${currentBody}\n\nFixes ${issue.identifier}` - ) - } - } -} - -/** - * Handles a Dependabot pull request that was merged. - * Posts a resolution message in the Slack thread. - */ -export async function handlePRMerged(event: PullRequestEvent, env: Env): Promise { - const { pull_request: pr, repository } = event - const owner = repository.owner.login - const repo = repository.name - const { dependencyName, prevVersion, newVersion } = parsePRBody(pr.title, pr.body) - - const token = await github.getInstallationToken( - env.GITHUB_APP_ID, - env.GITHUB_APP_PRIVATE_KEY, - env.GITHUB_INSTALLATION_ID - ) - - const ghsaId = await findGhsaForDependency(token, owner, repo, dependencyName) - - let threadTs: string | undefined - if (ghsaId) { - threadTs = await slack.findThread(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, ghsaId) - } - - const slackText = [ - `✅ Vulnerability resolved. PR merged: ${pr.html_url}`, - `\`${dependencyName}\` updated from ${prevVersion} → ${newVersion}` - ].join('\n') - - await slack.postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, slackText, threadTs) -} - -interface PRMetadata { - dependencyName: string - updateType: string - prevVersion: string - newVersion: string -} - -/** - * Extracts dependency metadata from the Dependabot PR title and body. - * Dependabot titles follow: "Bump from to " - */ -function parsePRBody(title: string, body: string | null): PRMetadata { - const bumpMatch = title.match(/^Bump (.+) from (\S+) to (\S+)/) - if (bumpMatch) { - const isMajor = isMajorBump(bumpMatch[2], bumpMatch[3]) - return { - dependencyName: bumpMatch[1], - prevVersion: bumpMatch[2], - newVersion: bumpMatch[3], - updateType: isMajor ? 'major' : 'minor/patch' - } - } - - const updateMatch = title.match(/^Update (.+) requirement from .* to (.*)/) - if (updateMatch) { - return { - dependencyName: updateMatch[1], - prevVersion: '', - newVersion: updateMatch[2], - updateType: 'minor/patch' - } - } - - return { - dependencyName: title, - prevVersion: '', - newVersion: '', - updateType: 'unknown' - } -} - -/** Determines if a version bump is a semver major change */ -function isMajorBump(from: string, to: string): boolean { - const fromMajor = from.replace(/^v/, '').split('.')[0] - const toMajor = to.replace(/^v/, '').split('.')[0] - return fromMajor !== toMajor -} - -/** - * Finds the GHSA ID associated with a dependency by looking up - * open Dependabot alerts for the repository. - */ -async function findGhsaForDependency( - token: string, - owner: string, - repo: string, - dependencyName: string -): Promise { - try { - const alerts = await github.listDependabotAlerts(token, owner, repo) - const match = alerts.find( - (a) => a.dependency.package.name === dependencyName - ) - return match?.security_advisory.ghsa_id - } catch { - console.error(`Failed to look up alerts for ${owner}/${repo}`) - return undefined - } -} diff --git a/workers/dependabot/src/index.ts b/workers/dependabot/src/index.ts deleted file mode 100644 index 73787c2..0000000 --- a/workers/dependabot/src/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Env } from './types/env' -import type { DependabotAlertEvent, PullRequestEvent } from './types/github' -import { verifyWebhookSignature } from './lib/verify' -import { handleAlert } from './handlers/alert' -import { handlePROpened, handlePRMerged } from './handlers/pull-request' - -export default { - async fetch(request: Request, env: Env): Promise { - if (request.method !== 'POST') { - return new Response('Method not allowed', { status: 405 }) - } - - const signature = request.headers.get('X-Hub-Signature-256') - const eventType = request.headers.get('X-GitHub-Event') - const body = await request.text() - - const valid = await verifyWebhookSignature(env.GITHUB_WEBHOOK_SECRET, body, signature) - if (!valid) { - return new Response('Invalid signature', { status: 401 }) - } - - const payload = JSON.parse(body) - - try { - if (eventType === 'dependabot_alert' && payload.action === 'created') { - await handleAlert(payload as DependabotAlertEvent, env) - return new Response('Alert handled', { status: 200 }) - } - - if (eventType === 'pull_request' && payload.sender?.login === 'dependabot[bot]') { - const event = payload as PullRequestEvent - - if (event.action === 'opened') { - await handlePROpened(event, env) - return new Response('PR opened handled', { status: 200 }) - } - - if (event.action === 'closed' && event.pull_request.merged) { - await handlePRMerged(event, env) - return new Response('PR merged handled', { status: 200 }) - } - } - - return new Response('OK', { status: 200 }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error' - console.error(`Handler error: ${message}`) - return new Response(`Internal error: ${message}`, { status: 500 }) - } - } -} diff --git a/workers/dependabot/src/lib/github.ts b/workers/dependabot/src/lib/github.ts deleted file mode 100644 index 068281a..0000000 --- a/workers/dependabot/src/lib/github.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { getToken } from 'universal-github-app-jwt' -import type { DependabotAlert } from '../types/github' - -const API = 'https://api.github.com' - -/** - * Generates a GitHub App installation access token by creating a JWT - * from the app credentials and exchanging it for a scoped token. - */ -export async function getInstallationToken( - appId: string, - privateKey: string, - installationId: string -): Promise { - const { token: jwt } = await getToken({ - id: appId, - privateKey - }) - - const res = await fetch( - `${API}/app/installations/${installationId}/access_tokens`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${jwt}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'amera-dependabot-worker' - } - } - ) - - if (!res.ok) { - throw new Error(`Failed to get installation token: ${res.status} ${await res.text()}`) - } - - const data = await res.json<{ token: string }>() - return data.token -} - -/** - * Enables auto-merge (squash) on a pull request via the GraphQL API. - * Uses GraphQL because the REST API does not support enabling auto-merge. - */ -export async function enableAutoMerge( - token: string, - pullRequestNodeId: string -): Promise { - const res = await fetch(`${API}/graphql`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'User-Agent': 'amera-dependabot-worker' - }, - body: JSON.stringify({ - query: `mutation($prId: ID!) { - enablePullRequestAutoMerge(input: { pullRequestId: $prId, mergeMethod: SQUASH }) { - pullRequest { autoMergeRequest { enabledAt } } - } - }`, - variables: { prId: pullRequestNodeId } - }) - }) - - if (!res.ok) { - throw new Error(`Failed to enable auto-merge: ${res.status} ${await res.text()}`) - } -} - -/** Adds a label to an issue or pull request */ -export async function addLabel( - token: string, - owner: string, - repo: string, - issueNumber: number, - label: string -): Promise { - const res = await fetch( - `${API}/repos/${owner}/${repo}/issues/${issueNumber}/labels`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'User-Agent': 'amera-dependabot-worker' - }, - body: JSON.stringify({ labels: [label] }) - } - ) - - if (!res.ok) { - throw new Error(`Failed to add label: ${res.status} ${await res.text()}`) - } -} - -/** Updates the body of a pull request */ -export async function updatePRBody( - token: string, - owner: string, - repo: string, - prNumber: number, - body: string -): Promise { - const res = await fetch( - `${API}/repos/${owner}/${repo}/pulls/${prNumber}`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'Content-Type': 'application/json', - 'User-Agent': 'amera-dependabot-worker' - }, - body: JSON.stringify({ body }) - } - ) - - if (!res.ok) { - throw new Error(`Failed to update PR body: ${res.status} ${await res.text()}`) - } -} - -/** - * Lists open Dependabot alerts for a repository. - * Used to find the GHSA ID associated with a Dependabot PR. - */ -export async function listDependabotAlerts( - token: string, - owner: string, - repo: string -): Promise { - const res = await fetch( - `${API}/repos/${owner}/${repo}/dependabot/alerts?state=open&per_page=100`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'amera-dependabot-worker' - } - } - ) - - if (!res.ok) { - throw new Error(`Failed to list alerts: ${res.status} ${await res.text()}`) - } - - return res.json() -} diff --git a/workers/dependabot/src/lib/linear.ts b/workers/dependabot/src/lib/linear.ts deleted file mode 100644 index 7133309..0000000 --- a/workers/dependabot/src/lib/linear.ts +++ /dev/null @@ -1,102 +0,0 @@ -const API = 'https://api.linear.app/graphql' - -interface LinearIssue { - id: string - identifier: string - url: string -} - -/** Creates a Linear issue and returns its ID, identifier (e.g. AMR-123), and URL */ -export async function createIssue( - apiKey: string, - teamId: string, - projectId: string, - title: string, - description: string -): Promise { - const res = await fetch(API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: apiKey - }, - body: JSON.stringify({ - query: `mutation($input: IssueCreateInput!) { - issueCreate(input: $input) { - success - issue { id identifier url } - } - }`, - variables: { - input: { title, description, teamId, projectId } - } - }) - }) - - const data = await res.json<{ - data?: { issueCreate?: { success: boolean; issue: LinearIssue } } - }>() - - return data.data?.issueCreate?.issue -} - -/** - * Finds an open Linear issue whose title contains the given GHSA ID. - * Scoped to a specific team. - */ -export async function findIssue( - apiKey: string, - teamId: string, - ghsaId: string -): Promise { - const res = await fetch(API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: apiKey - }, - body: JSON.stringify({ - query: `query($filter: IssueFilterInput) { - issues(filter: $filter) { - nodes { id identifier url } - } - }`, - variables: { - filter: { - team: { id: { eq: teamId } }, - title: { contains: ghsaId }, - state: { type: { neq: 'completed' } } - } - } - }) - }) - - const data = await res.json<{ - data?: { issues?: { nodes: LinearIssue[] } } - }>() - - return data.data?.issues?.nodes?.[0] -} - -/** Adds a comment to a Linear issue */ -export async function addComment( - apiKey: string, - issueId: string, - body: string -): Promise { - await fetch(API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: apiKey - }, - body: JSON.stringify({ - query: `mutation($input: CommentCreateInput!) { - commentCreate(input: $input) { success } - }`, - variables: { - input: { issueId, body } - } - }) - }) -} diff --git a/workers/dependabot/src/lib/slack.ts b/workers/dependabot/src/lib/slack.ts deleted file mode 100644 index caa2040..0000000 --- a/workers/dependabot/src/lib/slack.ts +++ /dev/null @@ -1,57 +0,0 @@ -const API = 'https://slack.com/api' - -interface SlackMessage { - ts: string - text: string -} - -/** Posts a message to a Slack channel, optionally as a thread reply */ -export async function postMessage( - token: string, - channel: string, - text: string, - threadTs?: string -): Promise { - const body: Record = { channel, text } - if (threadTs) body.thread_ts = threadTs - - const res = await fetch(`${API}/chat.postMessage`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }) - - const data = await res.json<{ ok: boolean; ts?: string; error?: string }>() - if (!data.ok) { - console.error(`Slack postMessage failed: ${data.error}`) - return undefined - } - - return data.ts -} - -/** - * Searches recent channel messages for one containing the given text. - * Returns the message `ts` (thread ID) if found, undefined otherwise. - */ -export async function findThread( - token: string, - channel: string, - searchText: string -): Promise { - const res = await fetch( - `${API}/conversations.history?channel=${channel}&limit=200`, - { - headers: { Authorization: `Bearer ${token}` } - } - ) - - const data = await res.json<{ ok: boolean; messages?: SlackMessage[] }>() - if (!data.ok || !data.messages) return undefined - - const match = data.messages.find((m) => m.text.includes(searchText)) - return match?.ts -} diff --git a/workers/dependabot/src/lib/verify.ts b/workers/dependabot/src/lib/verify.ts deleted file mode 100644 index 07e9750..0000000 --- a/workers/dependabot/src/lib/verify.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Verifies a GitHub webhook signature using HMAC-SHA256 via the Web Crypto API. - * Returns true if the signature in the header matches the computed signature. - */ -export async function verifyWebhookSignature( - secret: string, - payload: string, - signatureHeader: string | null -): Promise { - if (!signatureHeader) return false - - const encoder = new TextEncoder() - - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ) - - const signatureBytes = await crypto.subtle.sign( - 'HMAC', - key, - encoder.encode(payload) - ) - - const expected = `sha256=${Array.from(new Uint8Array(signatureBytes)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('')}` - - return timingSafeEqual(expected, signatureHeader) -} - -/** Constant-time string comparison to prevent timing attacks */ -function timingSafeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false - - const encoder = new TextEncoder() - const aBuf = encoder.encode(a) - const bBuf = encoder.encode(b) - - let result = 0 - for (let i = 0; i < aBuf.length; i++) { - result |= aBuf[i] ^ bBuf[i] - } - - return result === 0 -} diff --git a/workers/dependabot/src/types/env.ts b/workers/dependabot/src/types/env.ts deleted file mode 100644 index 4628a79..0000000 --- a/workers/dependabot/src/types/env.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Env { - GITHUB_WEBHOOK_SECRET: string - GITHUB_APP_ID: string - GITHUB_APP_PRIVATE_KEY: string - GITHUB_INSTALLATION_ID: string - SLACK_BOT_TOKEN: string - SLACK_CHANNEL_ID: string - LINEAR_API_KEY: string - LINEAR_TEAM_ID: string - LINEAR_PROJECT_ID: string -} diff --git a/workers/dependabot/src/types/github.ts b/workers/dependabot/src/types/github.ts deleted file mode 100644 index 0fe71b8..0000000 --- a/workers/dependabot/src/types/github.ts +++ /dev/null @@ -1,78 +0,0 @@ -export interface DependabotAlertEvent { - action: 'created' | 'dismissed' | 'fixed' | 'reintroduced' | 'reopened' - alert: { - number: number - state: string - html_url: string - dependency: { - package: { - name: string - ecosystem: string - } - manifest_path: string - } - security_advisory: { - ghsa_id: string - cve_id: string | null - summary: string - severity: 'low' | 'medium' | 'high' | 'critical' - } - security_vulnerability: { - severity: 'low' | 'medium' | 'high' | 'critical' - first_patched_version: { identifier: string } | null - vulnerable_version_range: string - } - } - repository: Repository - organization?: { login: string } - sender: { login: string } -} - -export interface PullRequestEvent { - action: 'opened' | 'closed' | 'synchronize' | 'reopened' | 'edited' | 'labeled' | 'unlabeled' - number: number - pull_request: { - number: number - html_url: string - title: string - body: string | null - merged: boolean - state: 'open' | 'closed' - head: { ref: string; sha: string } - base: { ref: string } - user: { login: string } - node_id: string - } - repository: Repository - organization?: { login: string } - sender: { login: string } -} - -interface Repository { - name: string - full_name: string - owner: { login: string } -} - -export interface DependabotAlert { - number: number - state: string - html_url: string - dependency: { - package: { - name: string - ecosystem: string - } - manifest_path: string - } - security_advisory: { - ghsa_id: string - cve_id: string | null - summary: string - severity: string - } - security_vulnerability: { - severity: string - first_patched_version: { identifier: string } | null - } -} diff --git a/workers/dependabot/tsconfig.json b/workers/dependabot/tsconfig.json deleted file mode 100644 index fa023cb..0000000 --- a/workers/dependabot/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true - }, - "include": ["src"] -} diff --git a/workers/dependabot/wrangler.toml b/workers/dependabot/wrangler.toml deleted file mode 100644 index 0cd058f..0000000 --- a/workers/dependabot/wrangler.toml +++ /dev/null @@ -1,8 +0,0 @@ -name = "dependabot-webhook" -main = "src/index.ts" -compatibility_date = "2025-01-01" - -[vars] -SLACK_CHANNEL_ID = "" -LINEAR_TEAM_ID = "" -LINEAR_PROJECT_ID = ""