Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions actions/setup/js/close_entity_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}`);
Expand Down
51 changes: 50 additions & 1 deletion actions/setup/js/close_entity_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -237,5 +237,54 @@ describe("close_entity_helpers", () => {
// System marker should still be present
expect(withMarker).toContain("<!-- gh-aw-workflow-id: test -->");
});
}),
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);
});
}));
});
Loading