diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 89de1b3..ab281ae 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -65,6 +65,18 @@ "depNameTemplate": "anomalyco/opencode", "versioningTemplate": "semver" }, + { + "customType": "regex", + "fileMatch": [ + "^tools/lockfiles/uv\\.lock\\.json$" + ], + "matchStrings": [ + "\"version\":\\s*\"(?[^\"]+)\"" + ], + "datasourceTemplate": "github-releases", + "depNameTemplate": "astral-sh/uv", + "versioningTemplate": "semver" + }, { "customType": "regex", "fileMatch": [ diff --git a/docs/README.md b/docs/README.md index 20f8a09..98eab8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,7 +81,7 @@ To support proxy environments, environment variables are set in the [`Dockerfile ## Tests -After an image build, tests check that each tool expected to be in the image is installed with the specified version for [pre-existing features](../src/s-core-devcontainer/test-project/test.sh), the [S-CORE feature](../src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh) and the [bazel feature](../src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh). +After an image build, tests check that each tool expected to be in the image is installed with the specified version for [pre-existing features](../src/s-core-devcontainer/test-project/test.sh), the [system CA trust store](../src/s-core-devcontainer/test-project/ca_certificates.sh), the [S-CORE feature](../src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh) and the [bazel feature](../src/s-core-devcontainer/.devcontainer/bazel-feature/tests/test_default.sh). This may seem overly complex at first, but prevents (1) accidentially wrong versions, (2) completely broken tools that cannot even execute. Both cases can happen and have happened in the past already, e.g. due to unexpected interactions between devcontainer features. diff --git a/src/s-core-devcontainer/.devcontainer/Dockerfile b/src/s-core-devcontainer/.devcontainer/Dockerfile index 2ba2494..ea50cde 100644 --- a/src/s-core-devcontainer/.devcontainer/Dockerfile +++ b/src/s-core-devcontainer/.devcontainer/Dockerfile @@ -16,6 +16,17 @@ FROM buildpack-deps:noble-curl LABEL dev.containers.features="common" +# Refresh the system trust store before anything else downloads over TLS. The +# base image carries the CA bundle from its own build date, and the +# common-utils feature deliberately runs with `upgradePackages: false`, so a +# distrusted CA would otherwise stay trusted for the life of the image. +# Deliberately unpinned: a pinned trust store is a stale trust store. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates --fresh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + COPY tools /usr/local/share/score-tools RUN userdel -f -r ubuntu diff --git a/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile b/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile index ab546e9..430371e 100644 --- a/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile +++ b/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile @@ -32,6 +32,17 @@ ENV no_proxy=${no_proxy} LABEL dev.containers.features="common" +# Refresh the system trust store before anything else downloads over TLS. The +# base image carries the CA bundle from its own build date, and the +# common-utils feature deliberately runs with `upgradePackages: false`, so a +# distrusted CA would otherwise stay trusted for the life of the image. +# Deliberately unpinned: a pinned trust store is a stale trust store. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && update-ca-certificates --fresh \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + # Unset proxy variables for all login shells COPY src/s-core-devcontainer/.devcontainer/unset-proxy.sh /etc/bash_completion.d/unset-proxy.sh COPY tools /usr/local/share/score-tools diff --git a/src/s-core-devcontainer/test-project/ca_certificates.sh b/src/s-core-devcontainer/test-project/ca_certificates.sh new file mode 100755 index 0000000..38daacf --- /dev/null +++ b/src/s-core-devcontainer/test-project/ca_certificates.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash + +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# Sourced by test.sh, which provides the `check` helper. + +# System trust store +# +# The October 2026 eMudhra incident compromised intermediate CA private keys +# chaining to "emSign ECC Root CA - G3"; Ubuntu distrusts that root as of +# ca-certificates 20260816ubuntu0.24.04.1. The Dockerfile upgrades the +# package, but image builds run with `--cache-from`, so a layer assembled +# before that upgrade could still be reused. Asserting the outcome here means +# a stale trust store fails CI instead of being published. +distrusted_root_cn="emSign ECC Root CA - G3" +distrusted_root_sha256="86:A1:EC:BA:08:9C:4A:8D:3B:BE:27:34:C6:12:BA:34:1D:81:3E:04:3C:F9:E8:A8:62:CD:5C:57:A3:6B:BE:6B" + +# Prove the bundle is readable first; otherwise the absence check below would +# pass simply because nothing could be parsed out of it. +check "validate the system CA bundle is present and parsable" bash -c \ + "[ \"\$(openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -c '^subject=')\" -gt 50 ]" +check "validate the compromised ${distrusted_root_cn} is not in the system CA bundle" bash -c \ + "! openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -qF '${distrusted_root_cn}'" + +# Bazel runs its JVM against this keystore (see the bazel feature's +# /etc/bazel.bazelrc), so it needs the same guarantee as the OpenSSL bundle. +# Matched on fingerprint because the keytool alias is derived from a filename. +# As above, prove the keystore is readable first, so a missing or corrupt +# keystore cannot make the absence check pass. +check "validate the Java keystore is present and parsable" bash -c \ + "[ \"\$(keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -c 'trustedCertEntry')\" -gt 50 ]" +check "validate the compromised ${distrusted_root_cn} is not in the Java keystore" bash -c \ + "! keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -qiF '${distrusted_root_sha256}'" diff --git a/src/s-core-devcontainer/test-project/test.sh b/src/s-core-devcontainer/test-project/test.sh index cc13e4b..10cdfa6 100755 --- a/src/s-core-devcontainer/test-project/test.sh +++ b/src/s-core-devcontainer/test-project/test.sh @@ -26,6 +26,10 @@ check "validate clang-format is working and has the correct version" bash -c "cl check "validate clang-tidy is working and has the correct version" bash -c "clang-tidy --version | grep '20.1.8'" check "validate clang is working and has the correct version" bash -c "clang --version | grep '20.1.8'" +# System trust store. Verifies the Dockerfile's CA refresh, so it is sourced +# explicitly here rather than being discovered; forgetting this line skips it. +source "${SCRIPT_DIR}/ca_certificates.sh" + # Tests from the local s-core-local feature source /devcontainer/features/s-core-local/tests/test_default.sh diff --git a/tools/README.md b/tools/README.md index f3485a3..86d7959 100644 --- a/tools/README.md +++ b/tools/README.md @@ -44,12 +44,12 @@ host-side Bazel invocation may require network access to download the binary. | `bazelisk` | `1.27.0` | Run the Bazel version selected by a repository | | `buildifier` | `8.2.1` | Format and lint Bazel files | | `opencode` | `1.18.31` | Run the OpenCode CLI | -| `pre-commit` | `4.5.1` | Run repository pre-commit hooks | +| `pre-commit` | `4.7.0` | Run repository pre-commit hooks | | `ruff` | `0.11.13` | Check and format Python code | | `shellcheck` | `0.10.0` | Check shell scripts | | `starpls` | `0.1.22` | Provide language-server support for Starlark | -| `uv` | `0.10.4` | Manage Python projects and packages | -| `uvx` | `0.10.4` | Run Python tools in isolated environments | +| `uv` | `0.13.5` | Manage Python projects and packages | +| `uvx` | `0.13.5` | Run Python tools in isolated environments | | `yamlfmt` | `0.17.0` | Format YAML files | diff --git a/tools/internal/refresh_lockfile_digests.py b/tools/internal/refresh_lockfile_digests.py index 8eb9004..aef78c2 100755 --- a/tools/internal/refresh_lockfile_digests.py +++ b/tools/internal/refresh_lockfile_digests.py @@ -49,16 +49,35 @@ def _api_get(url, token=None): return json.load(resp) -def _asset_name(url): - """Extract the asset filename from a GitHub release download URL.""" - return url.rsplit("/", 1)[-1] +def _parse_release_url(url): + """Split a GitHub release download URL into its components. + + Layout: https://github.com///releases/download// + Returns ``(owner, repo, tag, asset)`` or ``None`` if the URL does not match. + """ + parts = url.split("/") + if len(parts) != 9 or parts[2] != "github.com" or parts[5] != "releases": + return None + return parts[3], parts[4], parts[7], parts[8] + + +def _release_digests(owner, repo, tag, token, cache): + """Return ``{asset name: digest}`` for a release, fetching each one once.""" + key = (owner, repo, tag) + if key not in cache: + release = _api_get( + f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/{tag}", token + ) + cache[key] = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} + return cache[key] -def refresh_lockfile(path, token=None): +def refresh_lockfile(path, token=None, cache=None): """Refresh one lockfile in place. Returns a list of changed binary names.""" with open(path, encoding="utf-8") as f: data = json.load(f) + cache = {} if cache is None else cache changed = [] for tool_name, tool in data.items(): if not isinstance(tool, dict) or "binaries" not in tool: @@ -67,36 +86,40 @@ def refresh_lockfile(path, token=None): if not version: continue - # Derive the GitHub repo from the first binary URL: - # https://github.com///releases/download/v/ first_url = tool["binaries"][0].get("url", "") - parts = first_url.split("/") - if len(parts) < 8 or parts[2] != "github.com" or parts[5] != "releases": + parsed = _parse_release_url(first_url) + if parsed is None: print( f" ! {tool_name}: cannot derive repo from {first_url!r}", file=sys.stderr, ) continue - owner, repo = parts[3], parts[4] + owner, repo, first_tag, _ = parsed - release = _api_get( - f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/v{version}", token - ) - digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} + # Release tags are not uniformly prefixed: most projects publish + # `v1.27.0`, while the astral-sh ones (uv, ruff) publish a bare + # `0.13.5`. Reuse the prefix the pinned URL already encodes; assuming + # `v` here would rewrite those tools to a nonexistent tag. + tag = ("v" if first_tag.startswith("v") else "") + version + + digests = _release_digests(owner, repo, tag, token, cache) for binary in tool["binaries"]: - asset = _asset_name(binary.get("url", "")) + binary_parts = _parse_release_url(binary.get("url", "")) + asset = binary_parts[3] if binary_parts else None digest = digests.get(asset) if not digest: print( - f" ! {tool_name}: no digest for asset {asset!r} in v{version}", + f" ! {tool_name}: no digest for asset {asset!r} in {tag}", file=sys.stderr, ) continue sha = digest.split(":", 1)[1] if digest.startswith("sha256:") else digest # Rebuild the URL from the version so the file is consistent # whether or not Renovate already updated the URL. - new_url = f"https://github.com/{owner}/{repo}/releases/download/v{version}/{asset}" + new_url = ( + f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset}" + ) if binary.get("sha256") != sha or binary.get("url") != new_url: binary["url"] = new_url binary["sha256"] = sha @@ -120,11 +143,12 @@ def main(argv): if name.endswith(".lock.json") ] + cache = {} failed = False for path in argv[1:]: print(f"Refreshing {path}") try: - changed = refresh_lockfile(path, token) + changed = refresh_lockfile(path, token, cache) except Exception as exc: print(f" ! failed: {exc}", file=sys.stderr) failed = True diff --git a/tools/lockfiles/python_tools.bzl b/tools/lockfiles/python_tools.bzl index 216a52b..262d40b 100644 --- a/tools/lockfiles/python_tools.bzl +++ b/tools/lockfiles/python_tools.bzl @@ -21,7 +21,7 @@ PYTHON_TOOLS = { # users. They are separate because Python distributions may name them # differently. "package": "pre-commit", - "version": "4.5.1", + "version": "4.7.0", "entrypoint": "pre-commit", # Documentation is generated from the catalog so version and purpose # are reviewed together. diff --git a/tools/lockfiles/uv.lock.json b/tools/lockfiles/uv.lock.json index 5ab2f8f..b43edbd 100644 --- a/tools/lockfiles/uv.lock.json +++ b/tools/lockfiles/uv.lock.json @@ -1,14 +1,14 @@ { "$schema": "https://raw.githubusercontent.com/bazel-contrib/rules_multitool/main/lockfile.schema.json", "uv": { - "version": "0.10.4", + "version": "0.13.5", "description": "Manage Python projects and packages", "binaries": [ { "kind": "archive", "file": "uv-aarch64-apple-darwin/uv", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-aarch64-apple-darwin.tar.gz", - "sha256": "a6852e4dc565c8fedcf5adcdf09fca7caf5347739bed512bd95b15dada36db51", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-apple-darwin.tar.gz", + "sha256": "743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73", "type": "tar.gz", "os": "macos", "cpu": "arm64" @@ -16,8 +16,8 @@ { "kind": "archive", "file": "uv-x86_64-unknown-linux-gnu/uv", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "6b52a47358deea1c5e173278bf46b2b489747a59ae31f2a4362ed5c6c1c269f7", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f", "type": "tar.gz", "os": "linux", "cpu": "x86_64" @@ -25,8 +25,8 @@ { "kind": "archive", "file": "uv-aarch64-unknown-linux-gnu/uv", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-aarch64-unknown-linux-gnu.tar.gz", - "sha256": "c84a6e6405715caa6e2f5ef8e5f29a5d0bc558a954e9f1b5c082b9d4708c222e", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403", "type": "tar.gz", "os": "linux", "cpu": "arm64" @@ -34,14 +34,14 @@ ] }, "uvx": { - "version": "0.10.4", + "version": "0.13.5", "description": "Run Python tools in isolated environments", "binaries": [ { "kind": "archive", "file": "uv-aarch64-apple-darwin/uvx", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-aarch64-apple-darwin.tar.gz", - "sha256": "a6852e4dc565c8fedcf5adcdf09fca7caf5347739bed512bd95b15dada36db51", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-apple-darwin.tar.gz", + "sha256": "743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73", "type": "tar.gz", "os": "macos", "cpu": "arm64" @@ -49,8 +49,8 @@ { "kind": "archive", "file": "uv-x86_64-unknown-linux-gnu/uvx", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-x86_64-unknown-linux-gnu.tar.gz", - "sha256": "6b52a47358deea1c5e173278bf46b2b489747a59ae31f2a4362ed5c6c1c269f7", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-gnu.tar.gz", + "sha256": "f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f", "type": "tar.gz", "os": "linux", "cpu": "x86_64" @@ -58,8 +58,8 @@ { "kind": "archive", "file": "uv-aarch64-unknown-linux-gnu/uvx", - "url": "https://github.com/astral-sh/uv/releases/download/0.10.4/uv-aarch64-unknown-linux-gnu.tar.gz", - "sha256": "c84a6e6405715caa6e2f5ef8e5f29a5d0bc558a954e9f1b5c082b9d4708c222e", + "url": "https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-unknown-linux-gnu.tar.gz", + "sha256": "4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403", "type": "tar.gz", "os": "linux", "cpu": "arm64" diff --git a/tools/tests/python_tool_runner_test.sh b/tools/tests/python_tool_runner_test.sh index 91fae20..74f1f91 100755 --- a/tools/tests/python_tool_runner_test.sh +++ b/tools/tests/python_tool_runner_test.sh @@ -53,7 +53,7 @@ chmod +x "${fake_uvx}" # The explicit assertion verifies the requested release. Subsequent checks use # the parsed value to keep every expected uv argument aligned with the catalog. catalog_version="$("${installer}" version pre-commit)" -[[ "${catalog_version}" = "4.5.1" ]] +[[ "${catalog_version}" = "4.7.0" ]] # The Bazel launcher receives an execroot-relative uvx path, then changes to the # caller's workspace. This catches regressions where that path breaks after cd.