diff --git a/actions/setup/js/close_entity_helpers.cjs b/actions/setup/js/close_entity_helpers.cjs index 439c11dfe0f..213cd4f94b1 100644 --- a/actions/setup/js/close_entity_helpers.cjs +++ b/actions/setup/js/close_entity_helpers.cjs @@ -10,6 +10,7 @@ const { sanitizeContent } = require("./sanitize_content.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { isStagedMode } = require("./safe_output_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); +const { validateTargetRepo, resolveTargetRepoConfig } = require("./repo_helpers.cjs"); /** * @typedef {'issue' | 'pull_request'} EntityType @@ -262,6 +263,7 @@ function createCloseEntityHandler(config, entityConfig, callbacks, githubClient) const maxCount = config.max || 10; const comment = config.comment || ""; const isStaged = isStagedMode(config); + const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); let processedCount = 0; @@ -318,6 +320,14 @@ function createCloseEntityHandler(config, entityConfig, callbacks, githubClient) core.info(`Target repository: ${entityRepo}`); } + // 4b. Cross-repository allowlist validation (SEC-005) + const resolvedRepo = `${owner}/${repoName}`; + const repoValidation = validateTargetRepo(resolvedRepo, defaultTargetRepo, allowedRepos); + if (!repoValidation.valid) { + core.warning(`Skipping ${entityConfig.itemType}: cross-repo check failed for "${resolvedRepo}": ${repoValidation.error}`); + return { success: false, error: repoValidation.error }; + } + try { // 5. Entity details fetch core.info(`Fetching ${entityConfig.displayName} details for #${entityNumber} in ${owner}/${repoName}`); diff --git a/actions/setup/js/close_entity_helpers.test.cjs b/actions/setup/js/close_entity_helpers.test.cjs index 55e1f1ab1c1..f97439fdeb4 100644 --- a/actions/setup/js/close_entity_helpers.test.cjs +++ b/actions/setup/js/close_entity_helpers.test.cjs @@ -4,7 +4,7 @@ import path from "path"; const mockCore = { debug: vi.fn(), info: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } }, mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 42 }, pull_request: { number: 100 }, repository: { html_url: "https://github.com/testowner/testrepo" } } }; ((global.core = mockCore), (global.context = mockContext)); -const { checkLabelFilter, checkTitlePrefixFilter, parseEntityConfig, resolveEntityNumber, escapeMarkdownTitle, ISSUE_CONFIG, PULL_REQUEST_CONFIG } = require("./close_entity_helpers.cjs"); +const { checkLabelFilter, checkTitlePrefixFilter, parseEntityConfig, resolveEntityNumber, escapeMarkdownTitle, createCloseEntityHandler, ISSUE_CONFIG, PULL_REQUEST_CONFIG } = require("./close_entity_helpers.cjs"); describe("close_entity_helpers", () => { (beforeEach(() => { (vi.clearAllMocks(), @@ -237,5 +237,54 @@ describe("close_entity_helpers", () => { // System marker should still be present expect(withMarker).toContain(""); }); + }), + describe("createCloseEntityHandler cross-repo validation", () => { + const makeCallbacks = resolveTarget => ({ + resolveTarget, + getDetails: vi.fn().mockResolvedValue({ number: 1, title: "t", labels: [], html_url: "u", state: "open" }), + validateLabels: () => ({ valid: true }), + buildCommentBody: body => body, + addComment: vi.fn().mockResolvedValue({ id: 1, html_url: "u" }), + closeEntity: vi.fn().mockResolvedValue({ number: 1, html_url: "u", title: "t" }), + buildSuccessResult: (entity, comment, wasClosed, commentPosted) => ({ success: true }), + }); + + it("should reject cross-repo target not in allowlist", async () => { + const config = { "target-repo": "testowner/testrepo", comment: "closing" }; + const handler = createCloseEntityHandler( + config, + ISSUE_CONFIG, + makeCallbacks(() => ({ success: true, entityNumber: 1, owner: "other-org", repo: "other-repo" })), + {} + ); + const result = await handler({ body: "test", type: "close_issue" }); + expect(result.success).toBe(false); + expect(result.error).toContain("not in the allowed-repos list"); + expect(mockCore.warning).toHaveBeenCalled(); + }); + + it("should allow same-repo target", async () => { + const config = { "target-repo": "testowner/testrepo", comment: "closing" }; + const handler = createCloseEntityHandler( + config, + ISSUE_CONFIG, + makeCallbacks(() => ({ success: true, entityNumber: 1, owner: "testowner", repo: "testrepo" })), + {} + ); + const result = await handler({ body: "test", type: "close_issue" }); + expect(result.success).toBe(true); + }); + + it("should allow cross-repo target when in allowlist", async () => { + const config = { "target-repo": "testowner/testrepo", allowed_repos: ["other-org/other-repo"], comment: "closing" }; + const handler = createCloseEntityHandler( + config, + ISSUE_CONFIG, + makeCallbacks(() => ({ success: true, entityNumber: 1, owner: "other-org", repo: "other-repo" })), + {} + ); + const result = await handler({ body: "test", type: "close_issue" }); + expect(result.success).toBe(true); + }); })); });