Skip to content
5 changes: 5 additions & 0 deletions .changeset/find-repo-checkout-safe-directory.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion actions/setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,7 @@ async function main(config = {}) {
core.warning(`Repository ${itemRepo} not found in checkout mapping or workspace`);
return {
success: false,
error: `Repository '${itemRepo}' not found in workspace. Configure it in checkout: with a path to enable multi-repo PR creation.`,
error: checkoutResult.error,
};
}
}
Expand Down
105 changes: 99 additions & 6 deletions actions/setup/js/find_repo_checkout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,79 @@ function extractRepoSlugFromUrl(remoteUrl) {
return null;
}

/**
* Extract the host name from a git remote URL
* Supports both `https://host[:port]/owner/repo` and `[user@]host:owner/repo` forms.
* User info and port are stripped; the result is lowercased.
* @param {string} remoteUrl - The git remote URL
* @returns {string|null} The host name or null if not parseable
*/
function extractRemoteHost(remoteUrl) {
if (!remoteUrl) return null;
const url = remoteUrl.trim();

const schemeMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/]+)\//);
if (schemeMatch) {
const authority = schemeMatch[1];
const hostPort = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority;
const host = hostPort.replace(/:\d+$/, "");
return host ? host.toLowerCase() : null;
}

const sshMatch = url.match(/^(?:[^@/]+@)?([^:/]+):/);
if (sshMatch) {
return sshMatch[1].toLowerCase();
}

return null;
}

/**
* Determine whether a remote URL discovered by the workspace scan points at the
* GitHub instance this workflow runs against.
*
* Scan results come from `.git/config` files inside `$GITHUB_WORKSPACE`, which the
* agent can write. Without a host constraint, a planted config such as
* `https://attacker.example/owner/allowed-repo.git` would bind an agent-controlled
* directory to an allowlisted `owner/repo` slug, so safe-output git operations would
* run in that directory against an unrelated remote. Manifest entries are unaffected:
* they are emitted by the compiler and take precedence over the scan.
*
* @param {string} remoteUrl - The git remote URL
* @returns {boolean} True when the remote host is the configured GitHub host
*/
function isTrustedRemoteHost(remoteUrl) {
const host = extractRemoteHost(remoteUrl);
if (!host) return false;

const trustedHosts = new Set(["github.com"]);
const serverUrl = process.env.GITHUB_SERVER_URL;
if (serverUrl) {
try {
const serverHost = new URL(serverUrl).hostname;
if (serverHost) trustedHosts.add(serverHost.toLowerCase());
} catch {
// Malformed GITHUB_SERVER_URL: fall back to the github.com default
}
}

return trustedHosts.has(host);
}

/**
* Extract the repo slug from a remote URL discovered by the workspace scan,
* rejecting remotes hosted outside the configured GitHub instance.
* @param {string} remoteUrl - The git remote URL
* @returns {string|null} The repo slug (owner/repo) or null when untrusted or unparseable
*/
function extractScannedRepoSlug(remoteUrl) {
if (!isTrustedRemoteHost(remoteUrl)) {
debugLog(`Ignoring remote on untrusted host: ${remoteUrl}`);
return null;
}
return extractRepoSlugFromUrl(remoteUrl);
}

/**
* Find all repositories that contain a .git directory or gitdir-link file
* @param {string} basePath - The base path to search from
Expand Down Expand Up @@ -111,12 +184,30 @@ function findGitDirectories(basePath, maxDepth = 5) {

/**
* Get the remote origin URL for a git repository
*
* The safe-outputs server runs inside a container whose uid differs from the
* runner user that owns clones created by `steps:` entries or a manual
* `actions/checkout`. Under git's "dubious ownership" protection,
* `git config --get` silently ignores the repository config and exits 1, so
* every nested clone would look like it had no remote. Inject a scoped
* safe.directory override for this single config read so discovery works
* without mutating process-wide git trust for every scanned workspace path.
*
* @param {string} repoPath - Path to the repository root
* @returns {string|null} The remote URL or null if not found
*/
function getRemoteOriginUrl(repoPath) {
try {
const url = execGitSync(["config", "--get", "remote.origin.url"], { cwd: repoPath });
const url = execGitSync(["config", "--get", "remote.origin.url"], {
cwd: repoPath,
env: {
...process.env,
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "safe.directory",
GIT_CONFIG_VALUE_0: path.resolve(repoPath),
},
suppressLogs: true,
});
return url.trim();
} catch {
return null;
Expand Down Expand Up @@ -215,7 +306,7 @@ function findRepoCheckout(repoSlug, workspaceRoot, options = {}) {
continue;
}

const foundSlug = extractRepoSlugFromUrl(remoteUrl);
const foundSlug = extractScannedRepoSlug(remoteUrl);
debugLog(`Repo at ${repoPath} has slug: ${foundSlug}`);

if (foundSlug === targetSlug) {
Expand All @@ -232,7 +323,7 @@ function findRepoCheckout(repoSlug, workspaceRoot, options = {}) {
// This handles the scenario where only the root is a repo
const rootRemoteUrl = getRemoteOriginUrl(ws);
if (rootRemoteUrl) {
const rootSlug = extractRepoSlugFromUrl(rootRemoteUrl);
const rootSlug = extractScannedRepoSlug(rootRemoteUrl);
debugLog(`Workspace root has slug: ${rootSlug}`);
if (rootSlug === targetSlug) {
return {
Expand All @@ -245,7 +336,7 @@ function findRepoCheckout(repoSlug, workspaceRoot, options = {}) {

return {
success: false,
error: `Repository '${repoSlug}' not found in workspace. Make sure it's checked out using actions/checkout with a path.`,
error: `Repository '${repoSlug}' not found in workspace. Make sure it's checked out under $GITHUB_WORKSPACE, either via a 'checkout:' frontmatter entry or by cloning it into the workspace in a 'steps:' entry.`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in a89315a: the create/push safe-output paths now reuse checkoutResult.error instead of appending the stale actions/checkout path-only guidance, and the handler assertions were updated.

searchedPaths: gitDirs,
};
}
Expand Down Expand Up @@ -278,7 +369,7 @@ function buildRepoCheckoutMap(workspaceRoot) {
const remoteUrl = getRemoteOriginUrl(repoPath);
if (!remoteUrl) continue;

const slug = extractRepoSlugFromUrl(remoteUrl);
const slug = extractScannedRepoSlug(remoteUrl);
if (slug && !map.has(slug)) {
map.set(slug, repoPath);
}
Expand All @@ -287,7 +378,7 @@ function buildRepoCheckoutMap(workspaceRoot) {
// Also check workspace root
const rootRemoteUrl = getRemoteOriginUrl(ws);
if (rootRemoteUrl) {
const rootSlug = extractRepoSlugFromUrl(rootRemoteUrl);
const rootSlug = extractScannedRepoSlug(rootRemoteUrl);
if (rootSlug && !map.has(rootSlug)) {
map.set(rootSlug, ws);
}
Expand All @@ -301,6 +392,8 @@ module.exports = {
findRepoCheckout,
buildRepoCheckoutMap,
extractRepoSlugFromUrl,
extractRemoteHost,
isTrustedRemoteHost,
normalizeRepoSlug,
findGitDirectories,
};
199 changes: 198 additions & 1 deletion actions/setup/js/find_repo_checkout.test.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const { extractRepoSlugFromUrl, normalizeRepoSlug, findGitDirectories, findRepoCheckout, buildRepoCheckoutMap } = require("./find_repo_checkout.cjs");
const { spawnSync } = require("child_process");
const { extractRepoSlugFromUrl, extractRemoteHost, isTrustedRemoteHost, normalizeRepoSlug, findGitDirectories, findRepoCheckout, buildRepoCheckoutMap } = require("./find_repo_checkout.cjs");
const { getPatchPathForBranchInRepo, sanitizeBranchNameForPatch, sanitizeRepoSlugForPatch } = require("./generate_git_patch.cjs");
const { _resetCache: resetCheckoutManifestCache } = require("./checkout_manifest.cjs");

Expand Down Expand Up @@ -440,6 +442,201 @@ describe("find_repo_checkout", () => {
});
});

describe("git-scan fallback reads remotes with scoped safe.directory trust (issue #60021)", () => {
let workspaceDir;
let oldPath;
let oldGitScanEnvLog;
let oldGitConfigCount;
let oldGitConfigKey0;
let oldGitConfigValue0;
let oldCore;

beforeEach(() => {
oldPath = process.env.PATH;
oldGitScanEnvLog = process.env.GIT_SCAN_ENV_LOG;
oldGitConfigCount = process.env.GIT_CONFIG_COUNT;
oldGitConfigKey0 = process.env.GIT_CONFIG_KEY_0;
oldGitConfigValue0 = process.env.GIT_CONFIG_VALUE_0;
oldCore = globalThis.core;
globalThis.core = { debug: () => {}, error: () => {} };
delete process.env.GIT_CONFIG_COUNT;
delete process.env.GIT_CONFIG_KEY_0;
delete process.env.GIT_CONFIG_VALUE_0;
workspaceDir = fs.mkdtempSync(path.join(require("os").tmpdir(), "test-safedir-"));
});

afterEach(() => {
oldPath === undefined ? delete process.env.PATH : (process.env.PATH = oldPath);
oldGitScanEnvLog === undefined ? delete process.env.GIT_SCAN_ENV_LOG : (process.env.GIT_SCAN_ENV_LOG = oldGitScanEnvLog);
oldGitConfigCount === undefined ? delete process.env.GIT_CONFIG_COUNT : (process.env.GIT_CONFIG_COUNT = oldGitConfigCount);
oldGitConfigKey0 === undefined ? delete process.env.GIT_CONFIG_KEY_0 : (process.env.GIT_CONFIG_KEY_0 = oldGitConfigKey0);
oldGitConfigValue0 === undefined ? delete process.env.GIT_CONFIG_VALUE_0 : (process.env.GIT_CONFIG_VALUE_0 = oldGitConfigValue0);
oldCore === undefined ? delete globalThis.core : (globalThis.core = oldCore);
try {
fs.rmSync(workspaceDir, { recursive: true, force: true });
} catch {
// ignore
}
});

it("injects safe.directory only for the git config read", () => {
const nestedRepo = path.join(workspaceDir, "repos", "analytics");
fs.mkdirSync(path.join(nestedRepo, ".git"), { recursive: true });
const binDir = path.join(workspaceDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const envLog = path.join(workspaceDir, "git-env.log");
const fakeGit = path.join(binDir, "git");
fs.writeFileSync(
fakeGit,
`#!/bin/sh
printf '%s\\t%s\\t%s\\t%s\\n' "$PWD" "$GIT_CONFIG_COUNT" "$GIT_CONFIG_KEY_0" "$GIT_CONFIG_VALUE_0" >> "$GIT_SCAN_ENV_LOG"
if [ "$1" = "config" ] && [ "$2" = "--get" ] && [ "$3" = "remote.origin.url" ]; then
echo "https://github.com/owner/analytics.git"
exit 0
fi
exit 1
`
);
fs.chmodSync(fakeGit, 0o755);
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH || ""}`;
process.env.GIT_SCAN_ENV_LOG = envLog;

const result = findRepoCheckout("owner/analytics", workspaceDir);

expect(result.success).toBe(true);
expect(result.path).toBe(nestedRepo);
expect(process.env.GIT_CONFIG_COUNT).toBeUndefined();
expect(process.env.GIT_CONFIG_KEY_0).toBeUndefined();
expect(process.env.GIT_CONFIG_VALUE_0).toBeUndefined();
const configRead = fs.readFileSync(envLog, "utf8").trim().split("\n")[0].split("\t");
expect(configRead).toEqual([nestedRepo, "1", "safe.directory", path.resolve(nestedRepo)]);
});

it("reports a not-found error that mentions both checkout: and steps: clones", () => {
const result = findRepoCheckout("owner/analytics", workspaceDir);

expect(result.success).toBe(false);
expect(result.error).toContain("checkout:");
expect(result.error).toContain("steps:");
});

it("ignores a scanned repository whose remote points at another host", () => {
const plantedRepo = path.join(workspaceDir, "repos", "planted");
fs.mkdirSync(path.join(plantedRepo, ".git"), { recursive: true });
const binDir = path.join(workspaceDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const fakeGit = path.join(binDir, "git");
fs.writeFileSync(
fakeGit,
`#!/bin/sh
if [ "$1" = "config" ] && [ "$2" = "--get" ] && [ "$3" = "remote.origin.url" ]; then
echo "https://attacker.example/owner/analytics.git"
exit 0
fi
exit 1
`
);
fs.chmodSync(fakeGit, 0o755);
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH || ""}`;
process.env.GIT_SCAN_ENV_LOG = path.join(workspaceDir, "git-env.log");

const result = findRepoCheckout("owner/analytics", workspaceDir);

expect(result.success).toBe(false);
});
});

describe("git-scan fallback with a dubious-ownership checkout", () => {
let workspaceDir;
let originalEnv;
let originalCore;

beforeEach(() => {
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "test-safedir-git-"));
originalEnv = {
GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL,
GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM,
GITHUB_ENV: process.env.GITHUB_ENV,
GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL,
};
originalCore = globalThis.core;
globalThis.core = { debug: () => {}, error: () => {} };
process.env.GIT_CONFIG_GLOBAL = os.devNull;
process.env.GIT_CONFIG_NOSYSTEM = "1";
process.env.GITHUB_SERVER_URL = "https://github.com";
delete process.env.GITHUB_ENV;
});

afterEach(() => {
spawnSync("sudo", ["-n", "rm", "-rf", workspaceDir]);
for (const [key, value] of Object.entries(originalEnv)) {
value === undefined ? delete process.env[key] : (process.env[key] = value);
}
originalCore === undefined ? delete globalThis.core : (globalThis.core = originalCore);
});

it("discovers a nested checkout using per-invocation safe.directory trust", () => {
const sudoAvailable = spawnSync("sudo", ["-n", "true"], { encoding: "utf8" });
expect(sudoAvailable.status).toBe(0);

const nestedRepo = path.join(workspaceDir, "repos", "analytics");
fs.mkdirSync(nestedRepo, { recursive: true });
expect(spawnSync("git", ["init", "-q"], { cwd: nestedRepo }).status).toBe(0);
expect(spawnSync("git", ["remote", "add", "origin", "https://github.com/owner/analytics.git"], { cwd: nestedRepo }).status).toBe(0);
expect(spawnSync("sudo", ["-n", "chown", "-R", "12345:12345", nestedRepo]).status).toBe(0);

const untrustedRead = spawnSync("git", ["config", "--get", "remote.origin.url"], {
cwd: nestedRepo,
encoding: "utf8",
env: process.env,
});
expect(untrustedRead.status).not.toBe(0);

expect(findRepoCheckout("owner/analytics", workspaceDir)).toMatchObject({
success: true,
path: nestedRepo,
repoSlug: "owner/analytics",
});
});
});

describe("remote host trust", () => {
let oldServerUrl;

beforeEach(() => {
oldServerUrl = process.env.GITHUB_SERVER_URL;
});

afterEach(() => {
oldServerUrl === undefined ? delete process.env.GITHUB_SERVER_URL : (process.env.GITHUB_SERVER_URL = oldServerUrl);
});

it("extracts the host from HTTPS and SSH remotes", () => {
expect(extractRemoteHost("https://github.com/owner/repo.git")).toBe("github.com");
expect(extractRemoteHost("https://user@github.com/owner/repo.git")).toBe("github.com");
expect(extractRemoteHost("https://github.example.com:8443/owner/repo.git")).toBe("github.example.com");
expect(extractRemoteHost("git@github.com:owner/repo.git")).toBe("github.com");
expect(extractRemoteHost("/local/path/repo")).toBeNull();
expect(extractRemoteHost("")).toBeNull();
});

it("trusts github.com and the configured GitHub server host only", () => {
delete process.env.GITHUB_SERVER_URL;
expect(isTrustedRemoteHost("https://github.com/owner/repo.git")).toBe(true);
expect(isTrustedRemoteHost("git@GitHub.com:owner/repo.git")).toBe(true);
expect(isTrustedRemoteHost("https://attacker.example/owner/repo.git")).toBe(false);

process.env.GITHUB_SERVER_URL = "https://github.example.com";
expect(isTrustedRemoteHost("https://github.example.com/owner/repo.git")).toBe(true);
expect(isTrustedRemoteHost("https://github.com/owner/repo.git")).toBe(true);
expect(isTrustedRemoteHost("https://attacker.example/owner/repo.git")).toBe(false);

process.env.GITHUB_SERVER_URL = "not a url";
expect(isTrustedRemoteHost("https://github.com/owner/repo.git")).toBe(true);
expect(isTrustedRemoteHost("https://attacker.example/owner/repo.git")).toBe(false);
});
});

describe("buildRepoCheckoutMap", () => {
let testDir;

Expand Down
Loading
Loading