diff --git a/agent-base/Dockerfile b/agent-base/Dockerfile index 2ff26dfd0b..d13889449a 100644 --- a/agent-base/Dockerfile +++ b/agent-base/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -FROM ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a AS ubuntu_security_backports +FROM ghcr.io/pasturestack/ubuntu-security-backports:v0.1.1@sha256:45d6b507b63a18459ae5cf76afd4d9731f8ef43b9c40e4c3dbb01899e1752b19 AS ubuntu_security_backports FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS python-builder RUN rm -f /usr/bin/pebble @@ -70,7 +70,7 @@ ARG VCS_REF LABEL org.opencontainers.image.source="https://github.com/PastureStack/server" \ org.opencontainers.image.description="PastureStack node-agent compatibility base image." \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.version="v0.3.7" \ + org.opencontainers.image.version="v0.3.8" \ org.opencontainers.image.revision="$VCS_REF" RUN rm -f /usr/bin/pebble @@ -160,7 +160,7 @@ RUN --mount=from=ubuntu_security_backports,source=/out,target=/opt/pasturestack/ for name in manifest.json qualification.json source-inputs.lock.json openvex.json; do \ install -m 0644 "$data/$name" "$evidence/$name"; \ done; \ - printf '%s\n' 'ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a' > "$evidence/bundle-image.txt"; \ + printf '%s\n' 'ghcr.io/pasturestack/ubuntu-security-backports:v0.1.1@sha256:45d6b507b63a18459ae5cf76afd4d9731f8ef43b9c40e4c3dbb01899e1752b19' > "$evidence/bundle-image.txt"; \ cd "$evidence"; \ sha256sum manifest.json qualification.json openvex.json source-inputs.lock.json install.json verify.json bundle-image.txt > SHA256SUMS diff --git a/agent-base/build-image.sh b/agent-base/build-image.sh index f1c3a2a6c4..b3a4c7001f 100755 --- a/agent-base/build-image.sh +++ b/agent-base/build-image.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")" -IMAGE=${IMAGE:-ghcr.io/pasturestack/node-agent-base:v0.3.7} +IMAGE=${IMAGE:-ghcr.io/pasturestack/node-agent-base:v0.3.8} SHARE_MNT_BIN="${SHARE_MNT_BIN:-}" if [ -z "$SHARE_MNT_BIN" ] || [ ! -f "$SHARE_MNT_BIN" ]; then diff --git a/scripts/build-agent-release.sh b/scripts/build-agent-release.sh index 469402aad2..c5393c3ac0 100644 --- a/scripts/build-agent-release.sh +++ b/scripts/build-agent-release.sh @@ -13,7 +13,7 @@ printf '%s\n' "$source_sha" >"$evidence/source-revision.txt" python3 scripts/test-agent-package-consistency.py case "${COMPONENT:-}" in - base) repository=ghcr.io/pasturestack/node-agent-base; version=v0.3.7 ;; + base) repository=ghcr.io/pasturestack/node-agent-base; version=v0.3.8 ;; agent) repository=ghcr.io/pasturestack/node-agent; version=v1.2.33 ;; *) echo 'Unsupported agent component' >&2; exit 1 ;; esac @@ -113,6 +113,19 @@ backports_container= docker run --rm --network none --entrypoint /bin/bash "$candidate" -ec \ 'ldd "$(python3 -c "import _ssl; print(_ssl.__file__)")"; curl --version' \ >"$evidence/python-ssl-linkage.txt" +docker run --rm --network none --entrypoint python3 \ + -e PYTHONDONTWRITEBYTECODE=1 -v "$PWD:/source:ro" -w /source "$candidate" \ + scripts/check-agent-brotli-runtime.py >"$evidence/brotli-runtime.json" +entry_args=() +if [[ "$COMPONENT" = agent ]]; then entry_args=(--); fi +docker run --rm --network none "$candidate" "${entry_args[@]}" /bin/bash -ec \ + 'test "$(python3 --version)" = "Python 3.14.9"; curl --version; printf "AGENT_ENTRYPOINT_EXEC_OK\n"' \ + >"$evidence/entrypoint-smoke.txt" +if [[ "$COMPONENT" = agent ]]; then + docker run --rm --network none --entrypoint bash \ + -v "$PWD:/source:ro" -w /source "$candidate" \ + scripts/check-agent-run-downloads.sh >"$evidence/bootstrap-download-smoke.txt" +fi docker run --rm --network none --entrypoint /bin/bash "$candidate" -ec \ 'test "$(dpkg-query -W ca-certificates | cut -f2)" = 20260816ubuntu0.26.04.1; docker --version | grep -Fx "Docker version 29.9.2, build a82e528"; printf "6c71cfe71faff949a602946a4df4799b563232e79d0b8b6fd343d0ef081c9621 /usr/bin/docker\n" | sha256sum -c -; test -s /usr/bin/share-mnt; test -x /usr/bin/r' \ |& tee "$evidence/runtime-tools.txt" @@ -145,8 +158,8 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ -v "$scratch/trivy-cache:/root/.cache/trivy" -v "$evidence:/evidence" "$trivy" image --scanners vuln,secret --list-all-pkgs --format json \ --output /evidence/image-security.json "$candidate" -backports_image=$(sed -n 's/^FROM \(ghcr.io\/pasturestack\/ubuntu-security-backports:v0\.1\.0@sha256:[0-9a-f]\{64\}\) AS ubuntu_security_backports$/\1/p' agent-base/Dockerfile) -[[ "$backports_image" =~ ^ghcr.io/pasturestack/ubuntu-security-backports:v0\.1\.0@sha256:[0-9a-f]{64}$ ]] +backports_image=$(sed -n 's/^FROM \(ghcr.io\/pasturestack\/ubuntu-security-backports:v0\.1\.1@sha256:[0-9a-f]\{64\}\) AS ubuntu_security_backports$/\1/p' agent-base/Dockerfile) +[[ "$backports_image" =~ ^ghcr.io/pasturestack/ubuntu-security-backports:v0\.1\.1@sha256:[0-9a-f]{64}$ ]] docker pull --platform linux/amd64 "$backports_image" bundle_container=$(docker create --entrypoint /unused "$backports_image") docker cp "$bundle_container:/out/verify-consumer-evidence.py" "$scratch/verify-consumer-evidence.py" diff --git a/scripts/check-agent-base-downloads.sh b/scripts/check-agent-base-downloads.sh index 2e37512c99..92d36ebd33 100755 --- a/scripts/check-agent-base-downloads.sh +++ b/scripts/check-agent-base-downloads.sh @@ -35,7 +35,7 @@ if ! bash -n agent-base/build-image.sh; then fail "AGENT_BASE_BUILD_SCRIPT_SYNTAX_INVALID" fi -require_marker agent-base/Dockerfile 'org.opencontainers.image.version="v0.3.7"' AGENT_BASE_VERSION_NOT_ROLLED_FORWARD +require_marker agent-base/Dockerfile 'org.opencontainers.image.version="v0.3.8"' AGENT_BASE_VERSION_NOT_ROLLED_FORWARD require_marker agent-base/Dockerfile 'COPY ./share-mnt /usr/bin/share-mnt' AGENT_BASE_SHARE_MNT_NOT_CONTEXT_BACKED require_marker agent-base/Dockerfile 'COPY ./r /usr/bin/r' AGENT_BASE_NETWORK_HELPER_NOT_CONTEXT_BACKED require_marker agent-base/Dockerfile 'COPY ./update-platform-ssl /usr/bin/update-platform-ssl' AGENT_BASE_SSL_SCRIPT_NOT_CONTEXT_BACKED @@ -52,7 +52,7 @@ require_marker agent-base/Dockerfile 'echo "${DOCKER_SHA256} $docker_tgz" | sha require_marker agent-base/Dockerfile 'tar xzf "$docker_tgz" -C /usr/bin --strip-components=1 docker/docker' AGENT_BASE_DOCKER_EXTRACT_NOT_FILE_BACKED require_marker agent-base/Dockerfile 'echo "${DOCKER_BINARY_SHA256} /usr/bin/docker" | sha256sum -c -' AGENT_BASE_DOCKER_BINARY_SHA_NOT_CHECKED require_marker agent-base/Dockerfile 'rm -f "$docker_tgz"' AGENT_BASE_DOCKER_TMP_NOT_REMOVED -require_marker agent-base/build-image.sh 'IMAGE=${IMAGE:-ghcr.io/pasturestack/node-agent-base:v0.3.7}' AGENT_BASE_BUILD_DEFAULT_IMAGE_NOT_UPDATED +require_marker agent-base/build-image.sh 'IMAGE=${IMAGE:-ghcr.io/pasturestack/node-agent-base:v0.3.8}' AGENT_BASE_BUILD_DEFAULT_IMAGE_NOT_UPDATED require_marker agent-base/build-image.sh 'SHARE_MNT_BIN="${SHARE_MNT_BIN:-}"' AGENT_BASE_BUILD_SHARE_MNT_INPUT_MISSING require_marker agent-base/build-image.sh 'Set SHARE_MNT_BIN to the reviewed mount-propagation binary path before building $IMAGE' AGENT_BASE_BUILD_SHARE_MNT_FAIL_CLOSED_MISSING require_marker agent-base/build-image.sh 'cp "$SHARE_MNT_BIN" "$tmpdir/share-mnt"' AGENT_BASE_BUILD_SHARE_MNT_CONTEXT_COPY_MISSING @@ -95,4 +95,4 @@ if [ "$failure_count" -ne 0 ]; then exit 1 fi -printf 'AGENT_BASE_DOWNLOADS_OK base=v0.3.7 agent=v1.2.33 base_share_mnt_nonempty=1 tracked_loglevel=1 docker_cli=29.9.2 python_sha=1 docker_sha=1 remote_legacy_helpers=0\n' +printf 'AGENT_BASE_DOWNLOADS_OK base=v0.3.8 agent=v1.2.33 base_share_mnt_nonempty=1 tracked_loglevel=1 docker_cli=29.9.2 python_sha=1 docker_sha=1 remote_legacy_helpers=0\n' diff --git a/scripts/check-agent-brotli-runtime.py b/scripts/check-agent-brotli-runtime.py new file mode 100644 index 0000000000..78a5a01b0f --- /dev/null +++ b/scripts/check-agent-brotli-runtime.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Check the installed qualified Brotli library with ordinary valid inputs.""" +import ctypes +import json +from pathlib import Path +import subprocess + + +manifest = json.loads(Path('/usr/share/pasturestack/security/ubuntu-backports/manifest.json').read_text()) +expected = [package['version'] for package in manifest['packages'] if package['name'] == 'libbrotli1'] +assert len(expected) == 1, 'Qualified Brotli package is absent or ambiguous' +actual = subprocess.check_output(['dpkg-query', '-W', '-f=${Version}', 'libbrotli1'], text=True) +assert actual == expected[0], 'Installed Brotli does not match the qualified package' +encoder = ctypes.CDLL('libbrotlienc.so.1') +decoder = ctypes.CDLL('libbrotlidec.so.1') +size = ctypes.c_size_t +encoder.BrotliEncoderMaxCompressedSize.argtypes = [size] +encoder.BrotliEncoderMaxCompressedSize.restype = size +encoder.BrotliEncoderCompress.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, + size, ctypes.c_void_p, ctypes.POINTER(size), ctypes.c_void_p] +encoder.BrotliEncoderCompress.restype = ctypes.c_int +decoder.BrotliDecoderDecompress.argtypes = [size, ctypes.c_void_p, ctypes.POINTER(size), ctypes.c_void_p] +decoder.BrotliDecoderDecompress.restype = ctypes.c_int +for content in (b'', b'PastureStack ordinary Brotli runtime control\n' * 32, bytes(range(256))): + encoded_size = size(encoder.BrotliEncoderMaxCompressedSize(len(content))) + encoded = ctypes.create_string_buffer(max(1, encoded_size.value)) + assert encoder.BrotliEncoderCompress(5, 22, 0, len(content), content, + ctypes.byref(encoded_size), encoded) == 1 + decoded_size = size(max(1, len(content))) + decoded = ctypes.create_string_buffer(decoded_size.value) + assert decoder.BrotliDecoderDecompress(encoded_size.value, encoded, + ctypes.byref(decoded_size), decoded) == 1 + assert decoded.raw[:decoded_size.value] == content +print(json.dumps({'status': 'PASS', 'package': 'libbrotli1', 'version': actual, + 'bundleVersion': manifest['bundleVersion'], 'ordinaryRoundTrips': 3, + 'publicSonameControls': ['libbrotlienc.so.1', 'libbrotlidec.so.1'], + 'securityTriggerExecuted': False}, sort_keys=True)) diff --git a/scripts/check-agent-package-consistency.py b/scripts/check-agent-package-consistency.py index 58632bab4c..791d7c9b31 100644 --- a/scripts/check-agent-package-consistency.py +++ b/scripts/check-agent-package-consistency.py @@ -27,7 +27,7 @@ def verify(data): qualification_bytes = data['backports-qualification.json'].encode('utf-8') qualification = json.loads(qualification_bytes) receipt = json.loads(data['backports-install.json']) - require(manifest['schemaVersion'] == 1 and manifest['bundleVersion'] == '0.1.0' + require(manifest['schemaVersion'] == 1 and manifest['bundleVersion'] == '0.1.1' and manifest['platform'] == {'distribution': 'ubuntu', 'release': '26.04', 'architecture': 'amd64'}, 'Unexpected backport manifest identity') require(manifest['qualification']['sha256'] == hashlib.sha256(qualification_bytes).hexdigest(), @@ -38,12 +38,13 @@ def verify(data): and all(item['status'] == 'PASS' for item in fixtures), 'Backport consumer qualification is incomplete') allowed = {'libc6', 'libc-bin', 'libc-gconv-modules-extra', 'libpcre2-8-0', 'libpcre2-posix3', - 'rust-coreutils', 'jq', 'libjq1', 'p11-kit', 'p11-kit-modules', 'libp11-kit0', 'libxrender1'} + 'rust-coreutils', 'jq', 'libjq1', 'p11-kit', 'p11-kit-modules', 'libp11-kit0', 'libxrender1', + 'libbrotli1'} packages = manifest['packages'] qualified = {p['name']: p['version'] for p in packages} - require(len(packages) == len(qualified) == 12 and set(qualified) == allowed, + require(len(packages) == len(qualified) == 13 and set(qualified) == allowed, 'Unexpected qualified package catalog') - require(receipt['schemaVersion'] == 1 and receipt['bundleVersion'] == '0.1.0' + require(receipt['schemaVersion'] == 1 and receipt['bundleVersion'] == '0.1.1' and receipt['status'] == 'installed-and-verified' and receipt['providerPreserved'] is True and receipt['configurationPreserved'] is True, 'Runtime installation did not preserve its provider/configuration') diff --git a/scripts/test-agent-package-consistency.py b/scripts/test-agent-package-consistency.py index d2da643018..585b88e801 100644 --- a/scripts/test-agent-package-consistency.py +++ b/scripts/test-agent-package-consistency.py @@ -23,20 +23,23 @@ def setUp(self): 'rust-coreutils': '0.10.0-1ubuntu2~26.04.1+pasturestack.1', 'jq': '1.8.2-0pasturestack1~26.04.1', 'libjq1': '1.8.2-0pasturestack1~26.04.1', 'p11-kit': '0.26.2-2+pasturestack.1', 'p11-kit-modules': '0.26.2-2+pasturestack.1', 'libp11-kit0': '0.26.2-2+pasturestack.1', - 'libxrender1': '1:0.9.12-1build1+pasturestack.1'} + 'libxrender1': '1:0.9.12-1build1+pasturestack.1', + 'libbrotli1': '1.2.0-3build1+pasturestack.1'} self.builder = {'libc6': '2.43-2ubuntu2.4', 'libpcre2-8-0': '10.46-1build1', 'libssl3t64': '3.5.5-1ubuntu3.7', 'curl': '8.18.0-1ubuntu2.8', - 'ca-certificates': '20260816ubuntu0.26.04.1', 'zlib1g': '1.3.1'} + 'ca-certificates': '20260816ubuntu0.26.04.1', 'zlib1g': '1.3.1', + 'libbrotli1': '1.2.0-3build1'} self.runtime = dict(self.builder, libc6=self.versions['libc6']) self.runtime['libpcre2-8-0'] = self.versions['libpcre2-8-0'] - self.receipt = {'schemaVersion': 1, 'bundleVersion': '0.1.0', 'status': 'installed-and-verified', + self.runtime['libbrotli1'] = self.versions['libbrotli1'] + self.receipt = {'schemaVersion': 1, 'bundleVersion': '0.1.1', 'status': 'installed-and-verified', 'providerPreserved': True, 'configurationPreserved': True, - 'packages': ['libc6', 'libpcre2-8-0']} + 'packages': ['libc6', 'libpcre2-8-0', 'libbrotli1']} qualification = {'status': 'qualified', 'consumerFixtures': [ {'provider': 'gnu', 'status': 'PASS'}, {'provider': 'uutils', 'status': 'PASS'}]} content = (json.dumps(qualification) + '\n').encode() self.data['backports-qualification.json'] = content.decode('utf-8') - self.manifest = {'schemaVersion': 1, 'bundleVersion': '0.1.0', + self.manifest = {'schemaVersion': 1, 'bundleVersion': '0.1.1', 'platform': {'distribution': 'ubuntu', 'release': '26.04', 'architecture': 'amd64'}, 'qualification': {'sha256': hashlib.sha256(content).hexdigest()}, 'packages': [{'name': name, 'version': version} for name, version in self.versions.items()]} @@ -49,7 +52,7 @@ def verify(self): return module.verify(self.data) def test_only_qualified_final_runtime_differences_pass(self): - self.assertEqual(self.verify()['qualified_runtime_deltas'], ['libc6', 'libpcre2-8-0']) + self.assertEqual(self.verify()['qualified_runtime_deltas'], ['libbrotli1', 'libc6', 'libpcre2-8-0']) self.assertEqual(self.builder['libc6'], '2.43-2ubuntu2.4') def test_unrelated_drift_is_rejected(self): @@ -60,6 +63,10 @@ def test_nonqualified_library_version_is_rejected(self): self.runtime['libc6'] = '2.44' with self.assertRaisesRegex(ValueError, 'Unqualified'): self.verify() + def test_unpatched_brotli_is_rejected(self): + self.runtime['libbrotli1'] = self.builder['libbrotli1'] + with self.assertRaisesRegex(ValueError, 'Unqualified runtime version: libbrotli1'): self.verify() + def test_builder_changes_are_not_a_workaround(self): self.builder['libc6'] = self.versions['libc6'] with self.assertRaisesRegex(ValueError, 'Python-builder'): self.verify()