From e15d0afb2f65d30a97f76a7aceba7d4919665ab1 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:29:32 +0800 Subject: [PATCH 1/9] Build isolated official glibc coreutils and jq security candidates --- .github/workflows/ubuntu-backport-native.yml | 129 +++++++ .../ubuntu-backports/Dockerfile.native-ci | 153 ++++++++ .../glibc/build-glibc-backport.py | 348 +++++++++++++++++ .../ubuntu-backports/glibc/glibc.lock.json | 78 ++++ .../glibc/test_glibc_backport.py | 207 ++++++++++ .../ubuntu-backports/jq/build-jq-release.py | 305 +++++++++++++++ .../security/ubuntu-backports/jq/jq.lock.json | 260 +++++++++++++ .../ubuntu-backports/jq/test_jq_release.py | 117 ++++++ .../build-rust-coreutils-backport.py | 365 ++++++++++++++++++ .../rust-coreutils/native-tests.mk | 6 + .../rust-coreutils/rust-coreutils.lock.json | 266 +++++++++++++ .../test_rust_coreutils_backport.py | 121 ++++++ 12 files changed, 2355 insertions(+) create mode 100644 .github/workflows/ubuntu-backport-native.yml create mode 100644 server/security/ubuntu-backports/Dockerfile.native-ci create mode 100644 server/security/ubuntu-backports/glibc/build-glibc-backport.py create mode 100644 server/security/ubuntu-backports/glibc/glibc.lock.json create mode 100644 server/security/ubuntu-backports/glibc/test_glibc_backport.py create mode 100644 server/security/ubuntu-backports/jq/build-jq-release.py create mode 100644 server/security/ubuntu-backports/jq/jq.lock.json create mode 100644 server/security/ubuntu-backports/jq/test_jq_release.py create mode 100644 server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py create mode 100644 server/security/ubuntu-backports/rust-coreutils/native-tests.mk create mode 100644 server/security/ubuntu-backports/rust-coreutils/rust-coreutils.lock.json create mode 100644 server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py diff --git a/.github/workflows/ubuntu-backport-native.yml b/.github/workflows/ubuntu-backport-native.yml new file mode 100644 index 0000000000..45d08d6b80 --- /dev/null +++ b/.github/workflows/ubuntu-backport-native.yml @@ -0,0 +1,129 @@ +name: Ubuntu Backport Native Candidate + +on: + pull_request: + paths: + - '.github/workflows/ubuntu-backport-native.yml' + - 'server/security/ubuntu-backports/Dockerfile.native-ci' + - 'server/security/ubuntu-backports/build-pcre2-backport.py' + - 'server/security/ubuntu-backports/glibc/**' + - 'server/security/ubuntu-backports/rust-coreutils/**' + - 'server/security/ubuntu-backports/jq/**' + workflow_dispatch: + inputs: + module: + description: Native candidate to build and fully test + required: true + type: choice + default: all + options: [all, glibc, rust-coreutils, jq] + +permissions: + contents: read + +concurrency: + group: ubuntu-backport-native-${{ github.event.pull_request.number || github.ref }}-${{ inputs.module || 'all' }} + cancel-in-progress: true + +jobs: + native: + name: Native (${{ matrix.module }}) + runs-on: ubuntu-24.04 + timeout-minutes: 240 + strategy: + fail-fast: false + matrix: + module: ${{ fromJSON(inputs.module == 'glibc' && '["glibc"]' || inputs.module == 'rust-coreutils' && '["rust-coreutils"]' || inputs.module == 'jq' && '["jq"]' || '["glibc","rust-coreutils","jq"]') }} + env: + MODULE: ${{ matrix.module }} + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BUILD_JOBS: ${{ matrix.module == 'glibc' && '4' || '2' }} + MIN_FREE_GIB: ${{ matrix.module == 'glibc' && '20' || matrix.module == 'rust-coreutils' && '24' || '8' }} + + steps: + - name: Check out the exact candidate head + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Verify source identity and available disk + shell: bash + run: | + set -Eeuo pipefail + [[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + evidence="$RUNNER_TEMP/ubuntu-backport-$MODULE" + mkdir -p "$evidence" + printf '%s\n' "$SOURCE_SHA" >"$evidence/source-sha.txt" + git rev-parse 'HEAD^{tree}' >"$evidence/source-tree.txt" + docker_root="$(docker info --format '{{.DockerRootDir}}')" + df -Pk "$RUNNER_TEMP" "$docker_root" | tee "$evidence/disk-space.txt" + available_kib="$(df -Pk "$docker_root" | awk 'NR == 2 {print $4}')" + required_kib=$((MIN_FREE_GIB * 1024 * 1024)) + if (( available_kib < required_kib )); then + printf 'Native %s requires at least %s GiB free Docker workspace; available=%s KiB\n' \ + "$MODULE" "$MIN_FREE_GIB" "$available_kib" >&2 + exit 1 + fi + + - name: Validate the module lock and focused tests + shell: bash + run: | + set -Eeuo pipefail + module_dir="server/security/ubuntu-backports/$MODULE" + test_name="test_${MODULE//-/_}_backport.py" + helper="build-$MODULE-backport.py" + if [[ "$MODULE" == jq ]]; then test_name=test_jq_release.py; helper=build-jq-release.py; fi + evidence="$RUNNER_TEMP/ubuntu-backport-$MODULE" + python3 -B "$module_dir/$test_name" -v 2>&1 | tee "$evidence/module-tests.log" + python3 -B "$module_dir/$helper" --validate-lock \ + 2>&1 | tee "$evidence/lock-validation.log" + + - name: Build and export the isolated native evidence + shell: bash + run: | + set -Eeuo pipefail + evidence="$RUNNER_TEMP/ubuntu-backport-$MODULE" + docker buildx version + docker buildx build \ + --platform linux/amd64 \ + --file server/security/ubuntu-backports/Dockerfile.native-ci \ + --build-arg "MODULE=$MODULE" \ + --build-arg "BUILD_JOBS=$BUILD_JOBS" \ + --build-arg "SOURCE_SHA=$SOURCE_SHA" \ + --progress plain \ + --provenance=false \ + --metadata-file "$evidence/buildkit-metadata.json" \ + --output "type=local,dest=$evidence/export" \ + server/security/ubuntu-backports \ + 2>&1 | tee "$evidence/docker-build.log" + + - name: Enforce the original native exit status + if: always() + shell: bash + run: | + set -Eeuo pipefail + evidence="$RUNNER_TEMP/ubuntu-backport-$MODULE" + python3 - "$evidence/export" <<'PY' + import json, os, pathlib + directory = pathlib.Path(__import__('sys').argv[1]) + result = json.loads((directory / 'result.json').read_text()) + assert result['module'] == os.environ['MODULE'], 'Wrong module receipt' + assert result['sourceSha'] == os.environ['SOURCE_SHA'], 'Wrong candidate head' + assert result['exitCode'] == result['helperExitCode'] == result['logExitCode'] == 0, result + assert result['status'] == 'passed', result + assert (directory / 'package/provenance.json').is_file(), 'Missing owning-test provenance' + print(json.dumps(result, indent=2)) + PY + + - name: Retain native candidate evidence even on failure + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ubuntu-native-${{ matrix.module }}-${{ github.event.pull_request.head.sha || github.sha }}-${{ github.run_id }} + path: ${{ runner.temp }}/ubuntu-backport-${{ matrix.module }} + if-no-files-found: warn + retention-days: 14 + compression-level: 9 diff --git a/server/security/ubuntu-backports/Dockerfile.native-ci b/server/security/ubuntu-backports/Dockerfile.native-ci new file mode 100644 index 0000000000..7c3d9a241c --- /dev/null +++ b/server/security/ubuntu-backports/Dockerfile.native-ci @@ -0,0 +1,153 @@ +# syntax=docker/dockerfile:1.7 +# This image is a disposable native build fixture. Only /out/artifacts is exported. +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS native +ARG MODULE=glibc +ARG BUILD_JOBS=2 +ARG SOURCE_SHA +ENV DEBIAN_FRONTEND=noninteractive PYTHONDONTWRITEBYTECODE=1 +SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] + +# The original Ubuntu image has no CA bundle. Pin the bootstrap package before +# using the signed, fixed-date Ubuntu archive; TLS verification remains enabled. +ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /tmp/ca-certificates.deb +RUN <<'SH' +set -euo pipefail +case "$MODULE" in glibc|rust-coreutils|jq) ;; *) exit 2 ;; esac +mkdir -p /tmp/ca-bootstrap /etc/ssl/certs +dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap +while IFS= read -r certificate; do + cat "$certificate" + printf '\n' +done < <(find /tmp/ca-bootstrap/usr/share/ca-certificates -type f -name '*.crt' | LC_ALL=C sort) >/etc/ssl/certs/ca-certificates.crt +rm -f /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources +cat >/etc/apt/sources.list.d/pasturestack-snapshot.sources <<'SOURCES' +Types: deb deb-src +URIs: https://snapshot.ubuntu.com/ubuntu/20261023T000000Z +Suites: resolute resolute-updates resolute-security +Components: main universe restricted multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +Snapshot: no +Check-Valid-Until: no +SOURCES +cat >/etc/apt/apt.conf.d/80pasturestack <<'APT' +Acquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt"; +Acquire::https::Verify-Peer "true"; +Acquire::https::Verify-Host "true"; +Acquire::Retries "3"; +APT +apt-get update +apt-get install --no-install-recommends -y \ + build-essential debhelper dpkg-dev fakeroot patch quilt binutils python3 \ + ca-certificates gzip sudo util-linux +SH + +# The narrow context contains only these reviewed modules and their shared helper. +COPY . /modules/ +RUN <<'SH' +set -euo pipefail +helper="build-$MODULE-backport.py" +if [[ "$MODULE" == jq ]]; then helper=build-jq-release.py; fi +python3 -B "/modules/$MODULE/$helper" --validate-lock +apt-cache showsrc --only-source "$MODULE" >/tmp/native-source-index +export MODULE +python3 - <<'PY' +import json, os, pathlib +module = os.environ['MODULE'] +lock = json.loads(pathlib.Path(f'/modules/{module}/{module}.lock.json').read_text()) +records = [] +for paragraph in pathlib.Path('/tmp/native-source-index').read_text().split('\n\n'): + fields, key = {}, None + for line in paragraph.splitlines(): + if line.startswith((' ', '\t')) and key: + fields[key] += '\n' + line.strip() + elif ':' in line: + key, value = line.split(':', 1) + fields[key] = value.strip() + if fields.get('Package') == module and fields.get('Version') == lock['ubuntuSourceVersion']: + records.append(fields) +assert records, 'Pinned source version absent from the fixed snapshot' +for fields in records: + checksums = {name: (sha, int(size)) for sha, size, name in + (line.split() for line in fields['Checksums-Sha256'].splitlines() if line.strip())} + for item in lock['sourceFiles']: + assert checksums.get(item['name']) == (item['sha256'], item['size']), 'Snapshot/lock source mismatch' +pathlib.Path('/tmp/native-source-version').write_text(lock['ubuntuSourceVersion']) +PY +version="$(cat /tmp/native-source-version)" +if [[ "$MODULE" == glibc ]]; then + apt-get build-dep --no-install-recommends --arch-only --build-profiles=nobiarch,noudeb -y "glibc=$version" +else + apt-get build-dep --no-install-recommends --arch-only -y "$MODULE=$version" +fi +useradd --create-home --user-group --uid 10001 --shell /bin/bash builder +if [[ "$MODULE" == rust-coreutils ]]; then + # Only the helper's bounded credential subprocess uses sudo. No host mount + # or persistent host user/group edit is available in this disposable build. + printf 'builder ALL=(root) NOPASSWD: /usr/bin/setpriv *\n' >/etc/sudoers.d/native-ci-setpriv + chmod 0440 /etc/sudoers.d/native-ci-setpriv + visudo --check --file=/etc/sudoers.d/native-ci-setpriv +fi +install -d -o builder -g builder -m 0755 /work /out /out/artifacts +cp /tmp/native-source-index /out/artifacts/snapshot-source-index.txt +cp /tmp/native-source-version /out/artifacts/snapshot-source-version.txt +chown builder:builder /out/artifacts/snapshot-source-*.txt +SH + +USER builder +WORKDIR /work +RUN <<'SH' +set -euo pipefail +[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] +[[ "$BUILD_JOBS" == 2 || "$BUILD_JOBS" == 4 ]] +test "$(id -u)" != 0 +export MODULE SOURCE_SHA +df -Pk /work /out >/out/artifacts/container-disk-space.txt +test_name="test_${MODULE//-/_}_backport.py" +helper="build-$MODULE-backport.py" +if [[ "$MODULE" == jq ]]; then test_name=test_jq_release.py; helper=build-jq-release.py; fi +python3 -B "/modules/$MODULE/$test_name" -v + +# Defer the unchanged helper's exit solely to export its failure evidence. +# result.json is enforced by the workflow's always-running gate; a failed helper +# or failed log stream can never produce a passing native candidate job. +set +e +python3 -B "/modules/$MODULE/$helper" \ + --output-dir /out/artifacts/package --work-dir /work/native --jobs "$BUILD_JOBS" \ + 2>&1 | tee /out/artifacts/native-console.log +codes=("${PIPESTATUS[@]}") +set -e +export HELPER_EXIT_CODE="${codes[0]}" LOG_EXIT_CODE="${codes[1]}" +python3 - <<'PY' +import hashlib, json, os, pathlib +module = os.environ['MODULE'] +helper, log = int(os.environ['HELPER_EXIT_CODE']), int(os.environ['LOG_EXIT_CODE']) +lock = pathlib.Path(f'/modules/{module}/{module}.lock.json') +result = {'schemaVersion': 1, 'module': module, 'sourceSha': os.environ['SOURCE_SHA'], + 'helperExitCode': helper, 'logExitCode': log, 'exitCode': helper or log, + 'status': 'passed' if helper == log == 0 else 'failed', + 'sourceLockSha256': hashlib.sha256(lock.read_bytes()).hexdigest(), + 'publicationPerformed': False, 'deploymentPerformed': False} +pathlib.Path('/out/artifacts/result.json').write_text(json.dumps(result, indent=2) + '\n') +PY + +# Retain only logs/configuration/results; never export source, Cargo target trees, +# the container rootfs, cores, or the complete native build directory. +mkdir /out/artifacts/diagnostics +for name in build.log strict-tests.log; do + if [[ -f "/work/native/$name" ]]; then + gzip -c "/work/native/$name" >"/out/artifacts/diagnostics/$name.gz" + fi +done +if [[ "$MODULE" == glibc ]]; then + for name in tests.sum configparms config.make; do + path="/work/native/glibc-source/build-tree/amd64-libc/$name" + if [[ -f "$path" ]]; then + gzip -c "$path" >"/out/artifacts/diagnostics/$name.gz" + fi + done +fi +gzip /out/artifacts/native-console.log +SH + +FROM scratch AS artifacts +COPY --from=native /out/artifacts/ / diff --git a/server/security/ubuntu-backports/glibc/build-glibc-backport.py b/server/security/ubuntu-backports/glibc/build-glibc-backport.py new file mode 100644 index 0000000000..49dcb0529e --- /dev/null +++ b/server/security/ubuntu-backports/glibc/build-glibc-backport.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""Build selected first-party glibc debs in a disposable Ubuntu 26.04 amd64 CI builder. + +Uses original Ubuntu packaging, native tests and hardening. Never installs debs. +Native compilation is intentionally separate from --validate-lock/--validate-inputs. +""" +import argparse +import collections +import datetime +import email.utils +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.request + +HERE = Path(__file__).resolve().parent +LOCK_FILE = HERE / 'glibc.lock.json' +_spec = importlib.util.spec_from_file_location('ubuntu_backport_common', HERE.parent / 'build-pcre2-backport.py') +COMMON = importlib.util.module_from_spec(_spec) +_bytecode = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + _spec.loader.exec_module(COMMON) +finally: + sys.dont_write_bytecode = _bytecode +require, digest, run, capture = COMMON.require, COMMON.digest, COMMON.run, COMMON.capture +PACKAGES = ['libc6', 'libc-bin', 'libc-gconv-modules-extra'] +DEV_PACKAGES = ['libc6-dev', 'libc-dev-bin'] +FIX = '72351055c6a1328c1ce2c06b72b8a4e3ebe0ef08' +REGRESSION = '20b3e8717652bdc2f89c99c4cc6e38d56a754866' +SOURCE_PINS = { + 'glibc_2.43-2ubuntu2.4.dsc': ('da10d551ca51076bf7bf663de38369d04e267386b8211c283c2814e68e482ed3', 9078), + 'glibc_2.43.orig.tar.xz': ('d9c86c6b5dbddb43a3e08270c5844fc5177d19442cf5b8df4be7c07cd5fa3831', 20297012), + 'glibc_2.43.orig.tar.xz.asc': ('0a725eb20ceb526abf52d3129f03e0399afa0d97ffa1dd779bed42958ab23326', 1018), + 'glibc_2.43-2ubuntu2.4.debian.tar.xz': ('28103a7cf808c29901c6053d89a4e8299880abfbb850c0d97a09c8df8533e306', 530764)} +PATCH_PINS = {FIX: ('73ca6cb57d78c89785cd2bdd5c15cf7e5fdee873599bb8f1623035a2692915f1', 2175), + REGRESSION: ('bf4de5ac1937b76fd21ce003c77f5681b899edb8a42326ff3a13bf1ba4ab4ba1', 3183)} +OLD_CALL = "? upstr (ccs, cs + 5) : ccs) != 0)" +NEW_CALL = "if (ccs[2] == '\\0' || __wcsmbs_named_conv (&fcts, ccs) != 0)" +CONTROLS = ['libio/tst-fopen-ccs-empty', 'libio/tst-fopenloc', 'libio/tst-fopenloc2'] + + +def load_lock(file): + lock = json.loads(file.read_text(encoding='utf-8')) + expected = {'schemaVersion': 1, 'sourcePackage': 'glibc', 'ubuntuSourceVersion': '2.43-2ubuntu2.4', + 'customVersion': '2.43-2ubuntu2.4+pasturestack.1', 'distribution': 'resolute', + 'osVersion': '26.04', 'architecture': 'amd64', 'buildProfiles': ['nobiarch', 'noudeb'], + 'runtimePackages': PACKAGES, 'optionalDevPackages': DEV_PACKAGES, + 'fixedCves': ['CVE-2026-18374']} + require(all(lock.get(k) == v for k, v in expected.items()), 'Unsupported glibc lock identity/profiles') + require(type(lock.get('sourceDateEpoch')) is int and lock['sourceDateEpoch'] > 0, + 'SOURCE_DATE_EPOCH must be a positive integer') + upstream = lock['upstream'] + require(upstream['repository'] == 'https://sourceware.org/git/glibc.git' + and upstream['backportBranch'] == 'release/2.43/master' + and upstream['fixCommit'] == FIX and upstream['regressionCommit'] == REGRESSION, + 'Only the official 2.43 fix and its regression backport are supported') + sources, patches = lock['sourceFiles'], lock['patches'] + require(len(sources) == 4 and {i['name'] for i in sources} == set(SOURCE_PINS), + 'Exactly the original Ubuntu DSC/tarballs/signature are required') + require([i['commit'] for i in patches] == [FIX, REGRESSION], 'Unexpected patch set/order/branch') + for item in sources + patches: + name = item['name'] + require(Path(name).name == name and '/' not in name and '\\' not in name, 'Unsafe input filename') + if item in sources: + pin, size = SOURCE_PINS[name] + url = 'https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/' + name + else: + pin, size = PATCH_PINS[item['commit']] + require(name == item['commit'] + '.sourceware.patch', 'Unexpected patch filename') + url = 'https://sourceware.org/git/?p=glibc.git;a=patch;h=' + item['commit'] + require(item['sha256'] == pin and type(item['size']) is int and item['size'] == size, + 'Input differs from approved source SHA-256/length') + require(item['url'] == url, 'Unexpected official source URL') + require('First-party' in lock['identity'] and 'not issued by Canonical' in lock['identity'], + 'Custom output must not claim Canonical provenance') + return lock + + +def download(item, directory, cache): + """Reuse common HTTPS/hash checks; glibc's 20MiB input needs its own pinned-size bound.""" + destination = directory / item['name'] + cached = cache / item['name'] if cache else None + if cached and cached.exists(): + COMMON.verify_input(item, cached) + shutil.copyfile(cached, destination) + else: + opener = urllib.request.build_opener(COMMON.HttpsRedirect()) + with opener.open(item['url'], timeout=60) as response, destination.open('xb') as output: + require(response.url.startswith('https://'), 'Refusing non-HTTPS input') + size = 0 + while True: + chunk = response.read(65536) + if not chunk: + break + size += len(chunk) + require(size <= item['size'], 'Input exceeds its pinned size') + output.write(chunk) + COMMON.verify_input(item, destination) + if cached: + shutil.copyfile(destination, cached) + COMMON.verify_input(item, destination) + return destination + + +def verify_source_control(lock, directory): + text = (directory / 'glibc_2.43-2ubuntu2.4.dsc').read_text(encoding='utf-8') + require(all(re.search('^' + re.escape(line) + '$', text, re.M) for line in + ['Source: glibc', 'Version: 2.43-2ubuntu2.4', 'Format: 3.0 (quilt)']), + 'Unexpected Ubuntu source control') + block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) + require(block is not None, 'DSC lacks source checksums') + rows = re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M) + expected = {i['name']: (i['sha256'], i['size']) for i in lock['sourceFiles'] if not i['name'].endswith('.dsc')} + require(len(rows) == 3 and {name: (sha, int(size)) for sha, size, name in rows} == expected, + 'DSC does not bind exactly the approved original tarball, signature and packaging') + + +def verify_patch(item, file): + COMMON.verify_input(item, file) + text = file.read_text(encoding='utf-8') + require(text.startswith('From ' + item['commit'] + ' '), 'Patch commit identity mismatch') + expected = ['libio/fileops.c'] if item['commit'] == FIX else ['libio/Makefile', 'libio/tst-fopen-ccs-empty.c'] + paths = re.findall(r'^diff --git a/(\S+) b/(\S+)$', text, re.M) + require(paths == [(p, p) for p in expected], 'Patch must stay within its approved upstream paths') + require(not re.search(r'^(?:old mode|new mode|rename from|rename to|GIT binary patch)', text, re.M), + 'Unexpected patch metadata') + + +def validate_inputs(lock, directory): + for item in lock['sourceFiles'] + lock['patches']: + COMMON.verify_input(item, directory / item['name']) + verify_source_control(lock, directory) + for item in lock['patches']: + verify_patch(item, directory / item['name']) + + +def build_environment(lock, jobs): + # Package rules supply their existing flags/CET/symbol policy. Inherited + # compiler/profile/test overrides cannot weaken those settings. + env = COMMON.build_environment(lock, jobs) + env['DEB_BUILD_PROFILES'] = 'nobiarch noudeb' + return env + + +def source_closure(source): + text = (source / 'libio/fileops.c').read_text(encoding='utf-8') + require(NEW_CALL in text and OLD_CALL not in text, 'Official source closure is absent') + require('tst-fopen-ccs-empty' in (source / 'libio/Makefile').read_text(encoding='utf-8') + and (source / 'libio/tst-fopen-ccs-empty.c').is_file(), 'Official regression is not registered') + return {p: digest(source / p) for p in + ['libio/fileops.c', 'libio/Makefile', 'libio/tst-fopen-ccs-empty.c']} + + +def test_receipt(source): + report = source / 'build-tree/amd64-libc/tests.sum' + require(report.is_file(), 'Missing full native tests.sum') + text = report.read_text(encoding='utf-8') + entries = re.findall(r'^(PASS|FAIL|XFAIL|XPASS|UNSUPPORTED|UNRESOLVED|ERROR): (\S+)$', text, re.M) + require(entries and len(entries) == len([line for line in text.splitlines() if line.strip()]) + and len({name for _, name in entries}) == len(entries), 'Ambiguous native test results') + states = dict((name, state) for state, name in entries) + counts = collections.Counter(state for state, _ in entries) + require(not any(counts[s] for s in ['FAIL', 'ERROR', 'UNRESOLVED']), 'Unexpected native test failure') + require(all(states.get(name) == 'PASS' for name in CONTROLS), 'CVE regression/valid charset controls did not PASS') + for component in ['libio', 'stdio-common', 'iconv', 'wcsmbs', 'malloc', 'elf', 'nptl', 'nss']: + require(any(state == 'PASS' and name.startswith(component + '/') for state, name in entries), + 'Native component suite did not execute: ' + component) + require((source / 'stamp-dir/check_libc_passed').is_file() + and not (source / 'stamp-dir/check_libc_failed').exists(), 'Ubuntu native test gate did not pass') + require(not (source / 'build-tree/amd64-i386').exists() + and not (source / 'build-tree/amd64-x32').exists(), 'Unexpected extra ABI build') + return {'report': 'build-tree/amd64-libc/tests.sum', 'sha256': digest(report), 'counts': dict(counts), + 'controls': {name: states[name] for name in CONTROLS}, 'ubuntuGate': 'check_libc_passed', + 'semantics': 'XFAIL/UNSUPPORTED/XPASS are retained verbatim under unchanged Ubuntu rules; they are not zero failures or universal coverage.'} + + +def verify_debs(lock, work, source, environment, log, dev_packages): + selected = [] + extracted = work / 'verify-runtime' + extracted.mkdir() + for package in PACKAGES + dev_packages: + matches = list(work.glob(package + '_' + lock['customVersion'] + '_amd64.deb')) + require(len(matches) == 1, 'Missing/ambiguous selected deb: ' + package) + deb = matches[0] + for field, value in [('Package', package), ('Version', lock['customVersion']), ('Architecture', 'amd64')]: + require(COMMON.deb_field(deb, field, work, environment) == value, 'Unexpected deb metadata: ' + field) + require(COMMON.deb_field(deb, 'Source', work, environment) in + ['glibc', 'glibc (' + lock['customVersion'] + ')'], 'Unexpected deb Source identity') + run(['dpkg-deb', '--extract', str(deb), str(extracted)], work, environment, log) + selected.append({'package': package, 'version': lock['customVersion'], 'architecture': 'amd64', + 'file': deb.name, 'sha256': digest(deb), + 'role': 'runtime' if package in PACKAGES else 'explicit-consumer-dev-compatibility'}) + libc = extracted / 'usr/lib/x86_64-linux-gnu/libc.so.6' + require(libc.is_file() and libc.resolve().is_relative_to(extracted.resolve()), 'Candidate libc is absent or escapes extraction') + dynamic = capture(['readelf', '--dynamic', str(libc)], work, environment) + headers = capture(['readelf', '--program-headers', str(libc)], work, environment) + elf = capture(['readelf', '--file-header', str(libc)], work, environment) + require('ELF64' in elf and 'X86-64' in elf.upper(), 'Candidate libc is not native amd64 ELF64') + require('[libc.so.6]' in dynamic and 'GNU_RELRO' in headers + and ('BIND_NOW' in dynamic or re.search(r'FLAGS_1.*NOW', dynamic)), 'libc SONAME/hardening changed') + symbols = capture(['nm', '--dynamic', '--defined-only', str(libc)], work, environment) + require(all(re.search(r'\b' + name + r'@@?GLIBC_[0-9.]+$', symbols, re.M) + for name in ['fopen', 'fopen64', 'malloc', '__libc_start_main']), 'Required versioned libc ABI missing') + require((extracted / 'usr/sbin/ldconfig').is_file(), 'Selected libc-bin lacks ldconfig') + require(list((extracted / 'usr/lib/x86_64-linux-gnu/gconv').glob('*.so')), 'Selected gconv-extra has no modules') + return selected + + +def build(args, lock): + os_release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) + require(os_release.get('ID') == 'ubuntu' and os_release.get('VERSION_ID') == '26.04', 'Use an isolated Ubuntu 26.04 builder') + environment = build_environment(lock, args.jobs) + for command in ['dpkg', 'dpkg-source', 'dpkg-buildpackage', 'dpkg-deb', 'dpkg-buildflags', 'dpkg-checkbuilddeps', + 'dpkg-query', 'quilt', 'readelf', 'nm', 'fakeroot']: + require(shutil.which(command, path=environment['PATH']), 'Missing builder dependency: ' + command) + require(capture(['dpkg', '--print-architecture'], HERE, environment) == 'amd64', 'Use a native amd64 builder') + output = args.output_dir.resolve() + require(not output.exists(), 'Output directory must be new') + cache = args.cache_dir.resolve() if args.cache_dir else None + COMMON.require_separate_paths(output, args.work_dir.resolve() if args.work_dir else None, cache) + work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='glibc-ubuntu-backport-')) + if args.work_dir: + work.mkdir(parents=True, exist_ok=False) + print('GLIBC_BACKPORT_WORK_DIR=' + str(work), flush=True) + inputs = work / 'inputs' + inputs.mkdir() + if cache: + cache.mkdir(parents=True, exist_ok=True) + for item in lock['sourceFiles'] + lock['patches']: + download(item, inputs, cache) + validate_inputs(lock, inputs) + log = work / 'build.log' + source = work / 'glibc-source' + run(['dpkg-source', '--extract', str(inputs / 'glibc_2.43-2ubuntu2.4.dsc'), str(source)], work, environment, log) + packaging = COMMON.packaging_snapshot(source) + original_series = (source / 'debian/patches/series').read_text(encoding='utf-8') + require(OLD_CALL in (source / 'libio/fileops.c').read_text(encoding='utf-8'), 'Original source boundary changed') + environment['QUILT_PATCHES'] = 'debian/patches' + for item in lock['patches']: + run(['quilt', 'import', '-P', item['name'], str(inputs / item['name'])], source, environment, log) + run(['quilt', 'push', '--fuzz=0'], source, environment, log) + require(all(digest(source / name) == sha for name, sha in packaging.items() + if name != 'debian/patches/series'), 'Official backports changed Ubuntu packaging rules') + series = (source / 'debian/patches/series').read_text(encoding='utf-8') + require(series.startswith(original_series) and series[len(original_series):].split() + == [item['name'] for item in lock['patches']], 'Existing quilt series changed or extra patches added') + source_proof = source_closure(source) + approved_packaging = COMMON.packaging_snapshot(source) + changelog = source / 'debian/changelog' + date = email.utils.format_datetime(datetime.datetime.fromtimestamp(lock['sourceDateEpoch'], datetime.timezone.utc)) + changelog.write_text('glibc (' + lock['customVersion'] + ') resolute; urgency=high\n\n' + ' * First-party PastureStack rebuild; not issued by Canonical.\n' + ' * Apply official glibc 2.43 CVE-2026-18374 fix and regression test.\n\n' + ' -- PastureStack maintainers ' + date + '\n\n' + + changelog.read_text(encoding='utf-8'), encoding='utf-8') + # -G emits custom source provenance plus arch-specific binaries. Original + # tarballs stay beside the new DSC; no distro-independent docs are built. + for item in lock['sourceFiles']: + if '.orig.tar.' in item['name']: + shutil.copyfile(inputs / item['name'], work / item['name']) + run(['dpkg-checkbuilddeps', '-B', '-P' + ','.join(lock['buildProfiles'])], source, environment, log) + command = ['dpkg-buildpackage', '-G', '-us', '-uc', '-P' + ','.join(lock['buildProfiles']), COMMON.job_option(args.jobs)] + run(command, source, environment, log) + require(all((source / name).is_file() and digest(source / name) == sha + for name, sha in approved_packaging.items()), 'Build changed approved Ubuntu packaging inputs') + require(source_closure(source) == source_proof, 'Built source no longer matches the approved fix') + tests = test_receipt(source) + # Return package names from the fixed catalog, never CLI-derived path tokens. + dev_exports = [package for package in DEV_PACKAGES if package in args.export_dev_package] + selected = verify_debs(lock, work, source, environment, log, dev_exports) + output.mkdir(parents=True) + for item in selected: + shutil.copyfile(work / item['file'], output / item['file']) + for pattern in ['glibc_*.buildinfo', 'glibc_*.changes', 'glibc_' + lock['customVersion'] + '.dsc', + 'glibc_' + lock['customVersion'] + '.debian.tar.*']: + files = list(work.glob(pattern)) + require(len(files) == 1, 'Missing/ambiguous custom package/source receipt: ' + pattern) + shutil.copyfile(files[0], output / files[0].name) + for file, name in [(log, 'build.log'), (LOCK_FILE, 'glibc.lock.json'), + (source / 'debian/copyright', 'COPYRIGHT'), + (source / tests['report'], 'tests.sum'), + (source / 'build-tree/amd64-libc/configparms', 'native-configparms'), + (source / 'build-tree/amd64-libc/config.make', 'native-config.make')]: + shutil.copyfile(file, output / name) + for name in CONTROLS: + for suffix in ['.out', '.test-result']: + report = source / 'build-tree/amd64-libc' / (name + suffix) + require(report.is_file(), 'Missing native control output: ' + name + suffix) + shutil.copyfile(report, output / (Path(name).name + suffix)) + (output / 'builder-packages.tsv').write_text(capture( + ['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\n'], work, environment) + '\n', encoding='utf-8') + provenance = {'schemaVersion': 1, 'identity': lock['identity'], 'sourceLockSha256': digest(LOCK_FILE), + 'sourceDateEpoch': lock['sourceDateEpoch'], 'sourceFiles': lock['sourceFiles'], + 'upstream': lock['upstream'], 'patches': lock['patches'], 'fixedCves': lock['fixedCves'], + 'debianPackagingInputSha256': packaging, 'approvedPackagingSha256': approved_packaging, + 'sourceClosure': source_proof, 'buildEnvironment': environment, 'buildCommand': command, + 'dpkgBuildflagsContext': capture(['dpkg-buildflags', '--export=sh'], work, environment), + 'actualCompilerConfiguration': ['native-configparms', 'native-config.make', 'build.log'], + 'tests': tests, 'runtimePackages': [i for i in selected if i['role'] == 'runtime'], + 'compatibilityPackages': [i for i in selected if i['role'] != 'runtime'], + 'devPackagePolicy': 'Explicitly requested exports only; consumers may install a dev package only if already present. This helper installs no packages.', + 'scope': 'Native amd64 original Ubuntu packaging; nobiarch/noudeb; full native upstream suite and original expected-failure/ABI/hardening rules.', + 'signaturePolicy': 'Official input bytes SHA-256 pinned; custom outputs are not issued or signed by Canonical.'} + (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') + (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(LOCK_FILE) + '\n' + ''.join( + '\t'.join([i['package'], i['version'], i['architecture'], i['sha256'], i['file']]) + '\n' for i in selected), encoding='utf-8') + (output / 'SHA256SUMS').write_text(''.join(digest(f) + ' ' + f.name + '\n' for f in sorted(output.iterdir()) if f.is_file()), encoding='utf-8') + print('GLIBC_BACKPORT_BUILT packages=' + str(len(selected)) + ' lock=' + digest(LOCK_FILE), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--validate-lock', action='store_true') + mode.add_argument('--validate-inputs', action='store_true') + parser.add_argument('--output-dir', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--cache-dir', type=Path) + parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) + parser.add_argument('--export-dev-package', action='append', choices=DEV_PACKAGES, default=[]) + args = parser.parse_args() + require(1 <= args.jobs <= 32, 'Build jobs must be between 1 and 32') + require(len(set(args.export_dev_package)) == len(args.export_dev_package), 'Duplicate dev export') + lock = load_lock(LOCK_FILE) + if args.validate_lock: + print('GLIBC_BACKPORT_LOCK_OK sha256=' + digest(LOCK_FILE)) + elif args.validate_inputs: + require(args.cache_dir is not None, '--validate-inputs requires --cache-dir') + validate_inputs(lock, args.cache_dir) + print('GLIBC_BACKPORT_INPUTS_OK sha256=' + digest(LOCK_FILE)) + else: + require(args.output_dir is not None, '--output-dir is required for a native build') + build(args, lock) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: + print('GLIBC_BACKPORT_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/glibc/glibc.lock.json b/server/security/ubuntu-backports/glibc/glibc.lock.json new file mode 100644 index 0000000000..ea07e421fe --- /dev/null +++ b/server/security/ubuntu-backports/glibc/glibc.lock.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "sourcePackage": "glibc", + "ubuntuSourceVersion": "2.43-2ubuntu2.4", + "customVersion": "2.43-2ubuntu2.4+pasturestack.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "sourceDateEpoch": 1792857600, + "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18721986", + "sourceFiles": [ + { + "sha256": "da10d551ca51076bf7bf663de38369d04e267386b8211c283c2814e68e482ed3", + "size": 9078, + "name": "glibc_2.43-2ubuntu2.4.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/glibc_2.43-2ubuntu2.4.dsc" + }, + { + "sha256": "d9c86c6b5dbddb43a3e08270c5844fc5177d19442cf5b8df4be7c07cd5fa3831", + "size": 20297012, + "name": "glibc_2.43.orig.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/glibc_2.43.orig.tar.xz" + }, + { + "sha256": "0a725eb20ceb526abf52d3129f03e0399afa0d97ffa1dd779bed42958ab23326", + "size": 1018, + "name": "glibc_2.43.orig.tar.xz.asc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/glibc_2.43.orig.tar.xz.asc" + }, + { + "sha256": "28103a7cf808c29901c6053d89a4e8299880abfbb850c0d97a09c8df8533e306", + "size": 530764, + "name": "glibc_2.43-2ubuntu2.4.debian.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/glibc_2.43-2ubuntu2.4.debian.tar.xz" + } + ], + "upstream": { + "fixCommit": "72351055c6a1328c1ce2c06b72b8a4e3ebe0ef08", + "repository": "https://sourceware.org/git/glibc.git", + "advisory": "https://sourceware.org/git/?p=glibc.git;a=blob;f=advisories/GLIBC-SA-2026-0015", + "backportBranch": "release/2.43/master", + "regressionCommit": "20b3e8717652bdc2f89c99c4cc6e38d56a754866", + "ubuntuCve": "https://ubuntu.com/security/CVE-2026-18374" + }, + "patches": [ + { + "sha256": "73ca6cb57d78c89785cd2bdd5c15cf7e5fdee873599bb8f1623035a2692915f1", + "size": 2175, + "name": "72351055c6a1328c1ce2c06b72b8a4e3ebe0ef08.sourceware.patch", + "commit": "72351055c6a1328c1ce2c06b72b8a4e3ebe0ef08", + "url": "https://sourceware.org/git/?p=glibc.git;a=patch;h=72351055c6a1328c1ce2c06b72b8a4e3ebe0ef08" + }, + { + "sha256": "bf4de5ac1937b76fd21ce003c77f5681b899edb8a42326ff3a13bf1ba4ab4ba1", + "size": 3183, + "name": "20b3e8717652bdc2f89c99c4cc6e38d56a754866.sourceware.patch", + "commit": "20b3e8717652bdc2f89c99c4cc6e38d56a754866", + "url": "https://sourceware.org/git/?p=glibc.git;a=patch;h=20b3e8717652bdc2f89c99c4cc6e38d56a754866" + } + ], + "buildProfiles": [ + "nobiarch", + "noudeb" + ], + "runtimePackages": [ + "libc6", + "libc-bin", + "libc-gconv-modules-extra" + ], + "optionalDevPackages": [ + "libc6-dev", + "libc-dev-bin" + ], + "fixedCves": [ + "CVE-2026-18374" + ], + "identity": "First-party PastureStack rebuild of official Ubuntu source with the official glibc 2.43 fix and regression backport; not issued by Canonical." +} diff --git a/server/security/ubuntu-backports/glibc/test_glibc_backport.py b/server/security/ubuntu-backports/glibc/test_glibc_backport.py new file mode 100644 index 0000000000..35e0431c55 --- /dev/null +++ b/server/security/ubuntu-backports/glibc/test_glibc_backport.py @@ -0,0 +1,207 @@ +"""Network-free input, negative boundary and CLI checks; no glibc compilation.""" +import copy +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import urllib.request + +HERE = Path(__file__).resolve().parent +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location('glibc_backport', HERE / 'build-glibc-backport.py') +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class GlibcBackport(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='glibc-backport-contract-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.lock = MODULE.load_lock(HERE / 'glibc.lock.json') + + def load(self, value): + file = self.root / 'fixture.lock.json' + file.write_text(json.dumps(value), encoding='utf-8') + return MODULE.load_lock(file) + + def test_lock_has_exact_source_fix_regression_and_native_boundary(self): + self.assertEqual(4, len(self.lock['sourceFiles'])) + self.assertEqual([MODULE.FIX, MODULE.REGRESSION], [p['commit'] for p in self.lock['patches']]) + self.assertEqual(['nobiarch', 'noudeb'], self.lock['buildProfiles']) + self.assertEqual(['CVE-2026-18374'], self.lock['fixedCves']) + + def test_foreign_identity_architecture_profile_or_runtime_set_fails(self): + for field, value in [('sourcePackage', 'eglibc'), ('ubuntuSourceVersion', '2.44-1'), + ('customVersion', '2.43-2ubuntu2.4'), ('architecture', 'arm64'), + ('buildProfiles', ['nocheck']), ('runtimePackages', ['libc6']), + ('optionalDevPackages', ['libc6-dev']), ('sourceDateEpoch', True), + ('identity', 'Official Canonical update')]: + with self.subTest(field=field): + lock = copy.deepcopy(self.lock); lock[field] = value + with self.assertRaises(ValueError): self.load(lock) + + def test_missing_duplicate_or_tampered_source_pin_fails(self): + for mutation in ['missing', 'duplicate', 'sha256', 'size', 'url', 'name']: + lock = copy.deepcopy(self.lock) + if mutation == 'missing': lock['sourceFiles'].pop() + elif mutation == 'duplicate': lock['sourceFiles'][1] = lock['sourceFiles'][0] + else: lock['sourceFiles'][0][mutation] = {'sha256': '0' * 64, 'size': 9079, + 'url': 'https://example.invalid/source', 'name': '../outside.dsc'}[mutation] + with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) + + def test_other_branch_reordered_missing_or_rehashed_patch_fails(self): + for mutation in ['branch', 'order', 'missing', 'duplicate', 'sha256', 'url']: + lock = copy.deepcopy(self.lock) + if mutation == 'branch': lock['patches'][0]['commit'] = '9765a538ebf8661a6e5578e01e35a3dd30db7eb4' + if mutation == 'order': lock['patches'].reverse() + if mutation == 'missing': lock['patches'].pop() + if mutation == 'duplicate': lock['patches'][1] = lock['patches'][0] + if mutation == 'sha256': lock['patches'][0]['sha256'] = '0' * 64 + if mutation == 'url': lock['patches'][0]['url'] = 'http://sourceware.org/patch' + with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) + + def test_dsc_binds_quilt_format_and_all_three_original_inputs(self): + text = 'Format: 3.0 (quilt)\nSource: glibc\nVersion: 2.43-2ubuntu2.4\nChecksums-Sha256:\n' + ''.join( + ' {sha256} {size} {name}\n'.format(**item) for item in self.lock['sourceFiles'][1:]) + file = self.root / 'glibc_2.43-2ubuntu2.4.dsc' + file.write_text(text, encoding='utf-8'); MODULE.verify_source_control(self.lock, self.root) + for bad in [text.replace('3.0 (quilt)', '1.0'), text.replace('Source: glibc', 'Source: other'), + text.replace('2.43-2ubuntu2.4\n', '2.44-1\n'), text.rsplit('\n', 2)[0] + '\n', + text + text.splitlines()[-1] + '\n', + text.replace(self.lock['sourceFiles'][1]['sha256'], '0' * 64)]: + file.write_text(bad, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.verify_source_control(self.lock, self.root) + + def test_poisoned_cache_fails_without_network_fallback(self): + cache, output = self.root / 'cache', self.root / 'input' + cache.mkdir(); output.mkdir(); data = b'official pinned bytes\n' + item = {'name': 'input.patch', 'url': 'https://example.invalid/never', + 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()} + cached = cache / item['name']; cached.write_bytes(data) + with mock.patch.object(urllib.request, 'build_opener') as network: + self.assertEqual(data, MODULE.download(item, output, cache).read_bytes()) + cached.write_bytes(data + b'tampered') + with self.assertRaises(ValueError): MODULE.download(item, output, cache) + network.assert_not_called() + + def test_download_limit_uses_exact_pin_instead_of_generic_cap(self): + class Response(io.BytesIO): + url = 'https://sourceware.org/patch' + item = {'name': 'input.patch', 'url': Response.url, 'size': 3, + 'sha256': hashlib.sha256(b'abc').hexdigest()} + opener = mock.Mock(); opener.open.return_value = Response(b'abcd') + with mock.patch.object(urllib.request, 'build_opener', return_value=opener): + with self.assertRaisesRegex(ValueError, 'pinned size'): MODULE.download(item, self.root, None) + + def test_https_redirect_downgrade_fails(self): + with self.assertRaises(ValueError): + MODULE.COMMON.HttpsRedirect().redirect_request(urllib.request.Request('https://sourceware.org/patch'), + None, 302, 'Found', {}, 'http://example.invalid/patch') + + def test_patch_commit_paths_metadata_and_bytes_are_verified(self): + file = self.root / 'fix.patch' + original = 'From ' + MODULE.FIX + ' Mon Sep 17 00:00:00 2001\ndiff --git a/libio/fileops.c b/libio/fileops.c\n' + for text, valid in [(original, True), (original.replace(MODULE.FIX, '0' * 40), False), + (original.replace('libio/fileops.c', 'debian/rules'), False), + (original.replace('libio/fileops.c', 'libio/../../outside'), False), + (original + 'new mode 100755\n', False)]: + file.write_text(text, encoding='utf-8') + item = {'name': file.name, 'commit': MODULE.FIX, 'size': len(file.read_bytes()), + 'sha256': MODULE.digest(file)} + if valid: MODULE.verify_patch(item, file) + else: + with self.assertRaises(ValueError): MODULE.verify_patch(item, file) + file.write_text(original, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.verify_patch(item, file) + + def test_environment_cannot_skip_tests_replace_compilers_or_expand_abis(self): + with mock.patch.dict(os.environ, {'DEB_BUILD_OPTIONS': 'nocheck noopt', 'DEB_BUILD_PROFILES': 'stage1', + 'RUN_TESTSUITE': 'no', 'CC': 'unsafe', 'CFLAGS': '-O0'}): + environment = MODULE.build_environment(self.lock, 4) + self.assertEqual('parallel=4', environment['DEB_BUILD_OPTIONS']) + self.assertEqual('nobiarch noudeb', environment['DEB_BUILD_PROFILES']) + self.assertEqual('hardening=+all', environment['DEB_BUILD_MAINT_OPTIONS']) + for name in ['RUN_TESTSUITE', 'CC', 'CFLAGS']: self.assertNotIn(name, environment) + + def test_source_closure_rejects_old_fallback_or_unregistered_regression(self): + directory = self.root / 'libio'; directory.mkdir() + source = directory / 'fileops.c'; source.write_text(MODULE.NEW_CALL, encoding='utf-8') + (directory / 'Makefile').write_text('tst-fopen-ccs-empty', encoding='utf-8') + (directory / 'tst-fopen-ccs-empty.c').write_text('official fixture', encoding='utf-8') + self.assertEqual(3, len(MODULE.source_closure(self.root))) + source.write_text(MODULE.NEW_CALL + MODULE.OLD_CALL, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.source_closure(self.root) + source.write_text(MODULE.NEW_CALL, encoding='utf-8') + (directory / 'Makefile').write_text('', encoding='utf-8') + with self.assertRaises(ValueError): MODULE.source_closure(self.root) + + def native_fixture(self): + directory = self.root / 'build-tree/amd64-libc'; directory.mkdir(parents=True) + (self.root / 'stamp-dir').mkdir() + (self.root / 'stamp-dir/check_libc_passed').touch() + text = ''.join('PASS: ' + name + '\n' for name in MODULE.CONTROLS) + ''.join( + 'PASS: ' + component + '/ordinary-control\n' for component in + ['stdio-common', 'iconv', 'wcsmbs', 'malloc', 'elf', 'nptl', 'nss']) + text += 'XFAIL: elf/known-failure\nUNSUPPORTED: nss/unsupported-environment\nXPASS: elf/known-xpass\n' + file = directory / 'tests.sum'; file.write_text(text, encoding='utf-8') + return file, text + + def test_full_native_receipt_preserves_xfail_unsupported_xpass_semantics(self): + self.native_fixture() + result = MODULE.test_receipt(self.root) + self.assertEqual({'PASS': 10, 'XFAIL': 1, 'UNSUPPORTED': 1, 'XPASS': 1}, result['counts']) + + def test_native_failure_skipped_cve_component_or_extra_abi_fails(self): + file, text = self.native_fixture() + for bad in [text + 'FAIL: libio/surviving-bug\n', text + 'ERROR: elf/error\n', + text + 'NEW_UNKNOWN_STATE: libio/unknown\n', + text.replace('PASS: ' + MODULE.CONTROLS[0], 'UNSUPPORTED: ' + MODULE.CONTROLS[0]), + text.replace('PASS: malloc/ordinary-control\n', ''), text + 'PASS: ' + MODULE.CONTROLS[0] + '\n']: + file.write_text(bad, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.test_receipt(self.root) + file.write_text(text, encoding='utf-8'); (self.root / 'stamp-dir/check_libc_failed').touch() + with self.assertRaises(ValueError): MODULE.test_receipt(self.root) + (self.root / 'stamp-dir/check_libc_failed').unlink() + (self.root / 'build-tree/amd64-x32').mkdir() + with self.assertRaises(ValueError): MODULE.test_receipt(self.root) + + def test_original_packaging_and_disjoint_output_guards_are_reused(self): + debian = self.root / 'debian'; debian.mkdir() + (debian / 'rules').write_text('original tests and flags', encoding='utf-8') + (debian / 'changelog').write_text('original', encoding='utf-8') + snapshot = MODULE.COMMON.packaging_snapshot(self.root) + (debian / 'changelog').write_text('custom version', encoding='utf-8') + self.assertEqual(snapshot, MODULE.COMMON.packaging_snapshot(self.root)) + (debian / 'rules').write_text('nocheck', encoding='utf-8') + self.assertNotEqual(snapshot, MODULE.COMMON.packaging_snapshot(self.root)) + for directory in [self.root / 'out', self.root / 'out/nested', self.root]: + with self.assertRaises(ValueError): + MODULE.COMMON.require_separate_paths(self.root / 'out', directory, None) + + def cli(self, *args): + return subprocess.run([sys.executable, '-B', str(HERE / 'build-glibc-backport.py'), *args], + capture_output=True, text=True, encoding='utf-8') + + def test_cli_validate_lock_is_network_and_linux_build_independent(self): + result = self.cli('--validate-lock') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('GLIBC_BACKPORT_LOCK_OK', result.stdout) + self.assertNotIn('GLIBC_BACKPORT_BUILT', result.stdout) + + def test_cli_rejects_duplicate_foreign_dev_export_invalid_jobs_and_missing_cache(self): + for args in [('--validate-lock', '--jobs', '0'), ('--validate-lock', '--jobs', '33'), + ('--validate-lock', '--export-dev-package', 'libc6-dev', '--export-dev-package', 'libc6-dev'), + ('--validate-lock', '--export-dev-package', 'gcc'), ('--validate-inputs',)]: + with self.subTest(args=args): self.assertNotEqual(0, self.cli(*args).returncode) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/security/ubuntu-backports/jq/build-jq-release.py b/server/security/ubuntu-backports/jq/build-jq-release.py new file mode 100644 index 0000000000..2f610ed656 --- /dev/null +++ b/server/security/ubuntu-backports/jq/build-jq-release.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Build official stable jq 1.8.2 using preserved Ubuntu 26.04 packaging. + +Run non-root in an isolated native amd64 builder with the original Build-Depends. +Only changelog and already-upstream security-patch series entries are adjusted. +No runtime installation occurs. Native full tests, ABI, system-onig linkage and +extracted-package smoke must pass before exporting the two runtime packages. +""" +import argparse +import datetime +import email.utils +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile + +HERE = Path(__file__).resolve().parent +LOCK = HERE / 'jq.lock.json' +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location('pcre2_source_helpers', HERE.parent / 'build-pcre2-backport.py') +C = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(C) +require, digest, download, run, capture = C.require, C.digest, C.download, C.run, C.capture +UBUNTU = '1.8.1-4ubuntu2' +VERSION = '1.8.2-0pasturestack1~26.04.1' +KEPT = ['get-version.patch', 'dont-distribute-duplicate-docs.patch', + '0008-Do-not-use-venderized-oniguruma.patch', '0006-Do-not-use-pipenv-to-build-docs.patch'] +DROPPED = ['CVE-2026-' + x + '.patch' for x in ['32316', '33947', '33948', '39956', '39979', '40164']] +TESTS = ['mantest', 'jqtest', 'shtest', 'utf8test', 'base64test', 'uritest', + 'optionaltest', 'onigtest', 'manonigtest'] + + +def load_lock(): + lock = json.loads(LOCK.read_text(encoding='utf-8')) + expected = {'schemaVersion': 1, 'sourcePackage': 'jq', 'ubuntuSourceVersion': UBUNTU, + 'upstreamVersion': '1.8.2', 'customVersion': VERSION, 'distribution': 'resolute', + 'osVersion': '26.04', 'architecture': 'amd64', 'runtimePackages': ['jq', 'libjq1'], + 'owningTests': TESTS} + require(all(lock.get(k) == v for k, v in expected.items()), 'Unexpected jq lock identity') + require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, + 'Invalid source date') + source_names = {'jq_' + UBUNTU + '.dsc', 'jq_1.8.1.orig.tar.gz', + 'jq_' + UBUNTU + '.debian.tar.xz'} + require(len(lock['sourceFiles']) == 3 and + {x['name'] for x in lock['sourceFiles']} == source_names, 'Wrong Ubuntu source inputs') + require([p['name'] for p in lock['retainedUbuntuPatches']] == KEPT and + [p['patch'] for p in lock['removedUbuntuPatches']] == DROPPED, + 'Unexpected Ubuntu packaging patch boundary') + upstream = lock['upstream'] + require(upstream['commit'] == '34f7186b86743a083a589741b6cea95293524108' + and upstream['tag'] == 'jq-1.8.2' + and upstream['name'] == 'jq-1.8.2.tar.gz', 'Unexpected official stable source') + require(len(upstream['releaseSecurityCves']) == 16 and + set(upstream['additionalAdvisories']) == + {'GHSA-gf4g-95wj-4q4r', 'GHSA-hj52-j2c9-r8r4'}, 'Incomplete release security inventory') + for item in lock['sourceFiles'] + [upstream, upstream['checksumFile']]: + require(re.fullmatch(r'[A-Za-z0-9._~+-]+', item['name']) + and re.fullmatch(r'[0-9a-f]{64}', item['sha256']) + and type(item['size']) is int and 0 < item['size'] < 8 * 1024 * 1024, + 'Unpinned or unsafe source') + expected_url = ('https://archive.ubuntu.com/ubuntu/pool/main/j/jq/' + item['name'] + if item in lock['sourceFiles'] else + 'https://github.com/jqlang/jq/releases/download/jq-1.8.2/' + + ('sha256sum.txt' if item == upstream['checksumFile'] else item['name'])) + require(item['url'] == expected_url, 'Unexpected source URL') + for proof in lock['removedUbuntuPatches']: + require(proof['status'] == 'ahead' and proof['mergeBase'] == proof['officialFixCommit'] + and proof['officialReleaseCommit'] == upstream['commit'] + and re.fullmatch(r'[0-9a-f]{40}', proof['officialFixCommit']) + and re.fullmatch(r'[0-9a-f]{64}', proof['originalPatchSha256']), + 'Old security patch lacks official inclusion proof') + for group in ['releaseSourceSha256', 'ubuntuPackagingSha256', 'postUbuntuPatchSha256']: + for name, sha in lock[group].items(): + require(not Path(name).is_absolute() and '..' not in Path(name).parts + and re.fullmatch(r'[0-9a-f]{64}', sha), 'Unsafe/unpinned source path') + return lock + + +def verify_hashes(root, expected): + require(all((root / name).is_file() and digest(root / name) == sha + for name, sha in expected.items()), 'Source or packaging digest mismatch') + + +def verify_inputs(lock, inputs): + for item in lock['sourceFiles'] + [lock['upstream'], lock['upstream']['checksumFile']]: + file = inputs / item['name'] + require(file.is_file() and file.stat().st_size == item['size'] + and digest(file) == item['sha256'], 'Pinned input mismatch: ' + item['name']) + text = (inputs / ('jq_' + UBUNTU + '.dsc')).read_text(encoding='utf-8') + require(all(re.search('^' + re.escape(line) + '$', text, re.M) for line in + ['Format: 3.0 (quilt)', 'Source: jq', 'Version: ' + UBUNTU]), 'Wrong Ubuntu source') + block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) + require(block, 'Missing .dsc SHA-256 manifest') + actual = {n: (h, int(s)) for h, s, n in + re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M)} + require(actual == {x['name']: (x['sha256'], x['size']) for x in lock['sourceFiles'] + if not x['name'].endswith('.dsc')}, 'Ubuntu .dsc/lock manifest mismatch') + text = (inputs / lock['upstream']['checksumFile']['name']).read_text(encoding='utf-8') + require(re.search(r'^' + lock['upstream']['sha256'] + + r'\s+\*?jq-1\.8\.2\.tar\.gz$', text, re.M), + 'Official release checksum list disagrees') + + +def safe_unpack(archive, work): + with tarfile.open(archive) as source: + require(all(Path(p.name).parts and Path(p.name).parts[0] == 'jq-1.8.2' + and not Path(p.name).is_absolute() and '..' not in Path(p.name).parts + for p in source.getmembers()), 'Unsafe release archive layout') + source.extractall(work, filter='data') + return work / 'jq-1.8.2' + + +def prepare(lock, inputs, work, env, log): + old = work / 'ubuntu-packaging-source' + run(['dpkg-source', '--extract', str(inputs / ('jq_' + UBUNTU + '.dsc')), str(old)], + work, env, log) + verify_hashes(old, lock['ubuntuPackagingSha256']) + for patch in lock['retainedUbuntuPatches']: + require(digest(old / 'debian/patches' / patch['name']) == patch['sha256'], + 'Ubuntu packaging patch changed') + for proof in lock['removedUbuntuPatches']: + require(digest(old / 'debian/patches' / proof['patch']) == proof['originalPatchSha256'], + 'Old Ubuntu security patch changed') + source = safe_unpack(inputs / lock['upstream']['name'], work) + verify_hashes(source, lock['releaseSourceSha256']) + shutil.copytree(old / 'debian', source / 'debian', symlinks=True) + series = source / 'debian/patches/series' + require(series.read_text(encoding='utf-8').splitlines() == KEPT + DROPPED, + 'Unexpected original Ubuntu series') + series.write_text(''.join(p + '\n' for p in KEPT), encoding='utf-8') + changelog = source / 'debian/changelog' + date = email.utils.format_datetime(datetime.datetime.fromtimestamp( + lock['sourceDateEpoch'], datetime.timezone.utc)) + changelog.write_text('jq (' + VERSION + ') resolute; urgency=high\n\n' + ' * First-party PastureStack rebuild; not issued by Canonical.\n' + ' * Use official stable jq 1.8.2, including all published security fixes.\n' + ' * Preserve Ubuntu packaging; omit six series entries already upstream.\n\n' + ' -- PastureStack maintainers ' + date + + '\n\n' + changelog.read_text(encoding='utf-8'), encoding='utf-8') + # dpkg-source uses zero-fuzz quilt application and tracks the retained patches. + run(['dpkg-source', '--before-build', str(source)], work, env, log) + verify_hashes(source, lock['postUbuntuPatchSha256']) + original = C.packaging_snapshot(old) + original.pop('debian/patches/series') + verify_hashes(source, original) + return source, C.packaging_snapshot(source) + + +def test_receipts(source): + receipts = [] + for name in TESTS: + reports = list(source.rglob(name + '.trs')) + require(len(reports) == 1, 'Missing/ambiguous owning test receipt: ' + name) + report = reports[0] + require(re.search(r'^:test-result: PASS$', report.read_text(encoding='utf-8'), re.M), + 'Native owning test did not PASS: ' + name) + receipts.append({'test': name, 'status': 'PASS', 'path': report.relative_to(source).as_posix(), + 'sha256': digest(report)}) + return receipts + + +def verify_source_identity(package, value): + require(value in ['jq', 'jq (' + VERSION + ')'] or (package == 'jq' and value == ''), + 'Source package identity changed') + + +def verify_packages(lock, source, work, env, log): + extracted = work / 'extracted' + selected = [] + for package in ['jq', 'libjq1']: + files = list(work.glob(package + '_' + VERSION + '_amd64.deb')) + require(len(files) == 1, 'Missing/ambiguous package: ' + package) + file = files[0] + for field, expected in [('Package', package), ('Version', VERSION), ('Architecture', 'amd64')]: + require(capture(['dpkg-deb', '--field', str(file), field], work, env) == expected, + 'Package identity changed: ' + field) + verify_source_identity(package, capture(['dpkg-deb', '--field', str(file), 'Source'], work, env)) + run(['dpkg-deb', '--extract', str(file), str(extracted)], work, env, log) + selected.append({'package': package, 'version': VERSION, 'architecture': 'amd64', + 'file': file.name, 'sha256': digest(file)}) + binary, library = extracted / 'usr/bin/jq', extracted / 'usr/lib/x86_64-linux-gnu/libjq.so.1' + require(binary.is_file() and library.is_file(), 'Expected runtime entry point/SONAME missing') + dynamic = capture(['readelf', '--dynamic', str(library)], work, env) + require('[libjq.so.1]' in dynamic and re.search(r'NEEDED.*\[libonig\.so\.5\]', dynamic), + 'libjq ABI/system-oniguruma linkage changed') + required = re.findall(r'^ ([A-Za-z0-9_]+)@Base ', (source / 'debian/libjq1.symbols').read_text(), re.M) + symbols = capture(['nm', '--dynamic', '--defined-only', str(library)], work, env) + exports = {line.split()[-1].split('@')[0] for line in symbols.splitlines() if line.split()} + require(required and set(required) <= exports, 'An original Ubuntu public ABI symbol is missing') + for file in [binary, library]: + dyn = capture(['readelf', '--dynamic', str(file)], work, env) + headers = capture(['readelf', '--program-headers', str(file)], work, env) + require('GNU_RELRO' in headers and ('BIND_NOW' in dyn or re.search(r'FLAGS_1.*NOW', dyn)), + 'Ubuntu hardening missing') + child_env = dict(env, LD_LIBRARY_PATH=str(library.parent)) + require(capture([str(binary), '--version'], work, child_env) == 'jq-1.8.2', 'Wrong packaged version') + for expression, expected in [('{ok: (1+2)}', '{"ok":3}'), + ('"abc" | test("^a")', 'true'), + ('{a:{b:1}} * {a:{c:2}}', '{"a":{"b":1,"c":2}}'), + ('[1,[2]] | contains([[2]])', 'true')]: + require(capture([str(binary), '-nc', expression], work, child_env) == expected, + 'Packaged valid-input smoke failed') + require(digest(extracted / 'usr/share/doc/jq/copyright') == + digest(source / 'debian/copyright'), 'Copyright changed') + return selected, {'validInputCases': 4, 'status': 'PASS', 'publicAbiSymbols': len(required), + 'soname': 'libjq.so.1', 'systemOniguruma': 'libonig.so.5'} + + +def build(args, lock): + release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', + Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) + require(release.get('ID') == 'ubuntu' and release.get('VERSION_ID') == '26.04' + and os.geteuid() != 0, 'Use a non-root isolated Ubuntu 26.04 native builder') + require(sys.version_info >= (3, 12), 'Python 3.12 or later is required for safe tar extraction') + output = args.output_dir.resolve() + cache = args.cache_dir.resolve() if args.cache_dir else None + C.require_separate_paths(output, args.work_dir.resolve() if args.work_dir else None, cache) + require(not output.exists(), 'Output directory must be new') + work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='jq-stable-')) + if args.work_dir: work.mkdir(parents=True, exist_ok=False) + home = work / 'home' + home.mkdir(mode=0o700) + # CLI job choice never becomes an arbitrary command argument or compiler override. + jobs, job_arg = (2, '-j2') if args.jobs == 2 else (4, '-j4') + env = C.build_environment(lock, jobs) + env.update(HOME=str(home), DEB_BUILD_MAINT_OPTIONS='') + for tool in ['dpkg-source', 'dpkg-buildpackage', 'dpkg-checkbuilddeps', 'dpkg-deb', 'dpkg-query', + 'readelf', 'nm', 'patch', 'make', 'valgrind']: + require(shutil.which(tool, path=env['PATH']), 'Builder dependency missing: ' + tool) + require(capture(['dpkg', '--print-architecture'], work, env) == 'amd64', 'Native amd64 required') + print('JQ_STABLE_WORK_DIR=' + str(work), flush=True) + inputs = work / 'inputs' + inputs.mkdir() + if cache: cache.mkdir(parents=True, exist_ok=True) + for item in lock['sourceFiles'] + [lock['upstream'], lock['upstream']['checksumFile']]: + download(item, inputs, cache) + verify_inputs(lock, inputs) + log = work / 'build.log' + source, packaging = prepare(lock, inputs, work, env, log) + run(['dpkg-checkbuilddeps'], source, env, log) + run(['dpkg-buildpackage', '-b', '-us', '-uc', job_arg], source, env, log) + verify_hashes(source, packaging) + tests = test_receipts(source) + packages, smoke = verify_packages(lock, source, work, env, log) + output.mkdir(parents=True) + for package in packages: + shutil.copyfile(work / package['file'], output / package['file']) + shutil.copyfile(LOCK, output / 'jq.lock.json') + shutil.copyfile(log, output / 'build.log') + shutil.copyfile(source / 'debian/copyright', output / 'COPYRIGHT') + for receipt in tests: + for suffix in ['.trs', '.log']: + report = (source / receipt['path']).with_suffix(suffix) + require(report.is_file(), 'Test log missing') + shutil.copyfile(report, output / (receipt['test'] + suffix)) + for pattern in ['jq_*.buildinfo', 'jq_*.changes']: + matches = list(work.glob(pattern)) + require(len(matches) == 1, 'Package build receipt missing/ambiguous') + shutil.copyfile(matches[0], output / matches[0].name) + (output / 'builder-packages.tsv').write_text(capture( + ['dpkg-query', '--show', '--showformat=' + '$' + '{binary:Package}\t' + '$' + '{Version}\n'], + work, env) + '\n', encoding='utf-8') + provenance = {'schemaVersion': 1, 'identity': lock['identity'], 'sourceLockSha256': digest(LOCK), + 'sourceFiles': lock['sourceFiles'], 'upstream': lock['upstream'], + 'sharedHelpersSha256': digest(HERE.parent / 'build-pcre2-backport.py'), + 'packagingPreserved': packaging, 'tests': tests, 'packageSmoke': smoke, + 'runtimePackages': packages, 'runtimeInstalled': False, + 'removedSeriesEntries': lock['removedUbuntuPatches'], + 'buildCommand': ['dpkg-buildpackage', '-b', '-us', '-uc', job_arg]} + (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') + (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(LOCK) + '\n' + ''.join( + '\t'.join([p['package'], p['version'], p['architecture'], p['sha256'], p['file']]) + '\n' + for p in packages), encoding='utf-8') + (output / 'SHA256SUMS').write_text(''.join(digest(p) + ' ' + p.name + '\n' + for p in sorted(output.iterdir()) if p.is_file()), encoding='utf-8') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--validate-lock', action='store_true') + parser.add_argument('--output-dir', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--cache-dir', type=Path) + parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) + args = parser.parse_args() + lock = load_lock() + if args.validate_lock: + print('JQ_STABLE_LOCK_OK sha256=' + digest(LOCK)) + return + require(args.output_dir is not None, '--output-dir is required for native build') + build(args, lock) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print('JQ_STABLE_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/jq/jq.lock.json b/server/security/ubuntu-backports/jq/jq.lock.json new file mode 100644 index 0000000000..4c3e2eefb5 --- /dev/null +++ b/server/security/ubuntu-backports/jq/jq.lock.json @@ -0,0 +1,260 @@ +{ + "schemaVersion": 1, + "sourcePackage": "jq", + "ubuntuSourceVersion": "1.8.1-4ubuntu2", + "upstreamVersion": "1.8.2", + "customVersion": "1.8.2-0pasturestack1~26.04.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "runtimePackages": [ + "jq", + "libjq1" + ], + "identity": "First-party rebuild of official stable jq1.8.2 with Ubuntu packaging; not issued by Canonical.", + "sourceDateEpoch": 1792886400, + "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18373622", + "sourceFiles": [ + { + "name": "jq_1.8.1-4ubuntu2.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/j/jq/jq_1.8.1-4ubuntu2.dsc", + "sha256": "faffac21d3a1f6fc96815ee630b6e71903db2ca6c354afe87c68464d85a71e08", + "size": 1979 + }, + { + "name": "jq_1.8.1.orig.tar.gz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/j/jq/jq_1.8.1.orig.tar.gz", + "sha256": "51c4ec0913281d917f22a823d1ddcf17c9bc2be845fac987d5fe1d3ae78dd6a2", + "size": 1409730 + }, + { + "name": "jq_1.8.1-4ubuntu2.debian.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/j/jq/jq_1.8.1-4ubuntu2.debian.tar.xz", + "sha256": "7261bb970ddb1a677988606144151a8dc54437066f12e3ebb1496062da50f2c7", + "size": 18368 + } + ], + "upstream": { + "name": "jq-1.8.2.tar.gz", + "url": "https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-1.8.2.tar.gz", + "sha256": "71b8d6e8f5fe81f6c6d0d110e3892251f6ce76ed095abd315e26e6e1193af3af", + "size": 1959950, + "tag": "jq-1.8.2", + "commit": "34f7186b86743a083a589741b6cea95293524108", + "releaseURL": "https://github.com/jqlang/jq/releases/tag/jq-1.8.2", + "checksumFile": { + "name": "jq-1.8.2-sha256sum.txt", + "url": "https://github.com/jqlang/jq/releases/download/jq-1.8.2/sha256sum.txt", + "sha256": "dc86824a41c165ece971ff691aff6e08bbfe6e1d1f531688b47ee78c283a85cd", + "size": 2302 + }, + "releaseSecurityCves": [ + "CVE-2026-32316", + "CVE-2026-33947", + "CVE-2026-33948", + "CVE-2026-39956", + "CVE-2026-39979", + "CVE-2026-40164", + "CVE-2026-40612", + "CVE-2026-41256", + "CVE-2026-41257", + "CVE-2026-43894", + "CVE-2026-43895", + "CVE-2026-43896", + "CVE-2026-44777", + "CVE-2026-47770", + "CVE-2026-49839", + "CVE-2026-54679" + ], + "additionalAdvisories": [ + "GHSA-gf4g-95wj-4q4r", + "GHSA-hj52-j2c9-r8r4" + ] + }, + "retainedUbuntuPatches": [ + { + "name": "get-version.patch", + "sha256": "c0bd5f3dca373f87df3610e4510204cd8530dbdb155b0153b28457871b7950fe", + "paths": [ + "scripts/version" + ] + }, + { + "name": "dont-distribute-duplicate-docs.patch", + "sha256": "6386843af061c538768742207d9fc02603f56e678acd31196d438d3519a7eb6b", + "paths": [ + "Makefile.am" + ] + }, + { + "name": "0008-Do-not-use-venderized-oniguruma.patch", + "sha256": "56073768aadc6b776266e078ac53503b0cf710980c911090c4a92d8af68b3da5", + "paths": [ + "Makefile.am" + ] + }, + { + "name": "0006-Do-not-use-pipenv-to-build-docs.patch", + "sha256": "73500f946a485c28fa2f8f100d09cf46b25da9c59b77577b60cdf372b70a9153", + "paths": [ + "Makefile.am", + "configure.ac" + ] + } + ], + "removedUbuntuPatches": [ + { + "patch": "CVE-2026-32316.patch", + "originalPatchSha256": "6e0296c2cbee9607771448d017dd870a5b2f5dd286e14033fac60f5648311da7", + "officialFixCommit": "e47e56d226519635768e6aab2f38f0ab037c09e5", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/e47e56d226519635768e6aab2f38f0ab037c09e5...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "e47e56d226519635768e6aab2f38f0ab037c09e5", + "evidenceSha256": "129bd92e68e62a2ad8fadcce03f5676d5f997105d2d71c1f5ea40ca83984614e", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "Context/later security-code differences; inclusion established by official ancestry and pinned release source." + }, + { + "patch": "CVE-2026-33947.patch", + "originalPatchSha256": "5ac5af1290625a4e8c3ee609562c2d2569fcdf9f9cba39b0b130983cfb28b572", + "officialFixCommit": "fb59f1491058d58bdc3e8dd28f1773d1ac690a1f", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/fb59f1491058d58bdc3e8dd28f1773d1ac690a1f...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "fb59f1491058d58bdc3e8dd28f1773d1ac690a1f", + "evidenceSha256": "d4d669acc6b567e71809ef57390b8ae8c75478d1177cebafec5bd3e8381bd881", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "Context/later security-code differences; inclusion established by official ancestry and pinned release source." + }, + { + "patch": "CVE-2026-33948.patch", + "originalPatchSha256": "dfed461e2779f12c5feb80cbe39a96f937337a66416f9c1cfa7b271edafdce1f", + "officialFixCommit": "6374ae0bcdfe33a18eb0ae6db28493b1f34a0a5b", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/6374ae0bcdfe33a18eb0ae6db28493b1f34a0a5b...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "6374ae0bcdfe33a18eb0ae6db28493b1f34a0a5b", + "evidenceSha256": "2e81e6b8068d92bb7c6c46ff2dc7b26fff517df61fd9dd99fadd3ed31ddcb8a8", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "Context/later security-code differences; inclusion established by official ancestry and pinned release source." + }, + { + "patch": "CVE-2026-39956.patch", + "originalPatchSha256": "b3403757544145d2c13f59a517f7db7aa78948a492540e5fdf7e220fd4ed2d69", + "officialFixCommit": "fdf8ef0f0810e3d365cdd5160de43db46f57ed03", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/fdf8ef0f0810e3d365cdd5160de43db46f57ed03...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "fdf8ef0f0810e3d365cdd5160de43db46f57ed03", + "evidenceSha256": "ac96a4c2a113e691fb7356220f3c007517dd52a525c834cb8fe82fb893d39ca7", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "PASS" + }, + { + "patch": "CVE-2026-39979.patch", + "originalPatchSha256": "b97d5778c58cdd80673d2df1bf0b822e606c304d091e3433358b3a9d4ca34a86", + "officialFixCommit": "2f09060afab23fe9390cce7cb860b10416e1bf5f", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/2f09060afab23fe9390cce7cb860b10416e1bf5f...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "2f09060afab23fe9390cce7cb860b10416e1bf5f", + "evidenceSha256": "d233f73e23140b4c4195afbe6aa6e2a42deabf87fd5736769eda7599c10d67f0", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "PASS" + }, + { + "patch": "CVE-2026-40164.patch", + "originalPatchSha256": "91ff96bae1b5d8b83b76db5ff2d80ec1a7ad6245a5d1cae6487eaffafd0a1440", + "officialFixCommit": "0c7d133c3c7e37c00b6d46b658a02244fdd3c784", + "officialReleaseCommit": "34f7186b86743a083a589741b6cea95293524108", + "officialCompareURL": "https://api.github.com/repos/jqlang/jq/compare/0c7d133c3c7e37c00b6d46b658a02244fdd3c784...34f7186b86743a083a589741b6cea95293524108?per_page=1", + "status": "ahead", + "mergeBase": "0c7d133c3c7e37c00b6d46b658a02244fdd3c784", + "evidenceSha256": "e8eb1df9b9112de26ddf445110b2c3a1f686fa0cdcfeda94f2b2d5ed517ebc39", + "reason": "Official security-fix commit is an ancestor of the pinned release; exact release source hashes are verified before packaging. Existing patch file is preserved; only its series entry is removed.", + "reverseCheck": "PASS" + } + ], + "releaseSourceSha256": { + "src/jv.c": "a40433614bcf7e344a4256c4aaab7acd4ff3fd09b04e3caefd4e3eb102cbacc2", + "src/jv_aux.c": "fb7a4fff8f89ba06e8b9bab4c8047e5f118d971d790060388e028811f6d42cb2", + "src/util.c": "1f413a1ffeb9194f246f7d9079169d41126686ca212d7b262a1d7b8cf783be06", + "src/builtin.c": "0a29617dbf74a6fdabf644f5da82269113fef69bb46efaa3b232515d90305342", + "src/jv_parse.c": "3ba36daee76f99543761f1fe6a6f1d261e6ecfecb8578136cd32c76d093ff40b", + "configure.ac": "5bf3b41f9bee589f30db0f32203ada104fd62d278c2e95458fff7f9eb7322fa6", + "Makefile.am": "77edc24562d1fff62d87a701c2c287444faf327696a52ffc3bcdc9ea9f00ea0c", + "src/execute.c": "8069c06b05f5220a705977f612ae79557e272fba22bd4fc92e69e35a04ad5a16", + "src/exec_stack.h": "08092864ce27c09ceb3788813a3d9c056aa9130eb513558e30865356a8d7c9a8", + "src/linker.c": "07592a2aca011fa0ec6c0be9ea2cb280e74cc9db325c15a3688674828bc25c63" + }, + "ubuntuPackagingSha256": { + "debian/rules": "7434f091fc61bc9c9dba647f92230606ba59330b5263a4059a4281f319e5f68b", + "debian/control": "8462edd4f555a10a6891924ca868a8571d7e3148411085ac4cd9622c0b9dfc29", + "debian/libjq1.symbols": "5c2db775e6fee872d846857bfbac4e0ed7a8ea90cb1ed05783004d02302053fd", + "debian/copyright": "92965c1a25cfced63db0fcc869d060d773d6105da9bd8d3da232ed1691cda778" + }, + "postUbuntuPatchSha256": { + "Makefile.am": "08c90cb1bb74f2ea981dbe96bd061102bcaf739167e64805996aee6ecde160b3", + "configure.ac": "a818b6f5709a222be56a220c3be373866b1eafc08fb2cfc3cc1e1eb0c2e08a0c", + "scripts/version": "3f879889a892093320b25c0da50b37193fe98c3e9a9f312bea1fc51691b80548" + }, + "owningTests": [ + "mantest", + "jqtest", + "shtest", + "utf8test", + "base64test", + "uritest", + "optionaltest", + "onigtest", + "manonigtest" + ], + "sharedHelpers": { + "module": "../build-pcre2-backport.py", + "functions": [ + "require", + "digest", + "download", + "run", + "capture", + "packaging_snapshot", + "require_separate_paths" + ] + }, + "buildContract": { + "nativeOnly": true, + "buildUser": "non-root", + "originalConfigure": [ + "--disable-static" + ], + "preserveOriginalHardening": "hardening=+all abi=+time64 from original debian/rules", + "soname": "libjq.so.1", + "onigurumaSoname": "libonig.so.5", + "newTestSkips": 0, + "installRuntime": false + }, + "findingCounts": { + "originalPendingCves": [ + "CVE-2026-41256", + "CVE-2026-41257", + "CVE-2026-43895", + "CVE-2026-43896", + "CVE-2026-44777" + ], + "newOfficialCvesAwaitingProductConfirmation": [ + "CVE-2026-40612", + "CVE-2026-43894", + "CVE-2026-47770", + "CVE-2026-49839", + "CVE-2026-54679" + ], + "doNotChangeOriginal53Baseline": true + }, + "limitations": [ + "Official source bytes are SHA-256 pinned over HTTPS; the local preparation environment lacks the uploader PGP key, so it does not claim verified Ubuntu inline signatures or Canonical-issued custom outputs.", + "Source preparation passed; native package build, full owning tests, ABI/package and extracted-package smoke checks are required before a product remediation claim.", + "Three old Ubuntu patch reverse checks pass; the other three differ in context or later security implementations. All six official fix commits were independently confirmed as ancestors of the pinned release; source hashes are pinned rather than bypassing failed checks.", + "Additional official disclosures are recorded separately from the original53 findings; current artifact reachability has not been established for every disclosure." + ] +} diff --git a/server/security/ubuntu-backports/jq/test_jq_release.py b/server/security/ubuntu-backports/jq/test_jq_release.py new file mode 100644 index 0000000000..7512bb0c90 --- /dev/null +++ b/server/security/ubuntu-backports/jq/test_jq_release.py @@ -0,0 +1,117 @@ +"""Network-free guards for the official stable source/Ubuntu packaging boundary.""" +import contextlib +import copy +import importlib.util +import io +import json +from pathlib import Path +import sys +import tarfile +import tempfile +import unittest +from unittest import mock + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('jq_release', HERE / 'build-jq-release.py') +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class JqStable(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='jq-source-contract-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.lock = M.load_lock() + + def read_altered(self, field, value): + lock = copy.deepcopy(self.lock) + lock[field] = value + file = self.root / 'fixture.json' + file.write_text(json.dumps(lock), encoding='utf-8') + with mock.patch.object(M, 'LOCK', file): + return M.load_lock() + + def test_official_release_and_complete_security_inventory(self): + self.assertEqual('71b8d6e8f5fe81f6c6d0d110e3892251f6ce76ed095abd315e26e6e1193af3af', + self.lock['upstream']['sha256']) + self.assertEqual(16, len(self.lock['upstream']['releaseSecurityCves'])) + self.assertEqual(6, len(self.lock['removedUbuntuPatches'])) + self.assertEqual(4, len(self.lock['retainedUbuntuPatches'])) + + def test_other_versions_architectures_packages_and_incomplete_coverage_fail(self): + for key, bad in [('upstreamVersion', '1.8.1'), ('architecture', 'arm64'), + ('runtimePackages', ['jq']), ('owningTests', ['jqtest'])]: + with self.subTest(key=key): + with self.assertRaises(ValueError): self.read_altered(key, bad) + + def test_foreign_url_or_unpinned_release_fails(self): + for key, bad in [('url', 'https://example.invalid/jq.tar.gz'), ('sha256', ''), + ('name', '../outside.tar.gz')]: + upstream = copy.deepcopy(self.lock['upstream']) + upstream[key] = bad + with self.assertRaises(ValueError): self.read_altered('upstream', upstream) + + def test_removal_requires_exact_original_fix_ancestry(self): + removed = copy.deepcopy(self.lock['removedUbuntuPatches']) + removed[0]['mergeBase'] = '0' * 40 + with self.assertRaises(ValueError): self.read_altered('removedUbuntuPatches', removed) + + def test_tampered_cached_source_is_not_refetched(self): + inputs, cache = self.root / 'inputs', self.root / 'cache' + inputs.mkdir(); cache.mkdir() + item = self.lock['upstream'] + (cache / item['name']).write_bytes(b'tampered') + with mock.patch.object(M.C.urllib.request, 'build_opener') as opener: + with self.assertRaises(ValueError): M.download(item, inputs, cache) + opener.assert_not_called() + + def test_archive_cannot_extract_outside_pinned_source_root(self): + archive = self.root / 'bad.tar.gz' + with tarfile.open(archive, 'w:gz') as source: + info = tarfile.TarInfo('../outside') + info.size = 1 + source.addfile(info, io.BytesIO(b'x')) + with self.assertRaises(ValueError): M.safe_unpack(archive, self.root) + self.assertFalse((self.root.parent / 'outside').exists()) + + def test_all_nine_owning_suites_must_pass_without_new_skip(self): + for name in M.TESTS: + (self.root / (name + '.trs')).write_text(':test-result: PASS\n', encoding='utf-8') + self.assertEqual(9, len(M.test_receipts(self.root))) + (self.root / 'onigtest.trs').write_text(':test-result: SKIP\n', encoding='utf-8') + with self.assertRaises(ValueError): M.test_receipts(self.root) + (self.root / 'onigtest.trs').unlink() + with self.assertRaises(ValueError): M.test_receipts(self.root) + + def test_original_packaging_cannot_change_silently(self): + file = self.root / 'rules' + file.write_bytes(b'original') + hashes = {'rules': M.digest(file)} + M.verify_hashes(self.root, hashes) + file.write_bytes(b'changed') + with self.assertRaises(ValueError): M.verify_hashes(self.root, hashes) + + def test_root_cannot_run_the_build_or_full_suite(self): + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(M.os, 'geteuid', create=True, return_value=0): + with self.assertRaisesRegex(ValueError, 'non-root'): + M.build(None, self.lock) + + def test_cli_cannot_select_a_lock_or_arbitrary_jobs(self): + for args in [['--lock', '/tmp/untrusted'], ['--jobs', '8']]: + with mock.patch.object(sys, 'argv', ['build-jq-release.py'] + args), \ + contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: M.main() + self.assertEqual(2, error.exception.code) + + def test_source_field_may_only_be_omitted_for_same_name_binary(self): + M.verify_source_identity('jq', '') + M.verify_source_identity('libjq1', 'jq') + for package, value in [('libjq1', ''), ('jq', 'foreign'), ('libjq1', 'jq (1.8.1)')]: + with self.assertRaises(ValueError): M.verify_source_identity(package, value) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py b/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py new file mode 100644 index 0000000000..b5c24a6b0b --- /dev/null +++ b/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Build a first-party rust-coreutils security backport in Ubuntu 26.04 amd64. + +Uses the unchanged Ubuntu 0.10 packaging and vendored crates; backports only the +official id fix. No runtime package installation or alternatives changes occur. +The original non-gating test recipe is supplemented by a strict, unfiltered +owning-suite run and real/effective-GID tests of the extracted package. +""" +import argparse +import datetime +import email.utils +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.request + +HERE = Path(__file__).resolve().parent +LOCK_FILE = HERE / 'rust-coreutils.lock.json' +VERSION = '0.10.0-1ubuntu2~26.04.1' +COMMIT = 'ff382f527994b8a021ec0a086dec4c5cafa34f2a' +PATHS = ['src/uu/id/src/id.rs', 'tests/by-util/test_id.rs'] + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def job_option(jobs): + if jobs == 2: + return '-j2' + if jobs == 4: + return '-j4' + raise ValueError('Build jobs must be 2 or 4') + + +def digest(file): + sha = hashlib.sha256() + with file.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + sha.update(chunk) + return sha.hexdigest() + + +def load_lock(file): + lock = json.loads(file.read_text(encoding='utf-8')) + expected = {'schemaVersion': 1, 'sourcePackage': 'rust-coreutils', + 'ubuntuSourceVersion': VERSION, 'customVersion': VERSION + '+pasturestack.1', + 'distribution': 'resolute', 'osVersion': '26.04', 'architecture': 'amd64', + 'runtimePackages': ['rust-coreutils']} + require(all(lock.get(k) == v for k, v in expected.items()), 'Unsupported lock identity') + require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, + 'Invalid source date') + names = {'rust-coreutils_' + VERSION + '.dsc', + 'rust-coreutils_0.10.0.orig.tar.gz', 'rust-coreutils_0.10.0.orig-l10n.tar.gz', + 'rust-coreutils_' + VERSION + '.debian.tar.xz'} + require(len(lock['sourceFiles']) == 4 and + {x['name'] for x in lock['sourceFiles']} == names, 'Wrong Ubuntu source set') + require(len(lock['patches']) == 1 and lock['patches'][0]['commit'] == COMMIT + and lock['patches'][0]['paths'] == PATHS + and lock['patches'][0]['fixedCves'] == ['CVE-2026-35370', 'CVE-2026-35371'], + 'Only the approved two-CVE patch is supported') + for item in lock['sourceFiles'] + lock['patches']: + require(re.fullmatch(r'[A-Za-z0-9._~+-]+', item['name']), 'Unsafe input name') + require(re.fullmatch(r'[0-9a-f]{64}', item['sha256']) + and type(item['size']) is int and 0 < item['size'] < 32 * 1024 * 1024, + 'Invalid SHA-256/size') + expected_url = ('https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/' + + item['name'] if item in lock['sourceFiles'] else + 'https://github.com/uutils/coreutils/commit/' + COMMIT + '.patch') + require(item['url'] == expected_url, 'Unexpected download URL') + require(len(lock['alreadyPresentFixes']) == 18 and + len({x['id'] for x in lock['alreadyPresentFixes']}) == 18 and + all(x['status'] == 'already_in_original_ubuntu_source' + and x['sourcePath'] in lock['baselineSha256'] + for x in lock['alreadyPresentFixes']), 'Wrong already-present fix inventory') + require(set(lock['patchedSha256']) == set(PATHS), 'Unexpected patched paths') + for name, sha in {**lock['baselineSha256'], **lock['patchedSha256']}.items(): + require(not Path(name).is_absolute() and '..' not in Path(name).parts + and re.fullmatch(r'[0-9a-f]{64}', sha), 'Invalid source digest entry') + return lock + + +class HttpsRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file, code, message, headers, new_url): + require(new_url.startswith('https://'), 'Refusing HTTPS downgrade') + return super().redirect_request(request, file, code, message, headers, new_url) + + +def verify_input(item, file): + require(file.is_file() and file.stat().st_size == item['size'] + and digest(file) == item['sha256'], 'Source identity mismatch: ' + item['name']) + + +def download(item, inputs, cache): + file = inputs / item['name'] + cached = cache / item['name'] if cache else None + if cached and cached.exists(): + verify_input(item, cached) # A poisoned cache fails closed. + shutil.copyfile(cached, file) + else: + with urllib.request.build_opener(HttpsRedirect()).open(item['url'], timeout=60) as response: + require(response.url.startswith('https://'), 'Refusing non-HTTPS input') + with file.open('xb') as output: + size = 0 + for chunk in iter(lambda: response.read(65536), b''): + size += len(chunk) + require(size <= item['size'], 'Download exceeds pinned size') + output.write(chunk) + verify_input(item, file) + if cached: + shutil.copyfile(file, cached) + verify_input(item, file) + + +def verify_source_control(lock, inputs): + text = (inputs / ('rust-coreutils_' + VERSION + '.dsc')).read_text(encoding='utf-8') + require(all(re.search('^' + re.escape(line) + '$', text, re.M) for line in + ['Format: 3.0 (quilt)', 'Source: rust-coreutils', 'Version: ' + VERSION]), + 'Unexpected Ubuntu source control') + block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) + require(block, 'No source SHA-256 manifest') + actual = {name: (sha, int(size)) for sha, size, name in + re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M)} + require(actual == {x['name']: (x['sha256'], x['size']) for x in lock['sourceFiles'] + if not x['name'].endswith('.dsc')}, '.dsc/lock mismatch') + + +def verify_patch(item, file): + text = file.read_text(encoding='utf-8') + require(text.startswith('From ' + COMMIT + ' '), 'Patch commit identity mismatch') + paths = re.findall(r'^diff --git a/(\S+) b/(\S+)$', text, re.M) + require(paths == [(p, p) for p in PATHS], 'Patch changed unapproved paths') + + +def verify_sources(source, expected): + require(all((source / name).is_file() and digest(source / name) == sha + for name, sha in expected.items()), 'Unexpected source/packaging bytes') + + +def build_environment(lock, jobs, home): + # Do not inherit test suppressors, Cargo profiles, compiler flags or registries. + return {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': str(home), + 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', 'TZ': 'UTC', 'TERM': 'dumb', + 'SOURCE_DATE_EPOCH': str(lock['sourceDateEpoch']), + 'DEB_BUILD_OPTIONS': 'parallel=' + str(jobs), 'DEB_BUILD_PROFILES': '', + 'DEB_BUILD_MAINT_OPTIONS': '', 'DPKG_COLORS': 'never', + 'CARGO_NET_OFFLINE': 'true', 'CARGO_TERM_COLOR': 'never', + 'CARGO_BUILD_JOBS': str(jobs)} + + +def capture(command, work, env): + return subprocess.check_output(command, cwd=work, env=env, + text=True, encoding='utf-8').strip() + + +def run(command, work, env, log): + print('Running: ' + json.dumps(command), flush=True) + with log.open('ab') as output: + output.write(('\n$ ' + json.dumps(command) + '\n').encode()) + output.flush() + result = subprocess.run(command, cwd=work, env=env, + stdout=output, stderr=subprocess.STDOUT) + require(result.returncode == 0, 'Command failed; retained log: ' + str(log)) + + +def packaging_snapshot(source): + # Original packaging applies its own vendor quilt patches during configure. + return {p.relative_to(source).as_posix(): digest(p) + for p in sorted((source / 'debian').rglob('*')) + if p.is_file() and p.name != 'changelog' + and 'rust-vendor' not in p.relative_to(source / 'debian').parts} + + +def strict_test_receipt(log): + text = log.read_text(encoding='utf-8', errors='replace') + results = re.findall(r'test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored;', text) + require(results and all(int(failed) == 0 for _, failed, _ in results) + and sum(int(passed) for passed, _, _ in results) > 0 + and re.search(r'test (?:test_id::)?test_id_groups_ordering \.\.\. ok', text), + 'Missing strict owning-suite/id regression PASS receipt') + require('test result: FAILED' not in text, 'Owning test failure in log') + return {'status': 'PASS', 'addedSkips': 0, 'cargoResults': [ + {'passed': int(p), 'failed': int(f), 'upstreamIgnored': int(i)} for p, f, i in results], + 'officialIdRegression': 'PASS', 'sha256': digest(log)} + + +def credential_prefix(env, work): + prefix = [] if os.geteuid() == 0 else ['sudo', '-n', '--'] + command = prefix + ['/usr/bin/setpriv', '--reuid=65534', '--rgid=65534', + '--egid=1', '--groups=2', '/usr/bin/python3', '-c', + 'import os; print(os.getuid(),os.getgid(),os.getegid(),os.getgroups())'] + require(capture(command, work, env) == '65534 65534 1 [2]', + 'Isolated QA needs SETUID/SETGID privileges; no persistent user/group edits') + probe = prefix + ['/usr/bin/setpriv', '--reuid=65534', '--rgid=65534', '--egid=1', + '--groups=2', '/usr/bin/python3', '-c', + 'import os,sys; sys.exit(0 if os.access(sys.argv[1], os.X_OK) else 1)', + str(work)] + capture(probe, work, env) + return prefix + + +def verify_id(binary, prefix, env, work): + require(binary.is_file(), 'Packaged id entry point missing') + # The directory must be traversable by this bounded unprivileged child. + cases = [(65534, 1, [2]), (1, 65534, [1, 2, 65534]), (65534, 65534, [2])] + receipts = [] + for real, effective, supplementary in cases: + command = prefix + ['/usr/bin/setpriv', '--reuid=65534', '--rgid=' + str(real), + '--egid=' + str(effective), + '--groups=' + ','.join(map(str, supplementary)), str(binary)] + expected = list(dict.fromkeys([real, effective] + sorted(supplementary))) + outputs = {flag: capture(command + flag.split(), work, env) + for flag in ['-G', '-G -r', '-g', '-g -r', '']} + require(all(list(map(int, outputs[flag].split())) == expected + for flag in ['-G', '-G -r']), 'Incorrect real/effective/supplementary GID order') + require(outputs['-g'] == str(effective) and outputs['-g -r'] == str(real), + 'Single-GID semantics regressed') + match = re.search(r' groups=([^\s]+)', outputs['']) + actual = [int(x.split('(')[0]) for x in match[1].split(',')] if match else [] + require(actual == list(dict.fromkeys([effective] + sorted(supplementary))), + 'Default groups ordering changed') + receipts.append({'realGid': real, 'effectiveGid': effective, + 'supplementaryGids': supplementary, 'outputs': outputs, 'status': 'PASS'}) + return receipts + + +def build(args, lock): + release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', + Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) + require(release.get('ID') == 'ubuntu' and release.get('VERSION_ID') == '26.04', + 'Use a disposable Ubuntu 26.04 amd64 builder') + require(os.geteuid() != 0, + 'Build and full owning suite must run as non-root; grant sudo -n for bounded credential QA') + output = args.output_dir.resolve() + cache = args.cache_dir.resolve() if args.cache_dir else None + work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='rust-backport-')) + require(not output.exists(), 'Output directory must be new') + for other in [work, cache]: + require(not other or (output != other and output not in other.parents + and other not in output.parents), 'Output overlaps build/cache directory') + if args.work_dir: + work.mkdir(parents=True, exist_ok=False) + work.chmod(0o755) # Public source/binaries only; needed for the isolated UID fixture. + print('RUST_COREUTILS_BACKPORT_WORK_DIR=' + str(work), flush=True) + home = work / 'home' + home.mkdir(mode=0o700) + env = build_environment(lock, args.jobs, home) + for tool in ['dpkg', 'dpkg-source', 'dpkg-buildpackage', 'dpkg-deb', 'dpkg-query', + 'dpkg-checkbuilddeps', 'fakeroot', 'make', 'cargo', 'rustc', 'patch', 'readelf', 'setpriv']: + require(shutil.which(tool, path=env['PATH']), 'Builder dependency missing: ' + tool) + require(capture(['dpkg', '--print-architecture'], work, env) == 'amd64', 'Native amd64 required') + rust = capture(['rustc', '--version'], work, env) + require(tuple(map(int, re.search(r'rustc (\d+)\.(\d+)\.(\d+)', rust).groups())) >= (1, 88, 0), + 'Upstream MSRV is Rust 1.88') + prefix = credential_prefix(env, work) # Fail before expensive compilation. + inputs = work / 'inputs' + inputs.mkdir() + if cache: + cache.mkdir(parents=True, exist_ok=True) + for item in lock['sourceFiles'] + lock['patches']: + download(item, inputs, cache) + verify_source_control(lock, inputs) + source, log = work / 'source', work / 'build.log' + run(['dpkg-source', '--extract', str(inputs / ('rust-coreutils_' + VERSION + '.dsc')), + str(source)], work, env, log) + verify_sources(source, lock['baselineSha256']) + packaging = packaging_snapshot(source) + item = lock['patches'][0] + patch = inputs / item['name'] + verify_patch(item, patch) + run(['patch', '-p1', '--fuzz=0', '--batch', '--forward', '--input', str(patch)], source, env, log) + verify_sources(source, lock['patchedSha256']) + verify_sources(source, packaging) + changelog = source / 'debian/changelog' + date = email.utils.format_datetime(datetime.datetime.fromtimestamp( + lock['sourceDateEpoch'], datetime.timezone.utc)) + changelog.write_text('rust-coreutils (' + lock['customVersion'] + ') resolute; urgency=high\n\n' + ' * First-party PastureStack rebuild; not issued by Canonical.\n' + ' * Backport upstream ff382f527994b8a021ec0a086dec4c5cafa34f2a\n' + ' for CVE-2026-35370 and CVE-2026-35371; preserve Ubuntu packaging.\n\n' + ' -- PastureStack maintainers ' + + date + '\n\n' + changelog.read_text(encoding='utf-8'), encoding='utf-8') + run(['dpkg-checkbuilddeps'], source, env, log) + run(['dpkg-buildpackage', '-b', '-us', '-uc', job_option(args.jobs)], source, env, log) + strict_log = work / 'strict-tests.log' + run(['make', '-f', str(HERE / 'native-tests.mk'), 'pasturestack-strict-test'], + source, env, strict_log) + tests = strict_test_receipt(strict_log) + verify_sources(source, packaging) + verify_sources(source, lock['patchedSha256']) + debs = list(work.glob('rust-coreutils_' + lock['customVersion'] + '_amd64.deb')) + require(len(debs) == 1, 'Missing/ambiguous runtime package') + deb = debs[0] + for field, expected in [('Package', 'rust-coreutils'), ('Version', lock['customVersion']), + ('Architecture', 'amd64')]: + require(capture(['dpkg-deb', '--field', str(deb), field], work, env) == expected, + 'Wrong package ' + field) + require(capture(['dpkg-deb', '--field', str(deb), 'Source'], work, env) in + ['', 'rust-coreutils', 'rust-coreutils (' + lock['customVersion'] + ')'], 'Wrong Source identity') + extracted = work / 'extracted' + run(['dpkg-deb', '--extract', str(deb), str(extracted)], work, env, log) + binary = extracted / 'usr/lib/cargo/bin/coreutils/id' + credentials = verify_id(binary, prefix, env, work) + require(capture([str(binary), '--version'], work, env).startswith('id (uutils coreutils) 0.10.0'), + 'Unexpected upstream binary version') + copyright_file = extracted / 'usr/share/doc/rust-coreutils/copyright' + require(copyright_file.is_file() and digest(copyright_file) == digest(source / 'debian/copyright'), + 'Original copyright changed') + headers = capture(['readelf', '--program-headers', str(binary)], work, env) + dynamic = capture(['readelf', '--dynamic', str(binary)], work, env) + require('GNU_RELRO' in headers and ('BIND_NOW' in dynamic or re.search(r'FLAGS_1.*NOW', dynamic)), + 'Package binary lacks RELRO/immediate-binding hardening') + output.mkdir(parents=True) + for file in [deb, log, strict_log, source / 'debian/copyright']: + shutil.copyfile(file, output / file.name) + shutil.copyfile(LOCK_FILE, output / 'rust-coreutils.lock.json') + for pattern in ['rust-coreutils_*.buildinfo', 'rust-coreutils_*.changes']: + matches = list(work.glob(pattern)) + require(len(matches) == 1, 'Missing/ambiguous package receipt: ' + pattern) + shutil.copyfile(matches[0], output / matches[0].name) + (output / 'builder-packages.tsv').write_text(capture( + ['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\n'], work, env) + '\n', + encoding='utf-8') + receipt = {'schemaVersion': 1, 'status': 'native_build_and_required_tests_passed', + 'builtAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'identity': 'first-party rebuild; not issued by Canonical', + 'lockSha256': digest(LOCK_FILE), 'package': deb.name, 'packageSha256': digest(deb), + 'rustc': rust, 'strictOwningSuite': tests, 'distinctCredentialBinaryTests': credentials, + 'alreadyPresentFixes': lock['alreadyPresentFixes'], + 'newlyBackportedCves': item['fixedCves'], 'packagingPreserved': packaging, + 'runtimeInstalled': False, 'alternativesChanged': False} + (output / 'provenance.json').write_text(json.dumps(receipt, indent=2) + '\n', encoding='utf-8') + (output / 'SHA256SUMS').write_text(''.join(digest(p) + ' ' + p.name + '\n' + for p in sorted(output.iterdir()) if p.is_file()), encoding='utf-8') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--validate-lock', action='store_true') + parser.add_argument('--output-dir', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--cache-dir', type=Path) + parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) + args = parser.parse_args() + try: + lock = load_lock(LOCK_FILE) + require(1 <= args.jobs <= 64, 'Invalid job count') + if args.validate_lock: + print('Rust-coreutils source/patch lock: PASS') + return + require(args.output_dir is not None, '--output-dir is required') + build(args, lock) + except (ValueError, OSError, subprocess.SubprocessError) as error: + parser.exit(1, str(error) + '\n') + + +if __name__ == '__main__': + main() diff --git a/server/security/ubuntu-backports/rust-coreutils/native-tests.mk b/server/security/ubuntu-backports/rust-coreutils/native-tests.mk new file mode 100644 index 0000000000..4de1b1dcaa --- /dev/null +++ b/server/security/ubuntu-backports/rust-coreutils/native-tests.mk @@ -0,0 +1,6 @@ +# Retain the exact Ubuntu exports/features; only replace its non-gating test recipe. +include debian/rules + +.PHONY: pasturestack-strict-test +pasturestack-strict-test: + $(MAKE) -f GNUmakefile SELINUX_ENABLED=1 PROFILE=release MULTICALL=y TEST_NO_FAIL_FAST=--no-fail-fast test diff --git a/server/security/ubuntu-backports/rust-coreutils/rust-coreutils.lock.json b/server/security/ubuntu-backports/rust-coreutils/rust-coreutils.lock.json new file mode 100644 index 0000000000..62ec67058d --- /dev/null +++ b/server/security/ubuntu-backports/rust-coreutils/rust-coreutils.lock.json @@ -0,0 +1,266 @@ +{ + "schemaVersion": 1, + "sourcePackage": "rust-coreutils", + "ubuntuSourceVersion": "0.10.0-1ubuntu2~26.04.1", + "customVersion": "0.10.0-1ubuntu2~26.04.1+pasturestack.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "runtimePackages": [ + "rust-coreutils" + ], + "sourceDateEpoch": 1792886400, + "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18747163", + "sourceFiles": [ + { + "name": "rust-coreutils_0.10.0-1ubuntu2~26.04.1.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0-1ubuntu2~26.04.1.dsc", + "sha256": "c7eb62e7d2a07ec84c18bb2a6694331bb32bd0bde316261cfbe2f90acb2b6d28", + "size": 8746 + }, + { + "name": "rust-coreutils_0.10.0.orig.tar.gz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0.orig.tar.gz", + "sha256": "f8e68cd0e3629378f047544ead272161a83211c43f4985a9f52944e5db8f1a44", + "size": 2671039 + }, + { + "name": "rust-coreutils_0.10.0.orig-l10n.tar.gz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0.orig-l10n.tar.gz", + "sha256": "3df90fabddfb28c7ae1d31237fc8abf70bb1f6c1937d818008943a11e31cfd5b", + "size": 780526 + }, + { + "name": "rust-coreutils_0.10.0-1ubuntu2~26.04.1.debian.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0-1ubuntu2~26.04.1.debian.tar.xz", + "sha256": "61eda389122a85e90e542bf6b12c840ab68ee3741958db539f24a3599f5d4bb2", + "size": 22541108 + } + ], + "upstream": { + "repository": "https://github.com/uutils/coreutils", + "baseTag": "0.10.0", + "baseCommit": "28b6856d7b215bf844b4223589cb54ade84f5223", + "archiveUrl": "https://github.com/uutils/coreutils/archive/refs/tags/0.10.0.tar.gz", + "archiveSha256": "f8e68cd0e3629378f047544ead272161a83211c43f4985a9f52944e5db8f1a44", + "archiveEqualsUbuntuOrig": true, + "releaseUrl": "https://github.com/uutils/coreutils/releases/tag/0.10.0" + }, + "alreadyPresentFixes": [ + { + "id": "CVE-2026-35341", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.6.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/10376", + "commit": "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "sourcePath": "src/uu/mkfifo/src/mkfifo.rs" + }, + { + "id": "CVE-2026-35344", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12667", + "commit": "0f538b942a5cdaba6e0960f6952429a1baaedf93", + "sourcePath": "src/uu/dd/src/dd.rs" + }, + { + "id": "CVE-2026-35345", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12661", + "commit": "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "sourcePath": "src/uu/tail/src/tail.rs" + }, + { + "id": "CVE-2026-35348", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/11593", + "commit": "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "sourcePath": "src/uu/sort/src/sort.rs", + "additionalCommits": [ + "6e28633263ea84aad9ef7a30750232b73fb9133f" + ] + }, + { + "id": "CVE-2026-35350", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "sourcePath": "src/uu/cp/src/cp.rs" + }, + { + "id": "CVE-2026-35351", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/11706", + "commit": "874efa7cc3361cb5af2a97db869147f910bcab44", + "sourcePath": "src/uu/mv/src/mv.rs" + }, + { + "id": "CVE-2026-35352", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12174", + "commit": "c004672c5eb2415b03f11f766583c4b967ba024f", + "sourcePath": "src/uu/mkfifo/src/mkfifo.rs" + }, + { + "id": "CVE-2026-35354", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12170", + "commit": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "sourcePath": "src/uu/mv/src/mv.rs" + }, + { + "id": "CVE-2026-35357", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "sourcePath": "src/uu/cp/src/cp.rs" + }, + { + "id": "CVE-2026-35359", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "sourcePath": "src/uu/cp/src/cp.rs" + }, + { + "id": "CVE-2026-35360", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12171", + "commit": "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "sourcePath": "src/uu/touch/src/touch.rs" + }, + { + "id": "CVE-2026-35363", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/11005", + "commit": "6b16cc9688fe998f5dc2786f53f22ceea709e318", + "sourcePath": "src/uu/rm/src/rm.rs" + }, + { + "id": "CVE-2026-35364", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12170", + "commit": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "sourcePath": "src/uu/mv/src/mv.rs" + }, + { + "id": "CVE-2026-35367", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12339", + "commit": "acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "sourcePath": "src/uu/nohup/src/nohup.rs" + }, + { + "id": "CVE-2026-35368", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.9.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/11211", + "commit": "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "sourcePath": "src/uu/chroot/src/chroot.rs" + }, + { + "id": "CVE-2026-35373", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/11403", + "commit": "48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "sourcePath": "src/uu/ln/src/ln.rs" + }, + { + "id": "CVE-2026-35374", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12565", + "commit": "58040feb5c66fce588a7ffac299b3a23bd55cd81", + "sourcePath": "src/uu/split/src/split.rs" + }, + { + "id": "CVE-2026-35377", + "status": "already_in_original_ubuntu_source", + "firstFixedVersion": "0.10.0", + "upstreamPullRequest": "https://github.com/uutils/coreutils/pull/12657", + "commit": "b9d8ff64c224eb425702584b66ec0ac64a99592a", + "sourcePath": "src/uu/env/src/env.rs" + } + ], + "patches": [ + { + "name": "ff382f527994b8a021ec0a086dec4c5cafa34f2a.patch", + "commit": "ff382f527994b8a021ec0a086dec4c5cafa34f2a", + "url": "https://github.com/uutils/coreutils/commit/ff382f527994b8a021ec0a086dec4c5cafa34f2a.patch", + "sha256": "32d843876e0814cb070104afc049c036391604ea517c6e837ffc9345fe1e2de2", + "size": 4082, + "firstFixedVersion": "0.11.0", + "fixedCves": [ + "CVE-2026-35370", + "CVE-2026-35371" + ], + "pullRequest": "https://github.com/uutils/coreutils/pull/14103", + "paths": [ + "src/uu/id/src/id.rs", + "tests/by-util/test_id.rs" + ] + } + ], + "baselineSha256": { + "src/uu/id/src/id.rs": "de9b78d3d511f5c5ac14f42338648f8755ac5b99cae2376cf2af18e9cce5c3f0", + "tests/by-util/test_id.rs": "d6948f672f30bf9205fa87526ce9efcdabc9950910269e5cdef458b914d65b4e", + "src/uu/mkfifo/src/mkfifo.rs": "6c7c49a911cff80e6ea0499044971bd232c37b298a5a0c83551b41d5a9f46dbe", + "src/uu/dd/src/dd.rs": "11052025db5fd3e1c5cac0b026c67b1d45c7cf601cff01329eba0f2408ad54b9", + "src/uu/tail/src/tail.rs": "8168856be7226284452e65604fa6da162a137050cd2c1822e8c3aac87b7904b0", + "src/uu/sort/src/sort.rs": "95820b55661c4281b3a51e54496fb9c0f5050ed202f1e8c68b87efb3e17de5dd", + "src/uu/cp/src/cp.rs": "7094892394071883367c3b0de3b1b7d0beee6a354d32931397808276547ae525", + "src/uu/mv/src/mv.rs": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "src/uu/touch/src/touch.rs": "5a348cc8d276aa0613393ea0451fca703d5e614ae83e6cae6b25582cf0cbda27", + "src/uu/rm/src/rm.rs": "776b445347e3087e90d811b38b5b7604c8e362608c1772b762aa7aabe55671c4", + "src/uu/nohup/src/nohup.rs": "93a8ab0c82728d3856b0e181312bdb3f0418d86a33f70af00b61e915e2b3173c", + "src/uu/chroot/src/chroot.rs": "9282c030345eee3c85c029b0978f587452087c4c873d3ed2f527c4aa677b4e7c", + "src/uu/ln/src/ln.rs": "5c681cfa583a268bdce9a9b740f9dcd8b38c5ab53754883e5c20800259a4f1b3", + "src/uu/split/src/split.rs": "8ad1864e2abaac40dfc3523f29ea4013624e872dfd65e0c8c40e2bc1b38b4e75", + "src/uu/env/src/env.rs": "2cc9122003a63d95a439d39c0ab074409bca4098bc930a38c4e6cc5a625543e1", + "GNUmakefile": "eb532c745680e9c82a6836692b306e3a29b1fec5f1055e341aa3d41490acb459", + "debian/rules": "06c5fd183a4a1146748810e3cce859ba849643eeee244cc80cbb3315aeedb5e7", + "debian/copyright": "f1ea5d8870ff83301cc4c129ecd5e0e3c998bd566a9b0036df899e84d9336bd6" + }, + "patchedSha256": { + "src/uu/id/src/id.rs": "5a8ec6db1ac0ac370c6557ef2fe4832d9b5f2942e6c9e60f086a1c08f90a7468", + "tests/by-util/test_id.rs": "75fac73627073f8a33f086058510db446efc5226d8b9d3c944ae1a2b8caf8689" + }, + "buildContract": { + "nativeOnly": true, + "buildAndFullSuiteUser": "non-root", + "credentialFixturePrivilege": "sudo -n setpriv; bounded subprocess only", + "minRustVersion": "1.88.0", + "originalUbuntuPackaging": true, + "profile": "release", + "multicall": true, + "selinuxEnabled": true, + "lto": "thin", + "optLevel": "z", + "codegenUnits": 1, + "releaseDebug": true, + "network": "Cargo offline; vendored crates from the pinned Ubuntu Debian archive", + "strictTestTarget": "pasturestack-strict-test", + "newSkips": 0, + "installRuntime": false, + "alterAlternatives": false + }, + "limitations": [ + "This is a first-party rebuild candidate, not a Canonical-issued update.", + "The original Ubuntu test recipe has skips and ||true; package-build success alone is not sufficient. The helper additionally runs the complete owning suite without added skips and requires success.", + "Source SHA pins establish reviewed source identity. They do not claim verified PGP or upstream commit signatures.", + "Source preparation and zero-fuzz patch application are complete; native build, strict full tests and distinct-credential binary QA remain required before any remediation claim." + ] +} diff --git a/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py b/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py new file mode 100644 index 0000000000..97b0f8de3a --- /dev/null +++ b/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py @@ -0,0 +1,121 @@ +"""Network-free guards for source identity, unsuppressed tests and GID QA.""" +import copy +import importlib.util +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('rust_backport', HERE / 'build-rust-coreutils-backport.py') +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) + + +class RustBackport(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='rust-backport-contract-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.lock = M.load_lock(HERE / 'rust-coreutils.lock.json') + + def test_lock_rejects_widened_scope_unpinned_sources_and_wrong_identity(self): + for field, bad in [('architecture', 'arm64'), ('ubuntuSourceVersion', '0.12.0'), + ('runtimePackages', ['coreutils']), ('sourceDateEpoch', True)]: + with self.subTest(field=field): + altered = copy.deepcopy(self.lock) + altered[field] = bad + file = self.root / 'bad.json' + file.write_text(json.dumps(altered), encoding='utf-8') + with self.assertRaises(ValueError): M.load_lock(file) + for bad in ['../outside.patch', 'http://example.invalid/patch', 'bad-hash']: + altered = copy.deepcopy(self.lock) + key = 'name' if bad.startswith('..') else ('url' if bad.startswith('http') else 'sha256') + altered['patches'][0][key] = bad + file.write_text(json.dumps(altered), encoding='utf-8') + with self.assertRaises(ValueError): M.load_lock(file) + + def test_poisoned_cache_is_not_silently_refetched(self): + cache, inputs = self.root / 'cache', self.root / 'inputs' + cache.mkdir(); inputs.mkdir() + item = self.lock['patches'][0] + (cache / item['name']).write_bytes(b'poison') + with mock.patch.object(M.urllib.request, 'build_opener') as opener: + with self.assertRaises(ValueError): M.download(item, inputs, cache) + opener.assert_not_called() + + def test_source_control_manifest_must_match_all_three_pinned_archives(self): + entries = [x for x in self.lock['sourceFiles'] if not x['name'].endswith('.dsc')] + text = ('Format: 3.0 (quilt)\nSource: rust-coreutils\nVersion: ' + M.VERSION + + '\nChecksums-Sha256:\n' + ''.join(' ' + x['sha256'] + ' ' + str(x['size']) + + ' ' + x['name'] + '\n' for x in entries)) + file = self.root / ('rust-coreutils_' + M.VERSION + '.dsc') + file.write_text(text, encoding='utf-8') + M.verify_source_control(self.lock, self.root) + file.write_text(text.replace(entries[0]['sha256'], '0' * 64), encoding='utf-8') + with self.assertRaises(ValueError): M.verify_source_control(self.lock, self.root) + + def test_patch_cannot_modify_packaging_or_other_utilities(self): + file = self.root / 'patch' + text = 'From ' + M.COMMIT + ' Mon Sep 17 00:00:00 2001\n' + ''.join( + 'diff --git a/' + p + ' b/' + p + '\n' for p in M.PATHS) + file.write_text(text, encoding='utf-8') + M.verify_patch(self.lock['patches'][0], file) + file.write_text(text + 'diff --git a/debian/rules b/debian/rules\n', encoding='utf-8') + with self.assertRaises(ValueError): M.verify_patch(self.lock['patches'][0], file) + + def test_build_environment_discards_test_and_compiler_overrides(self): + with mock.patch.dict(os.environ, {'DEB_BUILD_OPTIONS': 'nocheck', 'CARGOFLAGS': '--test id', + 'RUSTFLAGS': '-C opt-level=0', 'TESTS': 'id'}): + env = M.build_environment(self.lock, 2, self.root) + self.assertEqual('parallel=2', env['DEB_BUILD_OPTIONS']) + self.assertEqual('', env['DEB_BUILD_MAINT_OPTIONS']) + self.assertEqual('true', env['CARGO_NET_OFFLINE']) + self.assertTrue(all(x not in env for x in ['CARGOFLAGS', 'RUSTFLAGS', 'TESTS', 'SKIP_UTILS'])) + + def test_root_cannot_accidentally_run_the_build_or_full_suite(self): + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(M.os, 'geteuid', create=True, return_value=0): + with self.assertRaisesRegex(ValueError, 'must run as non-root'): + M.build(None, self.lock) + + def test_strict_receipt_requires_real_suite_and_official_regression(self): + file = self.root / 'strict.log' + good = ('test test_id::test_id_groups_ordering ... ok\n' + 'test result: ok. 12 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out;\n') + file.write_text(good, encoding='utf-8') + self.assertEqual('PASS', M.strict_test_receipt(file)['status']) + for bad in [good.replace('groups_ordering', 'unrelated'), + good.replace('0 failed', '1 failed'), good + 'test result: FAILED\n', '']: + file.write_text(bad, encoding='utf-8') + with self.assertRaises(ValueError): M.strict_test_receipt(file) + + def test_native_command_failure_cannot_be_swallowed(self): + with self.assertRaises(ValueError): + M.run([sys.executable, '-c', 'raise SystemExit(3)'], self.root, dict(os.environ), + self.root / 'failed.log') + + def test_distinct_gid_fixture_detects_original_effective_first_bug(self): + binary = self.root / 'id' + binary.write_bytes(b'fixture') + def output(command, *_): + real = int(next(x.split('=')[1] for x in command if x.startswith('--rgid='))) + effective = int(next(x.split('=')[1] for x in command if x.startswith('--egid='))) + extra = sorted(map(int, next(x.split('=')[1] for x in command if x.startswith('--groups=')).split(','))) + args = command[command.index(str(binary)) + 1:] + if '-G' in args: return ' '.join(map(str, dict.fromkeys([real, effective] + extra))) + if '-g' in args: return str(real if '-r' in args else effective) + return 'uid=65534(nobody) gid=' + str(real) + ' groups=' + ','.join( + str(x) + '(group)' for x in dict.fromkeys([effective] + extra)) + with mock.patch.object(M, 'capture', side_effect=output): + self.assertEqual(3, len(M.verify_id(binary, [], {}, self.root))) + with mock.patch.object(M, 'capture', return_value='1 2'): + with self.assertRaises(ValueError): M.verify_id(binary, [], {}, self.root) + + +if __name__ == '__main__': + unittest.main() From bc129a88a5ec8c4e64b3ed89bfd97c7e82362d7c Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:34:52 +0800 Subject: [PATCH 2/9] Keep glibc source validation inside the verified native build --- .../ubuntu-backports/glibc/build-glibc-backport.py | 8 +------- .../ubuntu-backports/glibc/test_glibc_backport.py | 5 +++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/server/security/ubuntu-backports/glibc/build-glibc-backport.py b/server/security/ubuntu-backports/glibc/build-glibc-backport.py index 49dcb0529e..b60ad8f22b 100644 --- a/server/security/ubuntu-backports/glibc/build-glibc-backport.py +++ b/server/security/ubuntu-backports/glibc/build-glibc-backport.py @@ -317,9 +317,7 @@ def build(args, lock): def main(): parser = argparse.ArgumentParser(description=__doc__) - mode = parser.add_mutually_exclusive_group() - mode.add_argument('--validate-lock', action='store_true') - mode.add_argument('--validate-inputs', action='store_true') + parser.add_argument('--validate-lock', action='store_true') parser.add_argument('--output-dir', type=Path) parser.add_argument('--work-dir', type=Path) parser.add_argument('--cache-dir', type=Path) @@ -331,10 +329,6 @@ def main(): lock = load_lock(LOCK_FILE) if args.validate_lock: print('GLIBC_BACKPORT_LOCK_OK sha256=' + digest(LOCK_FILE)) - elif args.validate_inputs: - require(args.cache_dir is not None, '--validate-inputs requires --cache-dir') - validate_inputs(lock, args.cache_dir) - print('GLIBC_BACKPORT_INPUTS_OK sha256=' + digest(LOCK_FILE)) else: require(args.output_dir is not None, '--output-dir is required for a native build') build(args, lock) diff --git a/server/security/ubuntu-backports/glibc/test_glibc_backport.py b/server/security/ubuntu-backports/glibc/test_glibc_backport.py index 35e0431c55..d6b7e36b85 100644 --- a/server/security/ubuntu-backports/glibc/test_glibc_backport.py +++ b/server/security/ubuntu-backports/glibc/test_glibc_backport.py @@ -196,10 +196,11 @@ def test_cli_validate_lock_is_network_and_linux_build_independent(self): self.assertIn('GLIBC_BACKPORT_LOCK_OK', result.stdout) self.assertNotIn('GLIBC_BACKPORT_BUILT', result.stdout) - def test_cli_rejects_duplicate_foreign_dev_export_invalid_jobs_and_missing_cache(self): + def test_cli_rejects_duplicate_foreign_dev_export_invalid_jobs_and_external_input_mode(self): for args in [('--validate-lock', '--jobs', '0'), ('--validate-lock', '--jobs', '33'), ('--validate-lock', '--export-dev-package', 'libc6-dev', '--export-dev-package', 'libc6-dev'), - ('--validate-lock', '--export-dev-package', 'gcc'), ('--validate-inputs',)]: + ('--validate-lock', '--export-dev-package', 'gcc'), ('--validate-inputs',), + ('--validate-lock', '--lock', 'untrusted.json')]: with self.subTest(args=args): self.assertNotEqual(0, self.cli(*args).returncode) From 099d7bf5902019f733e070f30399a8487c42df6e Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:08:02 +0800 Subject: [PATCH 3/9] Validate official p11-kit Xrender and tar security backports --- .github/workflows/ubuntu-backport-native.yml | 11 +- .../ubuntu-backports/Dockerfile.native-ci | 7 +- server/security/ubuntu-backports/README.md | 23 ++ .../libxrender/build-libxrender-backport.py | 107 ++++++ .../libxrender/libxrender.lock.json | 99 ++++++ .../libxrender/reply-boundary.c | 70 ++++ .../libxrender/test_libxrender_backport.py | 84 +++++ .../p11-kit/build-p11-kit-backport.py | 105 ++++++ .../p11-kit/p11-kit.lock.json | 123 +++++++ .../p11-kit/test_p11_kit_backport.py | 158 +++++++++ .../ubuntu-backports/source_helpers.py | 245 +++++++++++++ .../tar/build-tar-backport.py | 323 ++++++++++++++++++ .../ubuntu-backports/tar/tar.lock.json | 109 ++++++ .../ubuntu-backports/tar/test_tar_backport.py | 183 ++++++++++ 14 files changed, 1644 insertions(+), 3 deletions(-) create mode 100644 server/security/ubuntu-backports/README.md create mode 100644 server/security/ubuntu-backports/libxrender/build-libxrender-backport.py create mode 100644 server/security/ubuntu-backports/libxrender/libxrender.lock.json create mode 100644 server/security/ubuntu-backports/libxrender/reply-boundary.c create mode 100644 server/security/ubuntu-backports/libxrender/test_libxrender_backport.py create mode 100644 server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py create mode 100644 server/security/ubuntu-backports/p11-kit/p11-kit.lock.json create mode 100644 server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py create mode 100644 server/security/ubuntu-backports/source_helpers.py create mode 100644 server/security/ubuntu-backports/tar/build-tar-backport.py create mode 100644 server/security/ubuntu-backports/tar/tar.lock.json create mode 100644 server/security/ubuntu-backports/tar/test_tar_backport.py diff --git a/.github/workflows/ubuntu-backport-native.yml b/.github/workflows/ubuntu-backport-native.yml index 45d08d6b80..6953546309 100644 --- a/.github/workflows/ubuntu-backport-native.yml +++ b/.github/workflows/ubuntu-backport-native.yml @@ -2,6 +2,9 @@ name: Ubuntu Backport Native Candidate on: pull_request: + # Initial candidates get a native run; subsequent fixes select only affected + # modules through workflow_dispatch so unchanged native PASS is not rebuilt. + types: [opened, reopened] paths: - '.github/workflows/ubuntu-backport-native.yml' - 'server/security/ubuntu-backports/Dockerfile.native-ci' @@ -9,6 +12,10 @@ on: - 'server/security/ubuntu-backports/glibc/**' - 'server/security/ubuntu-backports/rust-coreutils/**' - 'server/security/ubuntu-backports/jq/**' + - 'server/security/ubuntu-backports/p11-kit/**' + - 'server/security/ubuntu-backports/libxrender/**' + - 'server/security/ubuntu-backports/tar/**' + - 'server/security/ubuntu-backports/source_helpers.py' workflow_dispatch: inputs: module: @@ -16,7 +23,7 @@ on: required: true type: choice default: all - options: [all, glibc, rust-coreutils, jq] + options: [all, glibc, rust-coreutils, jq, p11-kit, libxrender, tar] permissions: contents: read @@ -33,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - module: ${{ fromJSON(inputs.module == 'glibc' && '["glibc"]' || inputs.module == 'rust-coreutils' && '["rust-coreutils"]' || inputs.module == 'jq' && '["jq"]' || '["glibc","rust-coreutils","jq"]') }} + module: ${{ fromJSON(inputs.module && inputs.module != 'all' && format('["{0}"]', inputs.module) || '["glibc","rust-coreutils","jq","p11-kit","libxrender","tar"]') }} env: MODULE: ${{ matrix.module }} SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/server/security/ubuntu-backports/Dockerfile.native-ci b/server/security/ubuntu-backports/Dockerfile.native-ci index 7c3d9a241c..3248c83c24 100644 --- a/server/security/ubuntu-backports/Dockerfile.native-ci +++ b/server/security/ubuntu-backports/Dockerfile.native-ci @@ -12,7 +12,7 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /tmp/ca-certificates.deb RUN <<'SH' set -euo pipefail -case "$MODULE" in glibc|rust-coreutils|jq) ;; *) exit 2 ;; esac +case "$MODULE" in glibc|rust-coreutils|jq|p11-kit|libxrender|tar) ;; *) exit 2 ;; esac mkdir -p /tmp/ca-bootstrap /etc/ssl/certs dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap while IFS= read -r certificate; do @@ -79,6 +79,11 @@ if [[ "$MODULE" == glibc ]]; then else apt-get build-dep --no-install-recommends --arch-only -y "$MODULE=$version" fi +if [[ "$MODULE" == libxrender ]]; then + # CI-only private display for the actual QueryFormats regression. Never + # connect to a host display or add Xvfb to a product's runtime dependencies. + apt-get install --no-install-recommends -y xvfb xauth +fi useradd --create-home --user-group --uid 10001 --shell /bin/bash builder if [[ "$MODULE" == rust-coreutils ]]; then # Only the helper's bounded credential subprocess uses sudo. No host mount diff --git a/server/security/ubuntu-backports/README.md b/server/security/ubuntu-backports/README.md new file mode 100644 index 0000000000..bf2af4ef6d --- /dev/null +++ b/server/security/ubuntu-backports/README.md @@ -0,0 +1,23 @@ +# Official source security candidates + +These are first-party rebuild tools, not Canonical packages or a statement that +any running product is fixed. Each module retains the original Ubuntu packaging, +pins official inputs by SHA-256, and requires its owning tests and extracted +package/ABI checks before exporting evidence. No helper installs runtime packages. + +`ubuntu-backport-native.yml` builds disposable Ubuntu26 amd64 candidates without +host mounts, publishing credentials or deployment rights. Initial pull requests +run native validation; after changes, explicitly dispatch only affected modules. +Do not rerun an unchanged module that already has valid native PASS evidence. +Its module source, lock, compiler/dependency inputs and expected artifact identity +must still match. Changes to shared behavior require revalidating its consumers. + +Select `glibc`, `rust-coreutils`, `jq`, `p11-kit`, `libxrender` or `tar` in the +workflow. Source helpers run as a non-root user; Rust credential regression +subprocesses alone receive privileges inside that temporary container. A failed +helper retains logs and a failing result; exported files alone are not a PASS. + +Before a product consumes a candidate, require the exact native result, +provenance, source lock, package hashes, ABI and affected runtime smoke. Any +remaining CVE or unknown architecture/reachability condition stays tracked. +Never overwrite a released package/image tag or patch a running container. diff --git a/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py b/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py new file mode 100644 index 0000000000..1939398e18 --- /dev/null +++ b/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Exact libXrender security backport; preserve Ubuntu packaging and public ABI.""" +import importlib.util +import json +from pathlib import Path +import re +import shlex +import subprocess +import sys + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('source_helpers', HERE.parent / 'source_helpers.py') +H = importlib.util.module_from_spec(SPEC) +sys.dont_write_bytecode = True +SPEC.loader.exec_module(H) +COMMITS = ['0564bce5446550c1b1681f3d8a933c20ea1455f7'] + + +def exports(file, work, env): + return {line.split()[-1].split('@')[0] for line in + H.capture(['nm', '--dynamic', '--defined-only', str(file)], work, env).splitlines() + if line.split()} + + +def check_result(code, output, negative): + if negative: + H.require(code == 2 and 'malformed header reached allocation:' in output, + 'Original-source negative control did not detect the specific vulnerable path') + else: + H.require(code == 0 and 'PASS' in output, 'Patched/packaged reply-boundary fixture failed') + + +def regression(source, extracted, work, env, log): + archive = source / 'build/src/.libs/libXrender.a' + H.require(archive.is_file(), 'Original configured static test archive missing') + cc = ['cc'] + for name in ['CPPFLAGS', 'CFLAGS']: + cc += shlex.split(H.capture(['dpkg-buildflags', '--get', name], work, env)) + includes = ['-I' + str(source / x) for x in ['build', 'src', 'include', 'include/X11/extensions']] + links = ['-lX11', '-Wl,--wrap=_XReply', '-Wl,--wrap=calloc'] + fixture = str(HERE / 'reply-boundary.c') + old_object = work / 'original-Xrender.o' + H.run(cc + includes + ['-DHAVE_CONFIG_H', '-c', str(work / 'original-Xrender.c'), + '-o', str(old_object)], work, env, log) + executables = [] + for name, objects in [('patched', [str(archive)]), ('original', [str(old_object), str(archive)]), + ('packaged', [str(extracted / 'usr/lib/x86_64-linux-gnu/libXrender.so.1')])]: + file = work / ('reply-' + name) + H.run(cc + includes + [fixture] + objects + links + ['-o', str(file)], work, env, log) + executables.append((name, file)) + child = dict(env, LD_LIBRARY_PATH=str(extracted / 'usr/lib/x86_64-linux-gnu')) + results = [] + for name, file in executables: + command = ['xvfb-run', '--auto-servernum', '--server-args=-nolisten tcp -screen 0 640x480x24', + str(file)] + (['--valid-only'] if name == 'packaged' else []) + result = subprocess.run(command, cwd=work, env=child, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=30) + check_result(result.returncode, result.stdout, name == 'original') + results.append({'test': name, 'exitCode': result.returncode, 'output': result.stdout, + 'status': 'EXPECTED_FAILURE' if name == 'original' else 'PASS'}) + report = work / 'reply-boundary.json' + report.write_text(json.dumps(results, indent=2) + '\n', encoding='utf-8') + return results, [(report, report.name)] + + +def verify(lock, source, extracted, work, env, log): + # This upstream has no tests: preserve its normal check target, then require + # the additional native negative/positive boundary fixtures instead of claiming + # zero upstream cases proved the vulnerability fixed. + check_log = work / 'owning-check.log' + H.run(['make', '-C', 'build', 'check'], source, env, check_log) + H.require(not list(source.rglob('*.trs')), 'Unexpected upstream test manifest; reassess full suite') + baseline_dir = work / 'original-package' + baseline = work / 'inputs' / lock['baselineBinary']['name'] + for field, expected in [('Package', 'libxrender1'), ('Version', '1:0.9.12-1build1'), + ('Architecture', 'amd64')]: + H.require(H.capture(['dpkg-deb', '--field', str(baseline), field], work, env) == expected, + 'Original Ubuntu ABI baseline identity mismatch') + H.run(['dpkg-deb', '--extract', str(baseline), str(baseline_dir)], work, env, log) + library = extracted / 'usr/lib/x86_64-linux-gnu/libXrender.so.1' + old = baseline_dir / 'usr/lib/x86_64-linux-gnu/libXrender.so.1' + H.require(exports(library, work, env) == exports(old, work, env), + 'Original Ubuntu exported ABI changed') + dynamic = H.capture(['readelf', '--dynamic', str(library)], work, env) + old_dynamic = H.capture(['readelf', '--dynamic', str(old)], work, env) + needed = lambda text: set(re.findall(r'NEEDED.*\[(.+?)\]', text)) + H.require('[libXrender.so.1]' in dynamic and needed(dynamic) == needed(old_dynamic), + 'SONAME/original runtime dependencies changed') + # The original source has no Debian symbols file; compare the exact original + # Ubuntu binary exports and NEEDED entries instead. + headers = H.capture(['readelf', '--program-headers', str(library)], work, env) + H.require('GNU_RELRO' in headers and ('BIND_NOW' in dynamic or re.search(r'FLAGS_1.*NOW', dynamic)), + 'Original Ubuntu hardening is missing') + cases, evidence = regression(source, extracted, work, env, log) + return {'owningCheck': 'PASS', 'upstreamDefinedTestCases': 0, + 'originalUbuntuAbiAndNeeded': 'PASS', 'originalBinarySha256': lock['baselineBinary']['sha256'], + 'replyBoundaryCases': cases, 'runtimeInstalled': False}, \ + [(check_log, check_log.name)] + evidence + + +if __name__ == '__main__': + try: + H.entry(HERE, 'libxrender', '1:0.9.12-1build1', COMMITS, verify, + ['cc', 'dpkg-buildflags', 'xvfb-run', 'Xvfb', 'xauth']) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print('LIBXRENDER_BACKPORT_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/libxrender/libxrender.lock.json b/server/security/ubuntu-backports/libxrender/libxrender.lock.json new file mode 100644 index 0000000000..f438120bcf --- /dev/null +++ b/server/security/ubuntu-backports/libxrender/libxrender.lock.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "baselineBinary": { + "name": "libxrender1_0.9.12-1build1_amd64.deb", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/libx/libxrender/libxrender1_0.9.12-1build1_amd64.deb", + "sha256": "60b2f01e8124318e2a11049b82c764fd4c05c21adba11312e93ab116d6594f1e", + "size": 19204, + "version": "1:0.9.12-1build1", + "indexURL": "https://archive.ubuntu.com/ubuntu/dists/resolute/main/binary-amd64/Packages.xz", + "indexSha256": "ed9ac41cb263efaecc5a06a0742dc56570e2fb5ff94c4f2e4b2fbdcddd73cf24", + "asOf": "2026-10-24T17:37:59.403844+00:00" + }, + "sourcePackage": "libxrender", + "ubuntuSourceVersion": "1:0.9.12-1build1", + "customVersion": "1:0.9.12-1build1+pasturestack.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "sourceDateEpoch": 1792857600, + "identity": "First-party rebuild with unchanged Ubuntu packaging and exact upstream security commits; not issued by Canonical.", + "runtimePackages": [ + "libxrender1" + ], + "sourceFiles": [ + { + "name": "libxrender_0.9.12-1build1.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/libx/libxrender/libxrender_0.9.12-1build1.dsc", + "size": 2282, + "sha256": "84d74d1d04750e0742521f453635b0b092b5fb13cdf3aa00c1360c6db2d68e42" + }, + { + "name": "libxrender_0.9.12-1build1.diff.gz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/libx/libxrender/libxrender_0.9.12-1build1.diff.gz", + "size": 21468, + "sha256": "473c174d5df6c9378e52d092909acf42bdbd453af419893df6414294619649ac" + }, + { + "name": "libxrender_0.9.12.orig.tar.gz.asc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/libx/libxrender/libxrender_0.9.12.orig.tar.gz.asc", + "size": 833, + "sha256": "0bbd310ac3974ef398cf4d8a4b362b0b4d60ceb43e6eba393c3cc740b03816fc" + }, + { + "name": "libxrender_0.9.12.orig.tar.gz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/libx/libxrender/libxrender_0.9.12.orig.tar.gz", + "size": 450034, + "sha256": "0fff64125819c02d1102b6236f3d7d861a07b5216d8eea336c3811d31494ecf7" + } + ], + "patches": [ + { + "name": "0564bce5446550c1b1681f3d8a933c20ea1455f7.patch", + "url": "https://gitlab.freedesktop.org/xorg/lib/libxrender/-/commit/0564bce5446550c1b1681f3d8a933c20ea1455f7.patch", + "size": 1300, + "sha256": "565e816e88ca223cebc6f833cfcfa63d20aaa22c8293d878bdd059910c4a66fc", + "commit": "0564bce5446550c1b1681f3d8a933c20ea1455f7", + "fixedCves": [ + "CVE-2026-88807" + ], + "firstFixedVersion": "0.9.13" + } + ], + "baselineSha256": { + "src/Xrender.c": "dcaa126454543938604573f5cde44978dbdec7e63e944d1bbf9dcd0ccf249265" + }, + "patchedSha256": { + "src/Xrender.c": "c5c93efd29a9e13cabe4f6fe99863c13a14d6c006e90267f747e9da9a8b800c8" + }, + "ubuntuPackagingSha256": { + "debian/control": "a0e3839d87703b5529b33c1afb9b51b66f2e43d446b0ee32261e61a1894bd2b4", + "debian/copyright": "ed2a78123a5076b4f11da38cb1ffb85b5cb9fdc26b5746c517b1d7e7a9029704", + "debian/libxrender-dev.install": "89e5d7d592067025e7a01f3a021e706d5fbd38d36287688f4581165b0386fa3c", + "debian/libxrender1-udeb.install": "260da073f134f45afdbb30624dbdbc3cd073a6686ff320a389ebe04b17841c00", + "debian/libxrender1.install": "a64e0b5d374cfb996d816612d84d39eb9f30af7bee549edcc71cb016e9383442", + "debian/not-installed": "124eb62e79e117cb874319b2c6bbc43f564bdc69ca9a9965c8799d4c26654c93", + "debian/README.source": "465394b01938cfeb0907b774eebb8de260196ecbaded5197dfdf80efbe069cf3", + "debian/rules": "d42e7ede44c920737206230a9f18930204abaf6aa5af74e8880b2b744d332ba8", + "debian/watch": "73b20911768168b7a4959eafdd8b11729dc03bfedee9aa98ff80d373d63b8595", + "debian/upstream/signing-key.asc": "41bfb9ac51e4e61957b7f5659470fa211b907069cc613a1f6327e639baef7af7" + }, + "buildContract": { + "nativeUbuntuAmd64": true, + "buildUser": "non-root", + "originalRulesAndDependenciesPreserved": true, + "addedSkips": 0, + "runtimeInstalled": false, + "requiredOwningSuite": "Original upstream defines zero tests; native make check must succeed, plus exact source negative-control and patched/packaged XRender reply-boundary fixtures.", + "requiredSoname": "libXrender.so.1", + "extraCiOnlyDependencies": [ + "xvfb", + "xauth" + ] + }, + "limitations": [ + "Official patch ships no regression test; an additional bounded Xvfb reply-header fixture checks the actual function against original and patched source.", + "Source preparation passed; no native compilation, full suite or packaged binary verification has run locally.", + "Source archives/signatures are SHA256-pinned from official Ubuntu HTTPS; this does not claim verified uploader PGP signatures or Canonical-signed custom binaries." + ] +} diff --git a/server/security/ubuntu-backports/libxrender/reply-boundary.c b/server/security/ubuntu-backports/libxrender/reply-boundary.c new file mode 100644 index 0000000000..59029822fe --- /dev/null +++ b/server/security/ubuntu-backports/libxrender/reply-boundary.c @@ -0,0 +1,70 @@ +/* Exercise the actual XRenderQueryFormats reply boundary against an isolated Xvfb. + * The original source safely reaches allocation after the malformed header; + * patched source must reject it before allocating. No malformed payload is sent. + */ +#include +#include +#include +#include +#include +#include + +static int armed, injected, after_reply, allocations; +extern Status __real__XReply(Display *, xReply *, int, Bool); +extern void *__real_calloc(size_t, size_t); + +void *__wrap_calloc(size_t n, size_t size) +{ + if (after_reply) + allocations++; + return __real_calloc(n, size); +} + +Status __wrap__XReply(Display *display, xReply *reply, int extra, Bool discard) +{ + Status result = __real__XReply(display, reply, extra, discard); + if (armed && result) { + xRenderQueryPictFormatsReply *formats = (void *) reply; + if (formats->numScreens == 0 || formats->numScreens > 100 || + formats->numSubpixel != formats->numScreens) { + fprintf(stderr, "unexpected isolated Xvfb reply shape\n"); + exit(3); + } + formats->numSubpixel = formats->numScreens + 1; + injected++; + after_reply = 1; + } + return result; +} + +int main(int argc, char **argv) +{ + Display *display = XOpenDisplay(NULL); + if (!display || !XRenderQueryFormats(display) || + !XRenderFindStandardFormat(display, PictStandardARGB32)) { + fprintf(stderr, "valid XRender control failed\n"); + return 3; + } + XCloseDisplay(display); + if (argc == 2 && strcmp(argv[1], "--valid-only") == 0) { + puts("packaged valid XRender control: PASS"); + return 0; + } + display = XOpenDisplay(NULL); + if (!display) + return 3; + armed = 1; + Status status = XRenderQueryFormats(display); + armed = after_reply = 0; + XCloseDisplay(display); + if (injected != 1 || status != 0) { + fprintf(stderr, "malformed reply rejection control failed\n"); + return 3; + } + if (allocations != 0) { + fprintf(stderr, "malformed header reached allocation: %d\n", allocations); + return 2; + } + puts("valid control and malformed header rejected before allocation: PASS"); + return 0; +} diff --git a/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py b/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py new file mode 100644 index 0000000000..24800ca8aa --- /dev/null +++ b/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py @@ -0,0 +1,84 @@ +"""Network-free guards; native build/test execution belongs to isolated CI.""" +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock +import sys + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('module_backport', HERE / 'build-libxrender-backport.py') +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) +H = M.H + + +class Backport(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='official-source-unit-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.lock = H.load_lock(HERE / 'libxrender.lock.json', 'libxrender', '1:0.9.12-1build1', M.COMMITS) + + def altered(self, key, value): + lock = copy.deepcopy(self.lock) + lock[key] = value + file = self.root / 'bad.json' + file.write_text(json.dumps(lock), encoding='utf-8') + return H.load_lock(file, 'libxrender', '1:0.9.12-1build1', M.COMMITS) + + def test_version_architecture_and_commit_boundary_cannot_change(self): + for key, value in [('architecture', 'arm64'), ('ubuntuSourceVersion', 'other'), + ('patches', []), ('runtimePackages', [])]: + with self.assertRaises(ValueError): self.altered(key, value) + + def test_foreign_url_or_unpinned_source_cannot_be_downloaded(self): + for key, value in [('url', 'https://example.invalid/source'), ('sha256', ''), + ('name', '../outside')]: + sources = copy.deepcopy(self.lock['sourceFiles']) + sources[0][key] = value + with self.assertRaises(ValueError): self.altered('sourceFiles', sources) + + def test_poisoned_cache_fails_closed_without_refetch(self): + cache, dest = self.root / 'cache', self.root / 'inputs' + cache.mkdir(); dest.mkdir() + item = self.lock['sourceFiles'][0] + (cache / item['name']).write_bytes(b'poison') + with mock.patch.object(H.COMMON.urllib.request, 'build_opener') as opener: + with self.assertRaises(ValueError): H.COMMON.download(item, dest, cache) + opener.assert_not_called() + + def test_source_and_packaging_hash_mismatch_is_fatal(self): + file = self.root / 'input' + file.write_bytes(b'original') + expected = {'input': H.digest(file)} + H.verify_hashes(self.root, expected) + file.write_bytes(b'changed') + with self.assertRaises(ValueError): H.verify_hashes(self.root, expected) + + def test_root_cannot_run_build_or_owning_tests(self): + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(H.os, 'geteuid', create=True, return_value=0): + with self.assertRaisesRegex(ValueError, 'non-root'): + H.build(None, self.lock, HERE, M.verify, []) + + def test_old_negative_control_must_fail_for_the_specific_reply_boundary(self): + M.check_result(2, 'malformed header reached allocation: 1\n', True) + for code, output in [(0, 'PASS'), (2, 'unrelated failure'), (-11, 'crashed')]: + with self.assertRaises(ValueError): M.check_result(code, output, True) + + def test_candidate_and_packaged_controls_must_pass(self): + M.check_result(0, 'valid control: PASS', False) + with self.assertRaises(ValueError): M.check_result(2, 'PASS', False) + + def test_original_ubuntu_abi_binary_is_exactly_scoped(self): + baseline = copy.deepcopy(self.lock['baselineBinary']) + baseline['version'] = 'other' + with self.assertRaises(ValueError): self.altered('baselineBinary', baseline) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py new file mode 100644 index 0000000000..01656e084f --- /dev/null +++ b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Exact p11-kit RPC security backports, with unchanged Ubuntu owning tests.""" +from collections import Counter +import ctypes +import importlib.util +import json +from pathlib import Path +import re +import subprocess +import sys + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('source_helpers', HERE.parent / 'source_helpers.py') +H = importlib.util.module_from_spec(SPEC) +sys.dont_write_bytecode = True +SPEC.loader.exec_module(H) +COMMITS = ['0eedd4ddd7c924f9cbb4ac496b3fda699435c3cf', '3e64244e538550c6a7fcf826fa8c50a4604416dc'] + + +def require_tap_ok(text, case): + pattern = (r'^(not ok|ok)[ \t]+[0-9]+[ \t]+(?:-[ \t]+)?' + re.escape(case) + + r'((?:[ \t]+.*)?)$') + results = [(state, suffix.strip()) for state, suffix in re.findall(pattern, text, re.M)] + H.require(results == [('ok', '')], + 'Official regression case lacks a single unqualified TAP ok: ' + case) + + +def full_suite(source): + logs = list(source.rglob('test-suite.log')) + H.require(len(logs) == 1, 'Missing/ambiguous full owning test-suite log') + summary = logs[0].read_text(encoding='utf-8', errors='replace') + states = ['PASS', 'SKIP', 'XFAIL', 'FAIL', 'XPASS', 'ERROR'] + pairs = re.findall(r'^# (TOTAL|PASS|SKIP|XFAIL|FAIL|XPASS|ERROR):[ \t]*([0-9]+)[ \t]*$', + summary, re.M) + H.require(len(pairs) == 7 and {key for key, _ in pairs} == {'TOTAL', *states}, + 'Missing or ambiguous owning-suite summary counts') + counts = {key: int(value) for key, value in pairs} + reports = list(source.rglob('*.trs')) + H.require(reports and counts['TOTAL'] > 0, 'Complete owning suite has no testcase results') + receipts, evidence = [], [(logs[0], 'test-suite.log')] + names, observed = set(), Counter() + for report in reports: + text = report.read_text(encoding='utf-8') + # tap-driver writes one result per TAP testcase, not one per .trs file. + result_lines = [line for line in text.splitlines() if line.startswith(':test-result:')] + matches = [re.fullmatch(r':test-result: (PASS|SKIP|XFAIL|FAIL|XPASS|ERROR)', line) + for line in result_lines] + H.require(matches and all(matches), 'Missing or invalid TRS testcase result: ' + str(report)) + H.require(re.findall(r'^:global-test-result: (\S+)$', text, re.M) == ['PASS'], + 'Owning TRS global result did not PASS: ' + str(report)) + case_counts = Counter(match[1] for match in matches) + observed.update(case_counts) + relative = report.relative_to(source).as_posix() + names.add(report.stem) + receipts.append({'path': relative, 'status': 'PASS', 'sha256': H.digest(report), + 'caseCounts': {state: case_counts[state] for state in states}}) + for suffix in ['.trs', '.log']: + file = report.with_suffix(suffix) + H.require(file.is_file(), 'Owning test output missing') + evidence.append((file, file.relative_to(source).as_posix().replace('/', '__'))) + H.require(sum(observed.values()) == counts['TOTAL'] + and all(observed[state] == counts[state] for state in states), + 'TRS testcase totals/states do not match owning-suite summary') + H.require(counts['PASS'] == counts['TOTAL'] and all(counts[state] == 0 for state in states[1:]), + 'Complete owning suite must PASS without skips') + H.require({'test-rpc', 'test-rpc-message'} <= names, 'RPC owning regression suites missing') + # Verify the new upstream cases actually ran, rather than only trusting a suite name. + rpc = next(p for p in reports if p.stem == 'test-rpc').with_suffix('.log').read_text(encoding='utf-8') + message = next(p for p in reports if p.stem == 'test-rpc-message').with_suffix('.log').read_text(encoding='utf-8') + require_tap_ok(rpc, '/rpc3/recursion-limit') + require_tap_ok(message, '/rpc-message/attribute-recursion-limit') + return {'status': 'PASS', 'counts': counts, 'receipts': receipts, + 'officialRpcRecursionCases': 'PASS', 'addedSkips': 0}, evidence + + +def verify(lock, source, extracted, work, env, log): + tests, evidence = full_suite(source) + library = extracted / 'usr/lib/x86_64-linux-gnu/libp11-kit.so.0' + abi = H.verify_abi(library, 'libp11-kit.so.0', source / 'debian/libp11-kit0.symbols', work, env) + for name in ['usr/bin/p11-kit', 'usr/libexec/p11-kit/p11-kit-remote', + 'usr/libexec/p11-kit/p11-kit-server', + 'usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so']: + H.require((extracted / name).is_file(), 'Original runtime entry point missing: ' + name) + child = dict(env, LD_LIBRARY_PATH=str(library.parent)) + help_text = H.capture([str(extracted / 'usr/bin/p11-kit'), '--help'], work, child) + H.require('list-modules' in help_text, 'Packaged CLI help contract failed') + api = ctypes.CDLL(str(library)) + count, table = ctypes.c_ulong(0), ctypes.c_void_p() + api.C_GetInterfaceList.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)] + api.C_GetInterfaceList.restype = ctypes.c_ulong + api.C_GetFunctionList.argtypes = [ctypes.POINTER(ctypes.c_void_p)] + api.C_GetFunctionList.restype = ctypes.c_ulong + H.require(api.C_GetInterfaceList(None, ctypes.byref(count)) == 0 and count.value > 0 + and api.C_GetFunctionList(ctypes.byref(table)) == 0 and table.value, + 'Packaged PKCS11 public interface smoke failed') + return {'fullOwningSuite': tests, 'abi': abi, 'packagedCliAndPkcs11': 'PASS', + 'cve18938ArchitectureCaveat': 'Officially32-bit only; nativeamd64 suite does not claim32-bit trigger coverage.'}, evidence + + +if __name__ == '__main__': + try: + H.entry(HERE, 'p11-kit', '0.26.2-2', COMMITS, verify, []) + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print('P11_KIT_BACKPORT_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json b/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json new file mode 100644 index 0000000000..4421303b04 --- /dev/null +++ b/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "sourcePackage": "p11-kit", + "ubuntuSourceVersion": "0.26.2-2", + "customVersion": "0.26.2-2+pasturestack.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "sourceDateEpoch": 1792857600, + "identity": "First-party rebuild with unchanged Ubuntu packaging and exact upstream security commits; not issued by Canonical.", + "runtimePackages": [ + "libp11-kit0", + "p11-kit", + "p11-kit-modules" + ], + "sourceFiles": [ + { + "name": "p11-kit_0.26.2-2.debian.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/p/p11-kit/p11-kit_0.26.2-2.debian.tar.xz", + "size": 24392, + "sha256": "1e0be1ac6c3f47a44305855b1c54e22064516a258eef911119c4944d5530b87f" + }, + { + "name": "p11-kit_0.26.2.orig.tar.xz.asc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/p/p11-kit/p11-kit_0.26.2.orig.tar.xz.asc", + "size": 228, + "sha256": "a2c205bbac7857adc91eb2670304c2ac6649aa9eaf4aab81bf0e46e124f13d8b" + }, + { + "name": "p11-kit_0.26.2.orig.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/p/p11-kit/p11-kit_0.26.2.orig.tar.xz", + "size": 1069216, + "sha256": "09fd9f44da4813a3141e73d5e7cf7008e5660d0405f13d56c15e1da9dcecf828" + }, + { + "name": "p11-kit_0.26.2-2.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/p/p11-kit/p11-kit_0.26.2-2.dsc", + "size": 2541, + "sha256": "066316279c279ca964caa83d92b0f9263914fbbe3c329419e3eec9ffb30540fb" + } + ], + "patches": [ + { + "name": "0eedd4ddd7c924f9cbb4ac496b3fda699435c3cf.patch", + "url": "https://github.com/p11-glue/p11-kit/commit/0eedd4ddd7c924f9cbb4ac496b3fda699435c3cf.patch", + "size": 11892, + "sha256": "24ad6541730f21f81b1afa203c6d85944975de3f3a0c96379d16e9b2a20b9039", + "commit": "0eedd4ddd7c924f9cbb4ac496b3fda699435c3cf", + "fixedCves": [ + "CVE-2026-13757" + ], + "firstFixedVersion": "0.26.3" + }, + { + "name": "3e64244e538550c6a7fcf826fa8c50a4604416dc.patch", + "url": "https://github.com/p11-glue/p11-kit/commit/3e64244e538550c6a7fcf826fa8c50a4604416dc.patch", + "size": 1735, + "sha256": "efabd4390f4072c815406a9ca4aea16b7d5c9d797dce23fd9c44d56032f449c2", + "commit": "3e64244e538550c6a7fcf826fa8c50a4604416dc", + "fixedCves": [ + "CVE-2026-18938" + ], + "firstFixedVersion": "0.26.5" + } + ], + "baselineSha256": { + "p11-kit/rpc-message.c": "68e35b13c9f639b6440acca346581a802cf0439602124dcc6d52d87b9d92b787", + "p11-kit/rpc-message.h": "5df3c3b08b3cebf8b22df8f21d27bbb776fbd42b537c41a22366ea1cb970f1c1", + "p11-kit/rpc-server.c": "25948e84d3a56fbdf3692911fa712d699e6129929fe83e7cb23075de8d7117c1", + "p11-kit/test-rpc-message.c": "527a89df034cb54d28d726b803a14174bc509ee3be4d7e47c56ae21e4d029a84", + "p11-kit/test-rpc.c": "f083d2c89d8db998ecfbf66f9a8c5aba9ed231fa0e65118c0b1851067bb4c51d" + }, + "patchedSha256": { + "p11-kit/rpc-message.c": "65d52baf8995f4b6b26fec92f0f432c0896703ab8735bf9c3dd19e0e50b18d56", + "p11-kit/rpc-message.h": "a9148098e2ebe719cad5c901556e1dfb81944f6176b2888cf688331e674b73f6", + "p11-kit/rpc-server.c": "091825adc750af3c393deb8e2d8e3597aefe1c4ec0904bd0beadf74c639d3659", + "p11-kit/test-rpc-message.c": "71196559d8e46c319b8637bb7a1382e3fa0abca0ef468e88876d4ed32cb27197", + "p11-kit/test-rpc.c": "9cbb1fbbfc2c16245c9cbdb66fc05e45e970b7e16a0233bf387e29f4843600c6" + }, + "ubuntuPackagingSha256": { + "debian/control": "4d5caa78fda0bad43aa13790d7022ef7e0c26bc7dd70bec6499e4b6d4eee09c0", + "debian/copyright": "a5611fbad151d1fa0aa484f6c393a5166af33ce9a9783dfb2712d6b87b462b37", + "debian/copyright-scan-patterns.yml": "c4a91cd70f48d5b9e091280df864d1722d15f97fed48490f6aa0694d47356a11", + "debian/copyright.template": "200b26e9d07a1f125fe3a8e7472b0bd0f8e5ccf0ff558555bc505be355ad420b", + "debian/fill.copyright.blanks.yml": "80edcf06e05e215df17860d73c7d5b9438a45f8164a5e9c24acedb36ae5af18d", + "debian/fix.scanned.copyright": "4f869c45f5c9e9fe17ddacac5a17bdfb58b02ad52be385192eb25c22d9f3ac5d", + "debian/libp11-kit-dev.install": "66b135b1e5d6a739030a49dc83a68dbc392e78c9f4083798dbabd37272bb031f", + "debian/libp11-kit0-dbg.links": "5883982da291a9932e7cd4f3c6bb6baaf994dfc52dd52f65e37ad3f5f58ff2fb", + "debian/libp11-kit0.examples": "2de4d9b1abb40e5b063c787ca07416737f8b43d6544a8c401bee4475a17d2825", + "debian/libp11-kit0.install": "0ff7b540b3e53d1d4d90544bc953d733e180b55ff772fa85ec29e497196454ef", + "debian/libp11-kit0.symbols": "b07399e1b7f27673b14b8e81d9f9c304ab72f29e2bc606f6b70a46d8a4fc2173", + "debian/not-installed": "f57dab4f50ef9d0e25350c586d53a9314149dce453f85d12bc5c38ca77b9bf98", + "debian/p11-kit-doc.install": "aa7c03ac5a004c8ade863ec9c62d9a3d2fca043841bdcd43249571cc794bbebf", + "debian/p11-kit-modules.install": "9e4fbdfb8e45f59c015d3ce110997c96290c22c888bd64e5bd54013e2f2ce485", + "debian/p11-kit-modules.links": "5ca4efb4a132c5c298f8fbe8f5fa63e72a55f9639c4d909993d688aaec902ee3", + "debian/p11-kit.examples": "9a6157743d04919c4acba3d20b457491f55b3506dfd1da411df8db5b91769fe7", + "debian/p11-kit.install": "1235b6f608f831ef615b3a49c4bf3d6300a69613b14e920a3cd47faac1d17e3c", + "debian/p11-kit.manpages": "ef4eaa2d15ff2e15350f45059c008e7912065259004f63deaaf9c242adda6801", + "debian/rules": "7afbb1b146790964b3edc5aba50641d23767e486514a7d9a746f306f99b57fc7", + "debian/watch": "1ea494fa571d525f14b50fac95f658f26400b228dbb05421b8bd9366b6dc7506", + "debian/patches/36_enable_locale.diff": "bfc402c700dfde13f7d3be6d74274cf080b88c7b2331d704eaefc2c51dadd728", + "debian/patches/series": "54ae2d371d09a8575bf3f14264335d7a8cb0a7149255e7b4f73612091d2d057f", + "debian/source/format": "1be7080d72e6b566df3e236ce2c55efdfbbb8fa1c972d825e5b672ff8773be1a", + "debian/source/lintian-overrides": "1704a18abe610ec865c58a05b3d305753aab8815c3b1524ab4f763758c679e5b", + "debian/upstream/metadata": "d1472eae01af6de9d30e86a5e2930862f14ae7cc6d07d7c494b41309934bf6e3", + "debian/upstream/signing-key.asc": "c14380726711246f99ed0244a5a937246b80686bf591dbac71c5340734e4c052" + }, + "buildContract": { + "nativeUbuntuAmd64": true, + "buildUser": "non-root", + "originalRulesAndDependenciesPreserved": true, + "addedSkips": 0, + "runtimeInstalled": false, + "requiredOwningSuite": "Every configured upstream Autotools test must PASS, including test-rpc and test-rpc-message.", + "requiredSoname": "libp11-kit.so.0", + "extraCiOnlyDependencies": [] + }, + "limitations": [ + "CVE-2026-18938 is officially exploitable only on32-bit systems; native amd64 build does not establish 32-bit trigger validation. Its official guard is still included.", + "Source preparation passed; no native compilation, full suite or packaged binary verification has run locally.", + "Source archives/signatures are SHA256-pinned from official Ubuntu HTTPS; this does not claim verified uploader PGP signatures or Canonical-signed custom binaries." + ] +} diff --git a/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py new file mode 100644 index 0000000000..9cc343b14e --- /dev/null +++ b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py @@ -0,0 +1,158 @@ +"""Network-free guards; native build/test execution belongs to isolated CI.""" +import copy +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest import mock +import sys + +sys.dont_write_bytecode = True +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('module_backport', HERE / 'build-p11-kit-backport.py') +M = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(M) +H = M.H + + +class Backport(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='official-source-unit-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.lock = H.load_lock(HERE / 'p11-kit.lock.json', 'p11-kit', '0.26.2-2', M.COMMITS) + + def altered(self, key, value): + lock = copy.deepcopy(self.lock) + lock[key] = value + file = self.root / 'bad.json' + file.write_text(json.dumps(lock), encoding='utf-8') + return H.load_lock(file, 'p11-kit', '0.26.2-2', M.COMMITS) + + def test_version_architecture_and_commit_boundary_cannot_change(self): + for key, value in [('architecture', 'arm64'), ('ubuntuSourceVersion', 'other'), + ('patches', []), ('runtimePackages', [])]: + with self.assertRaises(ValueError): self.altered(key, value) + + def test_foreign_url_or_unpinned_source_cannot_be_downloaded(self): + for key, value in [('url', 'https://example.invalid/source'), ('sha256', ''), + ('name', '../outside')]: + sources = copy.deepcopy(self.lock['sourceFiles']) + sources[0][key] = value + with self.assertRaises(ValueError): self.altered('sourceFiles', sources) + + def test_poisoned_cache_fails_closed_without_refetch(self): + cache, dest = self.root / 'cache', self.root / 'inputs' + cache.mkdir(); dest.mkdir() + item = self.lock['sourceFiles'][0] + (cache / item['name']).write_bytes(b'poison') + with mock.patch.object(H.COMMON.urllib.request, 'build_opener') as opener: + with self.assertRaises(ValueError): H.COMMON.download(item, dest, cache) + opener.assert_not_called() + + def test_source_and_packaging_hash_mismatch_is_fatal(self): + file = self.root / 'input' + file.write_bytes(b'original') + expected = {'input': H.digest(file)} + H.verify_hashes(self.root, expected) + file.write_bytes(b'changed') + with self.assertRaises(ValueError): H.verify_hashes(self.root, expected) + + def test_root_cannot_run_build_or_owning_tests(self): + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(H.os, 'geteuid', create=True, return_value=0): + with self.assertRaisesRegex(ValueError, 'non-root'): + H.build(None, self.lock, HERE, M.verify, []) + + def test_build_reaches_first_download_after_native_preflight(self): + args = SimpleNamespace(output_dir=self.root / 'output', work_dir=self.root / 'work', + cache_dir=None, jobs=2) + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(H.os, 'geteuid', create=True, return_value=10001), \ + mock.patch.object(H.shutil, 'which', return_value='/usr/bin/mock-tool'), \ + mock.patch.object(H, 'capture', return_value='amd64') as capture, \ + mock.patch.object(H, 'run') as run, \ + mock.patch.object(H.COMMON, 'download', + side_effect=RuntimeError('download boundary reached')) as download: + with self.assertRaisesRegex(RuntimeError, 'download boundary reached'): + H.build(args, self.lock, HERE, M.verify, []) + capture.assert_called_once_with(['dpkg', '--print-architecture'], args.work_dir, mock.ANY) + download.assert_called_once_with(self.lock['sourceFiles'][0], args.work_dir / 'inputs', None) + run.assert_not_called() + + def suite(self): + tests = {'test-rpc': 3, 'test-rpc-message': 2, 'test-control': 1} + for name, cases in tests.items(): + (self.root / (name + '.trs')).write_text(':global-test-result: PASS\n' + + ':test-result: PASS\n' * cases, encoding='utf-8') + (self.root / (name + '.log')).write_text('ok 1 /control\n', encoding='utf-8') + (self.root / 'test-rpc.log').write_text( + '1..3\nok 1 /rpc3/control\nok 2 /rpc3/recursion-limit\nok 3 /rpc3/another-control\n', + encoding='utf-8') + (self.root / 'test-rpc-message.log').write_text( + '1..2\nok 1 /rpc-message/control\nok 2 /rpc-message/attribute-recursion-limit\n', + encoding='utf-8') + (self.root / 'test-suite.log').write_text( + '# TOTAL: 6\n# PASS: 6\n# SKIP: 0\n# XFAIL: 0\n# FAIL: 0\n# XPASS: 0\n# ERROR: 0\n', + encoding='utf-8') + + def test_multi_case_trs_totals_match_full_summary(self): + self.suite() + result = M.full_suite(self.root)[0] + self.assertEqual('PASS', result['status']) + self.assertEqual(6, result['counts']['TOTAL']) + self.assertEqual(3, len(result['receipts'])) + self.assertEqual(6, sum(r['caseCounts']['PASS'] for r in result['receipts'])) + + def test_skips_and_incomplete_suite_never_become_pass(self): + self.suite() + summary = self.root / 'test-suite.log' + summary.write_text(summary.read_text().replace('# PASS: 6', '# PASS: 5').replace('# SKIP: 0', '# SKIP: 1')) + with self.assertRaises(ValueError): M.full_suite(self.root) + self.suite() + (self.root / 'test-control.trs').unlink() + with self.assertRaises(ValueError): M.full_suite(self.root) + + def test_case_total_and_each_summary_state_must_match(self): + for old, new in [('# TOTAL: 6', '# TOTAL: 7'), ('# PASS: 6', '# PASS: 5')] + [ + ('# ' + state + ': 0', '# ' + state + ': 1') + for state in ['SKIP', 'XFAIL', 'FAIL', 'XPASS', 'ERROR']]: + with self.subTest(summary=new): + self.suite() + summary = self.root / 'test-suite.log' + summary.write_text(summary.read_text().replace(old, new), encoding='utf-8') + with self.assertRaises(ValueError): M.full_suite(self.root) + + def test_nonpass_trs_case_cannot_hide_among_other_pass_cases(self): + for state in ['SKIP', 'XFAIL', 'FAIL', 'XPASS', 'ERROR', 'UNKNOWN']: + with self.subTest(state=state): + self.suite() + report = self.root / 'test-rpc.trs' + report.write_text(report.read_text().replace(':test-result: PASS', + ':test-result: ' + state, 1), encoding='utf-8') + if state != 'UNKNOWN': + summary = self.root / 'test-suite.log' + summary.write_text(summary.read_text().replace('# PASS: 6', '# PASS: 5').replace( + '# ' + state + ': 0', '# ' + state + ': 1'), encoding='utf-8') + with self.assertRaises(ValueError): M.full_suite(self.root) + + def test_required_case_needs_single_unqualified_tap_ok(self): + for file, case in [('test-rpc.log', '/rpc3/recursion-limit'), + ('test-rpc-message.log', '/rpc-message/attribute-recursion-limit')]: + for text in [case, '# ' + case, 'not ok 2 ' + case, 'ok 2 ' + case + ' # SKIP unavailable', + 'ok 2 ' + case + ' # TODO later', 'ok 2 ' + case + '-other', + 'ok 2 ' + case + '\nnot ok 3 ' + case, + 'ok 2 ' + case + '\nok 3 ' + case]: + with self.subTest(file=file, log=text): + self.suite() + (self.root / file).write_text(text + '\n', encoding='utf-8') + with self.assertRaises(ValueError): M.full_suite(self.root) + + def test_cve18938_retains_32bit_qualification(self): + self.assertTrue(any('32-bit' in value for value in self.lock['limitations'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/server/security/ubuntu-backports/source_helpers.py b/server/security/ubuntu-backports/source_helpers.py new file mode 100644 index 0000000000..5edda12341 --- /dev/null +++ b/server/security/ubuntu-backports/source_helpers.py @@ -0,0 +1,245 @@ +"""Small shared source/deb operations for the p11-kit and libXrender backports. + +This imports the already reviewed PCRE2 pure helpers. It does not change any +existing module or perform package installation. +""" +import argparse +import datetime +import email.utils +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('ubuntu_backport_common', HERE / 'build-pcre2-backport.py') +COMMON = importlib.util.module_from_spec(SPEC) +_bytecode = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + SPEC.loader.exec_module(COMMON) +finally: + sys.dont_write_bytecode = _bytecode +require, digest, run, capture = COMMON.require, COMMON.digest, COMMON.run, COMMON.capture + + +def inputs(lock): + return lock['sourceFiles'] + lock['patches'] + ([lock['baselineBinary']] if 'baselineBinary' in lock else []) + + +def load_lock(file, package, version, commits): + lock = json.loads(file.read_text(encoding='utf-8')) + require(all(lock.get(k) == v for k, v in { + 'schemaVersion': 1, 'sourcePackage': package, 'ubuntuSourceVersion': version, + 'customVersion': version + '+pasturestack.1', 'distribution': 'resolute', + 'osVersion': '26.04', 'architecture': 'amd64'}.items()), 'Wrong backport identity') + require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, 'Invalid source date') + require([p['commit'] for p in lock['patches']] == commits, 'Unexpected security commit set/order') + expected_names = ({'p11-kit_0.26.2-2.dsc', 'p11-kit_0.26.2.orig.tar.xz', + 'p11-kit_0.26.2.orig.tar.xz.asc', 'p11-kit_0.26.2-2.debian.tar.xz'} + if package == 'p11-kit' else + {'libxrender_0.9.12-1build1.dsc', 'libxrender_0.9.12.orig.tar.gz', + 'libxrender_0.9.12.orig.tar.gz.asc', 'libxrender_0.9.12-1build1.diff.gz'}) + require(len(lock['sourceFiles']) == 4 and {p['name'] for p in lock['sourceFiles']} == expected_names, + 'Wrong original Ubuntu source set') + expected_packages = (['libp11-kit0', 'p11-kit', 'p11-kit-modules'] if package == 'p11-kit' + else ['libxrender1']) + require(lock['runtimePackages'] == expected_packages, 'Unexpected exported package set') + require(('baselineBinary' in lock) == (package == 'libxrender'), 'Unexpected baseline binary scope') + if package == 'libxrender': + require(lock['baselineBinary']['name'] == 'libxrender1_0.9.12-1build1_amd64.deb' + and lock['baselineBinary']['version'] == version, 'Wrong original Ubuntu ABI baseline') + pool = 'p/p11-kit' if package == 'p11-kit' else 'libx/libxrender' + for item in inputs(lock): + require(re.fullmatch(r'[A-Za-z0-9._~+-]+', item['name']) + and re.fullmatch(r'[0-9a-f]{64}', item['sha256']) + and type(item['size']) is int and 0 < item['size'] < 8 * 1024 * 1024, + 'Unpinned/unsafe source') + if item in lock['sourceFiles'] or item == lock.get('baselineBinary'): + url = 'https://archive.ubuntu.com/ubuntu/pool/main/' + pool + '/' + item['name'] + else: + require(item['name'] == item['commit'] + '.patch', 'Patch filename/commit mismatch') + url = ('https://github.com/p11-glue/p11-kit/commit/' + item['commit'] + '.patch' + if package == 'p11-kit' else + 'https://gitlab.freedesktop.org/xorg/lib/libxrender/-/commit/' + + item['commit'] + '.patch') + require(item['url'] == url, 'Unexpected official source URL') + require(set(lock['baselineSha256']) == set(lock['patchedSha256']), 'Changed patch boundary') + for group in ['baselineSha256', 'patchedSha256', 'ubuntuPackagingSha256']: + for name, sha in lock[group].items(): + require(not Path(name).is_absolute() and '..' not in Path(name).parts + and re.fullmatch(r'[0-9a-f]{64}', sha), 'Unsafe/unpinned source path') + return lock + + +def verify_hashes(root, values): + require(all((root / name).is_file() and digest(root / name) == sha + for name, sha in values.items()), 'Source/packaging bytes changed') + + +def verify_inputs(lock, directory): + for item in inputs(lock): + file = directory / item['name'] + require(file.is_file() and file.stat().st_size == item['size'] + and digest(file) == item['sha256'], 'Input mismatch: ' + item['name']) + file = next(p for p in lock['sourceFiles'] if p['name'].endswith('.dsc')) + text = (directory / file['name']).read_text(encoding='utf-8') + require(all(re.search('^' + re.escape(value) + '$', text, re.M) for value in + ['Source: ' + lock['sourcePackage'], 'Version: ' + lock['ubuntuSourceVersion']]), + 'Wrong Ubuntu source control identity') + block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) + require(block, 'Missing .dsc SHA-256 manifest') + require({n: (h, int(s)) for h, s, n in + re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M)} == + {p['name']: (p['sha256'], p['size']) for p in lock['sourceFiles'] + if not p['name'].endswith('.dsc')}, 'Ubuntu .dsc/lock manifest mismatch') + for patch in lock['patches']: + text = (directory / patch['name']).read_text(encoding='utf-8') + require(text.startswith('From ' + patch['commit'] + ' '), 'Wrong upstream patch commit') + paths = re.findall(r'^diff --git a/(\S+) b/(\S+)$', text, re.M) + require(paths and all(a == b and a in lock['baselineSha256'] for a, b in paths), + 'Patch changed unapproved source/test paths') + + +def prepare(lock, directory, work, env, log): + source = work / 'source' + dsc = next(p['name'] for p in lock['sourceFiles'] if p['name'].endswith('.dsc')) + run(['dpkg-source', '--extract', str(directory / dsc), str(source)], work, env, log) + verify_hashes(source, lock['baselineSha256']) + verify_hashes(source, lock['ubuntuPackagingSha256']) + packaging = COMMON.packaging_snapshot(source) + if lock['sourcePackage'] == 'libxrender': + shutil.copyfile(source / 'src/Xrender.c', work / 'original-Xrender.c') + for patch in lock['patches']: + run(['patch', '-p1', '--fuzz=0', '--batch', '--forward', '--input', + str(directory / patch['name'])], source, env, log) + verify_hashes(source, lock['patchedSha256']) + verify_hashes(source, packaging) + changelog = source / 'debian/changelog' + date = email.utils.format_datetime(datetime.datetime.fromtimestamp( + lock['sourceDateEpoch'], datetime.timezone.utc)) + changelog.write_text(lock['sourcePackage'] + ' (' + lock['customVersion'] + + ') resolute; urgency=high\n\n' + ' * First-party PastureStack rebuild; not issued by Canonical.\n' + ' * Apply exact upstream security commits; preserve Ubuntu packaging.\n\n' + ' -- PastureStack maintainers ' + + date + '\n\n' + changelog.read_text(encoding='utf-8'), encoding='utf-8') + return source, packaging + + +def extract_packages(lock, source, work, env, log): + extracted, selected = work / 'extracted', [] + version = lock['customVersion'].split(':')[-1] + for name in lock['runtimePackages']: + matches = list(work.glob(name + '_' + version + '_amd64.deb')) + require(len(matches) == 1, 'Missing/ambiguous runtime package: ' + name) + file = matches[0] + for field, value in [('Package', name), ('Version', lock['customVersion']), ('Architecture', 'amd64')]: + require(capture(['dpkg-deb', '--field', str(file), field], work, env) == value, + 'Runtime package metadata changed: ' + field) + identity = capture(['dpkg-deb', '--field', str(file), 'Source'], work, env) + require(identity in [lock['sourcePackage'], lock['sourcePackage'] + ' (' + + lock['customVersion'] + ')'] or + (name == lock['sourcePackage'] and identity == ''), 'Wrong Source identity') + run(['dpkg-deb', '--extract', str(file), str(extracted)], work, env, log) + selected.append({'package': name, 'version': lock['customVersion'], 'architecture': 'amd64', + 'file': file.name, 'sha256': digest(file)}) + return extracted, selected + + +def verify_abi(library, soname, symbols_file, work, env): + require(library.is_file(), 'Missing expected SONAME') + dynamic = capture(['readelf', '--dynamic', str(library)], work, env) + headers = capture(['readelf', '--program-headers', str(library)], work, env) + require('[' + soname + ']' in dynamic and 'GNU_RELRO' in headers and + ('BIND_NOW' in dynamic or re.search(r'FLAGS_1.*NOW', dynamic)), 'ABI/hardening changed') + required = re.findall(r'^ ([A-Za-z_][A-Za-z0-9_]*)@\S+ ', + symbols_file.read_text(encoding='utf-8'), re.M) + exports = {line.split()[-1].split('@')[0] for line in + capture(['nm', '--dynamic', '--defined-only', str(library)], work, env).splitlines() + if line.split()} + require(required and set(required) <= exports, 'Original Ubuntu ABI symbol missing') + return {'soname': soname, 'originalAbiSymbols': len(required), 'RELRO': True, 'BIND_NOW': True} + + +def build(args, lock, here, verify, extra_tools): + release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', + Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) + require(release.get('ID') == 'ubuntu' and release.get('VERSION_ID') == '26.04' + and os.geteuid() != 0, 'Use a non-root isolated Ubuntu 26.04 native builder') + output = args.output_dir.resolve() + cache = args.cache_dir.resolve() if args.cache_dir else None + work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='official-backport-')) + COMMON.require_separate_paths(output, work, cache) + require(not output.exists(), 'Output directory must be new') + if args.work_dir: work.mkdir(parents=True, exist_ok=False) + home = work / 'home' + home.mkdir(mode=0o700) + jobs, job_arg = (2, '-j2') if args.jobs == 2 else (4, '-j4') + env = COMMON.build_environment(lock, jobs) + env.update(HOME=str(home), DEB_BUILD_MAINT_OPTIONS='') + for tool in ['dpkg-source', 'dpkg-buildpackage', 'dpkg-checkbuilddeps', 'dpkg-deb', + 'dpkg-query', 'patch', 'make', 'readelf', 'nm'] + extra_tools: + require(shutil.which(tool, path=env['PATH']), 'Native builder prerequisite missing: ' + tool) + require(capture(['dpkg', '--print-architecture'], work, env) == 'amd64', 'Native amd64 required') + print('OFFICIAL_BACKPORT_WORK_DIR=' + str(work), flush=True) + input_dir = work / 'inputs' + input_dir.mkdir() + if cache: cache.mkdir(parents=True, exist_ok=True) + for item in inputs(lock): + COMMON.download(item, input_dir, cache) + verify_inputs(lock, input_dir) + log = work / 'build.log' + source, packaging = prepare(lock, input_dir, work, env, log) + run(['dpkg-checkbuilddeps'], source, env, log) + run(['dpkg-buildpackage', '-b', '-us', '-uc', job_arg], source, env, log) + verify_hashes(source, packaging) + verify_hashes(source, lock['patchedSha256']) + extracted, packages = extract_packages(lock, source, work, env, log) + tests, evidence = verify(lock, source, extracted, work, env, log) + output.mkdir(parents=True) + for package in packages: shutil.copyfile(work / package['file'], output / package['file']) + lock_file = here / (lock['sourcePackage'] + '.lock.json') + for file, name in [(lock_file, lock_file.name), (log, 'build.log'), + (source / 'debian/copyright', 'COPYRIGHT')] + evidence: + shutil.copyfile(file, output / name) + for pattern in [lock['sourcePackage'] + '_*.buildinfo', lock['sourcePackage'] + '_*.changes']: + matches = list(work.glob(pattern)) + require(len(matches) == 1, 'Missing/ambiguous native package receipt') + shutil.copyfile(matches[0], output / matches[0].name) + (output / 'builder-packages.tsv').write_text(capture( + ['dpkg-query', '--show', '--showformat=' + '$' + '{binary:Package}\t' + '$' + '{Version}\n'], + work, env) + '\n', encoding='utf-8') + provenance = {'schemaVersion': 1, 'identity': lock['identity'], 'sourceLockSha256': digest(lock_file), + 'sourceFiles': lock['sourceFiles'], 'patches': lock['patches'], + 'sharedHelpersSha256': digest(Path(__file__)), + 'pcre2CommonSha256': digest(HERE / 'build-pcre2-backport.py'), 'tests': tests, + 'packagingPreserved': packaging, 'runtimePackages': packages, + 'runtimeInstalled': False, 'buildCommand': ['dpkg-buildpackage', '-b', '-us', '-uc', job_arg]} + (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') + (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(lock_file) + '\n' + ''.join( + '\t'.join([p['package'], p['version'], p['architecture'], p['sha256'], p['file']]) + '\n' + for p in packages), encoding='utf-8') + (output / 'SHA256SUMS').write_text(''.join(digest(p) + ' ' + p.name + '\n' + for p in sorted(output.iterdir()) if p.is_file()), encoding='utf-8') + + +def entry(here, package, version, commits, verify, extra_tools): + parser = argparse.ArgumentParser(description='Official security backport with preserved Ubuntu packaging') + parser.add_argument('--validate-lock', action='store_true') + parser.add_argument('--output-dir', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--cache-dir', type=Path) + parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) + args = parser.parse_args() + lock = load_lock(here / (package + '.lock.json'), package, version, commits) + if args.validate_lock: + print('OFFICIAL_SOURCE_LOCK_OK ' + package) + return + require(args.output_dir is not None, '--output-dir is required') + build(args, lock, here, verify, extra_tools) diff --git a/server/security/ubuntu-backports/tar/build-tar-backport.py b/server/security/ubuntu-backports/tar/build-tar-backport.py new file mode 100644 index 0000000000..495ac4d460 --- /dev/null +++ b/server/security/ubuntu-backports/tar/build-tar-backport.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""First-party Ubuntu tar 1.35 CVE-2026-18477 backport; native CI only. + +Keep original Ubuntu packaging and full configured Autotest suite. The complete +released vendor patch is retained; only its NEWS documentation section is omitted +from application. CVE-2026-18508 is tracked and has no patch in this module. +""" +import argparse +import collections +import datetime +import email.utils +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +HERE = Path(__file__).resolve().parent +LOCK_FILE = HERE / 'tar.lock.json' +SPEC = importlib.util.spec_from_file_location('tar_backport_common', HERE.parent / 'build-pcre2-backport.py') +COMMON = importlib.util.module_from_spec(SPEC) +_bytecode = sys.dont_write_bytecode +sys.dont_write_bytecode = True +try: + SPEC.loader.exec_module(COMMON) +finally: + sys.dont_write_bytecode = _bytecode +require, digest, run, capture = COMMON.require, COMMON.digest, COMMON.run, COMMON.capture +VERSION = '1.35+dfsg-4ubuntu0.4' +VENDOR = '60b4d64ef2d933d3c14cbf8844268e88a90dc69f' +PATCH_NAME = 'tar-1.35-CVE-2026-18477.patch' +PATCH_SHA = 'aea389d8ca495bc0474c00a339f137c00e159ffc28caed65b02823be4f5046af' +DERIVED_SHA = 'd8190f7ff8faa6c6fbe0f3df4bb464d440b9af6cdd678049a7a4dbbb98bf62c6' +DERIVED_NAME = 'tar-18477-ubuntu-doc-context.patch' +APPLIED = ['THANKS', 'lib/Makefile.am', 'lib/mkdtempat.c', 'lib/mkdtempat.h', + 'src/incremen.c', 'tests/Makefile.am', 'tests/rename08.at', + 'tests/rename09.at', 'tests/testsuite.at'] +PINS = {'tar_' + VERSION + '.dsc': ('e42458c1675ec7ba43cca2ea8e085231337e7a4553d7c6e2c21d2003761e5d43', 2124), + 'tar_1.35+dfsg.orig.tar.xz': ('9ae57e981c1e73c0eebc2b26c9b0c4497fe310ef1d516ea430efb5470b71f7a8', 2111608), + 'tar_' + VERSION + '.debian.tar.xz': ('25d8e50e31629e85baef5da32b3030e5ad0c9148ab3ee6b1428212a10e307e4d', 52824)} + + +def load_lock(file=LOCK_FILE): + lock = json.loads(file.read_text(encoding='utf-8')) + expected = {'schemaVersion': 1, 'sourcePackage': 'tar', 'ubuntuSourceVersion': VERSION, + 'customVersion': VERSION + '+pasturestack.1', 'distribution': 'resolute', + 'osVersion': '26.04', 'architecture': 'amd64', 'runtimePackages': ['tar'], + 'fixedCves': ['CVE-2026-18477']} + require(all(lock.get(k) == v for k, v in expected.items()), 'Unsupported tar source/output identity') + require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, 'Invalid source date') + require(len(lock['sourceFiles']) == 3 and {i['name'] for i in lock['sourceFiles']} == set(PINS), + 'Exactly the original Ubuntu DSC/orig/packaging are required') + for item in lock['sourceFiles']: + require(item['url'] == 'https://archive.ubuntu.com/ubuntu/pool/main/t/tar/' + item['name'] + and (item['sha256'], item['size']) == PINS[item['name']] + and type(item['size']) is int, 'Unexpected Ubuntu source URL/hash/size') + require(len(lock['patches']) == 1, 'Only the released 18477 vendor backport is supported') + patch = lock['patches'][0] + require(patch['name'] == PATCH_NAME and patch['sha256'] == PATCH_SHA and patch['size'] == 11055 + and patch['url'] == 'https://gitlab.com/redhat/centos-stream/rpms/tar/-/raw/' + VENDOR + '/' + PATCH_NAME + and patch['excludedDocumentationPaths'] == ['NEWS'] + and patch['derivedPatchSha256'] == DERIVED_SHA and patch['appliedPaths'] == APPLIED, + 'Unexpected vendor patch, documentation exclusion or derived bytes') + require(patch['upstreamAcceptedCommits'] == ['0714d2f082104005a1c70ee6ec4175194943ea88', + 'd479b2cc9160d9c2fb61afbc9ee70c2faadf80db', 'b17665b2c0548c77b6cd8d2d5b61e4c4fcc4f770'], + 'Unexpected accepted upstream commits') + require(lock['vendorBackport']['distGitCommit'] == VENDOR + and lock['vendorBackport']['version'] == '2:1.35-13.el10' + and lock['prerequisites']['additionalPatches'] == [] + and lock['prerequisites']['no18508PrerequisitesApplied'] is True + and [i['id'] for i in lock['blockedCves']] == ['CVE-2026-18508'], 'Unexpected vendor/prerequisite scope') + require(set(lock['patchedSha256']) == set(APPLIED), 'Incomplete applied-source closure') + for name, sha in {**lock['baselineSha256'], **lock['patchedSha256']}.items(): + require(not Path(name).is_absolute() and '..' not in Path(name).parts + and re.fullmatch('[0-9a-f]{64}', sha), 'Unsafe source proof entry') + return lock + + +def verify_source_control(lock, directory): + text = (directory / ('tar_' + VERSION + '.dsc')).read_text(encoding='utf-8') + require(all(re.search('^' + re.escape(line) + '$', text, re.M) for line in + ['Format: 3.0 (quilt)', 'Source: tar', 'Version: ' + VERSION]), 'Wrong Ubuntu source control') + block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) + require(block, 'Missing source checksum manifest') + rows = re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M) + require(len(rows) == 2 and {n: (s, int(z)) for s, z, n in rows} + == {i['name']: (i['sha256'], i['size']) for i in lock['sourceFiles'] if not i['name'].endswith('.dsc')}, + 'DSC/source lock mismatch') + + +def derived_patch(lock, file): + COMMON.verify_input(lock['patches'][0], file) + data = file.read_bytes() + blocks = re.findall(rb'(?ms)^diff --git .*?(?=^diff --git |\Z)', data) + paths = [re.match(rb'diff --git a/(\S+) b/(\S+)\n', b).groups() for b in blocks] + require(paths == [(p.encode(), p.encode()) for p in ['NEWS'] + APPLIED], 'Unexpected vendor patch paths/order') + # Entire diff sections are copied unchanged. No code/test hunk is edited, + # dropped, rebased or fuzzed; existing Ubuntu NEWS remains in place. + result = b''.join(blocks[1:]) + import hashlib + require(hashlib.sha256(result).hexdigest() == DERIVED_SHA, 'Derived patch bytes changed') + return result + + +def validate_inputs(lock, directory): + for item in lock['sourceFiles'] + lock['patches']: + COMMON.verify_input(item, directory / item['name']) + verify_source_control(lock, directory) + derived_patch(lock, directory / PATCH_NAME) + + +def verify_sources(source, expected): + require(all((source / n).is_file() and digest(source / n) == sha for n, sha in expected.items()), + 'Source/prerequisite closure does not match the reviewed Ubuntu backport') + + +def verify_series(original, updated): + lines = updated.splitlines(keepends=True) + added = [line for line in lines if line.strip() == DERIVED_NAME] + require(len(added) == 1 and ''.join(line for line in lines if line.strip() != DERIVED_NAME) == original, + 'Existing quilt series bytes changed') + active = lambda text: [line.strip() for line in text.splitlines() if line.strip() and not line.lstrip().startswith('#')] + require(active(updated) == active(original) + [DERIVED_NAME], 'Existing quilt patch order changed') + + +def build_environment(lock, jobs): + COMMON.job_option(jobs) + env = COMMON.build_environment(lock, jobs) + # Preserve Ubuntu's own future=+lfs and compiler/hardening policy in debian/rules. + env.pop('DEB_BUILD_MAINT_OPTIONS', None) + return env + + +def test_receipt(source): + script = source / 'tests/testsuite' + log = source / 'tests/testsuite.log' + require(script.is_file() and log.is_file(), 'Missing full native Autotest script/log') + expected = dict(re.findall(r'^(\d+);([^;]+\.at):\d+;', script.read_text(encoding='utf-8'), re.M)) + rows = re.findall(r'^(\d+)\. .*? \(([^()]+\.at):\d+\): (.+)$', log.read_text(encoding='utf-8', errors='replace'), re.M) + require(len(expected) > 200 and len(rows) == len(expected) and len({i for i, _, _ in rows}) == len(rows), + 'Configured native suite was missing, filtered or ambiguous') + actual, counts = {}, collections.Counter() + for number, name, text in rows: + require(expected.get(number) == name, 'Autotest identity mismatch') + if text.startswith('ok'): + state = 'PASS' + elif text.startswith('skipped ('): + state = 'SKIP' + elif text.startswith('expected failure ('): + state = 'XFAIL' + else: + raise ValueError('Unexpected native result: ' + text) + actual[number] = state + counts[state] += 1 + controls = {} + for name in ['rename08.at', 'rename09.at']: + matches = [i for i, f in expected.items() if f == name] + require(len(matches) == 1 and actual[matches[0]] == 'PASS', 'CVE regression did not PASS: ' + name) + controls[name] = matches[0] + return {'configuredGroups': len(expected), 'counts': dict(counts), 'regressionGroups': controls, + 'logSha256': digest(log), 'suiteSha256': digest(script), 'addedSkips': 0, + 'scope': 'All groups in the original Ubuntu configured suite; original SKIP/XFAIL are recorded. Optional external check-full corpus is not enabled by original packaging.'} + + +def tar_smoke(binary, work, env, log): + require(binary.is_file(), 'Packaged tar is missing') + directory = work / 'tar-smoke' + source, restored = directory / 'source', directory / 'restored' + tree = source / 'tree'; (tree / 'dir').mkdir(parents=True) + restored.mkdir() + payload = tree / 'dir/file'; payload.write_bytes(b'first-party tar roundtrip\n') + os.link(payload, tree / 'hard') + os.symlink('dir/file', tree / 'relative-link') + archive, delta, snapshot = directory / 'base.tar', directory / 'delta.tar', directory / 'state.snar' + run([str(binary), '--create', '--listed-incremental=' + str(snapshot), '--file', str(archive), + '--directory', str(source), 'tree'], work, env, log) + listing = capture([str(binary), '--list', '--file', str(archive)], work, env) + require(all(n in listing.splitlines() for n in ['tree/dir/file', 'tree/hard', 'tree/relative-link']), 'Archive listing changed') + run([str(binary), '--extract', '--listed-incremental=/dev/null', '--file', str(archive), + '--directory', str(restored)], work, env, log) + require((restored / 'tree/dir/file').read_bytes() == payload.read_bytes() + and os.readlink(restored / 'tree/relative-link') == 'dir/file' + and os.stat(restored / 'tree/hard').st_ino == os.stat(restored / 'tree/dir/file').st_ino, + 'Extracted file/link semantics changed') + (tree / 'dir').rename(tree / 'moved') + (tree / 'moved/file').write_bytes(b'incremental rename roundtrip\n') + run([str(binary), '--create', '--listed-incremental=' + str(snapshot), '--file', str(delta), + '--directory', str(source), 'tree'], work, env, log) + run([str(binary), '--extract', '--listed-incremental=/dev/null', '--file', str(delta), + '--directory', str(restored)], work, env, log) + require((restored / 'tree/moved/file').read_bytes() == (tree / 'moved/file').read_bytes() + and not (restored / 'tree/dir').exists(), 'Incremental directory rename semantics changed') + return {'status': 'PASS', 'checks': ['create/list/extract', 'hardlink inode', 'relative symlink', + 'incremental directory rename/restore'], 'binarySha256': digest(binary), + 'version': capture([str(binary), '--version'], work, env).splitlines()[0]} + + +def build(args, lock): + release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) + require(release.get('ID') == 'ubuntu' and release.get('VERSION_ID') == '26.04', 'Use an isolated Ubuntu 26.04 builder') + require(os.geteuid() != 0, 'Original Ubuntu tar full tests require a non-root builder') + env = build_environment(lock, args.jobs) + for tool in ['dpkg', 'dpkg-source', 'dpkg-buildpackage', 'dpkg-checkbuilddeps', 'dpkg-deb', + 'dpkg-query', 'quilt', 'readelf', 'fakeroot']: + require(shutil.which(tool, path=env['PATH']), 'Missing builder tool: ' + tool) + require(capture(['dpkg', '--print-architecture'], HERE, env) == 'amd64', 'Native amd64 required') + output = args.output_dir.resolve(); cache = args.cache_dir.resolve() if args.cache_dir else None + require(not output.exists(), 'Output directory must be new') + COMMON.require_separate_paths(output, args.work_dir.resolve() if args.work_dir else None, cache) + work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='tar-backport-')) + if args.work_dir: work.mkdir(parents=True, exist_ok=False) + print('TAR_BACKPORT_WORK_DIR=' + str(work), flush=True) + inputs = work / 'inputs'; inputs.mkdir() + if cache: cache.mkdir(parents=True, exist_ok=True) + for item in lock['sourceFiles'] + lock['patches']: COMMON.download(item, inputs, cache) + validate_inputs(lock, inputs) + patch = inputs / DERIVED_NAME; patch.write_bytes(derived_patch(lock, inputs / PATCH_NAME)) + source, log = work / 'source', work / 'build.log' + run(['dpkg-source', '--extract', str(inputs / ('tar_' + VERSION + '.dsc')), str(source)], work, env, log) + verify_sources(source, lock['baselineSha256']) + packaging = COMMON.packaging_snapshot(source) + news = digest(source / 'NEWS') + original_series = (source / 'debian/patches/series').read_text(encoding='utf-8') + env['QUILT_PATCHES'] = 'debian/patches' + run(['quilt', 'import', '-P', DERIVED_NAME, str(patch)], source, env, log) + run(['quilt', 'push', '--fuzz=0'], source, env, log) + verify_sources(source, lock['patchedSha256']) + require(digest(source / 'NEWS') == news, 'Existing Ubuntu NEWS changed') + require(all(digest(source / n) == sha for n, sha in packaging.items() if n != 'debian/patches/series'), + 'Original packaging inputs changed') + verify_series(original_series, (source / 'debian/patches/series').read_text(encoding='utf-8')) + approved = COMMON.packaging_snapshot(source) + changelog = source / 'debian/changelog' + date = email.utils.format_datetime(datetime.datetime.fromtimestamp(lock['sourceDateEpoch'], datetime.timezone.utc)) + changelog.write_text('tar (' + lock['customVersion'] + ') resolute; urgency=high\n\n' + ' * First-party rebuild, not issued by Canonical or Red Hat.\n' + ' * Apply released CVE-2026-18477 source/tests; preserve Ubuntu NEWS.\n' + ' * CVE-2026-18508 remains tracked and is not patched here.\n\n' + ' -- PastureStack maintainers ' + date + '\n\n' + + changelog.read_text(encoding='utf-8'), encoding='utf-8') + run(['dpkg-checkbuilddeps'], source, env, log) + command = ['dpkg-buildpackage', '-b', '-us', '-uc', COMMON.job_option(args.jobs)] + run(command, source, env, log) + verify_sources(source, approved); verify_sources(source, lock['patchedSha256']) + tests = test_receipt(source) + matches = list(work.glob('tar_' + lock['customVersion'] + '_amd64.deb')) + require(len(matches) == 1, 'Missing/ambiguous selected runtime tar package') + deb = matches[0] + for field, value in [('Package', 'tar'), ('Version', lock['customVersion']), ('Architecture', 'amd64')]: + require(COMMON.deb_field(deb, field, work, env) == value, 'Wrong package ' + field) + # Debian permits omission when binary and source names/versions are equal; + # the exact Package/Version above and original DSC already bind this case. + require(COMMON.deb_field(deb, 'Source', work, env) in ['', 'tar', 'tar (' + lock['customVersion'] + ')'], 'Wrong Source identity') + extracted = work / 'extracted' + run(['dpkg-deb', '--extract', str(deb), str(extracted)], work, env, log) + candidates = [extracted / n for n in ['usr/bin/tar', 'bin/tar'] if (extracted / n).is_file()] + require(len(candidates) == 1 and candidates[0].resolve().is_relative_to(extracted.resolve()), 'Ambiguous/unsafe packaged binary') + binary = candidates[0] + header = capture(['readelf', '--file-header', str(binary)], work, env) + program = capture(['readelf', '--program-headers', str(binary)], work, env) + dynamic = capture(['readelf', '--dynamic', str(binary)], work, env) + require('ELF64' in header and 'X86-64' in header.upper() and 'DYN' in header and 'GNU_RELRO' in program, + 'Native ELF/PIE/RELRO baseline changed') + smoke = tar_smoke(binary, work, env, log) + require(smoke['version'] == 'tar (GNU tar) 1.35', 'Unexpected GNU tar version') + require(digest(extracted / 'usr/share/doc/tar/copyright') == digest(source / 'debian/copyright'), + 'Packaged copyright differs from the original Ubuntu source') + output.mkdir(parents=True) + for f, name in [(deb, deb.name), (log, 'build.log'), (LOCK_FILE, 'tar.lock.json'), + (inputs / PATCH_NAME, PATCH_NAME), (patch, DERIVED_NAME), + (source / 'tests/testsuite.log', 'testsuite.log'), (source / 'config.log', 'config.log'), + (source / 'debian/copyright', 'COPYRIGHT')]: shutil.copyfile(f, output / name) + for pattern in ['tar_*.buildinfo', 'tar_*.changes']: + files = list(work.glob(pattern)); require(len(files) == 1, 'Missing native build receipt') + shutil.copyfile(files[0], output / files[0].name) + selected = {'package': 'tar', 'version': lock['customVersion'], 'architecture': 'amd64', 'file': deb.name, 'sha256': digest(deb)} + (output / 'builder-packages.tsv').write_text(capture( + ['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\n'], work, env) + '\n', encoding='utf-8') + provenance = {'schemaVersion': 1, 'identity': lock['identity'], 'sourceLockSha256': digest(LOCK_FILE), + 'sourceDateEpoch': lock['sourceDateEpoch'], + 'sourceFiles': lock['sourceFiles'], 'vendorBackport': lock['vendorBackport'], 'patches': lock['patches'], + 'documentationPolicy': lock['documentationPolicy'], 'fixedCves': lock['fixedCves'], + 'blockedCves': lock['blockedCves'], 'prerequisites': lock['prerequisites'], + 'debianPackagingInputSha256': packaging, 'approvedPackagingSha256': approved, + 'patchedSourceSha256': lock['patchedSha256'], 'buildCommand': command, 'buildEnvironment': env, + 'tests': tests, 'tarSmoke': smoke, 'runtimePackages': [selected], + 'hardening': {'PIE': True, 'RELRO': True, 'BIND_NOW': 'BIND_NOW' in dynamic or bool(re.search(r'FLAGS_1.*NOW', dynamic)), + 'policy': 'Original Ubuntu debian/rules, compiler flags and ABI retained; no new hardening flags or test exceptions.'}, + 'signaturePolicy': 'Official source SHA pins; custom output is not issued or signed by Canonical/Red Hat.', + 'runtimeInstalled': False} + (output / 'tar_smoke.json').write_text(json.dumps(smoke, indent=2) + '\n', encoding='utf-8') + (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') + (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(LOCK_FILE) + '\n' + '\t'.join( + [selected[k] for k in ['package', 'version', 'architecture', 'sha256', 'file']]) + '\n', encoding='utf-8') + (output / 'SHA256SUMS').write_text(''.join(digest(f) + ' ' + f.name + '\n' for f in sorted(output.iterdir()) if f.is_file()), encoding='utf-8') + print('TAR_BACKPORT_BUILT packages=1 fixedCves=CVE-2026-18477 lock=' + digest(LOCK_FILE), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--validate-lock', action='store_true') + parser.add_argument('--output-dir', type=Path) + parser.add_argument('--work-dir', type=Path) + parser.add_argument('--cache-dir', type=Path) + parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) + args = parser.parse_args() + lock = load_lock(LOCK_FILE) + if args.validate_lock: print('TAR_BACKPORT_LOCK_OK sha256=' + digest(LOCK_FILE)) + else: + require(args.output_dir is not None, '--output-dir is required') + build(args, lock) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: + print('TAR_BACKPORT_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/tar/tar.lock.json b/server/security/ubuntu-backports/tar/tar.lock.json new file mode 100644 index 0000000000..04f3898b71 --- /dev/null +++ b/server/security/ubuntu-backports/tar/tar.lock.json @@ -0,0 +1,109 @@ +{ + "schemaVersion": 1, + "sourcePackage": "tar", + "ubuntuSourceVersion": "1.35+dfsg-4ubuntu0.4", + "customVersion": "1.35+dfsg-4ubuntu0.4+pasturestack.1", + "distribution": "resolute", + "osVersion": "26.04", + "architecture": "amd64", + "sourceDateEpoch": 1792886400, + "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18633881", + "sourceFiles": [ + { + "name": "tar_1.35+dfsg-4ubuntu0.4.dsc", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg-4ubuntu0.4.dsc", + "sha256": "e42458c1675ec7ba43cca2ea8e085231337e7a4553d7c6e2c21d2003761e5d43", + "size": 2124 + }, + { + "name": "tar_1.35+dfsg.orig.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg.orig.tar.xz", + "sha256": "9ae57e981c1e73c0eebc2b26c9b0c4497fe310ef1d516ea430efb5470b71f7a8", + "size": 2111608 + }, + { + "name": "tar_1.35+dfsg-4ubuntu0.4.debian.tar.xz", + "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg-4ubuntu0.4.debian.tar.xz", + "sha256": "25d8e50e31629e85baef5da32b3030e5ad0c9148ab3ee6b1428212a10e307e4d", + "size": 52824 + } + ], + "patches": [ + { + "name": "tar-1.35-CVE-2026-18477.patch", + "url": "https://gitlab.com/redhat/centos-stream/rpms/tar/-/raw/60b4d64ef2d933d3c14cbf8844268e88a90dc69f/tar-1.35-CVE-2026-18477.patch", + "sha256": "aea389d8ca495bc0474c00a339f137c00e159ffc28caed65b02823be4f5046af", + "size": 11055, + "upstreamAcceptedCommits": [ + "0714d2f082104005a1c70ee6ec4175194943ea88", + "d479b2cc9160d9c2fb61afbc9ee70c2faadf80db", + "b17665b2c0548c77b6cd8d2d5b61e4c4fcc4f770" + ], + "excludedDocumentationPaths": [ + "NEWS" + ], + "derivedPatchSha256": "d8190f7ff8faa6c6fbe0f3df4bb464d440b9af6cdd678049a7a4dbbb98bf62c6", + "appliedPaths": [ + "THANKS", + "lib/Makefile.am", + "lib/mkdtempat.c", + "lib/mkdtempat.h", + "src/incremen.c", + "tests/Makefile.am", + "tests/rename08.at", + "tests/rename09.at", + "tests/testsuite.at" + ] + } + ], + "vendorBackport": { + "publisher": "CentOS Stream", + "version": "2:1.35-13.el10", + "distGitCommit": "60b4d64ef2d933d3c14cbf8844268e88a90dc69f", + "sourceRpmUrl": "https://kojihub.stream.centos.org/kojifiles/packages/tar/1.35/13.el10/src/tar-1.35-13.el10.src.rpm", + "sourceRpmSha256": "c15f522fd0000faf1b1e3c719198aab0262e9404be54d31c60c624f910460e58", + "patchByteComparison": "Full official patch bytes matched the published source RPM payload; no RPM installation. This is not the distinct RHEL el10_2 SRPM." + }, + "prerequisites": { + "additionalPatches": [], + "alreadyPresentInUbuntuSource": [ + "fdbase and confined directory operations from original Ubuntu CVE-2025-45582 stack", + "gnulib tempname.c/try_tempname_len and tempname module" + ], + "no18508PrerequisitesApplied": true + }, + "baselineSha256": { + "gnu/tempname.c": "8701d79ced94ba0bff60607d36583d23140cdf1e18e9f8d883a23508275f471c", + "gnu/tempname.h": "45167ab628da8c3f21050680d5f09fc3fcd7acffff7487a031cfe0fb36f4d870", + "gnu/Makefile.am": "804b9f9c7c37cd8788dde59d0ec1156d7700094d7a72415e28e8da43dc6634bd", + "src/common.h": "061713d04658467c595b41e4b81b277ad067ab352528a4e7832c1f1af8eaa143", + "lib/Makefile.am": "4bed9bf65e2eb453cdc0dade993b9e630f71f2015240d9074742f1f60db11aef", + "src/incremen.c": "c49c6d8a6fbe0e4554c10601041b7c21a2493ba43d99a2332bb00f2c5d240b92" + }, + "patchedSha256": { + "THANKS": "e8afcec1d516a7207e6f9c9ade75fba8f38643d9e712ec809dbddf31a15782bd", + "lib/Makefile.am": "271a1ed923f3d1c87ba6f98dd7004e3f0dc3dc51b7101806e781de7febbd48fd", + "lib/mkdtempat.c": "fa4427fb6720e0191041d839f692e4c61f9227f03be398d63971f7cc1d873d1e", + "lib/mkdtempat.h": "df1e68ae16ae567bcf123545d6f2c9744f426207c0e65525031efb8f63ecb1a5", + "src/incremen.c": "6b1e53b66c6edaaeec05b5cdb15531902d762431926caae9aff8bf4c54e23be9", + "tests/Makefile.am": "3353193af4de5e968938d1087cd607c36282697e4ae1aa2d937d71806e65e647", + "tests/rename08.at": "3c311ce3ff783d4f49fd1b4e91b2d2e8fcc9018f38e7fe2e7f691be802e8ae1b", + "tests/rename09.at": "c4774038a66286db7f7d6ff39b41113d388efeecbb4c05ae2b4ab75ee48a5dde", + "tests/testsuite.at": "bc298c2f1339e7a5eb262e1a163279e9a0b79243a1685bc5f716ec4c26da49e8" + }, + "runtimePackages": [ + "tar" + ], + "fixedCves": [ + "CVE-2026-18477" + ], + "blockedCves": [ + { + "id": "CVE-2026-18508", + "status": "tracking", + "reason": "Published one-top-level stack requires additional structural/API compatibility work for this Ubuntu source; no patch for this CVE is included." + } + ], + "identity": "First-party PastureStack rebuild of exact Ubuntu source with a released CentOS Stream upstream-accepted source backport; not issued by Canonical or Red Hat.", + "documentationPolicy": "Keep the full vendor patch and its SHA-256. Remove only the complete NEWS diff section when constructing the Ubuntu patch; preserve existing Ubuntu NEWS. All source/test hunks and THANKS remain byte-for-byte unchanged." +} diff --git a/server/security/ubuntu-backports/tar/test_tar_backport.py b/server/security/ubuntu-backports/tar/test_tar_backport.py new file mode 100644 index 0000000000..16809e82a2 --- /dev/null +++ b/server/security/ubuntu-backports/tar/test_tar_backport.py @@ -0,0 +1,183 @@ +"""Focused network-free tar backport contracts; no native compilation.""" +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest import mock +import urllib.request + +HERE = Path(__file__).resolve().parent +sys.dont_write_bytecode = True +SPEC = importlib.util.spec_from_file_location('tar_backport', HERE / 'build-tar-backport.py') +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class TarBackport(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='tar-backport-contract-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.lock = MODULE.load_lock() + + def load(self, lock): + file = self.root / 'fixture.json'; file.write_text(json.dumps(lock), encoding='utf-8') + return MODULE.load_lock(file) + + def test_fixed_source_and_single_cve_scope(self): + self.assertEqual(['CVE-2026-18477'], self.lock['fixedCves']) + self.assertEqual(['CVE-2026-18508'], [x['id'] for x in self.lock['blockedCves']]) + self.assertEqual([], self.lock['prerequisites']['additionalPatches']) + self.assertEqual(['NEWS'], self.lock['patches'][0]['excludedDocumentationPaths']) + + def test_foreign_version_architecture_or_cve_scope_fails(self): + for field, value in [('sourcePackage', 'gtar'), ('ubuntuSourceVersion', '1.35-13.el10'), + ('customVersion', MODULE.VERSION), ('architecture', 'arm64'), + ('runtimePackages', ['tar', 'tar-scripts']), + ('fixedCves', ['CVE-2026-18477', 'CVE-2026-18508']), ('sourceDateEpoch', True)]: + value_lock = copy.deepcopy(self.lock); value_lock[field] = value + with self.subTest(field=field), self.assertRaises(ValueError): self.load(value_lock) + + def test_source_set_url_hash_length_are_fixed(self): + for mutation in ['missing', 'duplicate', 'url', 'sha256', 'size']: + lock = copy.deepcopy(self.lock) + if mutation == 'missing': lock['sourceFiles'].pop() + elif mutation == 'duplicate': lock['sourceFiles'][1] = lock['sourceFiles'][0] + else: lock['sourceFiles'][0][mutation] = {'url': 'https://example.invalid/tar', 'sha256': '0' * 64, 'size': 1}[mutation] + with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) + + def test_only_full_accepted_released_vendor_patch_is_allowed(self): + for mutation in ['extra', 'url', 'sha256', 'commit', 'prerequisite']: + lock = copy.deepcopy(self.lock) + if mutation == 'extra': lock['patches'].append(copy.deepcopy(lock['patches'][0])) + if mutation == 'url': lock['patches'][0]['url'] = lock['patches'][0]['url'].replace(MODULE.VENDOR, 'rawhide') + if mutation == 'sha256': lock['patches'][0]['sha256'] = '0' * 64 + if mutation == 'commit': lock['patches'][0]['upstreamAcceptedCommits'][0] = '0' * 40 + if mutation == 'prerequisite': lock['prerequisites']['additionalPatches'] = ['18508-api.patch'] + with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) + + def test_code_test_or_thanks_exclusions_are_rejected(self): + for path in ['src/incremen.c', 'tests/rename08.at', 'THANKS', 'doc/tar.texi']: + lock = copy.deepcopy(self.lock); lock['patches'][0]['excludedDocumentationPaths'].append(path) + with self.subTest(path=path), self.assertRaises(ValueError): self.load(lock) + + def test_derived_patch_preserves_all_non_news_diff_bytes(self): + blocks = [('diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n@@ -1 +1 @@\n-old {0}\n+new {0}\n'.format(n)).encode() + for n in ['NEWS'] + MODULE.APPLIED] + file = self.root / MODULE.PATCH_NAME; file.write_bytes(b''.join(blocks)) + expected = b''.join(blocks[1:]) + with mock.patch.object(MODULE.COMMON, 'verify_input'), mock.patch.object(MODULE, 'DERIVED_SHA', hashlib.sha256(expected).hexdigest()): + self.assertEqual(expected, MODULE.derived_patch(self.lock, file)) + file.write_bytes(b''.join(blocks[:-1])) + with self.assertRaises(ValueError): MODULE.derived_patch(self.lock, file) + + def test_original_patch_tamper_fails_before_document_filtering(self): + file = self.root / MODULE.PATCH_NAME; file.write_bytes(b'diff --git a/NEWS b/NEWS\n') + with self.assertRaises(ValueError): MODULE.derived_patch(self.lock, file) + + def test_dsc_binds_original_source_and_packaging(self): + text = 'Format: 3.0 (quilt)\nSource: tar\nVersion: ' + MODULE.VERSION + '\nChecksums-Sha256:\n' + ''.join( + ' {sha256} {size} {name}\n'.format(**i) for i in self.lock['sourceFiles'][1:]) + file = self.root / ('tar_' + MODULE.VERSION + '.dsc') + file.write_text(text, encoding='utf-8'); MODULE.verify_source_control(self.lock, self.root) + for bad in [text.replace('Source: tar', 'Source: foreign'), text.replace('3.0 (quilt)', '1.0'), + text.replace(MODULE.VERSION, '1.35-13.el10'), text.rsplit('\n', 2)[0] + '\n', + text + text.splitlines()[-1] + '\n', text.replace(self.lock['sourceFiles'][1]['sha256'], '0' * 64)]: + file.write_text(bad, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.verify_source_control(self.lock, self.root) + + def test_poisoned_cache_fails_without_fetching_replacement(self): + cache, output = self.root / 'cache', self.root / 'out'; cache.mkdir(); output.mkdir() + data = b'official bytes'; file = cache / 'fixture.patch'; file.write_bytes(data) + item = {'name': file.name, 'url': 'https://example.invalid/input', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()} + with mock.patch.object(urllib.request, 'build_opener') as network: + MODULE.COMMON.download(item, output, cache) + file.write_bytes(data + b'tamper') + with self.assertRaises(ValueError): MODULE.COMMON.download(item, output, cache) + network.assert_not_called() + + def test_environment_cannot_filter_tests_or_override_compilers(self): + with mock.patch.dict(os.environ, {'DEB_BUILD_OPTIONS': 'nocheck', 'TESTSUITEFLAGS': '1', 'FULL_TEST': '1', + 'TARTEST_SKIP_LARGE_FILES': '1', 'CC': 'unsafe', 'CFLAGS': '-O0'}): + env = MODULE.build_environment(self.lock, 2) + self.assertEqual('parallel=2', env['DEB_BUILD_OPTIONS']) + self.assertEqual('', env['DEB_BUILD_PROFILES']) + for name in ['TESTSUITEFLAGS', 'TARTEST_SKIP_LARGE_FILES', 'FULL_TEST', 'CC', 'CFLAGS', 'DEB_BUILD_MAINT_OPTIONS']: + self.assertNotIn(name, env) + self.assertEqual('-j2', MODULE.COMMON.job_option(2)); self.assertEqual('-j4', MODULE.COMMON.job_option(4)) + with self.assertRaises(ValueError): MODULE.build_environment(self.lock, 8) + + def suite_fixture(self): + directory = self.root / 'tests'; directory.mkdir() + names = {str(i): 'test%04d.at' % i for i in range(1, 206)} + names['204'], names['205'] = 'rename08.at', 'rename09.at' + script = ''.join(f'{i};{name}:25;description;keyword;\n' for i, name in names.items()) + log = ''.join(f'{i}. description ({name}:25): ' + ('skipped (fixture)' if i == '1' else 'expected failure (fixture)' if i == '2' else 'ok') + '\n' + for i, name in names.items()) + (directory / 'testsuite').write_text(script, encoding='utf-8') + file = directory / 'testsuite.log'; file.write_text(log, encoding='utf-8') + return file, log + + def test_complete_suite_preserves_original_xfail_skip_semantics(self): + self.suite_fixture(); result = MODULE.test_receipt(self.root) + self.assertEqual(205, result['configuredGroups']) + self.assertEqual({'PASS': 203, 'XFAIL': 1, 'SKIP': 1}, result['counts']) + self.assertEqual(0, result['addedSkips']) + + def test_filtered_ambiguous_or_unexpected_native_results_fail(self): + file, original = self.suite_fixture() + for bad in [original.split('\n', 1)[1], original + original.splitlines()[-1] + '\n', + original.replace('): ok', '): FAILED (fixture)', 1), + original.replace('): ok', '): UNEXPECTED PASS', 1), + original.replace('): ok', '): UNKNOWN', 1), original.replace('rename09.at:25): ok', 'rename09.at:25): skipped (fixture)')]: + file.write_text(bad, encoding='utf-8') + with self.assertRaises(ValueError): MODULE.test_receipt(self.root) + + def test_source_prerequisite_hash_mismatch_and_missing_smoke_binary_fail(self): + file = self.root / 'input'; file.write_bytes(b'original') + expected = {'input': MODULE.digest(file)}; MODULE.verify_sources(self.root, expected) + file.write_bytes(b'tamper') + with self.assertRaises(ValueError): MODULE.verify_sources(self.root, expected) + with self.assertRaises(ValueError): MODULE.tar_smoke(self.root / 'missing', self.root, {}, self.root / 'log') + + def test_output_must_not_overlap_build_or_download_cache(self): + for directory in [self.root / 'out', self.root / 'out/nested', self.root]: + with self.assertRaises(ValueError): MODULE.COMMON.require_separate_paths(self.root / 'out', directory, None) + with self.assertRaises(ValueError): MODULE.COMMON.require_separate_paths(self.root / 'out', None, directory) + + def test_quilt_addition_preserves_order_and_existing_bytes_with_trailing_blank_lines(self): + original = '# original comment\nfirst.patch\nlast.patch\n\n' + added = MODULE.DERIVED_NAME + '\n' + MODULE.verify_series(original, original + added) + MODULE.verify_series(original, original[:-1] + added + '\n') + for bad in [added + original, original.replace('first.patch', 'foreign.patch') + added, + original.replace('# original comment\n', '') + added, original + added + added]: + with self.assertRaises(ValueError): MODULE.verify_series(original, bad) + + def cli(self, *args): + return subprocess.run([sys.executable, '-B', str(HERE / 'build-tar-backport.py'), *args], cwd=self.root, + text=True, encoding='utf-8', capture_output=True) + + def test_cli_uses_here_lock_independently_of_working_directory(self): + result = self.cli('--validate-lock') + self.assertEqual(0, result.returncode, result.stderr); self.assertIn('TAR_BACKPORT_LOCK_OK', result.stdout) + + def test_cli_rejects_lock_override_jobs_other_than_two_four_and_external_input_reader(self): + for args in [('--validate-lock', '--lock', 'foreign.json'), ('--validate-lock', '--jobs', '8'), + ('--validate-lock', '--jobs', '0'), ('--validate-lock', '--jobs', '-j2'), + ('--validate-inputs', '--cache-dir', str(self.root))]: + with self.subTest(args=args): + result = self.cli(*args) + self.assertNotEqual(0, result.returncode) + if args[0] == '--validate-inputs': + self.assertIn('unrecognized arguments', result.stderr) + + +if __name__ == '__main__': + unittest.main() From 07e4a7afa827cb45514a72aba12f03b1d30d121d Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:25:28 +0800 Subject: [PATCH 4/9] Correct native fixture prerequisites and preserve original owning test contracts --- .../ubuntu-backports/Dockerfile.native-ci | 10 ++ .../libxrender/build-libxrender-backport.py | 16 ++- .../libxrender/test_libxrender_backport.py | 12 ++ .../p11-kit/build-p11-kit-backport.py | 3 +- .../p11-kit/p11-kit.lock.json | 4 +- .../p11-kit/test_p11_kit_backport.py | 13 +++ .../build-rust-coreutils-backport.py | 110 +++++++++++++++--- .../rust-coreutils/native-tests.mk | 11 +- .../test_rust_coreutils_backport.py | 95 +++++++++++++-- .../tar/build-tar-backport.py | 8 +- .../ubuntu-backports/tar/tar.lock.json | 24 ++-- .../ubuntu-backports/tar/test_tar_backport.py | 13 ++- 12 files changed, 275 insertions(+), 44 deletions(-) diff --git a/server/security/ubuntu-backports/Dockerfile.native-ci b/server/security/ubuntu-backports/Dockerfile.native-ci index 3248c83c24..a56a115b7c 100644 --- a/server/security/ubuntu-backports/Dockerfile.native-ci +++ b/server/security/ubuntu-backports/Dockerfile.native-ci @@ -84,6 +84,11 @@ if [[ "$MODULE" == libxrender ]]; then # connect to a host display or add Xvfb to a product's runtime dependencies. apt-get install --no-install-recommends -y xvfb xauth fi +if [[ "$MODULE" == p11-kit ]]; then + # The upstream RPC server suite uses p11tool; without it that test is + # skipped. This is a disposable CI dependency, never a runtime addition. + apt-get install --no-install-recommends -y gnutls-bin +fi useradd --create-home --user-group --uid 10001 --shell /bin/bash builder if [[ "$MODULE" == rust-coreutils ]]; then # Only the helper's bounded credential subprocess uses sudo. No host mount @@ -106,6 +111,11 @@ set -euo pipefail [[ "$BUILD_JOBS" == 2 || "$BUILD_JOBS" == 4 ]] test "$(id -u)" != 0 export MODULE SOURCE_SHA +if [[ "$MODULE" == rust-coreutils ]]; then + # Truthful upstream CI semantics for this GitHub-hosted disposable build. + # Other modules and all runtime packages retain their original environment. + export CI=true +fi df -Pk /work /out >/out/artifacts/container-disk-space.txt test_name="test_${MODULE//-/_}_backport.py" helper="build-$MODULE-backport.py" diff --git a/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py b/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py index 1939398e18..b9f917c4f6 100644 --- a/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py +++ b/server/security/ubuntu-backports/libxrender/build-libxrender-backport.py @@ -30,6 +30,18 @@ def check_result(code, output, negative): H.require(code == 0 and 'PASS' in output, 'Patched/packaged reply-boundary fixture failed') +def verify_hardening(dynamic, headers, original_dynamic): + def bind_now(text): + return 'BIND_NOW' in text or bool(re.search(r'FLAGS_1.*NOW', text)) + + # The pinned Ubuntu library has RELRO but no BIND_NOW. Preserve its actual + # baseline; do not add build flags or weaken a baseline that does use NOW. + H.require('GNU_RELRO' in headers and (not bind_now(original_dynamic) or bind_now(dynamic)), + 'Original Ubuntu hardening regressed') + return {'RELRO': True, 'BIND_NOW': bind_now(dynamic), + 'originalBIND_NOW': bind_now(original_dynamic)} + + def regression(source, extracted, work, env, log): archive = source / 'build/src/.libs/libXrender.a' H.require(archive.is_file(), 'Original configured static test archive missing') @@ -89,11 +101,11 @@ def verify(lock, source, extracted, work, env, log): # The original source has no Debian symbols file; compare the exact original # Ubuntu binary exports and NEEDED entries instead. headers = H.capture(['readelf', '--program-headers', str(library)], work, env) - H.require('GNU_RELRO' in headers and ('BIND_NOW' in dynamic or re.search(r'FLAGS_1.*NOW', dynamic)), - 'Original Ubuntu hardening is missing') + hardening = verify_hardening(dynamic, headers, old_dynamic) cases, evidence = regression(source, extracted, work, env, log) return {'owningCheck': 'PASS', 'upstreamDefinedTestCases': 0, 'originalUbuntuAbiAndNeeded': 'PASS', 'originalBinarySha256': lock['baselineBinary']['sha256'], + 'hardening': hardening, 'replyBoundaryCases': cases, 'runtimeInstalled': False}, \ [(check_log, check_log.name)] + evidence diff --git a/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py b/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py index 24800ca8aa..d1adff6845 100644 --- a/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py +++ b/server/security/ubuntu-backports/libxrender/test_libxrender_backport.py @@ -79,6 +79,18 @@ def test_original_ubuntu_abi_binary_is_exactly_scoped(self): baseline['version'] = 'other' with self.assertRaises(ValueError): self.altered('baselineBinary', baseline) + def test_actual_ubuntu_relro_baseline_does_not_require_new_bind_now_flags(self): + dynamic = '0x000000000000000e (SONAME) Library soname: [libXrender.so.1]' + self.assertEqual({'RELRO': True, 'BIND_NOW': False, 'originalBIND_NOW': False}, + M.verify_hardening(dynamic, 'GNU_RELRO', dynamic)) + + def test_existing_relro_and_bind_now_cannot_regress(self): + for original in ['BIND_NOW', 'FLAGS_1 Flags: NOW']: + with self.subTest(original=original): + with self.assertRaises(ValueError): M.verify_hardening('', 'GNU_RELRO', original) + self.assertTrue(M.verify_hardening(original, 'GNU_RELRO', original)['BIND_NOW']) + with self.assertRaises(ValueError): M.verify_hardening('', '', '') + if __name__ == '__main__': unittest.main() diff --git a/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py index 01656e084f..f4f972a820 100644 --- a/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py +++ b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py @@ -15,6 +15,7 @@ sys.dont_write_bytecode = True SPEC.loader.exec_module(H) COMMITS = ['0eedd4ddd7c924f9cbb4ac496b3fda699435c3cf', '3e64244e538550c6a7fcf826fa8c50a4604416dc'] +EXTRA_TOOLS = ['p11tool'] def require_tap_ok(text, case): @@ -99,7 +100,7 @@ def verify(lock, source, extracted, work, env, log): if __name__ == '__main__': try: - H.entry(HERE, 'p11-kit', '0.26.2-2', COMMITS, verify, []) + H.entry(HERE, 'p11-kit', '0.26.2-2', COMMITS, verify, EXTRA_TOOLS) except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: print('P11_KIT_BACKPORT_FAILED: ' + str(error), file=sys.stderr) sys.exit(1) diff --git a/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json b/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json index 4421303b04..a8df5e57b0 100644 --- a/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json +++ b/server/security/ubuntu-backports/p11-kit/p11-kit.lock.json @@ -113,7 +113,9 @@ "runtimeInstalled": false, "requiredOwningSuite": "Every configured upstream Autotools test must PASS, including test-rpc and test-rpc-message.", "requiredSoname": "libp11-kit.so.0", - "extraCiOnlyDependencies": [] + "extraCiOnlyDependencies": [ + "gnutls-bin" + ] }, "limitations": [ "CVE-2026-18938 is officially exploitable only on32-bit systems; native amd64 build does not establish 32-bit trigger validation. Its official guard is still included.", diff --git a/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py index 9cc343b14e..fbae52d652 100644 --- a/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py +++ b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py @@ -5,6 +5,7 @@ from pathlib import Path from types import SimpleNamespace import tempfile +from types import SimpleNamespace import unittest from unittest import mock import sys @@ -66,6 +67,18 @@ def test_root_cannot_run_build_or_owning_tests(self): with self.assertRaisesRegex(ValueError, 'non-root'): H.build(None, self.lock, HERE, M.verify, []) + def test_server_access_prerequisite_is_required_before_compilation(self): + self.assertIn('gnutls-bin', self.lock['buildContract']['extraCiOnlyDependencies']) + args = SimpleNamespace(output_dir=self.root / 'out', work_dir=self.root / 'work', + cache_dir=None, jobs=2) + with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ + mock.patch.object(H.os, 'geteuid', create=True, return_value=10001), \ + mock.patch.object(H.shutil, 'which', side_effect=lambda tool, **_: None if tool == 'p11tool' else '/usr/bin/' + tool), \ + mock.patch.object(H, 'run') as native: + with self.assertRaisesRegex(ValueError, 'prerequisite missing: p11tool'): + H.build(args, self.lock, HERE, M.verify, M.EXTRA_TOOLS) + native.assert_not_called() + def test_build_reaches_first_download_after_native_preflight(self): args = SimpleNamespace(output_dir=self.root / 'output', work_dir=self.root / 'work', cache_dir=None, jobs=2) diff --git a/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py b/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py index b5c24a6b0b..3effac466c 100644 --- a/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py +++ b/server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py @@ -3,8 +3,8 @@ Uses the unchanged Ubuntu 0.10 packaging and vendored crates; backports only the official id fix. No runtime package installation or alternatives changes occur. -The original non-gating test recipe is supplemented by a strict, unfiltered -owning-suite run and real/effective-GID tests of the extracted package. +The original Ubuntu exclusions remain; its accidental positional filter and +failure swallowing are removed. Extracted-package GID tests remain mandatory. """ import argparse import datetime @@ -14,6 +14,7 @@ import os from pathlib import Path import re +import shlex import shutil import subprocess import sys @@ -25,6 +26,9 @@ VERSION = '0.10.0-1ubuntu2~26.04.1' COMMIT = 'ff382f527994b8a021ec0a086dec4c5cafa34f2a' PATHS = ['src/uu/id/src/id.rs', 'tests/by-util/test_id.rs'] +RULES_SHA256 = '06c5fd183a4a1146748810e3cce859ba849643eeee244cc80cbb3315aeedb5e7' +UBUNTU_EXCLUSIONS = ["test_dd::test_stdin_stdout_skip_w_multiplier","test_env::test_env_arg_ignore_signal_valid_signals","test_env::test_env_french","test_env::test_simulation_of_terminal_for_stdin_only","test_env::test_simulation_of_terminal_for_stdout_only","test_env::test_simulation_of_terminal_size_information","test_env::test_simulation_of_terminal_true","test_chmod::test_chmod_colored_output","test_nohup::test_nohup_stderr_to_stdout","test_nohup::test_nohup_with_pseudo_terminal_emulation_on_stdin_stdout_stderr_get_replaced","test_tail::test_following_with_pid","test_tail::test_follow_when_files_are_pointing_to_same_relative_file_and_file_stays_same_size","test_touch::test_touch_changes_time_of_file_in_stdout","test_error_messages_french_translation","test_french_colored_error_messages","test_help_messages_french_translation","util_invalid_name_invalid_command"] +ID_TESTS = ["test_id::test_id_default_format","test_id::test_id_digital_username","test_id::test_id_groups_ordering","test_id::test_id_ignore","test_id::test_id_multiple_users","test_id::test_id_multiple_users_non_existing","test_id::test_id_name","test_id::test_id_name_or_real_with_default_format","test_id::test_id_no_specified_user","test_id::test_id_no_specified_user_posixly","test_id::test_id_pretty_print_password_record","test_id::test_id_pretty_print_suid_binary","test_id::test_id_real","test_id::test_id_single_user","test_id::test_id_single_user_non_existing","test_id::test_id_zero","test_id::test_id_zero_with_default_format","test_id::test_id_zero_with_name_or_real","test_id::test_invalid_arg"] def require(condition, message): @@ -144,15 +148,55 @@ def verify_sources(source, expected): for name, sha in expected.items()), 'Unexpected source/packaging bytes') +def ubuntu_test_contract(rules): + require(digest(rules) == RULES_SHA256, 'Ubuntu owning-test rules digest changed') + text = rules.read_text(encoding='utf-8') + block = re.search(r'(?ms)^override_dh_auto_test:\n(.*?)^override_dh_auto_install:', text) + require(block and 'test||true' in block[1], 'Original Ubuntu test recipe boundary changed') + value = re.findall(r'TEST_NO_FAIL_FAST="([^"]+)"', block[1]) + require(len(value) == 1, 'Ambiguous Ubuntu test arguments') + tokens = shlex.split(value[0].replace('\\\n', '')) + require(tokens and tokens.pop(0) == '--', 'Unexpected original Cargo test separator') + exclusions, positional = [], [] + while tokens: + token = tokens.pop(0) + if token == '--skip': + require(tokens, 'Missing Ubuntu exclusion pattern') + exclusions.append(tokens.pop(0)) + else: + positional.append(token) + require(exclusions == UBUNTU_EXCLUSIONS and positional == ['stdout'], + 'Original Ubuntu 17 exclusions/legacy positional filter changed') + require(not any(pattern in name for pattern in exclusions for name in ID_TESTS), + 'Owning test configuration must not exclude an id case') + return {'rulesSha256': RULES_SHA256, 'excludedPatterns': exclusions, + 'removedLegacyPositionalFilters': positional, 'removedFailureSwallowing': True, + 'newExclusionPatterns': []} + + +def ubuntu_test_arguments(contract): + require(contract['rulesSha256'] == RULES_SHA256 + and contract['excludedPatterns'] == UBUNTU_EXCLUSIONS, + 'Unapproved owning-test contract') + # Build from the approved constants after comparing the parsed pinned recipe. + return shlex.join(['--no-fail-fast', '--'] + + [value for pattern in UBUNTU_EXCLUSIONS for value in ['--skip', pattern]]) + + def build_environment(lock, jobs, home): # Do not inherit test suppressors, Cargo profiles, compiler flags or registries. - return {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': str(home), + environment = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'HOME': str(home), 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8', 'TZ': 'UTC', 'TERM': 'dumb', 'SOURCE_DATE_EPOCH': str(lock['sourceDateEpoch']), 'DEB_BUILD_OPTIONS': 'parallel=' + str(jobs), 'DEB_BUILD_PROFILES': '', 'DEB_BUILD_MAINT_OPTIONS': '', 'DPKG_COLORS': 'never', 'CARGO_NET_OFFLINE': 'true', 'CARGO_TERM_COLOR': 'never', 'CARGO_BUILD_JOBS': str(jobs)} + # Only the real disposable GitHub builder's explicit CI marker is forwarded. + # Test filters, compiler overrides and arbitrary CI values are not inherited. + if os.environ.get('CI') == 'true': + environment['CI'] = 'true' + return environment def capture(command, work, env): @@ -178,17 +222,47 @@ def packaging_snapshot(source): and 'rust-vendor' not in p.relative_to(source / 'debian').parts} -def strict_test_receipt(log): +def strict_test_receipt(log, contract, ci_marker): + require(contract['rulesSha256'] == RULES_SHA256 + and contract['excludedPatterns'] == UBUNTU_EXCLUSIONS, 'Unapproved test contract') text = log.read_text(encoding='utf-8', errors='replace') - results = re.findall(r'test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored;', text) - require(results and all(int(failed) == 0 for _, failed, _ in results) - and sum(int(passed) for passed, _, _ in results) > 0 - and re.search(r'test (?:test_id::)?test_id_groups_ordering \.\.\. ok', text), - 'Missing strict owning-suite/id regression PASS receipt') - require('test result: FAILED' not in text, 'Owning test failure in log') - return {'status': 'PASS', 'addedSkips': 0, 'cargoResults': [ - {'passed': int(p), 'failed': int(f), 'upstreamIgnored': int(i)} for p, f, i in results], - 'officialIdRegression': 'PASS', 'sha256': digest(log)} + targets = ['Running unittests src/lib.rs', 'Running unittests src/bin/coreutils.rs', + 'Running tests/test_localization_and_colors.rs', 'Running tests/test_util_name.rs', + 'Running tests/test_uudoc.rs', 'Running tests/tests.rs', 'Doc-tests coreutils'] + require(all(target in text for target in targets), 'An owning Cargo target is missing') + results = re.findall(r'^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored; ' + r'(\d+) measured; (\d+) filtered out;', text, re.M) + require(len(results) == len(targets) and all(status == 'ok' and int(failed) == 0 + for status, _, failed, _, _, _ in results) and 'test result: FAILED' not in text, + 'Owning-suite failure or incomplete target receipts') + ids = re.findall(r'^test (test_id::\S+) \.\.\. (ok|FAILED|ignored)(?:[^\n]*)$', text, re.M) + require(len(ids) == len(ID_TESTS) and sorted(name for name, _ in ids) == ID_TESTS + and all(status == 'ok' for _, status in ids), 'All19 id cases must report ok') + logname = ['test_logname::test_normal', 'test_logname::test_output_format'] + require(all(re.search(r'^test ' + re.escape(name) + r' \.\.\. ok$', text, re.M) + for name in logname), 'Logname owner-CI outcomes are missing') + parsed = [{'passed': int(p), 'failed': int(f), 'upstreamIgnored': int(i), + 'measured': int(m), 'filteredOut': int(sk)} + for _, p, f, i, m, sk in results] + return {'status': 'PASS', 'scope': 'Pinned Ubuntu owning-test contract; not all-functional coverage', + 'owningTestContract': contract, 'cargoResults': parsed, + 'observedTotals': {key: sum(row[key] for row in parsed) for key in parsed[0]}, + 'idCasesReportedOk': [name for name, _ in ids], 'officialIdRegression': 'PASS', + 'lognameCoverage': {'tests': logname, 'ciMarkerForwarded': ci_marker == 'true', + 'formalOutcome': 'reported ok', 'genuineLoginFixtureValidated': False, + 'limitation': 'The upstream CI guard may return on no login name before login assertions; ' + 'these outcomes do not establish successful login-name retrieval.'}, + 'historicalUnfilteredAttempt': { + 'sourceSha': 'bc129a88a5ec8c4e64b3ed89bfd97c7e82362d7c', + 'runId': 40116776455, 'jobId': 120939862366, 'status': 'FAIL', + 'failed': 5, 'idCasesReportedOk': 19, 'upstreamIgnoredActual': 85, + 'artifactId': 12570471831, + 'artifactSha256': 'd219e2d964ba324bc7a6e27a3b0dd900d566a8b0e428f3ad87ec0855e0936238', + 'strictLogSha256': '653a3768ee166f1c221b97886392a7c02178bd2c9e983228de7d4a35ee70925b', + 'failedTests': ['test_help_messages_french_translation', + 'test_error_messages_french_translation', 'test_french_colored_error_messages', + 'test_logname::test_normal', 'test_logname::test_output_format']}, + 'sha256': digest(log)} def credential_prefix(env, work): @@ -271,6 +345,7 @@ def build(args, lock): run(['dpkg-source', '--extract', str(inputs / ('rust-coreutils_' + VERSION + '.dsc')), str(source)], work, env, log) verify_sources(source, lock['baselineSha256']) + test_contract = ubuntu_test_contract(source / 'debian/rules') packaging = packaging_snapshot(source) item = lock['patches'][0] patch = inputs / item['name'] @@ -292,7 +367,7 @@ def build(args, lock): strict_log = work / 'strict-tests.log' run(['make', '-f', str(HERE / 'native-tests.mk'), 'pasturestack-strict-test'], source, env, strict_log) - tests = strict_test_receipt(strict_log) + tests = strict_test_receipt(strict_log, test_contract, env.get('CI')) verify_sources(source, packaging) verify_sources(source, lock['patchedSha256']) debs = list(work.glob('rust-coreutils_' + lock['customVersion'] + '_amd64.deb')) @@ -343,7 +418,9 @@ def build(args, lock): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--validate-lock', action='store_true') + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--validate-lock', action='store_true') + mode.add_argument('--print-ubuntu-test-arguments', action='store_true') parser.add_argument('--output-dir', type=Path) parser.add_argument('--work-dir', type=Path) parser.add_argument('--cache-dir', type=Path) @@ -352,6 +429,9 @@ def main(): try: lock = load_lock(LOCK_FILE) require(1 <= args.jobs <= 64, 'Invalid job count') + if args.print_ubuntu_test_arguments: + print(ubuntu_test_arguments(ubuntu_test_contract(Path.cwd() / 'debian/rules'))) + return if args.validate_lock: print('Rust-coreutils source/patch lock: PASS') return diff --git a/server/security/ubuntu-backports/rust-coreutils/native-tests.mk b/server/security/ubuntu-backports/rust-coreutils/native-tests.mk index 4de1b1dcaa..60c499e23c 100644 --- a/server/security/ubuntu-backports/rust-coreutils/native-tests.mk +++ b/server/security/ubuntu-backports/rust-coreutils/native-tests.mk @@ -1,6 +1,13 @@ -# Retain the exact Ubuntu exports/features; only replace its non-gating test recipe. +# Retain Ubuntu exports/features and its pinned 17 exclusions. Remove only the +# accidental positional filter and failure swallowing from its test recipe. +override _PS_TEST_HELPER := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/build-rust-coreutils-backport.py) include debian/rules +override _PS_TEST_ARGS := $(shell python3 "$(_PS_TEST_HELPER)" --print-ubuntu-test-arguments) +ifneq ($(.SHELLSTATUS),0) +$(error Pinned Ubuntu owning-test arguments could not be verified) +endif +override TEST_NO_FAIL_FAST := $(_PS_TEST_ARGS) .PHONY: pasturestack-strict-test pasturestack-strict-test: - $(MAKE) -f GNUmakefile SELINUX_ENABLED=1 PROFILE=release MULTICALL=y TEST_NO_FAIL_FAST=--no-fail-fast test + $(MAKE) -f GNUmakefile SELINUX_ENABLED=1 PROFILE=release MULTICALL=y TEST_NO_FAIL_FAST="$(TEST_NO_FAIL_FAST)" test diff --git a/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py b/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py index 97b0f8de3a..6bdf8dab77 100644 --- a/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py +++ b/server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py @@ -3,6 +3,7 @@ import importlib.util import json import os +import shlex from pathlib import Path import sys import tempfile @@ -70,12 +71,18 @@ def test_patch_cannot_modify_packaging_or_other_utilities(self): def test_build_environment_discards_test_and_compiler_overrides(self): with mock.patch.dict(os.environ, {'DEB_BUILD_OPTIONS': 'nocheck', 'CARGOFLAGS': '--test id', - 'RUSTFLAGS': '-C opt-level=0', 'TESTS': 'id'}): + 'RUSTFLAGS': '-C opt-level=0', 'TESTS': 'id', 'CI': 'true', + 'TEST_NO_FAIL_FAST': 'stdout', '_PS_TEST_ARGS': '--ignored'}): env = M.build_environment(self.lock, 2, self.root) self.assertEqual('parallel=2', env['DEB_BUILD_OPTIONS']) self.assertEqual('', env['DEB_BUILD_MAINT_OPTIONS']) self.assertEqual('true', env['CARGO_NET_OFFLINE']) - self.assertTrue(all(x not in env for x in ['CARGOFLAGS', 'RUSTFLAGS', 'TESTS', 'SKIP_UTILS'])) + self.assertEqual('true', env['CI']) + self.assertTrue(all(x not in env for x in ['CARGOFLAGS', 'RUSTFLAGS', 'TESTS', 'SKIP_UTILS', + 'TEST_NO_FAIL_FAST', '_PS_TEST_ARGS'])) + for marker in ['', 'false', '1']: + with mock.patch.dict(os.environ, {'CI': marker}): + self.assertNotIn('CI', M.build_environment(self.lock, 2, self.root)) def test_root_cannot_accidentally_run_the_build_or_full_suite(self): with mock.patch.object(Path, 'read_text', return_value='ID=ubuntu\nVERSION_ID="26.04"\n'), \ @@ -83,16 +90,90 @@ def test_root_cannot_accidentally_run_the_build_or_full_suite(self): with self.assertRaisesRegex(ValueError, 'must run as non-root'): M.build(None, self.lock) - def test_strict_receipt_requires_real_suite_and_official_regression(self): + def contract_fixture(self): + patterns = list(M.UBUNTU_EXCLUSIONS) + patterns[11] += ' stdout' + args = '-- --skip ' + ' \\\n--skip '.join(patterns) + return 'override_dh_auto_test:\n TEST_NO_FAIL_FAST="' + args + '"\n test||true\n' + \ + 'override_dh_auto_install:\n' + + def contract(self): + rules = self.root / 'rules' + rules.write_text(self.contract_fixture(), encoding='utf-8') + with mock.patch.object(M, 'digest', return_value=M.RULES_SHA256): + return M.ubuntu_test_contract(rules) + + def test_original_contract_requires_pinned_rules_and_exact17_patterns(self): + rules = self.root / 'rules' + rules.write_text(self.contract_fixture(), encoding='utf-8') + with self.assertRaises(ValueError): M.ubuntu_test_contract(rules) + contract = self.contract() + self.assertEqual(17, len(contract['excludedPatterns'])) + self.assertEqual(['stdout'], contract['removedLegacyPositionalFilters']) + self.assertTrue(contract['removedFailureSwallowing']) + self.assertFalse(any(pattern in name for pattern in contract['excludedPatterns'] + for name in M.ID_TESTS)) + + def test_contract_rejects_new_missing_reordered_id_or_extra_filters(self): + original = self.contract_fixture() + first, second = M.UBUNTU_EXCLUSIONS[:2] + for bad in [original.replace(first, 'test_id::'), original.replace('--skip ' + first, ''), + original.replace(first, 'marker').replace(second, first).replace('marker', second), + original.replace(' stdout', ''), original.replace(' stdout', ' stdout --ignored'), + original.replace('test||true', 'test')]: + rules = self.root / 'rules' + rules.write_text(bad, encoding='utf-8') + with mock.patch.object(M, 'digest', return_value=M.RULES_SHA256): + with self.assertRaises(ValueError): M.ubuntu_test_contract(rules) + + def test_command_contains_only_owner_exclusions_without_positional_filter_or_swallow(self): + contract = self.contract() + tokens = shlex.split(M.ubuntu_test_arguments(contract)) + self.assertEqual(['--no-fail-fast', '--'], tokens[:2]) + self.assertEqual(17, tokens.count('--skip')) + self.assertEqual(M.UBUNTU_EXCLUSIONS, tokens[3::2]) + self.assertNotIn('stdout', tokens) + self.assertNotIn('||true', tokens) + altered = copy.deepcopy(contract) + altered['excludedPatterns'].append('test_id') + with self.assertRaises(ValueError): M.ubuntu_test_arguments(altered) + + def owner_log(self, main_ignored=83): + targets = ['Running unittests src/lib.rs', 'Running unittests src/bin/coreutils.rs', + 'Running tests/test_localization_and_colors.rs', 'Running tests/test_util_name.rs', + 'Running tests/test_uudoc.rs', 'Running tests/tests.rs', 'Doc-tests coreutils'] + ignored = [0, 0, 0, 2, 0, main_ignored, 0] + lines = [line for target, count in zip(targets, ignored) for line in + [target, f'test result: ok. 30 passed; 0 failed; {count} ignored; 0 measured; 0 filtered out;']] + lines += ['test ' + name + ' ... ok' for name in M.ID_TESTS] + lines += ['test test_logname::test_normal ... ok', 'test test_logname::test_output_format ... ok'] + return '\n'.join(lines) + '\n' + + def test_strict_receipt_requires_all_targets_and_all19_id_cases(self): file = self.root / 'strict.log' - good = ('test test_id::test_id_groups_ordering ... ok\n' - 'test result: ok. 12 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out;\n') + contract = self.contract() + good = self.owner_log() file.write_text(good, encoding='utf-8') - self.assertEqual('PASS', M.strict_test_receipt(file)['status']) + self.assertEqual('PASS', M.strict_test_receipt(file, contract, 'true')['status']) for bad in [good.replace('groups_ordering', 'unrelated'), + good.replace('test_id::test_id_real ... ok', 'test_id::test_id_real ... ignored'), + good.replace('Running tests/test_util_name.rs', 'unrelated target'), good.replace('0 failed', '1 failed'), good + 'test result: FAILED\n', '']: file.write_text(bad, encoding='utf-8') - with self.assertRaises(ValueError): M.strict_test_receipt(file) + with self.assertRaises(ValueError): M.strict_test_receipt(file, contract, 'true') + + def test_metadata_preserves_old_fail_85ignored_and_owner_ci_coverage_limits(self): + file = self.root / 'strict.log' + file.write_text(self.owner_log(main_ignored=80), encoding='utf-8') + receipt = M.strict_test_receipt(file, self.contract(), 'true') + self.assertEqual(17, len(receipt['owningTestContract']['excludedPatterns'])) + self.assertEqual(82, receipt['observedTotals']['upstreamIgnored']) + self.assertEqual(85, receipt['historicalUnfilteredAttempt']['upstreamIgnoredActual']) + self.assertEqual('FAIL', receipt['historicalUnfilteredAttempt']['status']) + self.assertEqual(5, receipt['historicalUnfilteredAttempt']['failed']) + self.assertEqual(19, len(receipt['idCasesReportedOk'])) + self.assertTrue(receipt['lognameCoverage']['ciMarkerForwarded']) + self.assertFalse(receipt['lognameCoverage']['genuineLoginFixtureValidated']) def test_native_command_failure_cannot_be_swallowed(self): with self.assertRaises(ValueError): diff --git a/server/security/ubuntu-backports/tar/build-tar-backport.py b/server/security/ubuntu-backports/tar/build-tar-backport.py index 495ac4d460..ac73c002c0 100644 --- a/server/security/ubuntu-backports/tar/build-tar-backport.py +++ b/server/security/ubuntu-backports/tar/build-tar-backport.py @@ -114,8 +114,12 @@ def validate_inputs(lock, directory): def verify_sources(source, expected): - require(all((source / n).is_file() and digest(source / n) == sha for n, sha in expected.items()), - 'Source/prerequisite closure does not match the reviewed Ubuntu backport') + for name, sha in expected.items(): + file = source / name + require(file.is_file(), 'Missing source/prerequisite file: ' + name) + actual = digest(file) + require(actual == sha, 'Source/prerequisite SHA-256 mismatch: ' + name + + ' (expected=' + sha + ', actual=' + actual + ')') def verify_series(original, updated): diff --git a/server/security/ubuntu-backports/tar/tar.lock.json b/server/security/ubuntu-backports/tar/tar.lock.json index 04f3898b71..90bba772e1 100644 --- a/server/security/ubuntu-backports/tar/tar.lock.json +++ b/server/security/ubuntu-backports/tar/tar.lock.json @@ -75,21 +75,21 @@ "baselineSha256": { "gnu/tempname.c": "8701d79ced94ba0bff60607d36583d23140cdf1e18e9f8d883a23508275f471c", "gnu/tempname.h": "45167ab628da8c3f21050680d5f09fc3fcd7acffff7487a031cfe0fb36f4d870", - "gnu/Makefile.am": "804b9f9c7c37cd8788dde59d0ec1156d7700094d7a72415e28e8da43dc6634bd", - "src/common.h": "061713d04658467c595b41e4b81b277ad067ab352528a4e7832c1f1af8eaa143", + "gnu/Makefile.am": "1833f9bcec8bc9652c8de73a526c9a327170976f6132f7e236860e1599a6a3dd", + "src/common.h": "69f61666b788e52981ccc1e9428c2c95b0cb4f2c6335a687bc1312cd5e783012", "lib/Makefile.am": "4bed9bf65e2eb453cdc0dade993b9e630f71f2015240d9074742f1f60db11aef", - "src/incremen.c": "c49c6d8a6fbe0e4554c10601041b7c21a2493ba43d99a2332bb00f2c5d240b92" + "src/incremen.c": "7c7ad665e4a2bcf40c93c57721d9ddecb48d881060d51dc35174fa46d79c6a68" }, "patchedSha256": { - "THANKS": "e8afcec1d516a7207e6f9c9ade75fba8f38643d9e712ec809dbddf31a15782bd", - "lib/Makefile.am": "271a1ed923f3d1c87ba6f98dd7004e3f0dc3dc51b7101806e781de7febbd48fd", - "lib/mkdtempat.c": "fa4427fb6720e0191041d839f692e4c61f9227f03be398d63971f7cc1d873d1e", - "lib/mkdtempat.h": "df1e68ae16ae567bcf123545d6f2c9744f426207c0e65525031efb8f63ecb1a5", - "src/incremen.c": "6b1e53b66c6edaaeec05b5cdb15531902d762431926caae9aff8bf4c54e23be9", - "tests/Makefile.am": "3353193af4de5e968938d1087cd607c36282697e4ae1aa2d937d71806e65e647", - "tests/rename08.at": "3c311ce3ff783d4f49fd1b4e91b2d2e8fcc9018f38e7fe2e7f691be802e8ae1b", - "tests/rename09.at": "c4774038a66286db7f7d6ff39b41113d388efeecbb4c05ae2b4ab75ee48a5dde", - "tests/testsuite.at": "bc298c2f1339e7a5eb262e1a163279e9a0b79243a1685bc5f716ec4c26da49e8" + "THANKS": "9b195c212a0940e19dbb2adb0d9bdff5df0e91a5c740d06f64dd139de753ee4d", + "lib/Makefile.am": "0e652fa024b047f6281aac30602d710e55951e643de3fef34c6af5ce650b75ff", + "lib/mkdtempat.c": "d55595c1c5d64f1f4820efd46bdc0813927833b27581ee8ece98af1703d17973", + "lib/mkdtempat.h": "e660013c2fc5d483f637d33fdfb60c7c10c52fd6407623daf56a00d5deacf4e3", + "src/incremen.c": "03984c0d771b5b145a2e1515ef063ef2666f5a19050ed9af8e76954d8670b324", + "tests/Makefile.am": "e88d326b839d64b54a99e913a3ef73cc45d32202b87e3e0efc9776d6e06ee849", + "tests/rename08.at": "da3b6e0ed0ea29d1e58017df21393de2b443908b510725afe20ffbaaef0980b3", + "tests/rename09.at": "a3924a039f6f158a1b7075bcc78dea25eede1e03483ef9af0601926abdba28e0", + "tests/testsuite.at": "36e033c4bc75260f790c3bcaa717d8a6aa422e7d875bff1f41786b325f493d99" }, "runtimePackages": [ "tar" diff --git a/server/security/ubuntu-backports/tar/test_tar_backport.py b/server/security/ubuntu-backports/tar/test_tar_backport.py index 16809e82a2..d4d1ae29b9 100644 --- a/server/security/ubuntu-backports/tar/test_tar_backport.py +++ b/server/security/ubuntu-backports/tar/test_tar_backport.py @@ -140,10 +140,19 @@ def test_filtered_ambiguous_or_unexpected_native_results_fail(self): with self.assertRaises(ValueError): MODULE.test_receipt(self.root) def test_source_prerequisite_hash_mismatch_and_missing_smoke_binary_fail(self): - file = self.root / 'input'; file.write_bytes(b'original') + file = self.root / 'input'; file.write_bytes(b'original\nsource\n') expected = {'input': MODULE.digest(file)}; MODULE.verify_sources(self.root, expected) + # Source hashes bind exact archive/patch bytes. A Windows preparation + # must not pin CRLF copies or normalize a runtime mismatch away. + file.write_bytes(b'original\r\nsource\r\n') + with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch: input'): + MODULE.verify_sources(self.root, expected) file.write_bytes(b'tamper') - with self.assertRaises(ValueError): MODULE.verify_sources(self.root, expected) + with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch: input'): + MODULE.verify_sources(self.root, expected) + file.unlink() + with self.assertRaisesRegex(ValueError, 'Missing source/prerequisite file: input'): + MODULE.verify_sources(self.root, expected) with self.assertRaises(ValueError): MODULE.tar_smoke(self.root / 'missing', self.root, {}, self.root / 'log') def test_output_must_not_overlap_build_or_download_cache(self): From 2a47f3cd99b6afc47b54ee6d8837b834e95c50f2 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:34:33 +0800 Subject: [PATCH 5/9] Match upstream p11-kit test-log multiplicity in native evidence --- .../p11-kit/build-p11-kit-backport.py | 31 ++++++++++--- .../p11-kit/test_p11_kit_backport.py | 45 ++++++++++++++++--- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py index f4f972a820..9e966b62a9 100644 --- a/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py +++ b/server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py @@ -26,7 +26,16 @@ def require_tap_ok(text, case): 'Official regression case lacks a single unqualified TAP ok: ' + case) -def full_suite(source): +def configured_test_logs(source, env): + # Query the original generated Makefile without running a test target. + # TEST_LOGS intentionally retains any duplicate entries used by Automake's + # summary; p11-kit 0.26.2 lists test-oid through two source fragments. + return H.capture(['make', '--no-print-directory', '--eval', + "pasturestack-print-test-logs:;@printf '%s\\n' $(TEST_LOGS)", + 'pasturestack-print-test-logs'], source, env).splitlines() + + +def full_suite(source, test_logs): logs = list(source.rglob('test-suite.log')) H.require(len(logs) == 1, 'Missing/ambiguous full owning test-suite log') summary = logs[0].read_text(encoding='utf-8', errors='replace') @@ -36,10 +45,16 @@ def full_suite(source): H.require(len(pairs) == 7 and {key for key, _ in pairs} == {'TOTAL', *states}, 'Missing or ambiguous owning-suite summary counts') counts = {key: int(value) for key, value in pairs} - reports = list(source.rglob('*.trs')) + H.require(test_logs and all(not Path(name).is_absolute() and '..' not in Path(name).parts + and Path(name).suffix == '.log' for name in test_logs), + 'Missing or unsafe configured TEST_LOGS manifest') + occurrences = Counter(source / Path(name).with_suffix('.trs') for name in test_logs) + reports = list(occurrences) + H.require(set(reports) == set(source.rglob('*.trs')), + 'Configured TEST_LOGS and native TRS files differ') H.require(reports and counts['TOTAL'] > 0, 'Complete owning suite has no testcase results') receipts, evidence = [], [(logs[0], 'test-suite.log')] - names, observed = set(), Counter() + names, observed, unique = set(), Counter(), Counter() for report in reports: text = report.read_text(encoding='utf-8') # tap-driver writes one result per TAP testcase, not one per .trs file. @@ -50,11 +65,13 @@ def full_suite(source): H.require(re.findall(r'^:global-test-result: (\S+)$', text, re.M) == ['PASS'], 'Owning TRS global result did not PASS: ' + str(report)) case_counts = Counter(match[1] for match in matches) - observed.update(case_counts) + unique.update(case_counts) + observed.update({state: count * occurrences[report] for state, count in case_counts.items()}) relative = report.relative_to(source).as_posix() names.add(report.stem) receipts.append({'path': relative, 'status': 'PASS', 'sha256': H.digest(report), - 'caseCounts': {state: case_counts[state] for state in states}}) + 'caseCounts': {state: case_counts[state] for state in states}, + 'summaryOccurrences': occurrences[report]}) for suffix in ['.trs', '.log']: file = report.with_suffix(suffix) H.require(file.is_file(), 'Owning test output missing') @@ -71,11 +88,13 @@ def full_suite(source): require_tap_ok(rpc, '/rpc3/recursion-limit') require_tap_ok(message, '/rpc-message/attribute-recursion-limit') return {'status': 'PASS', 'counts': counts, 'receipts': receipts, + 'configuredTestLogs': test_logs, 'uniqueCaseTotal': sum(unique.values()), + 'uniqueCaseCounts': {state: unique[state] for state in states}, 'officialRpcRecursionCases': 'PASS', 'addedSkips': 0}, evidence def verify(lock, source, extracted, work, env, log): - tests, evidence = full_suite(source) + tests, evidence = full_suite(source, configured_test_logs(source, env)) library = extracted / 'usr/lib/x86_64-linux-gnu/libp11-kit.so.0' abi = H.verify_abi(library, 'libp11-kit.so.0', source / 'debian/libp11-kit0.symbols', work, env) for name in ['usr/bin/p11-kit', 'usr/libexec/p11-kit/p11-kit-remote', diff --git a/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py index fbae52d652..27800d0130 100644 --- a/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py +++ b/server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py @@ -5,7 +5,6 @@ from pathlib import Path from types import SimpleNamespace import tempfile -from types import SimpleNamespace import unittest from unittest import mock import sys @@ -97,6 +96,7 @@ def test_build_reaches_first_download_after_native_preflight(self): def suite(self): tests = {'test-rpc': 3, 'test-rpc-message': 2, 'test-control': 1} + self.test_logs = [name + '.log' for name in tests] for name, cases in tests.items(): (self.root / (name + '.trs')).write_text(':global-test-result: PASS\n' + ':test-result: PASS\n' * cases, encoding='utf-8') @@ -113,7 +113,7 @@ def suite(self): def test_multi_case_trs_totals_match_full_summary(self): self.suite() - result = M.full_suite(self.root)[0] + result = M.full_suite(self.root, self.test_logs)[0] self.assertEqual('PASS', result['status']) self.assertEqual(6, result['counts']['TOTAL']) self.assertEqual(3, len(result['receipts'])) @@ -123,10 +123,10 @@ def test_skips_and_incomplete_suite_never_become_pass(self): self.suite() summary = self.root / 'test-suite.log' summary.write_text(summary.read_text().replace('# PASS: 6', '# PASS: 5').replace('# SKIP: 0', '# SKIP: 1')) - with self.assertRaises(ValueError): M.full_suite(self.root) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) self.suite() (self.root / 'test-control.trs').unlink() - with self.assertRaises(ValueError): M.full_suite(self.root) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) def test_case_total_and_each_summary_state_must_match(self): for old, new in [('# TOTAL: 6', '# TOTAL: 7'), ('# PASS: 6', '# PASS: 5')] + [ @@ -136,7 +136,7 @@ def test_case_total_and_each_summary_state_must_match(self): self.suite() summary = self.root / 'test-suite.log' summary.write_text(summary.read_text().replace(old, new), encoding='utf-8') - with self.assertRaises(ValueError): M.full_suite(self.root) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) def test_nonpass_trs_case_cannot_hide_among_other_pass_cases(self): for state in ['SKIP', 'XFAIL', 'FAIL', 'XPASS', 'ERROR', 'UNKNOWN']: @@ -149,7 +149,7 @@ def test_nonpass_trs_case_cannot_hide_among_other_pass_cases(self): summary = self.root / 'test-suite.log' summary.write_text(summary.read_text().replace('# PASS: 6', '# PASS: 5').replace( '# ' + state + ': 0', '# ' + state + ': 1'), encoding='utf-8') - with self.assertRaises(ValueError): M.full_suite(self.root) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) def test_required_case_needs_single_unqualified_tap_ok(self): for file, case in [('test-rpc.log', '/rpc3/recursion-limit'), @@ -161,7 +161,38 @@ def test_required_case_needs_single_unqualified_tap_ok(self): with self.subTest(file=file, log=text): self.suite() (self.root / file).write_text(text + '\n', encoding='utf-8') - with self.assertRaises(ValueError): M.full_suite(self.root) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) + + def test_configured_duplicate_logs_preserve_summary_and_unique_case_counts(self): + self.suite() + # Automake counts a configured TEST_LOGS entry twice, while the file and + # actual testcase execution exist only once. No skip or failure is hidden. + manifest = self.test_logs + ['test-rpc-message.log'] + summary = self.root / 'test-suite.log' + summary.write_text(summary.read_text().replace('# TOTAL: 6', '# TOTAL: 8').replace( + '# PASS: 6', '# PASS: 8'), encoding='utf-8') + result = M.full_suite(self.root, manifest)[0] + self.assertEqual(8, result['counts']['TOTAL']) + self.assertEqual(6, result['uniqueCaseTotal']) + self.assertEqual(2, next(r for r in result['receipts'] + if r['path'] == 'test-rpc-message.trs')['summaryOccurrences']) + with self.assertRaises(ValueError): M.full_suite(self.root, self.test_logs) + + def test_configured_manifest_cannot_omit_or_add_native_trs_files(self): + self.suite() + for manifest in [self.test_logs[:-1], self.test_logs + ['missing.log'], + self.test_logs + ['../outside.log'], []]: + with self.subTest(manifest=manifest), self.assertRaises(ValueError): + M.full_suite(self.root, manifest) + + def test_test_log_query_preserves_configured_order_and_duplicates(self): + with mock.patch.object(H, 'capture', return_value='test-rpc.log\ntest-oid.log\ntest-oid.log') as query: + self.assertEqual(['test-rpc.log', 'test-oid.log', 'test-oid.log'], + M.configured_test_logs(self.root, {})) + command = query.call_args[0][0] + self.assertEqual('make', command[0]) + self.assertIn('$(TEST_LOGS)', command[3]) + self.assertEqual('pasturestack-print-test-logs', command[-1]) def test_cve18938_retains_32bit_qualification(self): self.assertTrue(any('32-bit' in value for value in self.lock['limitations'])) From 507cadcb3eaafcd6a85c453f2f180a8aef415d88 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:51:40 +0800 Subject: [PATCH 6/9] Reject incompatible tar candidate and preserve canonical glibc build evidence --- .github/workflows/ubuntu-backport-native.yml | 5 +- .../ubuntu-backports/Dockerfile.native-ci | 26 +- server/security/ubuntu-backports/README.md | 7 +- .../glibc/build-glibc-backport.py | 80 ++++- .../ubuntu-backports/glibc/glibc.lock.json | 11 +- .../glibc/test_glibc_backport.py | 74 +++- .../tar/build-tar-backport.py | 327 ------------------ .../ubuntu-backports/tar/tar.lock.json | 109 ------ .../ubuntu-backports/tar/test_tar_backport.py | 192 ---------- 9 files changed, 186 insertions(+), 645 deletions(-) delete mode 100644 server/security/ubuntu-backports/tar/build-tar-backport.py delete mode 100644 server/security/ubuntu-backports/tar/tar.lock.json delete mode 100644 server/security/ubuntu-backports/tar/test_tar_backport.py diff --git a/.github/workflows/ubuntu-backport-native.yml b/.github/workflows/ubuntu-backport-native.yml index 6953546309..4cc7b72937 100644 --- a/.github/workflows/ubuntu-backport-native.yml +++ b/.github/workflows/ubuntu-backport-native.yml @@ -14,7 +14,6 @@ on: - 'server/security/ubuntu-backports/jq/**' - 'server/security/ubuntu-backports/p11-kit/**' - 'server/security/ubuntu-backports/libxrender/**' - - 'server/security/ubuntu-backports/tar/**' - 'server/security/ubuntu-backports/source_helpers.py' workflow_dispatch: inputs: @@ -23,7 +22,7 @@ on: required: true type: choice default: all - options: [all, glibc, rust-coreutils, jq, p11-kit, libxrender, tar] + options: [all, glibc, rust-coreutils, jq, p11-kit, libxrender] permissions: contents: read @@ -40,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - module: ${{ fromJSON(inputs.module && inputs.module != 'all' && format('["{0}"]', inputs.module) || '["glibc","rust-coreutils","jq","p11-kit","libxrender","tar"]') }} + module: ${{ fromJSON(inputs.module && inputs.module != 'all' && format('["{0}"]', inputs.module) || '["glibc","rust-coreutils","jq","p11-kit","libxrender"]') }} env: MODULE: ${{ matrix.module }} SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/server/security/ubuntu-backports/Dockerfile.native-ci b/server/security/ubuntu-backports/Dockerfile.native-ci index a56a115b7c..7dff462a67 100644 --- a/server/security/ubuntu-backports/Dockerfile.native-ci +++ b/server/security/ubuntu-backports/Dockerfile.native-ci @@ -12,7 +12,7 @@ SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /tmp/ca-certificates.deb RUN <<'SH' set -euo pipefail -case "$MODULE" in glibc|rust-coreutils|jq|p11-kit|libxrender|tar) ;; *) exit 2 ;; esac +case "$MODULE" in glibc|rust-coreutils|jq|p11-kit|libxrender) ;; *) exit 2 ;; esac mkdir -p /tmp/ca-bootstrap /etc/ssl/certs dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap while IFS= read -r certificate; do @@ -117,6 +117,8 @@ if [[ "$MODULE" == rust-coreutils ]]; then export CI=true fi df -Pk /work /out >/out/artifacts/container-disk-space.txt +dpkg-query --show --showformat='${binary:Package}\t${Version}\n' | LC_ALL=C sort \ + >/out/artifacts/builder-packages.tsv test_name="test_${MODULE//-/_}_backport.py" helper="build-$MODULE-backport.py" if [[ "$MODULE" == jq ]]; then test_name=test_jq_release.py; helper=build-jq-release.py; fi @@ -160,6 +162,28 @@ if [[ "$MODULE" == glibc ]]; then gzip -c "$path" >"/out/artifacts/diagnostics/$name.gz" fi done + # If a post-build evidence check fails, retain only the three bounded + # unqualified runtime packages, not the source tree or debug packages. + # result.json and the workflow gate remain failed; these are diagnostics, + # never a qualified package output or a publication input. + if [[ "$HELPER_EXIT_CODE" != 0 ]]; then + python3 - <<'PY' +import hashlib, json, pathlib, shutil +lock = json.loads(pathlib.Path('/modules/glibc/glibc.lock.json').read_text()) +source = pathlib.Path('/work/native') +out = pathlib.Path('/out/artifacts/diagnostics/unqualified-packages') +out.mkdir() +files = [] +for name in ('libc6', 'libc-bin', 'libc-gconv-modules-extra'): + matches = list(source.glob(name + '_' + lock['customVersion'] + '_amd64.deb')) + if len(matches) == 1: + file = matches[0] + shutil.copyfile(file, out / file.name) + files.append({'name': file.name, 'sha256': hashlib.sha256(file.read_bytes()).hexdigest()}) +(out / 'UNQUALIFIED.json').write_text(json.dumps({'status': 'FAILED_NATIVE_DIAGNOSTICS_ONLY', + 'publicationAllowed': False, 'files': files}, indent=2) + '\n') +PY + fi fi gzip /out/artifacts/native-console.log SH diff --git a/server/security/ubuntu-backports/README.md b/server/security/ubuntu-backports/README.md index bf2af4ef6d..d4e5e8e698 100644 --- a/server/security/ubuntu-backports/README.md +++ b/server/security/ubuntu-backports/README.md @@ -12,7 +12,7 @@ Do not rerun an unchanged module that already has valid native PASS evidence. Its module source, lock, compiler/dependency inputs and expected artifact identity must still match. Changes to shared behavior require revalidating its consumers. -Select `glibc`, `rust-coreutils`, `jq`, `p11-kit`, `libxrender` or `tar` in the +Select `glibc`, `rust-coreutils`, `jq`, `p11-kit` or `libxrender` in the workflow. Source helpers run as a non-root user; Rust credential regression subprocesses alone receive privileges inside that temporary container. A failed helper retains logs and a failing result; exported files alone are not a PASS. @@ -21,3 +21,8 @@ Before a product consumes a candidate, require the exact native result, provenance, source lock, package hashes, ABI and affected runtime smoke. Any remaining CVE or unknown architecture/reachability condition stays tracked. Never overwrite a released package/image tag or patch a running container. + +The attempted tar CVE-2026-18477 backport was rejected: its original cyclic-rename +regression loses three restored files on the Ubuntu baseline. Source application +alone was insufficient. Neither it nor the incomplete CVE-2026-18508 backport is +offered here; a complete compatible official fix is still required. diff --git a/server/security/ubuntu-backports/glibc/build-glibc-backport.py b/server/security/ubuntu-backports/glibc/build-glibc-backport.py index b60ad8f22b..ad332dd390 100644 --- a/server/security/ubuntu-backports/glibc/build-glibc-backport.py +++ b/server/security/ubuntu-backports/glibc/build-glibc-backport.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """Build selected first-party glibc debs in a disposable Ubuntu 26.04 amd64 CI builder. -Uses original Ubuntu packaging, native tests and hardening. Never installs debs. +Uses original Ubuntu source packaging/control templates, native tests and hardening. +First-party control metadata is generated by those rules, not issued by Canonical. +Never installs debs. Native compilation is intentionally separate from --validate-lock/--validate-inputs. """ import argparse @@ -44,6 +46,8 @@ OLD_CALL = "? upstr (ccs, cs + 5) : ccs) != 0)" NEW_CALL = "if (ccs[2] == '\\0' || __wcsmbs_named_conv (&fcts, ccs) != 0)" CONTROLS = ['libio/tst-fopen-ccs-empty', 'libio/tst-fopenloc', 'libio/tst-fopenloc2'] +CONTROL_ORIGINAL_SHA256 = '80ad63c9edb6751e404e3a1b05d453433111b307ec00893380487df0534f6999' +CONTROL_GENERATED_SHA256 = 'a1cca0ba6356e1318f218b05dec1213bae7d43dce2bd79055119d75070a9b85e' def load_lock(file): @@ -80,6 +84,10 @@ def load_lock(file): require(item['url'] == url, 'Unexpected official source URL') require('First-party' in lock['identity'] and 'not issued by Canonical' in lock['identity'], 'Custom output must not claim Canonical provenance') + require(lock['packagingMetadataPolicy']['originalControlSha256'] == CONTROL_ORIGINAL_SHA256 + and lock['packagingMetadataPolicy']['generatedControlSha256'] == CONTROL_GENERATED_SHA256 + and lock['packagingMetadataPolicy']['approvedChangedFields'] == + ['Maintainer', 'XSBC-Original-Maintainer'], 'Unexpected generated control metadata policy') return lock @@ -158,13 +166,63 @@ def source_closure(source): ['libio/fileops.c', 'libio/Makefile', 'libio/tst-fopen-ccs-empty.c']} +def parse_test_summary(text): + entries, headers, sections = [], 0, [] + for line in text.splitlines(): + if not line.strip(): + continue + if line.strip() == '=== glibc tests ===': + require(not entries and not sections and headers == 0, 'Ambiguous native test header') + headers += 1 + continue + section = re.fullmatch(r'Running (\.|[a-z0-9][a-z0-9_-]*) \.\.\.', line) + if section: + require(headers == 1 and section[1] not in sections, 'Ambiguous native test section') + sections.append(section[1]) + continue + result = re.fullmatch(r'(PASS|FAIL|XFAIL|XPASS|UNSUPPORTED|UNRESOLVED|ERROR): (\S+)', line) + require(result is not None and headers == 1 and sections, + 'Unknown native tests.sum line: ' + line) + entries.append(result.groups()) + require(headers == 1 and sections and entries + and len({name for _, name in entries}) == len(entries), 'Ambiguous native test results') + return entries + + +def verify_packaging_inputs(source, expected): + changed = [name for name, sha in expected.items() + if not (source / name).is_file() or digest(source / name) != sha] + require(not changed, 'Build changed approved Ubuntu packaging inputs: ' + ', '.join(changed)) + + +def prepare_generated_control(source, environment, log): + before = COMMON.packaging_snapshot(source) + require(before.get('debian/control') == CONTROL_ORIGINAL_SHA256, + 'Fresh original Ubuntu control metadata is required') + command = ['make', '--no-print-directory', '-f', 'debian/rules', 'debian/control'] + run(command, source, environment, log) + after = COMMON.packaging_snapshot(source) + changed = sorted(name for name in before.keys() | after.keys() if before.get(name) != after.get(name)) + require(changed == ['debian/control'], 'Unexpected generated packaging changes: ' + ', '.join(changed)) + require(after['debian/control'] == CONTROL_GENERATED_SHA256, + 'Generated control differs from the approved original-template output') + return {'command': command, 'path': 'debian/control', + 'originalSha256': before['debian/control'], 'generatedSha256': after['debian/control'], + 'inputSha256': {name: sha for name, sha in before.items() + if name.startswith('debian/control.in/') or + name in ['debian/rules', 'debian/rules.d/control.mk']}, + 'changedFields': { + 'Maintainer': {'from': 'Ubuntu Developers ', + 'to': 'GNU Libc Maintainers '}, + 'XSBC-Original-Maintainer': {'from': 'GNU Libc Maintainers ', + 'to': None}}, + 'semantics': 'Preserve Ubuntu source packaging and original control templates; first-party metadata follows the original make rules and is not a Canonical publication.'} + + def test_receipt(source): report = source / 'build-tree/amd64-libc/tests.sum' require(report.is_file(), 'Missing full native tests.sum') - text = report.read_text(encoding='utf-8') - entries = re.findall(r'^(PASS|FAIL|XFAIL|XPASS|UNSUPPORTED|UNRESOLVED|ERROR): (\S+)$', text, re.M) - require(entries and len(entries) == len([line for line in text.splitlines() if line.strip()]) - and len({name for _, name in entries}) == len(entries), 'Ambiguous native test results') + entries = parse_test_summary(report.read_text(encoding='utf-8')) states = dict((name, state) for state, name in entries) counts = collections.Counter(state for state, _ in entries) require(not any(counts[s] for s in ['FAIL', 'ERROR', 'UNRESOLVED']), 'Unexpected native test failure') @@ -218,7 +276,7 @@ def build(args, lock): require(os_release.get('ID') == 'ubuntu' and os_release.get('VERSION_ID') == '26.04', 'Use an isolated Ubuntu 26.04 builder') environment = build_environment(lock, args.jobs) for command in ['dpkg', 'dpkg-source', 'dpkg-buildpackage', 'dpkg-deb', 'dpkg-buildflags', 'dpkg-checkbuilddeps', - 'dpkg-query', 'quilt', 'readelf', 'nm', 'fakeroot']: + 'dpkg-query', 'quilt', 'readelf', 'nm', 'fakeroot', 'make']: require(shutil.which(command, path=environment['PATH']), 'Missing builder dependency: ' + command) require(capture(['dpkg', '--print-architecture'], HERE, environment) == 'amd64', 'Use a native amd64 builder') output = args.output_dir.resolve() @@ -252,7 +310,6 @@ def build(args, lock): require(series.startswith(original_series) and series[len(original_series):].split() == [item['name'] for item in lock['patches']], 'Existing quilt series changed or extra patches added') source_proof = source_closure(source) - approved_packaging = COMMON.packaging_snapshot(source) changelog = source / 'debian/changelog' date = email.utils.format_datetime(datetime.datetime.fromtimestamp(lock['sourceDateEpoch'], datetime.timezone.utc)) changelog.write_text('glibc (' + lock['customVersion'] + ') resolute; urgency=high\n\n' @@ -260,6 +317,8 @@ def build(args, lock): ' * Apply official glibc 2.43 CVE-2026-18374 fix and regression test.\n\n' ' -- PastureStack maintainers ' + date + '\n\n' + changelog.read_text(encoding='utf-8'), encoding='utf-8') + generated_control = prepare_generated_control(source, environment, log) + approved_packaging = COMMON.packaging_snapshot(source) # -G emits custom source provenance plus arch-specific binaries. Original # tarballs stay beside the new DSC; no distro-independent docs are built. for item in lock['sourceFiles']: @@ -268,8 +327,7 @@ def build(args, lock): run(['dpkg-checkbuilddeps', '-B', '-P' + ','.join(lock['buildProfiles'])], source, environment, log) command = ['dpkg-buildpackage', '-G', '-us', '-uc', '-P' + ','.join(lock['buildProfiles']), COMMON.job_option(args.jobs)] run(command, source, environment, log) - require(all((source / name).is_file() and digest(source / name) == sha - for name, sha in approved_packaging.items()), 'Build changed approved Ubuntu packaging inputs') + verify_packaging_inputs(source, approved_packaging) require(source_closure(source) == source_proof, 'Built source no longer matches the approved fix') tests = test_receipt(source) # Return package names from the fixed catalog, never CLI-derived path tokens. @@ -285,6 +343,7 @@ def build(args, lock): shutil.copyfile(files[0], output / files[0].name) for file, name in [(log, 'build.log'), (LOCK_FILE, 'glibc.lock.json'), (source / 'debian/copyright', 'COPYRIGHT'), + (source / 'debian/control', 'generated-debian-control'), (source / tests['report'], 'tests.sum'), (source / 'build-tree/amd64-libc/configparms', 'native-configparms'), (source / 'build-tree/amd64-libc/config.make', 'native-config.make')]: @@ -300,13 +359,14 @@ def build(args, lock): 'sourceDateEpoch': lock['sourceDateEpoch'], 'sourceFiles': lock['sourceFiles'], 'upstream': lock['upstream'], 'patches': lock['patches'], 'fixedCves': lock['fixedCves'], 'debianPackagingInputSha256': packaging, 'approvedPackagingSha256': approved_packaging, + 'generatedControlMetadata': generated_control, 'sourceClosure': source_proof, 'buildEnvironment': environment, 'buildCommand': command, 'dpkgBuildflagsContext': capture(['dpkg-buildflags', '--export=sh'], work, environment), 'actualCompilerConfiguration': ['native-configparms', 'native-config.make', 'build.log'], 'tests': tests, 'runtimePackages': [i for i in selected if i['role'] == 'runtime'], 'compatibilityPackages': [i for i in selected if i['role'] != 'runtime'], 'devPackagePolicy': 'Explicitly requested exports only; consumers may install a dev package only if already present. This helper installs no packages.', - 'scope': 'Native amd64 original Ubuntu packaging; nobiarch/noudeb; full native upstream suite and original expected-failure/ABI/hardening rules.', + 'scope': 'Native amd64 Ubuntu source packaging and original control templates; first-party metadata generated by original rules; nobiarch/noudeb; full native upstream suite and original expected-failure/ABI/hardening rules.', 'signaturePolicy': 'Official input bytes SHA-256 pinned; custom outputs are not issued or signed by Canonical.'} (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(LOCK_FILE) + '\n' + ''.join( diff --git a/server/security/ubuntu-backports/glibc/glibc.lock.json b/server/security/ubuntu-backports/glibc/glibc.lock.json index ea07e421fe..670912dccf 100644 --- a/server/security/ubuntu-backports/glibc/glibc.lock.json +++ b/server/security/ubuntu-backports/glibc/glibc.lock.json @@ -71,8 +71,17 @@ "libc6-dev", "libc-dev-bin" ], + "packagingMetadataPolicy": { + "originalControlSha256": "80ad63c9edb6751e404e3a1b05d453433111b307ec00893380487df0534f6999", + "generatedControlSha256": "a1cca0ba6356e1318f218b05dec1213bae7d43dce2bd79055119d75070a9b85e", + "approvedChangedFields": [ + "Maintainer", + "XSBC-Original-Maintainer" + ], + "scope": "Preserve Ubuntu source packaging and original control templates. First-party metadata is generated by the original make rules; not issued by Canonical. All other packaging inputs and the final generated control remain hash-bound." + }, "fixedCves": [ "CVE-2026-18374" ], - "identity": "First-party PastureStack rebuild of official Ubuntu source with the official glibc 2.43 fix and regression backport; not issued by Canonical." + "identity": "First-party PastureStack rebuild of official Ubuntu source packaging and original control templates with the official glibc 2.43 fix and regression backport; metadata generated by original make rules; not issued by Canonical." } diff --git a/server/security/ubuntu-backports/glibc/test_glibc_backport.py b/server/security/ubuntu-backports/glibc/test_glibc_backport.py index d6b7e36b85..f9510ca943 100644 --- a/server/security/ubuntu-backports/glibc/test_glibc_backport.py +++ b/server/security/ubuntu-backports/glibc/test_glibc_backport.py @@ -147,7 +147,7 @@ def native_fixture(self): directory = self.root / 'build-tree/amd64-libc'; directory.mkdir(parents=True) (self.root / 'stamp-dir').mkdir() (self.root / 'stamp-dir/check_libc_passed').touch() - text = ''.join('PASS: ' + name + '\n' for name in MODULE.CONTROLS) + ''.join( + text = '\t\t=== glibc tests ===\n\nRunning . ...\n\n' + ''.join('PASS: ' + name + '\n' for name in MODULE.CONTROLS) + ''.join( 'PASS: ' + component + '/ordinary-control\n' for component in ['stdio-common', 'iconv', 'wcsmbs', 'malloc', 'elf', 'nptl', 'nss']) text += 'XFAIL: elf/known-failure\nUNSUPPORTED: nss/unsupported-environment\nXPASS: elf/known-xpass\n' @@ -159,6 +159,78 @@ def test_full_native_receipt_preserves_xfail_unsupported_xpass_semantics(self): result = MODULE.test_receipt(self.root) self.assertEqual({'PASS': 10, 'XFAIL': 1, 'UNSUPPORTED': 1, 'XPASS': 1}, result['counts']) + def test_native_summary_requires_canonical_framing_and_rejects_unknown_lines(self): + good = '\t\t=== glibc tests ===\n\nRunning libio ...\nPASS: libio/control\n' + self.assertEqual([('PASS', 'libio/control')], MODULE.parse_test_summary(good)) + for bad in [good.replace('=== glibc tests ===', '=== another suite ==='), + good.replace('Running libio ...', 'Running libio unexpectedly'), + good + 'unexpected diagnostic\n', good + 'Running libio ...\n', + good + 'SKIP: libio/unknown-state\n', 'PASS: libio/control\n']: + with self.subTest(summary=bad), self.assertRaises(ValueError): MODULE.parse_test_summary(bad) + + def packaging_fixture(self): + debian = self.root / 'debian' + (debian / 'control.in').mkdir(parents=True) + (debian / 'rules.d').mkdir() + for name, text in [('control', 'Maintainer: Ubuntu Developers\n'), + ('control.in/main', 'Maintainer: GNU Libc Maintainers\n'), + ('rules', 'original rules\n'), ('rules.d/control.mk', 'original control generator\n')]: + (debian / name).write_text(text, encoding='utf-8') + return debian + + def test_original_control_metadata_remains_bound_to_the_packaging_snapshot(self): + debian = self.packaging_fixture() + approved = MODULE.COMMON.packaging_snapshot(self.root) + MODULE.verify_packaging_inputs(self.root, approved) + (debian / 'control').write_bytes((debian / 'control.in/main').read_bytes()) + with self.assertRaisesRegex(ValueError, 'debian/control'): + MODULE.verify_packaging_inputs(self.root, approved) + + def test_generated_control_only_accepts_the_approved_original_template_bytes(self): + debian = self.packaging_fixture() + original = MODULE.digest(debian / 'control') + generated = MODULE.digest(debian / 'control.in/main') + def generate(*_): + (debian / 'control').write_bytes((debian / 'control.in/main').read_bytes()) + with mock.patch.object(MODULE, 'CONTROL_ORIGINAL_SHA256', original), \ + mock.patch.object(MODULE, 'CONTROL_GENERATED_SHA256', generated), \ + mock.patch.object(MODULE, 'run', side_effect=generate): + receipt = MODULE.prepare_generated_control(self.root, {}, self.root / 'build.log') + self.assertEqual(original, receipt['originalSha256']) + self.assertEqual(generated, receipt['generatedSha256']) + self.assertIn('debian/control.in/main', receipt['inputSha256']) + approved = MODULE.COMMON.packaging_snapshot(self.root) + MODULE.verify_packaging_inputs(self.root, approved) + with self.assertRaisesRegex(ValueError, 'Fresh original'): + MODULE.prepare_generated_control(self.root, {}, self.root / 'build.log') + + def test_control_generation_rejects_extra_field_or_packaging_changes(self): + debian = self.packaging_fixture() + original_bytes = (debian / 'control').read_bytes() + original, generated = MODULE.digest(debian / 'control'), MODULE.digest(debian / 'control.in/main') + for change_other in [False, True]: + (debian / 'control').write_bytes(original_bytes) + def generate(*_): + (debian / 'control').write_bytes((debian / 'control.in/main').read_bytes()) + if change_other: + (debian / 'rules').write_text('changed rules\n', encoding='utf-8') + else: + with (debian / 'control').open('a', encoding='utf-8') as stream: + stream.write('Depends: unapproved\n') + with mock.patch.object(MODULE, 'CONTROL_ORIGINAL_SHA256', original), \ + mock.patch.object(MODULE, 'CONTROL_GENERATED_SHA256', generated), \ + mock.patch.object(MODULE, 'run', side_effect=generate), \ + self.assertRaisesRegex(ValueError, 'debian/rules|approved original-template'): + MODULE.prepare_generated_control(self.root, {}, self.root / 'build.log') + + def test_generated_control_policy_cannot_be_widened(self): + for field, value in [('originalControlSha256', '0' * 64), + ('generatedControlSha256', '0' * 64), + ('approvedChangedFields', ['Maintainer', 'Depends'])]: + lock = copy.deepcopy(self.lock) + lock['packagingMetadataPolicy'][field] = value + with self.subTest(field=field), self.assertRaises(ValueError): self.load(lock) + def test_native_failure_skipped_cve_component_or_extra_abi_fails(self): file, text = self.native_fixture() for bad in [text + 'FAIL: libio/surviving-bug\n', text + 'ERROR: elf/error\n', diff --git a/server/security/ubuntu-backports/tar/build-tar-backport.py b/server/security/ubuntu-backports/tar/build-tar-backport.py deleted file mode 100644 index ac73c002c0..0000000000 --- a/server/security/ubuntu-backports/tar/build-tar-backport.py +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 -"""First-party Ubuntu tar 1.35 CVE-2026-18477 backport; native CI only. - -Keep original Ubuntu packaging and full configured Autotest suite. The complete -released vendor patch is retained; only its NEWS documentation section is omitted -from application. CVE-2026-18508 is tracked and has no patch in this module. -""" -import argparse -import collections -import datetime -import email.utils -import importlib.util -import json -import os -from pathlib import Path -import re -import shutil -import subprocess -import sys -import tempfile - -HERE = Path(__file__).resolve().parent -LOCK_FILE = HERE / 'tar.lock.json' -SPEC = importlib.util.spec_from_file_location('tar_backport_common', HERE.parent / 'build-pcre2-backport.py') -COMMON = importlib.util.module_from_spec(SPEC) -_bytecode = sys.dont_write_bytecode -sys.dont_write_bytecode = True -try: - SPEC.loader.exec_module(COMMON) -finally: - sys.dont_write_bytecode = _bytecode -require, digest, run, capture = COMMON.require, COMMON.digest, COMMON.run, COMMON.capture -VERSION = '1.35+dfsg-4ubuntu0.4' -VENDOR = '60b4d64ef2d933d3c14cbf8844268e88a90dc69f' -PATCH_NAME = 'tar-1.35-CVE-2026-18477.patch' -PATCH_SHA = 'aea389d8ca495bc0474c00a339f137c00e159ffc28caed65b02823be4f5046af' -DERIVED_SHA = 'd8190f7ff8faa6c6fbe0f3df4bb464d440b9af6cdd678049a7a4dbbb98bf62c6' -DERIVED_NAME = 'tar-18477-ubuntu-doc-context.patch' -APPLIED = ['THANKS', 'lib/Makefile.am', 'lib/mkdtempat.c', 'lib/mkdtempat.h', - 'src/incremen.c', 'tests/Makefile.am', 'tests/rename08.at', - 'tests/rename09.at', 'tests/testsuite.at'] -PINS = {'tar_' + VERSION + '.dsc': ('e42458c1675ec7ba43cca2ea8e085231337e7a4553d7c6e2c21d2003761e5d43', 2124), - 'tar_1.35+dfsg.orig.tar.xz': ('9ae57e981c1e73c0eebc2b26c9b0c4497fe310ef1d516ea430efb5470b71f7a8', 2111608), - 'tar_' + VERSION + '.debian.tar.xz': ('25d8e50e31629e85baef5da32b3030e5ad0c9148ab3ee6b1428212a10e307e4d', 52824)} - - -def load_lock(file=LOCK_FILE): - lock = json.loads(file.read_text(encoding='utf-8')) - expected = {'schemaVersion': 1, 'sourcePackage': 'tar', 'ubuntuSourceVersion': VERSION, - 'customVersion': VERSION + '+pasturestack.1', 'distribution': 'resolute', - 'osVersion': '26.04', 'architecture': 'amd64', 'runtimePackages': ['tar'], - 'fixedCves': ['CVE-2026-18477']} - require(all(lock.get(k) == v for k, v in expected.items()), 'Unsupported tar source/output identity') - require(type(lock['sourceDateEpoch']) is int and lock['sourceDateEpoch'] > 0, 'Invalid source date') - require(len(lock['sourceFiles']) == 3 and {i['name'] for i in lock['sourceFiles']} == set(PINS), - 'Exactly the original Ubuntu DSC/orig/packaging are required') - for item in lock['sourceFiles']: - require(item['url'] == 'https://archive.ubuntu.com/ubuntu/pool/main/t/tar/' + item['name'] - and (item['sha256'], item['size']) == PINS[item['name']] - and type(item['size']) is int, 'Unexpected Ubuntu source URL/hash/size') - require(len(lock['patches']) == 1, 'Only the released 18477 vendor backport is supported') - patch = lock['patches'][0] - require(patch['name'] == PATCH_NAME and patch['sha256'] == PATCH_SHA and patch['size'] == 11055 - and patch['url'] == 'https://gitlab.com/redhat/centos-stream/rpms/tar/-/raw/' + VENDOR + '/' + PATCH_NAME - and patch['excludedDocumentationPaths'] == ['NEWS'] - and patch['derivedPatchSha256'] == DERIVED_SHA and patch['appliedPaths'] == APPLIED, - 'Unexpected vendor patch, documentation exclusion or derived bytes') - require(patch['upstreamAcceptedCommits'] == ['0714d2f082104005a1c70ee6ec4175194943ea88', - 'd479b2cc9160d9c2fb61afbc9ee70c2faadf80db', 'b17665b2c0548c77b6cd8d2d5b61e4c4fcc4f770'], - 'Unexpected accepted upstream commits') - require(lock['vendorBackport']['distGitCommit'] == VENDOR - and lock['vendorBackport']['version'] == '2:1.35-13.el10' - and lock['prerequisites']['additionalPatches'] == [] - and lock['prerequisites']['no18508PrerequisitesApplied'] is True - and [i['id'] for i in lock['blockedCves']] == ['CVE-2026-18508'], 'Unexpected vendor/prerequisite scope') - require(set(lock['patchedSha256']) == set(APPLIED), 'Incomplete applied-source closure') - for name, sha in {**lock['baselineSha256'], **lock['patchedSha256']}.items(): - require(not Path(name).is_absolute() and '..' not in Path(name).parts - and re.fullmatch('[0-9a-f]{64}', sha), 'Unsafe source proof entry') - return lock - - -def verify_source_control(lock, directory): - text = (directory / ('tar_' + VERSION + '.dsc')).read_text(encoding='utf-8') - require(all(re.search('^' + re.escape(line) + '$', text, re.M) for line in - ['Format: 3.0 (quilt)', 'Source: tar', 'Version: ' + VERSION]), 'Wrong Ubuntu source control') - block = re.search(r'^Checksums-Sha256:\n((?: .+\n)+)', text, re.M) - require(block, 'Missing source checksum manifest') - rows = re.findall(r'^ ([0-9a-f]{64}) (\d+) (\S+)$', block[1], re.M) - require(len(rows) == 2 and {n: (s, int(z)) for s, z, n in rows} - == {i['name']: (i['sha256'], i['size']) for i in lock['sourceFiles'] if not i['name'].endswith('.dsc')}, - 'DSC/source lock mismatch') - - -def derived_patch(lock, file): - COMMON.verify_input(lock['patches'][0], file) - data = file.read_bytes() - blocks = re.findall(rb'(?ms)^diff --git .*?(?=^diff --git |\Z)', data) - paths = [re.match(rb'diff --git a/(\S+) b/(\S+)\n', b).groups() for b in blocks] - require(paths == [(p.encode(), p.encode()) for p in ['NEWS'] + APPLIED], 'Unexpected vendor patch paths/order') - # Entire diff sections are copied unchanged. No code/test hunk is edited, - # dropped, rebased or fuzzed; existing Ubuntu NEWS remains in place. - result = b''.join(blocks[1:]) - import hashlib - require(hashlib.sha256(result).hexdigest() == DERIVED_SHA, 'Derived patch bytes changed') - return result - - -def validate_inputs(lock, directory): - for item in lock['sourceFiles'] + lock['patches']: - COMMON.verify_input(item, directory / item['name']) - verify_source_control(lock, directory) - derived_patch(lock, directory / PATCH_NAME) - - -def verify_sources(source, expected): - for name, sha in expected.items(): - file = source / name - require(file.is_file(), 'Missing source/prerequisite file: ' + name) - actual = digest(file) - require(actual == sha, 'Source/prerequisite SHA-256 mismatch: ' + name - + ' (expected=' + sha + ', actual=' + actual + ')') - - -def verify_series(original, updated): - lines = updated.splitlines(keepends=True) - added = [line for line in lines if line.strip() == DERIVED_NAME] - require(len(added) == 1 and ''.join(line for line in lines if line.strip() != DERIVED_NAME) == original, - 'Existing quilt series bytes changed') - active = lambda text: [line.strip() for line in text.splitlines() if line.strip() and not line.lstrip().startswith('#')] - require(active(updated) == active(original) + [DERIVED_NAME], 'Existing quilt patch order changed') - - -def build_environment(lock, jobs): - COMMON.job_option(jobs) - env = COMMON.build_environment(lock, jobs) - # Preserve Ubuntu's own future=+lfs and compiler/hardening policy in debian/rules. - env.pop('DEB_BUILD_MAINT_OPTIONS', None) - return env - - -def test_receipt(source): - script = source / 'tests/testsuite' - log = source / 'tests/testsuite.log' - require(script.is_file() and log.is_file(), 'Missing full native Autotest script/log') - expected = dict(re.findall(r'^(\d+);([^;]+\.at):\d+;', script.read_text(encoding='utf-8'), re.M)) - rows = re.findall(r'^(\d+)\. .*? \(([^()]+\.at):\d+\): (.+)$', log.read_text(encoding='utf-8', errors='replace'), re.M) - require(len(expected) > 200 and len(rows) == len(expected) and len({i for i, _, _ in rows}) == len(rows), - 'Configured native suite was missing, filtered or ambiguous') - actual, counts = {}, collections.Counter() - for number, name, text in rows: - require(expected.get(number) == name, 'Autotest identity mismatch') - if text.startswith('ok'): - state = 'PASS' - elif text.startswith('skipped ('): - state = 'SKIP' - elif text.startswith('expected failure ('): - state = 'XFAIL' - else: - raise ValueError('Unexpected native result: ' + text) - actual[number] = state - counts[state] += 1 - controls = {} - for name in ['rename08.at', 'rename09.at']: - matches = [i for i, f in expected.items() if f == name] - require(len(matches) == 1 and actual[matches[0]] == 'PASS', 'CVE regression did not PASS: ' + name) - controls[name] = matches[0] - return {'configuredGroups': len(expected), 'counts': dict(counts), 'regressionGroups': controls, - 'logSha256': digest(log), 'suiteSha256': digest(script), 'addedSkips': 0, - 'scope': 'All groups in the original Ubuntu configured suite; original SKIP/XFAIL are recorded. Optional external check-full corpus is not enabled by original packaging.'} - - -def tar_smoke(binary, work, env, log): - require(binary.is_file(), 'Packaged tar is missing') - directory = work / 'tar-smoke' - source, restored = directory / 'source', directory / 'restored' - tree = source / 'tree'; (tree / 'dir').mkdir(parents=True) - restored.mkdir() - payload = tree / 'dir/file'; payload.write_bytes(b'first-party tar roundtrip\n') - os.link(payload, tree / 'hard') - os.symlink('dir/file', tree / 'relative-link') - archive, delta, snapshot = directory / 'base.tar', directory / 'delta.tar', directory / 'state.snar' - run([str(binary), '--create', '--listed-incremental=' + str(snapshot), '--file', str(archive), - '--directory', str(source), 'tree'], work, env, log) - listing = capture([str(binary), '--list', '--file', str(archive)], work, env) - require(all(n in listing.splitlines() for n in ['tree/dir/file', 'tree/hard', 'tree/relative-link']), 'Archive listing changed') - run([str(binary), '--extract', '--listed-incremental=/dev/null', '--file', str(archive), - '--directory', str(restored)], work, env, log) - require((restored / 'tree/dir/file').read_bytes() == payload.read_bytes() - and os.readlink(restored / 'tree/relative-link') == 'dir/file' - and os.stat(restored / 'tree/hard').st_ino == os.stat(restored / 'tree/dir/file').st_ino, - 'Extracted file/link semantics changed') - (tree / 'dir').rename(tree / 'moved') - (tree / 'moved/file').write_bytes(b'incremental rename roundtrip\n') - run([str(binary), '--create', '--listed-incremental=' + str(snapshot), '--file', str(delta), - '--directory', str(source), 'tree'], work, env, log) - run([str(binary), '--extract', '--listed-incremental=/dev/null', '--file', str(delta), - '--directory', str(restored)], work, env, log) - require((restored / 'tree/moved/file').read_bytes() == (tree / 'moved/file').read_bytes() - and not (restored / 'tree/dir').exists(), 'Incremental directory rename semantics changed') - return {'status': 'PASS', 'checks': ['create/list/extract', 'hardlink inode', 'relative symlink', - 'incremental directory rename/restore'], 'binarySha256': digest(binary), - 'version': capture([str(binary), '--version'], work, env).splitlines()[0]} - - -def build(args, lock): - release = dict(re.findall(r'^(\w+)=["\']?([^"\'\n]+)', Path('/etc/os-release').read_text(encoding='utf-8'), re.M)) - require(release.get('ID') == 'ubuntu' and release.get('VERSION_ID') == '26.04', 'Use an isolated Ubuntu 26.04 builder') - require(os.geteuid() != 0, 'Original Ubuntu tar full tests require a non-root builder') - env = build_environment(lock, args.jobs) - for tool in ['dpkg', 'dpkg-source', 'dpkg-buildpackage', 'dpkg-checkbuilddeps', 'dpkg-deb', - 'dpkg-query', 'quilt', 'readelf', 'fakeroot']: - require(shutil.which(tool, path=env['PATH']), 'Missing builder tool: ' + tool) - require(capture(['dpkg', '--print-architecture'], HERE, env) == 'amd64', 'Native amd64 required') - output = args.output_dir.resolve(); cache = args.cache_dir.resolve() if args.cache_dir else None - require(not output.exists(), 'Output directory must be new') - COMMON.require_separate_paths(output, args.work_dir.resolve() if args.work_dir else None, cache) - work = args.work_dir.resolve() if args.work_dir else Path(tempfile.mkdtemp(prefix='tar-backport-')) - if args.work_dir: work.mkdir(parents=True, exist_ok=False) - print('TAR_BACKPORT_WORK_DIR=' + str(work), flush=True) - inputs = work / 'inputs'; inputs.mkdir() - if cache: cache.mkdir(parents=True, exist_ok=True) - for item in lock['sourceFiles'] + lock['patches']: COMMON.download(item, inputs, cache) - validate_inputs(lock, inputs) - patch = inputs / DERIVED_NAME; patch.write_bytes(derived_patch(lock, inputs / PATCH_NAME)) - source, log = work / 'source', work / 'build.log' - run(['dpkg-source', '--extract', str(inputs / ('tar_' + VERSION + '.dsc')), str(source)], work, env, log) - verify_sources(source, lock['baselineSha256']) - packaging = COMMON.packaging_snapshot(source) - news = digest(source / 'NEWS') - original_series = (source / 'debian/patches/series').read_text(encoding='utf-8') - env['QUILT_PATCHES'] = 'debian/patches' - run(['quilt', 'import', '-P', DERIVED_NAME, str(patch)], source, env, log) - run(['quilt', 'push', '--fuzz=0'], source, env, log) - verify_sources(source, lock['patchedSha256']) - require(digest(source / 'NEWS') == news, 'Existing Ubuntu NEWS changed') - require(all(digest(source / n) == sha for n, sha in packaging.items() if n != 'debian/patches/series'), - 'Original packaging inputs changed') - verify_series(original_series, (source / 'debian/patches/series').read_text(encoding='utf-8')) - approved = COMMON.packaging_snapshot(source) - changelog = source / 'debian/changelog' - date = email.utils.format_datetime(datetime.datetime.fromtimestamp(lock['sourceDateEpoch'], datetime.timezone.utc)) - changelog.write_text('tar (' + lock['customVersion'] + ') resolute; urgency=high\n\n' - ' * First-party rebuild, not issued by Canonical or Red Hat.\n' - ' * Apply released CVE-2026-18477 source/tests; preserve Ubuntu NEWS.\n' - ' * CVE-2026-18508 remains tracked and is not patched here.\n\n' - ' -- PastureStack maintainers ' + date + '\n\n' - + changelog.read_text(encoding='utf-8'), encoding='utf-8') - run(['dpkg-checkbuilddeps'], source, env, log) - command = ['dpkg-buildpackage', '-b', '-us', '-uc', COMMON.job_option(args.jobs)] - run(command, source, env, log) - verify_sources(source, approved); verify_sources(source, lock['patchedSha256']) - tests = test_receipt(source) - matches = list(work.glob('tar_' + lock['customVersion'] + '_amd64.deb')) - require(len(matches) == 1, 'Missing/ambiguous selected runtime tar package') - deb = matches[0] - for field, value in [('Package', 'tar'), ('Version', lock['customVersion']), ('Architecture', 'amd64')]: - require(COMMON.deb_field(deb, field, work, env) == value, 'Wrong package ' + field) - # Debian permits omission when binary and source names/versions are equal; - # the exact Package/Version above and original DSC already bind this case. - require(COMMON.deb_field(deb, 'Source', work, env) in ['', 'tar', 'tar (' + lock['customVersion'] + ')'], 'Wrong Source identity') - extracted = work / 'extracted' - run(['dpkg-deb', '--extract', str(deb), str(extracted)], work, env, log) - candidates = [extracted / n for n in ['usr/bin/tar', 'bin/tar'] if (extracted / n).is_file()] - require(len(candidates) == 1 and candidates[0].resolve().is_relative_to(extracted.resolve()), 'Ambiguous/unsafe packaged binary') - binary = candidates[0] - header = capture(['readelf', '--file-header', str(binary)], work, env) - program = capture(['readelf', '--program-headers', str(binary)], work, env) - dynamic = capture(['readelf', '--dynamic', str(binary)], work, env) - require('ELF64' in header and 'X86-64' in header.upper() and 'DYN' in header and 'GNU_RELRO' in program, - 'Native ELF/PIE/RELRO baseline changed') - smoke = tar_smoke(binary, work, env, log) - require(smoke['version'] == 'tar (GNU tar) 1.35', 'Unexpected GNU tar version') - require(digest(extracted / 'usr/share/doc/tar/copyright') == digest(source / 'debian/copyright'), - 'Packaged copyright differs from the original Ubuntu source') - output.mkdir(parents=True) - for f, name in [(deb, deb.name), (log, 'build.log'), (LOCK_FILE, 'tar.lock.json'), - (inputs / PATCH_NAME, PATCH_NAME), (patch, DERIVED_NAME), - (source / 'tests/testsuite.log', 'testsuite.log'), (source / 'config.log', 'config.log'), - (source / 'debian/copyright', 'COPYRIGHT')]: shutil.copyfile(f, output / name) - for pattern in ['tar_*.buildinfo', 'tar_*.changes']: - files = list(work.glob(pattern)); require(len(files) == 1, 'Missing native build receipt') - shutil.copyfile(files[0], output / files[0].name) - selected = {'package': 'tar', 'version': lock['customVersion'], 'architecture': 'amd64', 'file': deb.name, 'sha256': digest(deb)} - (output / 'builder-packages.tsv').write_text(capture( - ['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\n'], work, env) + '\n', encoding='utf-8') - provenance = {'schemaVersion': 1, 'identity': lock['identity'], 'sourceLockSha256': digest(LOCK_FILE), - 'sourceDateEpoch': lock['sourceDateEpoch'], - 'sourceFiles': lock['sourceFiles'], 'vendorBackport': lock['vendorBackport'], 'patches': lock['patches'], - 'documentationPolicy': lock['documentationPolicy'], 'fixedCves': lock['fixedCves'], - 'blockedCves': lock['blockedCves'], 'prerequisites': lock['prerequisites'], - 'debianPackagingInputSha256': packaging, 'approvedPackagingSha256': approved, - 'patchedSourceSha256': lock['patchedSha256'], 'buildCommand': command, 'buildEnvironment': env, - 'tests': tests, 'tarSmoke': smoke, 'runtimePackages': [selected], - 'hardening': {'PIE': True, 'RELRO': True, 'BIND_NOW': 'BIND_NOW' in dynamic or bool(re.search(r'FLAGS_1.*NOW', dynamic)), - 'policy': 'Original Ubuntu debian/rules, compiler flags and ABI retained; no new hardening flags or test exceptions.'}, - 'signaturePolicy': 'Official source SHA pins; custom output is not issued or signed by Canonical/Red Hat.', - 'runtimeInstalled': False} - (output / 'tar_smoke.json').write_text(json.dumps(smoke, indent=2) + '\n', encoding='utf-8') - (output / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n', encoding='utf-8') - (output / 'manifest.tsv').write_text('lock-sha256\t' + digest(LOCK_FILE) + '\n' + '\t'.join( - [selected[k] for k in ['package', 'version', 'architecture', 'sha256', 'file']]) + '\n', encoding='utf-8') - (output / 'SHA256SUMS').write_text(''.join(digest(f) + ' ' + f.name + '\n' for f in sorted(output.iterdir()) if f.is_file()), encoding='utf-8') - print('TAR_BACKPORT_BUILT packages=1 fixedCves=CVE-2026-18477 lock=' + digest(LOCK_FILE), flush=True) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--validate-lock', action='store_true') - parser.add_argument('--output-dir', type=Path) - parser.add_argument('--work-dir', type=Path) - parser.add_argument('--cache-dir', type=Path) - parser.add_argument('--jobs', type=int, choices=(2, 4), default=2) - args = parser.parse_args() - lock = load_lock(LOCK_FILE) - if args.validate_lock: print('TAR_BACKPORT_LOCK_OK sha256=' + digest(LOCK_FILE)) - else: - require(args.output_dir is not None, '--output-dir is required') - build(args, lock) - - -if __name__ == '__main__': - try: - main() - except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: - print('TAR_BACKPORT_FAILED: ' + str(error), file=sys.stderr) - sys.exit(1) diff --git a/server/security/ubuntu-backports/tar/tar.lock.json b/server/security/ubuntu-backports/tar/tar.lock.json deleted file mode 100644 index 90bba772e1..0000000000 --- a/server/security/ubuntu-backports/tar/tar.lock.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "schemaVersion": 1, - "sourcePackage": "tar", - "ubuntuSourceVersion": "1.35+dfsg-4ubuntu0.4", - "customVersion": "1.35+dfsg-4ubuntu0.4+pasturestack.1", - "distribution": "resolute", - "osVersion": "26.04", - "architecture": "amd64", - "sourceDateEpoch": 1792886400, - "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18633881", - "sourceFiles": [ - { - "name": "tar_1.35+dfsg-4ubuntu0.4.dsc", - "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg-4ubuntu0.4.dsc", - "sha256": "e42458c1675ec7ba43cca2ea8e085231337e7a4553d7c6e2c21d2003761e5d43", - "size": 2124 - }, - { - "name": "tar_1.35+dfsg.orig.tar.xz", - "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg.orig.tar.xz", - "sha256": "9ae57e981c1e73c0eebc2b26c9b0c4497fe310ef1d516ea430efb5470b71f7a8", - "size": 2111608 - }, - { - "name": "tar_1.35+dfsg-4ubuntu0.4.debian.tar.xz", - "url": "https://archive.ubuntu.com/ubuntu/pool/main/t/tar/tar_1.35+dfsg-4ubuntu0.4.debian.tar.xz", - "sha256": "25d8e50e31629e85baef5da32b3030e5ad0c9148ab3ee6b1428212a10e307e4d", - "size": 52824 - } - ], - "patches": [ - { - "name": "tar-1.35-CVE-2026-18477.patch", - "url": "https://gitlab.com/redhat/centos-stream/rpms/tar/-/raw/60b4d64ef2d933d3c14cbf8844268e88a90dc69f/tar-1.35-CVE-2026-18477.patch", - "sha256": "aea389d8ca495bc0474c00a339f137c00e159ffc28caed65b02823be4f5046af", - "size": 11055, - "upstreamAcceptedCommits": [ - "0714d2f082104005a1c70ee6ec4175194943ea88", - "d479b2cc9160d9c2fb61afbc9ee70c2faadf80db", - "b17665b2c0548c77b6cd8d2d5b61e4c4fcc4f770" - ], - "excludedDocumentationPaths": [ - "NEWS" - ], - "derivedPatchSha256": "d8190f7ff8faa6c6fbe0f3df4bb464d440b9af6cdd678049a7a4dbbb98bf62c6", - "appliedPaths": [ - "THANKS", - "lib/Makefile.am", - "lib/mkdtempat.c", - "lib/mkdtempat.h", - "src/incremen.c", - "tests/Makefile.am", - "tests/rename08.at", - "tests/rename09.at", - "tests/testsuite.at" - ] - } - ], - "vendorBackport": { - "publisher": "CentOS Stream", - "version": "2:1.35-13.el10", - "distGitCommit": "60b4d64ef2d933d3c14cbf8844268e88a90dc69f", - "sourceRpmUrl": "https://kojihub.stream.centos.org/kojifiles/packages/tar/1.35/13.el10/src/tar-1.35-13.el10.src.rpm", - "sourceRpmSha256": "c15f522fd0000faf1b1e3c719198aab0262e9404be54d31c60c624f910460e58", - "patchByteComparison": "Full official patch bytes matched the published source RPM payload; no RPM installation. This is not the distinct RHEL el10_2 SRPM." - }, - "prerequisites": { - "additionalPatches": [], - "alreadyPresentInUbuntuSource": [ - "fdbase and confined directory operations from original Ubuntu CVE-2025-45582 stack", - "gnulib tempname.c/try_tempname_len and tempname module" - ], - "no18508PrerequisitesApplied": true - }, - "baselineSha256": { - "gnu/tempname.c": "8701d79ced94ba0bff60607d36583d23140cdf1e18e9f8d883a23508275f471c", - "gnu/tempname.h": "45167ab628da8c3f21050680d5f09fc3fcd7acffff7487a031cfe0fb36f4d870", - "gnu/Makefile.am": "1833f9bcec8bc9652c8de73a526c9a327170976f6132f7e236860e1599a6a3dd", - "src/common.h": "69f61666b788e52981ccc1e9428c2c95b0cb4f2c6335a687bc1312cd5e783012", - "lib/Makefile.am": "4bed9bf65e2eb453cdc0dade993b9e630f71f2015240d9074742f1f60db11aef", - "src/incremen.c": "7c7ad665e4a2bcf40c93c57721d9ddecb48d881060d51dc35174fa46d79c6a68" - }, - "patchedSha256": { - "THANKS": "9b195c212a0940e19dbb2adb0d9bdff5df0e91a5c740d06f64dd139de753ee4d", - "lib/Makefile.am": "0e652fa024b047f6281aac30602d710e55951e643de3fef34c6af5ce650b75ff", - "lib/mkdtempat.c": "d55595c1c5d64f1f4820efd46bdc0813927833b27581ee8ece98af1703d17973", - "lib/mkdtempat.h": "e660013c2fc5d483f637d33fdfb60c7c10c52fd6407623daf56a00d5deacf4e3", - "src/incremen.c": "03984c0d771b5b145a2e1515ef063ef2666f5a19050ed9af8e76954d8670b324", - "tests/Makefile.am": "e88d326b839d64b54a99e913a3ef73cc45d32202b87e3e0efc9776d6e06ee849", - "tests/rename08.at": "da3b6e0ed0ea29d1e58017df21393de2b443908b510725afe20ffbaaef0980b3", - "tests/rename09.at": "a3924a039f6f158a1b7075bcc78dea25eede1e03483ef9af0601926abdba28e0", - "tests/testsuite.at": "36e033c4bc75260f790c3bcaa717d8a6aa422e7d875bff1f41786b325f493d99" - }, - "runtimePackages": [ - "tar" - ], - "fixedCves": [ - "CVE-2026-18477" - ], - "blockedCves": [ - { - "id": "CVE-2026-18508", - "status": "tracking", - "reason": "Published one-top-level stack requires additional structural/API compatibility work for this Ubuntu source; no patch for this CVE is included." - } - ], - "identity": "First-party PastureStack rebuild of exact Ubuntu source with a released CentOS Stream upstream-accepted source backport; not issued by Canonical or Red Hat.", - "documentationPolicy": "Keep the full vendor patch and its SHA-256. Remove only the complete NEWS diff section when constructing the Ubuntu patch; preserve existing Ubuntu NEWS. All source/test hunks and THANKS remain byte-for-byte unchanged." -} diff --git a/server/security/ubuntu-backports/tar/test_tar_backport.py b/server/security/ubuntu-backports/tar/test_tar_backport.py deleted file mode 100644 index d4d1ae29b9..0000000000 --- a/server/security/ubuntu-backports/tar/test_tar_backport.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Focused network-free tar backport contracts; no native compilation.""" -import copy -import hashlib -import importlib.util -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock -import urllib.request - -HERE = Path(__file__).resolve().parent -sys.dont_write_bytecode = True -SPEC = importlib.util.spec_from_file_location('tar_backport', HERE / 'build-tar-backport.py') -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class TarBackport(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory(prefix='tar-backport-contract-') - self.addCleanup(self.temp.cleanup) - self.root = Path(self.temp.name) - self.lock = MODULE.load_lock() - - def load(self, lock): - file = self.root / 'fixture.json'; file.write_text(json.dumps(lock), encoding='utf-8') - return MODULE.load_lock(file) - - def test_fixed_source_and_single_cve_scope(self): - self.assertEqual(['CVE-2026-18477'], self.lock['fixedCves']) - self.assertEqual(['CVE-2026-18508'], [x['id'] for x in self.lock['blockedCves']]) - self.assertEqual([], self.lock['prerequisites']['additionalPatches']) - self.assertEqual(['NEWS'], self.lock['patches'][0]['excludedDocumentationPaths']) - - def test_foreign_version_architecture_or_cve_scope_fails(self): - for field, value in [('sourcePackage', 'gtar'), ('ubuntuSourceVersion', '1.35-13.el10'), - ('customVersion', MODULE.VERSION), ('architecture', 'arm64'), - ('runtimePackages', ['tar', 'tar-scripts']), - ('fixedCves', ['CVE-2026-18477', 'CVE-2026-18508']), ('sourceDateEpoch', True)]: - value_lock = copy.deepcopy(self.lock); value_lock[field] = value - with self.subTest(field=field), self.assertRaises(ValueError): self.load(value_lock) - - def test_source_set_url_hash_length_are_fixed(self): - for mutation in ['missing', 'duplicate', 'url', 'sha256', 'size']: - lock = copy.deepcopy(self.lock) - if mutation == 'missing': lock['sourceFiles'].pop() - elif mutation == 'duplicate': lock['sourceFiles'][1] = lock['sourceFiles'][0] - else: lock['sourceFiles'][0][mutation] = {'url': 'https://example.invalid/tar', 'sha256': '0' * 64, 'size': 1}[mutation] - with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) - - def test_only_full_accepted_released_vendor_patch_is_allowed(self): - for mutation in ['extra', 'url', 'sha256', 'commit', 'prerequisite']: - lock = copy.deepcopy(self.lock) - if mutation == 'extra': lock['patches'].append(copy.deepcopy(lock['patches'][0])) - if mutation == 'url': lock['patches'][0]['url'] = lock['patches'][0]['url'].replace(MODULE.VENDOR, 'rawhide') - if mutation == 'sha256': lock['patches'][0]['sha256'] = '0' * 64 - if mutation == 'commit': lock['patches'][0]['upstreamAcceptedCommits'][0] = '0' * 40 - if mutation == 'prerequisite': lock['prerequisites']['additionalPatches'] = ['18508-api.patch'] - with self.subTest(mutation=mutation), self.assertRaises(ValueError): self.load(lock) - - def test_code_test_or_thanks_exclusions_are_rejected(self): - for path in ['src/incremen.c', 'tests/rename08.at', 'THANKS', 'doc/tar.texi']: - lock = copy.deepcopy(self.lock); lock['patches'][0]['excludedDocumentationPaths'].append(path) - with self.subTest(path=path), self.assertRaises(ValueError): self.load(lock) - - def test_derived_patch_preserves_all_non_news_diff_bytes(self): - blocks = [('diff --git a/{0} b/{0}\n--- a/{0}\n+++ b/{0}\n@@ -1 +1 @@\n-old {0}\n+new {0}\n'.format(n)).encode() - for n in ['NEWS'] + MODULE.APPLIED] - file = self.root / MODULE.PATCH_NAME; file.write_bytes(b''.join(blocks)) - expected = b''.join(blocks[1:]) - with mock.patch.object(MODULE.COMMON, 'verify_input'), mock.patch.object(MODULE, 'DERIVED_SHA', hashlib.sha256(expected).hexdigest()): - self.assertEqual(expected, MODULE.derived_patch(self.lock, file)) - file.write_bytes(b''.join(blocks[:-1])) - with self.assertRaises(ValueError): MODULE.derived_patch(self.lock, file) - - def test_original_patch_tamper_fails_before_document_filtering(self): - file = self.root / MODULE.PATCH_NAME; file.write_bytes(b'diff --git a/NEWS b/NEWS\n') - with self.assertRaises(ValueError): MODULE.derived_patch(self.lock, file) - - def test_dsc_binds_original_source_and_packaging(self): - text = 'Format: 3.0 (quilt)\nSource: tar\nVersion: ' + MODULE.VERSION + '\nChecksums-Sha256:\n' + ''.join( - ' {sha256} {size} {name}\n'.format(**i) for i in self.lock['sourceFiles'][1:]) - file = self.root / ('tar_' + MODULE.VERSION + '.dsc') - file.write_text(text, encoding='utf-8'); MODULE.verify_source_control(self.lock, self.root) - for bad in [text.replace('Source: tar', 'Source: foreign'), text.replace('3.0 (quilt)', '1.0'), - text.replace(MODULE.VERSION, '1.35-13.el10'), text.rsplit('\n', 2)[0] + '\n', - text + text.splitlines()[-1] + '\n', text.replace(self.lock['sourceFiles'][1]['sha256'], '0' * 64)]: - file.write_text(bad, encoding='utf-8') - with self.assertRaises(ValueError): MODULE.verify_source_control(self.lock, self.root) - - def test_poisoned_cache_fails_without_fetching_replacement(self): - cache, output = self.root / 'cache', self.root / 'out'; cache.mkdir(); output.mkdir() - data = b'official bytes'; file = cache / 'fixture.patch'; file.write_bytes(data) - item = {'name': file.name, 'url': 'https://example.invalid/input', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()} - with mock.patch.object(urllib.request, 'build_opener') as network: - MODULE.COMMON.download(item, output, cache) - file.write_bytes(data + b'tamper') - with self.assertRaises(ValueError): MODULE.COMMON.download(item, output, cache) - network.assert_not_called() - - def test_environment_cannot_filter_tests_or_override_compilers(self): - with mock.patch.dict(os.environ, {'DEB_BUILD_OPTIONS': 'nocheck', 'TESTSUITEFLAGS': '1', 'FULL_TEST': '1', - 'TARTEST_SKIP_LARGE_FILES': '1', 'CC': 'unsafe', 'CFLAGS': '-O0'}): - env = MODULE.build_environment(self.lock, 2) - self.assertEqual('parallel=2', env['DEB_BUILD_OPTIONS']) - self.assertEqual('', env['DEB_BUILD_PROFILES']) - for name in ['TESTSUITEFLAGS', 'TARTEST_SKIP_LARGE_FILES', 'FULL_TEST', 'CC', 'CFLAGS', 'DEB_BUILD_MAINT_OPTIONS']: - self.assertNotIn(name, env) - self.assertEqual('-j2', MODULE.COMMON.job_option(2)); self.assertEqual('-j4', MODULE.COMMON.job_option(4)) - with self.assertRaises(ValueError): MODULE.build_environment(self.lock, 8) - - def suite_fixture(self): - directory = self.root / 'tests'; directory.mkdir() - names = {str(i): 'test%04d.at' % i for i in range(1, 206)} - names['204'], names['205'] = 'rename08.at', 'rename09.at' - script = ''.join(f'{i};{name}:25;description;keyword;\n' for i, name in names.items()) - log = ''.join(f'{i}. description ({name}:25): ' + ('skipped (fixture)' if i == '1' else 'expected failure (fixture)' if i == '2' else 'ok') + '\n' - for i, name in names.items()) - (directory / 'testsuite').write_text(script, encoding='utf-8') - file = directory / 'testsuite.log'; file.write_text(log, encoding='utf-8') - return file, log - - def test_complete_suite_preserves_original_xfail_skip_semantics(self): - self.suite_fixture(); result = MODULE.test_receipt(self.root) - self.assertEqual(205, result['configuredGroups']) - self.assertEqual({'PASS': 203, 'XFAIL': 1, 'SKIP': 1}, result['counts']) - self.assertEqual(0, result['addedSkips']) - - def test_filtered_ambiguous_or_unexpected_native_results_fail(self): - file, original = self.suite_fixture() - for bad in [original.split('\n', 1)[1], original + original.splitlines()[-1] + '\n', - original.replace('): ok', '): FAILED (fixture)', 1), - original.replace('): ok', '): UNEXPECTED PASS', 1), - original.replace('): ok', '): UNKNOWN', 1), original.replace('rename09.at:25): ok', 'rename09.at:25): skipped (fixture)')]: - file.write_text(bad, encoding='utf-8') - with self.assertRaises(ValueError): MODULE.test_receipt(self.root) - - def test_source_prerequisite_hash_mismatch_and_missing_smoke_binary_fail(self): - file = self.root / 'input'; file.write_bytes(b'original\nsource\n') - expected = {'input': MODULE.digest(file)}; MODULE.verify_sources(self.root, expected) - # Source hashes bind exact archive/patch bytes. A Windows preparation - # must not pin CRLF copies or normalize a runtime mismatch away. - file.write_bytes(b'original\r\nsource\r\n') - with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch: input'): - MODULE.verify_sources(self.root, expected) - file.write_bytes(b'tamper') - with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch: input'): - MODULE.verify_sources(self.root, expected) - file.unlink() - with self.assertRaisesRegex(ValueError, 'Missing source/prerequisite file: input'): - MODULE.verify_sources(self.root, expected) - with self.assertRaises(ValueError): MODULE.tar_smoke(self.root / 'missing', self.root, {}, self.root / 'log') - - def test_output_must_not_overlap_build_or_download_cache(self): - for directory in [self.root / 'out', self.root / 'out/nested', self.root]: - with self.assertRaises(ValueError): MODULE.COMMON.require_separate_paths(self.root / 'out', directory, None) - with self.assertRaises(ValueError): MODULE.COMMON.require_separate_paths(self.root / 'out', None, directory) - - def test_quilt_addition_preserves_order_and_existing_bytes_with_trailing_blank_lines(self): - original = '# original comment\nfirst.patch\nlast.patch\n\n' - added = MODULE.DERIVED_NAME + '\n' - MODULE.verify_series(original, original + added) - MODULE.verify_series(original, original[:-1] + added + '\n') - for bad in [added + original, original.replace('first.patch', 'foreign.patch') + added, - original.replace('# original comment\n', '') + added, original + added + added]: - with self.assertRaises(ValueError): MODULE.verify_series(original, bad) - - def cli(self, *args): - return subprocess.run([sys.executable, '-B', str(HERE / 'build-tar-backport.py'), *args], cwd=self.root, - text=True, encoding='utf-8', capture_output=True) - - def test_cli_uses_here_lock_independently_of_working_directory(self): - result = self.cli('--validate-lock') - self.assertEqual(0, result.returncode, result.stderr); self.assertIn('TAR_BACKPORT_LOCK_OK', result.stdout) - - def test_cli_rejects_lock_override_jobs_other_than_two_four_and_external_input_reader(self): - for args in [('--validate-lock', '--lock', 'foreign.json'), ('--validate-lock', '--jobs', '8'), - ('--validate-lock', '--jobs', '0'), ('--validate-lock', '--jobs', '-j2'), - ('--validate-inputs', '--cache-dir', str(self.root))]: - with self.subTest(args=args): - result = self.cli(*args) - self.assertNotEqual(0, result.returncode) - if args[0] == '--validate-inputs': - self.assertIn('unrecognized arguments', result.stderr) - - -if __name__ == '__main__': - unittest.main() From 572e04aa6f5a19cf51a087507096aafee36b57f7 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:17:30 +0800 Subject: [PATCH 7/9] Qualify shared official Ubuntu backport inputs and preserve consumer boundaries --- .github/workflows/codeql.yml | 25 + .../publish-ubuntu-security-backports.yml | 218 ++ server/security/ubuntu-backports/README.md | 29 +- .../ubuntu-backports/bundle/Dockerfile.bundle | 25 + .../bundle/installer/README.md | 59 + .../installer/consumer-fixture.Dockerfile | 60 + .../bundle/installer/consumer-smoke.sh | 100 + .../ubuntu-backports/bundle/installer/go.mod | 3 + .../bundle/installer/installer.go | 494 +++++ .../bundle/installer/installer_test.go | 420 ++++ .../ubuntu-backports/bundle/installer/main.go | 75 + .../bundle/installer/ownership_linux.go | 17 + .../bundle/installer/ownership_other.go | 12 + .../bundle/installer/payload.go | 280 +++ .../bundle/installer/schema.go | 238 +++ .../bundle/qualify-native-packages.py | 729 +++++++ ...t-coreutils-original18-source-closure.json | 1874 +++++++++++++++++ .../bundle/source-inputs.lock.json | 698 ++++++ .../bundle/test_qualify_native_packages.py | 323 +++ .../bundle/test_verify_consumer_evidence.py | 115 + .../bundle/verify-consumer-evidence.py | 173 ++ 21 files changed, 5965 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish-ubuntu-security-backports.yml create mode 100644 server/security/ubuntu-backports/bundle/Dockerfile.bundle create mode 100644 server/security/ubuntu-backports/bundle/installer/README.md create mode 100644 server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile create mode 100644 server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh create mode 100644 server/security/ubuntu-backports/bundle/installer/go.mod create mode 100644 server/security/ubuntu-backports/bundle/installer/installer.go create mode 100644 server/security/ubuntu-backports/bundle/installer/installer_test.go create mode 100644 server/security/ubuntu-backports/bundle/installer/main.go create mode 100644 server/security/ubuntu-backports/bundle/installer/ownership_linux.go create mode 100644 server/security/ubuntu-backports/bundle/installer/ownership_other.go create mode 100644 server/security/ubuntu-backports/bundle/installer/payload.go create mode 100644 server/security/ubuntu-backports/bundle/installer/schema.go create mode 100644 server/security/ubuntu-backports/bundle/qualify-native-packages.py create mode 100644 server/security/ubuntu-backports/bundle/rust-coreutils-original18-source-closure.json create mode 100644 server/security/ubuntu-backports/bundle/source-inputs.lock.json create mode 100644 server/security/ubuntu-backports/bundle/test_qualify_native_packages.py create mode 100644 server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py create mode 100644 server/security/ubuntu-backports/bundle/verify-consumer-evidence.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e9330e2381..870670e96e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -63,6 +63,14 @@ jobs: shell: bash run: python3 scripts/test-published-install-gate.py + - name: Test the qualified backport bundle contracts + if: matrix.language == 'python' + shell: bash + run: | + set -Eeuo pipefail + python3 -B server/security/ubuntu-backports/bundle/test_qualify_native_packages.py + python3 -B server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py + - name: Set up the production Go compiler if: matrix.language == 'go' uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 @@ -123,6 +131,23 @@ jobs: -ldflags='-s -w -buildid=' \ -o "$RUNNER_TEMP/codeql-go-build/pasturestack-console-broker" . + - name: Build and test the static image-build backport installer + if: matrix.language == 'go' + working-directory: server/security/ubuntu-backports/bundle/installer + shell: bash + env: + CGO_ENABLED: '0' + GOOS: linux + GOARCH: amd64 + GOTOOLCHAIN: local + GOPROXY: 'off' + run: | + set -Eeuo pipefail + test "$(go env GOVERSION)" = 'go1.27.3' + go test -mod=readonly ./... + go build -mod=readonly -trimpath -buildvcs=false -ldflags='-s -w -buildid=' \ + -o "$RUNNER_TEMP/codeql-go-build/pasturestack-install-ubuntu-backports" . + - name: Compile Java sources with the production Engine libraries if: matrix.language == 'java-kotlin' shell: bash diff --git a/.github/workflows/publish-ubuntu-security-backports.yml b/.github/workflows/publish-ubuntu-security-backports.yml new file mode 100644 index 0000000000..2c4c505ff7 --- /dev/null +++ b/.github/workflows/publish-ubuntu-security-backports.yml @@ -0,0 +1,218 @@ +name: Publish Ubuntu Security Backport Inputs + +on: + pull_request: + paths: + - '.github/workflows/publish-ubuntu-security-backports.yml' + - 'server/security/ubuntu-backports/bundle/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ubuntu-security-backports-v0.1.0-${{ github.event_name }} + cancel-in-progress: false + +jobs: + checks: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - name: Check the source lock and qualifier boundaries + run: | + set -Eeuo pipefail + python3 -B server/security/ubuntu-backports/bundle/test_qualify_native_packages.py + python3 -B server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --check-lock + + publish: + needs: checks + if: github.event_name == 'workflow_dispatch' && github.repository == 'PastureStack/server' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 90 + permissions: + contents: read + actions: read + packages: write + id-token: write + attestations: write + artifact-metadata: write + env: + IMAGE: ghcr.io/pasturestack/ubuntu-security-backports + TAG: v0.1.0 + steps: + - name: Check out the exact protected publisher source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: source + persist-credentials: false + fetch-depth: 0 + + - name: Require all native package inputs before building + id: inputs + working-directory: source + run: | + set -Eeuo pipefail + qualifier=server/security/ubuntu-backports/bundle/qualify-native-packages.py + python3 -B "$qualifier" --require-ready + printf 'work=%s\n' "$(python3 -B "$qualifier" --print-work-root)" >>"$GITHUB_OUTPUT" + python3 - <<'PY' >>"$GITHUB_OUTPUT" + import json + from pathlib import Path + lock = json.loads(Path('server/security/ubuntu-backports/bundle/source-inputs.lock.json').read_text()) + print('pcre2-source=' + lock['pcre2']['sourceCommit']) + PY + + - name: Authenticate the existing Server owner package namespace + env: + REGISTRY_TOKEN: ${{ github.token }} + run: printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin + + - name: Refuse an existing tag or an ambiguous registry response + env: + REGISTRY_TOKEN: ${{ github.token }} + working-directory: source + run: python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --check-new-tag + + - name: Download and bind the reviewed native artifacts + working-directory: source + env: + GH_TOKEN: ${{ github.token }} + run: python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --fetch + + - name: Check out the released Server 551 PCRE2 producer + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.inputs.outputs.pcre2-source }} + path: pcre2-source + persist-credentials: false + + - name: Build only the released PCRE2 producer and static installer + working-directory: source + env: + WORK: ${{ steps.inputs.outputs.work }} + run: | + set -Eeuo pipefail + qualifier=server/security/ubuntu-backports/bundle/qualify-native-packages.py + python3 -B "$qualifier" --check-pcre2-source + mkdir "$WORK/publisher" + python3 - <<'PY' + import os + from pathlib import Path + original = Path('../pcre2-source/server/Dockerfile.web-compose-release').read_bytes() + appended = b'\nFROM scratch AS pcre2_bundle_inputs\nCOPY --from=runtime_security_packages /out/pcre2/ /pcre2/\n' + Path(os.environ['WORK'], 'Dockerfile.pcre2').write_bytes(original + appended) + PY + docker buildx build --platform linux/amd64 --progress plain --no-cache \ + --file "$WORK/Dockerfile.pcre2" --target pcre2_bundle_inputs \ + --output "type=local,dest=$WORK/pcre2-export" ../pcre2-source/server \ + 2>&1 | tee "$WORK/publisher/pcre2-build.log" + docker buildx build --platform linux/amd64 --progress plain --no-cache \ + --file server/security/ubuntu-backports/bundle/Dockerfile.bundle --target installer_export \ + --output "type=local,dest=$WORK/installer-export" server/security/ubuntu-backports/bundle \ + 2>&1 | tee "$WORK/publisher/installer-build.log" + + - name: Qualify package bytes, native receipts, payload metadata, SBOM and exact-package VEX + working-directory: source + env: + WORK: ${{ steps.inputs.outputs.work }} + run: | + set -Eeuo pipefail + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --qualify \ + 2>&1 | tee "$WORK/publisher/qualification.log" + + - name: Require real installation in both isolated Ubuntu provider fixtures + working-directory: source + env: + WORK: ${{ steps.inputs.outputs.work }} + run: | + set -Eeuo pipefail + for provider in gnu uutils; do + docker buildx build --platform linux/amd64 --progress plain --no-cache \ + --file server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile \ + --target evidence --build-arg "PROVIDER=$provider" \ + --build-context "qualified=$WORK/context" \ + --output "type=local,dest=$WORK/consumer-$provider" server/security/ubuntu-backports/bundle/installer \ + 2>&1 | tee "$WORK/publisher/consumer-$provider.log" + done + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --record-consumer-tests \ + 2>&1 | tee "$WORK/publisher/consumer-qualification.log" + + - name: Publish the new build-only OCI data artifact + id: image + working-directory: source + env: + WORK: ${{ steps.inputs.outputs.work }} + SOURCE_SHA: ${{ github.sha }} + REGISTRY_TOKEN: ${{ github.token }} + run: | + set -Eeuo pipefail + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --check-new-tag + docker buildx build --platform linux/amd64 --progress plain --provenance=false \ + --file server/security/ubuntu-backports/bundle/Dockerfile.bundle --target bundle \ + --build-arg "SOURCE_SHA=$SOURCE_SHA" --tag "$IMAGE:$TAG" \ + --metadata-file "$WORK/publisher/image-metadata.json" --push "$WORK/context" \ + 2>&1 | tee "$WORK/publisher/publish.log" + digest="$(python3 -c 'import json,os; print(json.load(open(os.environ["WORK"] + "/publisher/image-metadata.json"))["containerimage.digest"])')" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + printf 'digest=%s\n' "$digest" >>"$GITHUB_OUTPUT" + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --verify-published-manifest + + - name: Attest image provenance with the Server workflow identity + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ghcr.io/pasturestack/ubuntu-security-backports + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + + - name: Attest payload SBOM + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ghcr.io/pasturestack/ubuntu-security-backports + subject-digest: ${{ steps.image.outputs.digest }} + sbom-path: ${{ steps.inputs.outputs.work }}/context/out/sbom.cdx.json + push-to-registry: true + + - name: Require public anonymous pull and exact OCI data readback + working-directory: source + env: + WORK: ${{ steps.inputs.outputs.work }} + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -Eeuo pipefail + mkdir "$WORK/anonymous-docker" "$WORK/readback" + printf '{"auths":{}}\n' >"$WORK/anonymous-docker/config.json" + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --verify-anonymous-manifest + docker --config "$WORK/anonymous-docker" pull "$IMAGE@$IMAGE_DIGEST" + container="$(docker create --entrypoint /unused "$IMAGE@$IMAGE_DIGEST")" + trap 'docker rm "$container" >/dev/null' EXIT + docker cp "$container:/out" "$WORK/readback/out" + docker cp "$container:/bundle/install-existing-backports" "$WORK/readback/bundle-installer" + python3 -B server/security/ubuntu-backports/bundle/qualify-native-packages.py --verify-readback \ + 2>&1 | tee "$WORK/publisher/anonymous-readback.log" + printf 'Package inputs published: %s@%s\nConsumer installation, rootfs scan and product acceptance remain required.\n' \ + "$IMAGE" "$IMAGE_DIGEST" >>"$GITHUB_STEP_SUMMARY" + + - name: Clear registry credentials + if: always() + run: docker logout ghcr.io + + - name: Retain exact qualified package inputs and publication evidence + if: always() && steps.inputs.outputs.work != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ubuntu-security-backports-v0.1.0-${{ github.sha }}-${{ github.run_id }} + path: | + ${{ steps.inputs.outputs.work }}/context/out/ + ${{ steps.inputs.outputs.work }}/publisher/ + ${{ steps.inputs.outputs.work }}/consumer-gnu/ + ${{ steps.inputs.outputs.work }}/consumer-uutils/ + ${{ steps.inputs.outputs.work }}/inputs/*-api.json + if-no-files-found: warn + retention-days: 30 diff --git a/server/security/ubuntu-backports/README.md b/server/security/ubuntu-backports/README.md index d4e5e8e698..a18ae9503c 100644 --- a/server/security/ubuntu-backports/README.md +++ b/server/security/ubuntu-backports/README.md @@ -1,9 +1,11 @@ -# Official source security candidates +# Official source security backports These are first-party rebuild tools, not Canonical packages or a statement that any running product is fixed. Each module retains the original Ubuntu packaging, pins official inputs by SHA-256, and requires its owning tests and extracted -package/ABI checks before exporting evidence. No helper installs runtime packages. +package/ABI checks before exporting evidence. Native build helpers export packages; +the separate build-only bundle installs only qualified, already-present packages +into disposable consumer image builds. `ubuntu-backport-native.yml` builds disposable Ubuntu26 amd64 candidates without host mounts, publishing credentials or deployment rights. Initial pull requests @@ -26,3 +28,26 @@ The attempted tar CVE-2026-18477 backport was rejected: its original cyclic-rena regression loses three restored files on the Ubuntu baseline. Source application alone was insufficient. Neither it nor the incomplete CVE-2026-18508 backport is offered here; a complete compatible official fix is still required. + +## Shared consumer inputs + +`bundle/` binds the successful native job, source commit, source lock, package +bytes, licensing and regression evidence for each family. The separately +versioned `ubuntu-security-backports` OCI package is a build input, not a service, +runtime base image or Catalog entry. Consumers pin its immutable digest and +mount it only in their final runtime build stage, after their existing Ubuntu +security updates. Their compiler/build stages retain their original packages. + +The static Go installer selects only packages already installed in that rootfs. +It checks archive and payload hashes, dependencies, ABI, package integrity and +provider/configuration preservation before accepting an installation. It must +not add an absent Rust, development or other runtime package, change a coreutils +provider, invoke apt, use force flags or patch a running container. Legitimate +Ubuntu image documentation exclusions are recorded and restored only for the +selected package paths; runtime exclusions or integrity differences fail. + +Publishing requires real disposable consumer installs with both GNU and uutils +providers, in addition to the installer unit tests. A bundle PASS is still not a +product PASS: every consumer must retain its normal source/release gates, exact +final-rootfs SBOM, raw scan, scoped VEX and affected runtime smoke. See +`bundle/installer/README.md` for the fixed mount and command contract. diff --git a/server/security/ubuntu-backports/bundle/Dockerfile.bundle b/server/security/ubuntu-backports/bundle/Dockerfile.bundle new file mode 100644 index 0000000000..0d449a809c --- /dev/null +++ b/server/security/ubuntu-backports/bundle/Dockerfile.bundle @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 +# This is a build-only data artifact. It has no runtime entrypoint. +FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS installer_build +WORKDIR /src +COPY installer/ ./ +RUN set -eux; \ + test "$(go env GOVERSION)" = go1.27.3; \ + export CGO_ENABLED=0 GOTOOLCHAIN=local GOOS=linux GOARCH=amd64 GOPROXY=off GOSUMDB=off; \ + test -z "$(gofmt -l .)"; \ + go test -mod=readonly ./...; \ + go build -mod=readonly -trimpath -buildvcs=false -ldflags='-s -w -buildid=' \ + -o /out/install-existing-backports . + +FROM scratch AS installer_export +COPY --from=installer_build /out/install-existing-backports /install-existing-backports + +FROM scratch AS bundle +ARG SOURCE_SHA +LABEL org.opencontainers.image.title="Ubuntu security backport package inputs" \ + org.opencontainers.image.version="0.1.0" \ + org.opencontainers.image.source="https://github.com/PastureStack/server" \ + org.opencontainers.image.revision="${SOURCE_SHA}" \ + org.opencontainers.image.description="First-party qualified amd64 Ubuntu 26.04 deb inputs; consumer rootfs validation is separate." +COPY out/ /out/ +COPY out/install-existing-backports /bundle/install-existing-backports diff --git a/server/security/ubuntu-backports/bundle/installer/README.md b/server/security/ubuntu-backports/bundle/installer/README.md new file mode 100644 index 0000000000..310d9b5f7a --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/README.md @@ -0,0 +1,59 @@ +The installer is a Go 1.27.3 standard-library-only Linux amd64 binary. Build it +with `CGO_ENABLED=0 GOTOOLCHAIN=local GOOS=linux GOARCH=amd64`, run `go test ./...`, +and build with `go build -trimpath -ldflags='-s -w'`. The publisher places the same +binary at `/bundle/install-existing-backports` and `/out/install-existing-backports`. +It is a build tool, not a runtime dependency. + +In a disposable Ubuntu 26.04 amd64 Docker build, mount the immutable bundle's +`/out` read-only at `/opt/pasturestack/ubuntu-security-backports`, then execute +`/opt/pasturestack/ubuntu-security-backports/install-existing-backports --install-in-docker-build`. Use a build mount +or remove the copied binary and bundle in that same `RUN`. The final runtime +needs only its existing dpkg utilities; Python, readelf, nm and a Go toolchain +are not added. `--verify-installed` performs the installed checks without an +installation. No root/manifest/path override is accepted. + +Manifest v1 fixes Ubuntu 26.04 amd64, bundle 0.1.0, six families and twelve +runtime packages: PCRE2's two libraries; libc6, libc-bin and +libc-gconv-modules-extra; rust-coreutils; jq and libjq1; p11-kit, +p11-kit-modules and libp11-kit0; and libxrender1. Archives, complete non-directory +payload receipts, source provenance and qualification are SHA256-bound. The +installer independently reads each selected archive's control and tar stream, +checks every file/link and ELF identity, and preserves existing SONAME, NEEDED +and exported symbols before installation. The publisher qualifies native owning +test evidence; consumer installation and product acceptance remain separate. + +Only packages already installed in the consumer are selected. The package set +must remain identical; no Rust package or provider is added to a GNU-only image. +Exact-version dependencies from installed companion packages (including dev +packages) reject an upgrade if a matching companion is absent from the bundle. +The dependency dry run and actual batch installation use fixed dpkg options, +without apt, force options or dependency overrides. Original provider package +metadata, provider command paths, alternatives, diversions, statoverrides and +host configuration are compared before/after. Only the generated ld.so.cache +is excluded from the host-configuration snapshot. + +Before installation, dpkg checksum differences or missing runtime files fail. +The sole permitted pre-existing difference is a missing selected-package +documentation file proven excluded by the consumer's current dpkg path rules. +Every such path is enumerated in the JSON receipt. Exact one-shot +`--path-include=/full/path` options restore selected documentation without +editing dpkg configuration. Other excluded runtime payload paths fail. After +installation, every payload file's SHA256/type/mode/uid/gid or literal symlink +target, every ELF receipt, package metadata, dpkg audit and full `dpkg --verify` +must match. No post-install difference is ignored. + +Tests use temporary rootfs/bundle fixtures and a fake dpkg adapter. They never +install a package, change the host, run apt, or invoke libc-maintainer scripts. + +`consumer-fixture.Dockerfile` adds a separate prepublication acceptance gate on +the pinned Ubuntu 26.04 image and signed 2026-10-23 archive snapshot. Run it in +the CI runner twice with `PROVIDER=gnu` and `PROVIDER=uutils`, context this folder, +additional build context `qualified=`, target +`evidence`, and local output per provider. The GNU fixture has eleven selected +packages and no Rust; the uutils fixture has all twelve. Both consume the same +read-only qualified bundle bytes, restore deliberately path-excluded docs, +perform actual install/verify, preserve package set and provider, and exercise +the installed glibc loader/cache, jq, id, p11-kit, PCRE grep and NSS getent. +`result.json` and the console/install/verify receipts are exported. These are +isolated consumer fixtures, with normal official package/provider setup and no +force flags. They do not replace acceptance in each actual product rootfs. diff --git a/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile b/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile new file mode 100644 index 0000000000..2a9e6a82a8 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/consumer-fixture.Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 +# Disposable prepublication consumer acceptance; never used as a product image. +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b AS consumer +ARG PROVIDER=gnu +ENV DEBIAN_FRONTEND=noninteractive +SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] +ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /tmp/ca-certificates.deb +RUN <<'SH' +set -euo pipefail +case "$PROVIDER" in gnu|uutils) ;; *) exit 2 ;; esac +mkdir -p /tmp/ca-bootstrap /etc/ssl/certs +dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap +while IFS= read -r certificate; do + cat "$certificate" + printf '\n' +done < <(find /tmp/ca-bootstrap/usr/share/ca-certificates -type f -name '*.crt' | LC_ALL=C sort) >/etc/ssl/certs/ca-certificates.crt +rm -f /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources +cat >/etc/apt/sources.list.d/pasturestack-snapshot.sources <<'SOURCES' +Types: deb +URIs: https://snapshot.ubuntu.com/ubuntu/20261023T000000Z +Suites: resolute resolute-updates resolute-security +Components: main universe restricted multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +Snapshot: no +Check-Valid-Until: no +SOURCES +cat >/etc/apt/apt.conf.d/80pasturestack <<'APT' +Acquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt"; +Acquire::https::Verify-Peer "true"; +Acquire::https::Verify-Host "true"; +Acquire::Retries "3"; +APT +# Exercise an ordinary minimal-image doc filter, without suppressing runtime +# files. The installer must enumerate missing docs and restore exact paths. +printf 'path-exclude=/usr/share/doc/*\n' >/etc/dpkg/dpkg.cfg.d/01fixture-doc-exclusions +apt-get update +set -- \ + libpcre2-8-0=10.46-1build1 libpcre2-posix3=10.46-1build1 \ + libc6=2.43-2ubuntu2.4 libc-bin=2.43-2ubuntu2.4 libc-gconv-modules-extra=2.43-2ubuntu2.4 \ + jq=1.8.1-4ubuntu2 libjq1=1.8.1-4ubuntu2 \ + p11-kit=0.26.2-2 p11-kit-modules=0.26.2-2 libp11-kit0=0.26.2-2 \ + 'libxrender1=1:0.9.12-1build1' gnu-coreutils=9.7-3ubuntu2 ca-certificates grep +case "$PROVIDER" in + gnu) set -- "$@" 'coreutils-from-gnu=0.0.0~ubuntu25' ;; + uutils) set -- "$@" 'coreutils-from-uutils=0.0.0~ubuntu25' 'rust-coreutils=0.10.0-1ubuntu2~26.04.1' ;; +esac +# Baseline construction uses the official provider transition without force or +# protected-package overrides. A refused transition fails the fixture. +apt-get install --no-install-recommends --reinstall -y "$@" +if [[ "$PROVIDER" == gnu ]]; then + apt-get remove -y rust-coreutils +fi +rm -rf /var/lib/apt/lists/* /tmp/ca-bootstrap /tmp/ca-certificates.deb +SH +COPY consumer-smoke.sh /consumer-smoke.sh +RUN --mount=from=qualified,source=/out,target=/opt/pasturestack/ubuntu-security-backports,ro \ + /bin/bash /consumer-smoke.sh "$PROVIDER" + +FROM scratch AS evidence +COPY --from=consumer /evidence/ / diff --git a/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh b/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh new file mode 100644 index 0000000000..a960fbcb43 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/consumer-smoke.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Only run by consumer-fixture.Dockerfile in an isolated image build. +set -euo pipefail +export LC_ALL=C +provider="${1:?provider required}" +case "$provider" in gnu|uutils) ;; *) exit 2 ;; esac +test "$#" -eq 1 +readonly data=/opt/pasturestack/ubuntu-security-backports +readonly evidence=/evidence +mkdir -p "$evidence" +exec > >(tee "$evidence/console.log") 2>&1 + +installed() { + test "$(dpkg-query --show --showformat='${db:Status-Status}' "$1" 2>/dev/null)" = installed +} +absent() { + if installed "$1"; then + printf 'Unexpected installed package: %s\n' "$1" >&2 + return 1 + fi +} +package_set() { + dpkg-query --show --showformat='${binary:Package}\t${Architecture}\t${db:Status-Status}\n' | + awk -F '\t' '$3 == "installed" { print $1 "\t" $2 }' | sort +} +provider_state() { + dpkg-query --show --showformat='${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Status}\n' \ + "coreutils-from-$provider" gnu-coreutils + stat --format='%F:%a:%u:%g' /usr/bin/ls /usr/bin/id + for command in /usr/bin/ls /usr/bin/id; do + if test -L "$command"; then readlink "$command"; else sha256sum "$command"; fi + done +} +installed "coreutils-from-$provider" +installed gnu-coreutils +if [[ "$provider" == gnu ]]; then + absent rust-coreutils + absent coreutils-from-uutils + expected=11 +else + installed rust-coreutils + absent coreutils-from-gnu + expected=12 +fi +for package in libc6-dev libc-dev-bin libpcre2-dev libjq-dev libp11-kit-dev libxrender-dev; do + absent "$package" +done +input_manifest_sha="$(sha256sum "$data/manifest.json" | awk '{print $1}')" +package_set >"$evidence/packages-before.tsv" +provider_state >"$evidence/provider-before.tsv" +sha256sum /etc/ld.so.cache >"$evidence/ld-so-cache-before.sha256" + +# The only installer invocation which mutates this disposable consumer rootfs. +"$data/install-existing-backports" --install-in-docker-build >"$evidence/install.json" +"$data/install-existing-backports" --verify-installed >"$evidence/verify.json" +cat "$evidence/install.json" "$evidence/verify.json" +jq -e --argjson expected "$expected" '.status == "installed-and-verified" and .providerPreserved and .configurationPreserved and (.packages | length) == $expected and (.beforeMissingDocumentation | length) > 0 and (.exactPathIncludes | length) > 0' "$evidence/install.json" >/dev/null +jq -e '.status == "verified"' "$evidence/verify.json" >/dev/null +cmp <(jq -c '.packages|sort' "$evidence/install.json") <(jq -c '.packages|sort' "$evidence/verify.json") +if [[ "$provider" == gnu ]]; then + absent rust-coreutils + jq -e '.packages | index("rust-coreutils") == null' "$evidence/install.json" >/dev/null +else + installed rust-coreutils + jq -e '.packages | index("rust-coreutils") != null' "$evidence/install.json" >/dev/null +fi +package_set >"$evidence/packages-after.tsv" +provider_state >"$evidence/provider-after.tsv" +cmp "$evidence/packages-before.tsv" "$evidence/packages-after.tsv" +cmp "$evidence/provider-before.tsv" "$evidence/provider-after.tsv" +while IFS= read -r path; do + case "$path" in /usr/share/doc/*|/usr/share/man/*|/usr/share/info/*) ;; *) exit 1 ;; esac + test -e "$path" || test -L "$path" +done < <(jq -r '.exactPathIncludes[]' "$evidence/install.json") +test -z "$(dpkg --audit)" + +# Exercise the installed loader/cache and programs from the same qualified +# archives. This does not repeat the upstream compiler/owning suites. +test -s /etc/ld.so.cache +sha256sum /etc/ld.so.cache >"$evidence/ld-so-cache-after.sha256" +ldconfig -p >"$evidence/ldconfig.txt" +for soname in libc.so.6 libpcre2-8.so.0 libpcre2-posix.so.3 libjq.so.1 libp11-kit.so.0 libXrender.so.1; do + grep -F "$soname " "$evidence/ldconfig.txt" +done +getconf GNU_LIBC_VERSION | tee "$evidence/glibc-version.txt" | grep -Fx 'glibc 2.43' +/lib64/ld-linux-x86-64.so.2 --verify /usr/bin/jq +ldd /usr/lib/x86_64-linux-gnu/libXrender.so.1 >"$evidence/libxrender-loader.txt" +if grep -F 'not found' "$evidence/libxrender-loader.txt"; then exit 1; fi +printf '%s\n' '{"numbers":[1,2,3]}' | jq -e '.numbers | add == 6' +test "$(id -u)" = 0 +test "$(id -g)" = 0 +id -G >"$evidence/id-groups.txt" +p11-kit list-modules >"$evidence/p11-kit-modules.txt" +printf 'a123\n' | grep -P '^a\d{3}$' +getent passwd root | tee "$evidence/getent-root.txt" | grep -E '^root:[^:]*:0:0:' +test "$input_manifest_sha" = "$(sha256sum "$data/manifest.json" | awk '{print $1}')" +jq -n --arg provider "$provider" --arg manifest "$input_manifest_sha" \ + --slurpfile receipt "$evidence/install.json" \ + '{schemaVersion:1,provider:$provider,status:"PASS",inputManifestSha256:$manifest,selectedPackages:$receipt[0].packages,checks:["batch-install","verify-installed","package-set","provider","docs-restored","glibc-loader","jq","id","p11-kit","grep","getent"]}' >"$evidence/result.json" +cat "$evidence/result.json" diff --git a/server/security/ubuntu-backports/bundle/installer/go.mod b/server/security/ubuntu-backports/bundle/installer/go.mod new file mode 100644 index 0000000000..3ecd223d2d --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/go.mod @@ -0,0 +1,3 @@ +module pasturestack.local/ubuntu-security-backports-installer + +go 1.27.3 diff --git a/server/security/ubuntu-backports/bundle/installer/installer.go b/server/security/ubuntu-backports/bundle/installer/installer.go new file mode 100644 index 0000000000..a3cb7cf805 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/installer.go @@ -0,0 +1,494 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" +) + +type Installed struct{ Name, Version, Architecture, Source, SourceVersion, Status, Depends, PreDepends string } +type Runner interface { + Run(string, ...string) ([]byte, error) +} +type Installer struct { + bundle, root string + run Runner +} +type Receipt struct { + SchemaVersion int `json:"schemaVersion"` + BundleVersion string `json:"bundleVersion"` + Status string `json:"status"` + Packages []string `json:"packages"` + BeforeMissingDocumentation []string `json:"beforeMissingDocumentation"` + ExactPathIncludes []string `json:"exactPathIncludes"` + ProviderPreserved bool `json:"providerPreserved,omitempty"` + ConfigurationPreserved bool `json:"configurationPreserved,omitempty"` +} + +func parseInventory(b []byte) (map[string]Installed, error) { + result := map[string]Installed{} + for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") { + if line == "" { + continue + } + fields := strings.Split(line, "\t") + if len(fields) != 8 { + return nil, fmt.Errorf("malformed dpkg inventory") + } + name := strings.SplitN(fields[0], ":", 2)[0] + if fields[5] != "installed" { + if fields[5] == "not-installed" || fields[5] == "config-files" { + continue + } + return nil, fmt.Errorf("package not fully configured: %s (%s)", fields[0], fields[5]) + } + if _, duplicate := result[name]; duplicate { + return nil, fmt.Errorf("multiarch duplicate package: %s", name) + } + result[name] = Installed{name, fields[1], fields[2], fields[3], fields[4], fields[5], fields[6], fields[7]} + } + return result, nil +} +func (i Installer) inventory() (map[string]Installed, error) { + b, err := i.run.Run("/usr/bin/dpkg-query", "--show", "--showformat=${binary:Package}\t${Version}\t${Architecture}\t${source:Package}\t${source:Version}\t${db:Status-Status}\t${Depends}\t${Pre-Depends}\n") + if err != nil { + return nil, err + } + return parseInventory(b) +} +func (i Installer) platform() error { + b, err := os.ReadFile(filepath.Join(i.root, "etc/os-release")) + if err != nil { + return err + } + fields := map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(line, "=") + if ok { + fields[k] = strings.Trim(v, "\"'") + } + } + if fields["ID"] != "ubuntu" || fields["VERSION_ID"] != "26.04" { + return fmt.Errorf("only Ubuntu 26.04 rootfs is supported") + } + b, err = i.run.Run("/usr/bin/dpkg", "--print-architecture") + if err != nil { + return err + } + if strings.TrimSpace(string(b)) != "amd64" { + return fmt.Errorf("only native amd64 is supported") + } + return nil +} +func parseControl(b []byte) (map[string]string, error) { + result := map[string]string{} + last := "" + for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") { + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + if last == "" { + return nil, fmt.Errorf("invalid control continuation") + } + result[last] += " " + strings.TrimSpace(line) + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok || key == "" { + return nil, fmt.Errorf("invalid control field") + } + if _, duplicate := result[key]; duplicate { + return nil, fmt.Errorf("duplicate control field") + } + last = key + result[key] = strings.TrimSpace(value) + } + return result, nil +} +func (i Installer) archive(p Package, payload Payload) error { + archive := filepath.Join(i.bundle, filepath.FromSlash(p.Archive.Path)) + b, err := i.run.Run("/usr/bin/dpkg-deb", "--field", archive) + if err != nil { + return err + } + c, err := parseControl(b) + if err != nil { + return err + } + source := c["Source"] + if source == "" && p.Name == p.Source { + source = p.Source + } + if c["Package"] != p.Name || c["Version"] != p.Version || c["Architecture"] != p.Architecture || (source != p.Source && source != p.Source+" ("+p.Version+")") { + return fmt.Errorf("archive control identity mismatch: %s", p.Name) + } + b, err = i.run.Run("/usr/bin/dpkg-deb", "--fsys-tarfile", archive) + if err != nil { + return err + } + return validateArchive(b, payload) +} + +var exactDependency = regexp.MustCompile(`(?:^|[,|])\s*([a-z0-9][a-z0-9+.-]*)(?::[a-z0-9-]+)?\s*\(=\s*([^()]+)\)`) + +func companions(installed map[string]Installed, selected []Package) error { + versions := map[string]string{} + for _, p := range selected { + versions[p.Name] = p.Version + } + for name, p := range installed { + if _, upgraded := versions[name]; upgraded { + continue + } + for _, match := range exactDependency.FindAllStringSubmatch(p.Depends+","+p.PreDepends, -1) { + if target, upgraded := versions[match[1]]; upgraded && target != strings.TrimSpace(match[2]) { + return fmt.Errorf("installed companion %s requires %s (= %s); matching companion archive is absent", name, match[1], strings.TrimSpace(match[2])) + } + } + } + return nil +} + +type pathRule struct { + pattern string + include bool +} + +func (i Installer) pathRules() ([]pathRule, error) { + paths := []string{filepath.Join(i.root, "etc/dpkg/dpkg.cfg")} + extra, err := filepath.Glob(filepath.Join(i.root, "etc/dpkg/dpkg.cfg.d/*")) + if err != nil { + return nil, err + } + sort.Strings(extra) + paths = append(paths, extra...) + rules := []pathRule{} + for _, p := range paths { + info, err := os.Lstat(p) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("dpkg configuration is not a regular file: %s", p) + } + b, err := os.ReadFile(p) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(strings.SplitN(line, "#", 2)[0]) + line = strings.TrimPrefix(line, "--") + for _, kind := range []string{"path-exclude", "path-include"} { + if strings.HasPrefix(line, kind+"=") || strings.HasPrefix(line, kind+" ") { + pattern := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, kind), "=")) + if !strings.HasPrefix(pattern, "/") || strings.ContainsAny(pattern, "\x00\r\n") { + return nil, fmt.Errorf("invalid dpkg path rule") + } + rules = append(rules, pathRule{pattern, kind == "path-include"}) + } + } + } + } + return rules, nil +} + +// dpkg's pathname filters use '*' across '/' (unlike filepath.Match). +func excluded(name string, rules []pathRule) bool { + exclude := false + for _, rule := range rules { + pattern := "^" + for _, c := range rule.pattern { + switch c { + case '*': + pattern += ".*" + case '?': + pattern += "." + default: + pattern += regexp.QuoteMeta(string(c)) + } + } + pattern += "$" + if regexp.MustCompile(pattern).MatchString("/" + name) { + exclude = !rule.include + } + } + return exclude +} +func documentation(name, pkg string) bool { + return name == "usr/share/doc/"+pkg || strings.HasPrefix(name, "usr/share/doc/"+pkg+"/") || strings.HasPrefix(name, "usr/share/man/") || strings.HasPrefix(name, "usr/share/info/") +} + +var missingLine = regexp.MustCompile(`^missing\s+(/\S+)$`) + +func beforeVerify(output []byte, p Package, payload Payload, rules []pathRule) ([]string, error) { + allowed := map[string]bool{} + for _, f := range payload.Files { + if documentation(f.Path, p.Name) && excluded(f.Path, rules) { + allowed["/"+f.Path] = true + } + } + missing := []string{} + for _, line := range strings.Split(strings.TrimSuffix(string(output), "\n"), "\n") { + if line == "" { + continue + } + match := missingLine.FindStringSubmatch(line) + if match == nil || !allowed[match[1]] { + return nil, fmt.Errorf("pre-existing package integrity difference: %s: %s", p.Name, line) + } + missing = append(missing, match[1]) + } + return unique(missing), nil +} +func (i Installer) audit() error { + b, err := i.run.Run("/usr/bin/dpkg", "--audit") + if err != nil { + return err + } + if strings.TrimSpace(string(b)) != "" { + return fmt.Errorf("dpkg audit is not clean: %s", b) + } + return nil +} + +// Config files and provider selection are immutable across installation. Only +// ld.so.cache is generated by the required libc trigger; it is not host config. +func (i Installer) snapshot(installed map[string]Installed) (map[string]string, error) { + result := map[string]string{} + add := func(name string) error { + if name == "etc/ld.so.cache" { + return nil + } + p, err := rootPath(i.root, name) + if err != nil { + return err + } + info, err := os.Lstat(p) + if os.IsNotExist(err) { + result[name] = "absent" + return nil + } + if err != nil { + return err + } + if info.IsDir() { + return nil + } + f, _, err := installedFile(i.root, File{Path: name}) + if err != nil { + return err + } + result[name] = fmt.Sprintf("%s:%04o:%d:%d:%s:%s", f.Type, f.Mode, f.UID, f.GID, f.SHA256, f.Target) + return nil + } + for _, dir := range []string{"etc", "var/lib/dpkg/alternatives"} { + err := filepath.WalkDir(filepath.Join(i.root, dir), func(p string, d os.DirEntry, err error) error { + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if d.IsDir() { + return nil + } + name, err := filepath.Rel(i.root, p) + if err != nil { + return err + } + return add(filepath.ToSlash(name)) + }) + if err != nil { + return nil, err + } + } + for _, name := range []string{"var/lib/dpkg/diversions", "var/lib/dpkg/statoverride"} { + if err := add(name); err != nil { + return nil, err + } + } + for _, provider := range []string{"coreutils-from-uutils", "coreutils-from-gnu", "gnu-coreutils", "coreutils"} { + p, ok := installed[provider] + if !ok { + continue + } + result["provider-package:"+provider] = fmt.Sprintf("%#v", p) + b, err := i.run.Run("/usr/bin/dpkg-query", "--listfiles", provider) + if err != nil { + return nil, err + } + for _, name := range strings.Split(string(b), "\n") { + if strings.HasPrefix(name, "/usr/bin/") || strings.HasPrefix(name, "/bin/") { + if err := add(strings.TrimPrefix(name, "/")); err != nil { + return nil, err + } + } + } + } + return result, nil +} +func unchangedInventory(before, after map[string]Installed, selected []Package) error { + if len(before) != len(after) { + return fmt.Errorf("installation changed package set") + } + chosen := map[string]Package{} + for _, p := range selected { + chosen[p.Name] = p + } + for name, old := range before { + now, exists := after[name] + if !exists { + return fmt.Errorf("package removed: %s", name) + } + if p, ok := chosen[name]; ok { + if now.Version != p.Version || now.Architecture != p.Architecture || now.Source != p.Source || now.SourceVersion != p.Version || now.Status != "installed" { + return fmt.Errorf("installed package identity mismatch: %s", name) + } + } else if old != now { + return fmt.Errorf("unselected package changed: %s", name) + } + } + return nil +} +func (i Installer) Execute(install bool) (Receipt, error) { + r := Receipt{SchemaVersion: 1, BundleVersion: "0.1.0", Packages: []string{}, BeforeMissingDocumentation: []string{}, ExactPathIncludes: []string{}} + if err := i.platform(); err != nil { + return r, err + } + m, payloads, err := loadManifest(i.bundle) + if err != nil { + return r, err + } + installed, err := i.inventory() + if err != nil { + return r, err + } + if err = i.audit(); err != nil { + return r, err + } + selected := []Package{} + for _, p := range m.Packages { + old, exists := installed[p.Name] + if !exists { + continue + } + if old.Architecture != "amd64" || old.Source != p.Source { + return r, fmt.Errorf("unexpected installed source/architecture: %s", p.Name) + } + if !install && old.Version != p.Version { + return r, fmt.Errorf("installed version is not qualified: %s", p.Name) + } + if _, err = i.run.Run("/usr/bin/dpkg", "--compare-versions", old.Version, "le", p.Version); err != nil { + return r, fmt.Errorf("refuse downgrade: %s", p.Name) + } + if err = i.archive(p, payloads[p.Name]); err != nil { + return r, err + } + selected = append(selected, p) + r.Packages = append(r.Packages, p.Name) + } + if len(selected) == 0 { + return r, fmt.Errorf("no existing qualified packages selected") + } + if err = companions(installed, selected); err != nil { + return r, err + } + if !install { + for _, p := range selected { + if err = verifyPayload(i.root, payloads[p.Name]); err != nil { + return r, err + } + out, e := i.run.Run("/usr/bin/dpkg", "--verify", p.Name) + if e != nil || len(out) != 0 { + return r, fmt.Errorf("post-install dpkg verification failed: %s: %s", p.Name, out) + } + } + r.Status = "verified" + return r, nil + } + rules, err := i.pathRules() + if err != nil { + return r, err + } + includes := []string{} + for _, p := range selected { + out, e := i.run.Run("/usr/bin/dpkg", "--verify", p.Name) + if e != nil && len(out) == 0 { + return r, e + } + missing, err := beforeVerify(out, p, payloads[p.Name], rules) + if err != nil { + return r, err + } + r.BeforeMissingDocumentation = append(r.BeforeMissingDocumentation, missing...) + for _, f := range payloads[p.Name].Files { + if excluded(f.Path, rules) { + if !documentation(f.Path, p.Name) { + return r, fmt.Errorf("dpkg excludes a runtime payload path: %s", f.Path) + } + includes = append(includes, "/"+f.Path) + } + } + if err = checkABI(i.root, payloads[p.Name]); err != nil { + return r, err + } + } + r.BeforeMissingDocumentation = unique(r.BeforeMissingDocumentation) + r.ExactPathIncludes = unique(includes) + before, err := i.snapshot(installed) + if err != nil { + return r, err + } + archives := []string{} + for _, p := range selected { + archives = append(archives, filepath.Join(i.bundle, filepath.FromSlash(p.Archive.Path))) + } + dry := append([]string{"--no-act", "--install"}, archives...) + if _, err = i.run.Run("/usr/bin/dpkg", dry...); err != nil { + return r, fmt.Errorf("dependency preflight: %w", err) + } + args := []string{} + for _, p := range r.ExactPathIncludes { + args = append(args, "--path-include="+p) + } + args = append(args, "--install") + args = append(args, archives...) + if _, err = i.run.Run("/usr/bin/dpkg", args...); err != nil { + return r, fmt.Errorf("batch installation: %w", err) + } + after, err := i.inventory() + if err != nil { + return r, err + } + if err = unchangedInventory(installed, after, selected); err != nil { + return r, err + } + if err = i.audit(); err != nil { + return r, err + } + now, err := i.snapshot(after) + if err != nil { + return r, err + } + if !reflect.DeepEqual(before, now) { + return r, fmt.Errorf("provider or host configuration changed") + } + for _, p := range selected { + if err = verifyPayload(i.root, payloads[p.Name]); err != nil { + return r, err + } + out, e := i.run.Run("/usr/bin/dpkg", "--verify", p.Name) + if e != nil || len(out) != 0 { + return r, fmt.Errorf("post-install dpkg verification failed: %s: %s", p.Name, out) + } + } + r.Status = "installed-and-verified" + r.ProviderPreserved = true + r.ConfigurationPreserved = true + return r, nil +} diff --git a/server/security/ubuntu-backports/bundle/installer/installer_test.go b/server/security/ubuntu-backports/bundle/installer/installer_test.go new file mode 100644 index 0000000000..f40c6dd7b3 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/installer_test.go @@ -0,0 +1,420 @@ +package main + +import ( + "archive/tar" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +type fakeRunner struct { + t *testing.T + root, bundle string + installed map[string]Installed + packages map[string]Package + payloads map[string]Payload + tars map[string][]byte + contents map[string][]byte + beforeDiff, afterDiff map[string]string + commands [][]string + installedBatch []string + mutate func() + dryFail bool +} + +func (f *fakeRunner) Run(binary string, args ...string) ([]byte, error) { + f.commands = append(f.commands, append([]string{binary}, args...)) + if binary == "/usr/bin/dpkg-query" && args[0] == "--show" { + names := []string{} + for n := range f.installed { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + p := f.installed[n] + fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Version, p.Architecture, p.Source, p.SourceVersion, p.Status, p.Depends, p.PreDepends) + } + return []byte(b.String()), nil + } + if binary == "/usr/bin/dpkg-query" && args[0] == "--listfiles" { + return []byte("/usr/bin/ls\n"), nil + } + if binary == "/usr/bin/dpkg-deb" { + name := "" + for n, p := range f.packages { + if filepath.Join(f.bundle, filepath.FromSlash(p.Archive.Path)) == args[1] { + name = n + } + } + if name == "" { + return nil, errors.New("unexpected archive") + } + p := f.packages[name] + if args[0] == "--field" { + return []byte(fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: amd64\nSource: %s\n", name, p.Version, p.Source)), nil + } + if args[0] == "--fsys-tarfile" { + return f.tars[name], nil + } + } + if binary == "/usr/bin/dpkg" { + switch args[0] { + case "--print-architecture": + return []byte("amd64\n"), nil + case "--audit": + return nil, nil + case "--compare-versions": + if args[1] > args[3] { + return nil, errors.New("newer installed version") + } + return nil, nil + case "--verify": + if f.installedBatch == nil { + return []byte(f.beforeDiff[args[1]]), nil + } + return []byte(f.afterDiff[args[1]]), nil + case "--no-act": + if f.dryFail { + return nil, errors.New("dependency unsatisfied") + } + return nil, nil + } + installAt := -1 + for n, a := range args { + if a == "--install" { + installAt = n + } + if strings.Contains(a, "force") { + f.t.Fatal("force flag used") + } + } + if installAt >= 0 { + if f.installedBatch != nil { + f.t.Fatal("more than one actual install") + } + f.installedBatch = []string{} + for _, archive := range args[installAt+1:] { + name := "" + for n, p := range f.packages { + if filepath.Join(f.bundle, filepath.FromSlash(p.Archive.Path)) == archive { + name = n + } + } + if name == "" { + f.t.Fatal("unrecognized install path") + } + if _, exists := f.installed[name]; !exists { + f.t.Fatal("added a package") + } + p := f.packages[name] + f.installed[name] = Installed{Name: name, Version: p.Version, Architecture: "amd64", Source: p.Source, SourceVersion: p.Version, Status: "installed"} + f.installedBatch = append(f.installedBatch, name) + for _, file := range f.payloads[name].Files { + target := filepath.Join(f.root, filepath.FromSlash(file.Path)) + must(f.t, os.MkdirAll(filepath.Dir(target), 0755)) + must(f.t, os.WriteFile(target, f.contents[file.Path], os.FileMode(file.Mode))) + } + } + if f.mutate != nil { + f.mutate() + } + return nil, nil + } + } + return nil, fmt.Errorf("unexpected command: %s %v", binary, args) +} +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} +func write(t *testing.T, root, name string, b []byte) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(name)) + must(t, os.MkdirAll(filepath.Dir(p), 0755)) + must(t, os.WriteFile(p, b, 0644)) +} +func jsonFile(t *testing.T, root, name string, value any) Ref { + t.Helper() + b, err := json.Marshal(value) + must(t, err) + write(t, root, name, b) + return Ref{name, digest(b)} +} +func fixture(t *testing.T, names ...string) (Installer, *fakeRunner) { + t.Helper() + root := t.TempDir() + bundle := t.TempDir() + write(t, root, "etc/os-release", []byte("ID=ubuntu\nVERSION_ID=\"26.04\"\n")) + write(t, root, "etc/dpkg/dpkg.cfg.d/docker", []byte("path-exclude=/usr/share/doc/*\n")) + must(t, os.MkdirAll(filepath.Join(root, "var/lib/dpkg/alternatives"), 0755)) + write(t, root, "usr/bin/ls", []byte("provider command")) + f := &fakeRunner{t: t, root: root, bundle: bundle, installed: map[string]Installed{}, packages: map[string]Package{}, payloads: map[string]Payload{}, tars: map[string][]byte{}, contents: map[string][]byte{}, beforeDiff: map[string]string{}, afterDiff: map[string]string{}} + m := Manifest{SchemaVersion: 1, BundleVersion: "0.1.0", Platform: Platform{"ubuntu", "26.04", "amd64"}, Families: []Family{}, Packages: []Package{}} + q := Qualification{SchemaVersion: 1, BundleVersion: m.BundleVersion, Platform: m.Platform, Status: "qualified", Families: []QualifiedFamily{}} + lock := jsonFile(t, bundle, "source-inputs.lock.json", map[string]string{"identity": "source lock"}) + q.SourceLockSHA256 = lock.SHA256 + keys := []string{} + for family := range families { + keys = append(keys, family) + } + sort.Strings(keys) + for _, family := range keys { + lockSHA := digest([]byte(family)) + prov := jsonFile(t, bundle, "provenance/"+family+"/provenance.json", map[string]string{"sourceLockSha256": lockSHA}) + m.Families = append(m.Families, Family{family, family, "2.0", prov}) + qf := QualifiedFamily{Name: family, Status: "qualified", ProvenanceSHA256: prov.SHA256} + qf.NativeInput.LockSHA256 = lockSHA + q.Families = append(q.Families, qf) + for _, name := range families[family] { + p := Package{Name: name, Family: family, Version: "2.0", Architecture: "amd64", Source: family} + payload := Payload{SchemaVersion: 1, Package: name, Version: p.Version, Architecture: "amd64", Source: family, Files: []File{}, ELF: []ELF{}} + var tb bytes.Buffer + tw := tar.NewWriter(&tb) + for _, filename := range []string{"usr/bin/fixture-" + name, "usr/share/doc/" + name + "/README"} { + content := []byte("qualified-" + name + "-" + filename) + file := File{Path: filename, Type: "file", SHA256: digest(content), Mode: 0644, UID: os.Geteuid(), GID: os.Getegid()} + payload.Files = append(payload.Files, file) + f.contents[filename] = content + must(t, tw.WriteHeader(&tar.Header{Name: "./" + filename, Mode: 0644, Uid: file.UID, Gid: file.GID, Size: int64(len(content)), Typeflag: tar.TypeReg})) + _, err := tw.Write(content) + must(t, err) + } + must(t, tw.Close()) + f.tars[name] = tb.Bytes() + p.Archive = Ref{"debs/" + name + ".deb", digest([]byte("archive-" + name))} + write(t, bundle, p.Archive.Path, []byte("archive-"+name)) + p.Payload = jsonFile(t, bundle, "payload/"+name+".json", payload) + m.Packages = append(m.Packages, p) + f.packages[name] = p + f.payloads[name] = payload + } + } + m.Qualification = jsonFile(t, bundle, "qualification.json", q) + jsonFile(t, bundle, "manifest.json", m) + for _, name := range names { + source := name + if p, ok := f.packages[name]; ok { + source = p.Source + } + f.installed[name] = Installed{Name: name, Version: "1.0", Architecture: "amd64", Source: source, SourceVersion: "1.0", Status: "installed"} + if p, ok := f.payloads[name]; ok { + write(t, root, p.Files[0].Path, []byte("original-"+name)) + f.beforeDiff[name] = "missing /usr/share/doc/" + name + "/README\n" + } + } + return Installer{bundle: bundle, root: root, run: f}, f +} +func TestExistingOnlyGNUConsumer(t *testing.T) { + i, f := fixture(t, "libc6", "coreutils-from-gnu", "gnu-coreutils") + r, err := i.Execute(true) + must(t, err) + if !reflect.DeepEqual(f.installedBatch, []string{"libc6"}) || !r.ProviderPreserved || !r.ConfigurationPreserved { + t.Fatalf("wrong batch/receipt: %#v", r) + } + if _, exists := f.installed["rust-coreutils"]; exists { + t.Fatal("Rust was added to a GNU consumer") + } + if !reflect.DeepEqual(r.BeforeMissingDocumentation, []string{"/usr/share/doc/libc6/README"}) || !reflect.DeepEqual(r.ExactPathIncludes, r.BeforeMissingDocumentation) { + t.Fatal("missing docs were not explicitly restored") + } + for _, cmd := range f.commands { + if strings.Contains(strings.Join(cmd, " "), "--install") && cmd[1] != "--no-act" { + if !strings.Contains(strings.Join(cmd, " "), "--path-include=/usr/share/doc/libc6/README") { + t.Fatal("exact path include missing") + } + } + } +} +func TestUutilsProviderPreserved(t *testing.T) { + i, f := fixture(t, "rust-coreutils", "coreutils-from-uutils", "gnu-coreutils") + p := f.installed["coreutils-from-uutils"] + p.PreDepends = "rust-coreutils (>> 0.1.0+git20250813), gnu-coreutils" + f.installed[p.Name] = p + _, err := i.Execute(true) + must(t, err) + if !reflect.DeepEqual(f.installedBatch, []string{"rust-coreutils"}) { + t.Fatal("provider was installed") + } +} +func TestRefuseMissingExactCompanion(t *testing.T) { + i, f := fixture(t, "libc6", "libc6-dev") + p := f.installed["libc6-dev"] + p.Depends = "libc6 (= 1.0)" + f.installed[p.Name] = p + _, err := i.Execute(true) + if err == nil || !strings.Contains(err.Error(), "companion") { + t.Fatalf("expected companion refusal, got %v", err) + } + if f.installedBatch != nil { + t.Fatal("installed despite missing companion") + } +} +func TestTamperedArchiveStopsBeforeDpkg(t *testing.T) { + i, f := fixture(t, "libc6") + write(t, i.bundle, f.packages["jq"].Archive.Path, []byte("tampered unselected input")) + _, err := i.Execute(true) + if err == nil || !strings.Contains(err.Error(), "SHA256") { + t.Fatalf("expected SHA refusal, got %v", err) + } + if f.installedBatch != nil { + t.Fatal("installed tampered bundle") + } +} +func TestPreexistingIntegrityFailures(t *testing.T) { + for _, diff := range []string{"??5?????? /usr/bin/fixture-libc6\n", "missing /usr/bin/fixture-libc6\n", "missing c /etc/ld.so.conf\n", "missing /usr/share/doc/other/README\n"} { + t.Run(strings.TrimSpace(diff), func(t *testing.T) { + i, f := fixture(t, "libc6") + f.beforeDiff["libc6"] = diff + _, err := i.Execute(true) + if err == nil { + t.Fatal("corruption accepted") + } + if f.installedBatch != nil { + t.Fatal("installation reached") + } + }) + } +} +func TestMissingDocsRequireActualPathExclusion(t *testing.T) { + i, f := fixture(t, "libc6") + write(t, i.root, "etc/dpkg/dpkg.cfg.d/docker", []byte("# no exclusions\n")) + _, err := i.Execute(true) + if err == nil || f.installedBatch != nil { + t.Fatal("unexplained missing file accepted") + } +} +func TestRuntimePathExclusionRejected(t *testing.T) { + i, f := fixture(t, "libc6") + f.beforeDiff["libc6"] = "" + write(t, i.root, "etc/dpkg/dpkg.cfg.d/docker", []byte("path-exclude=/usr/bin/*\n")) + _, err := i.Execute(true) + if err == nil || !strings.Contains(err.Error(), "runtime payload") || f.installedBatch != nil { + t.Fatalf("runtime exclusion not rejected: %v", err) + } +} +func TestDryRunFailureNoMutation(t *testing.T) { + i, f := fixture(t, "libc6") + f.dryFail = true + _, err := i.Execute(true) + if err == nil || f.installedBatch != nil { + t.Fatal("failed dependency preflight mutated rootfs") + } +} +func TestPostIntegrityAndConfigFailures(t *testing.T) { + for _, kind := range []string{"dpkg", "payload", "config", "provider"} { + t.Run(kind, func(t *testing.T) { + i, f := fixture(t, "libc6", "coreutils-from-gnu") + switch kind { + case "dpkg": + f.afterDiff["libc6"] = "??5?????? /usr/bin/fixture-libc6\n" + case "payload": + f.mutate = func() { write(t, i.root, "usr/bin/fixture-libc6", []byte("bad")) } + case "config": + f.mutate = func() { write(t, i.root, "etc/os-release", []byte("changed")) } + case "provider": + f.mutate = func() { write(t, i.root, "usr/bin/ls", []byte("switched")) } + } + _, err := i.Execute(true) + if err == nil { + t.Fatal("post-install corruption accepted") + } + }) + } +} +func TestInstalledVerifierIsReadOnly(t *testing.T) { + i, f := fixture(t, "libc6") + _, err := i.Execute(true) + must(t, err) + f.commands = nil + _, err = i.Execute(false) + must(t, err) + for _, cmd := range f.commands { + for _, arg := range cmd { + if arg == "--install" || arg == "--no-act" { + t.Fatal("verifier invoked installation") + } + } + } +} +func TestArchiveReceiptAndTraversalRejection(t *testing.T) { + i, f := fixture(t, "libc6") + p := f.payloads["libc6"] + p.Files[0].SHA256 = digest([]byte("wrong")) + if err := validateArchive(f.tars["libc6"], p); err == nil { + t.Fatal("payload mismatch accepted") + } + var b bytes.Buffer + tw := tar.NewWriter(&b) + must(t, tw.WriteHeader(&tar.Header{Name: "../escape", Mode: 0644, Size: 0, Typeflag: tar.TypeReg})) + must(t, tw.Close()) + if _, _, err := inspectTar(b.Bytes()); err == nil { + t.Fatal("tar traversal accepted") + } + _ = i +} +func TestCLIHasNoPathOverrides(t *testing.T) { + for _, args := range [][]string{{}, {"--lock", "/tmp/x"}, {"--install-in-docker-build", "--verify-installed"}, {"--verify-installed", "/tmp/root"}} { + if err := cli(args, &bytes.Buffer{}); err == nil { + t.Fatalf("invalid CLI accepted: %v", args) + } + } +} +func TestDowngradeRejected(t *testing.T) { + i, f := fixture(t, "libc6") + p := f.installed["libc6"] + p.Version = "3.0" + f.installed[p.Name] = p + _, err := i.Execute(true) + if err == nil || !strings.Contains(err.Error(), "downgrade") || f.installedBatch != nil { + t.Fatalf("downgrade accepted: %v", err) + } +} +func TestHardlinkAndSymlinkTar(t *testing.T) { + var b bytes.Buffer + tw := tar.NewWriter(&b) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/a", Mode: 0644, Size: 3, Typeflag: tar.TypeReg})) + _, err := tw.Write([]byte("abc")) + must(t, err) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/b", Mode: 0644, Typeflag: tar.TypeLink, Linkname: "./usr/lib/a"})) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/c", Mode: 0777, Typeflag: tar.TypeSymlink, Linkname: "/usr/lib/a"})) + must(t, tw.Close()) + files, _, err := inspectTar(b.Bytes()) + must(t, err) + if len(files) != 3 || files[0].SHA256 != files[1].SHA256 || files[2].Target != "/usr/lib/a" { + t.Fatal("hardlink/symlink payload receipt wrong") + } + b.Reset() + tw = tar.NewWriter(&b) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/link", Mode: 0777, Typeflag: tar.TypeSymlink, Linkname: "/etc"})) + must(t, tw.WriteHeader(&tar.Header{Name: "./usr/lib/link/evil", Mode: 0644, Size: 0, Typeflag: tar.TypeReg})) + must(t, tw.Close()) + if _, _, err := inspectTar(b.Bytes()); err == nil { + t.Fatal("archive path below symlink accepted") + } +} +func TestNativeELFABIRejectsDroppedSymbols(t *testing.T) { + const library = "usr/lib/x86_64-linux-gnu/libc.so.6" + b, err := os.ReadFile("/" + library) + must(t, err) + e, err := inspectELF(library, b) + must(t, err) + if e.SONAME == nil || len(e.DefinedSymbols) == 0 { + t.Fatal("native amd64 libc fixture lacks SONAME/exports") + } + p := Payload{ELF: []ELF{e}} + must(t, checkABI("/", p)) + p.ELF[0].DefinedSymbols = []string{} + if err := checkABI("/", p); err == nil || !strings.Contains(err.Error(), "dropped exported symbol") { + t.Fatalf("ABI loss was not rejected: %v", err) + } +} diff --git a/server/security/ubuntu-backports/bundle/installer/main.go b/server/security/ubuntu-backports/bundle/installer/main.go new file mode 100644 index 0000000000..29908f22dc --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/main.go @@ -0,0 +1,75 @@ +// install-existing-backports is a build-only, dependency-free verifier/installer. +// It never invokes a shell, apt, force flags, alternatives, or network tools. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "runtime" +) + +type boundedBuffer struct { + bytes.Buffer + limit int +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + if len(p) > b.limit-b.Len() { + return 0, fmt.Errorf("command output exceeds bound") + } + return b.Buffer.Write(p) +} + +type systemRunner struct{} + +func (systemRunner) Run(binary string, args ...string) ([]byte, error) { + switch binary { + case "/usr/bin/dpkg", "/usr/bin/dpkg-query", "/usr/bin/dpkg-deb": + default: + return nil, fmt.Errorf("command is not allowed") + } + cmd := exec.Command(binary, args...) + cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL=C", "LANG=C", "DEBIAN_FRONTEND=noninteractive", "DPKG_COLORS=never", "HOME=/root", "TERM=dumb"} + var out = boundedBuffer{limit: 768 << 20} + var stderr = boundedBuffer{limit: 64 << 10} + cmd.Stdout = &out + cmd.Stderr = &stderr + cmd.Stdin = nil + err := cmd.Run() + if err != nil { + return out.Bytes(), fmt.Errorf("%s %v: %w; %s", binary, args, err, stderr.String()) + } + return out.Bytes(), nil +} +func cli(args []string, stdout io.Writer) error { + flags := flag.NewFlagSet("install-existing-backports", flag.ContinueOnError) + install := flags.Bool("install-in-docker-build", false, "install the fixed qualified bundle into a disposable Docker build rootfs") + verify := flags.Bool("verify-installed", false, "verify existing installed packages against the fixed qualified bundle") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 || *install == *verify { + return fmt.Errorf("choose exactly one of --install-in-docker-build or --verify-installed; no path overrides are accepted") + } + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || runtime.Version() != "go1.27.3" || os.Geteuid() != 0 { + return fmt.Errorf("requires root inside a Linux amd64 Docker build, compiled with Go 1.27.3") + } + receipt, err := (Installer{bundle: bundleRoot, root: "/", run: systemRunner{}}).Execute(*install) + if err != nil { + return err + } + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + return encoder.Encode(receipt) +} +func main() { + if err := cli(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "BACKPORT_INSTALLATION_FAILED:", err) + os.Exit(1) + } +} diff --git a/server/security/ubuntu-backports/bundle/installer/ownership_linux.go b/server/security/ubuntu-backports/bundle/installer/ownership_linux.go new file mode 100644 index 0000000000..01769d31ec --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/ownership_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + "syscall" +) + +func fileOwnership(info os.FileInfo) (uint32, int, int, error) { + s, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, 0, fmt.Errorf("missing Linux stat ownership") + } + return s.Mode & 07777, int(s.Uid), int(s.Gid), nil +} diff --git a/server/security/ubuntu-backports/bundle/installer/ownership_other.go b/server/security/ubuntu-backports/bundle/installer/ownership_other.go new file mode 100644 index 0000000000..36cd100cea --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/ownership_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import ( + "fmt" + "os" +) + +func fileOwnership(info os.FileInfo) (uint32, int, int, error) { + return 0, 0, 0, fmt.Errorf("installer requires Linux") +} diff --git a/server/security/ubuntu-backports/bundle/installer/payload.go b/server/security/ubuntu-backports/bundle/installer/payload.go new file mode 100644 index 0000000000..4f6a454c34 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/payload.go @@ -0,0 +1,280 @@ +package main + +import ( + "archive/tar" + "bytes" + "debug/elf" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "sort" + "strings" +) + +func unique(values []string) []string { + sort.Strings(values) + out := []string{} + for _, v := range values { + if len(out) == 0 || out[len(out)-1] != v { + out = append(out, v) + } + } + return out +} +func inspectELF(name string, content []byte) (ELF, error) { + e := ELF{Path: name, SHA256: digest(content), ELFClass: 64, Machine: 62, Needed: []string{}, DefinedSymbols: []string{}} + f, err := elf.NewFile(bytes.NewReader(content)) + if err != nil { + return e, err + } + defer f.Close() + if f.Class != elf.ELFCLASS64 || f.Machine != elf.EM_X86_64 || f.Data != elf.ELFDATA2LSB { + return e, fmt.Errorf("non-amd64 ELF: %s", name) + } + soname, err := f.DynString(elf.DT_SONAME) + if err != nil { + return e, err + } + if len(soname) > 1 { + return e, fmt.Errorf("multiple SONAMEs") + } + if len(soname) == 1 { + e.SONAME = &soname[0] + } + e.Needed, err = f.ImportedLibraries() + if err != nil { + return e, err + } + e.Needed = unique(e.Needed) + symbols, err := f.DynamicSymbols() + if err != nil && err != elf.ErrNoSymbols { + return e, err + } + for _, s := range symbols { + if s.Section != elf.SHN_UNDEF && elf.ST_BIND(s.Info) != elf.STB_LOCAL && s.Name != "" { + e.DefinedSymbols = append(e.DefinedSymbols, strings.SplitN(s.Name, "@", 2)[0]) + } + } + e.DefinedSymbols = unique(e.DefinedSymbols) + return e, nil +} + +// Parse the archive without extracting it. All non-directory entries, including +// hardlinks and symlinks, must exactly match the qualified payload receipt. +func inspectTar(content []byte) ([]File, []ELF, error) { + t := tar.NewReader(bytes.NewReader(content)) + files := map[string]File{} + blobs := map[string][]byte{} + links := map[string]string{} + dirs := map[string]bool{} + var total int64 + count := 0 + for { + h, err := t.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, err + } + name := strings.TrimSuffix(strings.TrimPrefix(h.Name, "./"), "/") + if name == "" && h.Typeflag == tar.TypeDir { + continue + } + count++ + total += h.Size + if count > 30000 || h.Size < 0 || total > 1<<30 || !safeRelative(name) || h.Uid < 0 || h.Gid < 0 || h.Mode < 0 || h.Mode > 07777 { + return nil, nil, fmt.Errorf("unsafe/unbounded tar entry %q", h.Name) + } + if _, ok := files[name]; ok || dirs[name] { + return nil, nil, fmt.Errorf("duplicate tar path %s", name) + } + f := File{Path: name, Mode: uint32(h.Mode), UID: h.Uid, GID: h.Gid} + switch h.Typeflag { + case tar.TypeDir: + dirs[name] = true + continue + case tar.TypeReg, tar.TypeRegA: + b, err := io.ReadAll(io.LimitReader(t, 256<<20+1)) + if err != nil { + return nil, nil, err + } + if len(b) > 256<<20 { + return nil, nil, fmt.Errorf("oversize payload file") + } + f.Type = "file" + f.SHA256 = digest(b) + blobs[name] = b + case tar.TypeSymlink: + if h.Linkname == "" || strings.ContainsAny(h.Linkname, "\x00\r\n") { + return nil, nil, fmt.Errorf("invalid symlink") + } + f.Type = "symlink" + f.Target = h.Linkname + case tar.TypeLink: + target := strings.TrimPrefix(h.Linkname, "./") + if !safeRelative(target) { + return nil, nil, fmt.Errorf("unsafe hardlink") + } + f.Type = "file" + links[name] = target + default: + return nil, nil, fmt.Errorf("special payload entry: %s", name) + } + files[name] = f + } + for len(links) > 0 { + progress := false + for name, target := range links { + if b, ok := blobs[target]; ok { + blobs[name] = b + f := files[name] + f.SHA256 = digest(b) + files[name] = f + delete(links, name) + progress = true + } + } + if !progress { + return nil, nil, fmt.Errorf("unresolved/cyclic hardlink") + } + } + result := []File{} + elfs := []ELF{} + cached := map[string]ELF{} + for name, f := range files { + for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) { + if files[parent].Type == "symlink" { + return nil, nil, fmt.Errorf("payload entry below symlink: %s", name) + } + } + result = append(result, f) + if b := blobs[name]; f.Type == "file" && bytes.HasPrefix(b, []byte("\x7fELF")) { + e, ok := cached[f.SHA256] + if !ok { + var err error + e, err = inspectELF(name, b) + if err != nil { + return nil, nil, err + } + cached[f.SHA256] = e + } + e.Path = name + elfs = append(elfs, e) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Path < result[j].Path }) + sort.Slice(elfs, func(i, j int) bool { return elfs[i].Path < elfs[j].Path }) + return result, elfs, nil +} +func validateArchive(content []byte, p Payload) error { + files, elfs, err := inspectTar(content) + if err != nil { + return err + } + if !reflect.DeepEqual(files, p.Files) || !reflect.DeepEqual(elfs, p.ELF) { + return fmt.Errorf("archive differs from qualified payload/ELF receipt: %s", p.Package) + } + return nil +} +func rootPath(root, name string) (string, error) { + if !safeRelative(name) { + return "", fmt.Errorf("unsafe rootfs path %q", name) + } + p := filepath.Join(root, filepath.FromSlash(name)) + parent, err := filepath.EvalSymlinks(filepath.Dir(p)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, parent) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("rootfs symlink escapes root") + } + return filepath.Join(parent, filepath.Base(p)), nil +} +func installedFile(root string, expected File) (File, []byte, error) { + f := File{Path: expected.Path} + p, err := rootPath(root, expected.Path) + if err != nil { + return f, nil, err + } + info, err := os.Lstat(p) + if err != nil { + return f, nil, err + } + f.Mode, f.UID, f.GID, err = fileOwnership(info) + if err != nil { + return f, nil, err + } + if info.Mode()&os.ModeSymlink != 0 { + f.Type = "symlink" + f.Target, err = os.Readlink(p) + return f, nil, err + } + if !info.Mode().IsRegular() || info.Size() > 256<<20 { + return f, nil, fmt.Errorf("special/unbounded installed file: %s", p) + } + f.Type = "file" + b, err := os.ReadFile(p) + if err != nil { + return f, nil, err + } + f.SHA256 = digest(b) + return f, b, nil +} +func verifyPayload(root string, p Payload) error { + elfs := map[string]ELF{} + for _, e := range p.ELF { + elfs[e.Path] = e + } + for _, expected := range p.Files { + got, b, err := installedFile(root, expected) + if err != nil { + return err + } + if got != expected { + return fmt.Errorf("installed payload metadata/content differs: %s", expected.Path) + } + if e, ok := elfs[expected.Path]; ok { + actual, err := inspectELF(expected.Path, b) + if err != nil { + return err + } + if !reflect.DeepEqual(actual, e) { + return fmt.Errorf("installed ELF differs: %s", e.Path) + } + } + } + return nil +} +func checkABI(root string, p Payload) error { + for _, candidate := range p.ELF { + if candidate.SONAME == nil { + continue + } + _, b, err := installedFile(root, File{Path: candidate.Path}) + if err != nil { + return err + } + old, err := inspectELF(candidate.Path, b) + if err != nil { + return err + } + if !reflect.DeepEqual(old.SONAME, candidate.SONAME) || !reflect.DeepEqual(old.Needed, candidate.Needed) { + return fmt.Errorf("library SONAME/NEEDED changed: %s", candidate.Path) + } + available := map[string]bool{} + for _, symbol := range candidate.DefinedSymbols { + available[symbol] = true + } + for _, symbol := range old.DefinedSymbols { + if !available[symbol] { + return fmt.Errorf("library dropped exported symbol %s: %s", symbol, candidate.Path) + } + } + } + return nil +} diff --git a/server/security/ubuntu-backports/bundle/installer/schema.go b/server/security/ubuntu-backports/bundle/installer/schema.go new file mode 100644 index 0000000000..5f42d3e86e --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/schema.go @@ -0,0 +1,238 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +const bundleRoot = "/opt/pasturestack/ubuntu-security-backports" + +var families = map[string][]string{ + "pcre2": {"libpcre2-8-0", "libpcre2-posix3"}, + "glibc": {"libc6", "libc-bin", "libc-gconv-modules-extra"}, + "rust-coreutils": {"rust-coreutils"}, "jq": {"jq", "libjq1"}, + "p11-kit": {"libp11-kit0", "p11-kit", "p11-kit-modules"}, + "libxrender": {"libxrender1"}, +} + +type Platform struct{ Distribution, Release, Architecture string } +type Ref struct{ Path, SHA256 string } +type Family struct { + Name, Source, Version string + Provenance Ref +} +type Package struct { + Name, Family, Version, Architecture, Source string + Archive, Payload Ref +} +type Manifest struct { + SchemaVersion int + BundleVersion string + Platform Platform + Qualification Ref + Families []Family + Packages []Package +} +type QualifiedFamily struct { + Name, Status, ProvenanceSHA256 string + NativeInput struct{ LockSHA256 string } +} +type Qualification struct { + SchemaVersion int + BundleVersion, Status, SourceLockSHA256 string + Platform Platform + Families []QualifiedFamily +} +type File struct { + Path, Type, SHA256, Target string + Mode uint32 + UID, GID int +} +type ELF struct { + Path, SHA256 string + ELFClass, Machine int + SONAME *string + Needed, DefinedSymbols []string +} +type Payload struct { + SchemaVersion int + Package, Version, Architecture, Source string + Files []File + ELF []ELF +} + +var digestPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +var versionPattern = regexp.MustCompile(`^[0-9][A-Za-z0-9.+:~\-]*$`) + +func digest(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } +func check(ok bool, format string, args ...any) error { + if !ok { + return fmt.Errorf(format, args...) + } + return nil +} +func safeRelative(p string) bool { + return p != "" && p != "." && !strings.ContainsAny(p, "\\\x00\r\n:") && + !strings.HasPrefix(p, "/") && path.Clean(p) == p && p != ".." && !strings.HasPrefix(p, "../") +} + +// Every component is checked without following links, even for SHA-bound files. +func regularRead(root, name string, limit int64) ([]byte, error) { + if !safeRelative(name) { + return nil, fmt.Errorf("unsafe bundle path %q", name) + } + p := root + parts := strings.Split(name, "/") + for n, part := range parts { + p = filepath.Join(p, part) + info, err := os.Lstat(p) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || (n < len(parts)-1 && !info.IsDir()) { + return nil, fmt.Errorf("linked/non-directory parent %s", p) + } + if n == len(parts)-1 && (!info.Mode().IsRegular() || info.Size() > limit) { + return nil, fmt.Errorf("not a bounded regular file: %s", p) + } + } + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, fmt.Errorf("file grew beyond size bound: %s", p) + } + return b, nil +} +func boundRead(root string, ref Ref, limit int64) ([]byte, error) { + if !digestPattern.MatchString(ref.SHA256) { + return nil, fmt.Errorf("invalid SHA256 for %s", ref.Path) + } + b, err := regularRead(root, ref.Path, limit) + if err != nil { + return nil, err + } + if digest(b) != ref.SHA256 { + return nil, fmt.Errorf("SHA256 mismatch: %s", ref.Path) + } + return b, nil +} +func decode(b []byte, value any, strict bool) error { + d := json.NewDecoder(bytes.NewReader(b)) + if strict { + d.DisallowUnknownFields() + } + if err := d.Decode(value); err != nil { + return err + } + if err := d.Decode(new(any)); err != io.EOF { + return fmt.Errorf("trailing JSON data") + } + return nil +} +func validatePlatform(p Platform) bool { return p == (Platform{"ubuntu", "26.04", "amd64"}) } + +func loadManifest(root string) (Manifest, map[string]Payload, error) { + var m Manifest + b, err := regularRead(root, "manifest.json", 2<<20) + if err != nil { + return m, nil, err + } + if err = decode(b, &m, true); err != nil { + return m, nil, err + } + if m.SchemaVersion != 1 || m.BundleVersion != "0.1.0" || !validatePlatform(m.Platform) || len(m.Families) != 6 || len(m.Packages) != 12 || m.Qualification.Path != "qualification.json" { + return m, nil, fmt.Errorf("unsupported bundle identity/catalog") + } + qb, err := boundRead(root, m.Qualification, 16<<20) + if err != nil { + return m, nil, err + } + var q Qualification // Native owning-test evidence is family-specific; required fields remain mandatory. + if err = decode(qb, &q, false); err != nil { + return m, nil, err + } + if q.SchemaVersion != 1 || q.BundleVersion != m.BundleVersion || !validatePlatform(q.Platform) || q.Status != "qualified" || len(q.Families) != 6 { + return m, nil, fmt.Errorf("bundle is not qualified") + } + if _, err = boundRead(root, Ref{"source-inputs.lock.json", q.SourceLockSHA256}, 8<<20); err != nil { + return m, nil, err + } + fm := map[string]Family{} + qm := map[string]QualifiedFamily{} + for _, f := range q.Families { + if _, ok := qm[f.Name]; ok { + return m, nil, fmt.Errorf("duplicate qualification family") + } + qm[f.Name] = f + } + for _, f := range m.Families { + _, allowed := families[f.Name] + _, duplicate := fm[f.Name] + qualified, exists := qm[f.Name] + if !allowed || duplicate || f.Source != f.Name || !versionPattern.MatchString(f.Version) || f.Provenance.Path != "provenance/"+f.Name+"/provenance.json" || !exists || qualified.Status != "qualified" || qualified.ProvenanceSHA256 != f.Provenance.SHA256 { + return m, nil, fmt.Errorf("invalid family %q", f.Name) + } + pb, err := boundRead(root, f.Provenance, 32<<20) + if err != nil { + return m, nil, err + } + var provenance struct{ SourceLockSHA256, LockSHA256 string } + if err = decode(pb, &provenance, false); err != nil { + return m, nil, err + } + pin := provenance.SourceLockSHA256 + if pin == "" { + pin = provenance.LockSHA256 + } + if !digestPattern.MatchString(pin) || pin != qualified.NativeInput.LockSHA256 { + return m, nil, fmt.Errorf("source provenance lock mismatch: %s", f.Name) + } + fm[f.Name] = f + } + result := map[string]Payload{} + for _, p := range m.Packages { + f, exists := fm[p.Family] + allowed := false + for _, n := range families[p.Family] { + if n == p.Name { + allowed = true + } + } + _, duplicate := result[p.Name] + if !exists || !allowed || duplicate || p.Source != f.Source || p.Version != f.Version || p.Architecture != "amd64" || !strings.HasPrefix(p.Archive.Path, "debs/") || path.Base(p.Archive.Path) != strings.TrimPrefix(p.Archive.Path, "debs/") || !strings.HasSuffix(p.Archive.Path, ".deb") || p.Payload.Path != "payload/"+p.Name+".json" { + return m, nil, fmt.Errorf("invalid package catalog entry %q", p.Name) + } + if _, err = boundRead(root, p.Archive, 256<<20); err != nil { + return m, nil, err + } + pb, err := boundRead(root, p.Payload, 32<<20) + if err != nil { + return m, nil, err + } + var payload Payload + if err = decode(pb, &payload, true); err != nil { + return m, nil, err + } + if payload.SchemaVersion != 1 || payload.Package != p.Name || payload.Version != p.Version || payload.Source != p.Source || payload.Architecture != p.Architecture || len(payload.Files) == 0 { + return m, nil, fmt.Errorf("payload identity mismatch: %s", p.Name) + } + result[p.Name] = payload + } + return m, result, nil +} diff --git a/server/security/ubuntu-backports/bundle/qualify-native-packages.py b/server/security/ubuntu-backports/bundle/qualify-native-packages.py new file mode 100644 index 0000000000..927a1ffd34 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/qualify-native-packages.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 +"""Qualify exact native package inputs for a build-only OCI bundle; never install. + +CLI inputs are the checked-in lock, current checkout and a fixed disposable +staging directory. A pending native artifact blocks qualification/publication. +""" +import argparse +import base64 +from collections import Counter +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import struct +import subprocess +import sys +import tarfile +import tempfile +from urllib.parse import quote +import urllib.error +import urllib.request +import zipfile + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[3] +LOCK_FILE = HERE / 'source-inputs.lock.json' +RUST_AUDIT_FILE = HERE / 'rust-coreutils-original18-source-closure.json' +RUST_AUDIT_RECEIPT_SHA = '73189028622c0a0fc074067b4c5b8521094921531f95cf3e68073baaebd2fbbd' +WORK = Path(tempfile.gettempdir()) / 'pasturestack-ubuntu-security-backports-v0.1.0' +REPO = 'PastureStack/server' +PLATFORM = {'distribution': 'ubuntu', 'release': '26.04', 'architecture': 'amd64'} +FAMILIES = { + 'pcre2': ['libpcre2-8-0', 'libpcre2-posix3'], + 'glibc': ['libc6', 'libc-bin', 'libc-gconv-modules-extra'], + 'rust-coreutils': ['rust-coreutils'], 'jq': ['jq', 'libjq1'], + 'p11-kit': ['libp11-kit0', 'p11-kit', 'p11-kit-modules'], 'libxrender': ['libxrender1'], +} +BASE = 'server/security/ubuntu-backports/' + + +def require(ok, message): + if not ok: + raise ValueError(message) + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def file_sha(path): + return sha(path.read_bytes()) + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + '\n', encoding='utf-8') + + +def safe_name(name): + require(isinstance(name, str) and name and '\\' not in name and not any(ord(c) < 32 for c in name), + 'Unsafe artifact path') + path = PurePosixPath(name) + require(path.parts and not path.is_absolute() and '..' not in path.parts and ':' not in name + and str(path) == name.rstrip('/'), + 'Artifact path escapes its root') + return path + + +def read_zip(path, digest): + require(file_sha(path) == digest, 'Native ZIP differs from the reviewed artifact pin') + result, names, total = {}, set(), 0 + with zipfile.ZipFile(path) as archive: + require(len(archive.infolist()) <= 10000, 'Too many native artifact entries') + for info in archive.infolist(): + safe_name(info.orig_filename) + safe_name(info.filename) + key = info.filename.rstrip('/').casefold() + require(key not in names and not info.flag_bits & 1, 'Duplicate/encrypted ZIP entry') + names.add(key) + mode = info.external_attr >> 16 + require(not stat.S_ISLNK(mode) and stat.S_IFMT(mode) in (0, stat.S_IFREG, stat.S_IFDIR), + 'ZIP special files are forbidden') + total += info.file_size + require(info.file_size <= 256 * 1024 * 1024 and total <= 1024 * 1024 * 1024, + 'Native ZIP exceeds bounded evidence size') + require('unqualified' not in info.filename.lower(), 'Unqualified diagnostics cannot be inputs') + if not info.is_dir(): + result[info.filename] = archive.read(info) + return result + + +def load_lock(ready=False): + lock = json.loads(LOCK_FILE.read_text(encoding='utf-8')) + require(lock['schemaVersion'] == 1 and lock['repository'] == REPO + and lock['bundleVersion'] == '0.1.0' and lock['platform'] == PLATFORM, + 'Unexpected bundle identity') + require(set(lock['nativeInputs']) == set(FAMILIES) - {'pcre2'}, 'Wrong native family set') + for family, item in lock['nativeInputs'].items(): + require(item['runtimePackages'] == FAMILIES[family], 'Unexpected native runtime package set') + require(re.fullmatch('[0-9a-f]{40}', item['sourceCommit']) and type(item['runId']) is int, + 'Native run/source must be immutable') + if item['status'] == 'pending': + require(family == 'glibc' and item['artifactId'] is None and item['zipSha256'] is None, + 'Pending input must not invent an artifact') + require(not ready, 'glibc native artifact is pending; publication is blocked') + continue + require(item['status'] == 'qualified' and type(item['artifactId']) is int + and type(item['jobId']) is int and re.fullmatch('[0-9a-f]{64}', item['zipSha256']), + 'Native artifact is not pinned/qualified') + require(len(item['packages']) == len(FAMILIES[family]) + and {p['name'] for p in item['packages']} == set(FAMILIES[family]), 'Wrong pinned deb set') + for package in item['packages']: + require(re.fullmatch('[0-9a-f]{64}', package['sha256']) and + safe_name(package['file']).name == package['file'] and package['source'] == family + and package['version'] == item['packages'][0]['version'], 'Unpinned/unsafe deb') + require(lock['pcre2']['kind'] == 'fresh-production-target' + and lock['pcre2']['runtimePackages'] == FAMILIES['pcre2'] + and lock['pcre2']['target'] == 'runtime_security_packages' + and lock['pcre2']['sourceTag'] == 'v1.6.551' + and re.fullmatch('[0-9a-f]{40}', lock['pcre2']['sourceCommit']) + and re.fullmatch('[0-9a-f]{64}', lock['pcre2']['lockSha256']) + and re.fullmatch('[0-9a-f]{64}', lock['pcre2']['copyrightSha256']), 'Wrong PCRE2 build contract') + load_rust_source_audit(lock['nativeInputs']['rust-coreutils']) + return lock + + +def load_rust_source_audit(item, provenance=None): + ref = item.get('sourceAudit', {}) + require(ref.get('path') == RUST_AUDIT_FILE.name + and ref.get('originalReceiptSha256') == RUST_AUDIT_RECEIPT_SHA + and file_sha(RUST_AUDIT_FILE) == ref.get('sha256'), 'Rust original18 source audit is missing or changed') + public = json.loads(RUST_AUDIT_FILE.read_text(encoding='utf-8')); audit = public['audit'] + require(public['schemaVersion'] == 1 and public['originalReceiptSha256'] == RUST_AUDIT_RECEIPT_SHA + and public['projection']['excludedFields'] == ['identity.originalZip'] + and 'originalZip' not in audit['identity'] and audit['schemaVersion'] == 1 + and audit['status'] == 'PASS_ORIGINAL18_SOURCE_CLOSURE', 'Unapproved Rust audit projection/status') + native = json.loads((HERE.parent / 'rust-coreutils' / 'rust-coreutils.lock.json').read_text(encoding='utf-8')) + identity, package = audit['identity'], item['packages'][0] + for field, expected in {'ciRun': item['runId'], 'ciJob': item['jobId'], 'artifactId': item['artifactId'], + 'producerCommit': item['sourceCommit'], 'originalZipSha256': item['zipSha256'], + 'lockSha256': item['lockSha256'], 'provenanceSha256': item['evidence']['provenance.json'], + 'packageSha256': package['sha256'], 'upstreamCommit': native['upstream']['baseCommit'], + 'ubuntuSourceVersion': native['ubuntuSourceVersion'], 'sourcePublication': native['sourcePublication']}.items(): + require(identity[field] == expected, 'Rust source audit native identity mismatch: ' + field) + require(audit['scope']['componentPackage'] == package['file'] + and audit['scope']['customVersion'] == package['version'] + and [{k: f[k] for k in ('name', 'url', 'sha256', 'size')} for f in identity['sourceFiles']] == native['sourceFiles'] + and all(f['verified'] is True for f in identity['sourceFiles']) + and identity['officialUpstreamArchive']['freshlyReadEqualsUbuntuOrig'] is True + and identity['officialUpstreamArchive']['sha256'] == native['upstream']['archiveSha256'], + 'Rust source audit original Ubuntu/upstream inputs disagree') + original = {r['id']: r for r in native['alreadyPresentFixes']} + rows = {r['id']: r for r in audit['results']} + new = {c for p in native['patches'] for c in p['fixedCves']} + require(len(audit['results']) == len(original) == 18 and set(rows) == set(original) + and len(item['fixedVulnerabilities']) == len(set(item['fixedVulnerabilities'])) + and set(item['fixedVulnerabilities']) == set(original) | new, 'Rust fixed set lacks exact original18 audit coverage') + commits = {c['commit']: c for c in audit['officialCommitPatchEvidence']} + for identifier, row in rows.items(): + require(row['status'] == 'fixed' and row['sourceFixPresent'] is True and row['effectiveRevertFound'] is False + and row['verdictScope'] == 'qualified-component-source-closure-only' + and row['lockMergeOrCompletionAnchor'] == original[identifier]['commit'] + and row['additionalLockCommits'] == original[identifier].get('additionalCommits', []) + and row['sourceEvidence'] and row['officialCodeCommits'] + and all(c in commits and re.fullmatch('[0-9a-f]{64}', commits[c]['officialPatchSha256']) + for c in row['officialCodeCommits']) + and row['existingNativeLog']['reused'] is True + and row['existingNativeLog']['completeCveSpecificDynamicCoverageClaimed'] is False, + 'Rust CVE lacks source/control/official-commit evidence: ' + identifier) + for evidence in row['sourceEvidence']: + safe_name(evidence['path']) + require(re.fullmatch('[0-9a-f]{64}', evidence['sha256']) + and type(evidence['lineStart']) is int and 0 < evidence['lineStart'] <= evidence['lineEnd'] + and (evidence['path'] not in native['baselineSha256'] + or evidence['sha256'] == native['baselineSha256'][evidence['path']]), 'Rust source evidence pin/line mismatch') + validation = audit['validation']; boundary = audit['sourceBuildBoundary']['customPatch'] + require(validation['status'] == 'PASS' and validation['originalCves'] == validation['nativeSourcePinsMatched'] == 18 + and all(validation[k] is True for k in ['rawPackageShaMatched', 'producerLockBytesMatch', + 'ubuntuOrigEqualsFreshOfficialArchive', 'originalUbuntuQuiltSourceControlsReviewed']) + and validation['newBuildTestScanExecuted'] is False + and audit['ubuntuQuilt']['matchedNativeBaselineFilePins'] == 18 + and audit['ubuntuQuilt']['originalRulesAndPackagingPreserved'] is True + and boundary['sha256'] == native['patches'][0]['sha256'] + and boundary['onlyTargetPaths'] == ['src/uu/id/src/id.rs', 'tests/by-util/test_id.rs'] + and boundary['original18SourceUntouched'] is True, 'Rust bounded source/quilt validation is incomplete') + if provenance is not None: + reused, actual = audit['nativeEvidenceReused']['strictOwningSuite'], provenance['strictOwningSuite'] + require(reused['observedTotals'] == actual['observedTotals'] + and reused['strictLogSha256'] == actual['sha256'] + and reused['lognameCoverage'] == actual['lognameCoverage'] + and reused['originalUbuntuRulesSha256'] == actual['owningTestContract']['rulesSha256'] + and audit['ubuntuQuilt']['seriesSha256'] == provenance['packagingPreserved']['debian/patches/series'] + and all(p['sha256'] == provenance['packagingPreserved']['debian/patches/' + p['patch']] + for p in audit['ubuntuQuilt']['enabledPatches']), 'Rust audit/native limits or quilt receipt mismatch') + return public + + +def check_source_files(root, expected): + for name, digest in expected.items(): + safe_name(name) + require(re.fullmatch('[0-9a-f]{64}', digest), 'Invalid source SHA-256') + if file_sha(root / name) != digest: + # Windows checkouts may apply core.autocrlf. Accept only the exact + # repository clean-filter result, not arbitrary text normalization. + canonical = subprocess.check_output(['git', 'show', 'HEAD:' + name], cwd=root) + actual = subprocess.check_output(['git', 'hash-object', '--path=' + name, name], cwd=root).strip() + committed = subprocess.check_output(['git', 'rev-parse', 'HEAD:' + name], cwd=root).strip() + require(sha(canonical) == digest and actual == committed, + 'Current module/CI source closure changed: ' + name) + + +def check_current_source(lock): + # Squash merging and deleting a candidate branch must not make its exact + # reviewed producer commit disappear from a fresh main checkout. + ensure_native_sources(lock) + for item in lock['nativeInputs'].values(): + check_source_files(ROOT, item['sourceFiles']) + for name, digest in item['sourceFiles'].items(): + original = command(['git', 'show', item['sourceCommit'] + ':' + name]) + require(sha(original) == digest, 'Module source differs from the qualified native commit: ' + name) + for name, proof in item['ciClosure'].items(): + check_source_files(ROOT, {name: proof['currentSha256']}) + original = subprocess.check_output(['git', 'show', item['sourceCommit'] + ':' + name], cwd=ROOT) + require(sha(original) == proof['nativeSha256'], 'Native CI source pin changed') + + +def ensure_native_sources(lock): + for commit in sorted({item['sourceCommit'] for item in lock['nativeInputs'].values()}): + require(re.fullmatch('[0-9a-f]{40}', commit), 'Native producer must be an exact commit SHA') + probe = ['git', 'cat-file', '-e', commit + '^{commit}'] + if subprocess.run(probe, cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode: + subprocess.run(['git', 'fetch', '--no-tags', 'origin', commit], cwd=ROOT, check=True) + subprocess.run(probe, cwd=ROOT, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def check_pcre2_source(lock): + source = ROOT.parent / 'pcre2-source' + require(command(['git', '-C', str(source), 'rev-parse', 'HEAD']).decode().strip() == lock['pcre2']['sourceCommit'], + 'Wrong production PCRE2 checkout') + check_source_files(source, lock['pcre2']['sourceFiles']) + + +def gh_json(endpoint): + return json.loads(subprocess.check_output(['gh', 'api', 'repos/' + REPO + '/' + endpoint])) + + +def verify_api(family, item, run, job, artifact): + require(run['id'] == item['runId'] and run['status'] == 'completed' + and run['head_sha'] == item['sourceCommit'] + and run['conclusion'] == item['runConclusion'] + and run['path'] == '.github/workflows/ubuntu-backport-native.yml', 'Native run API mismatch') + # jq's selected job passed in a matrix run whose other jobs failed. Preserve + # that run conclusion; never promote the whole run to success. + require(job['id'] == item['jobId'] and job['name'] == 'Native (' + family + ')' + and job['status'] == 'completed' and job['conclusion'] == 'success' + and job['head_sha'] == item['sourceCommit'] and job['run_id'] == item['runId'], + 'Selected native job did not succeed') + require(artifact['id'] == item['artifactId'] and not artifact['expired'] + and artifact['digest'] == 'sha256:' + item['zipSha256'] + and artifact['name'] == item['artifactName'] + and artifact['workflow_run']['id'] == item['runId'] + and artifact['workflow_run']['head_sha'] == item['sourceCommit'], 'Artifact API ownership mismatch') + + +def fetch_native(lock): + require(not WORK.exists(), 'Bundle staging directory must be new') + (WORK / 'inputs').mkdir(parents=True) + for family, item in lock['nativeInputs'].items(): + run = gh_json('actions/runs/' + str(item['runId'])) + job = gh_json('actions/jobs/' + str(item['jobId'])) + artifact = gh_json('actions/artifacts/' + str(item['artifactId'])) + verify_api(family, item, run, job, artifact) + write_json(WORK / 'inputs' / (family + '-api.json'), {'run': run, 'job': job, 'artifact': artifact}) + with (WORK / 'inputs' / (family + '.zip')).open('xb') as stream: + subprocess.run(['gh', 'api', 'repos/' + REPO + '/actions/artifacts/' + + str(item['artifactId']) + '/zip'], stdout=stream, check=True) + require(file_sha(WORK / 'inputs' / (family + '.zip')) == item['zipSha256'], 'Downloaded ZIP pin mismatch') + + +def registry_manifest(mode): + headers = {} + if mode != 'anonymous': + credentials = (os.environ['GITHUB_ACTOR'] + ':' + os.environ['REGISTRY_TOKEN']).encode() + headers['Authorization'] = 'Basic ' + base64.b64encode(credentials).decode() + scope = 'pull' if mode == 'anonymous' else 'pull,push' + request = urllib.request.Request('https://ghcr.io/token?service=ghcr.io&scope=repository:pasturestack/ubuntu-security-backports:' + scope, + headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + token = json.load(response)['token'] + headers = {'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json'} + request = urllib.request.Request('https://ghcr.io/v2/pasturestack/ubuntu-security-backports/manifests/v0.1.0', headers=headers) + try: + with urllib.request.urlopen(request, timeout=30) as response: + data = response.read(4 * 1024 * 1024 + 1) + require(len(data) <= 4 * 1024 * 1024, 'Registry manifest exceeds size bound') + except urllib.error.HTTPError as error: + body = json.load(error); codes = {entry['code'] for entry in body.get('errors', [])} + require(mode == 'absent' and error.code == 404 and codes + and codes <= {'MANIFEST_UNKNOWN', 'NAME_UNKNOWN'}, 'Registry response does not prove tag absence/public visibility') + print('NEW_NUMERIC_TAG_CONFIRMED'); return + require(mode != 'absent', 'Refusing to overwrite existing v0.1.0') + digest = json.loads((WORK / 'publisher' / 'image-metadata.json').read_text())['containerimage.digest'] + require(re.fullmatch('sha256:[0-9a-f]{64}', digest) and 'sha256:' + sha(data) == digest, + 'Registry manifest differs from the exact pushed digest') + (WORK / 'publisher' / (mode + '-manifest.json')).write_bytes(data) + + +def module_helper(family): + script = 'build-jq-release.py' if family == 'jq' else 'build-' + family + '-backport.py' + file = ROOT / BASE / family / script + spec = importlib.util.spec_from_file_location('qualified_' + family.replace('-', '_'), file) + module = importlib.util.module_from_spec(spec) + sys.dont_write_bytecode = True + spec.loader.exec_module(module) + return module + + +def check_owning_tests(family, files, provenance, directory): + directory.mkdir() + if family in ('jq', 'pcre2'): + expected = ['pcre2_jit_test', 'RunTest', 'RunGrepTest'] if family == 'pcre2' else [ + 'mantest', 'jqtest', 'shtest', 'utf8test', 'base64test', 'uritest', 'optionaltest', 'onigtest', 'manonigtest'] + tests = provenance['tests'] + require([row['test'] for row in tests] == expected, 'Owning test set changed') + for row in tests: + data = files[row['test'] + '.trs'] + require(sha(data) == row['sha256'] and row['status'] == 'PASS' + and re.findall(rb'^:test-result: (\S+)$', data, re.M) == [b'PASS'] + and re.findall(rb'^:global-test-result: (\S+)$', data, re.M) == [b'PASS'] + and row['test'] + '.log' in files, 'Native owning test did not pass') + if family == 'jq': + smoke = provenance['packageSmoke'] + require(smoke['status'] == 'PASS' and smoke['validInputCases'] == 4 + and smoke['soname'] == 'libjq.so.1' and smoke['systemOniguruma'] == 'libonig.so.5' + and smoke['publicAbiSymbols'] > 0, 'jq original ABI/system oniguruma smoke missing') + return {'status': 'qualified', 'owningTests': expected} + helper = module_helper(family) + if family == 'p11-kit': + suite = provenance['tests']['fullOwningSuite'] + (directory / 'test-suite.log').write_bytes(files['test-suite.log']) + for row in suite['receipts']: + path = safe_name(row['path']) + for suffix in ('.trs', '.log'): + name = str(path.with_suffix(suffix)) + target = directory / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(files[name.replace('/', '__')]) + result = helper.full_suite(directory, suite['configuredTestLogs'])[0] + require(result['counts'] == suite['counts'] and result['uniqueCaseTotal'] == suite['uniqueCaseTotal'], + 'P11 actual owning evidence disagrees with provenance') + require(provenance['tests']['packagedCliAndPkcs11'] == 'PASS', 'P11 package smoke missing') + return {'status': 'qualified', 'counts': result['counts'], 'uniqueCaseTotal': result['uniqueCaseTotal'], + 'architectureCaveat': provenance['tests']['cve18938ArchitectureCaveat']} + if family == 'rust-coreutils': + log = directory / 'strict-tests.log'; log.write_bytes(files['strict-tests.log']) + suite = provenance['strictOwningSuite'] + result = helper.strict_test_receipt(log, suite['owningTestContract'], + 'true' if suite['lognameCoverage']['ciMarkerForwarded'] else None) + require(result['sha256'] == suite['sha256'] and len(result['idCasesReportedOk']) == 19, + 'Rust owning-test evidence changed') + credentials = provenance['distinctCredentialBinaryTests'] + cases = [(65534, 1, [2]), (1, 65534, [1, 2, 65534]), (65534, 65534, [2])] + require([(c['realGid'], c['effectiveGid'], c['supplementaryGids']) for c in credentials] == cases, + 'Packaged distinct-GID case set changed') + for case in credentials: + real, effective, groups = case['realGid'], case['effectiveGid'], case['supplementaryGids'] + outputs = case['outputs']; expected = list(dict.fromkeys([real, effective] + sorted(groups))) + require(case['status'] == 'PASS' and all(list(map(int, outputs[flag].split())) == expected + for flag in ['-G', '-G -r']) and outputs['-g'] == str(effective) + and outputs['-g -r'] == str(real), 'Packaged distinct-GID numeric outputs failed') + match = re.search(r' groups=([^\s]+)', outputs['']) + actual = [int(x.split('(')[0]) for x in match[1].split(',')] if match else [] + require(actual == list(dict.fromkeys([effective] + sorted(groups))), 'Packaged default GID ordering failed') + return {'status': 'qualified', 'idCases': 19, 'credentialCases': 3, + 'owningTestContract': suite['owningTestContract'], 'lognameCoverage': suite['lognameCoverage']} + if family == 'libxrender': + cases = json.loads(files['reply-boundary.json']) + require([row['test'] for row in cases] == ['patched', 'original', 'packaged'], 'XRender fixture set changed') + for row in cases: + helper.check_result(row['exitCode'], row['output'], row['test'] == 'original') + require(provenance['tests']['owningCheck'] == 'PASS' + and provenance['tests']['upstreamDefinedTestCases'] == 0, 'XRender owning contract changed') + return {'status': 'qualified', 'upstreamDefinedTestCases': 0, 'replyBoundaryCases': cases} + require(family == 'glibc', 'Unknown owning-test family') + before, approved = provenance['debianPackagingInputSha256'], provenance['approvedPackagingSha256'] + generated = provenance['generatedControlMetadata'] + # The original quilt series is extended by exactly the two approved patches. + additions = {'debian/patches/' + p['name']: p['sha256'] for p in provenance['patches']} + require(set(approved) == set(before) | set(additions) + and all(approved[name] == digest for name, digest in additions.items()) + and sorted(name for name in before if before[name] != approved[name]) == ['debian/control', 'debian/patches/series'] + and before['debian/control'] == helper.CONTROL_ORIGINAL_SHA256 == generated['originalSha256'] + and approved['debian/control'] == helper.CONTROL_GENERATED_SHA256 == generated['generatedSha256'] + and sha(files['generated-debian-control']) == helper.CONTROL_GENERATED_SHA256 + and all(before[name] == digest for name, digest in generated['inputSha256'].items()), + 'glibc original-template control generation proof changed') + entries = helper.parse_test_summary(files['tests.sum'].decode('utf-8')) + counts = dict(Counter(state for state, _ in entries)); states = {name: state for state, name in entries} + require(not any(counts.get(s, 0) for s in ['FAIL', 'ERROR', 'UNRESOLVED']) + and all(states.get(name) == 'PASS' for name in helper.CONTROLS) + and counts == provenance['tests']['counts'] + and sha(files['tests.sum']) == provenance['tests']['sha256'] + and provenance['tests']['ubuntuGate'] == 'check_libc_passed', 'glibc native test contract failed') + for component in ['libio', 'stdio-common', 'iconv', 'wcsmbs', 'malloc', 'elf', 'nptl', 'nss']: + require(any(state == 'PASS' and name.startswith(component + '/') for state, name in entries), + 'glibc component suite did not execute: ' + component) + for name in helper.CONTROLS: + require(name.rsplit('/', 1)[-1] + '.out' in files + and b'PASS' in files[name.rsplit('/', 1)[-1] + '.test-result'], 'glibc control output is missing') + return {'status': 'qualified', 'counts': counts, 'controls': provenance['tests']['controls']} + + +def validate_native(family, item, archive, api, root, test_directory): + verify_api(family, item, api['run'], api['job'], api['artifact']) + check_source_files(root, item['sourceFiles']) + contents = read_zip(archive, item['zipSha256']) + require(contents['source-sha.txt'].decode().strip() == item['sourceCommit'], 'Artifact checkout identity mismatch') + result = json.loads(contents['export/result.json']) + require(result['module'] == family and result['sourceSha'] == item['sourceCommit'] + and result['status'] == 'passed' + and all(type(result[k]) is int and result[k] == 0 for k in ['exitCode', 'helperExitCode', 'logExitCode']) + and result['sourceLockSha256'] == item['lockSha256'], 'Native exit/lock receipt failed') + files = {name.removeprefix('export/package/'): data for name, data in contents.items() + if name.startswith('export/package/')} + for name, digest in item['evidence'].items(): + require(name in files and sha(files[name]) == digest, 'Native evidence pin mismatch: ' + name) + require(sha(files[item['copyright']['file']]) == item['copyright']['sha256'], 'Original COPYRIGHT pin mismatch') + provenance = json.loads(files['provenance.json']) + require(provenance.get('sourceLockSha256', provenance.get('lockSha256')) == item['lockSha256'] + and sha(files[family + '.lock.json']) == item['lockSha256'], 'Native source lock mismatch') + records = provenance.get('runtimePackages') or [{'package': family, 'file': provenance['package'], + 'sha256': provenance['packageSha256']}] + require({r['package'] for r in records} == set(item['runtimePackages']), 'Unexpected native runtime package catalog') + for package in item['packages']: + require(sha(files[package['file']]) == package['sha256'] + and any(r['package'] == package['name'] and r['file'] == package['file'] + and r['sha256'] == package['sha256'] for r in records), 'Deb differs from reviewed native bytes') + require({name for name in files if name.endswith('.deb')} == {p['file'] for p in item['packages']}, + 'Extra native debs are forbidden') + tests = check_owning_tests(family, files, provenance, test_directory) + if family == 'rust-coreutils': load_rust_source_audit(item, provenance) + return files, provenance, tests + + +def command(args): + return subprocess.check_output(args, cwd=ROOT, stderr=subprocess.PIPE) + + +def package_payload(archive, expected, directory): + fields = {key: command(['dpkg-deb', '--field', str(archive), key]).decode().strip() + for key in ['Package', 'Version', 'Architecture', 'Source']} + require(fields['Package'] == expected['name'] and fields['Version'] == expected['version'] + and fields['Architecture'] == 'amd64' and fields['Source'] in + ['', expected['source'], expected['source'] + ' (' + expected['version'] + ')'], 'Deb control identity mismatch') + require(fields['Source'] or expected['name'] == expected['source'], 'Deb Source omission is not permitted here') + data = command(['dpkg-deb', '--fsys-tarfile', str(archive)]) + files, blobs, pending, elf, members, elf_cache = [], {}, [], [], {}, {} + with tarfile.open(fileobj=io.BytesIO(data)) as source: + require(sum(m.size for m in source.getmembers()) < 1024 * 1024 * 1024, 'Deb payload exceeds size bound') + for member in source.getmembers(): + name = member.name.removeprefix('./').rstrip('/') + if name == '.' and member.isdir(): continue + if not name and member.isdir(): continue + path = str(safe_name(name)) + require(path not in members, 'Duplicate deb payload path') + members[path] = member + if member.isdir(): continue + item = {'path': path, 'mode': stat.S_IMODE(member.mode), 'uid': member.uid, 'gid': member.gid} + if member.issym(): + item.update(type='symlink', target=member.linkname) + elif member.isreg(): + content = source.extractfile(member).read(); blobs[path] = content + item.update(type='file', sha256=sha(content)) + elif member.islnk(): + item.update(type='file'); pending.append((item, str(safe_name(member.linkname.removeprefix('./'))))) + else: + raise ValueError('Special deb payload entries are forbidden') + files.append(item) + while pending: + next_pending = [] + for item, target in pending: + if target in blobs: + blobs[item['path']] = blobs[target]; item['sha256'] = sha(blobs[target]) + else: next_pending.append((item, target)) + require(len(next_pending) < len(pending), 'Unresolved/cyclic deb hardlink') + pending = next_pending + for item in files: + require(not any(parent.as_posix() in members and not members[parent.as_posix()].isdir() + for parent in PurePosixPath(item['path']).parents), 'Deb entry has a non-directory parent') + if item['type'] != 'file' or not blobs[item['path']].startswith(b'\x7fELF'): continue + content = blobs[item['path']] + require(len(content) >= 64 and content[4:6] == b'\x02\x01' and struct.unpack_from('= 64 and binary[:6] == b'\x7fELF\x02\x01' + and struct.unpack_from('= 4, 'Consumer install/verify/console evidence is missing') + records.append({'provider': provider, 'status': 'PASS', 'inputManifestSha256': manifest_sha, + 'selectedPackages': selected, 'checks': checks, 'evidence': evidence}) + qualification['consumerFixtures'] = records + qualification['consumerStatus'] = ('Both isolated provider fixtures passed actual installation and verification; ' + 'real consumer image rootfs scanning and product acceptance remain required.') + write_json(output / 'qualification.json', qualification) + manifest = json.loads((output / 'manifest.json').read_text()) + manifest['qualification']['sha256'] = file_sha(output / 'qualification.json') + write_json(output / 'manifest.json', manifest) + write_checksums(output) + print('BOTH_CONSUMER_INSTALL_FIXTURES_PASS') + + +def verify_readback(): + expected, actual = WORK / 'context' / 'out', WORK / 'readback' / 'out' + inventory = lambda root: {file.relative_to(root).as_posix(): file_sha(file) + for file in root.rglob('*') if file.is_file() and not file.is_symlink()} + require(expected.is_dir() and actual.is_dir() and inventory(expected) == inventory(actual), + 'Anonymous OCI readback differs from qualified producer bytes') + require(not any(p.is_symlink() for p in actual.rglob('*')), 'OCI data readback contains a symlink') + require(file_sha(WORK / 'readback' / 'bundle-installer') == file_sha(expected / 'install-existing-backports'), + 'OCI installer copies differ') + print('ANONYMOUS_OCI_READBACK_OK') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + for option in ['check-lock', 'require-ready', 'print-work-root', 'fetch', 'check-pcre2-source', 'qualify', + 'verify-readback', 'check-new-tag', 'verify-published-manifest', 'verify-anonymous-manifest', + 'record-consumer-tests']: + mode.add_argument('--' + option, action='store_true') + args = parser.parse_args() + if args.print_work_root: print(WORK); return + if args.verify_readback: verify_readback(); return + if args.record_consumer_tests: record_consumer_tests(); return + if args.check_new_tag: registry_manifest('absent'); return + if args.verify_published_manifest: registry_manifest('published'); return + if args.verify_anonymous_manifest: registry_manifest('anonymous'); return + lock = load_lock(ready=args.require_ready or args.fetch or args.qualify) + if args.check_lock or args.require_ready: + check_current_source(lock); print('BUNDLE_SOURCE_LOCK_OK'); return + if args.fetch: fetch_native(lock); return + if args.check_pcre2_source: check_pcre2_source(lock); return + qualify(lock) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, subprocess.SubprocessError, zipfile.BadZipFile) as error: + print('BUNDLE_QUALIFICATION_FAILED: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/server/security/ubuntu-backports/bundle/rust-coreutils-original18-source-closure.json b/server/security/ubuntu-backports/bundle/rust-coreutils-original18-source-closure.json new file mode 100644 index 0000000000..3216014e10 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/rust-coreutils-original18-source-closure.json @@ -0,0 +1,1874 @@ +{ + "audit": { + "actionsNotPerformed": [ + "\u672a\u4fee\u6539repo\uff0fsource tree\uff0flock", + "\u672a\u4fee\u6539canonical\uff0fSQLite\uff0fExcel", + "\u672a\u7de8\u8b6f\u6216\u57f7\u884c\u4efb\u4f55\u65b0\u6e2c\u8a66", + "\u672a\u8dd1\u6383\u63cf\u5668\u6216\u65b0\u589eagents", + "\u672a\u767c\u5e03\uff0f\u90e8\u7f72\uff0f\u64cd\u4f5cVM\u6216\u6b63\u5f0f\u7ad9" + ], + "asOfUtc": "2026-10-24 20:08:38 UTC", + "identity": { + "artifactId": 12571737310, + "ciJob": 120951205262, + "ciRun": 40120637646, + "dscSha256ManifestEntriesVerified": 3, + "firstPartyIdentity": "first-party rebuild; not issued by Canonical", + "lockSha256": "e52adb460ec7b01accc2f3f9cb3dccc8923e2619d5ced41aae1722494c81bd41", + "officialUpstreamArchive": { + "bytes": 2671039, + "freshlyReadEqualsUbuntuOrig": true, + "sha256": "f8e68cd0e3629378f047544ead272161a83211c43f4985a9f52944e5db8f1a44", + "url": "https://codeload.github.com/uutils/coreutils/tar.gz/refs/tags/0.10.0" + }, + "originalZipSha256": "7f4b4780a492425bb9de239d67c6d594ccd3e8c0c85ac03eaa85035248ac5e2b", + "packageSha256": "f90cdece3ffa01acc9153e3741725febb696e02db8ccf6c0d0c4f98ff93ec609", + "producerCommit": "07e4a7afa827cb45514a72aba12f03b1d30d121d", + "producerTree": "9b96026ba0e17b2a12269705ab19ff5f95fe098d", + "provenanceSha256": "f06fd1b74848aeb0ef9d23f38c3e0700e167d935e7c7fcf7879c8e3561c23391", + "signatures": "SHA/source identity\u5df2\u6838\uff1b\u672a\u5ba3\u7a31PGP\uff0fGit commit/tag signature\u9a57\u8b49\u3002\u5b98\u65b90.10 annotated tag API\u6a19\u793aunsigned\u3002", + "sourceFiles": [ + { + "name": "rust-coreutils_0.10.0-1ubuntu2~26.04.1.dsc", + "sha256": "c7eb62e7d2a07ec84c18bb2a6694331bb32bd0bde316261cfbe2f90acb2b6d28", + "size": 8746, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0-1ubuntu2~26.04.1.dsc", + "verified": true + }, + { + "name": "rust-coreutils_0.10.0.orig.tar.gz", + "sha256": "f8e68cd0e3629378f047544ead272161a83211c43f4985a9f52944e5db8f1a44", + "size": 2671039, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0.orig.tar.gz", + "verified": true + }, + { + "name": "rust-coreutils_0.10.0.orig-l10n.tar.gz", + "sha256": "3df90fabddfb28c7ae1d31237fc8abf70bb1f6c1937d818008943a11e31cfd5b", + "size": 780526, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0.orig-l10n.tar.gz", + "verified": true + }, + { + "name": "rust-coreutils_0.10.0-1ubuntu2~26.04.1.debian.tar.xz", + "sha256": "61eda389122a85e90e542bf6b12c840ab68ee3741958db539f24a3599f5d4bb2", + "size": 22541108, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/r/rust-coreutils/rust-coreutils_0.10.0-1ubuntu2~26.04.1.debian.tar.xz", + "verified": true + } + ], + "sourcePublication": "https://api.launchpad.net/1.0/ubuntu/+archive/primary/+sourcepub/18747163", + "ubuntuSourceVersion": "0.10.0-1ubuntu2~26.04.1", + "upstreamCommit": "28b6856d7b215bf844b4223589cb54ade84f5223", + "upstreamRepository": "https://github.com/uutils/coreutils", + "upstreamTag": "0.10.0", + "upstreamTagObject": "53e6f64b1519ebd0b18dfaf07e7006f233bbcc46", + "upstreamTree": "e9b7a01352471d10a542b30580fa2f98dda761ac" + }, + "limitations": [ + "\u7d50\u8ad6\u50c5\u9069\u7528package SHA f90cdece\u2026\u53ca\u6b64\u7cbe\u6e96source chain\uff1b\u672a\u5ba3\u7a31\u73fe\u5f7917\u500bconsumer\u6216\u516820\u9805\u7522\u54c1\u5df2\u4fee\u3002", + "fixed\u662fqualified-component source closure\uff0c\u4e0d\u662fCanonical\u5b98\u65b9security\u66f4\u65b0\u72c0\u614b\uff0c\u4e5f\u4e0d\u662fcanonical/VEX disposition mutation\u3002", + "\u7121effective revert\u662f\u5c0d\u76ee\u6a19orig+enabled quilt\u53ca\u76ee\u524d\u5b89\u5168\u63a7\u5236\u7684\u5224\u65b7\uff0c\u6c92\u6709\u5ba3\u7a31\u5b8c\u6574Git\u6b77\u53f2\u5f9e\u672a\u51fa\u73fe\u4efb\u4f55revert\u63d0\u4ea4\u3002", + "\u539fstrict suite\u670984 upstream ignored\u820717\u65e2\u6709filtered\uff1b\u4e0d\u662fall-functional coverage\uff0clogname\u771f\u5be6login fixture\u4e5f\u672a\u8b49\u660e\u3002", + "dd\u8207split\u7684source unit regression\u540d\u7a31\u672a\u5728\u539fstrict log\u89c0\u5bdf\u5230\uff1bsource\u63a7\u5236\u53ef\u8b49\uff0c\u8a72unit\u7684\u65b0\u589e\u57f7\u884c\u672a\u7372\u6388\u6b0a\u4e14\u672a\u9032\u884c\u3002", + "\u5169\u500bid\u65b0CVE35370/35371\u6cbf\u7528\u5df2\u7d66\u5b9anative\u5408\u683c\u8b49\u64da\uff0c\u672a\u65bc\u672c\u8f2a\u91cd\u65b0\u5224\u5b9a\u6216\u91cd\u8dd1\u3002" + ], + "method": [ + "\u539fZIP\u3001export lock\u3001provenance\u3001\u5be6\u969bdeb bytes\u9010\u4e00SHA\u7d81\u5b9a\uff1bgit show CI exact07e producer\u7684lock bytes\u5b8c\u5168\u76f8\u540c\u3002", + "\u56db\u500bUbuntu\u539fsource\u6a94\u9010\u4e00size/SHA\u6838\u5c0d\uff1b\u5b98\u65b90.10 archive\u91cd\u65b0\u8b80\u53d6\u8207Ubuntu orig\u5b8c\u5168\u540cbytes\uff0ctag\u7269\u4ef6\u2192commit\u2192tree\u5df2\u6838\u3002", + "16\u500block\u5b98\u65b9completion/fix anchors\u4ee5\u5b98\u65b9compare API\u6838\u5be6\u70ba0.10 commit\u7956\u5148\uff1b\u4e0d\u628a\u7956\u5148\u6216\u7248\u672c\u95dc\u4fc2\u55ae\u7368\u7576\u7121revert\u8b49\u64da\u3002", + "\u539fUbuntu quilt\u5b8c\u6574series\u53ca14\u500benabled patch target\u5df2\u6838\uff1b\u91dd\u5c0d18\u9805\u76f8\u95dcsource\uff0c\u5728\u8a18\u61b6\u9ad4\u91cd\u5efa\u5b8c\u6574exact-context hunks\uff0c18\u500bnative baseline file pins\u5168\u76f8\u7b26\uff0c\u4e0d\u5bebsource\u3002", + "\u4f9dCVE\u539f\u5b89\u5168\u908a\u754c\u8ffd\u67e5\u76ee\u524d\u5be6\u969bcontrol\uff0c\u542btail/watch\u3001cp Linux platform\u3001safe_copy\u3001mv fd-xattr\u3001env/split_iterator\u7b49shared paths\uff1b\u4e0d\u53ea\u6aa2\u67e5lock\u7684sourcePath\u3002", + "\u5c0d\u539ffix\u5df2\u88ab\u5f8c\u7e8c\u91cd\u69cb\u53d6\u4ee3\u8005\uff0c\u6838\u5c0d\u7b49\u50f9\u76ee\u524d\u63a7\u5236\uff1b\u5c0dcompletion commit\u53ea\u6539\u6e2c\u8a66\u8005\uff0c\u88dc\u5176\u5df2merged\u5b98\u65b9PR\u7684\u5be6\u969bcode commit\u3002", + "\u50c5\u89e3\u6790\u539f\u6210\u529fstrict-tests.log\uff0c\u4fdd\u7559ignored\uff0ffiltered\u8207\u672a\u89c0\u5bdf\u5230\u7684\u5c08\u5c6cunit test\u7bc4\u570d\uff1b\u672a\u57f7\u884c\u65b0\u7684build/test/scan\u3002" + ], + "nativeEvidenceReused": { + "alternativesChanged": false, + "builtAt": "2026-10-24T18:49:35.830033+00:00", + "newIdCredentialCases": 3, + "newlyBackportedCves": [ + "CVE-2026-35370", + "CVE-2026-35371" + ], + "runtimeInstalled": false, + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11) (built from a source tarball)", + "status": "native_build_and_required_tests_passed", + "strictOwningSuite": { + "lognameCoverage": { + "ciMarkerForwarded": true, + "formalOutcome": "reported ok", + "genuineLoginFixtureValidated": false, + "limitation": "The upstream CI guard may return on no login name before login assertions; these outcomes do not establish successful login-name retrieval.", + "tests": [ + "test_logname::test_normal", + "test_logname::test_output_format" + ] + }, + "newExclusionPatterns": [], + "observedTotals": { + "failed": 0, + "filteredOut": 17, + "measured": 0, + "passed": 5438, + "upstreamIgnored": 84 + }, + "originalUbuntuRulesSha256": "06c5fd183a4a1146748810e3cce859ba849643eeee244cc80cbb3315aeedb5e7", + "scope": "Pinned Ubuntu owning-test contract; not all-functional coverage", + "status": "PASS", + "strictLogSha256": "b4eba1bcfa7299a6fb2cbf76e19424cb1f19555779b71f14823a29b8bc43a3a7" + } + }, + "officialCommitPatchEvidence": [ + { + "apiResponseSha256": "9aca4e70e563c36e8172a55d501395ccbd622835a9c681d2810397453febf8cc", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "commit": "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "commitUrl": "https://github.com/uutils/coreutils/commit/00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "officialPatchBytes": 2603, + "officialPatchSha256": "1ba9044bb81463f5686c2422a933649a185b7a66124cee46d4e04cd9003bf2a3", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/00f77cc7081f8e6837271d21b4fe8ca6d2f047c3.patch", + "paths": [ + "src/uu/mkfifo/src/mkfifo.rs", + "tests/by-util/test_mkfifo.rs" + ], + "tree": "818e11303f42834e778e72f99fb32f1c60beae82" + }, + { + "apiResponseSha256": "212cb06fca663ea30ce4baaf2945f5ae5079769c217ca82d05fe68f64649b1ed", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/0f538b942a5cdaba6e0960f6952429a1baaedf93", + "commit": "0f538b942a5cdaba6e0960f6952429a1baaedf93", + "commitUrl": "https://github.com/uutils/coreutils/commit/0f538b942a5cdaba6e0960f6952429a1baaedf93", + "officialPatchBytes": 4353, + "officialPatchSha256": "0accf0902cc11a9cadccc2b27d96a4600b8e9fe16ac01683dfbdcab71519dc8a", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/0f538b942a5cdaba6e0960f6952429a1baaedf93.patch", + "paths": [ + "src/uu/dd/src/dd.rs" + ], + "tree": "ef42d1c3e6d1596996bd48c68f06227b65a066b6" + }, + { + "apiResponseSha256": "903733a437c3e6626accaa06dea4a2dfb7b1c800c8da0de5f7e57e48e6a724ff", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "commit": "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "commitUrl": "https://github.com/uutils/coreutils/commit/2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "officialPatchBytes": 6436, + "officialPatchSha256": "dad593df6f1585d8286f5818733e2d2571d8a3df37eb213256fbabf48a823cd4", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/2c2f1b0c1f11f120fc8cbf8d518194f6532c0607.patch", + "paths": [ + "src/uu/tail/locales/en-US.ftl", + "src/uu/tail/locales/fr-FR.ftl", + "src/uu/tail/src/follow/watch.rs", + "tests/by-util/test_tail.rs" + ], + "tree": "d1057779ddfc91bc09cd1483e0885a7984faf556" + }, + { + "apiResponseSha256": "ee78d245fb901681d1255aa6cb9f32ca2e6587310e4eeb18a678a79a6854218d", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "commit": "48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "commitUrl": "https://github.com/uutils/coreutils/commit/48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "officialPatchBytes": 5122, + "officialPatchSha256": "6d8e21cf5b6c625ed62c844b058073a1d42102ee59d5567d415bdbc0e32c2961", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/48d030dfb2ca7659ee2735f09cda706c8245a3fc.patch", + "paths": [ + "src/uu/ln/locales/en-US.ftl", + "src/uu/ln/locales/fr-FR.ftl", + "src/uu/ln/src/ln.rs", + "tests/by-util/test_ln.rs" + ], + "tree": "51de098847f7a7fde667dd1e4280c7eff29e9907" + }, + { + "apiResponseSha256": "fc1b71fa201613ec608999d5e5a5a9bed6b8cafbdef400195ddb5566578b3d91", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/500dd65c539bff848c712bf7a8a111109a32eae9", + "commit": "500dd65c539bff848c712bf7a8a111109a32eae9", + "commitUrl": "https://github.com/uutils/coreutils/commit/500dd65c539bff848c712bf7a8a111109a32eae9", + "officialPatchBytes": 13103, + "officialPatchSha256": "606ead540caf4391537ca39853f4c9564234770927f6bd91826eee415cc85a14", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/500dd65c539bff848c712bf7a8a111109a32eae9.patch", + "paths": [ + "src/uu/mv/Cargo.toml", + "src/uu/mv/src/mv.rs", + "src/uucore/Cargo.toml", + "src/uucore/src/lib/features/fsxattr.rs", + "util/check-safe-traversal.sh" + ], + "tree": "02a401b66312490bfdf3d48f90f9943bb3a0ba28" + }, + { + "apiResponseSha256": "1f200f3d20597bd861e3a8b592c5674abba1e1465186f037195f389b0539fdaa", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/55298ee943231c07e944bd6e1a9cf9e01236d324", + "commit": "55298ee943231c07e944bd6e1a9cf9e01236d324", + "commitUrl": "https://github.com/uutils/coreutils/commit/55298ee943231c07e944bd6e1a9cf9e01236d324", + "officialPatchBytes": 6072, + "officialPatchSha256": "eaf3a2c8482ddd371748981034f7f3fccc36287a04b2b800d088591a94157aeb", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/55298ee943231c07e944bd6e1a9cf9e01236d324.patch", + "paths": [ + "src/uu/env/src/split_iterator.rs", + "tests/by-util/test_env.rs" + ], + "tree": "d9994aa9dc07e97b3a0fe2d85546b07389a0da41" + }, + { + "apiResponseSha256": "dddc4e509185b70a8b8e007d0e6d1f7d567791bca2f731e1379cad715323624d", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/58040feb5c66fce588a7ffac299b3a23bd55cd81", + "commit": "58040feb5c66fce588a7ffac299b3a23bd55cd81", + "commitUrl": "https://github.com/uutils/coreutils/commit/58040feb5c66fce588a7ffac299b3a23bd55cd81", + "officialPatchBytes": 16038, + "officialPatchSha256": "22f595b78d7ce795f6a205f8721e00afeb4c43d5c7ad972875f96625de66a986", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/58040feb5c66fce588a7ffac299b3a23bd55cd81.patch", + "paths": [ + ".github/workflows/CICD.yml", + "src/uu/split/src/platform/mod.rs", + "src/uu/split/src/platform/unix.rs", + "src/uu/split/src/platform/windows.rs", + "src/uu/split/src/split.rs", + "util/check-safe-traversal.sh" + ], + "tree": "7ea333e0b5a0502cad5aa3aa3378264310fe076b" + }, + { + "apiResponseSha256": "e8f9ab30a8628b7034e26f82bde9b7124c03d4699c9003d025edf601fbba31fe", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/6b16cc9688fe998f5dc2786f53f22ceea709e318", + "commit": "6b16cc9688fe998f5dc2786f53f22ceea709e318", + "commitUrl": "https://github.com/uutils/coreutils/commit/6b16cc9688fe998f5dc2786f53f22ceea709e318", + "officialPatchBytes": 11906, + "officialPatchSha256": "48bb8ee8838c75e61e9d60c90431a2364158095d421a440fa5f25c784bd830c5", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/6b16cc9688fe998f5dc2786f53f22ceea709e318.patch", + "paths": [ + "src/uu/rm/src/rm.rs", + "tests/by-util/test_rm.rs" + ], + "tree": "d221c2e3ef578e6a2001f1e8ff3b6645122fe644" + }, + { + "apiResponseSha256": "fc004d1ec90e653583711849b019d7f9cfa1233a9ec70a4002fbdb8e981e9486", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/6e28633263ea84aad9ef7a30750232b73fb9133f", + "commit": "6e28633263ea84aad9ef7a30750232b73fb9133f", + "commitUrl": "https://github.com/uutils/coreutils/commit/6e28633263ea84aad9ef7a30750232b73fb9133f", + "officialPatchBytes": 3154, + "officialPatchSha256": "14b4ffee0cc8a6f4830ef2c648860a193a4673ceab0c1913c0dc78af12b2e998", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/6e28633263ea84aad9ef7a30750232b73fb9133f.patch", + "paths": [ + "src/uu/sort/src/sort.rs", + "tests/by-util/test_sort.rs" + ], + "tree": "4c63f8f9f660fc383128d83a758120ffea164821" + }, + { + "apiResponseSha256": "f46792547c1a00ab2de0804092a2907e233b98edb8963789ab0ef6d576337741", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/7c8563d88ffa8f0dba5745587baad0d0c58de62d", + "commit": "7c8563d88ffa8f0dba5745587baad0d0c58de62d", + "commitUrl": "https://github.com/uutils/coreutils/commit/7c8563d88ffa8f0dba5745587baad0d0c58de62d", + "officialPatchBytes": 10828, + "officialPatchSha256": "bbb3dabe9a42fe2bcd7801840ae4674541a2fa8f9616257ba368541ff032b3b7", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/7c8563d88ffa8f0dba5745587baad0d0c58de62d.patch", + "paths": [ + "src/uu/cp/Cargo.toml", + "src/uu/cp/src/platform/linux.rs", + "src/uu/cp/src/platform/other_unix.rs", + "tests/by-util/test_cp.rs", + "util/check-safe-traversal.sh" + ], + "tree": "9aa79b2c831f3480a708db475d206d9c84995784" + }, + { + "apiResponseSha256": "8c388af1c83a16507c1bbcdb16537f28e503e44c64cb361276c4cbe04925e0a8", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/874efa7cc3361cb5af2a97db869147f910bcab44", + "commit": "874efa7cc3361cb5af2a97db869147f910bcab44", + "commitUrl": "https://github.com/uutils/coreutils/commit/874efa7cc3361cb5af2a97db869147f910bcab44", + "officialPatchBytes": 4587, + "officialPatchSha256": "8ea4a6888fff67f800a9ae733601122011f716157bbefb9fe03c9c445eb656de", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/874efa7cc3361cb5af2a97db869147f910bcab44.patch", + "paths": [ + "src/uu/mv/Cargo.toml", + "src/uu/mv/src/mv.rs" + ], + "tree": "91db949af1f279fdc35019e9a1bd1f9d50991bd3" + }, + { + "apiResponseSha256": "2857a4ae71868269e1d9510fb3c9237ca83c6aa2559682f01a852ae3a9fe4b56", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "commit": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "commitUrl": "https://github.com/uutils/coreutils/commit/aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "officialPatchBytes": 6300, + "officialPatchSha256": "9094bf44214a41e9bcfa6ddcc75aefcfadcf41d3b858993b6179db2c7d0c4181", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/aa30c103004463ffe7b8a98ca0ce519d56bbbd41.patch", + "paths": [ + "src/uu/mv/src/mv.rs", + "src/uucore/src/lib/features/fsxattr.rs" + ], + "tree": "12f7037791792052b2ffc3030d4cad46db7983c7" + }, + { + "apiResponseSha256": "79d1cc4caf442ae86a2a02ce9339a936b5aa1fd55b3e406a5f57edddc2489636", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/acadaa0e51ab82309ec81e6021d630b875cd3e10", + "commit": "acadaa0e51ab82309ec81e6021d630b875cd3e10", + "commitUrl": "https://github.com/uutils/coreutils/commit/acadaa0e51ab82309ec81e6021d630b875cd3e10", + "officialPatchBytes": 34413, + "officialPatchSha256": "eec9d78c60d868dfdd1c4e64bd0811ab0636eb5a18d69686f52810e1ad9fcf3f", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/acadaa0e51ab82309ec81e6021d630b875cd3e10.patch", + "paths": [ + "src/uu/cp/src/cp.rs", + "src/uu/cp/src/platform/linux.rs", + "src/uu/cp/src/platform/macos.rs", + "src/uu/cp/src/platform/other_unix.rs", + "tests/by-util/test_cp.rs", + "util/check-safe-traversal.sh" + ], + "tree": "8f26179b4f7a8d1064bb3b177f9da384d978b073" + }, + { + "apiResponseSha256": "4e576a1fe4632cca7650792464b7241c283894ff15d18b99fd5719cbd954c93d", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "commit": "acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "commitUrl": "https://github.com/uutils/coreutils/commit/acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "officialPatchBytes": 1986, + "officialPatchSha256": "d383589178f9c5a9048204a1af3810f8bd55ff046003aa6968ab2f26c35b3ccf", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/acd51ef18d90e5c26f694df721f896fa1c4a9edc.patch", + "paths": [ + "src/uu/nohup/src/nohup.rs" + ], + "tree": "d7d0432fd02e92030dc28309457af847678cda1d" + }, + { + "apiResponseSha256": "8b90ed0577f665957d5af9db657d7a65d39cf8070ba14c0b8fb19ace67e54b97", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/b9d8ff64c224eb425702584b66ec0ac64a99592a", + "commit": "b9d8ff64c224eb425702584b66ec0ac64a99592a", + "commitUrl": "https://github.com/uutils/coreutils/commit/b9d8ff64c224eb425702584b66ec0ac64a99592a", + "officialPatchBytes": 1387, + "officialPatchSha256": "c5d37a9cf4021220bb9b4b9e8740f08893bcd42e115cde98c792805a4a6b0b11", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/b9d8ff64c224eb425702584b66ec0ac64a99592a.patch", + "paths": [ + "util/gnu-patches/tests_env_env-S.pl.patch" + ], + "tree": "4fc02226478b9ae2a054bcc2671c2e07fdb0b304" + }, + { + "apiResponseSha256": "bd91796b5e652bd4769c3f802bd9fa2e25648e6d5b038d6efdeee230f81a24cf", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "commit": "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "commitUrl": "https://github.com/uutils/coreutils/commit/bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "officialPatchBytes": 3772, + "officialPatchSha256": "81fb9e76addd04941b02081facb696ee5606b2bf2efa74790addb4e34f258cc1", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/bb8f7095fd63206ff54a4fb4256375e9e1ad88c9.patch", + "paths": [ + ".vscode/cspell.dictionaries/jargon.wordlist.txt", + "src/uu/sort/src/sort.rs", + "tests/by-util/test_sort.rs" + ], + "tree": "144815709dc138761d669d05f634ee3769aef249" + }, + { + "apiResponseSha256": "a051a49b53e56a0e2ca585a9ac4dba07ea7d72c2cb48699944ca9918b24b17ca", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "commitUrl": "https://github.com/uutils/coreutils/commit/bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "officialPatchBytes": 5171, + "officialPatchSha256": "109abb9cfe09dbc5adc3846bd8aa659f218e11c6b0e6243ad0b5d76c29fa43eb", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/bd170a5d4b19ed985417d322dffb8cade2cbbad5.patch", + "paths": [ + "src/uu/cp/src/cp.rs", + "tests/by-util/test_cp.rs" + ], + "tree": "c6657a107aaecca493fee4ffce97a2cac0b35bc4" + }, + { + "apiResponseSha256": "0f267705f6fd6bf597535a2bfe4f41e508b2701cd2ee77ab56c4309c810cc9c3", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/c004672c5eb2415b03f11f766583c4b967ba024f", + "commit": "c004672c5eb2415b03f11f766583c4b967ba024f", + "commitUrl": "https://github.com/uutils/coreutils/commit/c004672c5eb2415b03f11f766583c4b967ba024f", + "officialPatchBytes": 10481, + "officialPatchSha256": "7868720880cef10a4b5cab137f50569cb3fb033b3c43c189a8428d8263437064", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/c004672c5eb2415b03f11f766583c4b967ba024f.patch", + "paths": [ + "Cargo.lock", + "src/uu/mkfifo/Cargo.toml", + "src/uu/mkfifo/src/mkfifo.rs", + "util/check-toctou.sh" + ], + "tree": "d2790ab701598a77ffb0946a6ee55635f2bb644b" + }, + { + "apiResponseSha256": "f86fd88a0b34d37924bb9af619a3a117581a8feafb2f8040c719816bde22b1f3", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "commit": "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "commitUrl": "https://github.com/uutils/coreutils/commit/d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "officialPatchBytes": 7155, + "officialPatchSha256": "388b041099c7e9e9d32b912c1491c25162bf383403304bb9de5279543a08a64b", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/d296f8d39f3a71ef7c5bd3b949e65aa978f1436d.patch", + "paths": [ + "src/uu/touch/src/touch.rs", + "tests/by-util/test_touch.rs", + "util/check-safe-traversal.sh" + ], + "tree": "99951d7cda510d447fbca502f9f8b93a12277617" + }, + { + "apiResponseSha256": "741caa1a24d7ea486517c2ead36ee393dd41556820f25b74096198760a6e21ac", + "apiUrl": "https://api.github.com/repos/uutils/coreutils/commits/f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "commit": "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "commitUrl": "https://github.com/uutils/coreutils/commit/f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "officialPatchBytes": 2585, + "officialPatchSha256": "8f9c740836c2ae97109f15ce7d979f4be7f1b62b42ce7bb191af2d57bc75eb7f", + "officialPatchUrl": "https://github.com/uutils/coreutils/commit/f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f.patch", + "paths": [ + "src/uu/chroot/src/chroot.rs" + ], + "tree": "8a808807b339478b65e49023ffd05115c2ab3d55" + } + ], + "results": [ + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 2082, + "behind_by": 0, + "commit": "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "mergeBase": "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "status": "ahead", + "total_commits": 2082, + "url": "https://api.github.com/repos/uutils/coreutils/compare/00f77cc7081f8e6837271d21b4fe8ca6d2f047c3...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_mkfifo::test_mkfifo_permission_unchanged_when_failed ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35341", + "lockMergeOrCompletionAnchor": "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3", + "officialAdvisory": { + "sha256": "779c435f1acb92ea0141ad218fa03ae8074ac9045b4041302a717c17a616b88f", + "url": "https://ubuntu.com/security/cves/CVE-2026-35341.json" + }, + "officialCodeCommits": [ + "00f77cc7081f8e6837271d21b4fe8ca6d2f047c3" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/10376", + "securityBoundary": { + "control": "\u76ee\u524d\u4ee5create_fifo\u539f\u5b50\u5efa\u7acb\u4e26\u5957\u6307\u5b9amode\uff0c\u5931\u6557\u53ea\u5831\u932f\uff1b\u5df2\u6c92\u6709\u5efa\u7acb\u5931\u6557\u5f8c\u4ecdchmod\u65e2\u6709\u6a94\u6848\u7684\u8def\u5f91\u3002\u539f00f77\u7684continue\u7531\u5f8c\u7e8cc004672c\u5b89\u5168\u5be6\u4f5c\u53d6\u4ee3\uff0c\u4e0d\u80fd\u4ee5continue\u6587\u5b57\u6d88\u5931\u5224\u5b9arevert\u3002", + "platform": "native Linux amd64", + "sink": "\u65e2\u6709\u6a94\u6848\u6b0a\u9650\u8b8a\u66f4", + "source": "\u5df2\u5b58\u5728\u7684FIFO\u76ee\u6a19\u6a94\u6848" + }, + "sourceEvidence": [ + { + "lineEnd": 78, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 72, + "path": "src/uu/mkfifo/src/mkfifo.rs", + "sha256": "6c7c49a911cff80e6ea0499044971bd232c37b298a5a0c83551b41d5a9f46dbe", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mkfifo/src/mkfifo.rs" + }, + { + "lineEnd": 81, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 75, + "path": "src/uu/mkfifo/src/mkfifo.rs", + "sha256": "6c7c49a911cff80e6ea0499044971bd232c37b298a5a0c83551b41d5a9f46dbe", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mkfifo/src/mkfifo.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": "c004672c5eb2415b03f11f766583c4b967ba024f", + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 590, + "behind_by": 0, + "commit": "0f538b942a5cdaba6e0960f6952429a1baaedf93", + "mergeBase": "0f538b942a5cdaba6e0960f6952429a1baaedf93", + "status": "ahead", + "total_commits": 590, + "url": "https://api.github.com/repos/uutils/coreutils/compare/0f538b942a5cdaba6e0960f6952429a1baaedf93...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "\u8a72source unit regression\u5b58\u5728\uff0c\u4f46\u672a\u5728\u65e2\u6709strict log\u89c0\u5bdf\u5230\u6b64test\u540d\uff1b\u672c\u8f2a\u4e0d\u91cd\u8dd1\uff0csource closure\u8207\u4e00\u822cnative PASS\u5206\u958b\u3002", + "reused": true + }, + "id": "CVE-2026-35344", + "lockMergeOrCompletionAnchor": "0f538b942a5cdaba6e0960f6952429a1baaedf93", + "officialAdvisory": { + "sha256": "10fd01dbb0cbbd2c1b1a6e9c8e709441be6c89f3ff5727c26561cf1651626d13", + "url": "https://ubuntu.com/security/cves/CVE-2026-35344.json" + }, + "officialCodeCommits": [ + "0f538b942a5cdaba6e0960f6952429a1baaedf93" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12667", + "securityBoundary": { + "control": "Dest::truncate\u5c0d\u4e00\u822c\u6a94\u7684set_len\u5931\u6557\u56de\u50b3Err\uff0cBlockWriter\u4fdd\u7559Result\u4e14finalize\u4ee5?\u50b3\u64ad\uff1b\u7279\u6b8a\u8f38\u51fa\u4ecd\u4fdd\u7559GNU\u76f8\u5bb9\u8655\u7406\u3002Ubuntu dd-ensure-full-writes\u672a\u64a4\u56de\u6b64\u63a7\u5236\u3002", + "platform": "native Linux amd64", + "sink": "\u5047\u6210\u529f\u53ca\u820a\u8cc7\u6599\u6b98\u7559", + "source": "\u4e00\u822c\u8f38\u51fa\u6a94\u6848\u7684truncate\u5931\u6557" + }, + "sourceEvidence": [ + { + "lineEnd": 672, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 667, + "path": "src/uu/dd/src/dd.rs", + "sha256": "11052025db5fd3e1c5cac0b026c67b1d45c7cf601cff01329eba0f2408ad54b9", + "ubuntuQuiltTouches": [ + { + "name": "dd-ensure-full-writes.patch", + "sha256": "dd2c5a3cc8dfb89dea54e68db55903859f09ab6f0bb6ff3e7cbf7c7ecc7f80b1" + } + ], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/dd/src/dd.rs" + }, + { + "lineEnd": 1285, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1279, + "path": "src/uu/dd/src/dd.rs", + "sha256": "11052025db5fd3e1c5cac0b026c67b1d45c7cf601cff01329eba0f2408ad54b9", + "ubuntuQuiltTouches": [ + { + "name": "dd-ensure-full-writes.patch", + "sha256": "dd2c5a3cc8dfb89dea54e68db55903859f09ab6f0bb6ff3e7cbf7c7ecc7f80b1" + } + ], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/dd/src/dd.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 394, + "behind_by": 0, + "commit": "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "mergeBase": "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "status": "ahead", + "total_commits": 394, + "url": "https://api.github.com/repos/uutils/coreutils/compare/2c2f1b0c1f11f120fc8cbf8d518194f6532c0607...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_tail::test_follow_name_replaced_by_symlink_is_untailable ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35345", + "lockMergeOrCompletionAnchor": "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607", + "officialAdvisory": { + "sha256": "9b50fe889d672a5204d90d20f5329539c1687311420b52ef8b8f0e178699ec37", + "url": "https://ubuntu.com/security/cves/CVE-2026-35345.json" + }, + "officialCodeCommits": [ + "2c2f1b0c1f11f120fc8cbf8d518194f6532c0607" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12661", + "securityBoundary": { + "control": "\u4ee5symlink_metadata\u8b58\u5225\u66ff\u63db\uff0c\u6a19\u70bauntailable\u4e26reset_reader\uff0c\u4e0d\u7e7c\u7e8c\u8b80\u53d6symlink\u76ee\u6a19\u3002\u5be6\u969b\u63a7\u5236\u4f4d\u65bcfollow/watch.rs\u800c\u975elock\u6240\u5217tail.rs\u3002", + "platform": "native Linux amd64", + "sink": "\u654f\u611f\u6a94\u6848\u5167\u5bb9\u8f38\u51fa", + "source": "--follow=name\u76e3\u770b\u6a94\u6848\u88ab\u63db\u6210symlink" + }, + "sourceEvidence": [ + { + "lineEnd": 328, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 322, + "path": "src/uu/tail/src/follow/watch.rs", + "sha256": "9c6a2bdbe2bd14bcc11efe3b56bdd606eb6bfa1074d6c9f2c577403f8c76b79f", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/tail/src/follow/watch.rs" + }, + { + "lineEnd": 367, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 361, + "path": "src/uu/tail/src/follow/watch.rs", + "sha256": "9c6a2bdbe2bd14bcc11efe3b56bdd606eb6bfa1074d6c9f2c577403f8c76b79f", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/tail/src/follow/watch.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [ + "6e28633263ea84aad9ef7a30750232b73fb9133f" + ], + "ancestry": [ + { + "ahead_by": 250, + "behind_by": 0, + "commit": "6e28633263ea84aad9ef7a30750232b73fb9133f", + "mergeBase": "6e28633263ea84aad9ef7a30750232b73fb9133f", + "status": "ahead", + "total_commits": 250, + "url": "https://api.github.com/repos/uutils/coreutils/compare/6e28633263ea84aad9ef7a30750232b73fb9133f...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + }, + { + "ahead_by": 425, + "behind_by": 0, + "commit": "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "mergeBase": "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "status": "ahead", + "total_commits": 425, + "url": "https://api.github.com/repos/uutils/coreutils/compare/bb8f7095fd63206ff54a4fb4256375e9e1ad88c9...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_sort::test_files0_from_1 ... ok", + "test test_sort::test_files0_from_1a ... ok", + "test test_sort::test_files0_from_2 ... ok", + "test test_sort::test_files0_from_2a ... ok", + "test test_sort::test_files0_from_empty ... ok", + "test test_sort::test_files0_from_empty_non_regular ... ok", + "test test_sort::test_files0_from_extra_arg ... ok", + "test test_sort::test_files0_from_minus_in_stdin ... ok", + "test test_sort::test_files0_from_missing ... ok", + "test test_sort::test_files0_from_non_utf8 ... ok", + "test test_sort::test_files0_from_nul ... ok", + "test test_sort::test_files0_from_non_utf8_name ... ok", + "test test_sort::test_files0_from_nul2 ... ok", + "test test_sort::test_files0_from_zero_length ... ok", + "test test_sort::test_files0_read_error ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35348", + "lockMergeOrCompletionAnchor": "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9", + "officialAdvisory": { + "sha256": "649a788e0db24a89b2a8e26978948ed76971d4e7a7cecb7cc0bb2d7b751a47c5", + "url": "https://ubuntu.com/security/cves/CVE-2026-35348.json" + }, + "officialCodeCommits": [ + "bb8f7095fd63206ff54a4fb4256375e9e1ad88c9" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/11593", + "securityBoundary": { + "control": "NUL\u5206\u9694\u6a94\u540d\u4ea4\u7d66os_string_from_vec\uff1bLinux/Unix\u5206\u652f\u76f4\u63a5OsString::from_vec\uff0c\u4e0d\u4ee5UTF8 expect\u89f8\u767cpanic\u3002\u5f8c\u7e8c6e286332\u7684\u6e05\u55ae\u6a94\u540d\u4fee\u88dc\u4ea6\u662f0.10\u7956\u5148\uff1b\u76ee\u524d\u5be6\u4f5c\u70ba\u7b49\u50f9raw-byte helper\u3002", + "platform": "native Linux amd64", + "sink": "sort\u7a0b\u5e8fpanic\uff0f\u7ba1\u7dda\u4e2d\u65b7", + "source": "--files0-from\u7684\u975eUTF8\u6a94\u540d\u6216\u6e05\u55ae\u8def\u5f91" + }, + "sourceEvidence": [ + { + "lineEnd": 2036, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 2030, + "path": "src/uu/sort/src/sort.rs", + "sha256": "95820b55661c4281b3a51e54496fb9c0f5050ed202f1e8c68b87efb3e17de5dd", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/sort/src/sort.rs" + }, + { + "lineEnd": 516, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 510, + "path": "src/uucore/src/lib/lib.rs", + "sha256": "81d80403e7e16eb030c209f79cb65c2c5ad03f65457f07822fab42e9882c3f84", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/lib.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 866, + "behind_by": 0, + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "mergeBase": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "status": "ahead", + "total_commits": 866, + "url": "https://api.github.com/repos/uutils/coreutils/compare/bd170a5d4b19ed985417d322dffb8cade2cbbad5...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_cp::test_cp_preserve_setuid_when_chown_succeeds ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35350", + "lockMergeOrCompletionAnchor": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "officialAdvisory": { + "sha256": "a01cef4bc2418a58a044257522edf3d5e004155a78d96f9103a9047a3a5f9602", + "url": "https://ubuntu.com/security/cves/CVE-2026-35350.json" + }, + "officialCodeCommits": [ + "bd170a5d4b19ed985417d322dffb8cade2cbbad5" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "securityBoundary": { + "control": "\u8a18\u9304ownership_preserved=false\uff0c\u4fdd\u7559mode\u524d\u6e05\u96640o6000\uff1bUbuntu cp quilt\u4fdd\u7559\u6b64\u5206\u652f\u3002", + "platform": "native Linux amd64", + "sink": "\u8907\u672c\u932f\u8aa4\u4fdd\u7559setuid/setgid", + "source": "cp -p\u7121\u6cd5\u4fdd\u7559\u539fowner" + }, + "sourceEvidence": [ + { + "lineEnd": 1853, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1847, + "path": "src/uu/cp/src/cp.rs", + "sha256": "7094892394071883367c3b0de3b1b7d0beee6a354d32931397808276547ae525", + "ubuntuQuiltTouches": [ + { + "name": "cp-fix-symlink-target-permissions.patch", + "sha256": "53cfbb7a0376da6710985496774f8981e71d207aa97d932f73d5978c978ea100" + } + ], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/cp/src/cp.rs" + }, + { + "lineEnd": 1870, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1864, + "path": "src/uu/cp/src/cp.rs", + "sha256": "7094892394071883367c3b0de3b1b7d0beee6a354d32931397808276547ae525", + "ubuntuQuiltTouches": [ + { + "name": "cp-fix-symlink-target-permissions.patch", + "sha256": "53cfbb7a0376da6710985496774f8981e71d207aa97d932f73d5978c978ea100" + } + ], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/cp/src/cp.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 1078, + "behind_by": 0, + "commit": "874efa7cc3361cb5af2a97db869147f910bcab44", + "mergeBase": "874efa7cc3361cb5af2a97db869147f910bcab44", + "status": "ahead", + "total_commits": 1078, + "url": "https://api.github.com/repos/uutils/coreutils/compare/874efa7cc3361cb5af2a97db869147f910bcab44...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_mv::test_mv_cross_device_preserves_ownership ... ok", + "test test_mv::test_mv_cross_device_preserves_ownership_recursive ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35351", + "lockMergeOrCompletionAnchor": "874efa7cc3361cb5af2a97db869147f910bcab44", + "officialAdvisory": { + "sha256": "a63b5f1b5ad9b023c5fdc155effa5051639039be12ee201b7388de1a154ed096", + "url": "https://ubuntu.com/security/cves/CVE-2026-35351.json" + }, + "officialCodeCommits": [ + "874efa7cc3361cb5af2a97db869147f910bcab44" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/11706", + "securityBoundary": { + "control": "\u4e00\u822c\u6a94\u3001symlink\u8207\u76ee\u9304\u905e\u8ff4\u8def\u5f91\u5747\u547c\u53ebpreserve_ownership\uff0c\u5f9esource metadata\u53d6uid/gid\u4e26\u7528\u4e0dfollow symlink\u7684chown\uff1b\u4fdd\u7559\u5408\u6cd5\u975eroot best-effort\u8a9e\u610f\u3002", + "platform": "native Linux amd64", + "sink": "\u642c\u79fb\u5f8cowner\u932f\u7f6e", + "source": "mv\u8de8\u6a94\u6848\u7cfb\u7d71copy/delete fallback" + }, + "sourceEvidence": [ + { + "lineEnd": 1470, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1464, + "path": "src/uu/mv/src/mv.rs", + "sha256": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mv/src/mv.rs" + }, + { + "lineEnd": 1441, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1435, + "path": "src/uu/mv/src/mv.rs", + "sha256": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mv/src/mv.rs" + }, + { + "lineEnd": 1287, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1281, + "path": "src/uu/mv/src/mv.rs", + "sha256": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mv/src/mv.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 649, + "behind_by": 0, + "commit": "c004672c5eb2415b03f11f766583c4b967ba024f", + "mergeBase": "c004672c5eb2415b03f11f766583c4b967ba024f", + "status": "ahead", + "total_commits": 649, + "url": "https://api.github.com/repos/uutils/coreutils/compare/c004672c5eb2415b03f11f766583c4b967ba024f...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_mkfifo::test_mkfifo_permission_unchanged_when_failed ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35352", + "lockMergeOrCompletionAnchor": "c004672c5eb2415b03f11f766583c4b967ba024f", + "officialAdvisory": { + "sha256": "57f20a07444990dbe8a5736abc10b0173f9aeaaf0bc6599ba69df5bdfade2ccd", + "url": "https://ubuntu.com/security/cves/CVE-2026-35352.json" + }, + "officialCodeCommits": [ + "c004672c5eb2415b03f11f766583c4b967ba024f" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12174", + "securityBoundary": { + "control": "\u6307\u5b9amode\u96a8mkfifoat\u5efa\u7acb\u539f\u5b50\u5957\u7528\uff1b\u76ee\u524dutility\u7121\u5f8c\u7e8cpath-based set_permissions\u3002", + "platform": "native Linux amd64", + "sink": "\u5c0d\u975e\u9810\u671f\u76ee\u6a19chmod", + "source": "mkfifo\u5efa\u7acb\u8207\u5f8c\u7e8cpath chmod\u9593\u7684symlink\u7f6e\u63db" + }, + "sourceEvidence": [ + { + "lineEnd": 77, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 71, + "path": "src/uu/mkfifo/src/mkfifo.rs", + "sha256": "6c7c49a911cff80e6ea0499044971bd232c37b298a5a0c83551b41d5a9f46dbe", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mkfifo/src/mkfifo.rs" + }, + { + "lineEnd": 152, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 146, + "path": "src/uu/mkfifo/src/mkfifo.rs", + "sha256": "6c7c49a911cff80e6ea0499044971bd232c37b298a5a0c83551b41d5a9f46dbe", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mkfifo/src/mkfifo.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 793, + "behind_by": 0, + "commit": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "mergeBase": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "status": "ahead", + "total_commits": 793, + "url": "https://api.github.com/repos/uutils/coreutils/compare/aa30c103004463ffe7b8a98ca0ce519d56bbbd41...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "\u672c\u8f2a\u53ea\u4f5csource closure\uff1b\u4e0d\u5c07\u4e00\u822csuite PASS\u5192\u5145\u6b64CVE\u5c08\u5c6c\u52d5\u614b\u91cd\u73fe\u5168\u8986\u84cb\u3002", + "reused": true + }, + "id": "CVE-2026-35354", + "lockMergeOrCompletionAnchor": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "officialAdvisory": { + "sha256": "f2499baee7cf751fab8bac7328bd54186608a4485452650d2ab0d7634e06eb15", + "url": "https://ubuntu.com/security/cves/CVE-2026-35354.json" + }, + "officialCodeCommits": [ + "500dd65c539bff848c712bf7a8a111109a32eae9" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12170", + "securityBoundary": { + "control": "copy\u671f\u9593\u6301\u6709source/dest fd\uff0ccopy_xattrs_fd\u4ee5FileExt list/get/set\u64cd\u4f5c\u540cinode\uff1bignore-unsupported wrapper\u53ea\u6539ENOTSUP\u7b56\u7565\uff0c\u672a\u6539\u56depath-based xattr\u3002", + "platform": "native Linux amd64", + "sink": "\u76ee\u7684\u6a94\u5b89\u5168xattr\u6df7\u96dc", + "source": "mv\u8de8\u6a94\u6848\u7cfb\u7d71xattr\u8907\u88fd\u6642\u7684path\u7f6e\u63db" + }, + "sourceEvidence": [ + { + "lineEnd": 1431, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1425, + "path": "src/uu/mv/src/mv.rs", + "sha256": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mv/src/mv.rs" + }, + { + "lineEnd": 62, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 56, + "path": "src/uucore/src/lib/features/fsxattr.rs", + "sha256": "305e34986037e15dbbd05b2a2bc497527433af1f06f078f290642cee9c42eae6", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/features/fsxattr.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 866, + "behind_by": 0, + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "mergeBase": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "status": "ahead", + "total_commits": 866, + "url": "https://api.github.com/repos/uutils/coreutils/compare/bd170a5d4b19ed985417d322dffb8cade2cbbad5...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_cp::test_cp_final_mode_unchanged_after_restrictive_create ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35357", + "lockMergeOrCompletionAnchor": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "officialAdvisory": { + "sha256": "38f682a70a2dbae9144fe748b26558e8928103d43b660021d5f2a4f37417afef", + "url": "https://ubuntu.com/security/cves/CVE-2026-35357.json" + }, + "officialCodeCommits": [ + "7c8563d88ffa8f0dba5745587baad0d0c58de62d" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "securityBoundary": { + "control": "Linux\u5404copy/reflink/sparse/stream\u8def\u5f91\u4f7f\u7528create_dest_restrictive\uff1b\u5171\u7528helper\u65bcopen\u5efa\u7acb\u6642\u4ee50600\u5957\u7528\uff0c\u4e4b\u5f8c\u624d\u653e\u5bec\u81f3\u5408\u6cd5\u6700\u7d42mode\u3002", + "platform": "native Linux amd64", + "sink": "\u65b0\u6a94\u5167\u5bb9\u66ab\u6642\u6d29\u6f0f", + "source": "cp\u65b0\u76ee\u7684\u6a94\u5efa\u7acb\u5230\u6536\u7dcamode\u524d\u7684\u8b80\u53d6\u7af6\u614b" + }, + "sourceEvidence": [ + { + "lineEnd": 41, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 35, + "path": "src/uu/cp/src/platform/linux.rs", + "sha256": "c05c36f1f8cc3a904b4666b86d4719f42887354d6d7b67027b38f546c2dac86e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/cp/src/platform/linux.rs" + }, + { + "lineEnd": 79, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 73, + "path": "src/uucore/src/lib/features/safe_copy.rs", + "sha256": "638d5ca70fae2ac677fe154a6302f76a760bcaa370194e1b5123951d81ffa54e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/features/safe_copy.rs" + }, + { + "lineEnd": 84, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 78, + "path": "src/uucore/src/lib/features/safe_copy.rs", + "sha256": "638d5ca70fae2ac677fe154a6302f76a760bcaa370194e1b5123951d81ffa54e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/features/safe_copy.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 866, + "behind_by": 0, + "commit": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "mergeBase": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "status": "ahead", + "total_commits": 866, + "url": "https://api.github.com/repos/uutils/coreutils/compare/bd170a5d4b19ed985417d322dffb8cade2cbbad5...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "\u672c\u8f2a\u53ea\u4f5csource closure\uff1b\u4e0d\u5c07\u4e00\u822csuite PASS\u5192\u5145\u6b64CVE\u5c08\u5c6c\u52d5\u614b\u91cd\u73fe\u5168\u8986\u84cb\u3002", + "reused": true + }, + "id": "CVE-2026-35359", + "lockMergeOrCompletionAnchor": "bd170a5d4b19ed985417d322dffb8cade2cbbad5", + "officialAdvisory": { + "sha256": "7ada9773bfc6935e73c9fe554c10c3ad6118cd202e9073039a357817cd45d7f5", + "url": "https://ubuntu.com/security/cves/CVE-2026-35359.json" + }, + "officialCodeCommits": [ + "acadaa0e51ab82309ec81e6021d630b875cd3e10" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12135", + "securityBoundary": { + "control": "cp\u4f9ddereference policy\u7522\u751fnofollow\uff0cLinux\u666e\u901a/reflink/sparse/stream\u4f86\u6e90open\u90fd\u50b3\u905e\u6b64\u503c\uff1bsafe_copy::open_source\u8a2dO_NOFOLLOW\uff0c\u6b63\u5e38dereference\u4ecd\u4fdd\u7559GNU\u8a9e\u610f\u3002", + "platform": "native Linux amd64", + "sink": "\u610f\u5916\u8b80\u53d6\u4efb\u610f\u654f\u611fsymlink\u76ee\u6a19", + "source": "cp no-dereference\u4f86\u6e90\u5728lstat\u8207open\u9593\u88ab\u63db\u6210symlink" + }, + "sourceEvidence": [ + { + "lineEnd": 2870, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 2864, + "path": "src/uu/cp/src/cp.rs", + "sha256": "7094892394071883367c3b0de3b1b7d0beee6a354d32931397808276547ae525", + "ubuntuQuiltTouches": [ + { + "name": "cp-fix-symlink-target-permissions.patch", + "sha256": "53cfbb7a0376da6710985496774f8981e71d207aa97d932f73d5978c978ea100" + } + ], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/cp/src/cp.rs" + }, + { + "lineEnd": 39, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 33, + "path": "src/uu/cp/src/platform/linux.rs", + "sha256": "c05c36f1f8cc3a904b4666b86d4719f42887354d6d7b67027b38f546c2dac86e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/cp/src/platform/linux.rs" + }, + { + "lineEnd": 56, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 50, + "path": "src/uucore/src/lib/features/safe_copy.rs", + "sha256": "638d5ca70fae2ac677fe154a6302f76a760bcaa370194e1b5123951d81ffa54e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/features/safe_copy.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 659, + "behind_by": 0, + "commit": "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "mergeBase": "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "status": "ahead", + "total_commits": 659, + "url": "https://api.github.com/repos/uutils/coreutils/compare/d296f8d39f3a71ef7c5bd3b949e65aa978f1436d...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_touch::test_touch_does_not_truncate_symlink_target ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35360", + "lockMergeOrCompletionAnchor": "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d", + "officialAdvisory": { + "sha256": "66d7237bdb5f70d308eae0b07b3fbcee9dc565a2d992420d1495df61ff8507ef", + "url": "https://ubuntu.com/security/cves/CVE-2026-35360.json" + }, + "officialCodeCommits": [ + "d296f8d39f3a71ef7c5bd3b949e65aa978f1436d" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12171", + "securityBoundary": { + "control": "\u7f3a\u6a94\u5efa\u7acb\u5206\u652f\u6539\u547c\u53ebcreate_without_truncate\uff0cOpenOptions\u7684truncate(false)\u907f\u514d\u6e05\u7a7a\u65e2\u6709\u76ee\u6a19\uff1b\u4fdd\u7559\u6b63\u5e38\u5efa\u7acb\u8207timestamp\u529f\u80fd\u3002", + "platform": "native Linux amd64", + "sink": "\u65e2\u6709\u6a94\u6848\u88ab\u622a\u65b7", + "source": "touch\u5224\u5b9a\u7f3a\u6a94\u5f8c\u7684path\uff0fsymlink\u7f6e\u63db" + }, + "sourceEvidence": [ + { + "lineEnd": 449, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 443, + "path": "src/uu/touch/src/touch.rs", + "sha256": "5a348cc8d276aa0613393ea0451fca703d5e614ae83e6cae6b25582cf0cbda27", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/touch/src/touch.rs" + }, + { + "lineEnd": 507, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 501, + "path": "src/uu/touch/src/touch.rs", + "sha256": "5a348cc8d276aa0613393ea0451fca703d5e614ae83e6cae6b25582cf0cbda27", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/touch/src/touch.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 956, + "behind_by": 0, + "commit": "6b16cc9688fe998f5dc2786f53f22ceea709e318", + "mergeBase": "6b16cc9688fe998f5dc2786f53f22ceea709e318", + "status": "ahead", + "total_commits": 956, + "url": "https://api.github.com/repos/uutils/coreutils/compare/6b16cc9688fe998f5dc2786f53f22ceea709e318...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_rm::test_dot_protection_multiple_slashes ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35363", + "lockMergeOrCompletionAnchor": "6b16cc9688fe998f5dc2786f53f22ceea709e318", + "officialAdvisory": { + "sha256": "3ad4ea99fd41baf62cfe13313fab3a96611eadf1dec973cd7e162e52761454b2", + "url": "https://ubuntu.com/security/cves/CVE-2026-35363.json" + }, + "officialCodeCommits": [ + "6b16cc9688fe998f5dc2786f53f22ceea709e318" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/11005", + "securityBoundary": { + "control": "\u4fdd\u8b77\u6aa2\u67e5\u5148\u79fb\u9664\u6240\u6709\u5c3e\u7aefseparator\uff0c\u518d\u5224\u65b7\u6700\u5f8ccomponent\u662f\u5426.\u6216..\uff1b\u522a\u9664\u6d41\u7a0b\u5148\u547c\u53eb\u6b64guard\u3002", + "platform": "native Linux amd64", + "sink": "current/parent\u76ee\u9304\u5167\u5bb9\u88ab\u8aa4\u522a", + "source": "rm -rf\u7684./\u3001.///\u3001../\u7b49\u8def\u5f91" + }, + "sourceEvidence": [ + { + "lineEnd": 811, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 805, + "path": "src/uu/rm/src/rm.rs", + "sha256": "776b445347e3087e90d811b38b5b7604c8e362608c1772b762aa7aabe55671c4", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/rm/src/rm.rs" + }, + { + "lineEnd": 974, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 968, + "path": "src/uu/rm/src/rm.rs", + "sha256": "776b445347e3087e90d811b38b5b7604c8e362608c1772b762aa7aabe55671c4", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/rm/src/rm.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 793, + "behind_by": 0, + "commit": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "mergeBase": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "status": "ahead", + "total_commits": 793, + "url": "https://api.github.com/repos/uutils/coreutils/compare/aa30c103004463ffe7b8a98ca0ce519d56bbbd41...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_mv::test_mv_cross_device_refuses_planted_symlink_dest ... ok", + "test test_mv::test_mv_cross_device_dir_refuses_symlink_at_recreated_dest ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35364", + "lockMergeOrCompletionAnchor": "aa30c103004463ffe7b8a98ca0ce519d56bbbd41", + "officialAdvisory": { + "sha256": "fe0f4e59872e1e27a8a00dd28b97574e7db1e3659abf6254649db5ca9ccc7921", + "url": "https://ubuntu.com/security/cves/CVE-2026-35364.json" + }, + "officialCodeCommits": [ + "500dd65c539bff848c712bf7a8a111109a32eae9" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12170", + "securityBoundary": { + "control": "\u8de8device\u666e\u901a\u6a94fallback\u4ee5create_dest_restrictive(to,true)\u8a2dO_NOFOLLOW\uff1bfd\u4fdd\u7559\u5230copy\u8207metadata\u8655\u7406\uff0csource/dest\u4e0d\u56e0\u518dopen\u5c0e\u5411symlink\u76ee\u6a19\u3002", + "platform": "native Linux amd64", + "sink": "\u4efb\u610f\u76ee\u6a19\u88ab\u622a\u65b7\uff0f\u8986\u5beb", + "source": "mv\u79fb\u9664\u820a\u76ee\u7684\u6a94\u5f8c\u88ab\u690d\u5165symlink" + }, + "sourceEvidence": [ + { + "lineEnd": 1424, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 1418, + "path": "src/uu/mv/src/mv.rs", + "sha256": "852b6f61b68bc9b96ead38f1132ae23047a14e4229705a8f11f31f5ba1608734", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/mv/src/mv.rs" + }, + { + "lineEnd": 79, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 73, + "path": "src/uucore/src/lib/features/safe_copy.rs", + "sha256": "638d5ca70fae2ac677fe154a6302f76a760bcaa370194e1b5123951d81ffa54e", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uucore/src/lib/features/safe_copy.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 763, + "behind_by": 0, + "commit": "acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "mergeBase": "acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "status": "ahead", + "total_commits": 763, + "url": "https://api.github.com/repos/uutils/coreutils/compare/acd51ef18d90e5c26f694df721f896fa1c4a9edc...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "\u672c\u8f2a\u53ea\u4f5csource closure\uff1b\u4e0d\u5c07\u4e00\u822csuite PASS\u5192\u5145\u6b64CVE\u5c08\u5c6c\u52d5\u614b\u91cd\u73fe\u5168\u8986\u84cb\u3002", + "reused": true + }, + "id": "CVE-2026-35367", + "lockMergeOrCompletionAnchor": "acd51ef18d90e5c26f694df721f896fa1c4a9edc", + "officialAdvisory": { + "sha256": "84d14cafbb0fb2c3e353af964db234cab2ec91bd836f357938718836d3fd77d5", + "url": "https://ubuntu.com/security/cves/CVE-2026-35367.json" + }, + "officialCodeCommits": [ + "acd51ef18d90e5c26f694df721f896fa1c4a9edc" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12339", + "securityBoundary": { + "control": "OpenOptions\u65bc\u5efa\u7acb\u6642\u660e\u78bamode(0600)\uff0c\u65e2\u6709\u6a94mode\u4e0d\u88ab\u6539\u5beb\u3002", + "platform": "native Linux amd64", + "sink": "\u9810\u8a2dworld-readable\u8f38\u51fa", + "source": "nohup\u65b0\u5efanohup.out" + }, + "sourceEvidence": [ + { + "lineEnd": 166, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 160, + "path": "src/uu/nohup/src/nohup.rs", + "sha256": "93a8ab0c82728d3856b0e181312bdb3f0418d86a33f70af00b61e915e2b3173c", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/nohup/src/nohup.rs" + }, + { + "lineEnd": 174, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 168, + "path": "src/uu/nohup/src/nohup.rs", + "sha256": "93a8ab0c82728d3856b0e181312bdb3f0418d86a33f70af00b61e915e2b3173c", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/nohup/src/nohup.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 765, + "behind_by": 0, + "commit": "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "mergeBase": "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "status": "ahead", + "total_commits": 765, + "url": "https://api.github.com/repos/uutils/coreutils/compare/f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "\u672c\u8f2a\u53ea\u4f5csource closure\uff1b\u4e0d\u5c07\u4e00\u822csuite PASS\u5192\u5145\u6b64CVE\u5c08\u5c6c\u52d5\u614b\u91cd\u73fe\u5168\u8986\u84cb\u3002", + "reused": true + }, + "id": "CVE-2026-35368", + "lockMergeOrCompletionAnchor": "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f", + "officialAdvisory": { + "sha256": "cf6665f83960cfa5fee8ef279acc8173d0686de8fde2b7aafc64a7577e23d90a", + "url": "https://ubuntu.com/security/cves/CVE-2026-35368.json" + }, + "officialCodeCommits": [ + "f33bfb9321e5ebef0ef703aa6bfc8ee95bd21e1f" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/11211", + "securityBoundary": { + "control": "\u5404userspec branch\u5728enter_chroot\u524d\u89e3\u6790name\u2192uid/gid\u4e26\u8655\u7406supplementary gids\uff0c\u4e4b\u5f8c\u624dchroot\u53cadrop uid/gid\uff0c\u4e0d\u5728\u4e0d\u53ef\u4fe1root\u5167\u4ee5root\u67e5NSS\u3002", + "platform": "native Linux amd64", + "sink": "\u4e0d\u53ef\u4fe1NEWROOT\u7684NSS\u6a21\u7d44\u5728root\u6b0a\u9650\u8f09\u5165", + "source": "chroot --userspec\u642d\u914d\u4e0d\u53ef\u4fe1NEWROOT" + }, + "sourceEvidence": [ + { + "lineEnd": 431, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 398, + "path": "src/uu/chroot/src/chroot.rs", + "sha256": "9282c030345eee3c85c029b0978f587452087c4c873d3ed2f527c4aa677b4e7c", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/chroot/src/chroot.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 636, + "behind_by": 0, + "commit": "48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "mergeBase": "48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "status": "ahead", + "total_commits": 636, + "url": "https://api.github.com/repos/uutils/coreutils/compare/48d030dfb2ca7659ee2735f09cda706c8245a3fc...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_ln::test_symlink_target_dir_non_utf8_source_name ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35373", + "lockMergeOrCompletionAnchor": "48d030dfb2ca7659ee2735f09cda706c8245a3fc", + "officialAdvisory": { + "sha256": "fdab95750b279926855844eea7863db1034ae790d9b0cb76c26f59067dd98b73", + "url": "https://ubuntu.com/security/cves/CVE-2026-35373.json" + }, + "officialCodeCommits": [ + "48d030dfb2ca7659ee2735f09cda706c8245a3fc" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/11403", + "securityBoundary": { + "control": "\u4f86\u6e90\u4fdd\u6301PathBuf\uff0fOsStr\uff0c\u4f7f\u7528srcpath.file_name()\u53cajoin(srcpath)\uff0c\u4e0d\u8981\u6c42to_str\u6210\u529f\uff1b\u5408\u6cd5Unix raw-byte\u6a94\u540d\u53ef\u901a\u904e\u3002", + "platform": "native Linux amd64", + "sink": "\u975e\u9810\u671f\u5931\u6557\uff0flocal DoS", + "source": "ln target-directory\u5f62\u5f0f\u7684\u975eUTF8\u4f86\u6e90\u6a94\u540d" + }, + "sourceEvidence": [ + { + "lineEnd": 352, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 346, + "path": "src/uu/ln/src/ln.rs", + "sha256": "5c681cfa583a268bdce9a9b740f9dcd8b38c5ab53754883e5c20800259a4f1b3", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/ln/src/ln.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 646, + "behind_by": 0, + "commit": "58040feb5c66fce588a7ffac299b3a23bd55cd81", + "mergeBase": "58040feb5c66fce588a7ffac299b3a23bd55cd81", + "status": "ahead", + "total_commits": 646, + "url": "https://api.github.com/repos/uutils/coreutils/compare/58040feb5c66fce588a7ffac299b3a23bd55cd81...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [], + "proofGap": "source unit regression\u5b58\u5728\uff0c\u4f46\u672a\u5728\u65e2\u6709strict log\u89c0\u5bdf\u5230\u6b64test\u540d\uff1b\u6b64\u6536\u64da\u4e0d\u65b0\u589e\u6e2c\u8a66\u6216\u5ba3\u7a31\u8a72unit\u5df2\u57f7\u884c\u3002", + "reused": true + }, + "id": "CVE-2026-35374", + "lockMergeOrCompletionAnchor": "58040feb5c66fce588a7ffac299b3a23bd55cd81", + "officialAdvisory": { + "sha256": "6d611d8423834ed4e58c0a2dda5b8d4123f88d6059f4a511144a0163bbad3bd4", + "url": "https://ubuntu.com/security/cves/CVE-2026-35374.json" + }, + "officialCodeCommits": [ + "58040feb5c66fce588a7ffac299b3a23bd55cd81" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12565", + "securityBoundary": { + "control": "\u65b0\u5efa\u5148create_new\uff1b\u65e2\u6709\uff0f\u91cd\u958boutput\u7528\u5df2open fd\u7684inode\u8207input\u4f5c\u8eab\u5206\u6bd4\u8f03\uff0c\u901a\u904e\u5f8c\u624dset_len(0)\u6216\u7e7c\u7e8c\u5beb\uff1b\u5f8c\u7e8c\u5408\u6cd5\u975eseekable\u8f38\u51fa\u76f8\u5bb9\u5206\u652f\u672a\u64a4\u56de\u8eab\u5206\u6aa2\u67e5\u3002", + "platform": "native Linux amd64", + "sink": "input\u88ab\u7af6\u614b\u622a\u65b7\uff0f\u8986\u5beb", + "source": "split output\u8def\u5f91\u5728\u9810\u6aa2\u5f8c\u7f6e\u63db\u70bainput alias" + }, + "sourceEvidence": [ + { + "lineEnd": 196, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 170, + "path": "src/uu/split/src/platform/unix.rs", + "sha256": "9c0a3fe7abdf46d00ef8468ce19495ed687689b7d4abc329a5f63d551b497021", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/split/src/platform/unix.rs" + }, + { + "lineEnd": 159, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 153, + "path": "src/uu/split/src/platform/unix.rs", + "sha256": "9c0a3fe7abdf46d00ef8468ce19495ed687689b7d4abc329a5f63d551b497021", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/split/src/platform/unix.rs" + }, + { + "lineEnd": 210, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 204, + "path": "src/uu/split/src/platform/unix.rs", + "sha256": "9c0a3fe7abdf46d00ef8468ce19495ed687689b7d4abc329a5f63d551b497021", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/split/src/platform/unix.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + }, + { + "additionalLockCommits": [], + "ancestry": [ + { + "ahead_by": 518, + "behind_by": 0, + "commit": "b9d8ff64c224eb425702584b66ec0ac64a99592a", + "mergeBase": "b9d8ff64c224eb425702584b66ec0ac64a99592a", + "status": "ahead", + "total_commits": 518, + "url": "https://api.github.com/repos/uutils/coreutils/compare/b9d8ff64c224eb425702584b66ec0ac64a99592a...28b6856d7b215bf844b4223589cb54ade84f5223?per_page=1" + } + ], + "confidence": "high", + "effectiveRevertFound": false, + "evidence": "\u5b98\u65b9\u4fee\u88dc\uff0f\u7b49\u50f9\u5f8c\u7e8c\u5b89\u5168\u63a7\u5236\u5b58\u5728\u65bcexact Ubuntu0.10 orig+quilt\uff1bnative custom patch\u53ea\u52d5id\uff0c\u4e0d\u5f71\u97ff\u672c18\u9805\u3002\u6b64fixed\u50c5\u8868\u793a\u672cqualified component source\uff0c\u672a\u4fee\u6539\u4efb\u4f55\u6210\u54c1VEX\u6216canonical\u3002", + "existingNativeLog": { + "completeCveSpecificDynamicCoverageClaimed": false, + "observedRelatedTests": [ + "test test_env::test_split_string_single_quotes_keep_unknown_backslash_sequences_literal ... ok" + ], + "proofGap": null, + "reused": true + }, + "id": "CVE-2026-35377", + "lockMergeOrCompletionAnchor": "b9d8ff64c224eb425702584b66ec0ac64a99592a", + "officialAdvisory": { + "sha256": "f47ecf4fa958906c580316e18badce6c90c1ba5fbaff01b7fbc71f0700c1ae1f", + "url": "https://ubuntu.com/security/cves/CVE-2026-35377.json" + }, + "officialCodeCommits": [ + "55298ee943231c07e944bd6e1a9cf9e01236d324" + ], + "officialPullRequest": "https://github.com/uutils/coreutils/pull/12657", + "securityBoundary": { + "control": "state_single_quoted\u7684escape\u5206\u652f\u5c0d\u672a\u77e5\u5b57\u5143\u4fdd\u7559literal BACKSLASH\u800c\u975e\u56de\u50b3invalid-sequence\u3002\u771f\u6b63code commit\u662fPR12657\u516755298ee9\uff0clock\u7684b9d8\u662f\u8a72\u5df2merged PR\u5b8c\u6210\u9328\u9ede\uff08\u672ccommit\u53ea\u6539GNU test patch\uff09\u3002", + "platform": "native Linux amd64", + "sink": "\u5408\u6cd5split-string\u4ee5125\u975e\u9810\u671f\u7d42\u6b62", + "source": "env -S\u55ae\u5f15\u865f\u5167\u672a\u77e5\u53cd\u659c\u7dda\u5e8f\u5217" + }, + "sourceEvidence": [ + { + "lineEnd": 288, + "lineMeaning": "1-based lines in exact Ubuntu orig + original enabled quilt, before the id-only custom patch", + "lineStart": 244, + "path": "src/uu/env/src/split_iterator.rs", + "sha256": "da0cc908efed1adbf9baace843cfa4de72317512ba4143141ddd0a031bae21a9", + "ubuntuQuiltTouches": [], + "upstreamOriginalFileUrl": "https://github.com/uutils/coreutils/blob/28b6856d7b215bf844b4223589cb54ade84f5223/src/uu/env/src/split_iterator.rs" + } + ], + "sourceFixPresent": true, + "status": "fixed", + "successorCommit": null, + "ubuntuQuiltConclusion": "\u539fseries\u6240\u6709patch target\u5747\u5df2\u6838\u5c0d\uff1b\u6d89\u53ca\u6b64\u63a7\u5236\u7684source hunks\u5df2\u5728\u8a18\u61b6\u9ad4\u4ee5\u5b8c\u6574exact context\u91cd\u5efa\u4e26\u9010\u6bb5\u6aa2\u67e5\uff0c\u672a\u64a4\u56de\u76ee\u524d\u5b89\u5168\u63a7\u5236\u3002", + "verdictScope": "qualified-component-source-closure-only" + } + ], + "schemaVersion": 1, + "scope": { + "canonicalOrProductDispositionsChanged": false, + "componentPackage": "rust-coreutils_0.10.0-1ubuntu2~26.04.1+pasturestack.1_amd64.deb", + "customVersion": "0.10.0-1ubuntu2~26.04.1+pasturestack.1", + "newIdCvesExcludedFromReassessment": [ + "CVE-2026-35370", + "CVE-2026-35371" + ], + "originalPendingCves": 18, + "verdict": "18\u9805\u5728\u6307\u5b9a\u5408\u683ccandidate\u7684source\u4e2d\u6709\u5b98\u65b9\u4fee\u5fa9\uff0f\u7b49\u50f9\u5b89\u5168\u5f8c\u7e8c\u5be6\u4f5c\uff0c\u672a\u898b\u6709\u6548revert\uff1b\u4e0d\u662f20\u9805\u7522\u54c1\u5df2\u4fee\u5ba3\u544a\u3002" + }, + "sourceBuildBoundary": { + "customPatch": { + "commit": "ff382f527994b8a021ec0a086dec4c5cafa34f2a", + "firstFixedVersion": "0.11.0", + "fixedCves": [ + "CVE-2026-35370", + "CVE-2026-35371" + ], + "name": "ff382f527994b8a021ec0a086dec4c5cafa34f2a.patch", + "onlyTargetPaths": [ + "src/uu/id/src/id.rs", + "tests/by-util/test_id.rs" + ], + "original18SourceUntouched": true, + "paths": [ + "src/uu/id/src/id.rs", + "tests/by-util/test_id.rs" + ], + "pullRequest": "https://github.com/uutils/coreutils/pull/14103", + "sha256": "32d843876e0814cb070104afc049c036391604ea517c6e837ffc9345fe1e2de2", + "size": 4082, + "url": "https://github.com/uutils/coreutils/commit/ff382f527994b8a021ec0a086dec4c5cafa34f2a.patch" + }, + "helperOnlyNonSecurityChanges": "\u539f\u5305\u88ddchangelog\u52a0first-party\u7248\u672c\uff0f\u4f86\u6e90\uff1bstrict target\u7531\u5916\u90e8native-tests.mk\u57f7\u884c\uff0c\u672a\u653918\u9805\u5b89\u5168source\u3002", + "nativeHelper": "server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py", + "preBuildSourcePins": { + "lines": [ + 343, + 347 + ], + "meaning": "\u5b98\u65b9dsc\u4f86\u6e90\u9a57\u8b49\u8207dpkg-source orig+quilt\u62bd\u53d6\u5f8cbaseline SHA\u6838\u5c0d" + }, + "producerCommit": "07e4a7afa827cb45514a72aba12f03b1d30d121d" + }, + "status": "PASS_ORIGINAL18_SOURCE_CLOSURE", + "summary": { + "consumerBuildsOrRuntimeTestsPerformed": 0, + "inconclusiveSourceClosure": 0, + "oneJsonReceiptOnly": true, + "productCvesClosed": 0, + "sourceClosed": 18, + "stillVulnerable": 0 + }, + "ubuntuQuilt": { + "enabledPatches": [ + { + "allTargetPaths": [ + "GNUmakefile" + ], + "patch": "fix-prefix.diff", + "reviewedPathsTouched": [ + "GNUmakefile" + ], + "sha256": "b6257d84300a1013651f94646911433f6d1b606159511f76513b4e74834da064" + }, + { + "allTargetPaths": [ + "Cargo.toml" + ], + "patch": "workspace-exclude.patch", + "reviewedPathsTouched": [], + "sha256": "05ae74382b1217fe344f55ace401d2c742ece04b11a2c6e0b40de4ae628139c4" + }, + { + "allTargetPaths": [ + "GNUmakefile" + ], + "patch": "use-l10n-translations-in-makefile.patch", + "reviewedPathsTouched": [ + "GNUmakefile" + ], + "sha256": "26cf17de79dbd5f6bd14ed1b5c9fcd84353e046a191197d2a140ed0d3f847ed5" + }, + { + "allTargetPaths": [ + "GNUmakefile", + "src/uucore/src/lib/mods/locale.rs" + ], + "patch": "fix-locale-path.patch", + "reviewedPathsTouched": [ + "GNUmakefile" + ], + "sha256": "f24b39c73345332368f887ccdff4dfb16cebfaee87837488fb723e5b1cbb0631" + }, + { + "allTargetPaths": [ + "src/bin/uudoc.rs" + ], + "patch": "improve-man.diff", + "reviewedPathsTouched": [], + "sha256": "feafc91daa75d1895d85c46a4efc8ba6b1e4366f9fd0b05ecede9ede29b6fcb8" + }, + { + "allTargetPaths": [ + "src/bin/uudoc.rs" + ], + "patch": "reproducible-man-date.diff", + "reviewedPathsTouched": [], + "sha256": "5426f02e0117aa33d4a28768b040ccd94297211cd3cbaa374b57736d0ebd0b4a" + }, + { + "allTargetPaths": [ + "Cargo.toml" + ], + "patch": "remove-workspace-members.patch", + "reviewedPathsTouched": [], + "sha256": "3f60549df336e397f56dcdd8adf0c488ba13b40c6a6060ab19b32f13cfddfd5a" + }, + { + "allTargetPaths": [ + "Cargo.toml" + ], + "patch": "Tweak-release-build-profile.patch", + "reviewedPathsTouched": [], + "sha256": "0fbf669c40b7ec4880a9eea78dee14c041d1a07b0d39dde4bb69bf1ee852d085" + }, + { + "allTargetPaths": [ + "Cargo.toml" + ], + "patch": "build-stty.patch", + "reviewedPathsTouched": [], + "sha256": "d869d308d06cc9ccad6ab93f5e21297a8434711d286ec17a513ea76d38b5ac41" + }, + { + "allTargetPaths": [ + "src/bin/coreutils.rs", + "tests/by-util/test_env.rs", + "tests/test_util_name.rs" + ], + "patch": "require-utility-to-be-invoked-at-matching-path.patch", + "reviewedPathsTouched": [], + "sha256": "6761e8474f7244fa3e9e5d35038c0939aa8e34e32a2beb7254b90757a803640f" + }, + { + "allTargetPaths": [ + "src/uu/dd/src/dd.rs", + "tests/by-util/test_dd.rs" + ], + "patch": "dd-ensure-full-writes.patch", + "reviewedPathsTouched": [ + "src/uu/dd/src/dd.rs" + ], + "sha256": "dd2c5a3cc8dfb89dea54e68db55903859f09ab6f0bb6ff3e7cbf7c7ecc7f80b1" + }, + { + "allTargetPaths": [ + "Cargo.toml" + ], + "patch": "rustix-use-libc-backend.patch", + "reviewedPathsTouched": [], + "sha256": "29e6bd79d75a68cd6263c0f00fed61ed32c9e3270b3d32b86abd9a8b0e0c0cc8" + }, + { + "allTargetPaths": [ + "src/uu/df/src/df.rs", + "src/uu/df/src/filesystem.rs" + ], + "patch": "df-statfs-fallback.patch", + "reviewedPathsTouched": [], + "sha256": "23ee682dcf37befeefd4b6f52fe56de4d6756cf831f728acc8003c0f871beb43" + }, + { + "allTargetPaths": [ + "src/uu/cp/src/cp.rs", + "tests/by-util/test_cp.rs" + ], + "patch": "cp-fix-symlink-target-permissions.patch", + "reviewedPathsTouched": [ + "src/uu/cp/src/cp.rs", + "tests/by-util/test_cp.rs" + ], + "sha256": "53cfbb7a0376da6710985496774f8981e71d207aa97d932f73d5978c978ea100" + } + ], + "inMemoryReplayOnly": true, + "matchedNativeBaselineFilePins": 18, + "originalRulesAndPackagingPreserved": true, + "seriesSha256": "90890b8dda501e3212c826d070e42a9107e5a600cfe48fd3143b14e79f031a9b" + }, + "validation": { + "nativeSourcePinsMatched": 18, + "newBuildTestScanExecuted": false, + "officialCommitPatchByteBindings": 20, + "originalCves": 18, + "originalUbuntuQuiltSourceControlsReviewed": true, + "producerLockBytesMatch": true, + "rawPackageShaMatched": true, + "sourceOrCanonicalModified": false, + "status": "PASS", + "ubuntuOrigEqualsFreshOfficialArchive": true, + "uniqueLockOfficialAnchors": 16 + } + }, + "originalReceiptSha256": "73189028622c0a0fc074067b4c5b8521094921531f95cf3e68073baaebd2fbbd", + "projection": { + "excludedFields": [ + "identity.originalZip" + ], + "reason": "Omit the local evidence-store reference; preserve all source, native identity and limitation evidence." + }, + "schemaVersion": 1 +} diff --git a/server/security/ubuntu-backports/bundle/source-inputs.lock.json b/server/security/ubuntu-backports/bundle/source-inputs.lock.json new file mode 100644 index 0000000000..71da02ffed --- /dev/null +++ b/server/security/ubuntu-backports/bundle/source-inputs.lock.json @@ -0,0 +1,698 @@ +{ + "bundleVersion": "0.1.0", + "consumerFixture": { + "baselineMainIndexEvidence": { + "inReleaseSha256": "45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c", + "index": { + "path": "main/binary-amd64/Packages.xz", + "sha256": "ed9ac41cb263efaecc5a06a0742dc56570e2fb5ff94c4f2e4b2fbdcddd73cf24", + "size": 1480032 + }, + "packages": [ + { + "Filename": "pool/main/c/coreutils-from/coreutils-from-gnu_0.0.0~ubuntu25_all.deb", + "Package": "coreutils-from-gnu", + "SHA256": "862be75c7dd5d92815f2b80149bbcf819d11d21b1dba734f62b545bc5acb0eb7", + "Size": "6902", + "Version": "0.0.0~ubuntu25" + }, + { + "Filename": "pool/main/c/coreutils-from/coreutils-from-uutils_0.0.0~ubuntu25_all.deb", + "Package": "coreutils-from-uutils", + "SHA256": "fd89943a93fdb3e2746faff8fe1bb23106dbdd6c891df0f50fe2f8b42d6f089d", + "Size": "7124", + "Version": "0.0.0~ubuntu25" + }, + { + "Filename": "pool/main/c/coreutils/gnu-coreutils_9.7-3ubuntu2_amd64.deb", + "Package": "gnu-coreutils", + "SHA256": "6b5b60a5c372b6e9d1dcfa1507317aae59bf809d4f4d6d363d3ef0a58a189137", + "Size": "1339676", + "Version": "9.7-3ubuntu2" + } + ], + "snapshot": "20261023T000000Z", + "suite": "resolute" + }, + "signatureVerification": "The fixture apt-get update requires the original Ubuntu archive keyring and verifies signed InRelease; local review matched the downloaded Packages.xz SHA/size to that InRelease.", + "snapshotURL": "https://snapshot.ubuntu.com/ubuntu/20261023T000000Z", + "ubuntuImage": "ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b" + }, + "image": "ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0", + "nativeInputs": { + "glibc": { + "artifactId": 12573643405, + "artifactName": "ubuntu-native-glibc-507cadcb3eaafcd6a85c453f2f180a8aef415d88-40122645083", + "ciClosure": { + ".github/workflows/ubuntu-backport-native.yml": { + "currentSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "nativeSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "reviewedDifference": "Native source and effective CI recipe are identical to the approved current files." + }, + "server/security/ubuntu-backports/Dockerfile.native-ci": { + "currentSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "nativeSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "reviewedDifference": "Native source and effective CI recipe are identical to the approved current files." + } + }, + "copyright": { + "file": "COPYRIGHT", + "sha256": "c31d10900fdb95d1889005145a2205bb75e3bc018d3ec8265b81364901748e09" + }, + "copyrightEvidence": { + "path": "debian/copyright", + "source": { + "name": "glibc_2.43-2ubuntu2.4.debian.tar.xz", + "sha256": "28103a7cf808c29901c6053d89a4e8299880abfbb850c0d97a09c8df8533e306", + "size": 530764, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/g/glibc/glibc_2.43-2ubuntu2.4.debian.tar.xz" + } + }, + "evidence": { + "COPYRIGHT": "c31d10900fdb95d1889005145a2205bb75e3bc018d3ec8265b81364901748e09", + "builder-packages.tsv": "fa9d46ddbb27235e5b3f6f29fbbc63e0a5a5bfeb681ed57302b14938640dc385", + "generated-debian-control": "a1cca0ba6356e1318f218b05dec1213bae7d43dce2bd79055119d75070a9b85e", + "glibc.lock.json": "5b4ead11dcace84b99874d298eec4d6939203040aecd5cfc3f4452a83eb0bf12", + "glibc_2.43-2ubuntu2.4+pasturestack.1.dsc": "4a7bd217e000e44933db0b5d48d04e94936ea188dd7a42c4cc8ce6eca8f209b5", + "glibc_2.43-2ubuntu2.4+pasturestack.1_amd64.buildinfo": "ddc397c4caf06be75cc7c4be8394212f1ddfb242c115136f22868b1f3a5198b7", + "glibc_2.43-2ubuntu2.4+pasturestack.1_amd64.changes": "9bcf4cb463052e74fd08e649998397d9bc60220dd3c32ed16cccc20f560e0997", + "manifest.tsv": "a3ca5de5aec3bcb88c2499486ff14d9f976ce58a81a2a6d3daa7c76e099c0869", + "native-config.make": "4d7257d3ab6788b9a28cc7948e9025470996dd751eac2c91e37ec3e13977c0b9", + "native-configparms": "4b09bdaed30ef3d58e957d4599420019f2ca31c6dba952ae7e62084b89557b09", + "provenance.json": "cc77cdde3f1b6b5459b45282bbbdcccbe42c3d3fd5d7a41f4623a92509a2263a", + "tests.sum": "4d9b149fd0711f4a4cf67c15fae7f5be23edabf3f40f0c542125eda0c804e19f", + "tst-fopen-ccs-empty.out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tst-fopen-ccs-empty.test-result": "818ea455ca8c5e31c156a6b7046bf1a552bbcb9432d147c6a87c9a64669e57e4", + "tst-fopenloc.out": "89a245c0599fd89e6a9b8536c79ea7aefe16203cb4332cb8a841cdff4a5818d6", + "tst-fopenloc.test-result": "5234ae2c930bcfcbd094eab622797a4f869bbe261c9326e6a6f380656676c45f", + "tst-fopenloc2.out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "tst-fopenloc2.test-result": "80339c05fd3053b631971af004613f6da2be7a4ca39454d1d173d35a7693b13a" + }, + "fixedVulnerabilities": [ + "CVE-2026-18374" + ], + "jobId": 120957337330, + "lockSha256": "5b4ead11dcace84b99874d298eec4d6939203040aecd5cfc3f4452a83eb0bf12", + "packages": [ + { + "file": "libc6_2.43-2ubuntu2.4+pasturestack.1_amd64.deb", + "name": "libc6", + "sha256": "4119ebc8610d17694db4100e6838a693f8927189523f4b4be32be5aa4a82f497", + "source": "glibc", + "version": "2.43-2ubuntu2.4+pasturestack.1" + }, + { + "file": "libc-bin_2.43-2ubuntu2.4+pasturestack.1_amd64.deb", + "name": "libc-bin", + "sha256": "6e8ea949edd167a48e4f2ded82f4117e4dca89fc7c8060062b614bbb682b9417", + "source": "glibc", + "version": "2.43-2ubuntu2.4+pasturestack.1" + }, + { + "file": "libc-gconv-modules-extra_2.43-2ubuntu2.4+pasturestack.1_amd64.deb", + "name": "libc-gconv-modules-extra", + "sha256": "22cb6a83267d6e873538983d1eafda6cac9240c5c18ff61e2652fd6ae982e374", + "source": "glibc", + "version": "2.43-2ubuntu2.4+pasturestack.1" + } + ], + "runConclusion": "success", + "runId": 40122645083, + "runtimePackages": [ + "libc6", + "libc-bin", + "libc-gconv-modules-extra" + ], + "sourceCommit": "507cadcb3eaafcd6a85c453f2f180a8aef415d88", + "sourceFiles": { + "server/security/ubuntu-backports/build-pcre2-backport.py": "79ff6faf3ca5b8e6dfdfc38a78ba56238c749aeabfab59fd05ce2130537458b2", + "server/security/ubuntu-backports/glibc/build-glibc-backport.py": "a086f51359bab0d0994070d4cdf0e4fcc1f19bc4acf8dbd71d189707c72ef3e6", + "server/security/ubuntu-backports/glibc/glibc.lock.json": "5b4ead11dcace84b99874d298eec4d6939203040aecd5cfc3f4452a83eb0bf12", + "server/security/ubuntu-backports/glibc/test_glibc_backport.py": "d08cb0b93d6c49c0124eb69cedd55af3949b41c48a09c57d52d49e304641a583" + }, + "status": "qualified", + "vexScope": "Exact official glibc 2.43 fix and regression backports; native amd64 full upstream summary retains actual XFAIL/UNSUPPORTED/XPASS states. Original Ubuntu packaging templates generate first-party control metadata; these packages are not issued by Canonical.", + "zipSha256": "ed6714b9b4a884a0c0462b84b01f5d7f9e08b4cc1bbf849b521ac43fa4ef36c4" + }, + "jq": { + "artifactId": 12570170631, + "artifactName": "ubuntu-native-jq-bc129a88a5ec8c4e64b3ed89bfd97c7e82362d7c-40116776455", + "ciClosure": { + ".github/workflows/ubuntu-backport-native.yml": { + "currentSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "nativeSha256": "e2b32c1f0ccafc21503a4cd4b3eb5fb10a82aec82b7eb81c74dc634e7afa4c05", + "reviewedDifference": "Reviewed exact diff: workflow adds other module selection/initial-PR scheduling; recipe adds p11-kit/libxrender-only tools, Rust-only CI marker, builder inventory and failed-glibc diagnostics. jq effective source/build dependencies and owning commands are unchanged." + }, + "server/security/ubuntu-backports/Dockerfile.native-ci": { + "currentSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "nativeSha256": "b3dd3e1f7bbad9ace7c35e32f061d307862cad31b60c4818a0a666ed4b25e0fb", + "reviewedDifference": "Reviewed exact diff: workflow adds other module selection/initial-PR scheduling; recipe adds p11-kit/libxrender-only tools, Rust-only CI marker, builder inventory and failed-glibc diagnostics. jq effective source/build dependencies and owning commands are unchanged." + } + }, + "copyright": { + "file": "COPYRIGHT", + "sha256": "92965c1a25cfced63db0fcc869d060d773d6105da9bd8d3da232ed1691cda778" + }, + "evidence": { + "COPYRIGHT": "92965c1a25cfced63db0fcc869d060d773d6105da9bd8d3da232ed1691cda778", + "base64test.log": "0ec2e6651d00c82646424e5512bba5f4ec8376f5aab2306133f5a906009a97d9", + "base64test.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "builder-packages.tsv": "379d4a07715d6a587d9ce25105bc216dca2dd08ec8faa61f3ce0d7470b21d225", + "jq.lock.json": "3a000a74debdf3d04d0e475f7413f82e2aa422cb95d888c4fe1ee3949536c229", + "jq_1.8.2-0pasturestack1~26.04.1_amd64.buildinfo": "9a601a1e4fb6e0730b0d9bf740604b0e077d952b023f245d128a6a6429e9ccb4", + "jq_1.8.2-0pasturestack1~26.04.1_amd64.changes": "af908c5e73271510dc042a61c6e5917d91ab59dc976a8362c19aca10359967d2", + "jqtest.log": "5ab9b68330470807e9ce0587d7b89e7f0aba17d54a3c767cdf074093fdc77874", + "jqtest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "manifest.tsv": "2a3b62801f5f94ad88fa97c2372bd5831d5286c49d5b0f66c4a80cbc5c329ce1", + "manonigtest.log": "ae377728ce527be518b204e01ca59b897a86e37f339f4b896f1f477e1f98da24", + "manonigtest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "mantest.log": "3e9312bd0c98b88e2abe7c3d9225db33b6d89ba40b8ed906e1f2a845fe9528df", + "mantest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "onigtest.log": "26f99a726b6a9999f034abd0428a658db1c3dc5f4afe422eb8ae33f739d0d05e", + "onigtest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "optionaltest.log": "f87d529edd1dd85b887b3556f46ea034a6973468ce4fd30b4702ffe3792b7ff0", + "optionaltest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "provenance.json": "673cf3ebcb16ddd769f31b14960b4d7cfb091ae6f28e810d60050d4eb56c3c1d", + "shtest.log": "43247569114fac8222645dcd376fa4d8c146ce30bc9547e9eb4893fd5050406d", + "shtest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "uritest.log": "676ab96470f36d878725abb2f7d820c7098b26130409b8e964586b699816500a", + "uritest.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229", + "utf8test.log": "4a603cd14e39c981b121c56d1436279deca1493b2c36d9731391708d8b32b098", + "utf8test.trs": "7f175f2f5d04511903d382671ba96ca623e127f57730d53966587cf2c2689229" + }, + "fixedVulnerabilities": [ + "CVE-2026-32316", + "CVE-2026-33947", + "CVE-2026-33948", + "CVE-2026-39956", + "CVE-2026-39979", + "CVE-2026-40164", + "CVE-2026-40612", + "CVE-2026-41256", + "CVE-2026-41257", + "CVE-2026-43894", + "CVE-2026-43895", + "CVE-2026-43896", + "CVE-2026-44777", + "CVE-2026-47770", + "CVE-2026-49839", + "CVE-2026-54679", + "GHSA-gf4g-95wj-4q4r", + "GHSA-hj52-j2c9-r8r4" + ], + "jobId": 120939862251, + "lockSha256": "3a000a74debdf3d04d0e475f7413f82e2aa422cb95d888c4fe1ee3949536c229", + "packages": [ + { + "file": "jq_1.8.2-0pasturestack1~26.04.1_amd64.deb", + "name": "jq", + "sha256": "88dce8936c13daa0195783836e5b8658d25545068b59a8e8232ae8db442dcea9", + "source": "jq", + "version": "1.8.2-0pasturestack1~26.04.1" + }, + { + "file": "libjq1_1.8.2-0pasturestack1~26.04.1_amd64.deb", + "name": "libjq1", + "sha256": "e1b61456c00ae0508406163000d1a816de0c64e4db9f268a9667f31c4315f1df", + "source": "jq", + "version": "1.8.2-0pasturestack1~26.04.1" + } + ], + "runConclusion": "failure", + "runId": 40116776455, + "runtimePackages": [ + "jq", + "libjq1" + ], + "sourceCommit": "bc129a88a5ec8c4e64b3ed89bfd97c7e82362d7c", + "sourceFiles": { + "server/security/ubuntu-backports/build-pcre2-backport.py": "79ff6faf3ca5b8e6dfdfc38a78ba56238c749aeabfab59fd05ce2130537458b2", + "server/security/ubuntu-backports/jq/build-jq-release.py": "c71512fbb65df44c75d75cb47d31fa338acaca7b14699b9fcb419ea64180694b", + "server/security/ubuntu-backports/jq/jq.lock.json": "3a000a74debdf3d04d0e475f7413f82e2aa422cb95d888c4fe1ee3949536c229", + "server/security/ubuntu-backports/jq/test_jq_release.py": "8c78f324d76956b892e9b4f8cf19d17c5462df413a20826de39e80ded52018a5" + }, + "status": "qualified", + "vexScope": "Pinned official jq 1.8.2 release source with original Ubuntu packaging and system libonig.so.5; exact qualified package bytes only. This statement does not cover the separate libonig package.", + "zipSha256": "5536c059d0db1fd9587650634a9f1ec9800b804768c8ae4f83cbab19effcaef1" + }, + "libxrender": { + "artifactId": 12571296848, + "artifactName": "ubuntu-native-libxrender-07e4a7afa827cb45514a72aba12f03b1d30d121d-40120642373", + "ciClosure": { + ".github/workflows/ubuntu-backport-native.yml": { + "currentSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "nativeSha256": "f06a225d71f28b31f1592025ed272055aa54266c1c112bc08c70e55529d885f4", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + }, + "server/security/ubuntu-backports/Dockerfile.native-ci": { + "currentSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "nativeSha256": "bca055ed1bd8fd213a906eceb23c5ff3f9449d06acb51e249c976c3174d406bb", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + } + }, + "copyright": { + "file": "COPYRIGHT", + "sha256": "ed2a78123a5076b4f11da38cb1ffb85b5cb9fdc26b5746c517b1d7e7a9029704" + }, + "evidence": { + "COPYRIGHT": "ed2a78123a5076b4f11da38cb1ffb85b5cb9fdc26b5746c517b1d7e7a9029704", + "builder-packages.tsv": "5d5f3d65439a54d9680fb20acc2bcb06a7efadbd9187fbce5a7d43ed716803c2", + "libxrender.lock.json": "6ca89ea2cca1c36a7db42adf24c8ae8fff5fa69ef853da3508c74a38151fa720", + "libxrender_0.9.12-1build1+pasturestack.1_amd64.buildinfo": "a80211c4a875b07c67cc61fb4d341ee928063f73e416cab17031594354f0c647", + "libxrender_0.9.12-1build1+pasturestack.1_amd64.changes": "29847cb93b70b3e9bc69561f6dd2fec3803c59e8df3d1fd87f3780b68e363b3c", + "manifest.tsv": "d9a1e6c23df97346a4c3d69cf37559906699978e9b04d08cbb224ee06aae21ea", + "owning-check.log": "b5eb8d8721131e3f5ab58b6132a53f7690331cf26f9779772df64c8738ca9e28", + "provenance.json": "bf9592ac26e82fffc2c896e06cbb04b53a1f8970c7b96bd0f79d2b62a6db2e52", + "reply-boundary.json": "c50ae165a3fbfaf03eaa96daf64e3943e85cf19b7a51bb2c97365a87b709624f" + }, + "fixedVulnerabilities": [ + "CVE-2026-88807" + ], + "jobId": 120951219154, + "lockSha256": "6ca89ea2cca1c36a7db42adf24c8ae8fff5fa69ef853da3508c74a38151fa720", + "packages": [ + { + "file": "libxrender1_0.9.12-1build1+pasturestack.1_amd64.deb", + "name": "libxrender1", + "sha256": "49fa0140dcc46481271fb203c2b4bf7a4c9b588bd849f498856c75a4926c212c", + "source": "libxrender", + "version": "1:0.9.12-1build1+pasturestack.1" + } + ], + "runConclusion": "success", + "runId": 40120642373, + "runtimePackages": [ + "libxrender1" + ], + "sourceCommit": "07e4a7afa827cb45514a72aba12f03b1d30d121d", + "sourceFiles": { + "server/security/ubuntu-backports/build-pcre2-backport.py": "79ff6faf3ca5b8e6dfdfc38a78ba56238c749aeabfab59fd05ce2130537458b2", + "server/security/ubuntu-backports/libxrender/build-libxrender-backport.py": "343166b9a540fe9e134f5b22c02bd6faf776359267c76885957814fd53f66c8d", + "server/security/ubuntu-backports/libxrender/libxrender.lock.json": "6ca89ea2cca1c36a7db42adf24c8ae8fff5fa69ef853da3508c74a38151fa720", + "server/security/ubuntu-backports/libxrender/reply-boundary.c": "486cf9c93b149623be3470a52158d407acfccf07b42f398740d60a955c90885f", + "server/security/ubuntu-backports/libxrender/test_libxrender_backport.py": "76cb352603055d61574997d5c526604a37a864ee78775fd9d5087ddec8254986", + "server/security/ubuntu-backports/source_helpers.py": "d2b3f310f8ed99a50a696fe14094b7117ec5d7ed576e6080d5540c0e82891486" + }, + "status": "qualified", + "vexScope": "Exact official QueryFormats reply-boundary patch; original-source negative control and patched/packaged controls passed. Upstream make check defines zero testcases; the separate native fixture supplies this evidence.", + "zipSha256": "5d58962ad638f0562d72584a263f7fa3e4bafb34473fedd19e892b07eba5896b" + }, + "p11-kit": { + "artifactId": 12570718592, + "artifactName": "ubuntu-native-p11-kit-2a47f3cd99b6afc47b54ee6d8837b834e95c50f2-40121338859", + "ciClosure": { + ".github/workflows/ubuntu-backport-native.yml": { + "currentSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "nativeSha256": "f06a225d71f28b31f1592025ed272055aa54266c1c112bc08c70e55529d885f4", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + }, + "server/security/ubuntu-backports/Dockerfile.native-ci": { + "currentSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "nativeSha256": "bca055ed1bd8fd213a906eceb23c5ff3f9449d06acb51e249c976c3174d406bb", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + } + }, + "copyright": { + "file": "COPYRIGHT", + "sha256": "a5611fbad151d1fa0aa484f6c393a5166af33ce9a9783dfb2712d6b87b462b37" + }, + "evidence": { + "COPYRIGHT": "a5611fbad151d1fa0aa484f6c393a5166af33ce9a9783dfb2712d6b87b462b37", + "builder-packages.tsv": "33900d45599ada520e85af10a65664c16011240e8e72c23280d7e0a5168a66fa", + "manifest.tsv": "3395b9f859643f84a4dfb656ec60c66199cbb75ed8eaad45468f72147eb9bd73", + "p11-kit.lock.json": "636bfbe8d9730e1838207edd9e3a6108f373d601c5b13591888913c8a042d046", + "p11-kit_0.26.2-2+pasturestack.1_amd64.buildinfo": "7c109845d877822003710a8332fae788305f2f1dff05bc43060b254b7b99f2bf", + "p11-kit_0.26.2-2+pasturestack.1_amd64.changes": "b92d9b76536cb7e1c77ae5e6289dd65ddf8a0fe2b388fc53cf851794858cd7ac", + "p11-kit__test-export-public.sh.log": "ac7f88758b57f814b8520981e87e452918c9bf12cb896a4fe9097ab96d963efa", + "p11-kit__test-export-public.sh.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "p11-kit__test-generate-keypair.sh.log": "3a887affe49f6fcec98c79393db9773a69b1590aba750f03d75dcb8ff37b356c", + "p11-kit__test-generate-keypair.sh.trs": "5dddb0d74ac9fd4ce3f50299d3ec68dce52ca4ce8332d2e59c5355b5da536bab", + "p11-kit__test-import-public.sh.log": "ae529f425541c579763902a30bc575f634706260860d5226512099a68e498bd7", + "p11-kit__test-import-public.sh.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "p11-kit__test-list-mechanisms.sh.log": "a257e893105e69a857e1e29d65e3d474a1dff3715305f3965ce28c5b76169159", + "p11-kit__test-list-mechanisms.sh.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "p11-kit__test-list-tokens.sh.log": "72a907acf2f3c9af79a84b2c262c00532ad31897c860ea0fce3bd73b0ef317dc", + "p11-kit__test-list-tokens.sh.trs": "5dddb0d74ac9fd4ce3f50299d3ec68dce52ca4ce8332d2e59c5355b5da536bab", + "p11-kit__test-lists.sh.log": "2a04a7e73255bda2fa884b9910adeab7176e50362e216b28f4f62a96398820a1", + "p11-kit__test-lists.sh.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "p11-kit__test-messages.sh.log": "c1caa148cdf02cd146d6708a861b8ed27d023aae164f53f417f07cd97ddebeb6", + "p11-kit__test-messages.sh.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "p11-kit__test-objects.sh.log": "836efa2f43826a25b650c8bdab7cb261da852cae11c89bd056dfcdb016a814fd", + "p11-kit__test-objects.sh.trs": "9cdbbc7152714b8ec44ad6627c09d2670e17b4393c1424c33387dd355aa1f425", + "p11-kit__test-profiles.sh.log": "9e5e71a99f71c28797288b40ae24b99a43f27787a4a8223472921508efcac0e9", + "p11-kit__test-profiles.sh.trs": "eaad2ad90ee6daadee5c9bb5486426cac231c29639133c5f18db189f97657c36", + "p11-kit__test-server.sh.log": "a87077f31cc42ff50842f9f51b5eb6faf8cb71ba3663334440cbd79f9563d405", + "p11-kit__test-server.sh.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "provenance.json": "b5294407c8361f8cfee55c1a460e51d4b0b2db24c0584b68f114fcd49842ac9e", + "test-argv.log": "ba614cff4234d49a95c48953449cfe688cda19017947d57199b8d836bdeea248", + "test-argv.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-array.log": "8178fe96de4acab0277208e9f21cbeeb7c8ccd8a1abfd6039a6373cbb020c058", + "test-array.trs": "c5bc74eab7054de88905f106fe54ce55fdae1c98f39b6eef9ee569891892f671", + "test-asn1.log": "42653f6c15cc9bcf969fa566a261679c6b67f8d899d2105ac414f16097ce88c7", + "test-asn1.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-attrs.log": "d4c7736ff7e80df07beb32a9a1907ba666f84b1f40983164c9e031ff67a741e5", + "test-attrs.trs": "a74ca7b7925c8181aeb6bb89ba3d8d315e820e1f5791e4f8c8d1008223aa4ce3", + "test-base64.log": "fd3ff98b7f186230e7b384649b804a7985e362ced5d455f44889c8d0a2ba1443", + "test-base64.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-buffer.log": "4a36bf5a0b8f1f37d61666e14c5c6374d28c7b45ee6be6e3cf58da41ed6bf288", + "test-buffer.trs": "9fcb93a25d29bf85271d24531b3740047d85d130da7ca1229752b86f6e3d9bc6", + "test-builder.log": "23619c173eab7be3b84b449ee863b05ce770750740ee4811cf603d32727402e8", + "test-builder.trs": "db443166649c920d8ef64482844285d53b504c0fdaf17ce13e1a6d7e9c13f502", + "test-bundle.log": "fe6906b28fd8c411a451c7532153780307f2403b9232ab062dcb2d098388f73c", + "test-bundle.trs": "9fcb93a25d29bf85271d24531b3740047d85d130da7ca1229752b86f6e3d9bc6", + "test-cer.log": "4d345ec15a728c4018015a16c8f528031f4d88214c4f43e3f39cef8524ed2ed6", + "test-cer.trs": "7b0d14d75180a40ebe2164a52b0e32f921fcbb447789f3aeb74f14c44263d50b", + "test-compat.log": "d042e1aefc0bb3a77397633ae44155065e91f9a844b25c6851a8bc426f71aa58", + "test-compat.trs": "5dddb0d74ac9fd4ce3f50299d3ec68dce52ca4ce8332d2e59c5355b5da536bab", + "test-conf.log": "e0f377dff98faa96c4534684e032c4dc3da098e78c1141057750ff6d9ae1ee31", + "test-conf.trs": "e3b1a457cf69962eaaa6b89eebf9bbf2fb24d90e62ca50668aa87ed4f2861653", + "test-constants.log": "6b2329e8888f7c20a77749f928689f00546483238d5846efdcb02777baba0c86", + "test-constants.trs": "f81624a4f492eb26078518866962fe6dd9a2c0c34529f97a9a54c075fd3622fc", + "test-deprecated.log": "97bfa253e8a3f6c9d402ff556166ae37d0c7520bf09b7212a466ff8f67dce4d3", + "test-deprecated.trs": "0cf6c4dbcae9310529416ae5d56fbafcf873d48959e154c2af8c46307a9712af", + "test-dict.log": "7ae1c4db184023e036c19c1359b1e4879f49c1e5a7d567509f27b28addc58f7f", + "test-dict.trs": "c63c2276cf4ec02bcf4b9e1c7fa282491c6e7c07c0e51ab278f84c07bf4b802f", + "test-digest.log": "b1d91d3ac7e899314f2cd674458d1e52c158611605f5586fafbde4ec6220f322", + "test-digest.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-edk2.log": "37a926fff731f901cc421710120a149a113874899a5baba0daeda6323feac951", + "test-edk2.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "test-enumerate.log": "30006e3235ba30c08ce8e664913d7293842fb051b75c123c13e28147d87d7989", + "test-enumerate.trs": "035c9327d0173a24a63239d44c9fe044d246e9d67e77d66ecbf2ab2ae8d7d69b", + "test-filter.log": "f9e9b1ca738fad000134d1e233c05db25b41aeafe7323056cb806efdb8fbaff5", + "test-filter.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-hash.log": "b6b90fbfeee8f21e96c8e6e58e958daef4bf9f99c8518f2ca5deabe11826b372", + "test-hash.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-hex.log": "232d52eeae73f85471bec913f17981d1a5ff8e887d37378223065fb99435a5a2", + "test-hex.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-index.log": "828dce6d499442cbd50c56ce38da1817b4594901a8a93c316d1ea8d3ea82d374", + "test-index.trs": "e72c652859efdf9c3717475dd7e90d06bfa3d2d4293a194dc1f0a3137a37d619", + "test-init.log": "7f16b3676c995cd0f005689cfe557a22a4aa88fc85041f1a313bd92b4b577fc0", + "test-init.trs": "7b247c4c3ece016e82481ce77af6db795cae3cab861dc4fc86c788efdda8a3a1", + "test-iter.log": "65603e0db48b3346f723380be8d90c84747ddac71c3153e08062210cced300a5", + "test-iter.trs": "a4d55c0b1ad9d0455d354c02016ebf5a9e9a6fd8d907bc7f499c6e799bb7b405", + "test-jks.log": "65fa6d45a00e4358c10d7ff82aaad2a03216a82cd7eee6661fcc25d99458e5ae", + "test-jks.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-lexer.log": "400aadffd290caf89782d61a4025e205c184cfdabe2375094d5b59b15ca032a2", + "test-lexer.trs": "9fcb93a25d29bf85271d24531b3740047d85d130da7ca1229752b86f6e3d9bc6", + "test-log.log": "29c42e483a30644759bd7646d3fa888500b740fff4517229ab3fb328e5087187", + "test-log.trs": "0dc694b2fb33f367e8543148ffb838de67c98057b7fde9b51aac989bf50b06db", + "test-log3.log": "c159f5901e08d1e14ce1493cafbe5cacd5bf01bb259cefae61e3ed1ef01fbcc2", + "test-log3.trs": "566219f61afb12349258aecfd7e9dab8cf2cf610a0ef8f49c50ff097d51ebb0b", + "test-managed.log": "fdf3efebb5684841e4d22b1afd73ecdcc705f49ed7bdbf5c13844a4418f7e57f", + "test-managed.trs": "566219f61afb12349258aecfd7e9dab8cf2cf610a0ef8f49c50ff097d51ebb0b", + "test-message.log": "c40622436bdf1f03e155fc7e3cdaa02647497510d8510ec7aa6381b174a092df", + "test-message.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "test-module.log": "6f85ff1ff45dedce717977381fd320524347abed77f77d13cb76d0c98899e73e", + "test-module.trs": "7c2ec063bdfd3b3d491c85d4f0526d0eea0c03a7b6691bf4dd0a4a977ea43253", + "test-modules.log": "fb13d8f465cdb4ebbe88052d38c33f86ba3eafad80031200483b50a62619e887", + "test-modules.trs": "02d3912701f201986b92e087ce92a7114eaf6ff8266e84cc663047dc02250acd", + "test-oid.log": "f489119f8cecbadd3b9def6c586154253c7d7cdc1306f3f35de22b4e1f7d2921", + "test-oid.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-openssl.log": "4cf9743a9591af378e33c3c6247eeb1f06c2b7db1a01ec2690daa9b7c0d01966", + "test-openssl.trs": "f81624a4f492eb26078518866962fe6dd9a2c0c34529f97a9a54c075fd3622fc", + "test-parser.log": "ab4f96f220acc874a8dc166e7a4b645feb83b36edd25c0fedb3f0be19f985e09", + "test-parser.trs": "9cdbbc7152714b8ec44ad6627c09d2670e17b4393c1424c33387dd355aa1f425", + "test-path.log": "0d2103ab73dbb624f66a4076476e9e2d870f9ab671040fe80d44d2cdeaaeec37", + "test-path.trs": "0cf6c4dbcae9310529416ae5d56fbafcf873d48959e154c2af8c46307a9712af", + "test-pem.log": "d3306c15ab4c133b74ff1b1abba7db04dd84ac28216ca8c83977cf0c3c66613b", + "test-pem.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-persist.log": "39a9f4dfdb8021f1407c1d2ea6f1d1b9d1e1965a9b2ba2a3dbfda40f32ef0441", + "test-persist.trs": "d2e39ee331ca5994ec02ee5a7c6fd81ce13e9b5e7a47c3a068c93accee2d60a1", + "test-pin.log": "7721b10dd207f90b9be2bb1432028fa8657dbf73ca0ccfc765c805115c88a1c9", + "test-pin.trs": "c5bc74eab7054de88905f106fe54ce55fdae1c98f39b6eef9ee569891892f671", + "test-progname.log": "8f516cac7e35bd11066dd25b713c771f6a6935179e00e30de1625a180c9b653e", + "test-progname.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-proxy.log": "314c4051f95a3b7825c74a1fa5167bc71622ffa1768e9325157d78a5fc863c2e", + "test-proxy.trs": "a4f4e1d89f20ce646a51f23ac3f879513dafaf8c15a918d773253333b0378cd7", + "test-proxy3.log": "86e2b6637ff95d769cff86c71fa846c3f6cb80d3d7ae69c6ec45f63315c3635e", + "test-proxy3.trs": "a4f4e1d89f20ce646a51f23ac3f879513dafaf8c15a918d773253333b0378cd7", + "test-rpc-message.log": "cd162cb2a5edfbe4e805310e82f5c81a0bc0ddd419a099138578975b4a345397", + "test-rpc-message.trs": "5c1774bdb1c10f6d4e1acd63c903490e414770d83508a8b4abbc692174a7d125", + "test-rpc.log": "1389c4801d837c836bef39fb5b1028d61ce1cb2f080cb297513aa8b6179fe95b", + "test-rpc.trs": "f8e5da746cd392aeb0bc57ed56e234c30c4edffd80462b2f651b2f5a9e412580", + "test-runtime.log": "6d33d72e781c0d6ce763a3469aa7d7a909717b26846ab8313b6651985ac4d8dd", + "test-runtime.trs": "09416818c720b3bad5e7e2667490ad2e6075f8846f2346a7822a300f4a7bb6a2", + "test-save.log": "52ce41229d526dca5d8d65bbefdea70bb51bb051c285a0515603800e1ee6b7ab", + "test-save.trs": "ab29310fbe2d1eb904fa3fd371782c5f77c1b3dec1a1c73fb7914fe1228095ce", + "test-server.log": "722074c08613ba082a76a6c7f3f8822d87033d2c21ef791d920ce0e131f4759f", + "test-server.trs": "c5bc74eab7054de88905f106fe54ce55fdae1c98f39b6eef9ee569891892f671", + "test-suite.log": "15414b28374f07200d23e21f3dc48fe0a22eed760c2b02878190ce6e8d8adff9", + "test-tests.log": "7f1cd1e5e1082751f51eaef70ec836f7a91ceccd69dcdb0eb383e32c26102eb3", + "test-tests.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "test-token.log": "1aaea97a0bab0c17d71b1b5f66aaba39987cebe2ba6f48e071654aef419eb243", + "test-token.trs": "d2e39ee331ca5994ec02ee5a7c6fd81ce13e9b5e7a47c3a068c93accee2d60a1", + "test-transport.log": "569fe841c68fb26c7506ce385c513f0fe2ea87cd06bb2e1be324e44e644b80bd", + "test-transport.trs": "566219f61afb12349258aecfd7e9dab8cf2cf610a0ef8f49c50ff097d51ebb0b", + "test-transport3.log": "58c296b59b01a7e3aff8b17b5052d9ca746c59e4f291e74ea48124c210fd9052", + "test-transport3.trs": "3844f258e6e12ee69624cc3b922166335b113800b783f01e43d38cd698b16424", + "test-uri.log": "53efc49e9d232706628f29f62ebc8fea5db59d20243f4a0eb3207f13650379ef", + "test-uri.trs": "74be51fa3d5f510d2083c7f0b824c5863b539d5f0ab7a18782a790c938c9f74f", + "test-url.log": "4a7443d85db41082d80767e49f2f510452f890ae26f6b9ecf63b27c6d4f0a9c2", + "test-url.trs": "9fcb93a25d29bf85271d24531b3740047d85d130da7ca1229752b86f6e3d9bc6", + "test-utf8.log": "07e208f404e4c9f57909f11acabedf21498f8ec8ba0ee7d290cfb6bc20dc2d7f", + "test-utf8.trs": "9fcb93a25d29bf85271d24531b3740047d85d130da7ca1229752b86f6e3d9bc6", + "test-util.log": "c85bea9491f2f213490ca365446cb71f91cb6a79e02cf48cec8978199bb0ece9", + "test-util.trs": "6b72d26b78d92ec48ec59376b0dd9497ce01545f4ce9d56a41b64a3a91740153", + "test-version.log": "54a1a2ded34663e602d3f65fbf886b01a00c2baf7e181373263d31fd82899484", + "test-version.trs": "976ed8fdf324eb7f8f7501ea3ce1abeaa3000b30c96b3a7291b0d421a10200b4", + "test-virtual.log": "525f71d96b36b464a02f6d4687ce4298625815f17675c6b2ad4ba46bfaacb49f", + "test-virtual.trs": "5dddb0d74ac9fd4ce3f50299d3ec68dce52ca4ce8332d2e59c5355b5da536bab", + "test-x509.log": "cfff462f44fc206e184027488801d0ae8a6a677ed0a710826efeefe86b8c4ad5", + "test-x509.trs": "7b247c4c3ece016e82481ce77af6db795cae3cab861dc4fc86c788efdda8a3a1" + }, + "fixedVulnerabilities": [ + "CVE-2026-13757", + "CVE-2026-18938" + ], + "jobId": 120953326544, + "lockSha256": "636bfbe8d9730e1838207edd9e3a6108f373d601c5b13591888913c8a042d046", + "packages": [ + { + "file": "libp11-kit0_0.26.2-2+pasturestack.1_amd64.deb", + "name": "libp11-kit0", + "sha256": "b4d244aed0607da60c6c2da716fae1d66edc4a59449d5b598df613b84069702d", + "source": "p11-kit", + "version": "0.26.2-2+pasturestack.1" + }, + { + "file": "p11-kit_0.26.2-2+pasturestack.1_amd64.deb", + "name": "p11-kit", + "sha256": "d816fde1ff781f00facca43d37684085c7286d7056ad990e40d3d162e6f511d6", + "source": "p11-kit", + "version": "0.26.2-2+pasturestack.1" + }, + { + "file": "p11-kit-modules_0.26.2-2+pasturestack.1_amd64.deb", + "name": "p11-kit-modules", + "sha256": "1429e81a3a7ba4b148e0966af4abc441dcfd2179e41efd8fb1c8ad7016e94f4f", + "source": "p11-kit", + "version": "0.26.2-2+pasturestack.1" + } + ], + "runConclusion": "success", + "runId": 40121338859, + "runtimePackages": [ + "libp11-kit0", + "p11-kit", + "p11-kit-modules" + ], + "sourceCommit": "2a47f3cd99b6afc47b54ee6d8837b834e95c50f2", + "sourceFiles": { + "server/security/ubuntu-backports/build-pcre2-backport.py": "79ff6faf3ca5b8e6dfdfc38a78ba56238c749aeabfab59fd05ce2130537458b2", + "server/security/ubuntu-backports/p11-kit/build-p11-kit-backport.py": "2c4e2e7357ebebc12c2698a4b626393cecd9b46ba84b3698934a4a912e75a96e", + "server/security/ubuntu-backports/p11-kit/p11-kit.lock.json": "636bfbe8d9730e1838207edd9e3a6108f373d601c5b13591888913c8a042d046", + "server/security/ubuntu-backports/p11-kit/test_p11_kit_backport.py": "16505ce478e2134dafc78e722ee40786d5390a99f60945abb684b2a9010deb86", + "server/security/ubuntu-backports/source_helpers.py": "d2b3f310f8ed99a50a696fe14094b7117ec5d7ed576e6080d5540c0e82891486" + }, + "status": "qualified", + "vexScope": "Exact official recursion-limit security patches in qualified amd64 packages. CVE-2026-18938 is officially a 32-bit-only trigger; amd64 owning-suite PASS is not 32-bit execution coverage.", + "zipSha256": "2fab74734ae7e24d76b57a11d783fb0136399c05e9651f86019c68b8fa633f21" + }, + "rust-coreutils": { + "artifactId": 12571737310, + "artifactName": "ubuntu-native-rust-coreutils-07e4a7afa827cb45514a72aba12f03b1d30d121d-40120637646", + "ciClosure": { + ".github/workflows/ubuntu-backport-native.yml": { + "currentSha256": "329ad26ee9f4b22f6fae37b8d680c9bcf6e9dc65e22a016d4225b1dcba606b79", + "nativeSha256": "f06a225d71f28b31f1592025ed272055aa54266c1c112bc08c70e55529d885f4", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + }, + "server/security/ubuntu-backports/Dockerfile.native-ci": { + "currentSha256": "ba6f233913a679b53f0234423a7c281e31c36b8305fde354b4ab177e05890f0b", + "nativeSha256": "bca055ed1bd8fd213a906eceb23c5ff3f9449d06acb51e249c976c3174d406bb", + "reviewedDifference": "Reviewed exact diff: removes tar selection, adds builder inventory and failed-glibc diagnostics only. This family effective build dependency setup, source inputs and owning commands are unchanged." + } + }, + "copyright": { + "file": "copyright", + "sha256": "f1ea5d8870ff83301cc4c129ecd5e0e3c998bd566a9b0036df899e84d9336bd6" + }, + "evidence": { + "builder-packages.tsv": "6cdb72f354d275dcb8e34274fd324329a6734320c01b519c8c6898c62e27825d", + "copyright": "f1ea5d8870ff83301cc4c129ecd5e0e3c998bd566a9b0036df899e84d9336bd6", + "provenance.json": "f06fd1b74848aeb0ef9d23f38c3e0700e167d935e7c7fcf7879c8e3561c23391", + "rust-coreutils.lock.json": "e52adb460ec7b01accc2f3f9cb3dccc8923e2619d5ced41aae1722494c81bd41", + "rust-coreutils_0.10.0-1ubuntu2~26.04.1+pasturestack.1_amd64.buildinfo": "d87448f36eeb16399c10b4c16e0e46cb75bb6fa26274ff6c5e957da5c88e29bd", + "rust-coreutils_0.10.0-1ubuntu2~26.04.1+pasturestack.1_amd64.changes": "4fcc000e8f8629209b6e1e925548a6448dd9c2a836eb03590067116edc4f860e", + "strict-tests.log": "b4eba1bcfa7299a6fb2cbf76e19424cb1f19555779b71f14823a29b8bc43a3a7" + }, + "fixedVulnerabilities": [ + "CVE-2026-35370", + "CVE-2026-35371", + "CVE-2026-35341", + "CVE-2026-35344", + "CVE-2026-35345", + "CVE-2026-35348", + "CVE-2026-35350", + "CVE-2026-35351", + "CVE-2026-35352", + "CVE-2026-35354", + "CVE-2026-35357", + "CVE-2026-35359", + "CVE-2026-35360", + "CVE-2026-35363", + "CVE-2026-35364", + "CVE-2026-35367", + "CVE-2026-35368", + "CVE-2026-35373", + "CVE-2026-35374", + "CVE-2026-35377" + ], + "jobId": 120951205262, + "lockSha256": "e52adb460ec7b01accc2f3f9cb3dccc8923e2619d5ced41aae1722494c81bd41", + "packages": [ + { + "file": "rust-coreutils_0.10.0-1ubuntu2~26.04.1+pasturestack.1_amd64.deb", + "name": "rust-coreutils", + "sha256": "f90cdece3ffa01acc9153e3741725febb696e02db8ccf6c0d0c4f98ff93ec609", + "source": "rust-coreutils", + "version": "0.10.0-1ubuntu2~26.04.1+pasturestack.1" + } + ], + "runConclusion": "success", + "runId": 40120637646, + "runtimePackages": [ + "rust-coreutils" + ], + "sourceAudit": { + "originalReceiptSha256": "73189028622c0a0fc074067b4c5b8521094921531f95cf3e68073baaebd2fbbd", + "path": "rust-coreutils-original18-source-closure.json", + "sha256": "6057ffc353b5cdd86ba887354d2f1ee1f01afd11a4ba3bbca3f16b2b4600b3a5" + }, + "sourceCommit": "07e4a7afa827cb45514a72aba12f03b1d30d121d", + "sourceFiles": { + "server/security/ubuntu-backports/rust-coreutils/build-rust-coreutils-backport.py": "c94021aa839dfe37ad69978cc8d9d4e27308453c83a318d07ada3d4b1f42a6f0", + "server/security/ubuntu-backports/rust-coreutils/native-tests.mk": "8474160f550e8475fe743ce2fe737bdb60d097aaabfb7859fd4a2144c216215d", + "server/security/ubuntu-backports/rust-coreutils/rust-coreutils.lock.json": "e52adb460ec7b01accc2f3f9cb3dccc8923e2619d5ced41aae1722494c81bd41", + "server/security/ubuntu-backports/rust-coreutils/test_rust_coreutils_backport.py": "7ab36b954fcb2248489d69ad1ec08ba7aa578027bba3ccb8b4cb40dae6e65702" + }, + "status": "qualified", + "vexScope": "Exact qualified rust-coreutils package bytes: the new id CVEs use the official ff382 backport and packaged GID checks; the other 18 have an independently reviewed exact Ubuntu orig + enabled quilt source/control audit, bound to this native lock, provenance and package SHA. Source closure does not claim new CVE-specific dynamic execution: retain 84 upstream ignored, 17 original filtered tests, missing dd/split unit execution and the logname real-login limitation. Consumer images still require native Trivy --vex, installation and product acceptance.", + "zipSha256": "7f4b4780a492425bb9de239d67c6d594ccd3e8c0c85ac03eaa85035248ac5e2b" + } + }, + "pcre2": { + "copyrightEvidence": { + "creationHunkLines": 122, + "path": "debian/copyright", + "source": { + "name": "pcre2_10.46-1build1.diff.gz", + "sha256": "3534c69dec410207e74bf9fd6ed334ad5827fb6de1d0715175b7a14257c5bc72", + "size": 8804, + "url": "https://archive.ubuntu.com/ubuntu/pool/main/p/pcre2/pcre2_10.46-1build1.diff.gz" + } + }, + "copyrightSha256": "e915cde4251f96ec6af107ecd2e194440a4e69440893b0c982802f8a1e4ae40a", + "fixedVulnerabilities": [ + "CVE-2026-103111", + "CVE-2026-86145", + "CVE-2026-89156", + "CVE-2026-89157", + "CVE-2026-89158", + "CVE-2026-89160", + "CVE-2026-89161", + "CVE-2026-89162" + ], + "kind": "fresh-production-target", + "lockSha256": "c9bddd7006761549e30843a83c097fa4019b1a58262755a1c2dc23e10ba0aeb5", + "runtimePackages": [ + "libpcre2-8-0", + "libpcre2-posix3" + ], + "sourceCommit": "9abef4712901981e5917c41356bb261974fdba47", + "sourceFiles": { + "server/Dockerfile.web-compose-release": "17de14127a2414ecc8b410f483b46582aa252964bcc849a83678665cdefda1bb", + "server/scripts/download-ubuntu-security-updates.sh": "4284321d2b73802046d706f5fbd683b88ffc351187be8bc93d20bd3cf97b7216", + "server/security/ubuntu-backports/build-pcre2-backport.py": "79ff6faf3ca5b8e6dfdfc38a78ba56238c749aeabfab59fd05ce2130537458b2", + "server/security/ubuntu-backports/pcre2.lock.json": "c9bddd7006761549e30843a83c097fa4019b1a58262755a1c2dc23e10ba0aeb5", + "server/security/ubuntu-security-packages.lock": "8e43275a0b7b13a2a5d825eea2b15c68ec75a27f50dd6c0f497aadba164c6704" + }, + "sourceTag": "v1.6.551", + "target": "runtime_security_packages", + "version": "10.46-1build1+pasturestack.1", + "vexScope": "Fresh build of the exact released Server v1.6.551 runtime_security_packages producer and its pinned official release/pcre2-10.46 backports. All three original owning suites must PASS; exact generated runtime package hashes are in the bundle manifest." + }, + "platform": { + "architecture": "amd64", + "distribution": "ubuntu", + "release": "26.04" + }, + "repository": "PastureStack/server", + "schemaVersion": 1, + "trackedScopes": { + "consumerStatus": "NOT_PRODUCT_REMEDIATION_PROOF", + "schemaVersion": 1, + "statements": [ + { + "reason": "All 18 exact Ubuntu orig + enabled quilt security controls were reviewed with no effective revert, and are bound to the qualified native artifact, source lock, provenance and package SHA. This is component-source evidence; consumer/product remediation remains a separate gate.", + "scope": "rust-coreutils prior fixes", + "sourceAudit": { + "originalReceiptSha256": "73189028622c0a0fc074067b4c5b8521094921531f95cf3e68073baaebd2fbbd", + "path": "rust-coreutils-original18-source-closure.json", + "sha256": "6057ffc353b5cdd86ba887354d2f1ee1f01afd11a4ba3bbca3f16b2b4600b3a5" + }, + "status": "qualified_component_source_closed", + "vulnerabilities": [ + "CVE-2026-35341", + "CVE-2026-35344", + "CVE-2026-35345", + "CVE-2026-35348", + "CVE-2026-35350", + "CVE-2026-35351", + "CVE-2026-35352", + "CVE-2026-35354", + "CVE-2026-35357", + "CVE-2026-35359", + "CVE-2026-35360", + "CVE-2026-35363", + "CVE-2026-35364", + "CVE-2026-35367", + "CVE-2026-35368", + "CVE-2026-35373", + "CVE-2026-35374", + "CVE-2026-35377" + ] + }, + { + "reason": "No qualified tar package is part of this 12-runtime-package bundle; CVE-2026-18508 requires a separate structural API assessment.", + "scope": "tar", + "status": "not_in_bundle", + "vulnerabilities": [ + "CVE-2026-18477", + "CVE-2026-18508" + ] + }, + { + "reason": "jq uses system Oniguruma. No claim about Ruby/Onigmo, other runtime dependency packages or build-host scanners is made.", + "scope": "Ruby/Onigmo build-host dependency gap", + "status": "tracked_separately" + }, + { + "reason": "A scratch bundle scan with zero OS findings does not establish that these deb payloads or eventual consumer root filesystems are free of vulnerabilities.", + "scope": "consumer images", + "status": "pending_install_rootfs_scan_and_product_acceptance" + } + ] + }, + "vexTimestamp": "2026-10-25T00:00:00Z" +} diff --git a/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py b/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py new file mode 100644 index 0000000000..5dd81fe1de --- /dev/null +++ b/server/security/ubuntu-backports/bundle/test_qualify_native_packages.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Small negative/positive checks for the bundle trust boundaries; no builds.""" +import copy +import importlib.util +import io +import json +from pathlib import Path +import stat +import struct +import tarfile +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import urllib.error +import zipfile + +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location('qualifier', HERE / 'qualify-native-packages.py') +Q = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(Q) + + +def fixture_api(item, family='jq'): + return {'run': {'id': item['runId'], 'status': 'completed', 'head_sha': item['sourceCommit'], + 'conclusion': item['runConclusion'], 'path': '.github/workflows/ubuntu-backport-native.yml'}, + 'job': {'id': item['jobId'], 'run_id': item['runId'], 'head_sha': item['sourceCommit'], + 'name': 'Native (' + family + ')', 'status': 'completed', 'conclusion': 'success'}, + 'artifact': {'id': item['artifactId'], 'expired': False, 'name': item['artifactName'], + 'digest': 'sha256:' + item['zipSha256'], + 'workflow_run': {'id': item['runId'], 'head_sha': item['sourceCommit']}}} + + +class Boundaries(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='bundle-unit-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.lock = Q.load_lock() + + def make_zip(self, names): + archive = self.root / 'native.zip' + with zipfile.ZipFile(archive, 'w') as target: + for name, content in names: + if isinstance(name, str): + info = zipfile.ZipInfo('entry'); info.filename = info.orig_filename = name + target.writestr(info, content) + else: target.writestr(name, content) + return archive + + def test_pending_glibc_is_not_publishable(self): + lock = copy.deepcopy(self.lock) + lock['nativeInputs']['glibc'].update(status='pending', artifactId=None, zipSha256=None) + file = self.root / 'lock.json'; file.write_text(json.dumps(lock)) + with patch.object(Q, 'LOCK_FILE', file): + self.assertEqual(Q.load_lock()['nativeInputs']['glibc']['status'], 'pending') + with self.assertRaisesRegex(ValueError, 'pending'): + Q.load_lock(ready=True) + + def test_missing_native_producer_is_fetched_by_exact_sha_without_changing_head(self): + commits = sorted({item['sourceCommit'] for item in self.lock['nativeInputs'].values()}) + with patch.object(Q.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run: + Q.ensure_native_sources(self.lock) + self.assertEqual(len(run.call_args_list), len(commits)) + self.assertTrue(all(call.args[0][:3] == ['git', 'cat-file', '-e'] for call in run.call_args_list)) + replies = [SimpleNamespace(returncode=1), SimpleNamespace(returncode=0), SimpleNamespace(returncode=0)] + replies += [SimpleNamespace(returncode=0) for _ in commits[1:]] + with patch.object(Q.subprocess, 'run', side_effect=replies) as run: + Q.ensure_native_sources(self.lock) + fetches = [call.args[0] for call in run.call_args_list if call.args[0][1] != 'cat-file'] + self.assertEqual(fetches, [['git', 'fetch', '--no-tags', 'origin', commits[0]]]) + + def test_no_extra_runtime_family_or_deb(self): + for mutate in [lambda x: x['nativeInputs'].update(tar={}), + lambda x: x['nativeInputs']['jq']['runtimePackages'].append('libjq-dev')]: + lock = copy.deepcopy(self.lock); mutate(lock) + file = self.root / 'lock.json'; file.write_text(json.dumps(lock)) + with patch.object(Q, 'LOCK_FILE', file), self.assertRaises(ValueError): Q.load_lock() + + def test_zip_requires_external_pin(self): + archive = self.make_zip([('export/result.json', b'{}')]) + with self.assertRaisesRegex(ValueError, 'reviewed artifact pin'): Q.read_zip(archive, '0' * 64) + self.assertEqual(Q.read_zip(archive, Q.file_sha(archive)), {'export/result.json': b'{}'}) + + def test_zip_rejects_escaping_alias_and_diagnostic_paths(self): + for name in ['../escape', '/absolute', 'C:/escape', 'a\\b', 'a/./b', 'a//b', '.', 'line\nbreak', + 'export/diagnostics/unqualified-packages/libc6.deb']: + with self.subTest(name=name): + archive = self.make_zip([(name, b'x')]) + with self.assertRaises(ValueError): Q.read_zip(archive, Q.file_sha(archive)) + + def test_zip_rejects_duplicate_case_and_links(self): + archive = self.make_zip([('A', b'x'), ('a', b'y')]) + with self.assertRaises(ValueError): Q.read_zip(archive, Q.file_sha(archive)) + link = zipfile.ZipInfo('link'); link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive = self.make_zip([(link, b'/etc/passwd')]) + with self.assertRaises(ValueError): Q.read_zip(archive, Q.file_sha(archive)) + + def test_selected_job_success_preserves_actual_matrix_failure(self): + item = self.lock['nativeInputs']['jq']; api = fixture_api(item) + self.assertEqual(api['run']['conclusion'], 'failure') + Q.verify_api('jq', item, **api) + for section, key, value in [('job', 'conclusion', 'failure'), ('job', 'run_id', 1), + ('artifact', 'expired', True), ('run', 'head_sha', 'a' * 40), + ('run', 'conclusion', 'success')]: + changed = copy.deepcopy(api); changed[section][key] = value + with self.subTest(section=section, key=key), self.assertRaises(ValueError): + Q.verify_api('jq', item, **changed) + + def test_artifact_api_cannot_rebind_same_named_bytes(self): + item = self.lock['nativeInputs']['jq']; api = fixture_api(item) + api['artifact']['workflow_run']['id'] += 1 + with self.assertRaises(ValueError): Q.verify_api('jq', item, **api) + + def test_native_result_requires_all_three_numeric_zero_exits(self): + original = self.lock['nativeInputs']['jq'] + for key, value in [('exitCode', 1), ('helperExitCode', True), ('logExitCode', 1)]: + result = {'module': 'jq', 'sourceSha': original['sourceCommit'], 'status': 'passed', + 'exitCode': 0, 'helperExitCode': 0, 'logExitCode': 0, + 'sourceLockSha256': original['lockSha256']}; result[key] = value + archive = self.make_zip([('source-sha.txt', original['sourceCommit']), + ('export/result.json', json.dumps(result))]) + item = copy.deepcopy(original); item['zipSha256'] = Q.file_sha(archive); item['sourceFiles'] = {} + with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'exit/lock'): + Q.validate_native('jq', item, archive, fixture_api(item), self.root, self.root / 'tests') + + def test_pcre_requires_each_actual_suite_and_global_pass(self): + names = ['pcre2_jit_test', 'RunTest', 'RunGrepTest'] + good = b':test-result: PASS\n:global-test-result: PASS\n' + files = {name + '.trs': good for name in names} + files.update({name + '.log': b'actual native output' for name in names}) + provenance = {'tests': [{'test': n, 'sha256': Q.sha(good), 'status': 'PASS'} for n in names]} + Q.check_owning_tests('pcre2', files, provenance, self.root / 'good') + for number, wrong in enumerate([b':test-result: PASS\n:global-test-result: FAIL\n', + good + b':test-result: FAIL\n', b':test-result: SKIP\n']): + files[names[0] + '.trs'] = wrong; provenance['tests'][0]['sha256'] = Q.sha(wrong) + with self.assertRaises(ValueError): + Q.check_owning_tests('pcre2', files, provenance, self.root / ('bad' + str(number))) + + def tar_payload(self, entries): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode='w') as archive: + for name, kind, content in entries: + member = tarfile.TarInfo(name); member.mode = 0o755; member.uid = member.gid = 0 + member.type = kind + if kind == tarfile.REGTYPE: + member.size = len(content); archive.addfile(member, io.BytesIO(content)) + else: + member.linkname = content; archive.addfile(member) + return stream.getvalue() + + def payload(self, entries, source='rust-coreutils'): + data = self.tar_payload(entries) + fields = {'Package': b'rust-coreutils', 'Source': source.encode(), 'Version': b'1.0', 'Architecture': b'amd64'} + def command(args): + if args[1] == '--field': return fields[args[-1]] + if args[1] == '--fsys-tarfile': return data + raise AssertionError(args) + with patch.object(Q, 'command', command): + return Q.package_payload(self.root / 'native.deb', + {'name': 'rust-coreutils', 'source': 'rust-coreutils', 'version': '1.0'}, self.root) + + def test_deb_payload_resolves_hardlinks_without_extracting(self): + payload = self.payload([('./usr/bin/coreutils', tarfile.REGTYPE, b'payload'), + ('./usr/bin/id', tarfile.LNKTYPE, './usr/bin/coreutils'), + ('./usr/bin/realpath', tarfile.SYMTYPE, '/usr/bin/coreutils')]) + files = {p['path']: p for p in payload['files']} + self.assertEqual(files['usr/bin/id']['sha256'], Q.sha(b'payload')) + self.assertEqual(files['usr/bin/realpath']['target'], '/usr/bin/coreutils') + self.assertFalse((self.root / 'usr').exists()) + + def test_deb_rejects_traversal_special_cycle_and_symlink_parent(self): + for entries in [[('../escape', tarfile.REGTYPE, b'x')], + [('device', tarfile.FIFOTYPE, '')], + [('id', tarfile.LNKTYPE, 'other'), ('other', tarfile.LNKTYPE, 'id')], + [('usr', tarfile.SYMTYPE, '/etc'), ('usr/passwd', tarfile.REGTYPE, b'x')]]: + with self.subTest(entries=entries), self.assertRaises(ValueError): self.payload(entries) + + def test_deb_rejects_wrong_source(self): + with self.assertRaisesRegex(ValueError, 'control identity'): + self.payload([('file', tarfile.REGTYPE, b'x')], source='other') + + def test_elf_metadata_uses_dynamic_external_symbols_and_versions(self): + elf = bytearray(64); elf[:6] = b'\x7fELF\x02\x01'; struct.pack_into(' Date: Sun, 25 Oct 2026 04:19:52 +0800 Subject: [PATCH 8/9] Pin canonical LF security audit bytes across Windows and Linux --- .gitattributes | 1 + server/security/ubuntu-backports/bundle/source-inputs.lock.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 83edcddae1..0c90ed27d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ *.patch text eol=lf Dockerfile* text eol=lf **/Dockerfile* text eol=lf +server/security/ubuntu-backports/bundle/*.json text eol=lf scripts/** text eol=lf server/bin/** text eol=lf server/service/** text eol=lf diff --git a/server/security/ubuntu-backports/bundle/source-inputs.lock.json b/server/security/ubuntu-backports/bundle/source-inputs.lock.json index 71da02ffed..1dfaa8eb01 100644 --- a/server/security/ubuntu-backports/bundle/source-inputs.lock.json +++ b/server/security/ubuntu-backports/bundle/source-inputs.lock.json @@ -577,7 +577,7 @@ "sourceAudit": { "originalReceiptSha256": "73189028622c0a0fc074067b4c5b8521094921531f95cf3e68073baaebd2fbbd", "path": "rust-coreutils-original18-source-closure.json", - "sha256": "6057ffc353b5cdd86ba887354d2f1ee1f01afd11a4ba3bbca3f16b2b4600b3a5" + "sha256": "27bc317cfac11edd24b2c8beebf5094d84c8e312d4bf0cf4e61f05a300daacc8" }, "sourceCommit": "07e4a7afa827cb45514a72aba12f03b1d30d121d", "sourceFiles": { From fb710569970295cd5fb57eba297a847419276a4d Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:30:47 +0800 Subject: [PATCH 9/9] Confine installer reads and bind consumer evidence to immutable producer bytes --- .gitattributes | 2 + .../bundle/installer/confined_reads_test.go | 64 +++++++++++++++++++ .../bundle/installer/installer.go | 12 ++-- .../bundle/installer/payload.go | 45 +++++++------ .../bundle/installer/schema.go | 51 ++++++++++++++- .../bundle/test_verify_consumer_evidence.py | 33 ++++++++-- .../bundle/verify-consumer-evidence.py | 19 +++++- 7 files changed, 190 insertions(+), 36 deletions(-) create mode 100644 server/security/ubuntu-backports/bundle/installer/confined_reads_test.go diff --git a/.gitattributes b/.gitattributes index 0c90ed27d7..b5d6c83458 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ Dockerfile* text eol=lf **/Dockerfile* text eol=lf server/security/ubuntu-backports/bundle/*.json text eol=lf +server/security/ubuntu-backports/bundle/installer/*.go text eol=lf +server/security/ubuntu-backports/bundle/installer/go.mod text eol=lf scripts/** text eol=lf server/bin/** text eol=lf server/service/** text eol=lf diff --git a/server/security/ubuntu-backports/bundle/installer/confined_reads_test.go b/server/security/ubuntu-backports/bundle/installer/confined_reads_test.go new file mode 100644 index 0000000000..ab97cedf38 --- /dev/null +++ b/server/security/ubuntu-backports/bundle/installer/confined_reads_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfinedReadsRejectTraversalAndOutsideSymlinkParents(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + write(t, outside, "secret", []byte("outside bytes must not be read")) + must(t, os.Symlink(outside, filepath.Join(root, "escape"))) + for _, name := range []string{"../secret", "/secret", "escape/secret"} { + t.Run(name, func(t *testing.T) { + if _, err := regularRead(root, name, 1024); err == nil { + t.Fatal("bundle read escaped root") + } + if _, b, err := installedFile(root, File{Path: name}); err == nil || len(b) != 0 { + t.Fatal("installed file read escaped root") + } + }) + } +} + +func TestConfinedReadsPreserveRuntimeLinksAndBundleNoLinks(t *testing.T) { + root := t.TempDir() + write(t, root, "usr/lib/value", []byte("confined value")) + must(t, os.Symlink("lib", filepath.Join(root, "usr/alias"))) + got, b, err := installedFile(root, File{Path: "usr/alias/value"}) + must(t, err) + if got.SHA256 != digest([]byte("confined value")) || string(b) != "confined value" { + t.Fatal("legitimate relative runtime symlink failed") + } + if _, err := regularRead(root, "usr/alias/value", 1024); err == nil { + t.Fatal("bundle symlink parent was accepted") + } + must(t, os.Symlink("/usr/lib/value", filepath.Join(root, "usr/link"))) + got, b, err = installedFile(root, File{Path: "usr/link"}) + must(t, err) + if got.Type != "symlink" || got.Target != "/usr/lib/value" || len(b) != 0 { + t.Fatal("literal absolute final symlink was followed or changed") + } +} + +func TestConfinedOpenRejectsFinalSymlinkAndKeepsBounds(t *testing.T) { + root := t.TempDir() + write(t, root, "value", []byte("12345")) + must(t, os.Symlink("value", filepath.Join(root, "link"))) + directory, err := os.OpenRoot(root) + must(t, err) + defer directory.Close() + if file, err := openRegularInRoot(directory, "link"); err == nil { + file.Close() + t.Fatal("final symlink was followed by the confined regular open") + } + if _, err := regularRead(root, "value", 4); err == nil { + t.Fatal("size bound was weakened") + } + b, err := regularRead(root, "value", 5) + must(t, err) + if string(b) != "12345" { + t.Fatal("bounded legitimate read failed") + } +} diff --git a/server/security/ubuntu-backports/bundle/installer/installer.go b/server/security/ubuntu-backports/bundle/installer/installer.go index a3cb7cf805..9ac34f8d46 100644 --- a/server/security/ubuntu-backports/bundle/installer/installer.go +++ b/server/security/ubuntu-backports/bundle/installer/installer.go @@ -260,15 +260,19 @@ func (i Installer) audit() error { // ld.so.cache is generated by the required libc trigger; it is not host config. func (i Installer) snapshot(installed map[string]Installed) (map[string]string, error) { result := map[string]string{} + directory, err := os.OpenRoot(i.root) + if err != nil { + return nil, err + } + defer directory.Close() add := func(name string) error { if name == "etc/ld.so.cache" { return nil } - p, err := rootPath(i.root, name) - if err != nil { - return err + if !safeRelative(name) { + return fmt.Errorf("unsafe snapshot path %q", name) } - info, err := os.Lstat(p) + info, err := directory.Lstat(name) if os.IsNotExist(err) { result[name] = "absent" return nil diff --git a/server/security/ubuntu-backports/bundle/installer/payload.go b/server/security/ubuntu-backports/bundle/installer/payload.go index 4f6a454c34..dcb17b7d0d 100644 --- a/server/security/ubuntu-backports/bundle/installer/payload.go +++ b/server/security/ubuntu-backports/bundle/installer/payload.go @@ -8,7 +8,6 @@ import ( "io" "os" "path" - "path/filepath" "reflect" "sort" "strings" @@ -180,28 +179,17 @@ func validateArchive(content []byte, p Payload) error { } return nil } -func rootPath(root, name string) (string, error) { - if !safeRelative(name) { - return "", fmt.Errorf("unsafe rootfs path %q", name) - } - p := filepath.Join(root, filepath.FromSlash(name)) - parent, err := filepath.EvalSymlinks(filepath.Dir(p)) - if err != nil { - return "", err - } - rel, err := filepath.Rel(root, parent) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("rootfs symlink escapes root") - } - return filepath.Join(parent, filepath.Base(p)), nil -} func installedFile(root string, expected File) (File, []byte, error) { f := File{Path: expected.Path} - p, err := rootPath(root, expected.Path) + if !safeRelative(expected.Path) { + return f, nil, fmt.Errorf("unsafe rootfs path %q", expected.Path) + } + directory, err := os.OpenRoot(root) if err != nil { return f, nil, err } - info, err := os.Lstat(p) + defer directory.Close() + info, err := directory.Lstat(expected.Path) if err != nil { return f, nil, err } @@ -211,17 +199,32 @@ func installedFile(root string, expected File) (File, []byte, error) { } if info.Mode()&os.ModeSymlink != 0 { f.Type = "symlink" - f.Target, err = os.Readlink(p) + f.Target, err = directory.Readlink(expected.Path) return f, nil, err } if !info.Mode().IsRegular() || info.Size() > 256<<20 { - return f, nil, fmt.Errorf("special/unbounded installed file: %s", p) + return f, nil, fmt.Errorf("special/unbounded installed file: %s", expected.Path) } f.Type = "file" - b, err := os.ReadFile(p) + opened, err := openRegularInRoot(directory, expected.Path) + if err != nil { + return f, nil, err + } + defer opened.Close() + current, err := opened.Stat() if err != nil { return f, nil, err } + if !current.Mode().IsRegular() || current.Size() > 256<<20 || !os.SameFile(info, current) { + return f, nil, fmt.Errorf("installed file changed during confined open: %s", expected.Path) + } + b, err := io.ReadAll(io.LimitReader(opened, (256<<20)+1)) + if err != nil { + return f, nil, err + } + if len(b) > 256<<20 { + return f, nil, fmt.Errorf("installed file grew beyond size bound: %s", expected.Path) + } f.SHA256 = digest(b) return f, b, nil } diff --git a/server/security/ubuntu-backports/bundle/installer/schema.go b/server/security/ubuntu-backports/bundle/installer/schema.go index 5f42d3e86e..748c608bc8 100644 --- a/server/security/ubuntu-backports/bundle/installer/schema.go +++ b/server/security/ubuntu-backports/bundle/installer/schema.go @@ -85,16 +85,53 @@ func safeRelative(p string) bool { !strings.HasPrefix(p, "/") && path.Clean(p) == p && p != ".." && !strings.HasPrefix(p, "../") } +// The Root capability prevents escape while resolving path components. Keep +// final-link rejection and identity checks around the confined read-only open. +func openRegularInRoot(directory *os.Root, name string) (*os.File, error) { + before, err := directory.Lstat(name) + if err != nil { + return nil, err + } + if !before.Mode().IsRegular() { + return nil, fmt.Errorf("not a regular confined file: %s", name) + } + f, err := directory.Open(name) + if err != nil { + return nil, err + } + opened, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + after, err := directory.Lstat(name) + if err != nil { + f.Close() + return nil, err + } + if !after.Mode().IsRegular() || !os.SameFile(before, opened) || !os.SameFile(after, opened) { + f.Close() + return nil, fmt.Errorf("confined file changed while opening: %s", name) + } + return f, nil +} + // Every component is checked without following links, even for SHA-bound files. func regularRead(root, name string, limit int64) ([]byte, error) { if !safeRelative(name) { return nil, fmt.Errorf("unsafe bundle path %q", name) } - p := root + directory, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + defer directory.Close() + p := "" + var checked os.FileInfo parts := strings.Split(name, "/") for n, part := range parts { p = filepath.Join(p, part) - info, err := os.Lstat(p) + info, err := directory.Lstat(p) if err != nil { return nil, err } @@ -104,12 +141,20 @@ func regularRead(root, name string, limit int64) ([]byte, error) { if n == len(parts)-1 && (!info.Mode().IsRegular() || info.Size() > limit) { return nil, fmt.Errorf("not a bounded regular file: %s", p) } + checked = info } - f, err := os.Open(p) + f, err := openRegularInRoot(directory, p) if err != nil { return nil, err } defer f.Close() + opened, err := f.Stat() + if err != nil { + return nil, err + } + if !opened.Mode().IsRegular() || opened.Size() > limit || !os.SameFile(checked, opened) { + return nil, fmt.Errorf("bundle file changed during confined open: %s", p) + } b, err := io.ReadAll(io.LimitReader(f, limit+1)) if err != nil { return nil, err diff --git a/server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py b/server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py index 87f3d6ca2d..55ea2d7388 100644 --- a/server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py +++ b/server/security/ubuntu-backports/bundle/test_verify_consumer_evidence.py @@ -49,6 +49,10 @@ def checksums(files): return ''.join(guard.sha(files[name]) + ' ' + name + '\n' for name in guard.FILES).encode() +def producer_checksums(files): + return ''.join(guard.sha(files[name]) + ' ' + name + '\n' for name in guard.PRODUCER_FILES).encode() + + def change(files, name, mutation): doc = json.loads(files[name]); mutation(doc); files[name] = encoded(doc) @@ -63,7 +67,7 @@ def test_debian_epoch_matches_native_trivy_purl(self): def test_valid_partial_package_set_does_not_claim_absent_packages(self): files, sbom = fixture(); original = copy.deepcopy((files, sbom)) - result = guard.verify(files, checksums(files), sbom, DIGEST) + result = guard.verify(files, checksums(files), sbom, DIGEST, producer_checksums(files)) self.assertEqual(result['selectedPackages'], sorted(c['name'] for c in sbom['components'])) self.assertNotIn('jq', result['selectedPackages']) self.assertFalse(result['findingsFilteredByThisTool']) @@ -73,9 +77,9 @@ def test_wrong_digest_or_tampered_evidence_rejected(self): files, sbom = fixture() for pin in ['__BUNDLE_DIGEST_PENDING__', 'sha256:' + 'b' * 64]: with self.subTest(pin=pin), self.assertRaises(ValueError): - guard.verify(files, checksums(files), sbom, pin) + guard.verify(files, checksums(files), sbom, pin, producer_checksums(files)) sums = checksums(files); files['install.json'] += b' ' - with self.assertRaises(ValueError): guard.verify(files, sums, sbom, DIGEST) + with self.assertRaises(ValueError): guard.verify(files, sums, sbom, DIGEST, producer_checksums(files)) def test_receipt_sbom_versions_and_exact_selection_are_required(self): for kind in ['old-version', 'wrong-purl', 'missing', 'extra', 'duplicate', 'receipt']: @@ -89,7 +93,7 @@ def test_receipt_sbom_versions_and_exact_selection_are_required(self): sbom['components'].append({'name': 'jq', 'version': p['version'], 'purl': guard.package_purl(p)}) if kind == 'duplicate': sbom['components'].append(sbom['components'][0]) if kind == 'receipt': change(files, 'verify.json', lambda d: d['packages'].pop()) - with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST) + with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST, producer_checksums(files)) def test_blanket_or_unqualified_vex_is_rejected(self): for kind in ['no-version', 'wildcard', 'oci', 'subcomponent', 'unqualified-id', 'not-affected', 'duplicate']: @@ -104,11 +108,28 @@ def test_blanket_or_unqualified_vex_is_rejected(self): if kind == 'not-affected': row['status'] = 'not_affected' if kind == 'duplicate': row['products'].append(row['products'][0]) files['openvex.json'] = encoded(vex) - with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST) + with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST, producer_checksums(files)) def test_manifest_qualification_binding_is_required(self): files, sbom = fixture(); change(files, 'qualification.json', lambda d: d.update(status='pending')) - with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST) + with self.assertRaises(ValueError): guard.verify(files, checksums(files), sbom, DIGEST, producer_checksums(files)) + + def test_self_rehashed_candidate_metadata_cannot_replace_immutable_producer_bytes(self): + for name in guard.PRODUCER_FILES: + with self.subTest(name=name): + files, sbom = fixture(); producer = producer_checksums(files) + image = files['bundle-image.txt'] + files[name] += b' ' + if name == 'source-inputs.lock.json': + change(files, 'qualification.json', lambda q: q.update(sourceLockSha256=guard.sha(files[name]))) + if name in ('source-inputs.lock.json', 'qualification.json'): + change(files, 'manifest.json', lambda m: m['qualification'].update(sha256=guard.sha(files['qualification.json']))) + # This candidate remains internally consistent, including its + # rewritten checksums and unchanged bundle-image identity text. + guard.verify(files, checksums(files), sbom, DIGEST, producer_checksums(files)) + self.assertEqual(files['bundle-image.txt'], image) + with self.assertRaisesRegex(ValueError, 'differs from immutable OCI producer'): + guard.verify(files, checksums(files), sbom, DIGEST, producer) if __name__ == '__main__': diff --git a/server/security/ubuntu-backports/bundle/verify-consumer-evidence.py b/server/security/ubuntu-backports/bundle/verify-consumer-evidence.py index 3c08172c5c..8557d25bfc 100644 --- a/server/security/ubuntu-backports/bundle/verify-consumer-evidence.py +++ b/server/security/ubuntu-backports/bundle/verify-consumer-evidence.py @@ -22,6 +22,7 @@ } FILES = ('manifest.json', 'qualification.json', 'openvex.json', 'source-inputs.lock.json', 'install.json', 'verify.json', 'bundle-image.txt') +PRODUCER_FILES = ('manifest.json', 'qualification.json', 'openvex.json', 'source-inputs.lock.json') SHA256 = re.compile(r'[0-9a-f]{64}') DIGEST = re.compile(r'sha256:[0-9a-f]{64}') VULNERABILITY = re.compile(r'(?:CVE-[0-9]{4}-[0-9]+|GHSA-[23456789cfghjmpqrvwx]{4}(?:-[23456789cfghjmpqrvwx]{4}){2})') @@ -54,7 +55,7 @@ def unique_names(values, label): return set(values) -def verify(files, checksums, sbom, expected_digest): +def verify(files, checksums, sbom, expected_digest, bundle_checksums): require(DIGEST.fullmatch(expected_digest) is not None, 'Expected bundle digest is not immutable') expected_image = 'ghcr.io/pasturestack/ubuntu-security-backports:v0.1.0@' + expected_digest require(files['bundle-image.txt'].decode().strip() == expected_image, 'Consumer bundle identity mismatch') @@ -66,6 +67,16 @@ def verify(files, checksums, sbom, expected_digest): pins[name] = digest require(set(pins) == set(FILES) and all(sha(files[n]) == pins[n] for n in FILES), 'Retained consumer evidence checksum mismatch') + producer = {} + for line in bundle_checksums.decode().splitlines(): + digest, separator, name = line.partition(' ') + require(separator and name and SHA256.fullmatch(digest), 'Malformed immutable OCI producer checksum entry') + if name in PRODUCER_FILES: + require(name not in producer, 'Duplicate immutable OCI producer metadata checksum') + producer[name] = digest + require(set(producer) == set(PRODUCER_FILES), 'Immutable OCI producer metadata checksums are incomplete') + for name in PRODUCER_FILES: + require(sha(files[name]) == producer[name], 'Consumer metadata differs from immutable OCI producer: ' + name) docs = {name: json.loads(files[name]) for name in FILES if name.endswith('.json')} manifest, qualification = docs['manifest.json'], docs['qualification.json'] for name in ('manifest.json', 'qualification.json', 'source-inputs.lock.json'): @@ -143,6 +154,7 @@ def verify(files, checksums, sbom, expected_digest): pairs.add(pair) return {'schemaVersion': 1, 'status': 'PASS', 'bundleDigest': expected_digest, 'manifestSha256': sha(files['manifest.json']), 'openvexSha256': sha(files['openvex.json']), + 'bundleChecksumsSha256': sha(bundle_checksums), 'producerMetadataSha256': producer, 'selectedPackages': sorted(selected), 'selectedPURLs': sorted(actual.values()), 'qualifiedVexPairs': len(pairs), 'findingsFilteredByThisTool': False} @@ -157,11 +169,14 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--evidence-dir', type=Path, required=True) parser.add_argument('--sbom', type=Path, required=True) + parser.add_argument('--bundle-checksums', type=Path, required=True, + help='SHA256SUMS extracted from the pinned immutable OCI /out, independently of the candidate') parser.add_argument('--expected-bundle-digest', required=True) args = parser.parse_args() files = {name: bounded_read(args.evidence_dir / name) for name in FILES} result = verify(files, bounded_read(args.evidence_dir / 'SHA256SUMS'), - json.loads(bounded_read(args.sbom)), args.expected_bundle_digest) + json.loads(bounded_read(args.sbom)), args.expected_bundle_digest, + bounded_read(args.bundle_checksums)) print(json.dumps(result, indent=2, sort_keys=True))