Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion actions/setup/js/check_membership.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot } = require("./check_permissions_utils.cjs");
const { parseRequiredPermissions, parseAllowedBots, checkRepositoryPermission, checkBotStatus, isAllowedBot, isConfusedDeputyAttack } = require("./check_permissions_utils.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");

async function main() {
Expand Down Expand Up @@ -51,6 +51,21 @@ async function main() {
return;
}

// Guard against Dependabot Confused Deputy attacks.
// An attacker can trigger @dependabot recreate (for pull_request events) or
// @dependabot show (for issue_comment events) to make dependabot appear as the
// actor, bypassing permission checks that rely solely on github.actor.
// Reference: https://labs.boostsecurity.io/articles/weaponizing-dependabot-pwn-request-at-its-finest/
if (isConfusedDeputyAttack(actor, eventName, context.payload)) {
const errorMessage = `Access denied: Potential confused deputy attack detected. Actor '${actor}' does not match the event author. The workflow may have been triggered indirectly via a bot command.`;
core.warning(errorMessage);
core.setOutput("is_team_member", "false");
core.setOutput("result", "confused_deputy");
core.setOutput("error_message", errorMessage);
await writeDenialSummary(errorMessage, "This can occur when a bot command (e.g. @dependabot recreate) causes a bot to appear as the actor on a PR or comment that was originally authored by a different user.");
return;
}

// Check if the actor has the required repository permissions
const result = await checkRepositoryPermission(actor, owner, repo, requiredPermissions);

Expand Down
98 changes: 98 additions & 0 deletions actions/setup/js/check_membership.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,104 @@ describe("check_membership.cjs", () => {
});
});

describe("confused deputy attack protection", () => {
beforeEach(() => {
process.env.GH_AW_REQUIRED_ROLES = "write";
});

it("should deny access when actor differs from PR author (pull_request event)", async () => {
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "pull_request";
mockContext.payload = { pull_request: { user: { login: "attacker" } } };

await runScript();

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Potential confused deputy attack detected"));
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "confused_deputy");
});

it("should allow access when actor matches PR author (genuine dependabot PR)", async () => {
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "pull_request";
mockContext.payload = { pull_request: { user: { login: "dependabot[bot]" } } };

mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({
data: { permission: "write" },
});

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized");
});

it("should deny access when actor differs from comment author (issue_comment event)", async () => {
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "issue_comment";
mockContext.payload = { comment: { user: { login: "attacker" } } };

await runScript();

expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Potential confused deputy attack detected"));
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "false");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "confused_deputy");
});

it("should not trigger confused deputy check for safe events (schedule)", async () => {
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "schedule";
mockContext.payload = { pull_request: { user: { login: "attacker" } } };

await runScript();

expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "safe_event");
});

it("should not trigger confused deputy check for issues event (no PR/comment context)", async () => {
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "issues";
mockContext.payload = { issue: { user: { login: "someone-else" } } };
process.env.GH_AW_REQUIRED_ROLES = "write";

mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({
data: { permission: "write" },
});

await runScript();

// issues events don't trigger confused deputy detection
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized");
});

it("should work correctly for workflow_call events with aw_context (no false positive)", async () => {
// In workflow_call, context.payload = { inputs: { aw_context: "..." } }
// The aw_context carries event_type but NOT pull_request.user.login
// Confused deputy check must NOT trigger - this is a legitimate reusable workflow call
mockContext.actor = "dependabot[bot]";
mockContext.eventName = "workflow_call";
mockContext.payload = {
inputs: {
aw_context: JSON.stringify({ event_type: "pull_request", item_number: "42", actor: "attacker" }),
},
};
process.env.GH_AW_REQUIRED_ROLES = "write";

mockGithub.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({
data: { permission: "write" },
});

await runScript();

// workflow_call proceeds to normal permission check - no confused_deputy denial
expect(mockCore.setOutput).not.toHaveBeenCalledWith("result", "confused_deputy");
expect(mockCore.setOutput).toHaveBeenCalledWith("is_team_member", "true");
expect(mockCore.setOutput).toHaveBeenCalledWith("result", "authorized");
});
});

describe("API error handling", () => {
beforeEach(() => {
process.env.GH_AW_REQUIRED_ROLES = "admin";
Expand Down
44 changes: 44 additions & 0 deletions actions/setup/js/check_permissions_utils.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ function isAllowedBot(actor, allowedBots) {
return allowedBots.some(bot => canonicalizeBotIdentifier(bot) === canonicalActor);
}

/**
* Detect a potential Dependabot Confused Deputy attack.
*
* Attack vectors defended against:
* - @dependabot recreate on a PR not authored by dependabot causes
* github.actor = 'dependabot[bot]' while pull_request.user.login remains
* the original (human) PR author.
* - @dependabot show on an issue causes github.actor = 'dependabot[bot]'
* while comment.user.login differs from the actor.
*
* Reference: https://labs.boostsecurity.io/articles/weaponizing-dependabot-pwn-request-at-its-finest/
*
* @param {string} actor - The current github.actor
* @param {string} eventName - The GitHub event name (e.g. "pull_request", "issue_comment")
* @param {object|undefined} payload - The GitHub event payload (context.payload)
* @returns {boolean} true if the event looks like a confused deputy attack
*/
function isConfusedDeputyAttack(actor, eventName, payload) {
if (!payload) return false;

// For pull_request, pull_request_review, and pull_request_review_comment events,
// the PR author must match the actor. @dependabot recreate triggers a synchronize
// event with actor=dependabot[bot] but pull_request.user = original human author.
const prEvents = ["pull_request", "pull_request_review", "pull_request_review_comment"];
if (prEvents.includes(eventName)) {
const prAuthor = payload.pull_request?.user?.login;
if (prAuthor !== undefined && prAuthor !== actor) {
return true;

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isConfusedDeputyAttack treats pull_request_review and pull_request_review_comment as if the PR author must match github.actor, but for these events the actor is typically the reviewer/commenter and will not match payload.pull_request.user.login in normal operation. This causes false positives that will deny check_membership and bypass check_skip_bots for essentially all review/review-comment triggers. Use the event-specific author fields instead (e.g., payload.review.user.login for pull_request_review and payload.comment.user.login for pull_request_review_comment), and only fall back to payload.pull_request.user.login for pull_request events.

Copilot uses AI. Check for mistakes.
}
}

// For issue_comment events, @dependabot show can trigger a comment from dependabot
// with actor=dependabot[bot]. Verify the comment itself was authored by the actor.
if (eventName === "issue_comment") {
const commentAuthor = payload.comment?.user?.login;
if (commentAuthor !== undefined && commentAuthor !== actor) {
return true;
}
}

return false;
}

/**
* Check if the actor is a bot and if it's active on the repository.
* Accepts both <slug> and <slug>[bot] actor forms, since GitHub Apps
Expand Down Expand Up @@ -155,6 +198,7 @@ module.exports = {
parseAllowedBots,
canonicalizeBotIdentifier,
isAllowedBot,
isConfusedDeputyAttack,
checkRepositoryPermission,
checkBotStatus,
};
103 changes: 103 additions & 0 deletions actions/setup/js/check_permissions_utils.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("check_permissions_utils", () => {
let parseAllowedBots;
let canonicalizeBotIdentifier;
let isAllowedBot;
let isConfusedDeputyAttack;
let checkRepositoryPermission;
let checkBotStatus;
let originalEnv;
Expand All @@ -53,6 +54,7 @@ describe("check_permissions_utils", () => {
isAllowedBot = module.isAllowedBot;
checkRepositoryPermission = module.checkRepositoryPermission;
checkBotStatus = module.checkBotStatus;
isConfusedDeputyAttack = module.isConfusedDeputyAttack;
});

afterEach(() => {
Expand Down Expand Up @@ -530,4 +532,105 @@ describe("check_permissions_utils", () => {
expect(mockCore.warning).toHaveBeenCalledWith("Failed to check bot status: API rate limit exceeded");
});
});

describe("isConfusedDeputyAttack", () => {
describe("pull_request events", () => {
it("should return false when actor matches PR author (genuine dependabot PR)", () => {
const payload = { pull_request: { user: { login: "dependabot[bot]" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", payload)).toBe(false);
});

it("should return true when actor differs from PR author (confused deputy via @dependabot recreate)", () => {
const payload = { pull_request: { user: { login: "attacker" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", payload)).toBe(true);
});

it("should return false when actor matches PR author for a human PR", () => {
const payload = { pull_request: { user: { login: "octocat" } } };
expect(isConfusedDeputyAttack("octocat", "pull_request", payload)).toBe(false);
});

it("should return false when PR author is absent from payload", () => {
const payload = { pull_request: {} };
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", payload)).toBe(false);
});

it("should return false when pull_request is absent from payload", () => {
const payload = {};
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", payload)).toBe(false);
});

it("should return true for pull_request_review when actor differs from PR author", () => {
const payload = { pull_request: { user: { login: "attacker" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review", payload)).toBe(true);
});

it("should return true for pull_request_review_comment when actor differs from PR author", () => {
const payload = { pull_request: { user: { login: "attacker" } } };

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new tests assert that isConfusedDeputyAttack() should return true for pull_request_review / pull_request_review_comment when github.actor differs from the PR author. For these event types, the relevant author to compare against is the review author (payload.review.user.login) or the review comment author (payload.comment.user.login), not payload.pull_request.user.login. As written, the tests codify the same false-positive behavior that will break review/review-comment triggered workflows.

Suggested change
it("should return true for pull_request_review when actor differs from PR author", () => {
const payload = { pull_request: { user: { login: "attacker" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review", payload)).toBe(true);
});
it("should return true for pull_request_review_comment when actor differs from PR author", () => {
const payload = { pull_request: { user: { login: "attacker" } } };
it("should return false for pull_request_review when actor matches review author even if PR author differs", () => {
const payload = {
pull_request: { user: { login: "attacker" } },
review: { user: { login: "dependabot[bot]" } },
};
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review", payload)).toBe(false);
});
it("should return true for pull_request_review when actor differs from review author even if PR author matches", () => {
const payload = {
pull_request: { user: { login: "dependabot[bot]" } },
review: { user: { login: "attacker" } },
};
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review", payload)).toBe(true);
});
it("should return false for pull_request_review_comment when actor matches comment author even if PR author differs", () => {
const payload = {
pull_request: { user: { login: "attacker" } },
comment: { user: { login: "dependabot[bot]" } },
};
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review_comment", payload)).toBe(false);
});
it("should return true for pull_request_review_comment when actor differs from comment author even if PR author matches", () => {
const payload = {
pull_request: { user: { login: "dependabot[bot]" } },
comment: { user: { login: "attacker" } },
};

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! Using event-specific author fields (payload.review.user.login for pull_request_review) instead of always comparing against the PR author is the correct approach to avoid false positives on normal review workflows.

📰 BREAKING: Report filed by Smoke Copilot · ● 1.3M

expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request_review_comment", payload)).toBe(true);
});
});

describe("issue_comment events", () => {
it("should return false when actor matches comment author (genuine bot comment)", () => {
const payload = { comment: { user: { login: "dependabot[bot]" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "issue_comment", payload)).toBe(false);
});

it("should return true when actor differs from comment author", () => {
const payload = { comment: { user: { login: "attacker" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "issue_comment", payload)).toBe(true);
});

it("should return false when comment author is absent from payload", () => {
const payload = { comment: {} };
expect(isConfusedDeputyAttack("dependabot[bot]", "issue_comment", payload)).toBe(false);
});

it("should return false when comment is absent from payload", () => {
const payload = {};
expect(isConfusedDeputyAttack("dependabot[bot]", "issue_comment", payload)).toBe(false);
});
});

describe("other event types", () => {
it("should return false for push events (no PR/comment context)", () => {
const payload = { sender: { login: "dependabot[bot]" } };
expect(isConfusedDeputyAttack("dependabot[bot]", "push", payload)).toBe(false);
});

it("should return false for issues events", () => {
const payload = { issue: { user: { login: "attacker" } } };
expect(isConfusedDeputyAttack("dependabot[bot]", "issues", payload)).toBe(false);
});

it("should return false for schedule events", () => {
expect(isConfusedDeputyAttack("github-actions[bot]", "schedule", {})).toBe(false);
});

it("should return false for workflow_call events (no PR/comment in payload)", () => {
// In workflow_call, context.payload = { inputs: { aw_context: "..." } }
// aw_context carries event_type/item_number but NOT pull_request.user.login
const payload = { inputs: { aw_context: '{"event_type":"pull_request","item_number":"42","actor":"attacker"}' } };
expect(isConfusedDeputyAttack("dependabot[bot]", "workflow_call", payload)).toBe(false);
});

it("should return false for workflow_call even if payload contains unrelated pull_request data", () => {
// Even if someone injects pull_request data in the payload, the eventName check
// guards against false positives: workflow_call is never in prEvents
const payload = { pull_request: { user: { login: "attacker" } }, inputs: {} };
expect(isConfusedDeputyAttack("dependabot[bot]", "workflow_call", payload)).toBe(false);
});
});

describe("edge cases", () => {
it("should return false when payload is null", () => {
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", null)).toBe(false);
});

it("should return false when payload is undefined", () => {
expect(isConfusedDeputyAttack("dependabot[bot]", "pull_request", undefined)).toBe(false);
});
});
});
});
14 changes: 14 additions & 0 deletions actions/setup/js/check_skip_bots.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { isConfusedDeputyAttack } = require("./check_permissions_utils.cjs");
const { writeDenialSummary } = require("./pre_activation_summary.cjs");

/**
Expand Down Expand Up @@ -29,6 +30,19 @@ async function main() {
.filter(u => u);
core.info(`Checking if user '${actor}' is in skip-bots: ${skipBots.join(", ")}`);

// Guard against Dependabot Confused Deputy attacks.
// An attacker can trigger @dependabot recreate (for pull_request events) or
// @dependabot show (for issue_comment events) to make dependabot appear as the
// actor, causing a legitimate bot's skip-bots rule to suppress the workflow for
// the attacker's PR/issue.
// Reference: https://labs.boostsecurity.io/articles/weaponizing-dependabot-pwn-request-at-its-finest/
if (isConfusedDeputyAttack(actor, eventName, context.payload)) {
core.info(`Potential confused deputy attack detected: actor '${actor}' does not match the event author. Skipping skip-bots check.`);
core.setOutput("skip_bots_ok", "true");
core.setOutput("result", "not_skipped");
return;
}

// Check if the actor is in the skip-bots list
// Match both exact username and username with [bot] suffix
// e.g., "github-actions" matches both "github-actions" and "github-actions[bot]"
Expand Down
Loading
Loading