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/3005.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3005
affected:
- src/CodeIndex/Cli/GlobalToolLog.cs
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
---

## English

- **Global tool logs now redact underscore-separated secret options (#3005)** — lifecycle argument logging now treats `--api_key` and `--access_key` forms like existing API/access key spellings, so both assignment and split-value forms are redacted.

## 日本語

- **グローバルツールログが underscore 区切りの secret option を redact するようになりました (#3005)** — lifecycle 引数ログは `--api_key` と `--access_key` 形式も既存の API/access key 表記と同様に扱い、assignment 形式と値分離形式の両方を redact します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3006.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3006
affected:
- src/CodeIndex/Cli/SuggestionStore.cs
- tests/CodeIndex.Tests/SuggestionStoreTests.cs
---

## English

- **Suggestion persistence now redacts common key assignments (#3006)** — suggestion storage and GitHub submission scrubbing now covers low-entropy `token=`, `api_key=`, and access-key assignment forms, including hyphen and underscore variants.

## 日本語

- **Suggestion の永続化が一般的な key assignment を redact するようになりました (#3006)** — suggestion storage と GitHub submission の scrub は、低エントロピーの `token=`、`api_key=`、access-key assignment 形式を hyphen / underscore variants も含めて処理します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3070.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3070
affected:
- src/CodeIndex/Cli/GlobalToolLog.cs
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
---

## English

- **Global tool log redaction is now bounded (#3070)** — lifecycle argument scrubbing now uses timeout-bounded regexes and caps each argument before expensive redaction passes, replacing overlong suffixes with a truncation marker instead of logging them.

## 日本語

- **グローバルツールログの redaction が bounded になりました (#3070)** — lifecycle 引数の scrub は timeout 付き regex と引数ごとの入力長 cap を使い、長大な suffix はログへ残さず truncation marker に置き換えます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3071.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3071
affected:
- src/CodeIndex/Cli/SuggestionStore.cs
- tests/CodeIndex.Tests/SuggestionStoreTests.cs
---

## English

- **Suggestion text redaction is now bounded (#3071)** — suggestion persistence and GitHub submission scrubbing now uses timeout-bounded regexes and caps each text field before redaction, replacing overlong suffixes with an explicit truncation marker.

## 日本語

- **Suggestion text redaction が bounded になりました (#3071)** — suggestion persistence と GitHub submission の scrub は timeout 付き regex とフィールドごとの入力長 cap を使い、長大な suffix を明示的な truncation marker に置き換えます。
87 changes: 74 additions & 13 deletions src/CodeIndex/Cli/GlobalToolLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,28 @@ internal static class GlobalToolLog
private const long DefaultLogMaxSizeBytes = 50L * 1024L * 1024L;
internal const int MaxLogSizeMb = 1024;
internal const long MaxLogSizeBytes = MaxLogSizeMb * 1024L * 1024L;
internal const int RedactionArgumentLengthLimit = 8192;
internal const string RedactionTruncationMarker = "<truncated>";
private const string RedactedValue = "<redacted>";
private static readonly TimeSpan RedactionRegexTimeout = TimeSpan.FromSeconds(1);
internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System;
private static readonly AsyncLocal<Session?> CurrentSession = new();
private static readonly Regex SensitiveAssignmentPattern = new(
@"^(?<name>--?[^=\s]*(?:token|password|passwd|pwd|secret|auth|apikey|api-key|access-key|credential)[^=\s]*)=(?<value>.+)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
@"^(?<name>--?[^=\s]*(?:token|password|passwd|pwd|secret|auth|apikey|api-key|api_key|access-key|access_key|credential)[^=\s]*)=(?<value>.+)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
RedactionRegexTimeout);
private static readonly Regex UriUserInfoPattern = new(
@"(?<scheme>[a-z][a-z0-9+\-.]*://)(?<user>[^:@/\s]+):(?<password>[^@/\s]+)@",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
RedactionRegexTimeout);
private static readonly Regex LongHexPattern = new(
@"\b[0-9a-f]{32,}\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled);
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
RedactionRegexTimeout);
private static readonly Regex LongBase64Pattern = new(
@"\b[A-Za-z0-9+/]{40,}={0,2}\b",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
RegexOptions.CultureInvariant | RegexOptions.Compiled,
RedactionRegexTimeout);

internal static IDisposable? TryStart(string[] args, string appVersion)
=> TryStart(args, appVersion, createWriter: null, afterWriterCreated: null);
Expand Down Expand Up @@ -488,20 +495,74 @@ private static bool IsSensitiveFlag(string arg)
|| arg.Contains("auth", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("apikey", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("api-key", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("api_key", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("access-key", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("access_key", StringComparison.OrdinalIgnoreCase)
|| arg.Contains("credential", StringComparison.OrdinalIgnoreCase);
}

private static string RedactSensitiveText(string value)
{
var assignment = SensitiveAssignmentPattern.Match(value);
if (assignment.Success)
value = $"{assignment.Groups["name"].Value}={RedactedValue}";

value = UriUserInfoPattern.Replace(value, match => $"{match.Groups["scheme"].Value}{match.Groups["user"].Value}:{RedactedValue}@");
value = LongHexPattern.Replace(value, RedactedValue);
value = LongBase64Pattern.Replace(value, RedactedValue);
return value;
var truncated = false;
if (value.Length > RedactionArgumentLengthLimit)
{
value = value[..RedactionArgumentLengthLimit];
truncated = true;
}

try
{
if (value.Contains('=', StringComparison.Ordinal))
{
var assignment = SensitiveAssignmentPattern.Match(value);
if (assignment.Success)
return $"{assignment.Groups["name"].Value}={RedactedValue}";
}

value = UriUserInfoPattern.Replace(value, match => $"{match.Groups["scheme"].Value}{match.Groups["user"].Value}:{RedactedValue}@");
value = LongHexPattern.Replace(value, RedactedValue);
value = LongBase64Pattern.Replace(value, RedactedValue);
}
catch (RegexMatchTimeoutException)
{
return RedactedValue;
}

if (truncated && LooksLikeTruncatedUriUserInfo(value))
return RedactedValue;

return truncated ? value + RedactionTruncationMarker : value;
}

private static bool LooksLikeTruncatedUriUserInfo(string value)
{
var schemeEnd = value.IndexOf("://", StringComparison.Ordinal);
if (schemeEnd <= 0)
return false;

var authorityStart = schemeEnd + 3;
if (authorityStart >= value.Length)
return false;

var authorityEnd = value.Length;
for (var i = authorityStart; i < value.Length; i++)
{
var ch = value[i];
if (char.IsWhiteSpace(ch) || ch == '/' || ch == '?' || ch == '#')
{
authorityEnd = i;
break;
}
}

if (authorityEnd <= authorityStart)
return false;

var authorityLength = authorityEnd - authorityStart;
if (value.IndexOf('@', authorityStart, authorityLength) >= 0)
return false;

return value.IndexOf(':', authorityStart, authorityLength) >= 0;
}

private static string RedactPathLikeValue(string value)
Expand Down
122 changes: 91 additions & 31 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ public class SuggestionStore
private const string RedactedBearerToken = "[REDACTED:bearer_token]";
private const string RedactedCredential = "[REDACTED:credential]";
private const string RedactedHighEntropyToken = "[REDACTED:high_entropy_token]";
private static readonly Regex s_awsAccessKeyRegex = new(@"\bAKIA[0-9A-Z]{16}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex s_bearerTokenRegex = new(@"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex s_namedSecretRegex = new(@"(?i)\b(password|secret)=([^&\s]{1,200})", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex s_highEntropyTokenRegex = new(@"\b(?=[A-Za-z0-9._~+/=-]{32,}\b)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9._~+/=-]+\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private const string RedactedRegexTimeout = "[REDACTED:redaction_timeout]";
internal const int RedactionFieldLengthLimit = 32768;
internal const string RedactionTruncationMarker = "[REDACTED:truncated]";
private static readonly TimeSpan RedactionRegexTimeout = TimeSpan.FromSeconds(1);
private static readonly Regex s_awsAccessKeyRegex = new(@"\bAKIA[0-9A-Z]{16}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant, RedactionRegexTimeout);
private static readonly Regex s_bearerTokenRegex = new(@"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant, RedactionRegexTimeout);
private static readonly Regex s_namedSecretRegex = new(@"(?i)(^|[^\p{L}\p{N}_-])(?<name>[\p{L}\p{N}_-]*(?:password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key|credential)[\p{L}\p{N}_-]*)=(?<value>[^&\s]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant, RedactionRegexTimeout);
private static readonly Regex s_highEntropyTokenRegex = new(@"\b(?=[A-Za-z0-9._~+/=-]{32,}\b)(?=.*[A-Z])(?=.*[a-z])(?=.*\d)[A-Za-z0-9._~+/=-]+\b", RegexOptions.Compiled | RegexOptions.CultureInvariant, RedactionRegexTimeout);

private static readonly HashSet<string> s_dedupStopWords = new(StringComparer.Ordinal)
{
Expand Down Expand Up @@ -174,7 +178,8 @@ public record AddAndSubmitResult(
string? UpstreamUrl,
string? SubmissionError = null,
string? DuplicateOfHash = null,
double? DuplicateScore = null);
double? DuplicateScore = null,
string? StoredHash = null);

/// <summary>
/// Result of a GitHub submission attempt.
Expand Down Expand Up @@ -281,7 +286,8 @@ record = RedactRecordForPersistence(record);
reservation.UpstreamUrl,
null,
reservation.DuplicateOfHash,
reservation.DuplicateScore);
reservation.DuplicateScore,
reservation.Hash);
}

SubmitAttemptResult submitResult;
Expand Down Expand Up @@ -309,7 +315,8 @@ record = RedactRecordForPersistence(record);
reservation.UpstreamUrl,
null,
reservation.DuplicateOfHash,
reservation.DuplicateScore);
reservation.DuplicateScore,
reservation.Hash);
}

var issueUrl = submitResult.IssueUrl;
Expand All @@ -325,7 +332,8 @@ record = RedactRecordForPersistence(record);
issueUrl ?? found.UpstreamUrl,
submitResult.Error,
reservation.DuplicateOfHash,
reservation.DuplicateScore);
reservation.DuplicateScore,
found.Hash);
});
}

Expand Down Expand Up @@ -873,6 +881,9 @@ private static void StampSubmitResult(SuggestionRecord record, SubmitAttemptResu
McpClientName = record.McpClientName,
McpClientVersion = record.McpClientVersion,
ToolInvocationContext = record.ToolInvocationContext,
SampledTitle = record.SampledTitle,
SampledTags = record.SampledTags?.ToArray(),
EvidencePaths = record.EvidencePaths?.ToArray(),
UpstreamIssueNumber = record.UpstreamIssueNumber,
UpstreamUrl = record.UpstreamUrl,
LastSyncedAt = record.LastSyncedAt,
Expand All @@ -890,39 +901,65 @@ private static void StampSubmitResult(SuggestionRecord record, SubmitAttemptResu
internal static string RedactSensitiveText(string text, out IReadOnlyCollection<string> redactedTypes)
{
var types = new SortedSet<string>(StringComparer.Ordinal);
var redacted = s_awsAccessKeyRegex.Replace(text, match =>
var truncated = false;
if (text.Length > RedactionFieldLengthLimit)
{
types.Add("aws_access_key");
return RedactedAwsAccessKey;
});
redacted = s_bearerTokenRegex.Replace(redacted, match =>
{
types.Add("bearer_token");
return RedactedBearerToken;
});
redacted = s_namedSecretRegex.Replace(redacted, match =>
text = text[..RedactionFieldLengthLimit];
truncated = true;
types.Add("truncated");
}

try
{
types.Add("credential");
return $"{match.Groups[1].Value}={RedactedCredential}";
});
redacted = s_highEntropyTokenRegex.Replace(redacted, match =>
var redacted = s_awsAccessKeyRegex.Replace(text, match =>
{
types.Add("aws_access_key");
return RedactedAwsAccessKey;
});
redacted = s_bearerTokenRegex.Replace(redacted, match =>
{
types.Add("bearer_token");
return RedactedBearerToken;
});
redacted = s_namedSecretRegex.Replace(redacted, match =>
{
types.Add("credential");
return $"{match.Groups[1].Value}{match.Groups["name"].Value}={RedactedCredential}";
});
redacted = s_highEntropyTokenRegex.Replace(redacted, match =>
{
if (match.Value.StartsWith("[REDACTED:", StringComparison.Ordinal))
return match.Value;
types.Add("high_entropy_token");
return RedactedHighEntropyToken;
});

redactedTypes = types;
return truncated ? redacted + RedactionTruncationMarker : redacted;
}
catch (RegexMatchTimeoutException)
{
if (match.Value.StartsWith("[REDACTED:", StringComparison.Ordinal))
return match.Value;
types.Add("high_entropy_token");
return RedactedHighEntropyToken;
});

redactedTypes = types;
return redacted;
types.Add("redaction_timeout");
redactedTypes = types;
return RedactedRegexTimeout;
}
}

private static SuggestionRecord RedactRecordForPersistence(SuggestionRecord record)
{
var redactedDescription = RedactNullable(record.Description, out var descriptionTypes) ?? string.Empty;
var redactedContext = RedactNullable(record.Context, out var contextTypes);
var redactedToolInvocationContext = RedactNullable(record.ToolInvocationContext, out var toolInvocationTypes);
var allTypes = descriptionTypes.Concat(contextTypes).Concat(toolInvocationTypes).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
var redactedSampledTitle = RedactNullable(record.SampledTitle, out var sampledTitleTypes);
var redactedSampledTags = RedactArray(record.SampledTags, out var sampledTagTypes);
var allTypes = descriptionTypes
.Concat(contextTypes)
.Concat(toolInvocationTypes)
.Concat(sampledTitleTypes)
.Concat(sampledTagTypes)
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToArray();

if (allTypes.Length == 0)
return record;
Expand All @@ -932,10 +969,33 @@ private static SuggestionRecord RedactRecordForPersistence(SuggestionRecord reco
copy.Description = redactedDescription;
copy.Context = redactedContext;
copy.ToolInvocationContext = redactedToolInvocationContext;
copy.SampledTitle = redactedSampledTitle;
copy.SampledTags = redactedSampledTags;
copy.Hash = ComputeHash(copy.Category, copy.Language, copy.Description);
return copy;
}

private static string[]? RedactArray(string[]? values, out IReadOnlyCollection<string> redactedTypes)
{
if (values == null)
{
redactedTypes = Array.Empty<string>();
return null;
}

var types = new SortedSet<string>(StringComparer.Ordinal);
var redacted = new string[values.Length];
for (var i = 0; i < values.Length; i++)
{
redacted[i] = RedactSensitiveText(values[i] ?? string.Empty, out var valueTypes);
foreach (var type in valueTypes)
types.Add(type);
}

redactedTypes = types;
return redacted;
}

private static string? RedactNullable(string? value, out IReadOnlyCollection<string> redactedTypes)
{
if (value == null)
Expand Down
Loading
Loading