diff --git a/.github/workflows/codeql-verification.yml b/.github/workflows/codeql-verification.yml index 3cfa89ed..d47ab734 100644 --- a/.github/workflows/codeql-verification.yml +++ b/.github/workflows/codeql-verification.yml @@ -38,10 +38,10 @@ jobs: set -euo pipefail test -z "$(git status --porcelain)" git merge-base --is-ancestor 73e3a7c5f438dbda42c4078885a66e6f156aa731 HEAD - grep -Fq 'VERSION_OVERRIDE: v0.13.31' .github/workflows/validate.yml + grep -Fq 'VERSION_OVERRIDE: v0.13.32' .github/workflows/validate.yml grep -Fq 'SHA256SUMS' scripts/package grep -Fq 'bash scripts/verify-package "$linux_asset"' .github/workflows/validate.yml - grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.31.tar.gz' .github/workflows/security-release-gate.yml + grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.32.tar.gz' .github/workflows/security-release-gate.yml - name: Install the release Go toolchain uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -68,7 +68,7 @@ jobs: export GOFLAGS=-mod=vendor mkdir -p "$GITHUB_WORKSPACE/bin" CGO_ENABLED=0 go build -trimpath -tags 'netgo osusergo' \ - -ldflags='-w -s -X main.VERSION=v0.13.31' \ + -ldflags='-w -s -X main.VERSION=v0.13.32' \ -o "$GITHUB_WORKSPACE/bin/node-agent" ./ - name: Analyze without publishing temporary alerts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bd6d1ee8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,65 @@ +name: Publish verified immutable runtime +on: + workflow_dispatch: + inputs: + producer_run: + description: Successful exact-source security gate run + required: true + type: string +permissions: + contents: write + actions: read + packages: write +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + PRODUCER_RUN: ${{ inputs.producer_run }} + RELEASE_TAG: v0.13.32 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: v0.13.32 + fetch-depth: 0 + persist-credentials: false + - name: Verify immutable tag and successful producer + shell: bash + run: | + set -euo pipefail + [[ "$PRODUCER_RUN" =~ ^[0-9]+$ ]] + test "$(git cat-file -t "refs/tags/$RELEASE_TAG")" = tag + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/$RELEASE_TAG^{}")" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PRODUCER_RUN" > "$RUNNER_TEMP/producer.json" + jq -e '.status == "completed" and .conclusion == "success" and .path == ".github/workflows/security-release-gate.yml"' "$RUNNER_TEMP/producer.json" + source=$(jq -r .head_sha "$RUNNER_TEMP/producer.json") + [[ "$source" =~ ^[0-9a-f]{40}$ ]] + tree=$(gh api "repos/$GITHUB_REPOSITORY/commits/$source" --jq .commit.tree.sha) + test "$tree" = "$(git rev-parse 'HEAD^{tree}')" + gh run download "$PRODUCER_RUN" --repo "$GITHUB_REPOSITORY" --name "security-runtime-$source" --dir dist/security-runtime + test "$(cat dist/security-runtime/source-revision.txt)" = "$source" + (cd dist/security-runtime && sha256sum --check SHA256SUMS) + - name: Publish exact scanned image and release assets + shell: bash + run: | + set -euo pipefail + gzip -dc dist/security-runtime/images.tar.gz | docker load + printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + trap 'docker logout ghcr.io >/dev/null 2>&1 || true' EXIT + source_image='pasturestack/node-agent:v0.13.32' + remote_image="ghcr.io/pasturestack/node-agent:$RELEASE_TAG" + if docker manifest inspect "$remote_image" >/dev/null 2>"$RUNNER_TEMP/manifest-error"; then + echo "Refusing to replace an existing immutable image tag: $remote_image" >&2; exit 1 + else + grep -Eq 'manifest unknown|no such manifest' "$RUNNER_TEMP/manifest-error" + fi + docker tag "$source_image" "$remote_image" + docker push "$remote_image" | tee "$RUNNER_TEMP/push-log" + pushed=$(awk '/digest: sha256:/{for(i=1;i<=NF;i++) if($i=="digest:") print $(i+1)}' "$RUNNER_TEMP/push-log") + readback=$(docker buildx imagetools inspect "$remote_image" | awk '$1=="Digest:"{print $2; exit}') + test "$pushed" = "$readback" + [[ "$readback" =~ ^sha256:[0-9a-f]{64}$ ]] + source=$(cat dist/security-runtime/source-revision.txt) + jq -n --arg image "$remote_image" --arg digest "$readback" --arg source "$source" '{image:$image,digest:$digest,source:$source}' > dist/security-runtime/registry-images.json + gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$GITHUB_REPOSITORY $RELEASE_TAG" --notes "Exact tested and scanned producer $source, run $PRODUCER_RUN. The immutable release tag has the same source tree. No deployment." dist/security-runtime/* diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 0eba9a70..823bee29 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 120 env: DAPPER_IMAGE: pasturestack/node-agent-dapper:${{ github.sha }} - RUNTIME_IMAGE: pasturestack/node-agent:v0.13.31 + RUNTIME_IMAGE: pasturestack/node-agent:v0.13.32 TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 - VERSION_OVERRIDE: v0.13.31 + VERSION_OVERRIDE: v0.13.32 steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,25 +50,25 @@ jobs: set -euo pipefail CROSS=1 VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" ci make DAPPER_IMAGE="$DAPPER_IMAGE" IMAGE_NAME=pasturestack/node-agent \ - TAG=v0.13.31 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image - test -s dist/artifacts/node-agent-0.13.31.tar.gz - test -s dist/artifacts/node-agent-0.13.31-windows-amd64.zip + TAG=v0.13.32 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image + test -s dist/artifacts/node-agent-0.13.32.tar.gz + test -s dist/artifacts/node-agent-0.13.32-windows-amd64.zip test "$(cat dist/image)" = "$RUNTIME_IMAGE" - gzip -t dist/artifacts/node-agent-0.13.31.tar.gz - bash scripts/verify-package dist/artifacts/node-agent-0.13.31.tar.gz - unzip -t dist/artifacts/node-agent-0.13.31-windows-amd64.zip + gzip -t dist/artifacts/node-agent-0.13.32.tar.gz + bash scripts/verify-package dist/artifacts/node-agent-0.13.32.tar.gz + unzip -t dist/artifacts/node-agent-0.13.32-windows-amd64.zip ( cd dist/artifacts - sha256sum node-agent-0.13.31.tar.gz node-agent-0.13.31-windows-amd64.zip > SHA256SUMS + sha256sum node-agent-0.13.32.tar.gz node-agent-0.13.32-windows-amd64.zip > SHA256SUMS sha256sum -c SHA256SUMS ) - sha256sum dist/artifacts/node-agent-0.13.31.tar.gz \ - dist/artifacts/node-agent-0.13.31-windows-amd64.zip \ + sha256sum dist/artifacts/node-agent-0.13.32.tar.gz \ + dist/artifacts/node-agent-0.13.32-windows-amd64.zip \ bin/node-agent bin/windows/amd64/node-agent.exe > evidence/product-artifacts.sha256 docker image inspect "$RUNTIME_IMAGE" > evidence/runtime-image-inspect.json docker run --rm --entrypoint /usr/bin/node-agent "$RUNTIME_IMAGE" --version \ > evidence/runtime-version.txt - grep -Fxq 'node-agent version v0.13.31' evidence/runtime-version.txt + grep -Fxq 'node-agent version v0.13.32' evidence/runtime-version.txt - name: Verify reproducible Linux binary shell: bash @@ -118,8 +118,16 @@ jobs: --output /work/evidence/runtime-security.json "$RUNTIME_IMAGE" docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PWD:/work" -w /work -v "$cache:/root/.cache/trivy" "$TRIVY_IMAGE" image \ - --skip-db-update --offline-scan --scanners vuln,secret --format json \ - --output /work/evidence/dapper-security.json "$DAPPER_IMAGE" + --skip-db-update --offline-scan --scanners vuln,secret --list-all-pkgs --format json \ + --output /work/evidence/dapper-security-raw.json "$DAPPER_IMAGE" + docker run --rm --entrypoint python3 "$DAPPER_IMAGE" \ + /opt/python-security/scripts/check-pip-vendor-slice.py \ + > evidence/pip-vendor-proof.json + python3 scripts/pip-security/test-pip-vex.py + python3 scripts/pip-security/filter-pip-vendor-findings.py \ + --raw evidence/dapper-security-raw.json --proof evidence/pip-vendor-proof.json \ + --output evidence/dapper-security.json --vex evidence/pip-vendor.openvex.json \ + --subject "$(docker image inspect --format '{{.Id}}' "$DAPPER_IMAGE")" docker run --rm --entrypoint sh "$DAPPER_IMAGE" -lc \ "printf 'package\\tversion\\n'; dpkg-query -W -f='\${binary:Package}\\t\${Version}\\n' | LC_ALL=C sort" \ > evidence/dapper-dpkg.tsv @@ -243,13 +251,32 @@ jobs: print('{}_components={}'.format(scope, len(bom['components']))) PY + - name: Retain exact runtime for immutable release + shell: bash + run: | + set -euo pipefail + mkdir -p dist/security-runtime + docker image inspect "$RUNTIME_IMAGE" > dist/security-runtime/images.json + docker save "$RUNTIME_IMAGE" | gzip -n > dist/security-runtime/images.tar.gz + cp evidence/runtime-security.json evidence/runtime.cdx.json dist/security-runtime/ + cp dist/artifacts/node-agent-0.13.32.tar.gz dist/artifacts/node-agent-0.13.32-windows-amd64.zip dist/security-runtime/ + printf '%s\n' "$GITHUB_SHA" > dist/security-runtime/source-revision.txt + (cd dist/security-runtime && sha256sum -- * > SHA256SUMS) + - name: Upload exact runtime release bytes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: security-runtime-${{ github.sha }} + path: dist/security-runtime/ + if-no-files-found: error + retention-days: 14 + - name: Retain exact scanned release packages uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: node-agent-scanned-0.13.31 + name: node-agent-scanned-0.13.32 path: | - dist/artifacts/node-agent-0.13.31.tar.gz - dist/artifacts/node-agent-0.13.31-windows-amd64.zip + dist/artifacts/node-agent-0.13.32.tar.gz + dist/artifacts/node-agent-0.13.32-windows-amd64.zip dist/artifacts/SHA256SUMS if-no-files-found: error retention-days: 30 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 06fd276f..ce73f346 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -52,7 +52,7 @@ jobs: id: package env: CROSS: "1" - VERSION_OVERRIDE: v0.13.31 + VERSION_OVERRIDE: v0.13.32 VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }} run: | set -euo pipefail @@ -75,8 +75,8 @@ jobs: exit 1 ;; esac - linux_asset="dist/artifacts/node-agent-0.13.31.tar.gz" - windows_asset="dist/artifacts/node-agent-0.13.31-windows-amd64.zip" + linux_asset="dist/artifacts/node-agent-0.13.32.tar.gz" + windows_asset="dist/artifacts/node-agent-0.13.32-windows-amd64.zip" test -s "$linux_asset" test -s "$windows_asset" gzip -t "$linux_asset" @@ -92,8 +92,8 @@ jobs: ( cd dist/artifacts sha256sum \ - node-agent-0.13.31.tar.gz \ - node-agent-0.13.31-windows-amd64.zip >SHA256SUMS + node-agent-0.13.32.tar.gz \ + node-agent-0.13.32-windows-amd64.zip >SHA256SUMS sha256sum -c SHA256SUMS ) printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s scope=%s artifacts=linux,windows\n' \ @@ -102,10 +102,10 @@ jobs: - name: Retain reviewed release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: node-agent-0.13.31 + name: node-agent-0.13.32 path: | - dist/artifacts/node-agent-0.13.31.tar.gz - dist/artifacts/node-agent-0.13.31-windows-amd64.zip + dist/artifacts/node-agent-0.13.32.tar.gz + dist/artifacts/node-agent-0.13.32-windows-amd64.zip dist/artifacts/SHA256SUMS if-no-files-found: error retention-days: 30 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 0be4465a..2b2fe50b 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -2,10 +2,10 @@ The node agent preserves established event names, reply payloads, generated client schemas, Docker labels, state fields, registration-script inputs, and storage-driver identifiers consumed by existing deployments. -## Linux Host API producer in 0.13.31 +## Linux Host API producer in 0.13.32 -The source package version is `0.13.31`. Its Linux supervisor requires exactly -Host API `0.38.7`, installed first through the existing authenticated configcontent +The source package version is `0.13.32`. Its Linux supervisor requires exactly +Host API `0.38.8`, installed first through the existing authenticated configcontent package sequence. Version mismatch still closes Host API admission without a legacy fallback. Fixed producer paths, agent identity forwarding, executable ownership checks, parent-bound lifetime, package layout, and both SHA-1 and diff --git a/Dockerfile.dapper b/Dockerfile.dapper index 4a372fc5..aacf5648 100644 --- a/Dockerfile.dapper +++ b/Dockerfile.dapper @@ -17,9 +17,20 @@ ADD --checksum=sha256:6965aafe69e0a49ef34d2e26f8e83d09d1c6037b7e9ee37f19b292a3c6 ADD --checksum=sha256:51cf418571059831096abd40e0bd5debaa1ae48a6f8d87d9e4e5f7f12aa4db52 https://security.ubuntu.com/ubuntu/pool/main/o/openssl/openssl-provider-legacy_3.5.5-1ubuntu3.7_amd64.deb /tmp/openssl-provider-legacy.deb ADD --checksum=sha256:0498bc9510149ef06abd6c09b8932e43c5bc71b68634b4b8f84488b2e0a20e82 https://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_7.0.0-38.38_amd64.deb /tmp/linux-libc-dev.deb -ADD --checksum=sha256:6077d27c6b6f8b23590cb01ff877ed8c804a67a5442cc32b5a33da10d2bd0e90 \ - https://snapshot.ubuntu.com/ubuntu/20260825T000000Z/pool/main/c/ca-certificates/ca-certificates_20260601~26.04.1_all.deb \ +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 +ADD --checksum=sha256:76c12c7936d7a2e898f972b8b40ec74f3b58f1a534e67e256e1aa9d28c6c43d8 https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/libpython3.14_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/libpython3.14.deb +ADD --checksum=sha256:6f2e99c80311934d0a031bb12fe2faca684b05e5b6bb225fe9a07a5c05fdb3da https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/libpython3.14-dev_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/libpython3.14-dev.deb +ADD --checksum=sha256:fac6f0728cf9490a7a698e6b343e4140283e92355b060193c04f5770a666df8a https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/libpython3.14-minimal_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/libpython3.14-minimal.deb +ADD --checksum=sha256:3c267ef0f902f763c3615cbf28dd6fe91d2e4f603075d7f03db99f8d4eb66f46 https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/libpython3.14-stdlib_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/libpython3.14-stdlib.deb +ADD --checksum=sha256:780c308c47d8afcbb7d339f0023350e7cf60c1a37e9572a1f88774cc18f60526 https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/python3.14_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/python3.14.deb +ADD --checksum=sha256:5e76e07f9ebb181eda92986297eca3488aaf81f53f0efcfdd32866a409f94071 https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/python3.14-dev_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/python3.14-dev.deb +ADD --checksum=sha256:9d806b46f6dc429f156c5c53e6e2ec5bfc89302f9a441483b3249211e18e0067 https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/python3.14-gdbm_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/python3.14-gdbm.deb +ADD --checksum=sha256:0a7ea8a52834f9b3de0f11758924f80f7a6ebcbb4b02a55ba4f457503569f55f https://security.ubuntu.com/ubuntu/pool/main/p/python3.14/python3.14-minimal_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/python3.14-minimal.deb +ADD --checksum=sha256:cad2456bf3cb57cb18d2c82b48b549e5b4ac6eee9b3e14e65334209387e3d0ae https://security.ubuntu.com/ubuntu/pool/universe/p/python3.14/python3.14-venv_3.14.4-1ubuntu0.2_amd64.deb /opt/python-abi-security/python3.14-venv.deb +COPY scripts/install-python-abi-security /usr/local/bin/install-python-abi-security +ADD --checksum=sha256:5b6326f1932cfd7be57130561dc78192f09ed9f58bfbe6606ed0c50510c3b6cd https://archive.ubuntu.com/ubuntu/pool/main/c/ca-certificates/ca-certificates_20260816ubuntu0.26.04.1_all.deb /opt/pasturestack-ca-certificates.deb RUN set -eux; \ mkdir -p /tmp/ca-bootstrap /etc/ssl/certs; \ dpkg-deb --extract /tmp/ca-certificates.deb /tmp/ca-bootstrap; \ @@ -73,6 +84,12 @@ RUN set -eux; \ test "$(dpkg-query -W -f='${Version}' "$package")" = "$expected" || exit 1; \ rm -f "/tmp/${package}.deb"; \ done && \ + bash /usr/local/bin/install-python-abi-security && \ + dpkg -i /opt/pasturestack-ca-certificates.deb && \ + update-ca-certificates --fresh && \ + test "$(dpkg-query -W -f='${Version}' ca-certificates)" = '20260816ubuntu0.26.04.1' && \ + test ! -e /usr/share/ca-certificates/mozilla/emSign_ECC_Root_CA_-_G3.crt && \ + rm -f /opt/pasturestack-ca-certificates.deb && \ rm -rf /var/lib/apt/lists/* && \ rm -f /usr/bin/pebble && \ rm -f /bin/sh && ln -s /bin/bash /bin/sh @@ -82,10 +99,15 @@ RUN git config --system --add safe.directory '*' ARG GO_VERSION=1.27.3 ARG GO_SHA256_amd64=65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40 ARG GO_SHA256_arm=d6f4e11dd7157030d7ac11c51c8f73201a2db932d593c3106b1da11cc32bfdf8 -ARG PIP_VERSION=26.1.2 ARG SETUPTOOLS_VERSION=83.0.0 ARG WHEEL_VERSION=0.47.0 ARG TOX_VERSION=4.57.2 +COPY scripts/pip-security /opt/python-security/scripts +RUN python3 /opt/python-security/scripts/build-pip-overlay.py --work /tmp/pip-overlay-build --output /opt/python-security && \ + rm -rf /tmp/pip-overlay-build +ADD --checksum=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl /opt/python-security/certifi-2026.10.19-py3-none-any.whl +COPY scripts/install-python-security-tools /usr/local/bin/install-python-security-tools +ENV VIRTUALENV_PIP=26.2.2+pasturestack.1 VIRTUALENV_EXTRA_SEARCH_DIR=/usr/share/python-wheels VIRTUALENV_NO_PERIODIC_UPDATE=1 ENV GOLANG_ARCH_amd64=amd64 GOLANG_ARCH_arm=armv6l GOLANG_ARCH=GOLANG_ARCH_${ARCH} \ GO_SHA256=GO_SHA256_${ARCH} \ GOPATH=/go PATH=/opt/tox/bin:/go/bin:/usr/local/go/bin:${PATH} SHELL=/bin/bash \ @@ -95,9 +117,9 @@ RUN curl -fsSL -o /tmp/go.tgz "https://go.dev/dl/go${GO_VERSION}.linux-${!GOLANG echo "${!GO_SHA256} /tmp/go.tgz" | sha256sum -c - && \ tar -C /usr/local -xzf /tmp/go.tgz && \ rm -f /tmp/go.tgz && \ + bash /usr/local/bin/install-python-security-tools && \ python3 -m venv /opt/tox && \ /opt/tox/bin/pip install --no-cache-dir --upgrade \ - "pip==${PIP_VERSION}" \ "setuptools==${SETUPTOOLS_VERSION}" \ "wheel==${WHEEL_VERSION}" \ "tox==${TOX_VERSION}" && \ @@ -110,6 +132,8 @@ RUN curl -fsSL -o /tmp/go.tgz "https://go.dev/dl/go${GO_VERSION}.linux-${!GOLANG python3 --version && \ tox --version +RUN PYTHON_SECURITY_VIRTUALENV_PYTHON=/opt/tox/bin/python bash /usr/local/bin/install-python-security-tools + # Resolve the fully pinned test dependency set once while building the disposable # build image. Clean source checkouts then run without reaching PyPI. COPY tests/requirements.lock /tmp/node-agent-test-requirements.lock diff --git a/README.md b/README.md index 5434caf5..0c2506d3 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ From a Docker-capable Linux host: make test make build make package -make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.31 +make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.32 ``` Packaging is local only and does not push an image. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md). -The current recipe packages version `0.13.31`. Source changes alone are not +The current recipe packages version `0.13.32`. Source changes alone are not publication proof: use the [official releases](https://github.com/PastureStack/node-agent/releases) and their SHA-256 checksums to verify published packages. Published tags and assets remain immutable; later documentation commits do not change their producer identity. @@ -39,9 +39,9 @@ client 0.5, gopsutil v4.26, mapstructure v2.5, netlink v1.3, and netns v0.0.5. The retired AWS SDK v1, Aliyungo, root Docker module, GOPATH/Godeps, and Trash dependency paths are not part of the build. -`VERSION_OVERRIDE=v0.13.31 make package` produces the deterministic Linux asset `node-agent-0.13.31.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. Component publication and matching Server acceptance are separate evidence boundaries. +`VERSION_OVERRIDE=v0.13.32 make package` produces the deterministic Linux asset `node-agent-0.13.32.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. Component publication and matching Server acceptance are separate evidence boundaries. -On Linux, Node supervises the separately maintained `host-api` producer at `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/bin/host-api`. Host API `0.38.7` must be installed before the Node package by the existing `pyagent` package sequence. Node verifies its exact version, forwards the current agent/host identity only through process environment, restarts an exited child, and binds its lifetime to Node. Missing or incompatible producers never fall back to the embedded legacy Host API. Stream authorization, target binding, and private durable terminal evidence remain owned by the Host API producer. +On Linux, Node supervises the separately maintained `host-api` producer at `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/bin/host-api`. Host API `0.38.8` must be installed before the Node package by the existing `pyagent` package sequence. Node verifies its exact version, forwards the current agent/host identity only through process environment, restarts an exited child, and binds its lifetime to Node. Missing or incompatible producers never fall back to the embedded legacy Host API. Stream authorization, target binding, and private durable terminal evidence remain owned by the Host API producer. Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server recipe independently pins its Windows artifact; a Linux package publication and CI cross-builds do not establish new Windows runtime acceptance. @@ -60,3 +60,10 @@ The test harness starts a disposable inner Docker daemon and generates its BusyB ## License and attribution The inherited project remains licensed under [Apache License 2.0](LICENSE). Copyright and attribution for inherited work and vendored dependencies remain with their respective authors and contributors. PastureStack contributors claim authorship only for their own changes. + +## October 2026 security maintenance + +Maintained Go builds select the patched Go 1.27.3 compiler without raising the existing Go language requirement. Ubuntu build/runtime stages apply the checksum-pinned `ca-certificates 20260816ubuntu0.26.04.1` security package after their frozen APT baseline; the final trust store and installed version are verified. Python tooling uses the patched pip/CA path; CI interpreter pins remain on the Python 3.14 series. Release artifacts must pass the repository's existing checks before publication. +# Python installer security rebuild + +The disposable Dapper image rebuilds pip `26.2.2+pasturestack.1` from checksum-pinned official inputs using upstream vendoring and the original urllib3 patches. Only urllib3 changes to released `2.8.0`; this is a local security rebuild, not a new official PyPA release. The expected wheel SHA-256 is `be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a`. Ubuntu's Python ABI is retained. The image tests live pip, available ensurepip/new-venv seeds, virtualenv, streaming limits, and trusted/untrusted TLS. See `scripts/pip-security/README.md` for source and license evidence. diff --git a/scripts/install-python-abi-security b/scripts/install-python-abi-security new file mode 100644 index 00000000..6a1ad62e --- /dev/null +++ b/scripts/install-python-abi-security @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Apply the official same-ABI security backport only to installed packages. +set -euo pipefail +packages=(libpython3.14 libpython3.14-dev libpython3.14-minimal libpython3.14-stdlib python3.14 python3.14-dev python3.14-gdbm python3.14-minimal python3.14-venv) +debs=() +installed=() +for package in "${packages[@]}"; do + if dpkg-query -W -f='${db:Status-Status}' "$package" 2>/dev/null | grep -qx installed; then + file="/opt/python-abi-security/${package}.deb" + test "$(dpkg-deb -f "$file" Package)" = "$package" + test "$(dpkg-deb -f "$file" Version)" = 3.14.4-1ubuntu0.2 + test "$(dpkg-deb -f "$file" Architecture)" = amd64 + debs+=("$file") + installed+=("$package") + fi +done +test "${#debs[@]}" -gt 0 +dpkg -i "${debs[@]}" +for package in "${installed[@]}"; do + test "$(dpkg-query -W -f='${Version}' "$package")" = 3.14.4-1ubuntu0.2 +done +rm -rf /opt/python-abi-security +printf 'PYTHON_ABI_SECURITY_OK version=3.14.4-1ubuntu0.2\n' diff --git a/scripts/install-python-security-tools b/scripts/install-python-security-tools new file mode 100644 index 00000000..2076a0db --- /dev/null +++ b/scripts/install-python-security-tools @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Keep Ubuntu's ABI; install the reproducible local pip security rebuild and seeds. +set -euo pipefail +expected=26.2.2+pasturestack.1 +pip_wheel=/opt/python-security/pip-$expected-py3-none-any.whl +certifi_wheel=/opt/python-security/certifi-2026.10.19-py3-none-any.whl +test -s "$pip_wheel" +printf '%s %s\n' be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a "$pip_wheel" | sha256sum -c - +test -s "$certifi_wheel" +if dpkg-query -W -f='${db:Status-Status}' python3-pip 2>/dev/null | grep -qx installed; then + apt-get purge -y python3-pip +fi +PYTHONPATH="$pip_wheel" python3 -m pip install --break-system-packages --no-index --no-deps "$pip_wheel" "$certifi_wheel" +install -d /usr/share/python-wheels +find /usr/share/python-wheels -maxdepth 1 -type f -name 'pip-*.whl' -delete +install -m 0644 "$pip_wheel" /usr/share/python-wheels/ +python3 -c 'import pip, certifi, pip._vendor.certifi as c; assert pip.__version__ == "26.2.2+pasturestack.1"; assert certifi.__version__ == c.__version__ == "2026.10.19"' +python3 /opt/python-security/scripts/test-pip-overlay.py +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +if python3 -c 'import ensurepip' 2>/dev/null; then + python3 -c 'import ensurepip; assert ensurepip.version() == "26.2.2+pasturestack.1"' + python3 -m venv "$scratch/venv" + ensurepip_status=$expected +else + # Do not add an unused distro ensurepip package just to exercise a test. + python3 -m venv --without-pip "$scratch/venv" + PYTHONPATH="$pip_wheel" "$scratch/venv/bin/python" -m pip install --no-index --no-deps "$pip_wheel" + ensurepip_status=not-installed +fi +"$scratch/venv/bin/python" /opt/python-security/scripts/test-pip-overlay.py +if [ -n "${PYTHON_SECURITY_VIRTUALENV_PYTHON:-}" ]; then + "$PYTHON_SECURITY_VIRTUALENV_PYTHON" -m virtualenv --no-download --no-periodic-update --pip="$expected" --extra-search-dir=/usr/share/python-wheels "$scratch/virtualenv" + "$scratch/virtualenv/bin/python" /opt/python-security/scripts/test-pip-overlay.py +fi +printf 'PYTHON_SECURITY_TOOLS_OK pip=%s certifi=2026.10.19 ensurepip=%s\n' "$expected" "$ensurepip_status" diff --git a/scripts/pip-security/README.md b/scripts/pip-security/README.md new file mode 100644 index 00000000..e354536a --- /dev/null +++ b/scripts/pip-security/README.md @@ -0,0 +1,75 @@ +# 共用 pip 安全重包 + +這是內部衍生版本 `26.2.2+pasturestack.1`,不是 PyPA 官方 pip 發布。 +所有專案及映像的對外 release tag 仍使用原本的純數字規則。 + +來源是官方 pip `26.2.2` 的 commit +`b3b696fe2122f2339d782517559bfade2cb6b879`。使用上游 `vendoring==1.4.0` +及 pip 原有的三份 urllib3 patches,把官方 PyPI urllib3 改成 `2.8.0`。 +不手改 urllib3 原始碼、不 debundle。其餘內嵌 library 的每一個檔案均須 +保持原始 SHA-256;licenses、內嵌 CycloneDX SBOM 及 wheel RECORD 全部保留。 + +## 建置 + +Linux Python 3.12/3.14,amd64/arm64;所有工具與原始碼的 URL/SHA 在 +`inputs.json`。工具的相依套件不會進入產物。 + +```sh +python3 build-pip-overlay.py --work /tmp/pip-build --output /tmp/pip-dist \ + --input-cache /tmp/pip-inputs +# 從完全相同的已驗證上游檔案重新離線建置: +python3 build-pip-overlay.py --work /tmp/pip-rebuild --output /tmp/pip-dist2 \ + --input-cache /tmp/pip-inputs --offline +``` + +下載後的 vendoring、安裝工具與 wheel 建置使用 `PIP_NO_INDEX=1`。 +每次建置都做兩次 wheel 並比較位元組,驗證全部 RECORD hashes、無 native +extension、保留 license,以及只有 urllib3 library 有變更。 +輸出為 wheel、`build-evidence.json`、`pip-vendor.cdx.json`、工具 hash lock +與來源 manifest。已在 Python 3.12 建出 wheel SHA-256: +`be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a`。 + +## 安裝與三種 seed 路徑 + +編譯 CPython 前,以 `install-ensurepip-wheel.py --stdlib /Lib +--wheel --sha256 ` 同步 `_PIP_VERSION` 與 `_bundled` wheel +檔名,再正常編譯/安裝;不能只改 site-packages。 + +Ubuntu 的 distro Python 使用 `/usr/share/python-wheels` 時,將該固定 wheel +放入這個 seed directory 並移除其中舊的 pip wheel(不要動其他 wheel); +以此 wheel `--no-index --no-deps` 安裝 live pip。既有 Python ABI 不變。 + +完成映像內安裝後執行: + +```sh +bash verify-pip-runtime.sh +# Engine/compose 已有 virtualenv 時,再驗第三條 seed 路徑: +bash verify-pip-runtime.sh --virtualenv /usr/share/python-wheels +``` + +此 gate 驗證 live pip、ensurepip.version、新 venv、選用的新 virtualenv +全部載入衍生 pip 與 urllib3 2.8.0,並重跑攻防、可信/不可信 TLS、CLI。 +本機 Python 3.12 的 wheel/攻防/TLS/CLI 已通過;真正 3.14 的 seed 三路驗證 +須由消費映像 CI 執行,不能把本機結果冒充為 3.14 PASS。 + +## 已驗證的漏洞界線 + +- CVE-2026-97689:pip `response_chunks` 使用 `raw.stream`,確實可達。 + 測試在舊版對惡意 chunk-size/trailer 讀入 131072 bytes;新版在 65537 bytes + 即拒絕,正常資料與邊界內的 chunk extension 保持可用。 +- CVE-2026-57585:上游 fix commit + `2c56ddb5d0025ed481d962c0f5d62d19dec7476d` 只修 Cython/C 實作;本 wheel + 無 `.so/.pyd`,實際 `Unpacker` 為 `pip._vendor.msgpack.fallback`。 + 不為消除套件名稱掃描結果而更動未受影響的 msgpack。 +- setuptools 70.3.0 僅保留 `pkg_resources`;CVE-2025-47273 所需的 + `setuptools/package_index.py` 與 CVE-2026-59890 所需的 + `setuptools/command/egg_info.py` 均不存在。 + +`check-pip-vendor-slice.py` 會比對實際載入的全部 299 個 vendor 檔案與 +固定 wheel,拒絕額外檔案、其他同版本完整 distribution。只有取得此證據後, +`filter-pip-vendor-findings.py` 才可對上述三個 CVE(含 msgpack GHSA 別名) +產生綁定該映像的 OpenVEX 與有效掃描檔。原始掃描必須另行保存;未知 CVE、 +其他版本、非 SBOM 套件來源與 urllib3 finding 全部保留,不做泛用版本排除。 +執行 `python3 test-pip-vex.py` 可驗證以上六組正反控制。 + +官方政策:https://pip.pypa.io/en/stable/development/vendoring-policy/ diff --git a/scripts/pip-security/build-pip-overlay.py b/scripts/pip-security/build-pip-overlay.py new file mode 100644 index 00000000..88b53806 --- /dev/null +++ b/scripts/pip-security/build-pip-overlay.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Rebuild pip using released urllib3 and pip's unmodified vendoring machinery. + +Build dependencies never enter the product wheel. All network inputs are HTTPS +and SHA-256 pinned. The resulting local version is not an official pip release. +""" +import argparse +import base64 +import csv +import hashlib +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import tempfile +from urllib.request import HTTPRedirectHandler, HTTPSHandler, build_opener +from urllib.parse import urlsplit +import venv +import zipfile + + +class HTTPSOnlyRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if urlsplit(newurl).scheme != "https": + raise ValueError("Refusing a non-HTTPS build-input redirect") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def run(command, cwd, env): + print("+", " ".join(map(str, command)), flush=True) + subprocess.run(list(map(str, command)), cwd=cwd, env=env, check=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--work", type=Path, required=True) + parser.add_argument("--input-cache", type=Path) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + output, work = args.output.resolve(), args.work.resolve() + output.mkdir(parents=True, exist_ok=True) + work.mkdir(parents=True, exist_ok=True) + config = json.loads(Path(__file__).with_name("inputs.json").read_text()) + assert sys.version_info[:2] in ((3, 12), (3, 14)) and os.name == "posix" + scratch = Path(tempfile.mkdtemp(prefix="pip-revendor-", dir=work)) + downloads = scratch / "downloads" + downloads.mkdir() + cache = args.input_cache.resolve() if args.input_cache else downloads + cache.mkdir(parents=True, exist_ok=True) + paths = {} + for item in config["artifacts"]: + assert urlsplit(item["url"]).scheme == "https" + target = downloads / Path(urlsplit(item["url"]).path).name + cached = cache / target.name + if not cached.is_file(): + assert not args.offline, ("Offline input is missing", target.name) + opener = build_opener(HTTPSHandler(), HTTPSOnlyRedirect()) + with opener.open(item["url"], timeout=90) as response, cached.open("wb") as dest: + shutil.copyfileobj(response, dest) + assert sha(cached) == item["sha256"], ("cached input hash mismatch", target.name) + if cached != target: + shutil.copy2(cached, target) + assert sha(target) == item["sha256"], ("input hash mismatch", target.name) + paths.setdefault(item["name"], []).append(target) + + environment = os.environ.copy() + environment.update(PIP_NO_INDEX="1", PIP_FIND_LINKS=str(downloads), + PIP_DISABLE_PIP_VERSION_CHECK="1", PIP_ONLY_BINARY=":all:", + PYTHONHASHSEED="0", SOURCE_DATE_EPOCH=str(config["sourceDateEpoch"])) + for key in ("PYTHONPATH", "PYTHONHOME", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", + "PIP_TRUSTED_HOST", "PIP_CERT", "PIP_CLIENT_CERT"): + environment.pop(key, None) + environment["PIP_CONFIG_FILE"] = os.devnull + tool_env = scratch / "tool-env" + venv.EnvBuilder(with_pip=False).create(tool_env) + python = tool_env / "bin/python" + requirements = scratch / "tools.lock" + groups = {} + for item in config["artifacts"]: + if item["kind"] == "tool": + groups.setdefault((item["name"], item["version"]), []).append(item["sha256"]) + requirements.write_text("\n".join( + f"{name}=={version} " + " ".join(f"--hash=sha256:{value}" for value in hashes) + for (name, version), hashes in sorted(groups.items())) + "\n") + bootstrap_env = dict(environment, PYTHONPATH=str(paths["pip"][0])) + run([python, "-m", "pip", "install", "--require-hashes", "--no-compile", "-r", requirements], scratch, bootstrap_env) + environment["PATH"] = str(tool_env / "bin") + os.pathsep + environment["PATH"] + + source_parent = scratch / "source" + source_parent.mkdir() + with tarfile.open(paths["pip-source"][0]) as archive: + # Reject links and traversal before extracting this fixed official archive. + for member in archive.getmembers(): + assert not member.issym() and not member.islnk() + resolved = (source_parent / member.name).resolve() + assert resolved.is_relative_to(source_parent) + archive.extractall(source_parent, filter="data") + source = next(source_parent.iterdir()) + vendor = source / "src/pip/_vendor" + original = {str(p.relative_to(vendor)): sha(p) for p in vendor.rglob("*") if p.is_file()} + assert (source / "src/pip/__init__.py").read_text().count('__version__ = "26.2.2"') == 1 + + # A scoped project invokes the same upstream tool, namespace and urllib3 + # patches. Unrelated vendored libraries are never regenerated or replaced. + scoped = scratch / "urllib3-vendoring" + scoped_vendor = scoped / "src/pip/_vendor" + scoped_vendor.mkdir(parents=True) + (scoped_vendor / "vendor.txt").write_text("urllib3==2.8.0\n") + patches = scoped / "tools/vendoring/patches" + patches.mkdir(parents=True) + for patch in (source / "tools/vendoring/patches").glob("urllib3*.patch"): + shutil.copy2(patch, patches / patch.name) + assert len(list(patches.glob("*.patch"))) == 3 + (scoped / "pyproject.toml").write_text('''[tool.vendoring] +destination = "src/pip/_vendor/" +requirements = "src/pip/_vendor/vendor.txt" +namespace = "pip._vendor" +protected-files = ["vendor.txt"] +patches-dir = "tools/vendoring/patches" +[tool.vendoring.transformations] +drop = ["*.dist-info/", "bin/"] +''') + run([python, "-m", "vendoring", "sync", "-v"], scoped, environment) + generated = scoped_vendor / "urllib3" + assert (generated / "__init__.py").is_file() + shutil.rmtree(vendor / "urllib3") # exact disposable source subtree only + shutil.copytree(generated, vendor / "urllib3") + vendor_txt = vendor / "vendor.txt" + text = vendor_txt.read_text() + assert text.count("urllib3==2.7.0") == 1 + vendor_txt.write_text(text.replace("urllib3==2.7.0", "urllib3==2.8.0")) + version_file = source / "src/pip/__init__.py" + version_file.write_text(version_file.read_text().replace('__version__ = "26.2.2"', + f'__version__ = "{config["version"]}"')) + readme = vendor / "README.rst" + with readme.open("a") as handle: + handle.write("\n\nPastureStack security rebuild\n============================\n\n" + "This downstream local-version wheel uses released urllib3 2.8.0\n" + "through upstream vendoring 1.4.0 and pip's unchanged urllib3\n" + "patches. Other vendored library sources and licenses are unchanged.\n" + "It is not an official PyPA pip release.\n") + run([python, "-c", "from pathlib import Path; from vendoring.sbom import create_sbom_file; create_sbom_file('pip._vendor', Path('src/pip/_vendor/vendor.txt'), Path('src/pip/_vendor/bom.cdx.json'))"], source, environment) + current = {str(p.relative_to(vendor)): sha(p) for p in vendor.rglob("*") if p.is_file()} + allowed = {"vendor.txt", "README.rst", "bom.cdx.json"} + assert {p: value for p, value in original.items() if not p.startswith("urllib3/") and p not in allowed} == { + p: value for p, value in current.items() if not p.startswith("urllib3/") and p not in allowed} + wheels = [] + for suffix in ("first", "second"): + directory = scratch / suffix + directory.mkdir() + run([python, "-c", f"from flit_core.buildapi import build_wheel; build_wheel({str(directory)!r})"], source, environment) + wheels.append(next(directory.glob("*.whl"))) + assert sha(wheels[0]) == sha(wheels[1]), "wheel rebuild is not byte reproducible" + assert sha(wheels[0]) == config["expectedWheelSha256"], "unexpected rebuilt product bytes" + wheel = output / wheels[0].name + shutil.copy2(wheels[0], wheel) + licenses = [] + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + record = next(name for name in names if name.endswith(".dist-info/RECORD")) + for name, digest, size in csv.reader(io.StringIO(archive.read(record).decode())): + if not digest: + assert name == record + continue + data = archive.read(name) + expected = "sha256=" + base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode() + assert digest == expected and int(size) == len(data), name + assert not any(name.endswith((".so", ".pyd")) for name in names) + licenses = [name for name in names if "LICENSE" in name or "COPYING" in name] + assert any("urllib3" in name for name in licenses) + assert "pip/_vendor/pkg_resources/__init__.py" in names + assert not any("setuptools/package_index" in name for name in names) + shutil.copy2(vendor / "bom.cdx.json", output / "pip-vendor.cdx.json") + report = {"version": config["version"], "upstreamVersion": config["upstreamVersion"], + "upstreamCommit": config["sourceCommit"], "wheel": wheel.name, + "sha256": sha(wheel), "twoBuildsByteIdentical": True, + "allRecordHashesVerified": True, "nativeExtensionsPresent": False, + "licenses": licenses, "onlyVendoredLibraryChanged": "urllib3", "urllib3": "2.8.0"} + (output / "build-evidence.json").write_text(json.dumps(report, indent=2) + "\n") + shutil.copy2(requirements, output / "tools.lock") + shutil.copy2(Path(__file__).with_name("inputs.json"), output / "inputs.json") + print(json.dumps(report), flush=True) + + +if __name__ == "__main__": + if sys.flags.optimize: + raise RuntimeError("Security build verification must not run with Python optimization") + main() diff --git a/scripts/pip-security/check-pip-vendor-slice.py b/scripts/pip-security/check-pip-vendor-slice.py new file mode 100644 index 00000000..789bc280 --- /dev/null +++ b/scripts/pip-security/check-pip-vendor-slice.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Prove the exact installed pip vendor slice before producing narrow VEX data.""" +import argparse +import hashlib +import importlib.metadata +import importlib.resources +import json +from pathlib import Path +import zipfile +import pip +from pip._vendor import msgpack, urllib3 + +EXPECTED_SHA = "be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a" +FINDINGS = { + "GHSA-6v7p-g79w-8964": ("msgpack", "1.1.2", "C/Cython unpacker is absent; runtime selects pure Python fallback"), + "CVE-2026-57585": ("msgpack", "1.1.2", "C/Cython unpacker is absent; runtime selects pure Python fallback"), + "CVE-2025-47273": ("setuptools", "70.3.0", "setuptools/package_index.py is absent; only pkg_resources is vendored"), + "CVE-2026-59890": ("setuptools", "70.3.0", "setuptools/command/egg_info.py and FileList are absent"), +} + + +def verify(wheel): + assert hashlib.sha256(wheel.read_bytes()).hexdigest() == EXPECTED_SHA + assert pip.__version__ == "26.2.2+pasturestack.1" + assert urllib3.__version__ == "2.8.0" + assert msgpack.Unpacker.__module__ == "pip._vendor.msgpack.fallback" + live = importlib.resources.files("pip") + checked = 0 + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + expected_files = set() + for name in names: + if not name.startswith("pip/_vendor/") or name.endswith("/"): + continue + assert live.joinpath(name.removeprefix("pip/")).read_bytes() == archive.read(name), name + expected_files.add(name.removeprefix("pip/_vendor/")) + checked += 1 + assert not any(name.startswith("pip/_vendor/") and name.endswith((".so", ".pyd")) for name in names) + assert not any(name.startswith("pip/_vendor/setuptools/") for name in names) + assert not any(name.startswith("pip/_vendor/msgpack/_cmsgpack") for name in names) + def live_files(directory, prefix=""): + for entry in directory.iterdir(): + if entry.name == "__pycache__": + continue + relative = prefix + entry.name + if entry.is_dir(): + yield from live_files(entry, relative + "/") + else: + yield relative + assert set(live_files(live.joinpath("_vendor"))) == expected_files, "Unexpected live vendor files" + # A separate full setuptools installation cannot inherit the vendor-slice VEX. + for distribution in importlib.metadata.distributions(): + if distribution.metadata.get("Name", "").lower() == "setuptools": + assert distribution.version != "70.3.0", "A full setuptools 70.3.0 distribution is installed" + if distribution.metadata.get("Name", "").lower() == "msgpack": + assert distribution.version != "1.1.2", "A separate msgpack 1.1.2 distribution is installed" + return {"verifiedWheelSha256": EXPECTED_SHA, "pip": pip.__version__, + "urllib3": urllib3.__version__, "vendorFilesByteCompared": checked, + "msgpackImplementation": msgpack.Unpacker.__module__, + "setuptoolsPackageAbsent": True, "scope": "this exact pip vendor slice only", + "notAffected": [{"id": key, "package": value[0], "version": value[1], + "justification": "vulnerable_code_not_present", "detail": value[2]} + for key, value in FINDINGS.items()]} + + +if __name__ == "__main__": + import sys + if sys.flags.optimize: + raise RuntimeError("Security verification must not run with Python optimization") + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path) + args = parser.parse_args() + # The security proof is about the interpreter's actual selected seed, not + # an arbitrary file supplied through a CLI path. + try: + import ensurepip + except ImportError: + # compose's distro runtime intentionally has no ensurepip package. + # Its build retains the verified input at this fixed, non-user path. + result = verify(Path("/opt/python-security/pip-26.2.2+pasturestack.1-py3-none-any.whl")) + else: + with ensurepip._get_pip_whl_path_ctx() as wheel: + result = verify(Path(wheel)) + data = json.dumps(result, indent=2) + "\n" + if args.output: + args.output.write_text(data) + print(data, end="") diff --git a/scripts/pip-security/filter-pip-vendor-findings.py b/scripts/pip-security/filter-pip-vendor-findings.py new file mode 100644 index 00000000..5a7c1907 --- /dev/null +++ b/scripts/pip-security/filter-pip-vendor-findings.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Apply narrowly proven pip-vendor VEX, retaining the original Trivy report.""" +import argparse +from copy import deepcopy +from datetime import datetime, timezone +import json +from pathlib import Path + +WHEEL_SHA = "be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a" +RULES = { + "GHSA-6v7p-g79w-8964": ("msgpack", "1.1.2"), + "CVE-2026-57585": ("msgpack", "1.1.2"), + "CVE-2025-47273": ("setuptools", "70.3.0"), + "CVE-2026-59890": ("setuptools", "70.3.0"), +} + + +def apply(raw, proof, subject): + valid = (proof["verifiedWheelSha256"] == WHEEL_SHA and + proof["pip"] == "26.2.2+pasturestack.1" and proof["urllib3"] == "2.8.0" and + proof["msgpackImplementation"] == "pip._vendor.msgpack.fallback" and + proof["setuptoolsPackageAbsent"] is True and proof["vendorFilesByteCompared"] == 299) + if not valid: + raise ValueError("Exact installed pip vendor proof is required") + reasons = {item["id"]: item for item in proof["notAffected"]} + result = deepcopy(raw) + statements = [] + for section in result.get("Results", []): + retained = [] + for item in section.get("Vulnerabilities", []): + key = item.get("VulnerabilityID") + expected = RULES.get(key) + actual = (item.get("PkgName"), item.get("InstalledVersion")) + packages = [pkg for pkg in section.get("Packages", []) if + (pkg.get("Name"), pkg.get("Version")) == actual] + reason = reasons.get(key, {}) + eligible = (expected == actual and key in reasons and + (reason.get("package"), reason.get("version")) == actual and + section.get("Type") == "python-pkg" and packages and + all(pkg.get("AnalyzedBy") == "sbom" for pkg in packages)) + if not eligible: + retained.append(item) + continue + purl = "pkg:pypi/" + actual[0] + "@" + actual[1] + statements.append({ + "vulnerability": {"name": key}, + "products": [{"@id": subject, "subcomponents": [{"@id": purl}]}], + "status": "not_affected", "justification": "vulnerable_code_not_present", + "impact_statement": reasons[key]["detail"] + "; exact pip wheel SHA-256 " + WHEEL_SHA, + }) + section["Vulnerabilities"] = retained + vex = {"@context": "https://openvex.dev/ns/v0.2.0", "@id": subject + "/pip-vendor-vex", + "author": "PastureStack", "timestamp": datetime.now(timezone.utc).isoformat(), + "version": 1, "statements": statements} + return result, vex + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--raw", type=Path, required=True) + parser.add_argument("--proof", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--vex", type=Path, required=True) + parser.add_argument("--subject", required=True) + args = parser.parse_args() + report, vex = apply(json.loads(args.raw.read_text()), json.loads(args.proof.read_text()), args.subject) + args.output.write_text(json.dumps(report, indent=2) + "\n") + args.vex.write_text(json.dumps(vex, indent=2) + "\n") + print(f"PROVEN_PIP_VENDOR_VEX statements={len(vex['statements'])}") diff --git a/scripts/pip-security/inputs.json b/scripts/pip-security/inputs.json new file mode 100644 index 00000000..fc6b7de9 --- /dev/null +++ b/scripts/pip-security/inputs.json @@ -0,0 +1,188 @@ +{ + "schemaVersion": 1, + "upstreamVersion": "26.2.2", + "version": "26.2.2+pasturestack.1", + "expectedWheelSha256": "be5f63d537bbe611dc422fabb6740a44b5997888b592888df5091727f023802a", + "sourceCommit": "b3b696fe2122f2339d782517559bfade2cb6b879", + "sourceDateEpoch": 1792509744, + "updatedVendoredLibraries": { + "urllib3": "2.8.0" + }, + "artifacts": [ + { + "kind": "tool", + "name": "vendoring", + "version": "1.4.0", + "url": "https://files.pythonhosted.org/packages/e3/ba/2fe43514bcf426888101c2968f3c821c6338afc122ccbbde6e29ee2a5036/vendoring-1.4.0-py3-none-any.whl", + "sha256": "3a5b5c44bdec2683a94cd4c7c25bad556b38539cf316defca65948d76107eb30" + }, + { + "kind": "tool", + "name": "flit_core", + "version": "3.12.0", + "url": "https://files.pythonhosted.org/packages/f2/65/b6ba90634c984a4fcc02c7e3afe523fef500c4980fec67cc27536ee50acf/flit_core-3.12.0-py3-none-any.whl", + "sha256": "e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c" + }, + { + "kind": "tool", + "name": "click", + "version": "8.5.0", + "url": "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", + "sha256": "255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360" + }, + { + "kind": "tool", + "name": "jsonschema", + "version": "4.26.0", + "url": "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", + "sha256": "d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" + }, + { + "kind": "tool", + "name": "packaging", + "version": "26.3", + "url": "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", + "sha256": "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" + }, + { + "kind": "tool", + "name": "requests", + "version": "2.34.2", + "url": "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", + "sha256": "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" + }, + { + "kind": "tool", + "name": "rich", + "version": "15.0.0", + "url": "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", + "sha256": "33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" + }, + { + "kind": "tool", + "name": "attrs", + "version": "26.1.0", + "url": "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309" + }, + { + "kind": "tool", + "name": "idna", + "version": "3.20", + "url": "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", + "sha256": "ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c" + }, + { + "kind": "tool", + "name": "jsonschema-specifications", + "version": "2025.9.1", + "url": "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", + "sha256": "98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" + }, + { + "kind": "tool", + "name": "markdown-it-py", + "version": "4.2.0", + "url": "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", + "sha256": "9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" + }, + { + "kind": "tool", + "name": "Pygments", + "version": "2.21.0", + "url": "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", + "sha256": "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" + }, + { + "kind": "tool", + "name": "referencing", + "version": "0.37.0", + "url": "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", + "sha256": "381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" + }, + { + "kind": "tool", + "name": "urllib3", + "version": "2.8.0", + "url": "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", + "sha256": "0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3" + }, + { + "kind": "tool", + "name": "mdurl", + "version": "0.1.2", + "url": "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", + "sha256": "84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" + }, + { + "kind": "tool", + "name": "typing_extensions", + "version": "4.16.0", + "url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", + "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" + }, + { + "kind": "tool", + "name": "tomli", + "version": "2.5.0", + "url": "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", + "sha256": "32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b" + }, + { + "kind": "tool", + "name": "charset-normalizer", + "version": "3.5.2", + "url": "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", + "sha256": "b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685" + }, + { + "kind": "tool", + "name": "certifi", + "version": "2026.10.19", + "url": "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", + "sha256": "42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b" + }, + { + "kind": "tool", + "name": "rpds-py", + "version": "2026.9.1", + "url": "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "sha256": "2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a" + }, + { + "kind": "tool", + "name": "rpds-py", + "version": "2026.9.1", + "url": "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab" + }, + { + "kind": "tool", + "name": "rpds-py", + "version": "2026.9.1", + "url": "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + "sha256": "07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0" + }, + { + "kind": "tool", + "name": "rpds-py", + "version": "2026.9.1", + "url": "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd" + }, + { + "kind": "source", + "name": "pip-source", + "version": "26.2.2", + "url": "https://github.com/pypa/pip/archive/b3b696fe2122f2339d782517559bfade2cb6b879.tar.gz", + "sha256": "5202e6f34650ed74b8cfc74a4808af45a46ebd994b187e160a92d426e1bb3e0f" + }, + { + "kind": "tool", + "name": "pip", + "version": "26.2.2", + "url": "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl", + "sha256": "fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70" + } + ] +} diff --git a/scripts/pip-security/test-pip-overlay.py b/scripts/pip-security/test-pip-overlay.py new file mode 100644 index 00000000..a2c62651 --- /dev/null +++ b/scripts/pip-security/test-pip-overlay.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Focused streaming regression, CLI and TLS controls for the installed wheel.""" +import argparse +import contextlib +import http.client +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import io +import json +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading + +import pip +from pip._internal.network.utils import response_chunks +from pip._vendor import msgpack, requests, urllib3 +from pip._vendor.urllib3.exceptions import ProtocolError + + +class TrackedBody(io.BytesIO): + largest_readline = 0 + def readline(self, size=-1): + data = super().readline(size) + self.largest_readline = max(self.largest_readline, len(data)) + return data + + +class Socket: + def __init__(self, body): + self.body = body + def makefile(self, *_): + return self.body + + +def streamed(body): + wire = TrackedBody(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + body) + origin = http.client.HTTPResponse(Socket(wire), method="GET") + origin.begin() + response = requests.Response() + response.status_code = 200 + response.raw = urllib3.response.HTTPResponse( + body=origin, original_response=origin, headers=dict(origin.headers.items()), + preload_content=False) + return response, wire + + +def boundary_tests(expect_fixed): + outcome = [] + for label, body in ( + ("chunk-size", b"f" * 131072), + ("chunk-trailer", b"3\r\nfoo\r\n0\r\n" + b"X" * 131072), + ): + response, wire = streamed(body) + error_description = "no exception" + try: + list(response_chunks(response)) + except Exception as error: + error_description = repr(error) + if expect_fixed: + assert isinstance(error, ProtocolError), repr(error) + assert "exceeded maximum allowed length" in str(error), str(error) + if expect_fixed: + assert error_description != "no exception", "malicious chunk line was accepted" + assert wire.largest_readline <= 65537, wire.largest_readline + else: + assert wire.largest_readline == 131072, (wire.largest_readline, error_description) + outcome.append({"path": label, "max_readline": wire.largest_readline}) + for body in (b"3\r\nfoo\r\n0\r\n\r\n", b"3;" + b"x" * 65532 + b"\r\nfoo\r\n0\r\n\r\n"): + response, _ = streamed(body) + assert b"".join(response_chunks(response)) == b"foo" + return outcome + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--expect-vulnerable", action="store_true") + args = parser.parse_args() + result = {"pip": pip.__version__, "urllib3": urllib3.__version__, + "streaming": boundary_tests(not args.expect_vulnerable)} + if args.expect_vulnerable: + print(json.dumps(result)); return + assert pip.__version__ == "26.2.2+pasturestack.1" + assert urllib3.__version__ == "2.8.0" + assert msgpack.Unpacker.__module__ == "pip._vendor.msgpack.fallback" + import importlib.util + assert importlib.util.find_spec("pip._vendor.msgpack._cmsgpack") is None + unpacker = msgpack.Unpacker() + unpacker.feed(b"\x83\x73\xc4\x00") + try: + unpacker.unpack() + except (ValueError, msgpack.OutOfData): + pass + try: + unpacker.skip() + except (ValueError, msgpack.OutOfData): + pass + with tempfile.TemporaryDirectory(prefix="pip-tls-") as name: + root = Path(name) + cert, key = root / "cert.pem", root / "key.pem" + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-days", "1", "-subj", "/CN=localhost", "-addext", + "subjectAltName=DNS:localhost", "-keyout", str(key), "-out", str(cert)], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + body = b"trusted fixture" + self.send_response(200); self.send_header("Content-Length", str(len(body))) + self.end_headers(); self.wfile.write(body) + def log_message(self, *_): + pass + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(cert, key) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + try: + port = server.server_address[1] + session = requests.Session(); session.trust_env = False + assert session.get(f"https://localhost:{port}/", verify=str(cert), timeout=5).status_code == 200 + for url, verify in ((f"https://localhost:{port}/", True), + (f"https://127.0.0.1:{port}/", str(cert))): + try: + session.get(url, verify=verify, timeout=5) + except requests.exceptions.SSLError: + pass + else: + raise AssertionError("untrusted or wrong-host TLS was accepted") + finally: + server.shutdown(); server.server_close(); thread.join() + subprocess.run([sys.executable, "-m", "pip", "--version"], check=True) + subprocess.run([sys.executable, "-m", "pip", "check"], check=True) + result.update(tls_trusted="accepted", tls_untrusted="rejected", tls_wrong_host="rejected", + msgpack_implementation=msgpack.Unpacker.__module__, cli="passed") + print(json.dumps(result)) + + +if __name__ == "__main__": + if sys.flags.optimize: + raise RuntimeError("Security regression tests must not run with Python optimization") + main() diff --git a/scripts/pip-security/test-pip-vex.py b/scripts/pip-security/test-pip-vex.py new file mode 100644 index 00000000..9baa3e1c --- /dev/null +++ b/scripts/pip-security/test-pip-vex.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Negative controls for the exact pip-vendor VEX boundary.""" +from copy import deepcopy +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location("pip_vex", Path(__file__).with_name("filter-pip-vendor-findings.py")) +vex = importlib.util.module_from_spec(spec) +spec.loader.exec_module(vex) + + +class VexBoundary(unittest.TestCase): + def setUp(self): + self.proof = {"verifiedWheelSha256": vex.WHEEL_SHA, "pip": "26.2.2+pasturestack.1", + "urllib3": "2.8.0", "msgpackImplementation": "pip._vendor.msgpack.fallback", + "setuptoolsPackageAbsent": True, "vendorFilesByteCompared": 299, + "notAffected": [{"id": key, "package": value[0], "version": value[1], + "detail": "Test fixture only"} for key, value in vex.RULES.items()]} + self.raw = {"Results": [{"Type": "python-pkg", "Packages": [ + {"Name": name, "Version": version, "AnalyzedBy": "sbom"} + for name, version in set(vex.RULES.values())], "Vulnerabilities": [ + {"VulnerabilityID": key, "PkgName": value[0], "InstalledVersion": value[1]} + for key, value in vex.RULES.items()]}]} + + def test_exact_slice_only(self): + original = deepcopy(self.raw) + result, document = vex.apply(self.raw, self.proof, "urn:test:exact-image") + self.assertEqual(result["Results"][0]["Vulnerabilities"], []) + self.assertEqual(len(document["statements"]), 4) + self.assertEqual(self.raw, original, "raw evidence must not be mutated") + + def test_invalid_proof_fails_closed(self): + for key, value in [("verifiedWheelSha256", "0" * 64), ("urllib3", "2.7.0"), + ("vendorFilesByteCompared", 300), ("setuptoolsPackageAbsent", False)]: + proof = dict(self.proof, **{key: value}) + with self.subTest(key=key), self.assertRaises(ValueError): + vex.apply(self.raw, proof, "urn:test:image") + + def test_full_distribution_is_not_excluded(self): + for package in self.raw["Results"][0]["Packages"]: + package["AnalyzedBy"] = "python-pkg" + result, document = vex.apply(self.raw, self.proof, "urn:test:image") + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + self.assertEqual(document["statements"], []) + + def test_missing_packages_is_not_excluded(self): + self.raw["Results"][0]["Packages"] = [] + result, _ = vex.apply(self.raw, self.proof, "urn:test:image") + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + + def test_wrong_version_is_not_excluded(self): + for finding in self.raw["Results"][0]["Vulnerabilities"]: + finding["InstalledVersion"] = "999.0" + result, document = vex.apply(self.raw, self.proof, "urn:test:image") + self.assertEqual(len(result["Results"][0]["Vulnerabilities"]), 4) + self.assertEqual(document["statements"], []) + + def test_unknown_advisory_and_urllib3_are_retained(self): + unknown = {"VulnerabilityID": "CVE-TEST-UNKNOWN", "PkgName": "setuptools", "InstalledVersion": "70.3.0"} + urllib = {"VulnerabilityID": "CVE-2026-97689", "PkgName": "urllib3", "InstalledVersion": "2.7.0"} + self.raw["Results"][0]["Vulnerabilities"] += [unknown, urllib] + result, _ = vex.apply(self.raw, self.proof, "urn:test:image") + self.assertEqual(result["Results"][0]["Vulnerabilities"], [unknown, urllib]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/version b/scripts/version index bc4a1e5b..270e8d9b 100755 --- a/scripts/version +++ b/scripts/version @@ -5,4 +5,4 @@ if [ -n "$VERSION_OVERRIDE" ]; then return 0 fi -VERSION=v0.13.31 +VERSION=v0.13.32 diff --git a/scripts/windows-ci/InstallPackage.ps1 b/scripts/windows-ci/InstallPackage.ps1 index 675a154a..02dc0407 100755 --- a/scripts/windows-ci/InstallPackage.ps1 +++ b/scripts/windows-ci/InstallPackage.ps1 @@ -1,3 +1,3 @@ -go get github.com/golang/lint/golint +go get github.com/golang/lint/golint Move-Item -Path $env:GOPATH/bin/golint.exe -Destination C:/Go/bin/golint.exe \ No newline at end of file diff --git a/scripts/windows-ci/build.ps1 b/scripts/windows-ci/build.ps1 index 29489946..60d611b9 100755 --- a/scripts/windows-ci/build.ps1 +++ b/scripts/windows-ci/build.ps1 @@ -1,4 +1,4 @@ -Write-Host "Building" +Write-Host "Building" cd $PSScriptRoot/../.. diff --git a/scripts/windows-ci/ci.ps1 b/scripts/windows-ci/ci.ps1 index 6a837201..e753b1da 100755 --- a/scripts/windows-ci/ci.ps1 +++ b/scripts/windows-ci/ci.ps1 @@ -1,4 +1,4 @@ -Write-Host "Running CI" +Write-Host "Running CI" cd $PSScriptRoot diff --git a/scripts/windows-ci/package.ps1 b/scripts/windows-ci/package.ps1 index b12208e0..efe22243 100755 --- a/scripts/windows-ci/package.ps1 +++ b/scripts/windows-ci/package.ps1 @@ -1,4 +1,4 @@ -Write-Host "Packaging" +Write-Host "Packaging" cd $PSScriptRoot/../.. diff --git a/service/hostapilauncher/process_linux_test.go b/service/hostapilauncher/process_linux_test.go index 2da65b30..59bfdc82 100644 --- a/service/hostapilauncher/process_linux_test.go +++ b/service/hostapilauncher/process_linux_test.go @@ -19,7 +19,7 @@ func TestProducerIsBoundToLinuxParentDeathAndRejectsUnsafeExecutable(t *testing. } directory := t.TempDir() file := filepath.Join(directory, "host-api") - if err := os.WriteFile(file, []byte("#!/bin/sh\nprintf '0.38.7\\n'\n"), 0755); err != nil { + if err := os.WriteFile(file, []byte("#!/bin/sh\nprintf '0.38.8\\n'\n"), 0755); err != nil { t.Fatal(err) } if err := validateProducer(context.Background(), file, os.Environ()); err != nil { diff --git a/service/hostapilauncher/supervisor.go b/service/hostapilauncher/supervisor.go index a11beb3f..26fee6ea 100644 --- a/service/hostapilauncher/supervisor.go +++ b/service/hostapilauncher/supervisor.go @@ -17,7 +17,7 @@ import ( "github.com/rancher/log" ) -const RequiredVersion = "0.38.7" +const RequiredVersion = "0.38.8" type Settings struct { Binary string diff --git a/service/hostapilauncher/supervisor_test.go b/service/hostapilauncher/supervisor_test.go index b86ef5cb..4ee1f671 100644 --- a/service/hostapilauncher/supervisor_test.go +++ b/service/hostapilauncher/supervisor_test.go @@ -9,12 +9,12 @@ import ( ) func TestProducerVersionIsExactAndBounded(t *testing.T) { - for _, version := range []string{"0.38.7", "0.38.7\n"} { + for _, version := range []string{"0.38.8", "0.38.8\n"} { if !validVersion(version) { t.Fatal("current producer rejected") } } - for _, version := range []string{"0.38.4", "0.38.5", "0.38.6", "0.38.8", "0.39.0", "0.038.7", "v0.38.7", "0.38.7-dirty", "0.38.7\r\n", "0.38.7\nextra", "0.38.7\n0.38.7", ""} { + for _, version := range []string{"0.38.4", "0.38.5", "0.38.6", "0.38.7", "0.38.9", "0.39.0", "0.038.8", "v0.38.8", "0.38.8-dirty", "0.38.8\r\n", "0.38.8\nextra", "0.38.8\n0.38.8", ""} { if validVersion(version) { t.Fatal("incompatible producer accepted") } diff --git a/tests/requirements.lock b/tests/requirements.lock index 5bee17a8..b5b2596e 100644 --- a/tests/requirements.lock +++ b/tests/requirements.lock @@ -1,5 +1,5 @@ arrow==1.4.0 -certifi==2026.7.22 +certifi==2026.10.19 charset-normalizer==3.5.1 docker==7.2.0 flake8==7.3.0 @@ -7,7 +7,7 @@ idna==3.19 iniconfig==2.3.0 mccabe==0.7.0 packaging==26.3 -pip==26.2.1 +pip==26.2.2 pluggy==1.6.0 pycodestyle==2.14.0 pyflakes==3.4.0 diff --git a/third_party/event-subscriber/go.mod b/third_party/event-subscriber/go.mod index 66c9fd71..63924326 100644 --- a/third_party/event-subscriber/go.mod +++ b/third_party/event-subscriber/go.mod @@ -2,7 +2,7 @@ module github.com/rancher/event-subscriber go 1.26.0 -toolchain go1.27.0 +toolchain go1.27.3 require ( github.com/gorilla/websocket v1.5.3 diff --git a/third_party/go-rancher/go.mod b/third_party/go-rancher/go.mod index 980a24b4..52a5d4c8 100644 --- a/third_party/go-rancher/go.mod +++ b/third_party/go-rancher/go.mod @@ -2,7 +2,7 @@ module github.com/rancher/go-rancher go 1.26.0 -toolchain go1.27.0 +toolchain go1.27.3 require ( github.com/gorilla/websocket v1.5.3 diff --git a/third_party/log/go.mod b/third_party/log/go.mod index 2cf6803d..9c3c63d7 100644 --- a/third_party/log/go.mod +++ b/third_party/log/go.mod @@ -2,7 +2,7 @@ module github.com/rancher/log go 1.26.0 -toolchain go1.27.0 +toolchain go1.27.3 require github.com/sirupsen/logrus v1.10.2