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
3 changes: 3 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ jobs:
with:
name: TestResults-${{ matrix.os }}-${{ matrix.test-framework }}
if-no-files-found: warn
overwrite: true
path: |
TestResults/**/*.trx
TestResults/**/*.txt
Expand All @@ -255,6 +256,7 @@ jobs:
with:
name: Coverage-${{ matrix.os }}-${{ matrix.test-framework }}
if-no-files-found: warn
overwrite: true
path: TestResults/**/coverage.cobertura.xml

- name: Publish
Expand All @@ -266,4 +268,5 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: CodeIndex
overwrite: true
path: publish/**
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2855.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2855
affected:
- src/CodeIndex/Cli/GitHubIssueReporter.cs
- tests/CodeIndex.Tests/GitHubIssueReporterTests.cs
---

## English

- **GitHub suggestion submission now bounds and redacts API error bodies (#2855)** — failed issue creation and rate-limit diagnostics read only a small response excerpt, redact sensitive JSON-like fields, and keep stderr plus persisted retry details bounded.

## 日本語

- **GitHub 提案送信時の API エラー本文を上限付き・redaction 済みにしました (#2855)** — Issue 作成失敗や rate limit 診断では小さなレスポンス抜粋だけを読み、機密性のある JSON 風フィールドを redaction し、stderr と永続化される retry detail を bounded に保ちます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2888.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2888
affected:
- src/CodeIndex/Cli/GitHubIssueReporter.cs
- tests/CodeIndex.Tests/GitHubIssueReporterTests.cs
---

## English

- **GitHub suggestion submission now bounds code scrubbing input (#2888)** — outbound issue title/body scrubbing uses a capped linear scanner for fenced and inline code, preventing very large suggestion text from driving unbounded regex work or leaking truncated code spans.

## 日本語

- **GitHub 提案送信時のコード scrub 入力を上限付きにしました (#2888)** — 外部送信用 Issue のタイトル/本文 scrub は上限付きの線形スキャナで fenced code と inline code を処理し、巨大な提案本文による無制限の regex 処理や切り詰め途中の code span 漏えいを防ぎます。
197 changes: 182 additions & 15 deletions src/CodeIndex/Cli/GitHubIssueReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ internal static class GitHubIssueReporter
private static readonly TimeSpan DefaultRateLimitRetryDelay = TimeSpan.FromMinutes(1);
private const string TimeoutEnvironmentVariable = "CDIDX_GITHUB_SUBMIT_TIMEOUT_SECONDS";
internal const int MaxGitHubIssueTitleLength = 255;
internal const int MaxScrubInputLength = 16 * 1024;
internal const int MaxGitHubApiErrorBodyBytes = 4 * 1024;
private const int MaxGitHubApiErrorDetailLength = 500;
private const string CodeExampleRemovedText = "[code example removed]";
private const string ScrubInputTruncatedText = "\n[truncated]";
private const string ApiErrorBodyTruncatedText = " [response body truncated]";
private static readonly Regex SensitiveJsonFieldPattern = new(
"(\"(?:token|access_token|authorization|password|secret|client_secret|private_key|api_key)\"\\s*:\\s*)(\"(?:\\\\.|[^\"])*\"|[^,}\\]\\s]+)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));

// Static HttpClient singleton — .NET best practice for reuse.
// 静的 HttpClient シングルトン — .NET の再利用ベストプラクティス。
Expand Down Expand Up @@ -416,7 +426,7 @@ private static bool IsHexHash(string value)

if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
var errorBody = await ReadBoundedApiErrorBodyAsync(response.Content, cancellationToken);
var rateLimitRetryAt = GetRateLimitRetryAt(response, DateTime.UtcNow);
if (rateLimitRetryAt != null)
{
Expand Down Expand Up @@ -456,12 +466,55 @@ internal static string ScrubInlineCode(string text)
if (string.IsNullOrEmpty(text))
return text;

var scrubbed = Regex.Replace(
text,
@"(?s)```.*?```",
"[code example removed]");
var (boundedText, wasTruncated) = BoundScrubInput(text);
var scrubbed = ScrubFencedCodeBlocks(boundedText);
scrubbed = ScrubSingleBacktickSpans(scrubbed);

return ScrubSingleBacktickSpans(scrubbed);
return wasTruncated
? scrubbed + ScrubInputTruncatedText
: scrubbed;
}

private static (string Text, bool WasTruncated) BoundScrubInput(string text) =>
text.Length <= MaxScrubInputLength
? (text, false)
: (text[..MaxScrubInputLength], true);

private static string ScrubFencedCodeBlocks(string text)
{
var builder = new StringBuilder(text.Length);
var index = 0;
while (index < text.Length)
{
var open = FindTripleBacktickFence(text, index);
if (open < 0)
{
builder.Append(text, index, text.Length - index);
break;
}

builder.Append(text, index, open - index);
builder.Append(CodeExampleRemovedText);

var close = FindTripleBacktickFence(text, open + 3);
if (close < 0)
break;

index = close + 3;
}

return builder.ToString();
}

private static int FindTripleBacktickFence(string text, int start)
{
for (var i = start; i + 2 < text.Length; i++)
{
if (text[i] == '`' && text[i + 1] == '`' && text[i + 2] == '`')
return i;
}

return -1;
}

internal static string BuildIssueTitle(string category, string description)
Expand Down Expand Up @@ -534,12 +587,11 @@ private static string ScrubSingleBacktickSpans(string text)
var close = FindInlineCodeClose(text, index + 1);
if (close < 0)
{
builder.Append(text[index]);
index++;
continue;
builder.Append(CodeExampleRemovedText);
break;
}

builder.Append("[code example removed]");
builder.Append(CodeExampleRemovedText);
index = close + 1;
}

Expand Down Expand Up @@ -598,19 +650,134 @@ internal static string BuildSubmissionFailureMessage(string detail) =>
$"[cdidx] GitHub issue creation failed: {detail}. The suggestion stays recorded locally; check `CDIDX_GITHUB_TOKEN`, network access, and proxy environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY`), then retry `suggest_improvement` when ready.";

internal static string BuildApiFailureMessage(int statusCode, string errorBody) =>
$"[cdidx] GitHub API responded {statusCode}: {errorBody}. GitHub submission was skipped; the suggestion stays local. Check `CDIDX_GITHUB_TOKEN`, repository permissions, or network access, then retry `suggest_improvement`.";
$"[cdidx] GitHub API responded {BuildApiErrorDetail(statusCode, errorBody)}. GitHub submission was skipped; the suggestion stays local. Check `CDIDX_GITHUB_TOKEN`, repository permissions, or network access, then retry `suggest_improvement`.";

internal static string BuildRateLimitFailureMessage(int statusCode, string errorBody, DateTime nextRetryAt) =>
$"[cdidx] GitHub API rate limit response {statusCode}: {errorBody}. GitHub submission was paused until {nextRetryAt:O}; the suggestion stays local and will not be retried before then.";
$"[cdidx] GitHub API rate limit response {BuildApiErrorDetail(statusCode, errorBody)}. GitHub submission was paused until {nextRetryAt:O}; the suggestion stays local and will not be retried before then.";

internal static string BuildApiErrorDetail(int statusCode, string errorBody)
{
var normalized = errorBody.Replace("\r", " ").Replace("\n", " ").Trim();
if (normalized.Length > 500)
normalized = normalized[..500] + "...";
var normalized = SanitizeApiErrorBody(errorBody);
if (normalized.Length > MaxGitHubApiErrorDetailLength)
normalized = TruncateWithEllipsis(normalized, MaxGitHubApiErrorDetailLength);
return $"{statusCode}: {normalized}";
}

private static async Task<string> ReadBoundedApiErrorBodyAsync(HttpContent content, CancellationToken cancellationToken)
{
await using var stream = await content.ReadAsStreamAsync(cancellationToken);
return await ReadBoundedApiErrorBodyAsync(stream, cancellationToken);
}

internal static async Task<string> ReadBoundedApiErrorBodyAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[MaxGitHubApiErrorBodyBytes + 1];
var total = 0;
while (total < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken);
if (read == 0)
break;
total += read;
}

var bodyLength = Math.Min(total, MaxGitHubApiErrorBodyBytes);
var body = Encoding.UTF8.GetString(buffer, 0, bodyLength);
return total > MaxGitHubApiErrorBodyBytes
? body + ApiErrorBodyTruncatedText
: body;
}

private static string SanitizeApiErrorBody(string errorBody)
{
var bounded = BoundApiErrorBodyForFormatting(errorBody);
var sanitized = TryRedactSensitiveJsonFields(bounded, out var redactedJson)
? redactedJson
: RedactSensitiveJsonLikeFields(bounded);
var normalized = sanitized.Replace("\r", " ").Replace("\n", " ").Trim();
return normalized.Length == 0 ? "<empty response body>" : normalized;
}

private static string BoundApiErrorBodyForFormatting(string errorBody)
{
if (string.IsNullOrEmpty(errorBody))
return string.Empty;

return errorBody.Length <= MaxGitHubApiErrorBodyBytes
? errorBody
: errorBody[..MaxGitHubApiErrorBodyBytes] + ApiErrorBodyTruncatedText;
}

private static bool TryRedactSensitiveJsonFields(string errorBody, out string redactedJson)
{
redactedJson = errorBody;
try
{
var node = JsonNode.Parse(errorBody);
if (node == null || !RedactSensitiveJsonFields(node))
return false;

redactedJson = node.ToJsonString();
return true;
}
catch (JsonException)
{
return false;
}
}

private static bool RedactSensitiveJsonFields(JsonNode node)
{
var changed = false;
if (node is JsonObject obj)
{
foreach (var property in obj.ToArray())
{
if (IsSensitiveApiErrorField(property.Key))
{
obj[property.Key] = "[redacted]";
changed = true;
continue;
}

if (property.Value != null)
changed |= RedactSensitiveJsonFields(property.Value);
}
}
else if (node is JsonArray array)
{
foreach (var item in array)
{
if (item != null)
changed |= RedactSensitiveJsonFields(item);
}
}

return changed;
}

private static bool IsSensitiveApiErrorField(string fieldName) =>
fieldName.Contains("token", StringComparison.OrdinalIgnoreCase)
|| fieldName.Contains("secret", StringComparison.OrdinalIgnoreCase)
|| fieldName.Contains("password", StringComparison.OrdinalIgnoreCase)
|| fieldName.Equals("authorization", StringComparison.OrdinalIgnoreCase)
|| fieldName.Equals("api_key", StringComparison.OrdinalIgnoreCase)
|| fieldName.Equals("private_key", StringComparison.OrdinalIgnoreCase);

private static string RedactSensitiveJsonLikeFields(string errorBody)
{
try
{
return SensitiveJsonFieldPattern.Replace(
errorBody,
match => match.Groups[1].Value + "\"[redacted]\"");
}
catch (RegexMatchTimeoutException)
{
return "[response body omitted after redaction timeout]";
}
}

internal static string BuildRateLimitErrorDetail(int statusCode, string errorBody, DateTime nextRetryAt) =>
$"{BuildApiErrorDetail(statusCode, errorBody)}; next_retry_at={nextRetryAt:O}";

Expand Down
Loading
Loading