Skip to content
Merged
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2844.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: security
issues:
- 2844
affected:
- src/CodeIndex/Cli/CdidxConfigFile.cs
- src/CodeIndex/Cli/DataDirectorySecurity.cs
- src/CodeIndex/Cli/WorkspaceManifest.cs
- tests/CodeIndex.Tests/CdidxConfigFileTests.cs
- tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs
---

## English

- **CLI config and workspace manifest reads are now size-limited (#2844)** — `.cdidxrc.json` and workspace manifest loading now reject oversized JSON before deserialization instead of reading unbounded repository-controlled files.

## 日本語

- **CLI config と workspace manifest の読み込みにサイズ上限を設けました (#2844)** — `.cdidxrc.json` と workspace manifest の読み込みは、リポジトリ管理下の巨大 JSON を無制限に読むのではなく、deserialize 前に oversized file を拒否します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2856.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2856
affected:
- src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
---

## English

- **Full-scan checkpoint loading now has a byte cap (#2856)** — `.cdidx/scan-checkpoint.json` is ignored when it exceeds the configured limit, preventing unbounded reads before checkpoint deserialization.

## 日本語

- **full-scan checkpoint の読み込みに byte 上限を設けました (#2856)** — `.cdidx/scan-checkpoint.json` が上限を超える場合は無視し、checkpoint deserialize 前の無制限読み込みを防ぎます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2857.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2857
affected:
- src/CodeIndex/Cli/SuggestionStore.cs
- tests/CodeIndex.Tests/SuggestionStoreTests.cs
---

## English

- **Suggestion store reads are now bounded before JSON parsing (#2857)** — `suggestions.json` loading and filtered status reads now stop at the store size limit and preserve the corrupt-backup behavior instead of allocating for unbounded files.

## 日本語

- **Suggestion store の JSON parse 前読み込みに上限を設けました (#2857)** — `suggestions.json` の全件読み込みと status filter 読み込みは store size limit で停止し、無制限ファイルのために確保するのではなく既存の corrupt backup 挙動を維持します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2858.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2858
affected:
- src/CodeIndex/Cli/GitHelper.cs
- tests/CodeIndex.Tests/GitHelperTests.cs
---

## English

- **Git worktree metadata reads are now size-limited (#2858)** — `.git` files and worktree `commondir` files are ignored when oversized so repository-controlled git metadata cannot force unbounded CLI reads.

## 日本語

- **git worktree metadata の読み込みにサイズ上限を設けました (#2858)** — `.git` file と worktree の `commondir` file が oversized の場合は無視し、リポジトリ管理下の git metadata による無制限 CLI 読み込みを防ぎます。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2867.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2867
affected:
- src/CodeIndex/Cli/DbPathResolver.cs
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
- tests/CodeIndex.Tests/DbPathResolverTests.cs
---

## English

- **DbPathResolver checksum probes no longer read whole sample files (#2867)** — explicit `.cdidx/codeindex.db` project-root probing now computes sample checksums through a bounded streaming helper and skips checksum comparison for files over the indexer size cap.

## 日本語

- **DbPathResolver の checksum probe が sample file 全体を読み込まないようになりました (#2867)** — 明示指定された `.cdidx/codeindex.db` の project-root 推定では、上限制御された streaming helper で sample checksum を計算し、indexer の size cap を超える file は checksum 比較から外します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2870.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2870
affected:
- src/CodeIndex/Cli/UpdateChecker.cs
- tests/CodeIndex.Tests/ProgramRunnerTests.cs
---

## English

- **Update-check cache reads are now bounded (#2870)** — update notification cache loading now ignores oversized cache files before JSON parsing instead of reading arbitrary file contents.

## 日本語

- **update-check cache の読み込みに上限を設けました (#2870)** — update 通知 cache の読み込みは、任意サイズの file content を読むのではなく、JSON parse 前に oversized cache file を無視します。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2871.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2871
affected:
- src/CodeIndex/Cli/IndexCommandRunner.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
---

## English

- **Git exclude checks are now size-limited before appending `.cdidx/` (#2871)** — the indexer skips modifying an oversized `.git/info/exclude` file instead of reading it without a cap.

## 日本語

- **`.cdidx/` 追記前の git exclude 確認にサイズ上限を設けました (#2871)** — indexer は oversized な `.git/info/exclude` を無制限に読むのではなく、その file の更新をスキップします。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2872.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 2872
affected:
- src/CodeIndex/Cli/HookCommandRunner.cs
- tests/CodeIndex.Tests/HookCommandRunnerTests.cs
---

## English

- **Pre-commit hook marker checks are now bounded (#2872)** — hook install, uninstall, and status now treat oversized pre-commit hook files as custom hooks instead of reading the full file to find the managed marker.

## 日本語

- **pre-commit hook marker の確認に上限を設けました (#2872)** — hook install / uninstall / status は managed marker を探すために pre-commit hook 全体を読むのではなく、oversized hook を custom hook として扱います。
4 changes: 3 additions & 1 deletion src/CodeIndex/Cli/CdidxConfigFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal static class CdidxConfigFile
internal static readonly string ProjectConfigRelativePath = Path.Combine(".cdidx", "config.json");
internal const string DisableEnvVar = "CDIDX_DISABLE_CONFIG_FILE";
internal const string ConfigSourceEnvironmentVariablePrefix = "CDIDX_CONFIG_SOURCE__";
internal const int MaxConfigFileBytes = 64 * 1024;

private static readonly IReadOnlyList<string> KnownTopLevelKeys = new[]
{
Expand Down Expand Up @@ -85,7 +86,8 @@ internal static LoadResult LoadAndApply(
string text;
try
{
text = File.ReadAllText(path);
text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxConfigFileBytes)
?? throw new InvalidDataException($"{FileName} exceeds the {MaxConfigFileBytes} byte limit.");
}
catch (Exception ex)
{
Expand Down
22 changes: 17 additions & 5 deletions src/CodeIndex/Cli/DataDirectorySecurity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,19 @@ public static void WritePrivateText(string path, string contents, Encoding? enco
AtomicFileWriter.WriteText(path, contents, outputEncoding, ApplyPrivateFileMode);
}

public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read)
public static byte[]? ReadBytesWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read)
{
if (maxBytes <= 0)
throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be positive.");

var ioPath = LongPath.EnsureWindowsPrefix(path);
using var stream = File.Open(ioPath, FileMode.Open, FileAccess.Read, share);
using var output = new MemoryStream(capacity: Math.Min(maxBytes, 8192));
var buffer = new byte[Math.Min(maxBytes + 1, 8192)];
var buffer = new byte[Math.Min(maxBytes, 8192)];
var total = 0;
while (true)
{
var remaining = maxBytes + 1 - total;
var read = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining));
var read = stream.Read(buffer, 0, buffer.Length);
if (read == 0)
break;

Expand All @@ -80,7 +79,20 @@ public static void WritePrivateText(string path, string contents, Encoding? enco
output.Write(buffer, 0, read);
}

return Encoding.UTF8.GetString(output.ToArray());
return output.ToArray();
}

public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read)
{
var bytes = ReadBytesWithinLimit(path, maxBytes, share);
return bytes is null ? null : DecodeText(bytes);
}

private static string DecodeText(byte[] bytes)
{
using var stream = new MemoryStream(bytes);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}

public static string? GetUnixModeString(string? path)
Expand Down
6 changes: 4 additions & 2 deletions src/CodeIndex/Cli/DbPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -519,9 +519,11 @@ private static SampleMatchResult CountMatchingSamples(string candidateRoot, IRea
// checksums recorded by an indexer running on a different OS.
// FileIndexer のヘルパを使い、OS をまたいだ clone (CRLF と LF) でも、
// 他 OS で生成された checksum と引き続き一致するようにする。
var checksum = FileIndexer.ComputeChecksum(File.ReadAllBytes(ioPath));
if (string.Equals(checksum, sample.Checksum, StringComparison.Ordinal))
if (FileIndexer.TryComputeChecksum(ioPath, FileIndexer.DefaultMaxFileSizeBytes, out var checksum) &&
string.Equals(checksum, sample.Checksum, StringComparison.Ordinal))
{
checksumMatches++;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Expand Down
14 changes: 12 additions & 2 deletions src/CodeIndex/Cli/GitHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public sealed record GitHeadCommitResult(GitHeadCommitState State, string? Sha =
/// </summary>
public static class GitHelper
{
internal const int MaxGitMetadataFileBytes = 4 * 1024;

public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList<string> UnresolvedMergeFiles);

private static readonly HashSet<string> UnresolvedMergeStatuses = new(StringComparer.Ordinal)
Expand Down Expand Up @@ -73,7 +75,11 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList<string> Unresolv
: null;
}

var gitFileContent = File.ReadAllText(ioDotGit).Trim();
var gitFileContent = DataDirectorySecurity.ReadTextWithinLimit(ioDotGit, MaxGitMetadataFileBytes);
if (gitFileContent is null)
return null;

gitFileContent = gitFileContent.Trim();
if (!gitFileContent.StartsWith("gitdir:")) return null;

var worktreeGitDir = gitFileContent["gitdir:".Length..].Trim();
Expand All @@ -85,7 +91,11 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList<string> Unresolv
var ioCommonDirFile = LongPath.EnsureWindowsPrefix(commonDirFile);
if (File.Exists(ioCommonDirFile))
{
var commonDirRelative = File.ReadAllText(ioCommonDirFile).Trim();
var commonDirRelative = DataDirectorySecurity.ReadTextWithinLimit(ioCommonDirFile, MaxGitMetadataFileBytes);
if (commonDirRelative is null)
return null;

commonDirRelative = commonDirRelative.Trim();
return Path.GetFullPath(Path.Combine(worktreeGitDir, commonDirRelative));
}

Expand Down
15 changes: 10 additions & 5 deletions src/CodeIndex/Cli/HookCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public static class HookCommandRunner
private const string ChainedHookName = "pre-commit.cdidx-chain";
private const string BeginMarker = "# BEGIN CDIDX MANAGED PRE-COMMIT";
private const string EndMarker = "# END CDIDX MANAGED PRE-COMMIT";
internal const int MaxHookMarkerBytes = 64 * 1024;

public static int Run(string[] args, JsonSerializerOptions jsonOptions)
{
Expand Down Expand Up @@ -83,8 +84,7 @@ private static int Install(HookCommandOptions options, JsonSerializerOptions jso
var ioChainedHookPath = LongPath.EnsureWindowsPrefix(chainedHookPath);
if (File.Exists(ioHookPath))
{
var existing = File.ReadAllText(ioHookPath);
if (!IsManagedHook(existing))
if (!IsManagedHookFile(ioHookPath))
{
if (File.Exists(ioChainedHookPath) && !options.Force)
return WriteResult(options.Json, jsonOptions, "error", $"chained hook already exists: {chainedHookPath}", projectPath, hookPath, chainedHookPath, CommandExitCodes.UsageError);
Expand All @@ -107,8 +107,7 @@ private static int Uninstall(HookCommandOptions options, JsonSerializerOptions j
if (!File.Exists(ioHookPath))
return WriteResult(options.Json, jsonOptions, "absent", "cdidx pre-commit hook is not installed", projectPath, hookPath, File.Exists(ioChainedHookPath) ? chainedHookPath : null, CommandExitCodes.Success);

var existing = File.ReadAllText(ioHookPath);
if (!IsManagedHook(existing) && !options.Force)
if (!IsManagedHookFile(ioHookPath) && !options.Force)
return WriteResult(options.Json, jsonOptions, "error", "pre-commit hook is not managed by cdidx; pass --force to remove it", projectPath, hookPath, null, CommandExitCodes.UsageError);

File.Delete(ioHookPath);
Expand All @@ -123,7 +122,7 @@ private static int Status(HookCommandOptions options, JsonSerializerOptions json
var ioHookPath = LongPath.EnsureWindowsPrefix(hookPath);
var ioChainedHookPath = LongPath.EnsureWindowsPrefix(chainedHookPath);
var hookExists = File.Exists(ioHookPath);
var installed = hookExists && IsManagedHook(File.ReadAllText(ioHookPath));
var installed = hookExists && IsManagedHookFile(ioHookPath);
var status = installed ? "installed" : hookExists ? "custom" : "absent";
return WriteResult(options.Json, jsonOptions, status, $"cdidx pre-commit hook is {status}", projectPath, hookPath, File.Exists(ioChainedHookPath) ? chainedHookPath : null, CommandExitCodes.Success);
}
Expand All @@ -138,6 +137,12 @@ private static int UnknownCommand(HookCommandOptions options, JsonSerializerOpti
private static bool IsManagedHook(string content)
=> content.Contains(BeginMarker, StringComparison.Ordinal) && content.Contains(EndMarker, StringComparison.Ordinal);

private static bool IsManagedHookFile(string ioHookPath)
{
var content = DataDirectorySecurity.ReadTextWithinLimit(ioHookPath, MaxHookMarkerBytes, FileShare.ReadWrite);
return content is not null && IsManagedHook(content);
}

private static string BuildHookScript(string chainedHookPath)
{
var quotedChainedHook = QuoteShell(chainedHookPath);
Expand Down
8 changes: 7 additions & 1 deletion src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,20 @@ ex is UnauthorizedAccessException
|| ex is IOException
|| ex is SqliteException { SqliteErrorCode: 5 or 6 or 8 or 10 or 14 };

internal const int MaxScanCheckpointBytes = 1024 * 1024;

private static IReadOnlySet<string> LoadScanCheckpoint(string path, string? currentHead)
{
try
{
if (string.IsNullOrWhiteSpace(currentHead) || !File.Exists(path))
return new HashSet<string>(StringComparer.Ordinal);

var checkpoint = JsonSerializer.Deserialize<ScanCheckpoint>(File.ReadAllText(path));
var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxScanCheckpointBytes, FileShare.ReadWrite);
if (text is null)
return new HashSet<string>(StringComparer.Ordinal);

var checkpoint = JsonSerializer.Deserialize<ScanCheckpoint>(text);
if (checkpoint is not { Version: ScanCheckpointVersion }
|| !string.Equals(checkpoint.GitHead, currentHead, StringComparison.Ordinal)
|| checkpoint.Directories is not { Count: > 0 })
Expand Down
8 changes: 7 additions & 1 deletion src/CodeIndex/Cli/IndexCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static partial class IndexCommandRunner
internal const string IncludeSymbolKindsEnvironmentVariable = "CDIDX_INDEX_INCLUDE_SYMBOL_KINDS";
internal const string ExcludeSymbolKindsEnvironmentVariable = "CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS";
internal const int DefaultMaxSymbolsPerFile = 5000;
internal const int MaxGitExcludeBytes = 256 * 1024;
private const string SymbolKindFilterMetaKey = "index_symbol_kind_filter";
private const int ScanCheckpointVersion = 1;
private const string ScanCheckpointFileName = "scan-checkpoint.json";
Expand Down Expand Up @@ -731,7 +732,12 @@ private static void AddToGitExclude(string projectPath, string dbPath)
}

var ioExcludeFile = LongPath.EnsureWindowsPrefix(excludeFile);
var existingContent = File.Exists(ioExcludeFile) ? File.ReadAllText(ioExcludeFile) : "";
var existingContent = File.Exists(ioExcludeFile)
? DataDirectorySecurity.ReadTextWithinLimit(ioExcludeFile, MaxGitExcludeBytes, FileShare.ReadWrite)
: "";
if (existingContent is null)
return;

var existingLines = existingContent.Split('\n').Select(l => l.TrimEnd('\r')).ToHashSet();

var missing = patterns.Where(p => !existingLines.Contains(p)).ToList();
Expand Down
17 changes: 15 additions & 2 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class SuggestionStore
internal const double DefaultDedupThreshold = 0.85;
internal const int DefaultMaxAgeDays = 365;
internal const int DefaultMaxCount = 5000;
internal const int MaxSuggestionStoreBytes = 8 * 1024 * 1024;
private const int FuzzyDedupRecentLimit = 100;
private const string RedactedAwsAccessKey = "[REDACTED:aws_access_key]";
private const string RedactedBearerToken = "[REDACTED:bearer_token]";
Expand Down Expand Up @@ -439,7 +440,13 @@ private List<SuggestionRecord> ReadUnlocked()
return new List<SuggestionRecord>();
}

var json = File.ReadAllText(ioPath);
var json = DataDirectorySecurity.ReadTextWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare);
if (json is null)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

if (string.IsNullOrWhiteSpace(json))
return new List<SuggestionRecord>();

Expand Down Expand Up @@ -622,7 +629,13 @@ private List<SuggestionRecord> ReadFilteredUnlocked(
if (!File.Exists(ioPath))
return new List<SuggestionRecord>();

var snapshot = File.ReadAllBytes(ioPath);
var snapshot = DataDirectorySecurity.ReadBytesWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare);
if (snapshot is null)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

if (snapshot.Length == 0)
{
PreserveCorruptFile();
Expand Down
Loading
Loading