From b6f266abb037c44ebd82b78be0700a590c83ef2b Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:40:17 +0200 Subject: [PATCH 1/7] emudhra ca certificate update --- .github/renovate.json5 | 15 ++++++++++ .../.devcontainer/Dockerfile | 11 ++++++++ .../.devcontainer/bazel-feature/install.sh | 14 ++++++++-- .../.devcontainer/s-core-local/install.sh | 14 ++++++++-- .../s-core-local/tests/test_default.sh | 28 +++++++++++++++++++ .../.devcontainer/with-proxy-vars.Dockerfile | 11 ++++++++ tools/README.md | 6 ++-- tools/internal/refresh_lockfile_digests.py | 17 ++++++++--- tools/lockfiles/python_tools.bzl | 2 +- tools/lockfiles/uv.lock.json | 28 +++++++++---------- tools/tests/python_tool_runner_test.sh | 2 +- 11 files changed, 121 insertions(+), 27 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 650bff24..ddf79c67 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -64,6 +64,21 @@ "depNameTemplate": "anomalyco/opencode", "versioningTemplate": "semver" }, + { + // uv ships the TLS stack used by every Bazel and DevContainer Python + // tool, so it must track upstream security releases rather than being + // bumped by hand. Both the `uv` and `uvx` entries share one version. + "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/src/s-core-devcontainer/.devcontainer/Dockerfile b/src/s-core-devcontainer/.devcontainer/Dockerfile index 2ba2494a..ea50cde0 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/bazel-feature/install.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh index ea34c32c..71cc7fce 100755 --- a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh +++ b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh @@ -28,13 +28,23 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe # used by apt-get only inside this script DEBIAN_FRONTEND=noninteractive +apt-get update + +# Refresh the system trust store before the first TLS download. This must come +# before load_feature_versions.sh, which fetches yq over HTTPS, so every +# download in this script validates against a current set of roots. The feature +# is also usable standalone on a base image this repository does not control, +# so it cannot rely on the Dockerfile having done this already. +# Deliberately not pinned in versions.yaml: a pinned trust store is a stale +# trust store, and it has to be current before versions.yaml can even be read. +apt-get install -y --no-install-recommends ca-certificates +update-ca-certificates --fresh + # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/bazel/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) -apt-get update - # INSTALL CONTAINER BUILD DEPENDENCIES # Container build dependencies are not pinned, since they are removed anyway after container creation. apt-get install apt-transport-https -y diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh index 8e3ded87..02588835 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh @@ -30,14 +30,24 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe # used by apt-get only inside this script DEBIAN_FRONTEND=noninteractive +apt-get update + +# Refresh the system trust store before the first TLS download. This must come +# before load_feature_versions.sh, which fetches yq over HTTPS, so every +# download in this script validates against a current set of roots. The base +# image carries the CA bundle from its own build date and this feature runs +# with `upgradePackages: false`, so a distrusted CA would otherwise persist. +# Deliberately not pinned in versions.yaml: a pinned trust store is a stale +# trust store, and it has to be current before versions.yaml can even be read. +apt-get install -y --no-install-recommends ca-certificates +update-ca-certificates --fresh + # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) KERNEL=$(uname -s) -apt-get update - # Unminimize the image to include standard packages like man pages bash -c "yes || true" | unminimize apt-get install -y man-db manpages manpages-dev manpages-posix manpages-posix-dev diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh index f83eb4ad..146e8f0c 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh @@ -21,6 +21,30 @@ KERNEL=$(uname -s) # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml +# 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 install step 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. +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}'" + shellcheck_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version shellcheck)" ruff_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version ruff)" actionlint_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version actionlint)" @@ -87,6 +111,10 @@ check "validate opencode is working and has the correct version" bash -c "openco # uv check "validate uv is working and has the correct version" bash -c "uv --version | grep '${uv_lockfile_version}'" check "validate uvx is working and has the correct version" bash -c "uvx --version | grep '${uvx_lockfile_version}'" +# Both ship from one upstream release and one archive. Renovate matches the two +# entries in uv.lock.json separately, so a partial bump would otherwise leave +# them silently pinned to different releases. +check "validate uv and uvx are pinned to the same release" bash -c "[ '${uv_lockfile_version}' = '${uvx_lockfile_version}' ]" # pycairo build from source (requires pkg-config and libcairo2-dev) check "validate pycairo can be built and installed from source" bash -c "pip install --break-system-packages pycairo==1.29.0" diff --git a/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile b/src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile index ab546e9f..430371eb 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/tools/README.md b/tools/README.md index c294ec75..78025c1e 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 8eb90045..d79de5e1 100755 --- a/tools/internal/refresh_lockfile_digests.py +++ b/tools/internal/refresh_lockfile_digests.py @@ -68,10 +68,10 @@ def refresh_lockfile(path, token=None): continue # Derive the GitHub repo from the first binary URL: - # https://github.com///releases/download/v/ + # https://github.com///releases/download// first_url = tool["binaries"][0].get("url", "") parts = first_url.split("/") - if len(parts) < 8 or parts[2] != "github.com" or parts[5] != "releases": + if len(parts) < 9 or parts[2] != "github.com" or parts[5] != "releases": print( f" ! {tool_name}: cannot derive repo from {first_url!r}", file=sys.stderr, @@ -79,8 +79,15 @@ def refresh_lockfile(path, token=None): continue owner, repo = parts[3], parts[4] + # 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, because + # Renovate rewrites the version inside that URL but never the prefix. + # Assuming `v` here silently rewrote those tools to a nonexistent tag. + tag = ("v" if parts[7].startswith("v") else "") + version + release = _api_get( - f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/v{version}", token + f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/{tag}", token ) digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} @@ -96,7 +103,9 @@ def refresh_lockfile(path, token=None): 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 diff --git a/tools/lockfiles/python_tools.bzl b/tools/lockfiles/python_tools.bzl index 216a52b3..262d40b0 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 5ab2f8fb..b43edbd9 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 91fae20f..74f1f91a 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. From a140216401189493fc858a2954ed81eb31035056 Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:08:42 +0100 Subject: [PATCH 2/7] address review: drop CA refresh from feature install scripts, remove inaccurate Renovate comments Co-Authored-By: Claude Sonnet 5.5 --- .github/renovate.json5 | 3 --- .../.devcontainer/bazel-feature/install.sh | 14 ++------------ .../.devcontainer/s-core-local/install.sh | 14 ++------------ .../s-core-local/tests/test_default.sh | 6 +----- 4 files changed, 5 insertions(+), 32 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index ddf79c67..9eaa7eb0 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -65,9 +65,6 @@ "versioningTemplate": "semver" }, { - // uv ships the TLS stack used by every Bazel and DevContainer Python - // tool, so it must track upstream security releases rather than being - // bumped by hand. Both the `uv` and `uvx` entries share one version. "customType": "regex", "fileMatch": [ "^tools/lockfiles/uv\\.lock\\.json$" diff --git a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh index 71cc7fce..ea34c32c 100755 --- a/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh +++ b/src/s-core-devcontainer/.devcontainer/bazel-feature/install.sh @@ -28,23 +28,13 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe # used by apt-get only inside this script DEBIAN_FRONTEND=noninteractive -apt-get update - -# Refresh the system trust store before the first TLS download. This must come -# before load_feature_versions.sh, which fetches yq over HTTPS, so every -# download in this script validates against a current set of roots. The feature -# is also usable standalone on a base image this repository does not control, -# so it cannot rely on the Dockerfile having done this already. -# Deliberately not pinned in versions.yaml: a pinned trust store is a stale -# trust store, and it has to be current before versions.yaml can even be read. -apt-get install -y --no-install-recommends ca-certificates -update-ca-certificates --fresh - # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/bazel/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) +apt-get update + # INSTALL CONTAINER BUILD DEPENDENCIES # Container build dependencies are not pinned, since they are removed anyway after container creation. apt-get install apt-transport-https -y diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh index 02588835..8e3ded87 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/install.sh @@ -30,24 +30,14 @@ rm -f "${COPY_TARGET}/devcontainer-features.env" "${COPY_TARGET}/devcontainer-fe # used by apt-get only inside this script DEBIAN_FRONTEND=noninteractive -apt-get update - -# Refresh the system trust store before the first TLS download. This must come -# before load_feature_versions.sh, which fetches yq over HTTPS, so every -# download in this script validates against a current set of roots. The base -# image carries the CA bundle from its own build date and this feature runs -# with `upgradePackages: false`, so a distrusted CA would otherwise persist. -# Deliberately not pinned in versions.yaml: a pinned trust store is a stale -# trust store, and it has to be current before versions.yaml can even be read. -apt-get install -y --no-install-recommends ca-certificates -update-ca-certificates --fresh - # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml ARCHITECTURE=$(dpkg --print-architecture) KERNEL=$(uname -s) +apt-get update + # Unminimize the image to include standard packages like man pages bash -c "yes || true" | unminimize apt-get install -y man-db manpages manpages-dev manpages-posix manpages-posix-dev diff --git a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh index 146e8f0c..72578e71 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh @@ -25,7 +25,7 @@ KERNEL=$(uname -s) # # 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 install step upgrades the +# 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. @@ -111,10 +111,6 @@ check "validate opencode is working and has the correct version" bash -c "openco # uv check "validate uv is working and has the correct version" bash -c "uv --version | grep '${uv_lockfile_version}'" check "validate uvx is working and has the correct version" bash -c "uvx --version | grep '${uvx_lockfile_version}'" -# Both ship from one upstream release and one archive. Renovate matches the two -# entries in uv.lock.json separately, so a partial bump would otherwise leave -# them silently pinned to different releases. -check "validate uv and uvx are pinned to the same release" bash -c "[ '${uv_lockfile_version}' = '${uvx_lockfile_version}' ]" # pycairo build from source (requires pkg-config and libcairo2-dev) check "validate pycairo can be built and installed from source" bash -c "pip install --break-system-packages pycairo==1.29.0" From e0bc49efe36f31eb73f3b128b0f1d8ddd124630c Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:57:43 +0100 Subject: [PATCH 3/7] test: move CA trust store checks into dedicated ca_certificates.sh The checks verify the Dockerfile's CA refresh, not the s-core-local feature. Co-Authored-By: Claude Sonnet 5.5 --- docs/README.md | 2 +- .../s-core-local/tests/test_default.sh | 24 ----------- .../test-project/ca_certificates.sh | 40 +++++++++++++++++++ src/s-core-devcontainer/test-project/test.sh | 4 ++ 4 files changed, 45 insertions(+), 25 deletions(-) create mode 100755 src/s-core-devcontainer/test-project/ca_certificates.sh diff --git a/docs/README.md b/docs/README.md index 20f8a092..98eab8e4 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/s-core-local/tests/test_default.sh b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh index 72578e71..f83eb4ad 100755 --- a/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh +++ b/src/s-core-devcontainer/.devcontainer/s-core-local/tests/test_default.sh @@ -21,30 +21,6 @@ KERNEL=$(uname -s) # Read tool versions + metadata into environment variables . /usr/local/share/score-tools/internal/devcontainer/load_feature_versions.sh /devcontainer/features/s-core-local/versions.yaml -# 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. -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}'" - shellcheck_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version shellcheck)" ruff_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version ruff)" actionlint_lockfile_version="$(/usr/local/share/score-tools/internal/devcontainer/install.py version actionlint)" 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 00000000..082bf242 --- /dev/null +++ b/src/s-core-devcontainer/test-project/ca_certificates.sh @@ -0,0 +1,40 @@ +#!/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. +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 cc13e4b5..10cdfa6d 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 From 617594afe43a62001bf8c39e976d0f5f101106e7 Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:02:44 +0100 Subject: [PATCH 4/7] fix: drop inaccurate Renovate claim from tag prefix comment Co-Authored-By: Claude Sonnet 5.5 --- tools/internal/refresh_lockfile_digests.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/internal/refresh_lockfile_digests.py b/tools/internal/refresh_lockfile_digests.py index d79de5e1..7158abc6 100755 --- a/tools/internal/refresh_lockfile_digests.py +++ b/tools/internal/refresh_lockfile_digests.py @@ -81,9 +81,8 @@ def refresh_lockfile(path, token=None): # 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, because - # Renovate rewrites the version inside that URL but never the prefix. - # Assuming `v` here silently rewrote those tools to a nonexistent tag. + # `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 parts[7].startswith("v") else "") + version release = _api_get( From cb582d8d49aa124dc13ac0170c064f1bae90feb8 Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:11:28 +0100 Subject: [PATCH 5/7] refactor: dedupe release URL parsing and release fetches in refresh script Co-Authored-By: Claude Sonnet 5.5 --- tools/internal/refresh_lockfile_digests.py | 52 +++++++++++++++------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/tools/internal/refresh_lockfile_digests.py b/tools/internal/refresh_lockfile_digests.py index 7158abc6..44df02e7 100755 --- a/tools/internal/refresh_lockfile_digests.py +++ b/tools/internal/refresh_lockfile_digests.py @@ -49,16 +49,37 @@ 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,35 +88,31 @@ def refresh_lockfile(path, token=None): if not version: continue - # Derive the GitHub repo from the first binary URL: - # https://github.com///releases/download// first_url = tool["binaries"][0].get("url", "") - parts = first_url.split("/") - if len(parts) < 9 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 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 parts[7].startswith("v") else "") + version + tag = ("v" if first_tag.startswith("v") else "") + version - release = _api_get( - f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/{tag}", token - ) - digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} + 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 @@ -128,11 +145,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 From 9ab1c9c96fb0e426065a79439a170ad12e4ff2eb Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:07:00 +0100 Subject: [PATCH 6/7] test: require Java keystore to be readable before asserting absence Co-Authored-By: Claude Sonnet 5.5 --- src/s-core-devcontainer/test-project/ca_certificates.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/s-core-devcontainer/test-project/ca_certificates.sh b/src/s-core-devcontainer/test-project/ca_certificates.sh index 082bf242..38daacf8 100755 --- a/src/s-core-devcontainer/test-project/ca_certificates.sh +++ b/src/s-core-devcontainer/test-project/ca_certificates.sh @@ -36,5 +36,9 @@ check "validate the compromised ${distrusted_root_cn} is not in the system CA bu # 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}'" From 6701d5ee5ecb9a96ee4935aeff60eed40120500d Mon Sep 17 00:00:00 2001 From: Nick Hildebrant <234180643+nick-hildebrant-etas@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:10:28 +0100 Subject: [PATCH 7/7] style: apply ruff format Co-Authored-By: Claude Sonnet 5.5 --- tools/internal/refresh_lockfile_digests.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tools/internal/refresh_lockfile_digests.py b/tools/internal/refresh_lockfile_digests.py index 44df02e7..aef78c20 100755 --- a/tools/internal/refresh_lockfile_digests.py +++ b/tools/internal/refresh_lockfile_digests.py @@ -68,9 +68,7 @@ def _release_digests(owner, repo, tag, token, 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", []) - } + cache[key] = {a["name"]: a.get("digest", "") for a in release.get("assets", [])} return cache[key]