From b9e403a9d8cffe591642656833d1ea8cf8c46952 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:18:51 +0900 Subject: [PATCH 1/3] Redact report bundle paths (#2836) --- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/2836.security.md | 17 +++++++++++++++++ src/CodeIndex/Cli/ReportCommandRunner.cs | 10 +++++----- .../CodeIndex.Tests/ReportCommandRunnerTests.cs | 9 ++++++++- 4 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/2836.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 73e97aaa33..e2d627281c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1067,7 +1067,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` listing each SQLite table with its row count (no user content). It also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with `cwd=` and `args=` lines replaced by `[redacted]` so working-directory 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` 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, `cwd=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. | Flag | Default | Effect | |---|---|---| @@ -3172,7 +3172,7 @@ 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`)の末尾も含まれますが、`cwd=` と `args=` 行は `[redacted]` に置換されるため、作業ディレクトリのパスや具体的なクエリ文字列が端末から外に出ることはありません。 +`cdidx report --output ` は GitHub Issue に添付できる匿名化済み `.tar.gz` を生成します。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、各 SQLite テーブル名と整数の行数のみを記録した `schema.txt`(ユーザコンテンツは含まれません)が入ります。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`cwd=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 | フラグ | 既定値 | 効果 | |---|---|---| diff --git a/changelog.d/unreleased/2836.security.md b/changelog.d/unreleased/2836.security.md new file mode 100644 index 0000000000..a3b394bab9 --- /dev/null +++ b/changelog.d/unreleased/2836.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2836 +affected: + - src/CodeIndex/Cli/ReportCommandRunner.cs + - tests/CodeIndex.Tests/ReportCommandRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Report bundles now redact database and log-directory paths (#2836)** - `cdidx report` no longer writes local SQLite database paths or lifecycle-log source directories into the generated support bundle. + +## 日本語 + +- **report bundle で DB パスとログディレクトリのパスを伏せるようになりました (#2836)** - `cdidx report` は生成するサポート用 bundle に、ローカル SQLite DB パスやライフサイクルログの source directory を書き込まなくなりました。 diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 4d443ff8bd..9d4f86958f 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -188,7 +188,7 @@ internal static (string Text, List Tables, string? DbPath, bo { if (!File.Exists(LongPath.EnsureWindowsPrefix(dbPath))) { - var missingText = $"no SQLite index found at: {dbPath}\nRun `cdidx index ` first if you want schema details attached.\n"; + var missingText = $"no SQLite index found at: {RedactedPlaceholder}\nRun `cdidx index ` first if you want schema details attached.\n"; return (missingText, new List(), dbPath, false); } @@ -228,7 +228,7 @@ internal static (string Text, List Tables, string? DbPath, bo } var sb = new StringBuilder(); - sb.AppendLine($"database: {Path.GetFullPath(dbPath)}"); + sb.AppendLine($"database: {RedactedPlaceholder}"); sb.AppendLine($"tables : {tables.Count}"); sb.AppendLine(); sb.AppendLine("name | row_count"); @@ -244,14 +244,14 @@ internal static string BuildRecentLogTail(int maxLines, bool includeArgs, out in linesIncluded = 0; var logDir = GlobalToolLog.ResolveLogDirectoryForReport(); if (string.IsNullOrWhiteSpace(logDir) || !Directory.Exists(logDir)) - return $"no cdidx lifecycle log directory found (looked at: {logDir ?? ""}).\n"; + return $"no cdidx lifecycle log directory found (looked at: {RedactedPlaceholder}).\n"; var logFiles = new DirectoryInfo(logDir) .EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly) .OrderByDescending(f => f.Name, StringComparer.Ordinal) .ToList(); if (logFiles.Count == 0) - return $"no cdidx lifecycle log files found in: {logDir}\n"; + return $"no cdidx lifecycle log files found in: {RedactedPlaceholder}\n"; var collected = new LinkedList(); foreach (var file in logFiles) @@ -273,7 +273,7 @@ internal static string BuildRecentLogTail(int maxLines, bool includeArgs, out in var sb = new StringBuilder(); sb.AppendLine($"# cdidx lifecycle log (last {collected.Count} lines, newest last)"); - sb.AppendLine($"# source directory: {logDir}"); + sb.AppendLine($"# source directory: {RedactedPlaceholder}"); sb.AppendLine(); foreach (var line in collected) { diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 4a8f439676..3eea75f100 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -142,6 +142,8 @@ public void Run_NoDbAndNoLog_StillProducesBundleWithMetadata() var schemaText = Encoding.UTF8.GetString(entries["schema.txt"]); Assert.Contains("no SQLite index found", schemaText); + Assert.Contains($"no SQLite index found at: {ReportCommandRunner.RedactedPlaceholder}", schemaText); + Assert.DoesNotContain(missingDb, schemaText); } finally { @@ -174,6 +176,8 @@ public void Run_WithRealDb_SchemaTxtListsTablesAndRowCounts() Assert.Contains("files", schemaText); Assert.Contains("symbols", schemaText); Assert.Contains("row_count", schemaText); + Assert.Contains($"database: {ReportCommandRunner.RedactedPlaceholder}", schemaText); + Assert.DoesNotContain(dbPath, schemaText); Assert.DoesNotContain("no SQLite index found", schemaText); } finally @@ -213,8 +217,10 @@ public void Run_WithLogDirOverride_IncludesRedactedTail() var entries = ReadTarGzEntries(output); Assert.True(entries.ContainsKey("log/stderr-recent.log")); var logText = Encoding.UTF8.GetString(entries["log/stderr-recent.log"]); + Assert.Contains($"# source directory: {ReportCommandRunner.RedactedPlaceholder}", logText); Assert.Contains("args=[redacted]", logText); Assert.Contains("cwd=[redacted]", logText); + Assert.DoesNotContain(logDir, logText); Assert.DoesNotContain("/Users/widthdom/secret", logText); Assert.DoesNotContain("SELECT * FROM secret", logText); Assert.Contains("session_start", logText); @@ -253,7 +259,8 @@ public void Run_IncludeArgs_PreservesLiteralArgsAndCwd() var logText = Encoding.UTF8.GetString(entries["log/stderr-recent.log"]); Assert.Contains("cwd=/tmp/keep-this", logText); Assert.Contains("args=index .", logText); - Assert.DoesNotContain("[redacted]", logText); + Assert.DoesNotContain("cwd=[redacted]", logText); + Assert.DoesNotContain("args=[redacted]", logText); } finally { From dc836c498757e2373a53dfac8fdc28931548256f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:24:45 +0900 Subject: [PATCH 2/3] Redact report lifecycle paths (#2840) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/2840.security.md | 17 +++++ src/CodeIndex/Cli/ReportCommandRunner.cs | 18 ++++- .../ReportCommandRunnerTests.cs | 75 ++++++++++++++++++- 4 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/2840.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e2d627281c..30e6c70c58 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1067,7 +1067,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` 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, `cwd=`, 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` 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. | Flag | Default | Effect | |---|---|---| @@ -3172,7 +3172,7 @@ 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、`cwd=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 +`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]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。 | フラグ | 既定値 | 効果 | |---|---|---| diff --git a/changelog.d/unreleased/2840.security.md b/changelog.d/unreleased/2840.security.md new file mode 100644 index 0000000000..24750e0bc7 --- /dev/null +++ b/changelog.d/unreleased/2840.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2840 +affected: + - src/CodeIndex/Cli/ReportCommandRunner.cs + - tests/CodeIndex.Tests/ReportCommandRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Report log tails now redact lifecycle executable paths (#2840)** - `cdidx report` now redacts `process_path=`, `base_dir=`, and other path-bearing lifecycle fields by default, while `--include-args` only restores literal `args=` lines. + +## 日本語 + +- **report のログ末尾でライフサイクル実行パスを伏せるようになりました (#2840)** - `cdidx report` は既定で `process_path=`、`base_dir=` などの path-bearing lifecycle fields を伏せ、`--include-args` は `args=` 行だけを literal に戻すようになりました。 diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 9d4f86958f..a9c3222ded 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -169,8 +169,8 @@ internal static string BuildReadme(string version, bool includeLog, bool include { sb.AppendLine("- `log/stderr-recent.log` — last N lines of the cdidx lifecycle log"); sb.AppendLine(includeArgs - ? " (includes literal `args=` lines; rerun without `--include-args` to redact them)." - : " (`args=` lines are redacted; rerun with `--include-args` to keep them literal)."); + ? " (includes literal `args=` lines; path-bearing lifecycle fields stay redacted)." + : " (`args=` and path-bearing lifecycle fields are redacted; rerun with `--include-args` to keep arguments literal)."); } else { @@ -180,6 +180,7 @@ internal static string BuildReadme(string version, bool includeLog, bool include sb.AppendLine("## Redactions"); 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."); return sb.ToString(); } @@ -277,7 +278,7 @@ internal static string BuildRecentLogTail(int maxLines, bool includeArgs, out in sb.AppendLine(); foreach (var line in collected) { - sb.AppendLine(includeArgs ? line : RedactSensitiveFields(line)); + sb.AppendLine(includeArgs ? RedactPathFields(line) : RedactSensitiveFields(line)); } linesIncluded = collected.Count; return sb.ToString(); @@ -286,7 +287,16 @@ internal static string BuildRecentLogTail(int maxLines, bool includeArgs, out in internal static string RedactSensitiveFields(string line) { var redacted = RedactKeyValue(line, "args="); - redacted = RedactKeyValue(redacted, "cwd="); + return RedactPathFields(redacted); + } + + private static string RedactPathFields(string line) + { + var redacted = RedactKeyValue(line, "cwd="); + redacted = RedactKeyValue(redacted, "process_path="); + redacted = RedactKeyValue(redacted, "base_dir="); + redacted = RedactKeyValue(redacted, "db="); + redacted = RedactKeyValue(redacted, "path="); return redacted; } diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 3eea75f100..b3aa5b1154 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -197,7 +197,11 @@ public void Run_WithLogDirOverride_IncludesRedactedTail() Path.Combine(logDir, "stderr-20260516.log"), string.Join('\n', "2026-05-16T03:00:00Z [INFO] session_start pid=1 version=1.21.0", + "2026-05-16T03:00:00Z [INFO] process_path=/Users/widthdom/.dotnet/tools/cdidx", + "2026-05-16T03:00:00Z [INFO] base_dir=/Users/widthdom/.dotnet/tools/.store/cdidx", "2026-05-16T03:00:00Z [INFO] cwd=/Users/widthdom/secret", + "2026-05-16T03:00:00Z [ERROR] database_open_failed db=/Users/widthdom/secret/.cdidx/codeindex.db", + "2026-05-16T03:00:00Z [INFO] config_file_loaded path=/Users/widthdom/secret/.cdidx/config.json", "2026-05-16T03:00:00Z [INFO] args=query \"SELECT * FROM secret\"", "2026-05-16T03:00:01Z [ERROR] sample error", "")); @@ -220,8 +224,16 @@ public void Run_WithLogDirOverride_IncludesRedactedTail() Assert.Contains($"# source directory: {ReportCommandRunner.RedactedPlaceholder}", logText); Assert.Contains("args=[redacted]", logText); Assert.Contains("cwd=[redacted]", logText); + Assert.Contains("process_path=[redacted]", logText); + Assert.Contains("base_dir=[redacted]", logText); + Assert.Contains("db=[redacted]", logText); + Assert.Contains("path=[redacted]", logText); Assert.DoesNotContain(logDir, logText); Assert.DoesNotContain("/Users/widthdom/secret", logText); + Assert.DoesNotContain("/Users/widthdom/.dotnet/tools/cdidx", logText); + Assert.DoesNotContain("/Users/widthdom/.dotnet/tools/.store/cdidx", logText); + Assert.DoesNotContain("/Users/widthdom/secret/.cdidx/codeindex.db", logText); + Assert.DoesNotContain("/Users/widthdom/secret/.cdidx/config.json", logText); Assert.DoesNotContain("SELECT * FROM secret", logText); Assert.Contains("session_start", logText); } @@ -233,14 +245,21 @@ public void Run_WithLogDirOverride_IncludesRedactedTail() } [Fact] - public void Run_IncludeArgs_PreservesLiteralArgsAndCwd() + public void Run_IncludeArgs_PreservesLiteralArgsButRedactsPaths() { var workDir = CreateWorkDir(); var logDir = Path.Combine(workDir, "logs"); Directory.CreateDirectory(logDir); File.WriteAllText( Path.Combine(logDir, "stderr-20260516.log"), - "2026-05-16T03:00:00Z [INFO] cwd=/tmp/keep-this\n2026-05-16T03:00:00Z [INFO] args=index .\n"); + string.Join('\n', + "2026-05-16T03:00:00Z [INFO] process_path=/tmp/cdidx", + "2026-05-16T03:00:00Z [INFO] base_dir=/tmp/cdidx-store", + "2026-05-16T03:00:00Z [INFO] cwd=/tmp/keep-this", + "2026-05-16T03:00:00Z [ERROR] database_open_failed db=/tmp/keep-this/.cdidx/codeindex.db", + "2026-05-16T03:00:00Z [INFO] config_file_loaded path=/tmp/keep-this/.cdidx/config.json", + "2026-05-16T03:00:00Z [INFO] args=index .", + "")); var previousLogDir = Environment.GetEnvironmentVariable("CDIDX_GLOBAL_TOOL_LOG_DIR"); Environment.SetEnvironmentVariable("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); @@ -257,9 +276,17 @@ public void Run_IncludeArgs_PreservesLiteralArgsAndCwd() Assert.Equal(CommandExitCodes.Success, exitCode); var entries = ReadTarGzEntries(output); var logText = Encoding.UTF8.GetString(entries["log/stderr-recent.log"]); - Assert.Contains("cwd=/tmp/keep-this", logText); Assert.Contains("args=index .", logText); - Assert.DoesNotContain("cwd=[redacted]", logText); + Assert.Contains("cwd=[redacted]", logText); + Assert.Contains("process_path=[redacted]", logText); + Assert.Contains("base_dir=[redacted]", logText); + Assert.Contains("db=[redacted]", logText); + Assert.Contains("path=[redacted]", logText); + Assert.DoesNotContain("/tmp/keep-this", logText); + Assert.DoesNotContain("/tmp/cdidx", logText); + Assert.DoesNotContain("/tmp/cdidx-store", logText); + Assert.DoesNotContain("/tmp/keep-this/.cdidx/codeindex.db", logText); + Assert.DoesNotContain("/tmp/keep-this/.cdidx/config.json", logText); Assert.DoesNotContain("args=[redacted]", logText); } finally @@ -305,6 +332,46 @@ public void RedactSensitiveFields_RedactsCwdLine() Assert.DoesNotContain("/private/foo/secret-project", redacted); } + [Fact] + public void RedactSensitiveFields_RedactsProcessPathLine() + { + var redacted = ReportCommandRunner.RedactSensitiveFields( + "2026-05-16T03:00:00Z [INFO] process_path=/Users/example/.dotnet/tools/cdidx"); + + Assert.Contains("process_path=[redacted]", redacted); + Assert.DoesNotContain("/Users/example/.dotnet/tools/cdidx", redacted); + } + + [Fact] + public void RedactSensitiveFields_RedactsBaseDirLine() + { + var redacted = ReportCommandRunner.RedactSensitiveFields( + "2026-05-16T03:00:00Z [INFO] base_dir=/Users/example/.dotnet/tools/.store/cdidx"); + + Assert.Contains("base_dir=[redacted]", redacted); + Assert.DoesNotContain("/Users/example/.dotnet/tools/.store/cdidx", redacted); + } + + [Fact] + public void RedactSensitiveFields_RedactsDatabasePathLine() + { + var redacted = ReportCommandRunner.RedactSensitiveFields( + "2026-05-16T03:00:00Z [ERROR] database_open_failed db=/Users/example/project/.cdidx/codeindex.db"); + + Assert.Contains("db=[redacted]", redacted); + Assert.DoesNotContain("/Users/example/project/.cdidx/codeindex.db", redacted); + } + + [Fact] + public void RedactSensitiveFields_RedactsConfigPathLine() + { + var redacted = ReportCommandRunner.RedactSensitiveFields( + "2026-05-16T03:00:00Z [INFO] config_file_loaded path=/Users/example/project/.cdidx/config.json"); + + Assert.Contains("path=[redacted]", redacted); + Assert.DoesNotContain("/Users/example/project/.cdidx/config.json", redacted); + } + [Fact] public void RedactSensitiveFields_RedactsArgsLine() { From 185a39f994ccb9ff70ab43537a611a536fd629df Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 10:32:50 +0900 Subject: [PATCH 3/3] Harden report bundle permissions (#2841) --- USER_GUIDE.md | 4 +- changelog.d/unreleased/2841.security.md | 17 ++++++ src/CodeIndex/Cli/ReportCommandRunner.cs | 20 ++++++- .../ReportCommandRunnerTests.cs | 59 +++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/2841.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 30e6c70c58..a8ae7a76aa 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1071,7 +1071,7 @@ cdidx report --output report.tgz --json | Flag | Default | Effect | |---|---|---| -| `--output ` / `-o ` | (required) | Destination `.tar.gz`. The directory is created if missing. | +| `--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. | | `--log-lines ` | `200` | How many trailing lifecycle-log lines to include (`0` disables the tail). | | `--no-log` | | Skip the lifecycle log entirely. | @@ -3176,7 +3176,7 @@ cdidx report --output report.tgz --json | フラグ | 既定値 | 効果 | |---|---|---| -| `--output ` / `-o ` | (必須) | 出力先 `.tar.gz`。親ディレクトリが無ければ作成します。 | +| `--output ` / `-o ` | (必須) | 出力先 `.tar.gz`。親ディレクトリが無ければ作成します。POSIX では archive と tar entry は owner の読み書きのみになります。 | | `--db ` | `.cdidx/codeindex.db` | スキーマ要約対象の DB を上書きします。存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。 | | `--log-lines ` | `200` | ライフサイクルログ末尾を何行含めるか(`0` で末尾を含めません)。 | | `--no-log` | | ライフサイクルログを完全に省略します。 | diff --git a/changelog.d/unreleased/2841.security.md b/changelog.d/unreleased/2841.security.md new file mode 100644 index 0000000000..71e0e11548 --- /dev/null +++ b/changelog.d/unreleased/2841.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2841 +affected: + - src/CodeIndex/Cli/ReportCommandRunner.cs + - tests/CodeIndex.Tests/ReportCommandRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Report bundles now use owner-only permissions (#2841)** - `cdidx report` creates support archives and tar entries with user-read/write permissions only on POSIX filesystems. + +## 日本語 + +- **report bundle が owner-only permission を使うようになりました (#2841)** - `cdidx report` は POSIX ファイルシステム上で、サポート用 archive と tar entry を user の読み書きのみの permission で作成します。 diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index a9c3222ded..ee1a7dfb70 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -22,6 +22,7 @@ public static class ReportCommandRunner { internal const int DefaultLogLines = 200; internal const string RedactedPlaceholder = "[redacted]"; + internal const UnixFileMode BundleFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, string? appVersion = null) { @@ -314,7 +315,22 @@ private static void WriteBundle(string outputPath, ReportBundle bundle) if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + if (!OperatingSystem.IsWindows() && File.Exists(outputPath)) + File.SetUnixFileMode(outputPath, BundleFileMode); + + var streamOptions = new FileStreamOptions + { + Mode = FileMode.Create, + Access = FileAccess.Write, + Share = FileShare.None, + }; + if (!OperatingSystem.IsWindows()) + streamOptions.UnixCreateMode = BundleFileMode; + + using var fileStream = new FileStream(outputPath, streamOptions); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(outputPath, BundleFileMode); + using var gz = new GZipStream(fileStream, CompressionLevel.Optimal); using var tar = new TarWriter(gz, TarEntryFormat.Pax, leaveOpen: true); @@ -323,7 +339,7 @@ private static void WriteBundle(string outputPath, ReportBundle bundle) var entry = new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = new MemoryStream(bytes, writable: false), - Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead, + Mode = BundleFileMode, ModificationTime = DateTimeOffset.UtcNow, }; tar.WriteEntry(entry); diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index b3aa5b1154..6580ba0eaa 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -20,6 +20,17 @@ namespace CodeIndex.Tests; [Collection("SQLite pool sensitive")] public class ReportCommandRunnerTests { + private const UnixFileMode PermissionBits = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupWrite | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherWrite | + UnixFileMode.OtherExecute; + private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -151,6 +162,39 @@ public void Run_NoDbAndNoLog_StillProducesBundleWithMetadata() } } + [Fact] + public void Run_OutputArchiveAndEntriesUseOwnerOnlyPermissions() + { + var workDir = CreateWorkDir(); + try + { + var output = Path.Combine(workDir, "bundle.tgz"); + + var (exitCode, _, _) = RunAndCaptureStreams([ + "--output", output, + "--db", Path.Combine(workDir, "missing.db"), + "--no-log", + ]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + if (!OperatingSystem.IsWindows()) + { + var fileMode = File.GetUnixFileMode(output) & PermissionBits; + Assert.Equal(ReportCommandRunner.BundleFileMode, fileMode); + } + + var entryModes = ReadTarGzEntryModes(output); + Assert.NotEmpty(entryModes); + Assert.All( + entryModes.Values, + mode => Assert.Equal(ReportCommandRunner.BundleFileMode, mode & PermissionBits)); + } + finally + { + TryDeleteDirectory(workDir); + } + } + [Fact] public void Run_WithRealDb_SchemaTxtListsTablesAndRowCounts() { @@ -470,4 +514,19 @@ private static Dictionary ReadTarGzEntries(string path) } return entries; } + + private static Dictionary ReadTarGzEntryModes(string path) + { + var entries = new Dictionary(StringComparer.Ordinal); + using var fileStream = File.OpenRead(path); + using var gz = new GZipStream(fileStream, CompressionMode.Decompress); + using var tar = new TarReader(gz); + while (tar.GetNextEntry() is { } entry) + { + if (entry.EntryType != TarEntryType.RegularFile) + continue; + entries[entry.Name] = entry.Mode; + } + return entries; + } }