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
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3433.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3433
affected:
- src/CodeIndex/Cli/GitHelper.cs
- tests/CodeIndex.Tests/GitHelperTests.cs
---

## English

- **GitHelper no longer resolves git through ambient PATH (#3433)** — git commands now use a trusted absolute executable path so indexing helpers do not inherit a caller-controlled `git` from `PATH`.

## 日本語

- **GitHelper が環境 PATH 経由で git を解決しないようになりました (#3433)** — git コマンドは信頼済みの絶対実行ファイルパスを使うため、indexing helper が呼び出し元に制御された `PATH` 上の `git` を継承しなくなりました。
18 changes: 18 additions & 0 deletions changelog.d/unreleased/3455.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
category: security
issues:
- 3455
affected:
- src/CodeIndex/Indexer/DotnetHostPathResolver.cs
- src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs
- src/CodeIndex/Indexer/Hooks/PostExtractionHookCallbackWorker.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
---

## English

- **Worker child processes no longer fall back to PATH-resolved dotnet (#3455)** — isolated symbol extraction and post-extraction hook workers now require a trusted absolute dotnet host path and return a structured diagnostic when one cannot be resolved.

## 日本語

- **worker 子プロセスが PATH 解決の dotnet にフォールバックしなくなりました (#3455)** — isolated symbol extraction と post-extraction hook worker は信頼済みの絶対 dotnet host パスを必須にし、解決できない場合は構造化された診断を返します。
168 changes: 123 additions & 45 deletions src/CodeIndex/Cli/GitHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,116 @@ internal static TimeSpan GitCommandTimeout
set => GitCommandTimeoutOverride.Value = value;
}

private static readonly Lazy<string?> TrustedGitExecutablePath = new(ResolveTrustedGitExecutablePathFromKnownLocations);
private static readonly AsyncLocal<string?> GitExecutablePathOverrideValue = new();
internal static string? GitExecutablePathOverride
{
get => GitExecutablePathOverrideValue.Value;
set => GitExecutablePathOverrideValue.Value = value;
}

private static readonly TimeSpan GitKillWaitTimeout = TimeSpan.FromSeconds(5);
private const int GitProcessFailureExitCode = -1;
private const string TrustedGitUnavailableMessage =
"Could not resolve a trusted git executable path. Install git in a standard system location. / 信頼済みの git 実行ファイルパスを解決できませんでした。標準のシステム場所に git をインストールしてください。";

private static ProcessStartInfo? TryCreateGitStartInfo(string projectRoot)
{
var gitExecutablePath = TryResolveGitExecutablePath();
if (gitExecutablePath == null)
return null;

return new ProcessStartInfo
{
FileName = gitExecutablePath,
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
}

private static ProcessStartInfo CreateGitStartInfoOrThrow(string projectRoot)
=> TryCreateGitStartInfo(projectRoot) ?? throw new InvalidOperationException(TrustedGitUnavailableMessage);

private static string? TryResolveGitExecutablePath()
{
var overridePath = NormalizeTrustedGitExecutablePath(GitExecutablePathOverrideValue.Value);
return overridePath ?? TrustedGitExecutablePath.Value;
}

private static string? ResolveTrustedGitExecutablePathFromKnownLocations()
{
foreach (var candidate in EnumerateTrustedGitExecutableCandidates())
{
var normalized = NormalizeTrustedGitExecutablePath(candidate);
if (normalized != null)
return normalized;
}

return null;
}

private static IEnumerable<string> EnumerateTrustedGitExecutableCandidates()
{
if (OperatingSystem.IsWindows())
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrWhiteSpace(programFiles))
{
yield return Path.Combine(programFiles, "Git", "cmd", "git.exe");
yield return Path.Combine(programFiles, "Git", "bin", "git.exe");
}

var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
if (!string.IsNullOrWhiteSpace(programFilesX86))
{
yield return Path.Combine(programFilesX86, "Git", "cmd", "git.exe");
yield return Path.Combine(programFilesX86, "Git", "bin", "git.exe");
}

var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
if (!string.IsNullOrWhiteSpace(windows))
yield return Path.Combine(windows, "System32", "git.exe");
yield break;
}

if (OperatingSystem.IsMacOS())
{
yield return "/Library/Developer/CommandLineTools/usr/bin/git";
yield return "/Applications/Xcode.app/Contents/Developer/usr/bin/git";
yield break;
}

yield return "/usr/bin/git";
yield return "/bin/git";
}

internal static IReadOnlyList<string> TrustedGitExecutableCandidatePathsForTests()
=> EnumerateTrustedGitExecutableCandidates().ToList();

private static string? NormalizeTrustedGitExecutablePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return null;

try
{
if (!Path.IsPathFullyQualified(path))
return null;

var fullPath = Path.GetFullPath(path);
if (!string.Equals(Path.GetFileNameWithoutExtension(fullPath), "git", StringComparison.OrdinalIgnoreCase))
return null;

return File.Exists(LongPath.EnsureWindowsPrefix(fullPath)) ? fullPath : null;
}
catch
{
return null;
}
}

/// <summary>
/// Resolve the common git directory for a project root, handling both normal repos and worktrees.
Expand Down Expand Up @@ -147,15 +255,7 @@ public static List<string> GetChangedFilesFromCommit(
{
ValidateSingleCommitRef(projectRoot, commitId, cancellationToken);

var psi = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var psi = CreateGitStartInfoOrThrow(projectRoot);
psi.ArgumentList.Add("diff-tree");
psi.ArgumentList.Add("--no-commit-id");
psi.ArgumentList.Add("--root");
Expand Down Expand Up @@ -205,6 +305,9 @@ private static void ValidateSingleCommitRef(
string commitId,
CancellationToken cancellationToken = default)
{
if (TryResolveGitExecutablePath() == null)
throw new InvalidOperationException(TrustedGitUnavailableMessage);

// Reject range/pathspec syntax before invoking git so --commits remains a list
// of single commit-ish values, not revision-set expressions.
if (string.IsNullOrWhiteSpace(commitId)
Expand Down Expand Up @@ -246,15 +349,7 @@ public static List<string> GetChangedFilesBetweenRefs(
ValidateGitRef(oldRef, nameof(oldRef));
ValidateGitRef(newRef, nameof(newRef));

var psi = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var psi = CreateGitStartInfoOrThrow(projectRoot);
psi.ArgumentList.Add("diff");
psi.ArgumentList.Add("--name-status");
psi.ArgumentList.Add("-M");
Expand Down Expand Up @@ -432,15 +527,10 @@ private static bool TryRunGitForExitCode(string projectRoot, params string[] arg
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var psi = TryCreateGitStartInfo(projectRoot);
if (psi == null)
return false;

foreach (var arg in args)
psi.ArgumentList.Add(arg);

Expand Down Expand Up @@ -636,15 +726,9 @@ private static bool HasGitMetadataEntry(string projectRoot)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var psi = TryCreateGitStartInfo(projectRoot);
if (psi == null)
return null;

foreach (var arg in args)
psi.ArgumentList.Add(arg);
Expand Down Expand Up @@ -685,15 +769,9 @@ private static GitCommandResult RunGitCapturingResult(
{
try
{
var psi = new ProcessStartInfo
{
FileName = "git",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
var psi = TryCreateGitStartInfo(projectRoot);
if (psi == null)
return new GitCommandResult(null, null, null, TrustedGitUnavailableMessage);

foreach (var arg in args)
psi.ArgumentList.Add(arg);
Expand Down
81 changes: 81 additions & 0 deletions src/CodeIndex/Indexer/DotnetHostPathResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
namespace CodeIndex.Indexer;

internal static class DotnetHostPathResolver
{
private static readonly AsyncLocal<IReadOnlyList<string>?> TrustedDotnetHostCandidatesOverrideValue = new();

internal static IReadOnlyList<string>? TrustedDotnetHostCandidatesOverride
{
get => TrustedDotnetHostCandidatesOverrideValue.Value;
set => TrustedDotnetHostCandidatesOverrideValue.Value = value;
}

internal static string? Resolve(string? currentProcessPath)
{
if (TryNormalizeDotnetHostPath(currentProcessPath, out var normalized))
return normalized;

foreach (var candidate in TrustedDotnetHostCandidatesOverrideValue.Value ?? EnumerateTrustedDotnetHostCandidates())
{
if (TryNormalizeDotnetHostPath(candidate, out normalized))
return normalized;
}

return null;
}

internal static bool IsDotnetHostPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return false;

try
{
return string.Equals(Path.GetFileNameWithoutExtension(path), "dotnet", StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}

private static IEnumerable<string> EnumerateTrustedDotnetHostCandidates()
{
if (OperatingSystem.IsWindows())
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrWhiteSpace(programFiles))
yield return Path.Combine(programFiles, "dotnet", "dotnet.exe");

var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
if (!string.IsNullOrWhiteSpace(programFilesX86))
yield return Path.Combine(programFilesX86, "dotnet", "dotnet.exe");

yield break;
}

yield return "/usr/bin/dotnet";
yield return "/usr/share/dotnet/dotnet";
}

private static bool TryNormalizeDotnetHostPath(string? path, out string normalized)
{
normalized = string.Empty;
if (string.IsNullOrWhiteSpace(path) || !IsDotnetHostPath(path))
return false;

try
{
if (!Path.IsPathFullyQualified(path))
return false;

normalized = Path.GetFullPath(path);
return File.Exists(LongPath.EnsureWindowsPrefix(normalized));
}
catch
{
normalized = string.Empty;
return false;
}
}
}
Loading
Loading