diff --git a/.changeset/patch-validate-cross-repo-allowlist.md b/.changeset/patch-validate-cross-repo-allowlist.md new file mode 100644 index 00000000000..656f06d4b66 --- /dev/null +++ b/.changeset/patch-validate-cross-repo-allowlist.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add allowlist validation for the cross-repo helpers so `targetRepo` parameters and GH_AW_ALLOWED_REPOS checks now guard git and API work before any operations. diff --git a/actions/setup/js/dynamic_checkout.cjs b/actions/setup/js/dynamic_checkout.cjs index 8f420f2aaa6..f565bee902b 100644 --- a/actions/setup/js/dynamic_checkout.cjs +++ b/actions/setup/js/dynamic_checkout.cjs @@ -1,6 +1,9 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); +const { ERR_VALIDATION } = require("./error_codes.cjs"); + /** * Dynamic repository checkout utilities for multi-repo scenarios * Enables switching between different repositories during handler execution @@ -51,6 +54,7 @@ async function getCurrentCheckoutRepo() { * @param {string} token - GitHub token for authentication * @param {Object} options - Additional options * @param {string} [options.baseBranch] - Base branch to checkout (defaults to 'main') + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for allowlist validation * @returns {Promise} Result with success status */ async function checkoutRepo(repoSlug, token, options = {}) { @@ -60,10 +64,20 @@ async function checkoutRepo(repoSlug, token, options = {}) { if (parts.length !== 2 || !parts[0] || !parts[1]) { return { success: false, - error: `Invalid repository slug: ${repoSlug}. Expected format: owner/repo`, + error: `${ERR_VALIDATION}: Invalid repository slug: ${repoSlug}. Expected format: owner/repo`, }; } + // Validate target repo against configured allowlist before any git operations + const allowedRepos = parseAllowedRepos(options.allowedRepos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(repoSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + return { success: false, error: `${ERR_VALIDATION}: ${validation.error}` }; + } + } + const [owner, repo] = parts; core.info(`Switching checkout to repository: ${repoSlug}`); diff --git a/actions/setup/js/extra_empty_commit.cjs b/actions/setup/js/extra_empty_commit.cjs index b58b0a3ac91..de58a9cb4a1 100644 --- a/actions/setup/js/extra_empty_commit.cjs +++ b/actions/setup/js/extra_empty_commit.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); + /** * @fileoverview Extra Empty Commit Helper * @@ -47,9 +49,10 @@ function isCrossRepoTarget(repoOwner, repoName) { * @param {number} [options.newCommitCount] - Number of new commits being pushed. Only pushes the * empty commit when exactly 1 new commit was pushed, preventing accidental workflow-file * modifications on multi-commit branches and reducing loop risk. + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for allowlist validation * @returns {Promise<{success: boolean, skipped?: boolean, error?: string}>} */ -async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount }) { +async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMessage, newCommitCount, allowedRepos: allowedReposInput }) { const token = process.env.GH_AW_CI_TRIGGER_TOKEN; if (!token || !token.trim()) { @@ -57,6 +60,18 @@ async function pushExtraEmptyCommit({ branchName, repoOwner, repoName, commitMes return { success: true, skipped: true }; } + // Validate target repository against allowlist before any git operations + const allowedRepos = parseAllowedRepos(allowedReposInput); + if (allowedRepos.size > 0) { + const targetRepo = `${repoOwner}/${repoName}`; + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetRepo, defaultRepo, allowedRepos); + if (!validation.valid) { + core.warning(`ERR_VALIDATION: ${validation.error}`); + return { success: false, error: validation.error ?? "" }; + } + } + // Cross-repo guard: never push an extra empty commit to a different repository. // A token is needed to create the PR and that will trigger events anyway. if (isCrossRepoTarget(repoOwner, repoName)) { diff --git a/actions/setup/js/find_repo_checkout.cjs b/actions/setup/js/find_repo_checkout.cjs index 38a556b4fdc..2421b303d5c 100644 --- a/actions/setup/js/find_repo_checkout.cjs +++ b/actions/setup/js/find_repo_checkout.cjs @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const { execGitSync } = require("./git_helpers.cjs"); +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); /** * Debug logging helper - logs to stderr when DEBUG env var matches @@ -129,9 +130,11 @@ function getRemoteOriginUrl(repoPath) { * * @param {string} repoSlug - The repository slug to find (owner/repo format) * @param {string} [workspaceRoot] - The workspace root to search from + * @param {Object} [options] - Additional options + * @param {string[]|string} [options.allowedRepos] - Allowed repository patterns for validation * @returns {Object} Result with success status and path or error */ -function findRepoCheckout(repoSlug, workspaceRoot) { +function findRepoCheckout(repoSlug, workspaceRoot, options = {}) { const ws = workspaceRoot || process.env.GITHUB_WORKSPACE || process.cwd(); const targetSlug = normalizeRepoSlug(repoSlug); @@ -144,6 +147,16 @@ function findRepoCheckout(repoSlug, workspaceRoot) { }; } + // Validate target repo against configured allowlist before searching + const allowedRepos = parseAllowedRepos(options.allowedRepos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + } + // Find all git directories in the workspace const gitDirs = findGitDirectories(ws); debugLog(`Found ${gitDirs.length} git directories: ${gitDirs.join(", ")}`); diff --git a/actions/setup/js/get_base_branch.cjs b/actions/setup/js/get_base_branch.cjs index 1c5e220d0d2..ac2a692c2fd 100644 --- a/actions/setup/js/get_base_branch.cjs +++ b/actions/setup/js/get_base_branch.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); + /** * Get the base branch name, resolving dynamically based on event context. * @@ -40,6 +42,21 @@ async function getBaseBranch(targetRepo = null) { if (typeof github !== "undefined") { const repoOwner = targetRepo?.owner ?? context.repo.owner; const repoName = targetRepo?.repo ?? context.repo.repo; + + // Validate target repo against allowlist before any API calls + const targetRepoSlug = `${repoOwner}/${repoName}`; + const allowedRepos = parseAllowedRepos(process.env.GH_AW_ALLOWED_REPOS); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(); + const validation = validateTargetRepo(targetRepoSlug, defaultRepo, allowedRepos); + if (!validation.valid) { + if (typeof core !== "undefined") { + core.warning(`ERR_VALIDATION: ${validation.error}`); + } + return process.env.DEFAULT_BRANCH || "main"; + } + } + const { data: pr } = await github.rest.pulls.get({ owner: repoOwner, repo: repoName, diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index b800d459519..c1b9d3d313a 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -1,6 +1,7 @@ // @ts-check const { getErrorMessage } = require("./error_helpers.cjs"); +const { validateTargetRepo, parseAllowedRepos, getDefaultTargetRepo } = require("./repo_helpers.cjs"); const fs = require("fs"); @@ -100,6 +101,15 @@ function registerPredefinedTools(server, tools, config, registerTool, normalizeT if (tool.name === "create_pull_request" && config.create_pull_request) { const targetRepo = config.create_pull_request["target-repo"]; if (targetRepo) { + // Validate the configured target-repo against the allowed-repos list + const allowedRepos = parseAllowedRepos(config.create_pull_request.allowed_repos); + if (allowedRepos.size > 0) { + const defaultRepo = getDefaultTargetRepo(config.create_pull_request); + const validation = validateTargetRepo(targetRepo, defaultRepo, allowedRepos); + if (!validation.valid) { + server.debug(`WARNING: SEC-005: ${validation.error}`); + } + } toolToRegister = JSON.parse(JSON.stringify(tool)); toolToRegister.description += ` Note: This workflow is configured to create pull requests in '${targetRepo}'. You do not need to specify the repo parameter.`; if (toolToRegister.inputSchema && toolToRegister.inputSchema.properties && toolToRegister.inputSchema.properties.repo) {