From 3f4ddf2097bf3eb6d1aaf5f420cfcd26ad974e69 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 23:43:36 +0900 Subject: [PATCH 1/2] Isolate symbol worker test hooks (#3398) --- changelog.d/unreleased/3398.security.md | 16 ++ .../Indexer/Symbols/SymbolExtractionWorker.cs | 140 +++++++++++++----- .../IndexCommandRunnerTests.cs | 80 ++++++++-- 3 files changed, 186 insertions(+), 50 deletions(-) create mode 100644 changelog.d/unreleased/3398.security.md diff --git a/changelog.d/unreleased/3398.security.md b/changelog.d/unreleased/3398.security.md new file mode 100644 index 0000000000..1d448a5f86 --- /dev/null +++ b/changelog.d/unreleased/3398.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3398 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Symbol extraction worker test hooks no longer honor ambient environment variables (#3398)** — the isolated worker now ignores legacy `CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_*` environment variables during normal execution and only receives bounded test controls through an internal test seam. + +## 日本語 + +- **symbol extraction worker のテストフックが環境変数を拾わないよう修正 (#3398)** — isolated worker は通常実行中に従来の `CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_*` 環境変数を無視し、内部テストシームから渡される上限付きのテスト制御だけを受け取るようになりました。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs index 50618b37a2..05d1bb5701 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractionWorker.cs @@ -331,12 +331,14 @@ internal static class SymbolExtractionWorker { internal const string CommandName = "__cdidx-symbol-extraction"; internal const int WorkerKillWaitMilliseconds = 5000; - internal const string DelayEnvironmentVariable = "CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DELAY_MS"; - internal const string CompletionPathEnvironmentVariable = "CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DONE_PATH"; - internal const string ConsoleStdoutEnvironmentVariable = "CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_STDOUT"; + internal const int MaxDelayMillisecondsForTesting = 5000; private const string ProtocolMaxLineBytesOption = "--protocol-max-line-bytes"; + private const string TestDelayMillisecondsOption = "--test-delay-ms"; + private const string TestConsoleStdoutOption = "--test-console-stdout"; private const int CapturedConsoleMaxChars = 32 * 1024; internal static readonly JsonSerializerOptions JsonOptions = SymbolExtractionWorkerJsonContext.Default.Options; + internal static int? DelayMillisecondsForTesting { get; set; } + internal static string? ConsoleStdoutForTesting { get; set; } internal static bool TryRunCommand( string[] args, @@ -402,6 +404,7 @@ internal static bool TryCreateStartInfo( startInfo.FileName = currentProcessPath!; startInfo.ArgumentList.Add(CommandName); AddProtocolLineLimitArguments(startInfo, maxProtocolLineBytes); + AddTestingArguments(startInfo); error = string.Empty; return true; } @@ -425,6 +428,7 @@ internal static bool TryCreateStartInfo( startInfo.ArgumentList.Add(runnerAssemblyPath); startInfo.ArgumentList.Add(CommandName); AddProtocolLineLimitArguments(startInfo, maxProtocolLineBytes); + AddTestingArguments(startInfo); ApplyCurrentRuntimeRollForward(startInfo); error = string.Empty; @@ -490,20 +494,19 @@ private static int RunCommand( int maxProtocolLineCharacters, int maxProtocolLineUtf8Bytes) { - if (!TryResolveProtocolLineLimit( + if (!TryResolveWorkerOptions( args, maxProtocolLineCharacters, maxProtocolLineUtf8Bytes, - out var resolvedProtocolLineCharacters, - out var resolvedProtocolLineUtf8Bytes, + out var workerOptions, out var protocolLimitError)) { error.WriteLine(protocolLimitError); return 2; } - maxProtocolLineCharacters = resolvedProtocolLineCharacters; - maxProtocolLineUtf8Bytes = resolvedProtocolLineUtf8Bytes; + maxProtocolLineCharacters = workerOptions.MaxProtocolLineCharacters; + maxProtocolLineUtf8Bytes = workerOptions.MaxProtocolLineUtf8Bytes; try { @@ -542,7 +545,7 @@ private static int RunCommand( try { - response = InvokeInsideWorker(request); + response = InvokeInsideWorker(request, workerOptions); } catch (Exception ex) { @@ -562,7 +565,7 @@ private static int RunCommand( } } - private static WorkerResponse InvokeInsideWorker(WorkerRequest request) + private static WorkerResponse InvokeInsideWorker(WorkerRequest request, WorkerOptions options) { var originalOut = Console.Out; var originalError = Console.Error; @@ -572,8 +575,8 @@ private static WorkerResponse InvokeInsideWorker(WorkerRequest request) { Console.SetOut(capturedOut); Console.SetError(capturedError); - WriteConsoleOutputForTestingIfRequested(); - DelayForTestingIfRequested(); + WriteConsoleOutputForTestingIfRequested(options); + DelayForTestingIfRequested(options); var symbols = SymbolExtractor.Extract( request.FileId, request.Lang, @@ -594,26 +597,18 @@ private static WorkerResponse InvokeInsideWorker(WorkerRequest request) } } - private static void WriteConsoleOutputForTestingIfRequested() + private static void WriteConsoleOutputForTestingIfRequested(WorkerOptions options) { - var stdout = Environment.GetEnvironmentVariable(ConsoleStdoutEnvironmentVariable); - if (!string.IsNullOrEmpty(stdout)) - Console.Out.WriteLine(stdout); + if (!string.IsNullOrEmpty(options.ConsoleStdoutForTesting)) + Console.Out.WriteLine(options.ConsoleStdoutForTesting); } - private static void DelayForTestingIfRequested() + private static void DelayForTestingIfRequested(WorkerOptions options) { - var raw = Environment.GetEnvironmentVariable(DelayEnvironmentVariable); - if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var milliseconds) - || milliseconds <= 0) - { + if (options.DelayMillisecondsForTesting is not { } milliseconds) return; - } Thread.Sleep(milliseconds); - var completionPath = Environment.GetEnvironmentVariable(CompletionPathEnvironmentVariable); - if (!string.IsNullOrWhiteSpace(completionPath)) - File.WriteAllText(completionPath, "completed"); } private static void AddProtocolLineLimitArguments(ProcessStartInfo startInfo, int maxProtocolLineBytes) @@ -622,34 +617,91 @@ private static void AddProtocolLineLimitArguments(ProcessStartInfo startInfo, in startInfo.ArgumentList.Add(maxProtocolLineBytes.ToString(CultureInfo.InvariantCulture)); } - private static bool TryResolveProtocolLineLimit( + private static void AddTestingArguments(ProcessStartInfo startInfo) + { + if (DelayMillisecondsForTesting is { } delayMilliseconds && delayMilliseconds > 0) + { + var boundedDelay = Math.Min(delayMilliseconds, MaxDelayMillisecondsForTesting); + startInfo.ArgumentList.Add(TestDelayMillisecondsOption); + startInfo.ArgumentList.Add(boundedDelay.ToString(CultureInfo.InvariantCulture)); + } + + if (!string.IsNullOrEmpty(ConsoleStdoutForTesting)) + { + startInfo.ArgumentList.Add(TestConsoleStdoutOption); + startInfo.ArgumentList.Add(ConsoleStdoutForTesting); + } + } + + private static bool TryResolveWorkerOptions( string[] args, int fallbackMaxProtocolLineCharacters, int fallbackMaxProtocolLineUtf8Bytes, - out int maxProtocolLineCharacters, - out int maxProtocolLineUtf8Bytes, + out WorkerOptions options, out string error) { - maxProtocolLineCharacters = fallbackMaxProtocolLineCharacters; - maxProtocolLineUtf8Bytes = fallbackMaxProtocolLineUtf8Bytes; + var maxProtocolLineCharacters = fallbackMaxProtocolLineCharacters; + var maxProtocolLineUtf8Bytes = fallbackMaxProtocolLineUtf8Bytes; + int? delayMillisecondsForTesting = null; + string? consoleStdoutForTesting = null; error = string.Empty; - if (args.Length == 1) - return true; + options = new WorkerOptions( + maxProtocolLineCharacters, + maxProtocolLineUtf8Bytes, + delayMillisecondsForTesting, + consoleStdoutForTesting); - if (args.Length == 3 - && StringComparer.Ordinal.Equals(args[1], ProtocolMaxLineBytesOption) - && int.TryParse(args[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - && parsed > 0) + for (var index = 1; index < args.Length; index++) { - maxProtocolLineCharacters = parsed; - maxProtocolLineUtf8Bytes = parsed; - return true; + var option = args[index]; + if (index + 1 >= args.Length) + { + error = BuildWorkerOptionError(); + return false; + } + + var value = args[++index]; + if (StringComparer.Ordinal.Equals(option, ProtocolMaxLineBytesOption) + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var protocolBytes) + && protocolBytes > 0) + { + maxProtocolLineCharacters = protocolBytes; + maxProtocolLineUtf8Bytes = protocolBytes; + continue; + } + + if (StringComparer.Ordinal.Equals(option, TestDelayMillisecondsOption) + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var delay) + && delay is > 0 and <= MaxDelayMillisecondsForTesting) + { + delayMillisecondsForTesting = delay; + continue; + } + + if (StringComparer.Ordinal.Equals(option, TestConsoleStdoutOption)) + { + consoleStdoutForTesting = value; + continue; + } + + error = BuildWorkerOptionError(); + return false; } - error = $"symbol extraction worker accepts only `{ProtocolMaxLineBytesOption} `."; - return false; + options = new WorkerOptions( + maxProtocolLineCharacters, + maxProtocolLineUtf8Bytes, + delayMillisecondsForTesting, + consoleStdoutForTesting); + return true; } + private static string BuildWorkerOptionError() + => "symbol extraction worker accepts only " + + $"`{ProtocolMaxLineBytesOption} `, " + + $"`{TestDelayMillisecondsOption} `, or " + + $"`{TestConsoleStdoutOption} `."; + private static string? ResolveCurrentRunnerAssemblyPath() { var assemblyName = typeof(SymbolExtractionWorker).Assembly.GetName().Name; @@ -707,6 +759,12 @@ internal sealed record WorkerResponse( string? WorkerError, string? CapturedStderr); + private sealed record WorkerOptions( + int MaxProtocolLineCharacters, + int MaxProtocolLineUtf8Bytes, + int? DelayMillisecondsForTesting, + string? ConsoleStdoutForTesting); + private sealed class BoundedTextWriter(int maxChars) : TextWriter { private readonly StringBuilder builder = new(); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index eaac4a9950..d5f92f781b 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -236,14 +236,9 @@ public void SymbolExtractionWorker_TimeoutKillsWorkerBeforeDelayedExtractionCont var projectRoot = CreateTempProject(); lock (TestConsoleLock.Gate) { - using var env = EnvironmentVariableScope.Capture( - SymbolExtractionWorker.DelayEnvironmentVariable, - SymbolExtractionWorker.CompletionPathEnvironmentVariable); try { - var completionPath = Path.Combine(projectRoot, "symbol-worker.done"); - env.Set(SymbolExtractionWorker.DelayEnvironmentVariable, "500"); - env.Set(SymbolExtractionWorker.CompletionPathEnvironmentVariable, completionPath); + SymbolExtractionWorker.DelayMillisecondsForTesting = 500; using var worker = new SymbolExtractionWorkerClient(); var result = worker.Invoke( @@ -256,7 +251,42 @@ public void SymbolExtractionWorker_TimeoutKillsWorkerBeforeDelayedExtractionCont Assert.True(result.TimedOut); Assert.False(result.Success); - AssertFileDoesNotAppear(completionPath, TimeSpan.FromMilliseconds(1000)); + } + finally + { + SymbolExtractionWorker.DelayMillisecondsForTesting = null; + DeleteDirectory(projectRoot); + } + } + } + + [Fact] + public void SymbolExtractionWorker_LegacyEnvironmentHooksAreIgnored_Issue3398() + { + var projectRoot = CreateTempProject(); + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture( + "CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DELAY_MS", + "CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DONE_PATH"); + try + { + var completionPath = Path.Combine(projectRoot, "symbol-worker.done"); + env.Set("CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DELAY_MS", "500"); + env.Set("CDIDX_TEST_SYMBOL_EXTRACTION_WORKER_DONE_PATH", completionPath); + + using var worker = new SymbolExtractionWorkerClient(); + var result = worker.Invoke( + 0, + "csharp", + "public class App { }\n", + Path.Combine(projectRoot, "App.cs"), + projectRoot, + TimeSpan.FromSeconds(5)); + + Assert.True(result.Success, result.WorkerError); + Assert.False(result.TimedOut); + Assert.False(File.Exists(completionPath)); } finally { @@ -271,13 +301,12 @@ public void SymbolExtractionWorker_CapturesStdoutAndForwardsStderrDiagnostics() var projectRoot = CreateTempProject(); lock (TestConsoleLock.Gate) { - using var env = EnvironmentVariableScope.Capture(SymbolExtractionWorker.ConsoleStdoutEnvironmentVariable); try { WriteSymbolWorkerPatternConfig( projectRoot, "language: \"toydsl\"\nextensions:\n - extension: \".toy\"\npatterns:\n - kind: \"class\"\n regex: \"^(a+)+$\"\n"); - env.Set(SymbolExtractionWorker.ConsoleStdoutEnvironmentVariable, "not-json-protocol"); + SymbolExtractionWorker.ConsoleStdoutForTesting = "not-json-protocol"; var slowLine = new string('a', 10_000) + "!"; SymbolExtractionWorkerResult? result = null; @@ -303,6 +332,7 @@ public void SymbolExtractionWorker_CapturesStdoutAndForwardsStderrDiagnostics() } finally { + SymbolExtractionWorker.ConsoleStdoutForTesting = null; ExtractorPluginRegistry.ResetForTests(); DeleteDirectory(projectRoot); } @@ -430,6 +460,38 @@ public void SymbolExtractionWorker_StartInfo_UsesCurrentCdidxExecutableWhenAvail Assert.True(startInfo.RedirectStandardError); } + [Fact] + public void SymbolExtractionWorker_StartInfo_BoundsInternalTestDelay_Issue3398() + { + var currentProcessPath = Path.Combine(Path.GetTempPath(), OperatingSystem.IsWindows() ? "cdidx.exe" : "cdidx"); + try + { + SymbolExtractionWorker.DelayMillisecondsForTesting = + SymbolExtractionWorker.MaxDelayMillisecondsForTesting + 1; + + var created = SymbolExtractionWorker.TryCreateStartInfo( + currentProcessPath, + runnerAssemblyPath: string.Empty, + out var startInfo, + out var error); + + Assert.True(created, error); + Assert.Equal( + [ + SymbolExtractionWorker.CommandName, + "--protocol-max-line-bytes", + WorkerProtocolLineLimits.MaxLineUtf8Bytes.ToString(CultureInfo.InvariantCulture), + "--test-delay-ms", + SymbolExtractionWorker.MaxDelayMillisecondsForTesting.ToString(CultureInfo.InvariantCulture), + ], + startInfo.ArgumentList); + } + finally + { + SymbolExtractionWorker.DelayMillisecondsForTesting = null; + } + } + [Fact] public void SymbolExtractionWorker_StartInfo_UsesFrameworkDependentDllWithTrustedDotnetHost() { From b2c320474061d85970da5b34f787995098f24114 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 13 Jun 2026 23:47:05 +0900 Subject: [PATCH 2/2] Gate GitHub proxy default credentials (#3369) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/3369.security.md | 20 ++++++++++ src/CodeIndex/Cli/GitHubHttpClientFactory.cs | 40 +++++++++++++++++++ src/CodeIndex/Cli/GitHubIssueReporter.cs | 20 +--------- src/CodeIndex/Cli/IssueDuplicatePreflight.cs | 20 +--------- .../GitHubIssueReporterTests.cs | 19 ++++++++- .../IssueDuplicatePreflightTests.cs | 5 ++- 7 files changed, 86 insertions(+), 42 deletions(-) create mode 100644 changelog.d/unreleased/3369.security.md create mode 100644 src/CodeIndex/Cli/GitHubHttpClientFactory.cs diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c15879e468..8ce06efcac 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2358,7 +2358,7 @@ Filter parsing also warns on `stderr` when an allow/deny variable is empty, cont ### AI Feedback -cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline up to 300 seconds. Non-positive, non-numeric, and larger values fall back to the 10-second default. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). +cdidx includes a `suggest_improvement` MCP tool for AI agents that hit gaps or bugs. Suggestions are saved locally beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), and are sent to GitHub only when the user explicitly provides `CDIDX_GITHUB_TOKEN`. GitHub submission runs outside the suggestion-store file lock and uses a 10-second timeout by default; set `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=` to tune that deadline up to 300 seconds. Non-positive, non-numeric, and larger values fall back to the 10-second default. GitHub HTTP calls use .NET's default proxy discovery, but they do not forward OS/default proxy credentials by default; set `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` only when an enterprise proxy explicitly requires those credentials. Local records include lifecycle metadata: `draft`, `submitted_pending_triage`, `open_in_upstream`, `resolved_in_upstream`, `wont_fix`, `duplicate`, or `superseded`, plus upstream issue URL/number fields when known. They also persist GitHub submission diagnostics (`last_submit_attempt`, `submit_attempt_count`, `last_submit_error`, and rate-limit `next_retry_at`) so operators can tell whether a suggestion was never attempted, failed transiently, is waiting for a rate-limit window, or was rejected by the API. New records also store attribution metadata: the MCP `initialize.clientInfo` name/version when available, an opaque cdidx session id, the cdidx version that recorded the suggestion, optional natural-language `toolInvocationContext`, and optional repository-relative `evidencePaths` supplied by the caller. Payload details and source-code leak guardrails are documented in the [Developer Guide](DEVELOPER_GUIDE.md#ai-feedback-implementation). Use `cdidx suggestions list` to review recorded suggestions, `cdidx suggestions show ` to inspect one entry, and `cdidx suggestions export --format markdown` to share a filtered triage bundle with a team. Use `cdidx suggestions export --format issue-drafts --open-issues open-issues.json` to emit issue-ready drafts with title, labels, evidence paths, body text, and duplicate matches from an open-issues JSON preflight. The command reads the suggestion store beside the selected DB (`.cdidx/suggestions-codeindex.json` by default), supports filters such as `--status`, `--language`, `--category`, `--since`, and `--agent`, and prints JSON with `--json` for scripts. By default, `suggestions list` and `suggestions export` emit every matching record in newest-first order; pass `--limit ` and `--offset ` to page or cap large stores. Exported JSON, markdown bundles, and issue-draft bodies cap long description/context/tool-invocation text with a `[truncated]` marker; use `cdidx suggestions show ` when you need the full local record body. @@ -4793,7 +4793,7 @@ filter 解析では、allow / deny 変数が空、CSV 内に空 entry がある ### AIフィードバック -cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で最大 300 秒まで調整できます。0 以下、数値以外、または上限を超える値は 10 秒の既定値へ戻ります。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 +cdidx には、AI エージェントがギャップや不具合に気づいたときに使える `suggest_improvement` MCP ツールがあります。提案は選択した DB の隣(既定は `.cdidx/suggestions-codeindex.json`)にローカル保存され、`CDIDX_GITHUB_TOKEN` を明示設定した場合に限って GitHub へ送信されます。GitHub 送信は suggestion-store のファイルロック外で実行され、既定では 10 秒で timeout します。この deadline は `CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS=<秒>` で最大 300 秒まで調整できます。0 以下、数値以外、または上限を超える値は 10 秒の既定値へ戻ります。GitHub HTTP 呼び出しは .NET の既定 proxy 検出を使いますが、既定では OS/default proxy 資格情報を転送しません。企業 proxy が明示的にその資格情報を必要とする場合だけ `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` を設定してください。ローカルレコードには lifecycle metadata として `draft`、`submitted_pending_triage`、`open_in_upstream`、`resolved_in_upstream`、`wont_fix`、`duplicate`、`superseded` と、判明している upstream issue URL/番号が保存されます。さらに GitHub 送信診断として `last_submit_attempt`、`submit_attempt_count`、`last_submit_error`、rate-limit 時の `next_retry_at` も永続化されるため、提案が未試行なのか、一時的に失敗したのか、rate-limit window 待ちなのか、API に拒否されたのかを運用者が判断できます。新規レコードには attribution metadata も保存されます。取得可能な場合は MCP `initialize.clientInfo` の name/version、不透明な cdidx セッション ID、提案を記録した cdidx バージョン、呼び出し元が任意で渡す自然言語の `toolInvocationContext`、任意のリポジトリ相対 `evidencePaths` が含まれます。ペイロード詳細とソースコード漏えいガードは [DEVELOPER_GUIDE.md#aiフィードバックの実装](DEVELOPER_GUIDE.md#aiフィードバックの実装) にまとめています。 記録済みの提案は `cdidx suggestions list` で確認し、`cdidx suggestions show ` で1件を詳細表示し、`cdidx suggestions export --format markdown` でチーム triage 用に共有できます。`cdidx suggestions export --format issue-drafts --open-issues open-issues.json` は、title、labels、evidence paths、body text、open issue JSON との重複候補を含む Issue 作成用 draft を出力します。このコマンドは選択した DB の隣にある提案ストア(既定は `.cdidx/suggestions-codeindex.json`)を読み、`--status`、`--language`、`--category`、`--since`、`--agent` で絞り込めます。スクリプト向けには `--json` を使います。既定では `suggestions list` と `suggestions export` は一致した全レコードを新しい順に出力します。大きなストアでは `--limit ` と `--offset ` でページングまたは出力上限を指定できます。export JSON、markdown bundle、issue draft body は長い description / context / tool-invocation text を `[truncated]` marker 付きで制限します。ローカルレコード本文をすべて確認する場合は `cdidx suggestions show ` を使ってください。 diff --git a/changelog.d/unreleased/3369.security.md b/changelog.d/unreleased/3369.security.md new file mode 100644 index 0000000000..e47331b7b5 --- /dev/null +++ b/changelog.d/unreleased/3369.security.md @@ -0,0 +1,20 @@ +--- +category: security +issues: + - 3369 +affected: + - src/CodeIndex/Cli/GitHubHttpClientFactory.cs + - src/CodeIndex/Cli/GitHubIssueReporter.cs + - src/CodeIndex/Cli/IssueDuplicatePreflight.cs + - tests/CodeIndex.Tests/GitHubIssueReporterTests.cs + - tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs + - USER_GUIDE.md +--- + +## English + +- **GitHub issue submission no longer forwards default proxy credentials by default (#3369)** — GitHub submission and duplicate-preflight HTTP clients still use standard proxy discovery, but OS/default proxy credentials are sent only when `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` is explicitly configured. + +## 日本語 + +- **GitHub Issue 送信が既定で default proxy 資格情報を転送しないよう修正 (#3369)** — GitHub 送信と重複 preflight の HTTP クライアントは標準の proxy 検出を引き続き使いますが、OS/default proxy 資格情報は `CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS=1` が明示設定された場合にだけ送信されます。 diff --git a/src/CodeIndex/Cli/GitHubHttpClientFactory.cs b/src/CodeIndex/Cli/GitHubHttpClientFactory.cs new file mode 100644 index 0000000000..b207df47b0 --- /dev/null +++ b/src/CodeIndex/Cli/GitHubHttpClientFactory.cs @@ -0,0 +1,40 @@ +using System.Net; + +namespace CodeIndex.Cli; + +internal static class GitHubHttpClientFactory +{ + internal const string ProxyDefaultCredentialsEnvironmentVariable = "CDIDX_GITHUB_PROXY_USE_DEFAULT_CREDENTIALS"; + + internal static HttpClient CreateDefaultHttpClient(TimeSpan timeout) + { + var handler = new HttpClientHandler + { + UseProxy = true, + Proxy = HttpClient.DefaultProxy, + }; + if (ShouldUseDefaultProxyCredentials()) + handler.DefaultProxyCredentials = CredentialCache.DefaultCredentials; + + var client = new HttpClient(handler) + { + Timeout = timeout, + DefaultRequestHeaders = + { + { "User-Agent", "cdidx" }, + { "Accept", "application/vnd.github+json" }, + { "X-GitHub-Api-Version", "2022-11-28" }, + }, + }; + return client; + } + + internal static bool ShouldUseDefaultProxyCredentials() + { + var raw = Environment.GetEnvironmentVariable(ProxyDefaultCredentialsEnvironmentVariable)?.Trim(); + return raw != null + && (string.Equals(raw, "1", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(raw, "yes", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/CodeIndex/Cli/GitHubIssueReporter.cs b/src/CodeIndex/Cli/GitHubIssueReporter.cs index b01efe1689..6da5419b80 100644 --- a/src/CodeIndex/Cli/GitHubIssueReporter.cs +++ b/src/CodeIndex/Cli/GitHubIssueReporter.cs @@ -70,25 +70,7 @@ internal static class GitHubIssueReporter private static readonly HttpClient s_defaultHttpClient = CreateDefaultHttpClient(); private static HttpClient CreateDefaultHttpClient() - { - var handler = new HttpClientHandler - { - UseProxy = true, - Proxy = HttpClient.DefaultProxy, - DefaultProxyCredentials = CredentialCache.DefaultCredentials, - }; - var client = new HttpClient(handler) - { - Timeout = Timeout.InfiniteTimeSpan, - DefaultRequestHeaders = - { - { "User-Agent", "cdidx" }, - { "Accept", "application/vnd.github+json" }, - { "X-GitHub-Api-Version", "2022-11-28" }, - } - }; - return client; - } + => GitHubHttpClientFactory.CreateDefaultHttpClient(Timeout.InfiniteTimeSpan); // Test seam: when set, replaces the default HttpClient so tests can // mock GitHub responses without hitting the network. Production code diff --git a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs index 89acc143cc..3e37d12397 100644 --- a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs +++ b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -356,24 +355,7 @@ private static bool IsValidGitHubRepositoryPart(string value) } private static HttpClient CreateDefaultHttpClient() - { - var handler = new HttpClientHandler - { - UseProxy = true, - Proxy = HttpClient.DefaultProxy, - DefaultProxyCredentials = CredentialCache.DefaultCredentials, - }; - return new HttpClient(handler) - { - Timeout = TimeSpan.FromSeconds(10), - DefaultRequestHeaders = - { - { "User-Agent", "cdidx" }, - { "Accept", "application/vnd.github+json" }, - { "X-GitHub-Api-Version", "2022-11-28" }, - }, - }; - } + => GitHubHttpClientFactory.CreateDefaultHttpClient(TimeSpan.FromSeconds(10)); private static string? TryReadString(JsonNode? node, int maxLength) { diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index 4026d89d7d..2312461db9 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -24,7 +24,8 @@ public class GitHubIssueReporterTests : IDisposable private readonly EnvironmentVariableScope _env = EnvironmentVariableScope.Capture( "CDIDX_GITHUB_TOKEN", "GITHUB_TOKEN", - "CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS"); + "CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS", + GitHubHttpClientFactory.ProxyDefaultCredentialsEnvironmentVariable); [Fact] public void ResolveToken_NeitherSet_ReturnsNull() @@ -109,6 +110,22 @@ public void ResolveSubmitTimeout_AboveMaximum_UsesDefault() Assert.Equal(GitHubIssueReporter.DefaultTimeout, GitHubIssueReporter.ResolveSubmitTimeout()); } + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("0", false)] + [InlineData("false", false)] + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData(" yes ", true)] + [InlineData("yes", true)] + public void ShouldUseDefaultProxyCredentials_IsExplicitOptIn_Issue3369(string? value, bool expected) + { + _env.Set(GitHubHttpClientFactory.ProxyDefaultCredentialsEnvironmentVariable, value); + + Assert.Equal(expected, GitHubHttpClientFactory.ShouldUseDefaultProxyCredentials()); + } + // --- ScrubInlineCode tests / ScrubInlineCode テスト --- [Fact] diff --git a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs index 493474b40b..df761fef21 100644 --- a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs +++ b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs @@ -8,7 +8,10 @@ namespace CodeIndex.Tests; public sealed class IssueDuplicatePreflightTests : IDisposable { private readonly string _tempDir; - private readonly EnvironmentVariableScope _env = EnvironmentVariableScope.Capture("CDIDX_GITHUB_TOKEN", "GITHUB_TOKEN"); + private readonly EnvironmentVariableScope _env = EnvironmentVariableScope.Capture( + "CDIDX_GITHUB_TOKEN", + "GITHUB_TOKEN", + GitHubHttpClientFactory.ProxyDefaultCredentialsEnvironmentVariable); public IssueDuplicatePreflightTests() {