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
21 changes: 11 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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' *)
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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` で入れたものは次のコマンドで削除できます。

Expand Down
18 changes: 18 additions & 0 deletions changelog.d/unreleased/3004.security.md
Original file line number Diff line number Diff line change
@@ -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 を照合します。
93 changes: 90 additions & 3 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -3027,6 +3040,7 @@ internal static int RunUpgrade(
CancellationToken.None)
.GetAwaiter()
.GetResult();
VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName);
}

var startInfo = CreateInstallerProcessStartInfo(scriptPath, result.LatestVersion, installDir);
Expand Down Expand Up @@ -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<string> 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,
Expand Down
94 changes: 92 additions & 2 deletions tests/CodeIndex.Tests/ProgramRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<InvalidDataException>(() =>
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<InvalidDataException>(() =>
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()
{
Expand Down Expand Up @@ -777,6 +849,24 @@ await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
}
}

[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<InvalidDataException>(() =>
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()
{
Expand Down
Loading