Skip to content

Commit fb15dd8

Browse files
fix: refresh system CA trust store (eMudhra distrust) (#175)
This pull request focuses on improving the security and reliability of the system trust store handling in devcontainer builds, ensuring that TLS operations always use an up-to-date CA bundle. It also upgrades several Python tools (`uv`, `uvx`, and `pre-commit`) to their latest versions and makes related changes to lockfiles, documentation, and test coverage. Additionally, the lockfile refresh logic is improved for better compatibility with upstream release tagging conventions. response to: https://access.redhat.com/security/vulnerabilities/RHSB-2026-012 **Security and Trust Store Improvements:** - Ensured the system CA trust store is refreshed at the start of the Dockerfiles (`.devcontainer/Dockerfile`, `with-proxy-vars.Dockerfile`), before any feature runs to prevent usage of stale or distrusted CAs, with explanatory comments clarifying the rationale. [[1]](diffhunk://#diff-c41eaf5c4a8beb1e6e26cc217a10f8b09ccf5c9c52b731cb55bbe69b0268d043R19-R29) [[2]](diffhunk://#diff-a245988e16fc0a7bdeed434b983c09154e1a78dc7a08591a18079adc6fa1b046R35-R45) [[3]](diffhunk://#diff-86fdd4af8dde43a24c4493de7c4619d171f36f79e86acb219c3b74db143070c2R31-L37) [[4]](diffhunk://#diff-e418ce180663a5c3fc806f1c352a9d737097e60cecaa9cd1724c8236a955a335R33-L40) - Added integration tests (`test_default.sh`) to assert that compromised or distrusted CAs (e.g., "emSign ECC Root CA - G3") are not present in either the OpenSSL or Java trust stores, ensuring CI fails if the trust store is stale. **Python Tool Upgrades and Lockfile Consistency:** - Upgraded `uv` and `uvx` from version 0.10.4 to 0.13.5 and updated their lockfile entries. [[1]](diffhunk://#diff-04d8b8927de9ba2ef18dfda8472e1b851e931e5f3f27932e2cb69dc7bf960072L4-R62) [[2]](diffhunk://#diff-9cea6a1607559a2e7aefc9e317dbd94ee9c9c8488e24f0e86074509cd2850090L47-R52) - Upgraded `pre-commit` from version 4.5.1 to 4.7.0 in the tool catalog, lockfile, and corresponding tests. [[1]](diffhunk://#diff-64af8db65e15981354dad2dd9ca0e5bacfe9695528630bad87d41962bfd14564L24-R24) [[2]](diffhunk://#diff-9cea6a1607559a2e7aefc9e317dbd94ee9c9c8488e24f0e86074509cd2850090L47-R52) [[3]](diffhunk://#diff-00ddadc72e6f336b3dfc668282c5dcacb77cffc4ef70c087461b2f73e4aa6662L56-R56) - Added a Renovate configuration to manage `uv.lock.json` updates automatically. **Lockfile Refresh Logic Improvements:** - Updated `refresh_lockfile_digests.py` to correctly handle upstream release tags that may or may not be prefixed with `v` (e.g., `v1.27.0` vs `0.13.5`), ensuring URLs and API calls match the actual release format and preventing broken upgrades for tools like `uv` and `ruff`. [[1]](diffhunk://#diff-72ab530ef768bfb67d5a758a0551df3688f63c9a421de973dce0d1686c6eca7bL71-R90) [[2]](diffhunk://#diff-72ab530ef768bfb67d5a758a0551df3688f63c9a421de973dce0d1686c6eca7bL99-R108) **Test and Documentation Updates:** - Updated `tools/README.md` to reflect the new versions of `uv`, `uvx`, and `pre-commit`. --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 3d8e047 commit fb15dd8

11 files changed

Lines changed: 143 additions & 37 deletions

File tree

‎.github/renovate.json5‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@
6565
"depNameTemplate": "anomalyco/opencode",
6666
"versioningTemplate": "semver"
6767
},
68+
{
69+
"customType": "regex",
70+
"fileMatch": [
71+
"^tools/lockfiles/uv\\.lock\\.json$"
72+
],
73+
"matchStrings": [
74+
"\"version\":\\s*\"(?<currentValue>[^\"]+)\""
75+
],
76+
"datasourceTemplate": "github-releases",
77+
"depNameTemplate": "astral-sh/uv",
78+
"versioningTemplate": "semver"
79+
},
6880
{
6981
"customType": "regex",
7082
"fileMatch": [

‎docs/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ To support proxy environments, environment variables are set in the [`Dockerfile
8181

8282
## Tests
8383

84-
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).
84+
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).
8585
This may seem overly complex at first, but prevents (1) accidentially wrong versions, (2) completely broken tools that cannot even execute.
8686
Both cases can happen and have happened in the past already, e.g. due to unexpected interactions between devcontainer features.
8787

‎src/s-core-devcontainer/.devcontainer/Dockerfile‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ FROM buildpack-deps:noble-curl
1616

1717
LABEL dev.containers.features="common"
1818

19+
# Refresh the system trust store before anything else downloads over TLS. The
20+
# base image carries the CA bundle from its own build date, and the
21+
# common-utils feature deliberately runs with `upgradePackages: false`, so a
22+
# distrusted CA would otherwise stay trusted for the life of the image.
23+
# Deliberately unpinned: a pinned trust store is a stale trust store.
24+
RUN apt-get update \
25+
&& apt-get install -y --no-install-recommends ca-certificates \
26+
&& update-ca-certificates --fresh \
27+
&& apt-get clean \
28+
&& rm -rf /var/lib/apt/lists/*
29+
1930
COPY tools /usr/local/share/score-tools
2031

2132
RUN userdel -f -r ubuntu

‎src/s-core-devcontainer/.devcontainer/with-proxy-vars.Dockerfile‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ ENV no_proxy=${no_proxy}
3232

3333
LABEL dev.containers.features="common"
3434

35+
# Refresh the system trust store before anything else downloads over TLS. The
36+
# base image carries the CA bundle from its own build date, and the
37+
# common-utils feature deliberately runs with `upgradePackages: false`, so a
38+
# distrusted CA would otherwise stay trusted for the life of the image.
39+
# Deliberately unpinned: a pinned trust store is a stale trust store.
40+
RUN apt-get update \
41+
&& apt-get install -y --no-install-recommends ca-certificates \
42+
&& update-ca-certificates --fresh \
43+
&& apt-get clean \
44+
&& rm -rf /var/lib/apt/lists/*
45+
3546
# Unset proxy variables for all login shells
3647
COPY src/s-core-devcontainer/.devcontainer/unset-proxy.sh /etc/bash_completion.d/unset-proxy.sh
3748
COPY tools /usr/local/share/score-tools
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env bash
2+
3+
# *******************************************************************************
4+
# Copyright (c) 2026 Contributors to the Eclipse Foundation
5+
#
6+
# See the NOTICE file(s) distributed with this work for additional
7+
# information regarding copyright ownership.
8+
#
9+
# This program and the accompanying materials are made available under the
10+
# terms of the Apache License Version 2.0 which is available at
11+
# https://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# SPDX-License-Identifier: Apache-2.0
14+
# *******************************************************************************
15+
16+
# Sourced by test.sh, which provides the `check` helper.
17+
18+
# System trust store
19+
#
20+
# The October 2026 eMudhra incident compromised intermediate CA private keys
21+
# chaining to "emSign ECC Root CA - G3"; Ubuntu distrusts that root as of
22+
# ca-certificates 20260816ubuntu0.24.04.1. The Dockerfile upgrades the
23+
# package, but image builds run with `--cache-from`, so a layer assembled
24+
# before that upgrade could still be reused. Asserting the outcome here means
25+
# a stale trust store fails CI instead of being published.
26+
distrusted_root_cn="emSign ECC Root CA - G3"
27+
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"
28+
29+
# Prove the bundle is readable first; otherwise the absence check below would
30+
# pass simply because nothing could be parsed out of it.
31+
check "validate the system CA bundle is present and parsable" bash -c \
32+
"[ \"\$(openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -c '^subject=')\" -gt 50 ]"
33+
check "validate the compromised ${distrusted_root_cn} is not in the system CA bundle" bash -c \
34+
"! openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt | openssl pkcs7 -print_certs -noout | grep -qF '${distrusted_root_cn}'"
35+
36+
# Bazel runs its JVM against this keystore (see the bazel feature's
37+
# /etc/bazel.bazelrc), so it needs the same guarantee as the OpenSSL bundle.
38+
# Matched on fingerprint because the keytool alias is derived from a filename.
39+
# As above, prove the keystore is readable first, so a missing or corrupt
40+
# keystore cannot make the absence check pass.
41+
check "validate the Java keystore is present and parsable" bash -c \
42+
"[ \"\$(keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -c 'trustedCertEntry')\" -gt 50 ]"
43+
check "validate the compromised ${distrusted_root_cn} is not in the Java keystore" bash -c \
44+
"! keytool -list -keystore /etc/ssl/certs/java/cacerts -storepass changeit | grep -qiF '${distrusted_root_sha256}'"

‎src/s-core-devcontainer/test-project/test.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ check "validate clang-format is working and has the correct version" bash -c "cl
2626
check "validate clang-tidy is working and has the correct version" bash -c "clang-tidy --version | grep '20.1.8'"
2727
check "validate clang is working and has the correct version" bash -c "clang --version | grep '20.1.8'"
2828

29+
# System trust store. Verifies the Dockerfile's CA refresh, so it is sourced
30+
# explicitly here rather than being discovered; forgetting this line skips it.
31+
source "${SCRIPT_DIR}/ca_certificates.sh"
32+
2933
# Tests from the local s-core-local feature
3034
source /devcontainer/features/s-core-local/tests/test_default.sh
3135

‎tools/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ host-side Bazel invocation may require network access to download the binary.
4444
| `bazelisk` | `1.27.0` | Run the Bazel version selected by a repository |
4545
| `buildifier` | `8.2.1` | Format and lint Bazel files |
4646
| `opencode` | `1.18.31` | Run the OpenCode CLI |
47-
| `pre-commit` | `4.5.1` | Run repository pre-commit hooks |
47+
| `pre-commit` | `4.7.0` | Run repository pre-commit hooks |
4848
| `ruff` | `0.11.13` | Check and format Python code |
4949
| `shellcheck` | `0.10.0` | Check shell scripts |
5050
| `starpls` | `0.1.22` | Provide language-server support for Starlark |
51-
| `uv` | `0.10.4` | Manage Python projects and packages |
52-
| `uvx` | `0.10.4` | Run Python tools in isolated environments |
51+
| `uv` | `0.13.5` | Manage Python projects and packages |
52+
| `uvx` | `0.13.5` | Run Python tools in isolated environments |
5353
| `yamlfmt` | `0.17.0` | Format YAML files |
5454

5555
<!-- END GENERATED TOOL TABLE -->

‎tools/internal/refresh_lockfile_digests.py‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,35 @@ def _api_get(url, token=None):
4949
return json.load(resp)
5050

5151

52-
def _asset_name(url):
53-
"""Extract the asset filename from a GitHub release download URL."""
54-
return url.rsplit("/", 1)[-1]
52+
def _parse_release_url(url):
53+
"""Split a GitHub release download URL into its components.
54+
55+
Layout: https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
56+
Returns ``(owner, repo, tag, asset)`` or ``None`` if the URL does not match.
57+
"""
58+
parts = url.split("/")
59+
if len(parts) != 9 or parts[2] != "github.com" or parts[5] != "releases":
60+
return None
61+
return parts[3], parts[4], parts[7], parts[8]
62+
63+
64+
def _release_digests(owner, repo, tag, token, cache):
65+
"""Return ``{asset name: digest}`` for a release, fetching each one once."""
66+
key = (owner, repo, tag)
67+
if key not in cache:
68+
release = _api_get(
69+
f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/{tag}", token
70+
)
71+
cache[key] = {a["name"]: a.get("digest", "") for a in release.get("assets", [])}
72+
return cache[key]
5573

5674

57-
def refresh_lockfile(path, token=None):
75+
def refresh_lockfile(path, token=None, cache=None):
5876
"""Refresh one lockfile in place. Returns a list of changed binary names."""
5977
with open(path, encoding="utf-8") as f:
6078
data = json.load(f)
6179

80+
cache = {} if cache is None else cache
6281
changed = []
6382
for tool_name, tool in data.items():
6483
if not isinstance(tool, dict) or "binaries" not in tool:
@@ -67,36 +86,40 @@ def refresh_lockfile(path, token=None):
6786
if not version:
6887
continue
6988

70-
# Derive the GitHub repo from the first binary URL:
71-
# https://github.com/<owner>/<repo>/releases/download/v<ver>/<asset>
7289
first_url = tool["binaries"][0].get("url", "")
73-
parts = first_url.split("/")
74-
if len(parts) < 8 or parts[2] != "github.com" or parts[5] != "releases":
90+
parsed = _parse_release_url(first_url)
91+
if parsed is None:
7592
print(
7693
f" ! {tool_name}: cannot derive repo from {first_url!r}",
7794
file=sys.stderr,
7895
)
7996
continue
80-
owner, repo = parts[3], parts[4]
97+
owner, repo, first_tag, _ = parsed
8198

82-
release = _api_get(
83-
f"{GITHUB_API}/repos/{owner}/{repo}/releases/tags/v{version}", token
84-
)
85-
digests = {a["name"]: a.get("digest", "") for a in release.get("assets", [])}
99+
# Release tags are not uniformly prefixed: most projects publish
100+
# `v1.27.0`, while the astral-sh ones (uv, ruff) publish a bare
101+
# `0.13.5`. Reuse the prefix the pinned URL already encodes; assuming
102+
# `v` here would rewrite those tools to a nonexistent tag.
103+
tag = ("v" if first_tag.startswith("v") else "") + version
104+
105+
digests = _release_digests(owner, repo, tag, token, cache)
86106

87107
for binary in tool["binaries"]:
88-
asset = _asset_name(binary.get("url", ""))
108+
binary_parts = _parse_release_url(binary.get("url", ""))
109+
asset = binary_parts[3] if binary_parts else None
89110
digest = digests.get(asset)
90111
if not digest:
91112
print(
92-
f" ! {tool_name}: no digest for asset {asset!r} in v{version}",
113+
f" ! {tool_name}: no digest for asset {asset!r} in {tag}",
93114
file=sys.stderr,
94115
)
95116
continue
96117
sha = digest.split(":", 1)[1] if digest.startswith("sha256:") else digest
97118
# Rebuild the URL from the version so the file is consistent
98119
# whether or not Renovate already updated the URL.
99-
new_url = f"https://github.com/{owner}/{repo}/releases/download/v{version}/{asset}"
120+
new_url = (
121+
f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset}"
122+
)
100123
if binary.get("sha256") != sha or binary.get("url") != new_url:
101124
binary["url"] = new_url
102125
binary["sha256"] = sha
@@ -120,11 +143,12 @@ def main(argv):
120143
if name.endswith(".lock.json")
121144
]
122145

146+
cache = {}
123147
failed = False
124148
for path in argv[1:]:
125149
print(f"Refreshing {path}")
126150
try:
127-
changed = refresh_lockfile(path, token)
151+
changed = refresh_lockfile(path, token, cache)
128152
except Exception as exc:
129153
print(f" ! failed: {exc}", file=sys.stderr)
130154
failed = True

‎tools/lockfiles/python_tools.bzl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ PYTHON_TOOLS = {
2121
# users. They are separate because Python distributions may name them
2222
# differently.
2323
"package": "pre-commit",
24-
"version": "4.5.1",
24+
"version": "4.7.0",
2525
"entrypoint": "pre-commit",
2626
# Documentation is generated from the catalog so version and purpose
2727
# are reviewed together.

‎tools/lockfiles/uv.lock.json‎

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)