From 3a5e31b0cf7b8ed3f515644f4dc9479dc71e9e5e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 10:27:31 +0900 Subject: [PATCH 1/2] Cap report schema summary work (#3146) --- USER_GUIDE.md | 8 +- changelog.d/unreleased/3146.security.md | 17 ++++ src/CodeIndex/Cli/ReportCommandRunner.cs | 48 ++++++++-- .../ReportCommandRunnerTests.cs | 89 +++++++++++++++++++ 4 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 changelog.d/unreleased/3146.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7aec5917fd..a1e601a19d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1121,12 +1121,12 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` packages a redacted `.tar.gz` you can attach to a GitHub issue. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` listing each SQLite table with its row count (no user content). It also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. +`cdidx report --output ` packages a redacted `.tar.gz` you can attach to a GitHub issue. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no user content). It also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. | Flag | Default | Effect | |---|---|---| | `--output ` / `-o ` | (required) | Destination `.tar.gz`. The directory is created if missing; on POSIX, the archive and tar entries are owner-readable/writable only. | -| `--db ` | `.cdidx/codeindex.db` | Override the database whose schema is summarized. If absent, `schema.txt` records that no DB was found. | +| `--db ` | `.cdidx/codeindex.db` | Override the database whose schema is summarized. If absent, `schema.txt` records that no DB was found. Schema summaries cap table entries at 64, displayed table names at 96 characters, and row-count scans at 1000 rows per table. | | `--log-lines ` | `200` | How many trailing lifecycle-log lines to include (`0` disables the tail; values above `2000` are clamped). Each log file contributes from a bounded 1,048,576-byte tail window instead of being loaded fully. | | `--no-log` | | Skip the lifecycle log entirely. | | `--include-args` | | Keep literal `cwd=` and `args=` values in the log tail (opt-in; share only with trusted recipients). | @@ -3330,12 +3330,12 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` は GitHub Issue に添付できる匿名化済み `.tar.gz` を生成します。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、各 SQLite テーブル名と整数の行数のみを記録した `schema.txt`(ユーザコンテンツは含まれません)が入ります。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 +`cdidx report --output ` は GitHub Issue に添付できる匿名化済み `.tar.gz` を生成します。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(ユーザコンテンツは含まれません)が入ります。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 | フラグ | 既定値 | 効果 | |---|---|---| | `--output ` / `-o ` | (必須) | 出力先 `.tar.gz`。親ディレクトリが無ければ作成します。POSIX では archive と tar entry は owner の読み書きのみになります。 | -| `--db ` | `.cdidx/codeindex.db` | スキーマ要約対象の DB を上書きします。存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。 | +| `--db ` | `.cdidx/codeindex.db` | スキーマ要約対象の DB を上書きします。存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。スキーマ要約は table entry を 64 件、表示 table 名を 96 文字、行数 scan を table ごとに 1000 行までに制限します。 | | `--log-lines ` | `200` | ライフサイクルログ末尾を何行含めるか(`0` で末尾を含めません。`2000` を超える値は clamp されます)。各ログファイルは全体を読み込まず、末尾 1,048,576 byte の範囲から収集します。 | | `--no-log` | | ライフサイクルログを完全に省略します。 | | `--include-args` | | ログ末尾の `cwd=` / `args=` 値を伏字化せずそのまま含めます(信頼できる相手にだけ使用してください)。 | diff --git a/changelog.d/unreleased/3146.security.md b/changelog.d/unreleased/3146.security.md new file mode 100644 index 0000000000..6208e420ca --- /dev/null +++ b/changelog.d/unreleased/3146.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3146 +affected: + - src/CodeIndex/Cli/ReportCommandRunner.cs + - tests/CodeIndex.Tests/ReportCommandRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Report schema summaries now bound arbitrary SQLite table work (#3146)** — `cdidx report --db` caps schema table entries, displayed table-name length, and per-table row-count scans so malformed or oversized databases cannot force unbounded schema-summary collection. + +## 日本語 + +- **report の schema summary が任意 SQLite DB で行う table 処理に上限を設けました (#3146)** — `cdidx report --db` は schema table entry、表示 table 名の長さ、table ごとの行数 scan を制限し、壊れた DB や巨大 DB が schema summary 収集を無制限に走らせないようにしました。 diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 8d59f501ff..8b053b829f 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -23,8 +23,12 @@ public static class ReportCommandRunner internal const int DefaultLogLines = 200; internal const int MaxLogLines = 2000; internal const int MaxLogFileTailBytes = 1024 * 1024; + internal const int MaxSchemaTables = 64; + internal const int MaxSchemaTableNameDisplayChars = 96; + internal const int MaxSchemaRowCountScanRows = 1000; internal const string RedactedPlaceholder = "[redacted]"; internal const UnixFileMode BundleFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + private const string TruncatedTableNameSuffix = "...[truncated]"; public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, string? appVersion = null) { @@ -167,7 +171,7 @@ internal static string BuildReadme(string version, bool includeLog, bool include sb.AppendLine("- `metadata.json` — version, OS, .NET runtime info (machine-readable)."); sb.AppendLine("- `version.txt` — cdidx version only."); sb.AppendLine("- `env.txt` — human-readable OS / runtime summary."); - sb.AppendLine("- `schema.txt` — list of SQLite tables and row counts (no user content)."); + sb.AppendLine("- `schema.txt` — capped SQLite table list and bounded row counts (no user content)."); if (includeLog) { sb.AppendLine("- `log/stderr-recent.log` — last N lines of the cdidx lifecycle log"); @@ -184,7 +188,7 @@ internal static string BuildReadme(string version, bool includeLog, bool include sb.AppendLine(); sb.AppendLine("- Indexed source content, file paths, query strings, and `args=` lines are not included by default."); sb.AppendLine("- Path-bearing lifecycle fields such as `process_path=`, `base_dir=`, `cwd=`, `db=`, and `path=` are redacted by default."); - sb.AppendLine("- Schema reporting only emits table names and integer row counts."); + sb.AppendLine($"- Schema reporting emits at most {MaxSchemaTables} table names, capped at {MaxSchemaTableNameDisplayChars} display characters, with row counts bounded at {MaxSchemaRowCountScanRows} scanned rows per table."); return sb.ToString(); } @@ -209,40 +213,66 @@ internal static (string Text, List Tables, string? DbPath, bo var tableNames = new List(); using (var listCmd = connection.CreateCommand()) { - listCmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"; + listCmd.CommandText = $"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT {MaxSchemaTables + 1}"; using var reader = listCmd.ExecuteReader(); while (reader.Read()) tableNames.Add(reader.GetString(0)); } + var tableListTruncated = tableNames.Count > MaxSchemaTables; + if (tableListTruncated) + tableNames.RemoveRange(MaxSchemaTables, tableNames.Count - MaxSchemaTables); + foreach (var name in tableNames) { long rowCount; + var rowCountTruncated = false; try { using var countCmd = connection.CreateCommand(); - countCmd.CommandText = $"SELECT COUNT(*) FROM \"{name.Replace("\"", "\"\"")}\""; - rowCount = Convert.ToInt64(countCmd.ExecuteScalar()); + countCmd.CommandText = $"SELECT COUNT(*) FROM (SELECT 1 FROM \"{name.Replace("\"", "\"\"")}\" LIMIT {MaxSchemaRowCountScanRows + 1})"; + var cappedCount = Convert.ToInt64(countCmd.ExecuteScalar()); + rowCountTruncated = cappedCount > MaxSchemaRowCountScanRows; + rowCount = rowCountTruncated ? MaxSchemaRowCountScanRows : cappedCount; } catch (SqliteException) { rowCount = -1; } - tables.Add(new ReportSchemaTable(name, rowCount)); + tables.Add(new ReportSchemaTable(FormatSchemaTableName(name), rowCount, rowCountTruncated)); } var sb = new StringBuilder(); sb.AppendLine($"database: {RedactedPlaceholder}"); - sb.AppendLine($"tables : {tables.Count}"); + sb.AppendLine(tableListTruncated + ? $"tables : {tables.Count} (capped; additional tables omitted)" + : $"tables : {tables.Count}"); + sb.AppendLine($"limits : table entries <= {MaxSchemaTables}, table name chars <= {MaxSchemaTableNameDisplayChars}, row count scan rows <= {MaxSchemaRowCountScanRows}"); sb.AppendLine(); sb.AppendLine("name | row_count"); sb.AppendLine("-----|----------"); foreach (var t in tables) - sb.AppendLine($"{t.Name} | {(t.RowCount < 0 ? "(unreadable)" : t.RowCount.ToString())}"); + sb.AppendLine($"{t.Name} | {FormatSchemaRowCount(t)}"); return (sb.ToString(), tables, dbPath, true); } + private static string FormatSchemaTableName(string name) + { + if (name.Length <= MaxSchemaTableNameDisplayChars) + return name; + + return name[..(MaxSchemaTableNameDisplayChars - TruncatedTableNameSuffix.Length)] + TruncatedTableNameSuffix; + } + + private static string FormatSchemaRowCount(ReportSchemaTable table) + { + if (table.RowCount < 0) + return "(unreadable)"; + + return table.RowCountTruncated ? $">={table.RowCount}" : table.RowCount.ToString(); + } + internal static string BuildRecentLogTail(int maxLines, bool includeArgs, out int linesIncluded) { linesIncluded = 0; @@ -498,7 +528,7 @@ public void AddText(string name, string content) => Files.Add((name, Encoding.UTF8.GetBytes(content))); } -internal sealed record ReportSchemaTable(string Name, long RowCount); +internal sealed record ReportSchemaTable(string Name, long RowCount, bool RowCountTruncated = false); internal sealed record ReportMetadata( string Version, diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 2b65be463a..4fd8f29c21 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -269,6 +269,93 @@ public void Run_WithRealDb_SchemaTxtListsTablesAndRowCounts() } } + [Fact] + public void BuildSchemaSummary_CapsTableEntries_Issue3146() + { + var workDir = CreateWorkDir(); + var dbPath = Path.Combine(workDir, "many-tables.db"); + try + { + using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString)) + { + connection.Open(); + for (var i = 0; i < ReportCommandRunner.MaxSchemaTables + 3; i++) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"CREATE TABLE {QuoteIdentifier($"table_{i:D3}")}(value INTEGER)"; + cmd.ExecuteNonQuery(); + } + } + + var (schemaText, tables, _, dbIncluded) = ReportCommandRunner.BuildSchemaSummary(dbPath); + + Assert.True(dbIncluded); + Assert.Equal(ReportCommandRunner.MaxSchemaTables, tables.Count); + Assert.Contains($"tables : {ReportCommandRunner.MaxSchemaTables} (capped; additional tables omitted)", schemaText); + Assert.Contains($"limits : table entries <= {ReportCommandRunner.MaxSchemaTables}", schemaText); + Assert.Contains("table_063 | 0", schemaText); + Assert.DoesNotContain("table_064 |", schemaText); + } + finally + { + SqliteConnection.ClearAllPools(); + TryDeleteDirectory(workDir); + } + } + + [Fact] + public void BuildSchemaSummary_CapsDisplayedTableNamesAndRowCountScans_Issue3146() + { + var workDir = CreateWorkDir(); + var dbPath = Path.Combine(workDir, "large-table.db"); + var longName = "table_" + new string('a', ReportCommandRunner.MaxSchemaTableNameDisplayChars + 20); + try + { + using (var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString)) + { + connection.Open(); + using (var createCmd = connection.CreateCommand()) + { + createCmd.CommandText = $"CREATE TABLE {QuoteIdentifier(longName)}(value INTEGER)"; + createCmd.ExecuteNonQuery(); + } + + using var transaction = connection.BeginTransaction(); + using var insertCmd = connection.CreateCommand(); + insertCmd.Transaction = transaction; + insertCmd.CommandText = $"INSERT INTO {QuoteIdentifier(longName)}(value) VALUES ($value)"; + var valueParameter = insertCmd.CreateParameter(); + valueParameter.ParameterName = "$value"; + insertCmd.Parameters.Add(valueParameter); + + for (var i = 0; i < ReportCommandRunner.MaxSchemaRowCountScanRows + 5; i++) + { + valueParameter.Value = i; + insertCmd.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + var (schemaText, tables, _, dbIncluded) = ReportCommandRunner.BuildSchemaSummary(dbPath); + var table = Assert.Single(tables); + + Assert.True(dbIncluded); + Assert.Equal(ReportCommandRunner.MaxSchemaRowCountScanRows, table.RowCount); + Assert.True(table.RowCountTruncated); + Assert.True(table.Name.Length <= ReportCommandRunner.MaxSchemaTableNameDisplayChars); + Assert.Contains("[truncated]", table.Name); + Assert.Contains($">={ReportCommandRunner.MaxSchemaRowCountScanRows}", schemaText); + Assert.DoesNotContain(longName, schemaText); + Assert.DoesNotContain((ReportCommandRunner.MaxSchemaRowCountScanRows + 5).ToString(), schemaText); + } + finally + { + SqliteConnection.ClearAllPools(); + TryDeleteDirectory(workDir); + } + } + [Fact] public void Run_WithLogDirOverride_IncludesRedactedTail() { @@ -552,6 +639,8 @@ private static string CreateWorkDir() return path; } + private static string QuoteIdentifier(string value) => "\"" + value.Replace("\"", "\"\"") + "\""; + private static void TryDeleteDirectory(string path) { try From 855ac17fc8f5e7b3d7539aaae66271434eb2a5b4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 10:30:58 +0900 Subject: [PATCH 2/2] Clarify report schema row content wording (#3146) --- USER_GUIDE.md | 4 ++-- src/CodeIndex/Cli/ReportCommandRunner.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a1e601a19d..3c16eade0b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1121,7 +1121,7 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` packages a redacted `.tar.gz` you can attach to a GitHub issue. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no user content). It also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. +`cdidx report --output ` packages a redacted `.tar.gz` you can attach to a GitHub issue. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). It also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. | Flag | Default | Effect | |---|---|---| @@ -3330,7 +3330,7 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` は GitHub Issue に添付できる匿名化済み `.tar.gz` を生成します。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(ユーザコンテンツは含まれません)が入ります。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 +`cdidx report --output ` は GitHub Issue に添付できる匿名化済み `.tar.gz` を生成します。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 | フラグ | 既定値 | 効果 | |---|---|---| diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 8b053b829f..c22286b375 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -171,7 +171,7 @@ internal static string BuildReadme(string version, bool includeLog, bool include sb.AppendLine("- `metadata.json` — version, OS, .NET runtime info (machine-readable)."); sb.AppendLine("- `version.txt` — cdidx version only."); sb.AppendLine("- `env.txt` — human-readable OS / runtime summary."); - sb.AppendLine("- `schema.txt` — capped SQLite table list and bounded row counts (no user content)."); + sb.AppendLine("- `schema.txt` — capped SQLite table list and bounded row counts (no table row contents)."); if (includeLog) { sb.AppendLine("- `log/stderr-recent.log` — last N lines of the cdidx lifecycle log");