diff --git a/agent-base/Dockerfile b/agent-base/Dockerfile index e89caed007..2ff26dfd0b 100644 --- a/agent-base/Dockerfile +++ b/agent-base/Dockerfile @@ -1,4 +1,5 @@ # syntax=docker/dockerfile:1.7 +FROM ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@sha256:2742a3d429e4e14bc587f6dedc51ded4246cf90bbaedcd86c67d0885e32a6f2a AS ubuntu_security_backports FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS python-builder RUN rm -f /usr/bin/pebble @@ -69,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.6" \ + org.opencontainers.image.version="v0.3.7" \ org.opencontainers.image.revision="$VCS_REF" RUN rm -f /usr/bin/pebble @@ -147,4 +148,20 @@ RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources. rm /var/run && \ mkdir /var/run +# Only the final runtime consumes these qualified packages. Keep the Python +# compiler stage and the existing coreutils provider unchanged. +RUN --mount=from=ubuntu_security_backports,source=/out,target=/opt/pasturestack/ubuntu-security-backports,ro \ + set -eux; \ + data=/opt/pasturestack/ubuntu-security-backports; \ + evidence=/usr/share/pasturestack/security/ubuntu-backports; \ + mkdir -p "$evidence"; \ + "$data/install-existing-backports" --install-in-docker-build > "$evidence/install.json"; \ + "$data/install-existing-backports" --verify-installed > "$evidence/verify.json"; \ + 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"; \ + cd "$evidence"; \ + sha256sum manifest.json qualification.json openvex.json source-inputs.lock.json install.json verify.json bundle-image.txt > SHA256SUMS + ENTRYPOINT ["/pasturestack-entrypoint.sh"] diff --git a/agent-base/README.md b/agent-base/README.md index 45d566fa88..6a65bc98f2 100644 --- a/agent-base/README.md +++ b/agent-base/README.md @@ -1,10 +1,18 @@ # Node agent image builds -The compatibility base targets `ghcr.io/pasturestack/node-agent-base:v0.3.6`. +The compatibility base targets `ghcr.io/pasturestack/node-agent-base:v0.3.7`. It builds Python 3.14.9 from the official SHA-256-verified archive, retains Docker CLI 29.9.2 (official Go 1.26.10 build), and uses the fixed Ubuntu 26.04 snapshot `20261023T000000Z`. Builder and runtime package inventories are compared; -the runtime verifies the patched OpenSSL, curl, libc and CA package versions. +only exact qualified security-backport versions may differ in the final runtime. +The Python compiler stage keeps its original libc. The runtime verifies the +patched OpenSSL, curl, libc and CA package versions. + +The digest-pinned, build-only Ubuntu backport bundle updates only already +installed packages, preserving the existing coreutils provider and package set. +Its source/native-test qualification, package manifest and installation receipt +are retained under `/usr/share/pasturestack/security/ubuntu-backports/`. +The bundle and installer are read-only build mounts, not runtime dependencies. The shared `tools/pip-security-overlay` helper builds the clearly identified downstream wheel `pip 26.2.2+pasturestack.1` from pinned official inputs using diff --git a/agent-base/build-image.sh b/agent-base/build-image.sh index b8ae5b65a2..f1c3a2a6c4 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.6} +IMAGE=${IMAGE:-ghcr.io/pasturestack/node-agent-base:v0.3.7} 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 a7f258f48f..e7791f1228 100644 --- a/scripts/build-agent-release.sh +++ b/scripts/build-agent-release.sh @@ -10,9 +10,10 @@ test "$source_sha" = "$GITHUB_SHA" evidence=${EVIDENCE_DIR:?EVIDENCE_DIR is required} mkdir -p "$evidence" 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.6 ;; + base) repository=ghcr.io/pasturestack/node-agent-base; version=v0.3.7 ;; agent) repository=ghcr.io/pasturestack/node-agent; version=v1.2.32 ;; *) echo 'Unsupported agent component' >&2; exit 1 ;; esac @@ -20,8 +21,12 @@ esac target="$repository:$version" candidate="local/pasturestack-node-agent-${COMPONENT}:${GITHUB_RUN_ID}" helper_container="pasturestack-agent-helper-${GITHUB_RUN_ID}" +backports_container= +bundle_container= scratch=$(mktemp -d) cleanup() { + if [[ -n "$backports_container" ]]; then docker rm -v "$backports_container" >/dev/null 2>&1 || true; fi + if [[ -n "$bundle_container" ]]; then docker rm -v "$bundle_container" >/dev/null 2>&1 || true; fi docker rm -v "$helper_container" >/dev/null 2>&1 || true docker logout ghcr.io >/dev/null 2>&1 || true rm -rf "$scratch" @@ -76,23 +81,32 @@ docker run --rm --network none --entrypoint cat "$candidate" \ /usr/share/pasturestack/python-builder-packages.tsv >"$evidence/builder-packages.tsv" docker run --rm --network none --entrypoint dpkg-query "$candidate" \ -W -f='${binary:Package}\t${Version}\n' >"$evidence/runtime-packages.tsv" -python3 - "$evidence" <<'PY' -from pathlib import Path -import json, sys -root = Path(sys.argv[1]) -def packages(name): - return dict(line.split('\t', 1) for line in (root / name).read_text().splitlines()) -builder, runtime = packages('builder-packages.tsv'), packages('runtime-packages.tsv') -common = builder.keys() & runtime.keys() -assert all(builder[name] == runtime[name] for name in common), 'builder/runtime package drift' -for package, version in {'libssl3t64:amd64': '3.5.5-1ubuntu3.7', 'curl': '8.18.0-1ubuntu2.8', - 'libc6:amd64': '2.43-2ubuntu2.4', 'ca-certificates': '20260816ubuntu0.26.04.1'}.items(): - assert runtime.get(package) == version, (package, runtime.get(package), version) -report = {'builder_packages': len(builder), 'runtime_packages': len(runtime), - 'common_packages_same_versions': len(common), 'expected_security_packages': True} -(root / 'package-comparison.json').write_text(json.dumps(report, indent=2) + '\n') -print(json.dumps(report)) -PY +if [[ "$COMPONENT" = base ]]; then + for name in manifest qualification install; do + docker run --rm --network none --entrypoint cat "$candidate" \ + "/usr/share/pasturestack/security/ubuntu-backports/${name}.json" >"$evidence/backports-${name}.json" + done + jq -n --rawfile builder "$evidence/builder-packages.tsv" \ + --rawfile runtime "$evidence/runtime-packages.tsv" \ + --rawfile manifest "$evidence/backports-manifest.json" \ + --rawfile qualification "$evidence/backports-qualification.json" \ + --rawfile installation "$evidence/backports-install.json" \ + '{"builder-packages.tsv":$builder,"runtime-packages.tsv":$runtime, + "backports-manifest.json":$manifest,"backports-qualification.json":$qualification, + "backports-install.json":$installation}' \ + | python3 scripts/check-agent-package-consistency.py >"$evidence/package-comparison.json" + backports_container=$(docker create "$candidate") + docker cp "$backports_container:/usr/share/pasturestack/security/ubuntu-backports" "$evidence/ubuntu-backports" + docker rm -v "$backports_container" >/dev/null + backports_container= +else + # The wrapper remains pinned to the published 0.3.6 base until 0.3.7 passes + # publication. Its existing strict checks must continue to work meanwhile. + jq -n --rawfile builder "$evidence/builder-packages.tsv" \ + --rawfile runtime "$evidence/runtime-packages.tsv" \ + '{"builder-packages.tsv":$builder,"runtime-packages.tsv":$runtime}' \ + | python3 scripts/check-agent-package-consistency.py --legacy-wrapper >"$evidence/package-comparison.json" +fi 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" @@ -121,14 +135,38 @@ docker run --rm --entrypoint python3 "$candidate" -c \ trivy=ghcr.io/aquasecurity/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 docker pull "$trivy" +mkdir -p "$scratch/trivy-cache" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$evidence:/evidence" "$trivy" image --format cyclonedx \ + -v "$scratch/trivy-cache:/root/.cache/trivy" -v "$evidence:/evidence" "$trivy" image --format cyclonedx \ --output /evidence/image.cdx.json "$candidate" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$evidence:/evidence" "$trivy" image --scanners vuln,secret --list-all-pkgs --format json \ + -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" +pip_scan_input="$evidence/image-security.json" +if [[ "$COMPONENT" = base ]]; then +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}$ ]] +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" +docker cp "$bundle_container:/out/SHA256SUMS" "$scratch/backports-SHA256SUMS" +docker rm "$bundle_container" >/dev/null +bundle_container= +python3 "$scratch/verify-consumer-evidence.py" --evidence-dir "$evidence/ubuntu-backports" \ + --sbom "$evidence/image.cdx.json" --expected-bundle-digest "${backports_image##*@}" \ + --bundle-checksums "$scratch/backports-SHA256SUMS" \ + >"$evidence/ubuntu-backports-consumer-proof.json" +# Preserve the original full scan. Native Trivy applies only the qualified, +# exact-package OpenVEX, followed by the existing pip-vendor interpretation. +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 --skip-db-update --skip-vex-repo-update \ + --vex /evidence/ubuntu-backports/openvex.json --format json \ + --output /evidence/image-security-os-effective.json "$candidate" +pip_scan_input="$evidence/image-security-os-effective.json" +fi python3 tools/pip-security-overlay/filter-pip-vendor-findings.py \ - --raw "$evidence/image-security.json" --proof "$evidence/pip-vendor-slice.json" \ + --raw "$pip_scan_input" --proof "$evidence/pip-vendor-slice.json" \ --output "$evidence/image-security-effective.json" --vex "$evidence/image.vex.json" \ --subject "urn:sha256:${local_id#sha256:}" python3 - "$evidence" <<'PY' @@ -164,7 +202,7 @@ if [[ "$PUBLISH" = true ]]; then docker pull "$repository@$digest" test "$(docker image inspect "$repository@$digest" --format '{{.Id}}')" = "$local_id" python3 tools/pip-security-overlay/filter-pip-vendor-findings.py \ - --raw "$evidence/image-security.json" --proof "$evidence/pip-vendor-slice.json" \ + --raw "$pip_scan_input" --proof "$evidence/pip-vendor-slice.json" \ --output "$evidence/image-security-effective.json" --vex "$evidence/image.vex.json" \ --subject "https://ghcr.io/v2/${repository#ghcr.io/}/manifests/$digest" printf 'repository=%s\nversion=%s\nsource=%s\ndigest=%s\nimage_id=%s\n' \ diff --git a/scripts/check-agent-package-consistency.py b/scripts/check-agent-package-consistency.py new file mode 100644 index 0000000000..5005566c8e --- /dev/null +++ b/scripts/check-agent-package-consistency.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Compare the real Python builder/runtime inventories and qualified deltas.""" +import hashlib +import json +import sys + + +def require(value, message): + if not value: + raise ValueError(message) + + +def inventory(text): + require(isinstance(text, str), 'Package inventory must be text') + rows = [line.split('\t') for line in text.splitlines()] + require(all(len(row) == 2 for row in rows), 'Malformed package inventory') + result = {name.split(':', 1)[0]: version for name, version in rows} + require(len(result) == len(rows), 'Duplicate/multiarch package inventory') + return result + + +def verify_legacy_wrapper(data): + """Preserve the published 0.3.6 wrapper contract until its new base exists.""" + builder = inventory(data['builder-packages.tsv']) + runtime = inventory(data['runtime-packages.tsv']) + common = builder.keys() & runtime.keys() + require(all(builder[name] == runtime[name] for name in common), 'Legacy wrapper builder/runtime drift') + expected = {'libssl3t64': '3.5.5-1ubuntu3.7', 'curl': '8.18.0-1ubuntu2.8', + 'libc6': '2.43-2ubuntu2.4', 'ca-certificates': '20260816ubuntu0.26.04.1'} + require(all(runtime.get(name) == version for name, version in expected.items()), + 'Unexpected published-wrapper security package') + return {'builder_packages': len(builder), 'runtime_packages': len(runtime), + 'common_packages_same_versions': len(common), 'expected_security_packages': True} + + +def verify(data): + builder = inventory(data['builder-packages.tsv']) + runtime = inventory(data['runtime-packages.tsv']) + manifest_bytes = data['backports-manifest.json'].encode('utf-8') + manifest = json.loads(manifest_bytes) + 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' + and manifest['platform'] == {'distribution': 'ubuntu', 'release': '26.04', 'architecture': 'amd64'}, + 'Unexpected backport manifest identity') + require(manifest['qualification']['sha256'] == hashlib.sha256(qualification_bytes).hexdigest(), + 'Backport qualification checksum mismatch') + fixtures = qualification['consumerFixtures'] + require(qualification['status'] == 'qualified' and len(fixtures) == 2 + and {item['provider'] for item in fixtures} == {'gnu', 'uutils'} + 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'} + packages = manifest['packages'] + qualified = {p['name']: p['version'] for p in packages} + require(len(packages) == len(qualified) == 12 and set(qualified) == allowed, + 'Unexpected qualified package catalog') + require(receipt['schemaVersion'] == 1 and receipt['bundleVersion'] == '0.1.0' + and receipt['status'] == 'installed-and-verified' + and receipt['providerPreserved'] is True and receipt['configurationPreserved'] is True, + 'Runtime installation did not preserve its provider/configuration') + selected = set(receipt['packages']) + require(len(selected) == len(receipt['packages']) and selected == allowed & runtime.keys(), + 'Installation receipt does not match the existing runtime package selection') + for name in selected: + require(runtime[name] == qualified[name], 'Unqualified runtime version: ' + name) + common = builder.keys() & runtime.keys() + changed = {name for name in common if builder[name] != runtime[name]} + require(changed <= selected, 'Unrelated builder/runtime package drift: ' + ', '.join(sorted(changed - selected))) + # Keep the original compiler inputs. Only final-runtime library versions + # may differ, and only by the exact qualified manifest above. + require(builder.get('libc6') == '2.43-2ubuntu2.4', 'Unexpected Python-builder libc version') + expected = {'libssl3t64': '3.5.5-1ubuntu3.7', 'curl': '8.18.0-1ubuntu2.8', + 'ca-certificates': '20260816ubuntu0.26.04.1'} + for name, version in expected.items(): + require(runtime.get(name) == version, 'Unexpected unchanged security package: ' + name) + return {'builder_packages': len(builder), 'runtime_packages': len(runtime), + 'common_packages_same_versions': len(common - changed), + 'qualified_runtime_deltas': sorted(changed), 'expected_security_packages': True, + 'backport_manifest_sha256': hashlib.sha256(manifest_bytes).hexdigest()} + + +if __name__ == '__main__': + require(sys.argv[1:] in ([], ['--legacy-wrapper']), 'Unsupported arguments') + data = json.load(sys.stdin) + report = verify_legacy_wrapper(data) if sys.argv[1:] else verify(data) + print(json.dumps(report, indent=2)) diff --git a/scripts/test-agent-package-consistency.py b/scripts/test-agent-package-consistency.py new file mode 100644 index 0000000000..21144e96fe --- /dev/null +++ b/scripts/test-agent-package-consistency.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location('package_consistency', Path(__file__).with_name('check-agent-package-consistency.py')) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class PackageConsistencyTests(unittest.TestCase): + def setUp(self): + self.data = {} + self.versions = {'libc6': '2.43-2ubuntu2.4+pasturestack.1', 'libc-bin': '2.43-2ubuntu2.4+pasturestack.1', + 'libc-gconv-modules-extra': '2.43-2ubuntu2.4+pasturestack.1', + 'libpcre2-8-0': '10.46-1build1+pasturestack.1', 'libpcre2-posix3': '10.46-1build1+pasturestack.1', + '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'} + 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'} + 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', + 'providerPreserved': True, 'configurationPreserved': True, + 'packages': ['libc6', 'libpcre2-8-0']} + 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', + '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()]} + + def verify(self): + for filename, data in [('backports-manifest.json', self.manifest), ('backports-install.json', self.receipt)]: + self.data[filename] = json.dumps(data) + for filename, data in [('builder-packages.tsv', self.builder), ('runtime-packages.tsv', self.runtime)]: + self.data[filename] = ''.join(name + '\t' + version + '\n' for name, version in data.items()) + 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.builder['libc6'], '2.43-2ubuntu2.4') + + def test_unrelated_drift_is_rejected(self): + self.runtime['zlib1g'] = 'unexpected' + with self.assertRaisesRegex(ValueError, 'Unrelated'): self.verify() + + def test_nonqualified_library_version_is_rejected(self): + self.runtime['libc6'] = '2.44' + with self.assertRaisesRegex(ValueError, 'Unqualified'): 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() + + def test_false_selection_or_provider_proof_is_rejected(self): + original = copy.deepcopy(self.receipt) + for mutate in [lambda r: r['packages'].append('jq'), lambda r: r.update(providerPreserved=False)]: + self.receipt = copy.deepcopy(original) + mutate(self.receipt) + with self.assertRaises(ValueError): self.verify() + + def test_changed_qualification_is_rejected(self): + self.data['backports-qualification.json'] = '{}' + with self.assertRaisesRegex(ValueError, 'checksum'): self.verify() + + def test_published_wrapper_keeps_its_original_strict_contract(self): + self.runtime = dict(self.builder) + self.verify_files_only() + self.assertTrue(module.verify_legacy_wrapper(self.data)['expected_security_packages']) + self.runtime['libc6'] = self.versions['libc6'] + self.verify_files_only() + with self.assertRaisesRegex(ValueError, 'drift'): + module.verify_legacy_wrapper(self.data) + + def verify_files_only(self): + for filename, data in [('builder-packages.tsv', self.builder), ('runtime-packages.tsv', self.runtime)]: + self.data[filename] = ''.join(name + '\t' + version + '\n' for name, version in data.items()) + + def test_inventory_text_preserves_bytes_and_rejects_malformed_rows(self): + result = self.verify() + self.assertEqual(result['backport_manifest_sha256'], hashlib.sha256( + self.data['backports-manifest.json'].encode('utf-8')).hexdigest()) + with self.assertRaisesRegex(ValueError, 'Malformed'): + module.inventory('libc6\tversion\textra\n') + with self.assertRaisesRegex(ValueError, 'Duplicate'): + module.inventory('libc6\tversion\nlibc6:amd64\tversion\n') + + +if __name__ == '__main__': + unittest.main(verbosity=2)