diff --git a/.github/workflows/closed-pr-comment.yml b/.github/workflows/closed-pr-comment.yml new file mode 100644 index 00000000..5ce2ef7c --- /dev/null +++ b/.github/workflows/closed-pr-comment.yml @@ -0,0 +1,176 @@ +name: Closed PR Comment Redirect + +# COE AI-7: alert users who comment on closed PRs to open a GitHub issue. +# An external user reported the v1.4.8 SPII leak by commenting on the +# already-closed revert PR. Closed-PR comments are not surfaced to oncall, +# but issues are. This workflow: +# 1. Replies to the commenter with a link to open a new issue. +# 2. Notifies oncall via the existing SLACK_WEBHOOK_URL. +# Maintainers (admin/maintain/write) are skipped so internal back-and-forth +# doesn't trigger the redirect. + +on: + issue_comment: + types: [created] + +permissions: + pull-requests: write + issues: read + +# Serialize per-PR so the marker-comment dedup is race-free (otherwise two +# rapid-fire comments could both see "no marker" and both post a redirect). +# cancel-in-progress is false so the second run still executes after the +# first finishes — we want to evaluate dedup against the just-posted marker. +concurrency: + group: closed-pr-comment-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + redirect: + runs-on: ubuntu-latest + # Only fire on PRs (issue_comment fires for issues too) that are closed. + if: >- + github.event.issue.pull_request != null && github.event.issue.state == 'closed' && github.event.comment.user.type + != 'Bot' + steps: + - name: Check commenter permission + id: perm + uses: actions/github-script@v9 + with: + script: | + // External users on private repos can 404 here; treat any + // failure as "not a maintainer" so the redirect still fires. + let permission = 'none'; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.payload.comment.user.login, + }); + permission = data.permission; + } catch (err) { + core.info(`Permission lookup failed for ${context.payload.comment.user.login}: ${err.message}. Treating as non-maintainer.`); + } + const skip = ['admin', 'maintain', 'write'].includes(permission); + core.setOutput('skip', String(skip)); + core.info(`Commenter ${context.payload.comment.user.login} permission=${permission} skip=${skip}`); + + - name: Check for existing redirect comment + id: existing + if: steps.perm.outputs.skip != 'true' + uses: actions/github-script@v9 + with: + script: | + // Marker we embed in our reply so we don't double-post on the same PR. + const marker = ''; + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + per_page: 100, + } + ); + const alreadyPosted = comments.some(c => c.body && c.body.includes(marker)); + core.setOutput('already_posted', String(alreadyPosted)); + + - name: Post redirect comment + if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' + # The Slack alert is the load-bearing part of this workflow (it's + # what closes the COE detection gap). If posting the bot reply + # fails (rate limit, transient error), don't block oncall paging. + continue-on-error: true + uses: actions/github-script@v9 + env: + # Repos with issue templates use /issues/new/choose; repos without + # templates should change this to /issues/new. + ISSUES_NEW_URL: https://github.com/${{ github.repository }}/issues/new/choose + with: + script: | + const commenter = context.payload.comment.user.login; + const issuesNewUrl = process.env.ISSUES_NEW_URL; + const body = [ + '', + '', + `Thanks for the report, @${commenter} — feedback like this is exactly`, + "how we catch the things we missed. Because this PR is already", + "closed, the team won't see follow-up comments here.", + '', + 'Would you mind opening a new issue so we can track it properly?', + issuesNewUrl, + '', + 'If this is a security issue, please report it privately via', + 'https://aws.amazon.com/security/vulnerability-reporting/ instead', + 'of a public issue.', + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body, + }); + + - name: Compute PR state + id: pr_state + if: steps.perm.outputs.skip != 'true' + uses: actions/github-script@v9 + with: + script: | + // PRs surface as `issue` events; merged_at is null when closed-not-merged. + const mergedAt = context.payload.issue.pull_request && + context.payload.issue.pull_request.merged_at; + core.setOutput('state', mergedAt ? 'merged' : 'closed'); + + - name: Notify Slack + # Page oncall only on the FIRST external comment per PR (gated by + # already_posted). Subsequent comments on the same PR don't page — + # the redirect comment has already told the commenter to open an + # issue, and issues page oncall via the issue path. This bounds + # paging volume regardless of how chatty a thread becomes. + if: steps.perm.outputs.skip != 'true' && steps.existing.outputs.already_posted != 'true' + # Attacker-controlled fields are passed through env: rather than + # interpolated into the YAML payload, to prevent workflow injection. + # Schema is uniform across event types: every workflow sends the + # same 20 keys so Slack-side branching on event_type is reliable. + # For closed-PR comments, the issue_* fields are empty (this isn't + # an issue) and the pr_*/comment_* fields carry the real data. + env: + REPOSITORY: ${{ github.repository }} + CREATED_AT: ${{ github.event.comment.created_at }} + PR_NUMBER: ${{ github.event.issue.number }} + PR_TITLE: ${{ github.event.issue.title }} + PR_URL: ${{ github.event.issue.html_url }} + PR_AUTHOR: ${{ github.event.issue.user.login }} + PR_CLOSED_AT: ${{ github.event.issue.closed_at }} + PR_MERGED_AT: ${{ github.event.issue.pull_request.merged_at }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_URL: ${{ github.event.comment.html_url }} + COMMENT_AUTHOR: ${{ github.event.comment.user.login }} + COMMENT_BODY: ${{ github.event.comment.body }} + uses: slackapi/slack-github-action@v3.0.1 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + event_type: "closed_pr_comment" + repository: "${{ env.REPOSITORY }}" + created_at: "${{ env.CREATED_AT }}" + issue_number: "" + issue_title: "" + issue_url: "" + issue_author: "" + issue_body: "" + labels: "" + pr_number: "${{ env.PR_NUMBER }}" + pr_title: ${{ toJSON(env.PR_TITLE) }} + pr_url: "${{ env.PR_URL }}" + pr_author: "${{ env.PR_AUTHOR }}" + pr_state: "${{ steps.pr_state.outputs.state }}" + pr_closed_at: "${{ env.PR_CLOSED_AT }}" + pr_merged_at: "${{ env.PR_MERGED_AT }}" + comment_id: "${{ env.COMMENT_ID }}" + comment_url: "${{ env.COMMENT_URL }}" + comment_author: "${{ env.COMMENT_AUTHOR }}" + comment_body: ${{ toJSON(env.COMMENT_BODY) }} diff --git a/.github/workflows/slack-issue-notification.yml b/.github/workflows/slack-issue-notification.yml index 4f2b7659..6ac5599f 100644 --- a/.github/workflows/slack-issue-notification.yml +++ b/.github/workflows/slack-issue-notification.yml @@ -4,20 +4,50 @@ on: issues: types: [opened] +permissions: {} + jobs: notify-slack: runs-on: ubuntu-latest steps: - name: Send issue details to Slack + # Attacker-controlled fields are passed through env: rather than + # interpolated into the YAML payload, to prevent workflow injection. + # Schema is uniform across event types: every workflow sends the + # same 20 keys so Slack-side branching on event_type is reliable. + # For issue_opened, the issue_* fields carry the data and the + # pr_*/comment_* fields are empty. + env: + REPOSITORY: ${{ github.repository }} + CREATED_AT: ${{ github.event.issue.created_at }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_AUTHOR: ${{ github.event.issue.user.login }} + ISSUE_BODY: ${{ github.event.issue.body }} + LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }} uses: slackapi/slack-github-action@v3.0.1 with: webhook: ${{ secrets.SLACK_WEBHOOK_URL }} webhook-type: webhook-trigger payload: | - issue_title: "${{ github.event.issue.title }}" - issue_number: "${{ github.event.issue.number }}" - issue_url: "${{ github.event.issue.html_url }}" - issue_author: "${{ github.event.issue.user.login }}" - issue_body: ${{ toJSON(github.event.issue.body) }} - repository: "${{ github.repository }}" - created_at: "${{ github.event.issue.created_at }}" + event_type: "issue_opened" + repository: "${{ env.REPOSITORY }}" + created_at: "${{ env.CREATED_AT }}" + issue_number: "${{ env.ISSUE_NUMBER }}" + issue_title: ${{ toJSON(env.ISSUE_TITLE) }} + issue_url: "${{ env.ISSUE_URL }}" + issue_author: "${{ env.ISSUE_AUTHOR }}" + issue_body: ${{ toJSON(env.ISSUE_BODY) }} + labels: ${{ toJSON(env.LABELS) }} + pr_number: "" + pr_title: "" + pr_url: "" + pr_author: "" + pr_state: "" + pr_closed_at: "" + pr_merged_at: "" + comment_id: "" + comment_url: "" + comment_author: "" + comment_body: ""