From a26ce6ac553f08a52d3915b5d76598308200ed20 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 13:51:26 +0900 Subject: [PATCH] Verify upgrade installer script checksum (#3004) --- .github/workflows/release.yml | 21 ++--- README.md | 11 ++- changelog.d/unreleased/3004.security.md | 18 ++++ src/CodeIndex/Cli/ProgramRunner.cs | 93 +++++++++++++++++++- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 94 ++++++++++++++++++++- 5 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unreleased/3004.security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c84959179..f3dd0fcb51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -440,16 +440,16 @@ jobs: - name: Collect release files run: | mkdir -p release-files - # Tarballs/zips are per-RID artifacts; the SBOM is a single - # RID-independent JSON file. Both go in release-files so - # sha256sums.txt covers the SBOM as well, letting supply-chain - # consumers verify the SBOM bytes the same way they verify the - # binaries. - # tarball/zip は RID 別の成果物、SBOM は RID 非依存の単一 JSON。 - # どちらも release-files に集約することで sha256sums.txt が SBOM の - # ハッシュもカバーし、SBOM 利用側はバイナリと同じ手順で SBOM の - # 完全性を検証できる。 + # Tarballs/zips are per-RID artifacts; the SBOM and installer script + # are RID-independent files. Put all of them in release-files so + # sha256sums.txt covers the bytes that supply-chain consumers and + # `cdidx upgrade` verify before use. + # tarball/zip は RID 別の成果物、SBOM と installer script は RID 非依存の + # ファイル。すべて release-files に集約することで sha256sums.txt が + # supply-chain consumer と `cdidx upgrade` が使用前に検証する bytes を + # カバーする。 find all-artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.cdx.json' \) -exec cp {} release-files/ \; + cp install.sh release-files/install.sh cd release-files expected_rids="linux-x64 linux-arm64 osx-arm64 win-x64 win-arm64" for rid in $expected_rids; do @@ -510,6 +510,7 @@ jobs: release-files/*.tar.gz release-files/*.zip release-files/*.cdx.json + release-files/install.sh release-files/sha256sums.txt release-files/sha256sums.txt.asc @@ -597,7 +598,7 @@ jobs: # リリースが "Failed to download sha256sums.txt ... HTTP 404" で # 失敗した。verify step が取得する全 asset をポーリングし、伝播の # 遅い asset があってもリリースを失敗させず待ち切る。 - VERIFY_ASSET_NAMES: CodeIndex-linux-x64.tar.gz sha256sums.txt sha256sums.txt.asc + VERIFY_ASSET_NAMES: CodeIndex-linux-x64.tar.gz install.sh sha256sums.txt sha256sums.txt.asc run: | set -euo pipefail mapfile -t expected_assets < <(cd release-files && printf '%s\n' *) diff --git a/README.md b/README.md index ef392c6d34..5b94fc2e59 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ downgrading `cdidx`. ### Upgrade and uninstall -`cdidx upgrade --check-only` reports whether a newer GitHub release is available. `cdidx upgrade` downloads `install.sh` from the resolved release tag, refuses unwritable install directories, and reruns the installer with `CDIDX_INSTALL_DIR` pointed at the current binary directory. +`cdidx upgrade --check-only` reports whether a newer GitHub release is available. `cdidx upgrade` downloads the release `sha256sums.txt` manifest and `install.sh` from the resolved release tag, verifies the script SHA-256 entry before execution, refuses unwritable install directories, and reruns the installer with `CDIDX_INSTALL_DIR` pointed at the current binary directory. This pins the script bytes to the GitHub release checksum manifest; operators who require signer verification should verify the release `sha256sums.txt.asc` manually before running the release `install.sh`. Direct `install.sh` installs can be removed with: @@ -482,9 +482,12 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し ### アップグレードとアンインストール `cdidx upgrade --check-only` は新しい GitHub release の有無だけを報告します。 -`cdidx upgrade` は解決済み release tag から `install.sh` を取得し、現在の binary -directory が書き込み可能か確認したうえで、`CDIDX_INSTALL_DIR` をその directory に -向けて installer を再実行します。 +`cdidx upgrade` は解決済み release tag から release の `sha256sums.txt` manifest と +`install.sh` を取得し、script の SHA-256 entry を実行前に検証します。その後、現在の +binary directory が書き込み可能か確認したうえで、`CDIDX_INSTALL_DIR` をその directory +に向けて installer を再実行します。この検証は script の bytes を GitHub release の +checksum manifest に固定します。署名者検証まで必要な運用では、release の +`sha256sums.txt.asc` を手動で検証してから release の `install.sh` を実行してください。 直接 `install.sh` で入れたものは次のコマンドで削除できます。 diff --git a/changelog.d/unreleased/3004.security.md b/changelog.d/unreleased/3004.security.md new file mode 100644 index 0000000000..126d81f8a8 --- /dev/null +++ b/changelog.d/unreleased/3004.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 3004 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - .github/workflows/release.yml + - README.md + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **`cdidx upgrade` now verifies the downloaded installer script (#3004)** — releases publish `install.sh` in the checksum manifest, and `upgrade` checks the downloaded script SHA-256 before executing it. + +## 日本語 + +- **`cdidx upgrade` がダウンロードした installer script を検証するようになりました (#3004)** — release は `install.sh` を checksum manifest に含め、`upgrade` は実行前にダウンロード済み script の SHA-256 を照合します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 6fc40d5e89..065d4582b8 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Net; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -19,8 +20,11 @@ internal static class ProgramRunner { private const int RetainedQueryTraceFileCount = 30; internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; - private const string InstallerScriptUrlTemplate = "https://raw.githubusercontent.com/Widthdom/CodeIndex/{0}/install.sh"; + private const string ReleaseAssetUrlTemplate = "https://github.com/Widthdom/CodeIndex/releases/download/{0}/{1}"; + private const string InstallerScriptAssetName = "install.sh"; + private const string ReleaseChecksumAssetName = "sha256sums.txt"; private const long MaxInstallerScriptBytes = 1024 * 1024; + internal const long MaxReleaseChecksumBytes = 256 * 1024; internal const int WorkspaceVersionPinMaxBytes = 4096; internal const int WorkspaceVersionPinMaxSkippedBlankLines = 16; internal const int WorkspaceVersionPinMaxLineChars = 256; @@ -3019,6 +3023,15 @@ internal static int RunUpgrade( { using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }) { + var checksumManifest = DownloadReleaseChecksumManifestAsync( + client, + result.LatestVersion, + TimeSpan.FromSeconds(20), + CancellationToken.None) + .GetAwaiter() + .GetResult(); + var expectedInstallerSha256 = GetReleaseAssetChecksum(checksumManifest, InstallerScriptAssetName); + DownloadInstallerScriptAsync( client, result.LatestVersion, @@ -3027,6 +3040,7 @@ internal static int RunUpgrade( CancellationToken.None) .GetAwaiter() .GetResult(); + VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName); } var startInfo = CreateInstallerProcessStartInfo(scriptPath, result.LatestVersion, installDir); @@ -3079,10 +3093,83 @@ internal static int RunInstallerProcess(ProcessStartInfo startInfo, TimeSpan tim } internal static string BuildInstallerScriptUrl(string releaseTag) + => BuildReleaseAssetUrl(releaseTag, InstallerScriptAssetName); + + internal static string BuildReleaseAssetUrl(string releaseTag, string assetName) => string.Format( CultureInfo.InvariantCulture, - InstallerScriptUrlTemplate, - Uri.EscapeDataString(releaseTag.Trim())); + ReleaseAssetUrlTemplate, + Uri.EscapeDataString(releaseTag.Trim()), + Uri.EscapeDataString(assetName)); + + internal static async Task DownloadReleaseChecksumManifestAsync( + HttpClient client, + string releaseTag, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadCts.CancelAfter(timeout); + using var request = new HttpRequestMessage(HttpMethod.Get, BuildReleaseAssetUrl(releaseTag, ReleaseChecksumAssetName)); + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + downloadCts.Token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var bytes = await BoundedHttpContentReader.ReadAsByteArrayAsync( + response.Content, + MaxReleaseChecksumBytes, + downloadCts.Token).ConfigureAwait(false); + return Encoding.UTF8.GetString(bytes); + } + + internal static string GetReleaseAssetChecksum(string checksumManifest, string assetName) + { + foreach (var rawLine in checksumManifest.Split('\n')) + { + var line = rawLine.TrimEnd('\r'); + if (line.Length < 66) + continue; + + var checksum = line[..64]; + if (!IsSha256Hex(checksum) || !char.IsWhiteSpace(line[64])) + continue; + + var fileName = line[65..].TrimStart(); + if (fileName.StartsWith('*')) + fileName = fileName[1..]; + if (string.Equals(fileName, assetName, StringComparison.Ordinal)) + return checksum.ToLowerInvariant(); + } + + throw new InvalidDataException($"Release checksum manifest does not contain {assetName}."); + } + + internal static void VerifyFileSha256(string path, string expectedSha256Hex, string assetName) + { + if (!IsSha256Hex(expectedSha256Hex)) + throw new InvalidDataException($"Release checksum for {assetName} is not a valid SHA-256 digest."); + + using var stream = File.OpenRead(path); + var actual = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + if (!string.Equals(actual, expectedSha256Hex, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException( + $"Downloaded {assetName} checksum mismatch: expected {expectedSha256Hex}, got {actual}."); + } + + private static bool IsSha256Hex(string value) + { + if (value.Length != 64) + return false; + + foreach (var ch in value) + { + if (!Uri.IsHexDigit(ch)) + return false; + } + + return true; + } internal static async Task DownloadInstallerScriptAsync( HttpClient client, diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 936da9e6d3..103db1c149 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Net; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -695,13 +696,84 @@ public void UpdateChecker_Check_PropagatesCallerCancellation() } [Theory] - [InlineData("v1.26.0", "https://raw.githubusercontent.com/Widthdom/CodeIndex/v1.26.0/install.sh")] - [InlineData(" release/test ", "https://raw.githubusercontent.com/Widthdom/CodeIndex/release%2Ftest/install.sh")] + [InlineData("v1.26.0", "https://github.com/Widthdom/CodeIndex/releases/download/v1.26.0/install.sh")] + [InlineData(" release/test ", "https://github.com/Widthdom/CodeIndex/releases/download/release%2Ftest/install.sh")] public void BuildInstallerScriptUrl_UsesResolvedReleaseTag(string releaseTag, string expected) { Assert.Equal(expected, ProgramRunner.BuildInstallerScriptUrl(releaseTag)); } + [Theory] + [InlineData("v1.26.0", "install.sh", "https://github.com/Widthdom/CodeIndex/releases/download/v1.26.0/install.sh")] + [InlineData(" release/test ", "sha256sums.txt", "https://github.com/Widthdom/CodeIndex/releases/download/release%2Ftest/sha256sums.txt")] + public void BuildReleaseAssetUrl_UsesResolvedReleaseTagAndAsset(string releaseTag, string assetName, string expected) + { + Assert.Equal(expected, ProgramRunner.BuildReleaseAssetUrl(releaseTag, assetName)); + } + + [Fact] + public void GetReleaseAssetChecksum_FindsInstallerScriptEntry() + { + var expected = new string('a', 64); + var manifest = $""" +{new string('b', 64)} CodeIndex-linux-x64.tar.gz +{expected} install.sh +"""; + + var checksum = ProgramRunner.GetReleaseAssetChecksum(manifest, "install.sh"); + + Assert.Equal(expected, checksum); + } + + [Fact] + public void GetReleaseAssetChecksum_RequiresInstallerScriptEntry() + { + var manifest = $"{new string('b', 64)} CodeIndex-linux-x64.tar.gz\n"; + + var ex = Assert.Throws(() => + ProgramRunner.GetReleaseAssetChecksum(manifest, "install.sh")); + + Assert.Contains("install.sh", ex.Message); + } + + [Fact] + public void VerifyFileSha256_AcceptsExpectedDigest() + { + var path = Path.Combine(Path.GetTempPath(), $"cdidx-install-checksum-{Guid.NewGuid():N}.sh"); + var content = Encoding.UTF8.GetBytes("#!/bin/sh\necho ok\n"); + File.WriteAllBytes(path, content); + try + { + var expected = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); + + ProgramRunner.VerifyFileSha256(path, expected, "install.sh"); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Fact] + public void VerifyFileSha256_RejectsMismatchedDigest() + { + var path = Path.Combine(Path.GetTempPath(), $"cdidx-install-checksum-{Guid.NewGuid():N}.sh"); + File.WriteAllText(path, "#!/bin/sh\necho ok\n"); + try + { + var ex = Assert.Throws(() => + ProgramRunner.VerifyFileSha256(path, new string('0', 64), "install.sh")); + + Assert.Contains("checksum mismatch", ex.Message); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + [Fact] public void CreateInstallerProcessStartInfo_UsesArgumentList() { @@ -777,6 +849,24 @@ await Assert.ThrowsAnyAsync(() => } } + [Fact] + public async Task DownloadReleaseChecksumManifestAsync_RejectsOverLimitResponse() + { + using var client = new HttpClient(new StaticResponseHandler(new ByteArrayContent(new byte[(int)ProgramRunner.MaxReleaseChecksumBytes + 1]))) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + var ex = await Assert.ThrowsAsync(() => + ProgramRunner.DownloadReleaseChecksumManifestAsync( + client, + "v1.27.0", + TimeSpan.FromSeconds(1), + CancellationToken.None)); + + Assert.Contains($"{ProgramRunner.MaxReleaseChecksumBytes} byte limit", ex.Message); + } + [Fact] public async Task UpdateChecker_ReadLatestReleaseTagAsync_ParsesTagName() {