-
Notifications
You must be signed in to change notification settings - Fork 8
feat(review-pr): add rate-anomaly safeguard for review requests #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
5836af5
b69a05c
ab4a2a6
633b8e9
7f5e3c3
8b63e17
8080ae7
c6bbbd3
9576ee0
bf2774f
731b522
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,6 +80,20 @@ permissions: | |
| id-token: write | ||
| actions: read # download-artifact across workflow_run boundary | ||
|
|
||
| # Rate-anomaly safeguard: serialize same-trigger bursts on a PR (e.g. rapid | ||
| # force-pushes firing repeated auto-reviews) instead of running N in parallel. | ||
| # The group is keyed per PR AND per trigger intent: the comment id (or event | ||
| # name) suffix keeps distinct comments/replies in distinct groups, so a quick | ||
| # conversational reply is never queued behind a 45-minute review. cancel-in- | ||
| # progress is false so an in-flight review/reply is never killed mid-post. | ||
| # Per-PR request *frequency* is enforced by the rate-limit check below, and the | ||
| # in-action cache lock (review-pr/action.yml) prevents concurrent reviews; the | ||
| # workflow_run/fork path (PR number only in the artifact) falls back to a per-run | ||
| # group, where those two mechanisms still bound abuse. | ||
| concurrency: | ||
| group: pr-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr-number || github.run_id }}-${{ github.event.comment.id || github.event_name }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| resolve-context: | ||
| if: inputs.trigger-run-id != '' | ||
|
|
@@ -315,10 +329,49 @@ jobs: | |
| REQUESTER: ${{ github.event.sender.login }} | ||
| run: node "$DOCKER_AGENT_ACTION_ROOT/dist/check-org-membership.js" | ||
|
|
||
| # Rate-anomaly safeguard: count how many docker-agent review/reply comments | ||
| # were posted on this PR in the recent window. An authorized account can | ||
| # still drive the bot at high frequency (each request costs an LLM run), so | ||
| # a burst above the threshold is flagged and the expensive review is skipped. | ||
| # Runs BEFORE "Create check run" so a throttled request creates no check run | ||
| # (a skipped review must not surface as a green "PR Review" check). | ||
| - name: Check rate anomaly | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] The if: |
steps.membership.outputs.is_member == 'true' &&
steps.command.outputs.is_review != 'false' &&
steps.draft.outputs.skip != 'true'When an This means an org member can flood |
||
| id: rate | ||
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| continue-on-error: true # fail-open: a rate-check error must not block reviews | ||
| shell: bash | ||
| env: | ||
| GITHUB_TOKEN: ${{ env.GITHUB_APP_TOKEN || github.token }} | ||
| RATE_PR_NUMBER: ${{ steps.pr.outputs.number }} | ||
| run: node "$DOCKER_AGENT_ACTION_ROOT/dist/rate-limit.js" | ||
|
|
||
| # Force-push safeguard: compare the SHA the review was requested for against | ||
| # the current PR head. The review still runs against the latest commit | ||
| # (refs/pull/N/head); this records the SHA actually reviewed and posts a | ||
| # notice when the branch was force-pushed/rebased after the request. | ||
| - name: Check force-push staleness | ||
| id: staleness | ||
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| continue-on-error: true # fail-open: a staleness-check error must not block reviews | ||
| shell: bash | ||
| env: | ||
| GITHUB_TOKEN: ${{ env.GITHUB_APP_TOKEN || github.token }} | ||
| STALE_PR_NUMBER: ${{ steps.pr.outputs.number }} | ||
| STALE_REQUESTED_SHA: ${{ needs.resolve-context.outputs.pr-head-sha || github.event.pull_request.head.sha }} | ||
| run: node "$DOCKER_AGENT_ACTION_ROOT/dist/check-staleness.js" | ||
|
|
||
| - name: Create check run | ||
| if: | | ||
| (steps.pr.outputs.source == 'event' || steps.pr.outputs.source == 'trigger') && | ||
| steps.membership.outputs.is_member == 'true' | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| id: create-check | ||
| continue-on-error: true | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
|
|
@@ -345,11 +398,94 @@ jobs: | |
| }); | ||
| core.setOutput('check-id', check.id); | ||
|
|
||
| # Audit safeguard: emit one structured record per review request that joins | ||
| # the requester, time, trigger, PR, reviewed SHA, and the authorize/deny/ | ||
| # throttle decision. Runs on the allow AND deny paths so denied, throttled, | ||
| # and stale requests are all recorded, not just successful ones. If | ||
| # setup-credentials failed (no dist bundle / DOCKER_AGENT_ACTION_ROOT), the | ||
| # record is still emitted inline so even infra-failure denials are logged. | ||
| # Placed BEFORE "Checkout PR head" on purpose: the decision is fully known by | ||
| # now, and keeping this run-step ahead of the untrusted checkout avoids an | ||
| # execute-after-untrusted-checkout (TOCTOU) pattern. | ||
| - name: Audit review request | ||
| if: | | ||
| always() && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| continue-on-error: true | ||
| shell: bash | ||
| env: | ||
| IS_MEMBER: ${{ steps.membership.outputs.is_member }} | ||
| RATE_ANOMALOUS: ${{ steps.rate.outputs.anomalous }} | ||
| STALE: ${{ steps.staleness.outputs.stale }} | ||
| CURRENT_SHA: ${{ steps.staleness.outputs.current-sha }} | ||
| REQUESTED_SHA: ${{ needs.resolve-context.outputs.pr-head-sha || github.event.pull_request.head.sha }} | ||
| USER_REQUESTED: ${{ steps.trigger-type.outputs.user_requested }} | ||
| AUDIT_ACTOR: ${{ github.event.comment.user.login || github.event.sender.login || github.actor }} | ||
| AUDIT_PR_NUMBER: ${{ steps.pr.outputs.number }} | ||
| AUDIT_EVENT: ${{ github.event_name }} | ||
| REVIEW_AUDIT_FILE: ${{ runner.temp }}/review-audit/review-audit.jsonl | ||
| run: | | ||
| if [ "$IS_MEMBER" != "true" ]; then | ||
| DECISION=denied | ||
| REASON="requester is not a docker org member" | ||
| elif [ "$RATE_ANOMALOUS" = "true" ]; then | ||
| DECISION=throttled | ||
| REASON="rate anomaly: too many recent review requests on this PR" | ||
| else | ||
| DECISION=authorized | ||
| if [ "$STALE" = "true" ]; then | ||
| REASON="docker org member (PR head moved after request — reviewing latest commit)" | ||
| else | ||
| REASON="docker org member" | ||
| fi | ||
| fi | ||
| export AUDIT_DECISION="$DECISION" | ||
| export AUDIT_REASON="$REASON" | ||
| export AUDIT_TRIGGER="$([ "$USER_REQUESTED" = "true" ] && echo user-requested || echo automatic)" | ||
| export AUDIT_HEAD_SHA="${CURRENT_SHA:-$REQUESTED_SHA}" | ||
| export AUDIT_REQUESTED_SHA="$REQUESTED_SHA" | ||
|
|
||
| # If setup-credentials failed (OIDC/Secrets Manager misconfig), the dist | ||
| # bundle and DOCKER_AGENT_ACTION_ROOT are never set. Still record the | ||
| # denial inline so the "log every request, including denials" guarantee | ||
| # holds even on infra-failure paths. | ||
| if [ -z "$DOCKER_AGENT_ACTION_ROOT" ] || [ ! -f "$DOCKER_AGENT_ACTION_ROOT/dist/audit-log.js" ]; then | ||
| TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) | ||
| REC=$(jq -nc \ | ||
| --arg ts "$TS" --arg event "$AUDIT_EVENT" --arg trigger "$AUDIT_TRIGGER" \ | ||
| --arg actor "${AUDIT_ACTOR:-unknown}" --arg repo "$GITHUB_REPOSITORY" \ | ||
| --arg pr "$AUDIT_PR_NUMBER" --arg head "$AUDIT_HEAD_SHA" \ | ||
| --arg requested "$AUDIT_REQUESTED_SHA" --arg decision "$AUDIT_DECISION" \ | ||
| --arg reason "$AUDIT_REASON (audit-log bundle unavailable — emitted inline)" \ | ||
| '{timestamp:$ts,event:$event,trigger:$trigger,actor:$actor,repository:$repo,prNumber:$pr,headSha:$head,requestedSha:$requested,decision:$decision,reason:$reason}') | ||
| echo "::notice title=Review request audit::[review-request-audit] $REC" | ||
| mkdir -p "$(dirname "$REVIEW_AUDIT_FILE")" | ||
| printf '%s\n' "$REC" >> "$REVIEW_AUDIT_FILE" || true | ||
| exit 0 | ||
| fi | ||
|
|
||
| node "$DOCKER_AGENT_ACTION_ROOT/dist/audit-log.js" || echo "::warning::audit-log failed" | ||
|
|
||
| - name: Upload review audit log | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| if: | | ||
| always() && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| continue-on-error: true | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: pr-review-audit-${{ github.run_id }}-${{ github.run_attempt }} | ||
| path: ${{ runner.temp }}/review-audit/ | ||
| retention-days: 90 | ||
| if-no-files-found: ignore | ||
|
|
||
| - name: Checkout PR head | ||
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| steps.draft.outputs.skip != 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
@@ -359,7 +495,8 @@ jobs: | |
| if: | | ||
| steps.membership.outputs.is_member == 'true' && | ||
| steps.command.outputs.is_review != 'false' && | ||
| steps.draft.outputs.skip != 'true' | ||
| steps.draft.outputs.skip != 'true' && | ||
| steps.rate.outputs.anomalous != 'true' | ||
| id: run-review | ||
| continue-on-error: true | ||
| uses: docker/docker-agent-action/review-pr@e96a4bb40cac114f64358621e1d08346c8eadc8c # v2.0.1 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,15 @@ on: | |
| pull_request_review_comment: | ||
| types: [ created ] | ||
|
|
||
| # Rate-anomaly safeguard: collapse a burst of review_requested events on the same | ||
| # PR at the source — re-requesting a review is redundant, so cancel-in-progress | ||
| # drops superseded context-capture runs and only the latest fans out to a review. | ||
| # Distinct review comments stay independent (the comment id keys them into their | ||
| # own group) so a real reply is never dropped by another comment on the same PR. | ||
| concurrency: | ||
| group: pr-review-trigger-${{ github.event.pull_request.number }}-${{ github.event.comment.id || 'review-request' }} | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (super non-blocking): if group: pr-review-trigger-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.comment.id || 'review-request' }} |
||
| cancel-in-progress: true | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This actually already exists, but doesn't use GH's
concurrency, and only 1 review-per-PR can run within a window of time: https://github.com/docker/docker-agent-action/blob/main/review-pr/action.yml#L142-L146There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct that a per-PR collapse already exists via the cache lock. The new
concurrency:group is additive rather than equivalent: it is keyed per (PR + trigger intent), queues instead of skipping, applies before a runner spins up, and has no race window (the lock has a documented one). The trigger workflow also usescancel-in-progressto drop superseded context-capture runs. If relying on the lock plus the rate-limit check is preferred, the group can be dropped; it is left in place for now as a cheap, race-free pre-resource layer.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, thanks for the explanation!