From 58d5f650ab04df6788666831c58ce10dac58cb07 Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:30:29 -0500 Subject: [PATCH 1/2] docs: record component accountability and lifecycle procedures Bind all 79 locked runtime RPMs to their license tag, source RPM, publisher and redistribution policy, lifecycle boundary, and named update owner, and check that inventory against every acquired RPM in native CI. Define reviewed lock refresh, signing-key rotation and revocation, immutable mirroring, image rollback, and disconnected transfer, with a schema and fail-closed manifest binding a payload to its repository revision, architecture lock, and component inventory. Record the rootless runtime and lifecycle contract, and strengthen the smoke suite to require precise mounted-configuration and unwritable-path diagnostics, worker-replacing reloads that retain PID 1, complete active-request draining on SIGQUIT, the exact embedded 79-RPM manifest, and the reviewed NGINX compile-feature and empty dynamic-module inventories. --- .github/workflows/ci.yml | 13 +- CHANGELOG.md | 16 ++ README.md | 17 +- THIRD_PARTY_NOTICES.md | 5 + artifacts/components.json | 116 +++++++++++ artifacts/nginx-features.json | 39 ++++ artifacts/transfer-manifest.schema.json | 36 ++++ docs/ARTIFACT-ACQUISITION.md | 18 +- docs/ARTIFACT-LIFECYCLE.md | 236 ++++++++++++++++++++++ docs/CI.md | 28 ++- docs/COMPONENT-OWNERSHIP.md | 70 +++++++ docs/ROADMAP.md | 27 +-- docs/RUNTIME-CONTRACT.md | 86 ++++++++ scripts/components.py | 206 +++++++++++++++++++ scripts/nginx_features.py | 127 ++++++++++++ scripts/transfer.py | 254 ++++++++++++++++++++++++ tests/fixtures/graceful-nginx.conf | 30 +++ tests/fixtures/invalid-nginx.conf | 1 + tests/smoke.sh | 161 ++++++++++++++- tests/test_components.py | 79 ++++++++ tests/test_nginx_features.py | 59 ++++++ tests/test_transfer.py | 105 ++++++++++ 22 files changed, 1684 insertions(+), 45 deletions(-) create mode 100644 artifacts/components.json create mode 100644 artifacts/nginx-features.json create mode 100644 artifacts/transfer-manifest.schema.json create mode 100644 docs/ARTIFACT-LIFECYCLE.md create mode 100644 docs/COMPONENT-OWNERSHIP.md create mode 100644 docs/RUNTIME-CONTRACT.md create mode 100644 scripts/components.py create mode 100644 scripts/nginx_features.py create mode 100644 scripts/transfer.py create mode 100644 tests/fixtures/graceful-nginx.conf create mode 100644 tests/fixtures/invalid-nginx.conf create mode 100644 tests/test_components.py create mode 100644 tests/test_nginx_features.py create mode 100644 tests/test_transfer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c593ef..8502537 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,11 @@ jobs: - name: Run repository checks run: pre-commit run --all-files --show-diff-on-failure - - name: Validate artifact locks - run: python -m unittest tests.test_artifacts -v + - name: Validate artifact locks and component accountability + run: >- + python -m unittest + tests.test_artifacts tests.test_components tests.test_nginx_features + tests.test_transfer -v - name: Audit GitHub Actions security uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 @@ -122,6 +125,12 @@ jobs: artifacts/locks/${{ matrix.architecture }}.json .artifact-bundle/${{ matrix.architecture }} + - name: Verify runtime component metadata + run: >- + python scripts/components.py + --lock artifacts/locks/${{ matrix.architecture }}.json + --bundle .artifact-bundle/${{ matrix.architecture }} + - name: Reject invalid RPM bundles run: >- python tests/rpm-bundle-negative.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7903fc9..c0bd384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,22 @@ but container releases use the upstream-derived format documented in bound production lock validation to the reviewed base images, NGINX seed, and signing-key inputs; and required filenames to agree with RPM metadata and official source URLs. +- Added a lock-bound accountability inventory for all 79 runtime RPMs with + exact license and source-RPM metadata, publisher and redistribution policy, + lifecycle boundary, and named update ownership; native CI now checks the + recorded metadata against every acquired AMD64 and ARM64 RPM. +- Defined reviewed lock refresh, signing-key rotation and revocation, immutable + artifact mirroring, image rollback, and disconnected-transfer procedures; + added a schema and fail-closed transfer manifest that binds payload hashes to + the repository revision, architecture lock, and component inventory. +- Embedded the exact verified 79-RPM manifest in the package-manager-free final + image; added a reviewed inventory for 22 optional NGINX compile-time modules + and features; and made native Podman plus Docker compatibility tests reject + package, module, or NGINX build drift. +- Strengthened rootless failure and lifecycle tests to require precise mounted + configuration and unwritable-temporary-path diagnostics, worker-replacing + reloads with PID 1 retained, and complete active-request draining on + `SIGQUIT` before a clean exit. - Expanded logging guidance with a field-by-field explanation of `$request`, a sensitive ClickHouse example, and safer variable choices. - Defined a source-independent pipeline contract that downloads and verifies diff --git a/README.md b/README.md index 659dee8..16f766d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ root filesystem, explicit `tmpfs` mounts, dropped capabilities, and - [External artifact acquisition](docs/ARTIFACT-ACQUISITION.md) defines the pre-build download and verification process and hermetic image assembly contract. +- [Artifact lifecycle and controlled transfer](docs/ARTIFACT-LIFECYCLE.md) + defines lock refresh, signing-key rotation, immutable mirrors, rollback, and + disconnected transfer and verification. +- [Runtime component accountability](docs/COMPONENT-OWNERSHIP.md) binds every + locked runtime RPM to its source, license metadata, redistribution and + lifecycle policy, and named update owner. +- [Rootless runtime and lifecycle contract](docs/RUNTIME-CONTRACT.md) records + startup diagnostics, reload and graceful-stop behavior, and the enforced RPM + and NGINX module inventories. TLS, configuration, architecture, control-matrix/OSCAL, SCAP, vulnerability-management, and disconnected-network guides will be added as @@ -152,10 +161,14 @@ python -m pip install --require-hashes --only-binary=:all: \ pre-commit run --all-files --show-diff-on-failure ``` -Validate the reviewed artifact locks and their negative cases with: +Validate the reviewed artifact locks, component inventory, and their negative +cases with: ```console -python -m unittest tests.test_artifacts -v +python -m unittest \ + tests.test_artifacts tests.test_components tests.test_nginx_features \ + tests.test_transfer -v +python scripts/components.py ``` Acquire and verify the exact AMD64 RPM bundle from the official sources: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 4e2a8b5..19505e3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -26,6 +26,11 @@ describes the principal packaging and NGINX license relationship; consumers must also review the SBOM, embedded notices, UBI terms, and every component's license. +The machine-validated +[runtime component inventory](docs/COMPONENT-OWNERSHIP.md) records the exact +RPM license tag and source RPM for every locked binary package. RPM license +tags are upstream metadata rather than a project-authored legal conclusion. + ## Release review Before publishing a release: diff --git a/artifacts/components.json b/artifacts/components.json new file mode 100644 index 0000000..511fea8 --- /dev/null +++ b/artifacts/components.json @@ -0,0 +1,116 @@ +{ + "schema_version": 1, + "locks": { + "amd64": { + "path": "artifacts/locks/amd64.json", + "sha256": "864428143fb11dcf5bedcf2abf6df950fd557c37650410b2f28c56cbd36ae0d5" + }, + "arm64": { + "path": "artifacts/locks/arm64.json", + "sha256": "213747506323650d82d8dd8db946581ad002071d3c61f2a070aa743b88554b13" + } + }, + "policies": [ + { + "id": "nginx-stable", + "publisher": "F5, Inc. / NGINX", + "component_source": "https://nginx.org/packages/rhel/9/", + "redistribution_terms": "https://nginx.org/en/docs/faq/license_copyright.html", + "license_reference": "https://nginx.org/LICENSE", + "support_lifecycle": "The open-source stable channel has no fixed support term identified by this project; Datopsis maintainers monitor upstream releases and advisories and own update decisions.", + "update_owner": "Datopsis maintainers", + "rpm_vendor": "NGINX Packaging " + }, + { + "id": "redhat-ubi9", + "publisher": "Red Hat, Inc.", + "component_source": "https://cdn-ubi.redhat.com/content/public/ubi/dist/ubi9/", + "redistribution_terms": "https://cdn-ubi.redhat.com/content/public/ubi/EULA.html", + "license_reference": "https://developers.redhat.com/articles/ubi-faq", + "support_lifecycle": "UBI content follows the RHEL lifecycle; Red Hat support depends on an eligible subscription and supported deployment, while Datopsis maintainers own image update decisions.", + "update_owner": "Datopsis maintainers", + "rpm_vendor": "Red Hat, Inc." + } + ], + "components": [ + {"name": "acl", "source_rpm": "acl-2.4.0-1.el9_8.src.rpm", "license": "GPLv2+", "policy": "redhat-ubi9"}, + {"name": "alternatives", "source_rpm": "chkconfig-1.24-2.el9.src.rpm", "license": "GPL-2.0-only", "policy": "redhat-ubi9"}, + {"name": "audit-libs", "source_rpm": "audit-3.1.5-8.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "basesystem", "source_rpm": "basesystem-11-13.el9.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "bash", "source_rpm": "bash-5.1.8-9.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "bzip2-libs", "source_rpm": "bzip2-1.0.8-11.el9.src.rpm", "license": "BSD", "policy": "redhat-ubi9"}, + {"name": "ca-certificates", "source_rpm": "ca-certificates-2025.2.80_v9.0.305-91.el9.src.rpm", "license": "MIT AND GPL-2.0-or-later", "policy": "redhat-ubi9"}, + {"name": "coreutils", "source_rpm": "coreutils-8.32-41.el9_8.1.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "coreutils-common", "source_rpm": "coreutils-8.32-41.el9_8.1.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "cracklib", "source_rpm": "cracklib-2.9.6-28.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "cracklib-dicts", "source_rpm": "cracklib-2.9.6-28.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "crypto-policies", "source_rpm": "crypto-policies-20260224-1.gitea0f072.el9_8.src.rpm", "license": "LGPL-2.1-or-later", "policy": "redhat-ubi9"}, + {"name": "dbus", "source_rpm": "dbus-1.12.20-8.el9.src.rpm", "license": "(GPLv2+ or AFL) and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "dbus-broker", "source_rpm": "dbus-broker-28-9.el9_8.src.rpm", "license": "ASL 2.0", "policy": "redhat-ubi9"}, + {"name": "dbus-common", "source_rpm": "dbus-1.12.20-8.el9.src.rpm", "license": "(GPLv2+ or AFL) and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "expat", "source_rpm": "expat-2.5.0-6.el9_8.3.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, + {"name": "filesystem", "source_rpm": "filesystem-3.16-5.el9.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "findutils", "source_rpm": "findutils-4.8.0-7.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "glibc", "source_rpm": "glibc-2.34-275.el9_8.src.rpm", "license": "LGPL-2.1-or-later AND SunPro AND LGPL-2.1-or-later WITH GCC-exception-2.0 AND BSD-3-Clause AND GPL-2.0-or-later AND LGPL-2.1-or-later WITH GNU-compiler-exception AND GPL-2.0-only AND ISC AND LicenseRef-Fedora-Public-Domain AND HPND AND CMU-Mach AND LGPL-2.0-or-later AND Unicode-3.0 AND GFDL-1.1-or-later AND GPL-1.0-or-later AND FSFUL AND MIT AND Inner-Net-2.0 AND X11 AND GPL-2.0-or-later WITH GCC-exception-2.0 AND GFDL-1.3-only AND GFDL-1.1-only AND GPL-3.0-or-later AND GPL-3.0-or-later WITH Autoconf-exception-generic-3.0 AND GPL-3.0-or-later WITH Texinfo-exception", "policy": "redhat-ubi9"}, + {"name": "glibc-common", "source_rpm": "glibc-2.34-275.el9_8.src.rpm", "license": "LGPL-2.1-or-later AND SunPro AND LGPL-2.1-or-later WITH GCC-exception-2.0 AND BSD-3-Clause AND GPL-2.0-or-later AND LGPL-2.1-or-later WITH GNU-compiler-exception AND GPL-2.0-only AND ISC AND LicenseRef-Fedora-Public-Domain AND HPND AND CMU-Mach AND LGPL-2.0-or-later AND Unicode-3.0 AND GFDL-1.1-or-later AND GPL-1.0-or-later AND FSFUL AND MIT AND Inner-Net-2.0 AND X11 AND GPL-2.0-or-later WITH GCC-exception-2.0 AND GFDL-1.3-only AND GFDL-1.1-only AND GPL-3.0-or-later AND GPL-3.0-or-later WITH Autoconf-exception-generic-3.0 AND GPL-3.0-or-later WITH Texinfo-exception", "policy": "redhat-ubi9"}, + {"name": "glibc-minimal-langpack", "source_rpm": "glibc-2.34-275.el9_8.src.rpm", "license": "LGPL-2.1-or-later AND SunPro AND LGPL-2.1-or-later WITH GCC-exception-2.0 AND BSD-3-Clause AND GPL-2.0-or-later AND LGPL-2.1-or-later WITH GNU-compiler-exception AND GPL-2.0-only AND ISC AND LicenseRef-Fedora-Public-Domain AND HPND AND CMU-Mach AND LGPL-2.0-or-later AND Unicode-3.0 AND GFDL-1.1-or-later AND GPL-1.0-or-later AND FSFUL AND MIT AND Inner-Net-2.0 AND X11 AND GPL-2.0-or-later WITH GCC-exception-2.0 AND GFDL-1.3-only AND GFDL-1.1-only AND GPL-3.0-or-later AND GPL-3.0-or-later WITH Autoconf-exception-generic-3.0 AND GPL-3.0-or-later WITH Texinfo-exception", "policy": "redhat-ubi9"}, + {"name": "gmp", "source_rpm": "gmp-6.2.0-13.el9.src.rpm", "license": "LGPLv3+ or GPLv2+", "policy": "redhat-ubi9"}, + {"name": "grep", "source_rpm": "grep-3.6-5.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "gzip", "source_rpm": "gzip-1.12-2.el9_8.src.rpm", "license": "GPLv3+ and GFDL", "policy": "redhat-ubi9"}, + {"name": "kmod-libs", "source_rpm": "kmod-28-11.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libacl", "source_rpm": "acl-2.4.0-1.el9_8.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libattr", "source_rpm": "attr-2.6.0-1.el9_8.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libblkid", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libcap", "source_rpm": "libcap-2.48-10.el9_8.1.src.rpm", "license": "BSD or GPLv2", "policy": "redhat-ubi9"}, + {"name": "libcap-ng", "source_rpm": "libcap-ng-0.8.2-7.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libdb", "source_rpm": "libdb-5.3.28-57.el9_6.src.rpm", "license": "BSD and LGPLv2 and Sleepycat and MIT", "policy": "redhat-ubi9"}, + {"name": "libeconf", "source_rpm": "libeconf-0.4.1-7.el9_8.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, + {"name": "libfdisk", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libffi", "source_rpm": "libffi-3.4.2-8.el9.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, + {"name": "libgcc", "source_rpm": "gcc-11.5.0-14.el9.src.rpm", "license": "GPLv3+ and GPLv3+ with exceptions and GPLv2+ with exceptions and LGPLv2+ and BSD", "policy": "redhat-ubi9"}, + {"name": "libgcrypt", "source_rpm": "libgcrypt-1.10.0-13.el9_8.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libgpg-error", "source_rpm": "libgpg-error-1.42-5.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libmount", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libpwquality", "source_rpm": "libpwquality-1.4.4-8.el9.src.rpm", "license": "BSD or GPLv2+", "policy": "redhat-ubi9"}, + {"name": "libseccomp", "source_rpm": "libseccomp-2.5.2-2.el9.src.rpm", "license": "LGPLv2", "policy": "redhat-ubi9"}, + {"name": "libselinux", "source_rpm": "libselinux-3.6-3.el9.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "libsemanage", "source_rpm": "libsemanage-3.6-5.el9_6.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libsepol", "source_rpm": "libsepol-3.6-3.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libsigsegv", "source_rpm": "libsigsegv-2.13-4.el9.src.rpm", "license": "GPLv2+", "policy": "redhat-ubi9"}, + {"name": "libsmartcols", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libtasn1", "source_rpm": "libtasn1-4.16.0-10.el9_8.src.rpm", "license": "GPLv3+ and LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libutempter", "source_rpm": "libutempter-1.2.1-6.el9.src.rpm", "license": "LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "libuuid", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "BSD", "policy": "redhat-ubi9"}, + {"name": "libxcrypt", "source_rpm": "libxcrypt-4.4.18-3.el9.src.rpm", "license": "LGPLv2+ and BSD and Public Domain", "policy": "redhat-ubi9"}, + {"name": "libzstd", "source_rpm": "zstd-1.5.5-1.el9.src.rpm", "license": "BSD and GPLv2", "policy": "redhat-ubi9"}, + {"name": "lz4-libs", "source_rpm": "lz4-1.9.3-5.el9.src.rpm", "license": "GPLv2+ and BSD", "policy": "redhat-ubi9"}, + {"name": "ncurses-base", "source_rpm": "ncurses-6.2-12.20210508.el9.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, + {"name": "ncurses-libs", "source_rpm": "ncurses-6.2-12.20210508.el9.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, + {"name": "nginx", "source_rpm": "nginx-1.30.4-1.el9.ngx.src.rpm", "license": "2-clause BSD-like license", "policy": "nginx-stable"}, + {"name": "openssl", "source_rpm": "openssl-3.5.5-6.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, + {"name": "openssl-fips-provider", "source_rpm": "openssl-fips-provider-3.0.7-11.el9_8.src.rpm", "license": "ASL 2.0", "policy": "redhat-ubi9"}, + {"name": "openssl-fips-provider-so", "source_rpm": "openssl-fips-provider-3.0.7-11.el9_8.src.rpm", "license": "ASL 2.0", "policy": "redhat-ubi9"}, + {"name": "openssl-libs", "source_rpm": "openssl-3.5.5-6.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, + {"name": "p11-kit", "source_rpm": "p11-kit-0.26.4-1.el9_8.src.rpm", "license": "BSD-3-Clause", "policy": "redhat-ubi9"}, + {"name": "p11-kit-trust", "source_rpm": "p11-kit-0.26.4-1.el9_8.src.rpm", "license": "BSD-3-Clause", "policy": "redhat-ubi9"}, + {"name": "pam", "source_rpm": "pam-1.5.1-28.el9_8.1.src.rpm", "license": "BSD and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "pcre", "source_rpm": "pcre-8.44-4.el9.src.rpm", "license": "BSD", "policy": "redhat-ubi9"}, + {"name": "pcre2", "source_rpm": "pcre2-10.40-6.el9.src.rpm", "license": "BSD", "policy": "redhat-ubi9"}, + {"name": "pcre2-syntax", "source_rpm": "pcre2-10.40-6.el9.src.rpm", "license": "BSD", "policy": "redhat-ubi9"}, + {"name": "procps-ng", "source_rpm": "procps-ng-3.3.17-14.el9.src.rpm", "license": "GPL+ and GPLv2 and GPLv2+ and GPLv3+ and LGPLv2+", "policy": "redhat-ubi9"}, + {"name": "readline", "source_rpm": "readline-8.1-4.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "redhat-release", "source_rpm": "redhat-release-9.8-1.0.el9.src.rpm", "license": "GPLv2", "policy": "redhat-ubi9"}, + {"name": "sed", "source_rpm": "sed-4.8-10.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, + {"name": "setup", "source_rpm": "setup-2.13.7-10.el9.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "shadow-utils", "source_rpm": "shadow-utils-4.9-16.el9.src.rpm", "license": "BSD and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd-libs", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT", "policy": "redhat-ubi9"}, + {"name": "systemd-pam", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd-rpm-macros", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "tzdata", "source_rpm": "tzdata-2026c-1.el9_8.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "util-linux", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "GPLv2 and GPLv2+ and LGPLv2+ and BSD with advertising and Public Domain", "policy": "redhat-ubi9"}, + {"name": "util-linux-core", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "GPLv2 and GPLv2+ and LGPLv2+ and BSD with advertising and Public Domain", "policy": "redhat-ubi9"}, + {"name": "xz-libs", "source_rpm": "xz-5.2.5-8.el9_0.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, + {"name": "zlib", "source_rpm": "zlib-1.2.11-40.el9.src.rpm", "license": "zlib and Boost", "policy": "redhat-ubi9"} + ] +} diff --git a/artifacts/nginx-features.json b/artifacts/nginx-features.json new file mode 100644 index 0000000..d2c6e09 --- /dev/null +++ b/artifacts/nginx-features.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "nginx_version": "1.30.4", + "nginx_rpm_version": "1.30.4-1.el9.ngx", + "compiled_features": [ + "--with-compat", + "--with-file-aio", + "--with-threads" + ], + "compiled_optional_modules": [ + "--with-http_addition_module", + "--with-http_auth_request_module", + "--with-http_dav_module", + "--with-http_flv_module", + "--with-http_gunzip_module", + "--with-http_gzip_static_module", + "--with-http_mp4_module", + "--with-http_random_index_module", + "--with-http_realip_module", + "--with-http_secure_link_module", + "--with-http_slice_module", + "--with-http_ssl_module", + "--with-http_stub_status_module", + "--with-http_sub_module", + "--with-http_v2_module", + "--with-http_v3_module", + "--with-mail", + "--with-mail_ssl_module", + "--with-stream", + "--with-stream_realip_module", + "--with-stream_ssl_module", + "--with-stream_ssl_preread_module" + ], + "dynamic_module_files": [], + "compiled_but_unsupported_subsystems": [ + "mail", + "stream" + ] +} diff --git a/artifacts/transfer-manifest.schema.json b/artifacts/transfer-manifest.schema.json new file mode 100644 index 0000000..cda3fd5 --- /dev/null +++ b/artifacts/transfer-manifest.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/datopsis/nginx-ubi/blob/main/artifacts/transfer-manifest.schema.json", + "title": "nginx-ubi disconnected transfer manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "architecture", + "repository_revision", + "artifact_lock_sha256", + "component_inventory_sha256", + "files" + ], + "properties": { + "schema_version": {"const": 1}, + "architecture": {"enum": ["amd64", "arm64"]}, + "repository_revision": {"type": "string", "pattern": "^[a-f0-9]{40}([a-f0-9]{24})?$"}, + "artifact_lock_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "component_inventory_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "files": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "size", "sha256"], + "properties": { + "path": {"type": "string", "minLength": 1}, + "size": {"type": "integer", "minimum": 0}, + "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } + } + } + } +} diff --git a/docs/ARTIFACT-ACQUISITION.md b/docs/ARTIFACT-ACQUISITION.md index 8eeff80..914edd6 100644 --- a/docs/ARTIFACT-ACQUISITION.md +++ b/docs/ARTIFACT-ACQUISITION.md @@ -91,7 +91,10 @@ python scripts/artifacts.py validate-lock artifacts/locks/amd64.json \ --inputs artifacts/lock-inputs.json python scripts/artifacts.py validate-lock artifacts/locks/arm64.json \ --inputs artifacts/lock-inputs.json -python -m unittest tests.test_artifacts -v +python scripts/components.py +python -m unittest \ + tests.test_artifacts tests.test_components tests.test_nginx_features \ + tests.test_transfer -v ``` `scripts/fetch-lock-inputs.py`, `scripts/resolve-lock.sh`, and @@ -133,6 +136,9 @@ python scripts/artifacts.py acquire \ --output .artifact-bundle/amd64 bash scripts/verify-rpm-bundle.sh \ artifacts/locks/amd64.json .artifact-bundle/amd64 +python scripts/components.py \ + --lock artifacts/locks/amd64.json \ + --bundle .artifact-bundle/amd64 ``` Add `--include-sources` to both commands when preparing a redistribution and @@ -147,6 +153,8 @@ Before the artifact bundle is exposed to the build: - compare every file with its locked SHA-256 digest and expected size; - verify every RPM signature against the approved full signing fingerprint; - verify that RPM NEVRA and architecture match the lock; +- compare RPM license, source-RPM, and vendor metadata with the lock-bound + component accountability inventory; - reject unsigned, expired-policy, wrong-architecture, duplicate, and additional RPMs; - confirm the base-image manifest digest and platform; @@ -247,6 +255,14 @@ configuration and will use the official public source by default. The artifact lock, verification semantics, and network-disabled assembly remain identical when an alternate source is deliberately selected. +The operational procedures for deliberate lock refresh, signing-key changes, +mirror population and reacquisition, immutable-image rollback, and controlled +disconnected transfer are defined in +[Artifact lifecycle and controlled transfer](ARTIFACT-LIFECYCLE.md). The +transfer tooling binds an exact payload to its repository revision, +architecture lock, and component inventory, but its separately conveyed +manifest digest does not replace RPM publisher signatures. + Local preparation consumes an existing lock by default. Refreshing a lock is a separate explicit command so an ordinary local build cannot silently upgrade a dependency. diff --git a/docs/ARTIFACT-LIFECYCLE.md b/docs/ARTIFACT-LIFECYCLE.md new file mode 100644 index 0000000..db10f8f --- /dev/null +++ b/docs/ARTIFACT-LIFECYCLE.md @@ -0,0 +1,236 @@ +# Artifact lifecycle and controlled transfer + +This procedure governs artifact-lock refresh, publisher signing-key changes, +alternate-source mirrors, rollback, and transfer into a disconnected build +environment. It extends the verification contract in +[External artifact acquisition](ARTIFACT-ACQUISITION.md); it does not replace +publisher signatures, reviewed locks, or native release evidence. + +## Roles and approval + +| Activity | Preparer | Required reviewer | Evidence owner | +| --- | --- | --- | --- | +| Routine lock refresh | Datopsis maintainer | Code owner who did not prepare the change | Datopsis maintainers | +| Signing-key addition, removal, or emergency revocation | Datopsis security maintainer | Independent code owner | Datopsis maintainers | +| Mirror configuration and credentials | Environment owner | Environment security owner | Environment owner | +| Connected-to-disconnected transfer | Transfer custodian | Receiving security owner | Environment owner | +| Deployment rollback | Deployment operator | Service owner under local change policy | Environment owner | + +No ordinary build may refresh a lock, select a newer dependency, rotate a key, +or fall back to an unreviewed source. A lock update is an image-affecting +change and invalidates affected release-candidate evidence. + +## Lock refresh + +1. Open a change that records the reason, preparer, intended NGINX and UBI + versions, both base-image manifest digests, upstream advisory references, + and whether any package, source RPM, key, license tag, or repository changed. +2. Independently retrieve the proposed NGINX RPM and public signing keys from + the publisher. Record full key fingerprints and SHA-256 values in + `artifacts/lock-inputs.json`; never approve a key from a short key ID alone. + Resolve each base tag to its architecture-covering manifest-list digest and + record that digest before resolution. +3. Fetch only the reviewed seeds into a new directory: + + ```console + python scripts/fetch-lock-inputs.py \ + --inputs artifacts/lock-inputs.json \ + --architecture amd64 \ + --output .artifact-inputs/amd64 + ``` + +4. Run `scripts/resolve-lock.sh ARCHITECTURE INPUT_DIR OUTPUT_DIR` for AMD64 and + ARM64 in a clean, networked UBI 9 resolver environment. This is the only + phase permitted to resolve dependencies. Retain resolver version, platform, + repository metadata timestamp, stdout/stderr, and the input hashes. +5. Render each result to a new candidate path; do not overwrite a reviewed + lock in place: + + ```console + python scripts/render-lock.py \ + --inputs artifacts/lock-inputs.json \ + --architecture amd64 \ + --binary-inventory .artifact-resolver/amd64/binary-inventory.tsv \ + --source-inventory .artifact-resolver/amd64/source-inventory.tsv \ + --output .artifact-resolver/amd64.json + ``` + +6. Review both lock diffs. Explain package additions and removals, version and + repository changes, signer changes, source changes, and base digest changes. + Confirm both architectures still have an intentional component set. +7. Update `artifacts/components.json` from the candidate RPM headers, review + every changed license/source/vendor record, and bind it to both final lock + hashes. Do not relabel an upstream license merely to make it SPDX-shaped. +8. Acquire both candidate bundles from official sources, execute RPM and + component verification plus negative tests, build without network or pulls, + and complete native runtime and vulnerability evidence. +9. Merge the inputs, both locks, and component inventory as one reviewed + change. Delete local resolver material after retaining approved evidence in + the change record or CI; do not commit downloaded RPMs or resolver caches. + +If either architecture fails or differs without an approved explanation, stop +the refresh. Do not publish a one-architecture lock generation as a supported +multi-architecture release candidate. + +## Publisher signing-key rotation + +A key change is never inferred from a failed signature or fetched blindly from +an RPM header. Confirm the new fingerprint and transition through at least two +independent publisher-controlled references or an authenticated publisher +notice, then have an independent reviewer compare the full fingerprint. + +For a planned overlap, add the new key URL, SHA-256, and full fingerprint to +the reviewed inputs; regenerate both locks; and prove that every RPM signer +maps to exactly one approved fingerprint. Keep the old key only while current +locked artifacts require it. Remove it from the current inputs and locks after +the package transition. Historical Git revisions retain the data needed to +verify historical locks. + +For suspected compromise or publisher revocation, block lock refreshes and +releases, preserve the incident evidence, remove the key from current trust, +select publisher-reissued artifacts, regenerate the entire affected lock +generation, and repeat all image evidence. Never solve revocation by disabling +signature checks, accepting an unknown signer, or re-signing upstream RPMs. + +Key expiry, revocation, or announcement status is a review-time decision using +current publisher information; possession of a formerly accepted key file is +not sufficient approval. + +## Artifact mirror + +An approved mirror stores the exact publisher bytes under immutable object +identities. It must not rebuild, modify, decompress/recompress, or re-sign an +RPM. Populate it only after official-source acquisition and verification. +Record the source lock SHA-256, object SHA-256, upload identity, time, mirror +object/version identifier, retention policy, and deletion authority. + +Mirror credentials and private CA material remain protected environment +configuration. Generate the complete external source-map JSON for the chosen +lock and bundle mode, then reacquire into a clean directory with +`--source-map`, `--token-env`, and, when required, `--ca-bundle`. Verification +must use repository-trusted publisher keys rather than mirror-controlled keys. +Compare the mirror-acquired bundle with the same lock and run the normal RPM +verification before assembly. + +A missing mirror object, changed byte, redirect to another host, expired +credential, or TLS failure stops acquisition. Automatic fallback from a +protected mirror to the public internet is prohibited because it can bypass +the environment's egress and audit boundary. + +## Rollback + +The preferred deployment rollback selects the previously approved immutable +image digest and its matching configuration digest. Retain that image in the +deployment registry for the locally approved rollback window. Record the +failed digest, restored digest, reason, authorization, start/end time, health +result, and any security exposure caused by returning to older software. + +Do not edit a current lock to resemble an older generation or mix an older +lock with current inputs, keys, or component inventory. If rebuilding is +unavoidable, check out the exact historical repository revision, acquire its +exact locked artifacts, repeat its verification and current vulnerability +review, and publish the result as a new immutable image with new evidence. +An old build that succeeds is not automatically safe to redeploy. + +Before an update, rehearse the digest rollback in staging and confirm the old +configuration remains compatible. After rollback, diagnose the failed update +and issue a new reviewed candidate; never move or overwrite a published tag. + +## Disconnected transfer + +The transfer manifest schema is +`artifacts/transfer-manifest.schema.json`. `scripts/transfer.py` inventories +every payload file by path, byte size, and SHA-256 and binds the set to a full +repository revision, architecture lock, and component inventory. The tool +rejects missing, additional, modified, or symbolic-link payloads. + +The manifest is not a signature. Its SHA-256 must travel through a separately +authenticated channel, such as an approved signed change record, and be +compared before trusting tools or metadata carried on the transfer medium. + +### Connected preparation station + +For each architecture, use an empty staging directory and: + +1. Check out the reviewed full repository revision and ensure the worktree is + clean. Record `git rev-parse HEAD` as `REVISION`. +2. Acquire `.artifact-transfer/ARCHITECTURE/bundle` with `--include-sources`; run + `verify-rpm-bundle.sh` with source verification and run + `scripts/components.py` against the bundle. +3. Pull the two exact lock-selected base references and save each with Podman + as an OCI archive under `.artifact-transfer/ARCHITECTURE/bases/`. The archive hash is + transport evidence; the lock-selected image digest remains the image + identity checked after import. +4. Create `.artifact-transfer/ARCHITECTURE/source/nginx-ubi.bundle` with `git bundle` + from the exact revision so the receiver can reconstruct and inspect the + reviewed source without network access. +5. Add only approved, public evidence needed by the receiving procedure. Do + not include credentials, tokens, private CA keys, scanner caches, unrelated + files, or writable runtime secrets. +6. Seal the set and print its manifest digest: + + ```console + python scripts/transfer.py create \ + --root .artifact-transfer/amd64 \ + --architecture amd64 \ + --repository-revision REVISION \ + --lock artifacts/locks/amd64.json + ``` + +7. Record the printed manifest SHA-256 in the approved transfer record through + a channel separate from the payload. Record preparer, reviewer, source and + destination, custody changes, medium identifier, timestamps, malware-scan + result, and authorized recipient. Make the staged set read-only before + handoff. + +### Disconnected receiving station + +1. Verify custody and inspect the medium under local removable-media and + malware policy. Copy it to a new, access-controlled staging directory. +2. Hash `transfer-manifest.json` using a pre-positioned trusted utility and + compare it with the separately received digest before executing transferred + code. +3. Verify and clone the Git bundle, check out the expected full revision, and + run the repository's transfer verifier from that checkout: + + ```console + python scripts/transfer.py verify \ + --root TRANSFER_ROOT \ + --architecture amd64 \ + --repository-revision REVISION \ + --lock artifacts/locks/amd64.json \ + --expected-manifest-sha256 EXPECTED_SHA256 + ``` + +4. Re-run `artifacts.py verify-bundle`, `verify-rpm-bundle.sh` with sources, + and `components.py`. This re-establishes exact hashes, publisher signatures, + NEVRA, architecture, source RPM, license tag, and vendor records after the + trust-boundary crossing. +5. Import each OCI archive into the local Podman store. Confirm both exact + lock-selected base references exist, then build with `PULL_BASES=0`; the + build remains `--pull=never` and `--network none`. +6. Run native smoke and security checks and record the output image digest, + source revision, lock and transfer-manifest hashes, tool versions, operator, + time, and result. Retain evidence under local policy and sanitize the + transfer workspace when authorized. + +Repeat independently for ARM64. Offline advisory, vulnerability, revocation, +and scanner data is point-in-time: record its source and age, define the local +maximum age, and block release when that policy is exceeded. A successful +disconnected build does not prove that its intelligence was current. + +## Failure and recovery rules + +- Never repair a failed transfer by editing its manifest. Prepare a new empty + staging set and obtain a new independently conveyed manifest digest. +- Quarantine media or mirror objects involved in unexplained hash/signature + failures and open an incident under the owning environment's process. +- Retain the last accepted lock generation and immutable image digest until + the new generation passes its rollback window. +- Never copy acquisition credentials, private trust anchors, private signing + keys, or production TLS keys into an artifact bundle, transfer set, image, + or evidence archive. + +These procedures are implemented controls, but mirror operation, media +custody, offline freshness, and rollback execution require environment-specific +qualification before they can support a release claim. diff --git a/docs/CI.md b/docs/CI.md index 7e7e27a..4bf6132 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -32,9 +32,13 @@ pre-commit run --all-files --show-diff-on-failure The configured hooks check text normalization, YAML and JSON syntax, merge markers, unsafe or broken symlinks, oversized files, private keys, shell code, container build files, GitHub Actions, and prohibited co-author trailers. -The lint job also runs `python -m unittest tests.test_artifacts -v` to validate -the reviewed lock inputs, both architecture locks, and fail-closed negative -cases without downloading artifacts. +The lint job also runs +`python -m unittest tests.test_artifacts tests.test_components +tests.test_nginx_features tests.test_transfer -v` to validate the reviewed +lock inputs, both architecture locks, the lock-bound 79-package component +accountability inventory, the NGINX compile-feature inventory, +disconnected-transfer integrity, and fail-closed negative cases without +downloading artifacts. The `commit-msg` hook applies only after `pre-commit install` installs the configured hook types. CI separately evaluates repository files but cannot @@ -82,9 +86,11 @@ digest-pinned UBI bases, and build with Podman using `--network none` and and cannot fetch an unavailable base. Before assembly, isolated copies of the real bundle prove rejection of tampering, signature removal, signer mismatch, wrong version, wrong architecture, missing RPMs, and additional RPMs. The -completed image is transferred by local archive into Docker solely for the -existing compatibility smoke and scanner steps; that transfer performs no -image build or registry pull. +jobs also compare every RPM's publisher-supplied license tag, source RPM, and +vendor header with `artifacts/components.json`. The completed image is +transferred by local archive into Docker solely for the existing compatibility +smoke and scanner steps; that transfer performs no image build or registry +pull. The official public source is the default. An alternate approved source can be selected through protected CI configuration, but private endpoints, @@ -101,10 +107,12 @@ The implemented image pipeline performs: 1. Trivy build-configuration scanning. 2. Verified, network-disabled, no-pull native architecture builds followed by - native Podman and Docker-compatibility restricted-runtime tests covering - the declared and arbitrary runtime identities, process privileges, a - read-only root, hardened temporary storage, static content, health behavior, - log streams, reload and shutdown, and actionable startup failures. + native Podman and Docker-compatibility restricted-runtime tests. They cover + the exact 79-RPM manifest, NGINX compile-feature and empty dynamic-module + inventories, declared and arbitrary runtime identities, process privileges, + a read-only root, hardened temporary storage, static content, health and log + behavior, worker-replacing reload, active-request graceful shutdown, and + actionable startup failures. 3. Trivy image vulnerability scanning. 4. SPDX inventory generation with Syft. 5. Independent fixed High/Critical vulnerability gating with Grype and a diff --git a/docs/COMPONENT-OWNERSHIP.md b/docs/COMPONENT-OWNERSHIP.md new file mode 100644 index 0000000..0557726 --- /dev/null +++ b/docs/COMPONENT-OWNERSHIP.md @@ -0,0 +1,70 @@ +# Runtime component accountability + +The authoritative runtime-component inventory is +[`artifacts/components.json`](../artifacts/components.json). It accounts for +all 79 binary RPMs in both reviewed architecture locks and records each +package name, exact RPM `License` tag, source RPM identity, publisher policy, +redistribution terms, support-lifecycle boundary, and update owner. + +The inventory is bound to the SHA-256 digest of each architecture lock. A lock +refresh therefore cannot retain a stale component record: validation fails +until the inventory is reviewed and rebound. The common inventory is valid +only while AMD64 and ARM64 contain the same package names, source RPMs, and +publisher classification. + +## Publisher and terms boundary + +| Policy | Components | Source and redistribution | Lifecycle and support | Update owner | +| --- | ---: | --- | --- | --- | +| `nginx-stable` | 1 | Official NGINX stable RPM repository; NGINX two-clause BSD terms and required acknowledgements | The project identified no fixed support term for the open-source stable channel and makes no F5 or NGINX support claim | Datopsis maintainers | +| `redhat-ubi9` | 78 | Public Red Hat UBI repositories; redistribution remains subject to the UBI EULA and each component license | UBI content follows the RHEL lifecycle; Red Hat support requires an eligible subscription and supported deployment combination | Datopsis maintainers | + +Authoritative upstream references: + +- [NGINX Linux packages](https://nginx.org/en/linux_packages.html) +- [NGINX license and copyright FAQ](https://nginx.org/en/docs/faq/license_copyright.html) +- [NGINX two-clause BSD license](https://nginx.org/LICENSE) +- [Red Hat UBI FAQ](https://developers.redhat.com/articles/ubi-faq) +- [Red Hat UBI EULA](https://cdn-ubi.redhat.com/content/public/ubi/EULA.html) +- [Red Hat UBI content availability](https://access.redhat.com/support/policy/updates/ubi) +- [Red Hat container support policy](https://access.redhat.com/support/policy/container-support-policy) + +The RPM `License` value is publisher-supplied package metadata. It is retained +verbatim for change detection and review; it is not a project-authored SPDX +normalization or a legal conclusion. The release SBOM and embedded license +files remain required evidence. Final license and third-party-notice review is +a separate release gate. + +## Validation + +The download-free check validates the inventory structure, exact coverage, +publisher classification, source RPM identities, and both lock bindings: + +```console +python scripts/components.py +``` + +After acquiring an architecture bundle, validate the recorded license, source +RPM, and vendor directly against every signed RPM header: + +```console +python scripts/components.py \ + --lock artifacts/locks/amd64.json \ + --bundle .artifact-bundle/amd64 +``` + +Native CI runs the second check for AMD64 and ARM64. A package addition, +removal, source change, publisher change, license-tag change, or lock change +blocks CI until the corresponding record receives review. + +## Maintenance responsibility + +Datopsis maintainers own monitoring official NGINX stable releases, Red Hat +UBI errata and lifecycle notices, vulnerability findings, and dependency-lock +changes. They decide when to propose, test, and publish an updated image. +Operators remain responsible for selecting supported deployment combinations, +maintaining any required vendor subscriptions, and replacing superseded image +digests in their environments. + +This record is operational supply-chain documentation, not legal advice and +not a statement that Red Hat, F5, or NGINX supports this image. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 60cb998..e08aab8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,35 +51,22 @@ are separately approved. They must not delay the core first release. Work proceeds in this dependency order: -1. Close rootless failure diagnostics, graceful lifecycle tests, and - package/module inventory checks. -2. Qualify the minimum static, reverse-proxy, structured-logging, and TLS +1. Qualify the minimum static, reverse-proxy, structured-logging, and TLS profiles needed for the first supported image; keep additional profiles explicitly preview until their tests close. -3. Complete the repository policy files, support boundary, threat model, +2. Complete the repository policy files, support boundary, threat model, requirement analysis, control ownership, vulnerability policy, tailored SCAP evidence, and deployment cyber package needed for review. -4. Qualify standalone rootless Podman/Quadlet deployment, systemd lifecycle, +3. Qualify standalone rootless Podman/Quadlet deployment, systemd lifecycle, journald collection, controlled-network operation, and rollback on an exact supported Linux host. -5. Rehearse the multi-architecture publish, provenance, SBOM, signing, and +4. Rehearse the multi-architecture publish, provenance, SBOM, signing, and verification workflow from an untagged release candidate. -6. Freeze inputs, regenerate release-candidate evidence, approve findings, +5. Freeze inputs, regenerate release-candidate evidence, approve findings, create the immutable tag, publish by digest, and verify the release. -Step 1 is the immediate engineering critical path. Steps 2 and 3 can proceed -in parallel only where they do not assume an unfrozen NGINX package or module -set. - -## Package 2: rootless minimal image - -- [ ] Record source, redistribution, licensing, support lifecycle, and update - ownership for every runtime component. -- [ ] Define lock refresh, key rotation, artifact mirroring, rollback, and - disconnected artifact-transfer procedures. - -**Fast-release checkpoint:** after Package 2, a development image is usable for -local evaluation but is not yet a supported release. +Step 1 is the immediate engineering critical path. Step 2 can proceed in +parallel only where it does not assume an unfrozen NGINX package or module set. ## Package 3: supported configurations and TLS diff --git a/docs/RUNTIME-CONTRACT.md b/docs/RUNTIME-CONTRACT.md new file mode 100644 index 0000000..f626edd --- /dev/null +++ b/docs/RUNTIME-CONTRACT.md @@ -0,0 +1,86 @@ +# Rootless runtime and lifecycle contract + +This document defines the tested baseline behavior of the development image. +It is image-level evidence, not yet a supported-platform or release claim. + +## Startup and diagnostics + +NGINX is PID 1 and starts directly as UID/GID `999:0`. There is no root phase, +privilege-changing entrypoint, or startup script that edits mounted files. An +orchestrator may assign another non-root UID with primary group 0; the runtime +still receives no capabilities and cannot gain new privileges. + +The default configuration requires a writable `/tmp` for its PID and bounded +NGINX temporary directories. Under a read-only root filesystem, operators must +supply the documented `rw,noexec,nosuid,nodev` tmpfs. Startup without it fails +nonzero and identifies the first affected `/tmp/nginx-*` path and filesystem +error in the container error stream. + +Configuration is consumed read-only. A syntactically invalid mounted main +configuration fails through the normal image entrypoint, exits nonzero, and +identifies the directive plus `/etc/nginx/nginx.conf` line number. The image +does not replace a bad mount with packaged defaults or enter a repair shell. +Operators should retain the container logs before restart automation discards +them. + +## Reload and stop + +`nginx -s reload` sends SIGHUP to the PID-1 master. The test requires: + +- the master PID remains 1; +- a nonempty replacement worker set starts; +- every previous worker exits; and +- health requests succeed after reconfiguration. + +The OCI stop signal is `SIGQUIT`. The lifecycle test starts a rate-limited +response, confirms that bytes are in flight, requests container stop, and +requires the complete response, graceful-shutdown log event, and exit code 0. +This proves application-level draining for the test request. Deployment stop +timeouts must still be sized for the environment's longest accepted request; +the runtime may force-kill NGINX after that timeout. + +## Package inventory + +Assembly compares the installed RPM database with all 79 locked RPMs before +creating the final image. It then embeds the verified, architecture-specific +manifest at `/usr/share/nginx-ubi/rpm-manifest.tsv` and removes RPM and package +manager commands. The restricted-runtime test derives the expected manifest +from the matching reviewed lock and compares the complete file SHA-256 and +80-line shape (header plus 79 packages). + +The embedded manifest records filename, name, epoch, version, release, +architecture, source RPM, full signer fingerprint, and RPM SHA-256. It is +immutable image metadata for inspection and comparison; the release SBOM and +publisher-signature evidence remain separately required. + +## NGINX feature and module inventory + +[`artifacts/nginx-features.json`](../artifacts/nginx-features.json) records the +selected RPM's three reviewed compile features and 22 optional configure-time +modules/subsystems. `scripts/nginx_features.py` compares this record with real +`nginx -V` output and rejects missing or additional optional modules, +unreviewed `--add-module` paths, feature drift, or NGINX input-version drift. + +The runtime additionally requires `/usr/lib64/nginx/modules` to be empty. No +separately packaged dynamic module is installed and the default configuration +contains no `load_module` directive. + +The official RPM compiles mail and stream subsystems into the binary. They are +recorded as compiled but unsupported for the first release, have no default +configuration block or listener, and must not be represented as supported +features. HTTP/3 is also compiled, but the default configuration opens no QUIC +listener; protocol-profile support remains gated on its own configuration and +qualification work. + +The configure-argument inventory covers optional build flags exposed by +`nginx -V`; NGINX modules that are always built and have no configure flag are +governed through configuration review and use-case tests rather than falsely +presented as absent. + +## Automated evidence + +`tests/smoke.sh` runs this contract with a read-only root, hardened `/tmp`, all +capabilities dropped, and `no-new-privileges`. Native CI runs it with Podman on +AMD64 and ARM64. The same image is then transferred locally into Docker and +the suite runs again as compatibility evidence. Neither result substitutes +for the exact supported-host and OpenShift qualification still on the roadmap. diff --git a/scripts/components.py b/scripts/components.py new file mode 100644 index 0000000..9d8af06 --- /dev/null +++ b/scripts/components.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Validate the reviewed runtime-component accountability inventory.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path + +import artifacts + + +class InventoryError(ValueError): + """The component inventory is incomplete or no longer matches its locks.""" + + +POLICY_FIELDS = { + "id", "publisher", "component_source", "redistribution_terms", + "license_reference", "support_lifecycle", "update_owner", "rpm_vendor", +} +COMPONENT_FIELDS = {"name", "source_rpm", "license", "policy"} +ROOT_FIELDS = {"schema_version", "locks", "policies", "components"} + + +def fail(message: str) -> None: + raise InventoryError(message) + + +def require_keys(value: dict, expected: set[str], label: str) -> None: + missing = expected - value.keys() + extra = value.keys() - expected + if missing: + fail(f"{label} is missing fields: {', '.join(sorted(missing))}") + if extra: + fail(f"{label} has unexpected fields: {', '.join(sorted(extra))}") + + +def require_text(value: object, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + fail(f"{label} must be a non-empty string") + return value + + +def file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def read_inventory(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot read valid JSON from {path}: {exc}") + if not isinstance(value, dict): + fail("component inventory must be one JSON object") + return value + + +def validate_inventory(inventory_path: Path, repository: Path) -> dict: + value = read_inventory(inventory_path) + require_keys(value, ROOT_FIELDS, "component inventory") + if value["schema_version"] != 1: + fail("only component inventory schema version 1 is supported") + + locks = value["locks"] + if not isinstance(locks, dict) or set(locks) != set(artifacts.ARCHES): + fail("inventory must bind exactly the amd64 and arm64 locks") + + lock_packages: dict[str, dict[str, dict]] = {} + for architecture in artifacts.ARCHES: + binding = locks[architecture] + if not isinstance(binding, dict): + fail(f"{architecture} lock binding must be an object") + require_keys(binding, {"path", "sha256"}, f"{architecture} lock binding") + relative = Path(require_text(binding["path"], f"{architecture} lock path")) + if relative.is_absolute() or ".." in relative.parts: + fail(f"{architecture} lock path must stay within the repository") + lock_path = repository / relative + expected_hash = artifacts.require_sha256( + binding["sha256"], f"{architecture} lock SHA-256" + ) + if expected_hash != file_sha256(lock_path): + fail(f"{architecture} lock SHA-256 no longer matches the inventory") + lock = artifacts.validate_lock( + lock_path, repository / "artifacts" / "lock-inputs.json" + ) + if lock["architecture"] != architecture: + fail(f"{architecture} binding references the wrong architecture lock") + lock_packages[architecture] = {item["name"]: item for item in lock["packages"]} + + policies = value["policies"] + if not isinstance(policies, list) or not policies: + fail("policies must be a non-empty array") + policy_ids: set[str] = set() + policy_vendors: dict[str, str] = {} + for index, policy in enumerate(policies): + if not isinstance(policy, dict): + fail(f"policy {index} must be an object") + require_keys(policy, POLICY_FIELDS, f"policy {index}") + for field in POLICY_FIELDS: + require_text(policy[field], f"policy {index} {field}") + identifier = policy["id"] + if identifier in policy_ids: + fail(f"duplicate policy id: {identifier}") + policy_ids.add(identifier) + policy_vendors[identifier] = policy["rpm_vendor"] + + components = value["components"] + if not isinstance(components, list) or not components: + fail("components must be a non-empty array") + records: dict[str, dict] = {} + for index, component in enumerate(components): + if not isinstance(component, dict): + fail(f"component {index} must be an object") + require_keys(component, COMPONENT_FIELDS, f"component {index}") + for field in COMPONENT_FIELDS: + require_text(component[field], f"component {index} {field}") + name = component["name"] + if name in records: + fail(f"duplicate component: {name}") + if component["policy"] not in policy_ids: + fail(f"component {name} references an unknown policy") + records[name] = component + + expected_names = set(lock_packages["amd64"]) + for architecture, packages in lock_packages.items(): + if set(packages) != expected_names: + fail(f"{architecture} package names differ from the common inventory") + if set(records) != expected_names: + missing = expected_names - records.keys() + extra = records.keys() - expected_names + fail( + "component names differ from the locks; " + f"missing={','.join(sorted(missing)) or '-'}; " + f"unexpected={','.join(sorted(extra)) or '-'}" + ) + + for name, record in records.items(): + for architecture, packages in lock_packages.items(): + package = packages[name] + if record["source_rpm"] != package["source_rpm"]: + fail(f"{name} source RPM differs from the {architecture} lock") + expected_policy = ( + "nginx-stable" if package["repository"] == "nginx-stable" + else "redhat-ubi9" + ) + if record["policy"] != expected_policy: + fail(f"{name} has the wrong publisher policy for {architecture}") + value["_policy_vendors"] = policy_vendors + return value + + +def verify_rpms(inventory: dict, lock_path: Path, bundle: Path) -> None: + lock = artifacts.validate_lock(lock_path) + architecture = lock["architecture"] + if file_sha256(lock_path) != inventory["locks"][architecture]["sha256"]: + fail(f"supplied {architecture} lock is not the inventory-bound lock") + records = {item["name"]: item for item in inventory["components"]} + policy_vendors = inventory["_policy_vendors"] + for package in lock["packages"]: + rpm_path = bundle / "rpms" / package["filename"] + result = subprocess.run( + [ + "rpm", "-qp", "--queryformat", + r"%{NAME}\t%{LICENSE}\t%{SOURCERPM}\t%{VENDOR}", str(rpm_path), + ], + check=False, capture_output=True, text=True, + ) + if result.returncode: + fail(f"cannot query RPM metadata for {package['filename']}: {result.stderr.strip()}") + fields = result.stdout.split("\t") + if len(fields) != 4: + fail(f"unexpected RPM metadata for {package['filename']}") + name, license_value, source_rpm, vendor = fields + record = records.get(name) + if record is None: + fail(f"RPM {name} is absent from the component inventory") + expected = (record["license"], record["source_rpm"], policy_vendors[record["policy"]]) + if (license_value, source_rpm, vendor) != expected: + fail(f"RPM metadata differs from the component inventory for {name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", type=Path, default=Path("artifacts/components.json")) + parser.add_argument("--repository", type=Path, default=Path(".")) + parser.add_argument("--lock", type=Path) + parser.add_argument("--bundle", type=Path) + args = parser.parse_args() + try: + inventory = validate_inventory(args.inventory, args.repository.resolve()) + if (args.lock is None) != (args.bundle is None): + fail("--lock and --bundle must be supplied together") + if args.lock is not None: + verify_rpms(inventory, args.lock, args.bundle) + except (InventoryError, artifacts.LockError, OSError) as exc: + print(f"component inventory verification failed: {exc}", file=sys.stderr) + return 1 + print(f"component inventory verified: {len(inventory['components'])} runtime packages") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/nginx_features.py b/scripts/nginx_features.py new file mode 100644 index 0000000..0646aa9 --- /dev/null +++ b/scripts/nginx_features.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Validate the selected NGINX RPM's compiled feature inventory.""" + +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from pathlib import Path + +import artifacts + + +ROOT_FIELDS = { + "schema_version", "nginx_version", "nginx_rpm_version", + "compiled_features", "compiled_optional_modules", "dynamic_module_files", + "compiled_but_unsupported_subsystems", +} +FEATURES = {"--with-compat", "--with-file-aio", "--with-threads"} + + +class FeatureError(ValueError): + """The observed NGINX build differs from the reviewed feature inventory.""" + + +def fail(message: str) -> None: + raise FeatureError(message) + + +def string_array(value: object, label: str) -> list[str]: + if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value): + fail(f"{label} must be an array of non-empty strings") + if value != sorted(set(value)): + fail(f"{label} must be sorted and unique") + return value + + +def validate_inventory(path: Path, inputs_path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot read valid feature inventory: {exc}") + if not isinstance(value, dict): + fail("feature inventory must be one JSON object") + artifacts.require_keys(value, ROOT_FIELDS, "NGINX feature inventory") + if value["schema_version"] != 1: + fail("only NGINX feature inventory schema version 1 is supported") + inputs = artifacts.validate_inputs(inputs_path) + for field in ("nginx_version", "nginx_rpm_version"): + if value[field] != inputs[field]: + fail(f"feature inventory {field} differs from reviewed inputs") + features = string_array(value["compiled_features"], "compiled_features") + if set(features) != FEATURES: + fail("compiled feature flags differ from the reviewed feature set") + modules = string_array( + value["compiled_optional_modules"], "compiled_optional_modules" + ) + for module in modules: + if not module.startswith("--with-") or not ( + module.endswith("_module") or module in {"--with-mail", "--with-stream"} + ): + fail(f"invalid optional module argument: {module}") + if string_array(value["dynamic_module_files"], "dynamic_module_files"): + fail("the first-release package must not install dynamic module files") + unsupported = string_array( + value["compiled_but_unsupported_subsystems"], + "compiled_but_unsupported_subsystems", + ) + if unsupported != ["mail", "stream"]: + fail("compiled but unsupported subsystems must be exactly mail and stream") + if not {"--with-mail", "--with-stream"}.issubset(modules): + fail("unsupported compiled subsystems are absent from the module inventory") + return value + + +def validate_nginx_v(output: str, inventory: dict) -> None: + expected_version = f"nginx version: nginx/{inventory['nginx_version']}" + if expected_version not in output.splitlines(): + fail("nginx -V version differs from the reviewed inventory") + marker = "configure arguments:" + matching = [line for line in output.splitlines() if line.startswith(marker)] + if len(matching) != 1: + fail("nginx -V must contain exactly one configure-arguments line") + try: + arguments = shlex.split(matching[0][len(marker):].strip()) + except ValueError as exc: + fail(f"cannot parse nginx configure arguments: {exc}") + if any(item.startswith(("--add-module=", "--add-dynamic-module=")) for item in arguments): + fail("nginx was built with an unreviewed external module path") + actual_features = sorted(item for item in arguments if item in FEATURES) + actual_modules = sorted( + item for item in arguments + if item.startswith("--with-") + and (item.endswith("_module") or item in {"--with-mail", "--with-stream"}) + ) + if actual_features != inventory["compiled_features"]: + fail("nginx compiled feature flags differ from the reviewed inventory") + if actual_modules != inventory["compiled_optional_modules"]: + fail("nginx optional modules differ from the reviewed inventory") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", type=Path, default=Path("artifacts/nginx-features.json")) + parser.add_argument("--inputs", type=Path, default=Path("artifacts/lock-inputs.json")) + parser.add_argument("--nginx-v-output", type=Path, help="read output from a file instead of stdin") + args = parser.parse_args() + try: + inventory = validate_inventory(args.inventory, args.inputs) + output = ( + args.nginx_v_output.read_text(encoding="utf-8") + if args.nginx_v_output else sys.stdin.read() + ) + validate_nginx_v(output, inventory) + except (FeatureError, artifacts.LockError, OSError) as exc: + print(f"NGINX feature verification failed: {exc}", file=sys.stderr) + return 1 + print( + "NGINX feature inventory verified: " + f"{len(inventory['compiled_optional_modules'])} optional modules" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/transfer.py b/scripts/transfer.py new file mode 100644 index 0000000..2e9c6c4 --- /dev/null +++ b/scripts/transfer.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""Create and verify a lock-bound manifest for disconnected transfers.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +import artifacts +import components + + +MANIFEST_NAME = "transfer-manifest.json" +MANIFEST_FIELDS = { + "schema_version", "architecture", "repository_revision", + "artifact_lock_sha256", "component_inventory_sha256", "files", +} +FILE_FIELDS = {"path", "size", "sha256"} +REVISION_RE = re.compile(r"^[a-f0-9]{40}(?:[a-f0-9]{24})?$") + + +class TransferError(ValueError): + """A transfer set or its expected identity is invalid.""" + + +def fail(message: str) -> None: + raise TransferError(message) + + +def require_keys(value: dict, fields: set[str], label: str) -> None: + missing = fields - value.keys() + extra = value.keys() - fields + if missing: + fail(f"{label} is missing fields: {', '.join(sorted(missing))}") + if extra: + fail(f"{label} has unexpected fields: {', '.join(sorted(extra))}") + + +def validate_revision(value: str) -> str: + if not REVISION_RE.fullmatch(value): + fail("repository revision must be a full lowercase Git object ID") + return value + + +def inventory_files(root: Path) -> list[dict]: + if not root.is_dir(): + fail(f"transfer root is not a directory: {root}") + records = [] + for path in sorted(root.rglob("*")): + if path.is_symlink(): + fail(f"transfer set must not contain symbolic links: {path}") + if path.is_dir(): + continue + if not path.is_file(): + fail(f"transfer set contains a non-regular file: {path}") + relative = path.relative_to(root).as_posix() + if relative == MANIFEST_NAME: + continue + records.append({ + "path": relative, + "size": path.stat().st_size, + "sha256": artifacts.sha256_file(path), + }) + if not records: + fail("transfer set must contain at least one payload file") + return records + + +def create_manifest( + root: Path, + architecture: str, + revision: str, + lock_sha256: str, + inventory_sha256: str, +) -> str: + if architecture not in artifacts.ARCHES: + fail(f"unsupported architecture: {architecture}") + validate_revision(revision) + artifacts.require_sha256(lock_sha256, "artifact lock") + artifacts.require_sha256(inventory_sha256, "component inventory") + manifest_path = root / MANIFEST_NAME + if manifest_path.exists(): + fail(f"refusing to overwrite existing manifest: {manifest_path}") + manifest = { + "schema_version": 1, + "architecture": architecture, + "repository_revision": revision, + "artifact_lock_sha256": lock_sha256, + "component_inventory_sha256": inventory_sha256, + "files": inventory_files(root), + } + encoded = (json.dumps(manifest, indent=2) + "\n").encode("utf-8") + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{MANIFEST_NAME}.", dir=root + ) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(encoded) + os.replace(temporary_name, manifest_path) + finally: + if os.path.exists(temporary_name): + os.unlink(temporary_name) + return artifacts.sha256_file(manifest_path) + + +def read_manifest(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot read valid transfer manifest: {exc}") + if not isinstance(value, dict): + fail("transfer manifest must be one JSON object") + require_keys(value, MANIFEST_FIELDS, "transfer manifest") + if value["schema_version"] != 1: + fail("only transfer manifest schema version 1 is supported") + if value["architecture"] not in artifacts.ARCHES: + fail("transfer manifest has an unsupported architecture") + validate_revision(value["repository_revision"]) + artifacts.require_sha256(value["artifact_lock_sha256"], "artifact lock") + artifacts.require_sha256(value["component_inventory_sha256"], "component inventory") + files = value["files"] + if not isinstance(files, list) or not files: + fail("transfer manifest files must be a non-empty array") + paths: set[str] = set() + for index, record in enumerate(files): + if not isinstance(record, dict): + fail(f"transfer file {index} must be an object") + require_keys(record, FILE_FIELDS, f"transfer file {index}") + relative = record["path"] + if not isinstance(relative, str) or not relative: + fail(f"transfer file {index} path must be a non-empty string") + parsed = Path(relative) + if parsed.is_absolute() or ".." in parsed.parts or "\\" in relative: + fail(f"unsafe transfer path: {relative}") + if relative == MANIFEST_NAME or relative in paths: + fail(f"duplicate or reserved transfer path: {relative}") + if not isinstance(record["size"], int) or record["size"] < 0: + fail(f"invalid transfer size for {relative}") + artifacts.require_sha256(record["sha256"], f"transfer file {relative}") + paths.add(relative) + if [record["path"] for record in files] != sorted(paths): + fail("transfer files must be sorted by path") + return value + + +def verify_manifest( + root: Path, + expected_manifest_sha256: str, + architecture: str, + revision: str, + lock_sha256: str, + inventory_sha256: str, +) -> dict: + artifacts.require_sha256(expected_manifest_sha256, "expected transfer manifest") + manifest_path = root / MANIFEST_NAME + if artifacts.sha256_file(manifest_path) != expected_manifest_sha256: + fail("transfer manifest differs from the separately conveyed digest") + manifest = read_manifest(manifest_path) + expected_context = { + "architecture": architecture, + "repository_revision": validate_revision(revision), + "artifact_lock_sha256": lock_sha256, + "component_inventory_sha256": inventory_sha256, + } + for field, expected in expected_context.items(): + if manifest[field] != expected: + fail(f"transfer manifest {field} differs from the expected context") + actual = inventory_files(root) + if actual != manifest["files"]: + fail("transfer payload inventory, size, or SHA-256 differs from the manifest") + return manifest + + +def validated_context(args: argparse.Namespace) -> tuple[str, str]: + repository = args.repository.resolve() + validate_revision(args.repository_revision) + head = subprocess.run( + ["git", "-C", str(repository), "rev-parse", "--verify", "HEAD"], + check=False, capture_output=True, text=True, + ) + if head.returncode or head.stdout.strip() != args.repository_revision: + fail("repository HEAD differs from the declared transfer revision") + status = subprocess.run( + ["git", "-C", str(repository), "status", "--porcelain=v1"], + check=False, capture_output=True, text=True, + ) + if status.returncode or status.stdout: + fail("transfer creation and verification require a clean repository checkout") + + def repository_file(path: Path, label: str) -> Path: + resolved = path.resolve() if path.is_absolute() else (repository / path).resolve() + if not resolved.is_relative_to(repository): + fail(f"{label} must stay within the repository checkout") + return resolved + + lock_path = repository_file(args.lock, "artifact lock") + inputs_path = repository_file(args.inputs, "lock inputs") + inventory_path = repository_file(args.inventory, "component inventory") + lock = artifacts.validate_lock(lock_path, inputs_path) + if lock["architecture"] != args.architecture: + fail("supplied lock architecture differs from the transfer architecture") + inventory = components.validate_inventory(inventory_path, repository) + lock_sha256 = artifacts.sha256_file(lock_path) + inventory_sha256 = artifacts.sha256_file(inventory_path) + binding = inventory["locks"][args.architecture] + if binding["sha256"] != lock_sha256: + fail("supplied lock is not bound by the component inventory") + return lock_sha256, inventory_sha256 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + for command in ("create", "verify"): + child = subparsers.add_parser(command) + child.add_argument("--root", required=True, type=Path) + child.add_argument("--architecture", required=True, choices=sorted(artifacts.ARCHES)) + child.add_argument("--repository-revision", required=True) + child.add_argument("--repository", type=Path, default=Path(".")) + child.add_argument("--inputs", type=Path, default=Path("artifacts/lock-inputs.json")) + child.add_argument("--lock", required=True, type=Path) + child.add_argument("--inventory", type=Path, default=Path("artifacts/components.json")) + subparsers.choices["verify"].add_argument( + "--expected-manifest-sha256", required=True + ) + args = parser.parse_args() + try: + lock_sha256, inventory_sha256 = validated_context(args) + if args.command == "create": + digest = create_manifest( + args.root, args.architecture, args.repository_revision, + lock_sha256, inventory_sha256, + ) + print(digest) + else: + verify_manifest( + args.root, args.expected_manifest_sha256, args.architecture, + args.repository_revision, lock_sha256, inventory_sha256, + ) + print("disconnected transfer verified") + except (TransferError, artifacts.LockError, components.InventoryError, OSError) as exc: + print(f"transfer verification failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/graceful-nginx.conf b/tests/fixtures/graceful-nginx.conf new file mode 100644 index 0000000..3e6aedc --- /dev/null +++ b/tests/fixtures/graceful-nginx.conf @@ -0,0 +1,30 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 32; +} + +http { + access_log /dev/stdout; + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; + + server { + listen 8080; + + location = /healthz { + access_log off; + return 200 "ok\n"; + } + + location = /slow { + alias /tmp/slow.bin; + limit_rate 32k; + } + } +} diff --git a/tests/fixtures/invalid-nginx.conf b/tests/fixtures/invalid-nginx.conf new file mode 100644 index 0000000..ccc94bd --- /dev/null +++ b/tests/fixtures/invalid-nginx.conf @@ -0,0 +1 @@ +invalid_directive; diff --git a/tests/smoke.sh b/tests/smoke.sh index b0bc710..ffb8041 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -3,11 +3,17 @@ set -Eeuo pipefail runtime="${CONTAINER_RUNTIME:-podman}" image="${IMAGE:-localhost/nginx-ubi9:development}" +python="${PYTHON:-python3}" +script_dir="${SCRIPT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}" +repository="${REPOSITORY:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}" +null_device="${NULL_DEVICE:-/dev/null}" prefix="nginx-ubi9-smoke-${RANDOM}-$$" primary="${prefix}-primary" arbitrary="${prefix}-arbitrary" missing_tmp="${prefix}-missing-tmp" invalid_config="${prefix}-invalid-config" +graceful="${prefix}-graceful" +graceful_output=$(mktemp "${SMOKE_TMPDIR:-/tmp}/nginx-ubi-smoke.XXXXXX") missing_tmp_runtime_args=() no_new_privileges="no-new-privileges:true" @@ -22,7 +28,9 @@ fi cleanup() { "${runtime}" rm --force \ "${primary}" "${arbitrary}" "${missing_tmp}" "${invalid_config}" \ + "${graceful}" \ >/dev/null 2>&1 || true + rm -f -- "${graceful_output}" } trap cleanup EXIT @@ -100,6 +108,115 @@ assert_clean_exit() { test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${name}")" = "0" } +worker_pids() { + local name="$1" + # NGINX workers are the non-PID-1 nginx processes in this single-service image. + # The variables expand in the inner container shell, not this script. + # shellcheck disable=SC2016 + "${runtime}" exec "${name}" sh -eu -c ' + for status in /proc/[0-9]*/status; do + name="" + pid="" + while IFS=: read -r key value; do + case "${key}" in + Name) set -- ${value}; name="$1" ;; + Pid) set -- ${value}; pid="$1" ;; + esac + done < "${status}" + if test "${name}" = nginx && test "${pid}" != 1; then + printf "%s\n" "${pid}" + fi + done + ' | sort -n +} + +assert_reload_replaced_workers() { + local name="$1" + local old_workers + local new_workers + local old_pid + local replaced + local _ + old_workers=$(worker_pids "${name}") + test -n "${old_workers}" + "${runtime}" exec "${name}" nginx -s reload + for _ in {1..30}; do + new_workers=$(worker_pids "${name}") + replaced=1 + test -n "${new_workers}" || replaced="" + test "${new_workers}" != "${old_workers}" || replaced="" + while IFS= read -r old_pid; do + test -n "${old_pid}" || continue + if "${runtime}" exec "${name}" test -e "/proc/${old_pid}"; then + replaced="" + fi + done <<< "${old_workers}" + if test -n "${replaced}"; then + test "$("${runtime}" exec "${name}" sh -c 'cat /tmp/nginx.pid')" = "1" + return + fi + sleep 1 + done + "${runtime}" logs "${name}" >&2 + echo "NGINX reload did not replace its worker processes" >&2 + return 1 +} + +assert_active_request_drains_on_stop() { + local binding + local host_port + local curl_pid + local size + local started="" + local _ + "${runtime}" run --detach --name "${graceful}" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ + --cap-drop ALL \ + --security-opt "${no_new_privileges}" \ + --publish 127.0.0.1::8080 \ + --volume "${script_dir}/fixtures/graceful-nginx.conf:/tmp/graceful-nginx.conf:ro" \ + --entrypoint sh \ + "${image}" -eu -c \ + 'dd if=/dev/zero of=/tmp/slow.bin bs=1024 count=256 2>/dev/null; exec nginx -c /tmp/graceful-nginx.conf -g "daemon off;"' \ + >/dev/null + binding=$("${runtime}" port "${graceful}" 8080/tcp) + host_port=${binding##*:} + started="" + for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "http://127.0.0.1:${host_port}/healthz" || true)" = 200; then + started=1 + break + fi + sleep 1 + done + if test -z "${started}"; then + "${runtime}" logs "${graceful}" >&2 + echo "Timed out waiting for the graceful-stop test server" >&2 + return 1 + fi + started="" + curl --fail --silent --show-error \ + "http://127.0.0.1:${host_port}/slow" --output "${graceful_output}" & + curl_pid=$! + for _ in {1..50}; do + size=$(wc -c < "${graceful_output}") + if test "${size}" -gt 0 && test "${size}" -lt 262144; then + started=1 + break + fi + sleep 0.1 + done + test -n "${started}" + "${runtime}" stop --time 20 "${graceful}" >/dev/null + wait "${curl_pid}" + test "$(wc -c < "${graceful_output}")" -eq 262144 + test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${graceful}")" = "0" + grep -Fq 'gracefully shutting down' <<< \ + "$("${runtime}" logs "${graceful}" 2>&1)" +} + wait_for_exit() { local name="$1" local state @@ -158,11 +275,32 @@ test "$("${runtime}" exec "${primary}" id -g)" = "0" assert_process_security "${primary}" assert_tmpfs_security "${primary}" "${runtime}" exec "${primary}" nginx -t -q +nginx_build=$("${runtime}" exec "${primary}" nginx -V 2>&1) +printf '%s\n' "${nginx_build}" | "${python}" \ + "${repository}/scripts/nginx_features.py" +architecture=$("${runtime}" image inspect --format '{{.Architecture}}' "${image}") +expected_rpm_manifest_sha256=$( + "${python}" "${repository}/scripts/artifacts.py" rpm-manifest \ + "${repository}/artifacts/locks/${architecture}.json" \ + | tr -d '\r' | sha256sum | cut -d' ' -f1 +) +actual_rpm_manifest_sha256=$( + "${runtime}" exec "${primary}" cat /usr/share/nginx-ubi/rpm-manifest.tsv \ + | sha256sum | cut -d' ' -f1 +) +test "${actual_rpm_manifest_sha256}" = "${expected_rpm_manifest_sha256}" +test "$("${runtime}" exec "${primary}" \ + sh -c 'wc -l < /usr/share/nginx-ubi/rpm-manifest.tsv')" -eq 80 +"${runtime}" exec "${primary}" test ! -w /usr/share/nginx-ubi/rpm-manifest.tsv +"${runtime}" exec "${primary}" test -d /usr/lib64/nginx/modules +test -z "$("${runtime}" exec "${primary}" \ + find /usr/lib64/nginx/modules -mindepth 1 -maxdepth 1 -print)" "${runtime}" exec "${primary}" test ! -w /etc/nginx/nginx.conf "${runtime}" exec "${primary}" sh -c \ '! command -v dnf && ! command -v microdnf && ! command -v rpm && ! command -v yum' "${runtime}" exec "${primary}" sh -c \ '! (printf probe > /root-filesystem-probe) >/dev/null 2>&1' +"${runtime}" exec "${primary}" test ! -e /etc/yum.repos.d # The variable expands in the inner container shell, not this script. # shellcheck disable=SC2016 "${runtime}" exec "${primary}" sh -c 'read -r pid < /tmp/nginx.pid; test "${pid}" = "1"' @@ -172,10 +310,10 @@ host_port="${binding##*:}" test "$(curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")" = "ok" grep -Fq 'NGINX on UBI 9' <<< \ "$(curl --fail --silent --show-error "http://127.0.0.1:${host_port}/")" -test "$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ +test "$(curl --silent --show-error --output "${null_device}" --write-out '%{http_code}' \ "http://127.0.0.1:${host_port}/missing?smoke-probe=value")" = "404" grep -Eiq '^server: nginx[[:space:]]*$' <<< \ - "$(curl --fail --silent --show-error --dump-header - --output /dev/null \ + "$(curl --fail --silent --show-error --dump-header - --output "${null_device}" \ "http://127.0.0.1:${host_port}/healthz")" primary_logs="$("${runtime}" logs "${primary}" 2>&1)" grep -Fq '/missing?smoke-probe=value' <<< "${primary_logs}" @@ -184,7 +322,7 @@ if grep -Fq 'GET /healthz' <<< "${primary_logs}"; then exit 1 fi -"${runtime}" exec "${primary}" nginx -s reload +assert_reload_replaced_workers "${primary}" test "$(curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")" = "ok" wait_for_log "${primary}" 'reconfiguring' @@ -203,21 +341,24 @@ assert_tmpfs_security "${arbitrary}" --security-opt "${no_new_privileges}" \ "${image}" >/dev/null wait_for_exit "${missing_tmp}" -grep -Eiq 'read-only file system|/tmp/nginx.pid' <<< \ - "$("${runtime}" logs "${missing_tmp}" 2>&1)" +missing_tmp_logs=$("${runtime}" logs "${missing_tmp}" 2>&1) +grep -Fq '/tmp/nginx-client-body' <<< "${missing_tmp_logs}" +grep -Eiq 'read-only file system|permission denied' <<< "${missing_tmp_logs}" "${runtime}" run --detach --name "${invalid_config}" \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ --cap-drop ALL \ --security-opt "${no_new_privileges}" \ - --entrypoint sh \ - "${image}" -eu -c \ - 'printf "invalid_directive;\n" > /tmp/invalid.conf; exec nginx -t -c /tmp/invalid.conf' \ + --volume "${script_dir}/fixtures/invalid-nginx.conf:/etc/nginx/nginx.conf:ro" \ + "${image}" \ >/dev/null wait_for_exit "${invalid_config}" -grep -Eiq 'unknown directive.*invalid_directive|emerg' <<< \ - "$("${runtime}" logs "${invalid_config}" 2>&1)" +invalid_logs=$("${runtime}" logs "${invalid_config}" 2>&1) +grep -Eiq 'unknown directive.*invalid_directive' <<< "${invalid_logs}" +grep -Fq '/etc/nginx/nginx.conf:1' <<< "${invalid_logs}" + +assert_active_request_drains_on_stop assert_clean_exit "${arbitrary}" assert_clean_exit "${primary}" diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..647ffcc --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import copy +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import components # noqa: E402 + + +class ComponentInventoryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.repository = Path(__file__).resolve().parents[1] + cls.inventory_path = cls.repository / "artifacts" / "components.json" + cls.inventory = components.read_inventory(cls.inventory_path) + + def write_inventory(self, value: dict) -> Path: + temporary = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", dir=self.repository, delete=False, encoding="utf-8" + ) + self.addCleanup(Path(temporary.name).unlink, missing_ok=True) + with temporary: + json.dump(value, temporary) + return Path(temporary.name) + + def test_repository_inventory_covers_both_reviewed_locks(self) -> None: + result = components.validate_inventory(self.inventory_path, self.repository) + self.assertEqual(len(result["components"]), 79) + + def test_missing_component_is_rejected(self) -> None: + value = copy.deepcopy(self.inventory) + value["components"].pop() + with self.assertRaisesRegex(components.InventoryError, "component names differ"): + components.validate_inventory(self.write_inventory(value), self.repository) + + def test_lock_hash_drift_is_rejected(self) -> None: + value = copy.deepcopy(self.inventory) + value["locks"]["amd64"]["sha256"] = "0" * 64 + with self.assertRaisesRegex(components.InventoryError, "no longer matches"): + components.validate_inventory(self.write_inventory(value), self.repository) + + def test_wrong_publisher_policy_is_rejected(self) -> None: + value = copy.deepcopy(self.inventory) + nginx = next(item for item in value["components"] if item["name"] == "nginx") + nginx["policy"] = "redhat-ubi9" + with self.assertRaisesRegex(components.InventoryError, "wrong publisher policy"): + components.validate_inventory(self.write_inventory(value), self.repository) + + def test_rpm_headers_must_match_the_reviewed_metadata(self) -> None: + inventory = components.validate_inventory(self.inventory_path, self.repository) + records = {item["name"]: item for item in inventory["components"]} + rpm_metadata = copy.deepcopy(records) + lock_path = self.repository / "artifacts" / "locks" / "amd64.json" + lock = json.loads(lock_path.read_text(encoding="utf-8")) + filenames = {item["filename"]: item["name"] for item in lock["packages"]} + + def rpm_query(command: list[str], **_kwargs: object) -> SimpleNamespace: + name = filenames[Path(command[-1]).name] + record = rpm_metadata[name] + vendor = inventory["_policy_vendors"][record["policy"]] + output = "\t".join((name, record["license"], record["source_rpm"], vendor)) + return SimpleNamespace(returncode=0, stdout=output, stderr="") + + with mock.patch.object(components.subprocess, "run", side_effect=rpm_query): + components.verify_rpms(inventory, lock_path, Path("unused-bundle")) + records["nginx"]["license"] = "wrong license" + with self.assertRaisesRegex(components.InventoryError, "metadata differs"): + components.verify_rpms(inventory, lock_path, Path("unused-bundle")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nginx_features.py b/tests/test_nginx_features.py new file mode 100644 index 0000000..9847edb --- /dev/null +++ b/tests/test_nginx_features.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import copy +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import nginx_features # noqa: E402 + + +class NginxFeatureInventoryTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + repository = Path(__file__).resolve().parents[1] + cls.inventory = nginx_features.validate_inventory( + repository / "artifacts" / "nginx-features.json", + repository / "artifacts" / "lock-inputs.json", + ) + + def output(self, arguments: list[str] | None = None) -> str: + selected = arguments or ( + self.inventory["compiled_features"] + + self.inventory["compiled_optional_modules"] + ) + return ( + f"nginx version: nginx/{self.inventory['nginx_version']}\n" + f"configure arguments: {' '.join(selected)}\n" + ) + + def test_reviewed_feature_inventory_is_accepted(self) -> None: + nginx_features.validate_nginx_v(self.output(), self.inventory) + + def test_missing_or_unexpected_module_is_rejected(self) -> None: + arguments = ( + self.inventory["compiled_features"] + + self.inventory["compiled_optional_modules"] + ) + with self.assertRaisesRegex(nginx_features.FeatureError, "optional modules"): + nginx_features.validate_nginx_v(self.output(arguments[:-1]), self.inventory) + with self.assertRaisesRegex(nginx_features.FeatureError, "external module"): + nginx_features.validate_nginx_v( + self.output(arguments + ["--add-module=/unreviewed"]), self.inventory + ) + + def test_version_and_reviewed_input_drift_are_rejected(self) -> None: + with self.assertRaisesRegex(nginx_features.FeatureError, "version differs"): + nginx_features.validate_nginx_v( + self.output().replace("nginx/1.30.4", "nginx/1.30.3"), self.inventory + ) + changed = copy.deepcopy(self.inventory) + changed["compiled_features"].pop() + with self.assertRaisesRegex(nginx_features.FeatureError, "feature flags"): + nginx_features.validate_nginx_v(self.output(), changed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transfer.py b/tests/test_transfer.py new file mode 100644 index 0000000..16109e5 --- /dev/null +++ b/tests/test_transfer.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import transfer # noqa: E402 + + +REVISION = "1" * 40 +LOCK_SHA256 = "2" * 64 +INVENTORY_SHA256 = "3" * 64 + + +class DisconnectedTransferTests(unittest.TestCase): + def setUp(self) -> None: + repository = Path(__file__).resolve().parents[1] + self.temporary = tempfile.TemporaryDirectory(dir=repository) + self.root = Path(self.temporary.name) + (self.root / "bundle" / "rpms").mkdir(parents=True) + (self.root / "bundle" / "rpms" / "example.rpm").write_bytes(b"rpm") + (self.root / "bases").mkdir() + (self.root / "bases" / "runtime.oci.tar").write_bytes(b"base") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def create(self) -> str: + return transfer.create_manifest( + self.root, "amd64", REVISION, LOCK_SHA256, INVENTORY_SHA256 + ) + + def verify(self, digest: str) -> None: + transfer.verify_manifest( + self.root, digest, "amd64", REVISION, LOCK_SHA256, INVENTORY_SHA256 + ) + + def test_exact_transfer_set_is_accepted(self) -> None: + digest = self.create() + self.verify(digest) + + def test_manifest_requires_separately_conveyed_digest(self) -> None: + self.create() + with self.assertRaisesRegex(transfer.TransferError, "separately conveyed"): + self.verify("0" * 64) + + def test_modified_missing_and_unexpected_payloads_are_rejected(self) -> None: + for mutation in ("modified", "missing", "unexpected"): + with self.subTest(mutation=mutation): + if (self.root / transfer.MANIFEST_NAME).exists(): + (self.root / transfer.MANIFEST_NAME).unlink() + rpm = self.root / "bundle" / "rpms" / "example.rpm" + rpm.write_bytes(b"rpm") + unexpected = self.root / "unexpected" + unexpected.unlink(missing_ok=True) + digest = self.create() + if mutation == "modified": + rpm.write_bytes(b"changed") + elif mutation == "missing": + rpm.unlink() + else: + unexpected.write_bytes(b"extra") + with self.assertRaisesRegex(transfer.TransferError, "payload inventory"): + self.verify(digest) + + def test_rollback_context_mismatch_is_rejected(self) -> None: + digest = self.create() + with self.assertRaisesRegex(transfer.TransferError, "artifact_lock_sha256"): + transfer.verify_manifest( + self.root, digest, "amd64", REVISION, "4" * 64, INVENTORY_SHA256 + ) + + def test_existing_manifest_is_not_overwritten(self) -> None: + self.create() + with self.assertRaisesRegex(transfer.TransferError, "refusing to overwrite"): + self.create() + + def test_repository_context_binds_the_reviewed_lock_and_inventory(self) -> None: + repository = Path(__file__).resolve().parents[1] + arguments = Namespace( + repository=repository, + lock=repository / "artifacts" / "locks" / "amd64.json", + inputs=repository / "artifacts" / "lock-inputs.json", + inventory=repository / "artifacts" / "components.json", + architecture="amd64", + repository_revision="1" * 40, + ) + git_results = [ + SimpleNamespace(returncode=0, stdout="1" * 40 + "\n"), + SimpleNamespace(returncode=0, stdout=""), + ] + with mock.patch.object(transfer.subprocess, "run", side_effect=git_results): + lock_sha256, inventory_sha256 = transfer.validated_context(arguments) + self.assertEqual(len(lock_sha256), 64) + self.assertEqual(len(inventory_sha256), 64) + + +if __name__ == "__main__": + unittest.main() From 7a5649bb1481126ccdccf51c882b4965b09dce2a Mon Sep 17 00:00:00 2001 From: joey-huckabee <138994589+joey-huckabee@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:09:55 -0500 Subject: [PATCH 2/2] build: rebind the component inventory to the refreshed locks The artifact locks moved onto the patched UBI 9.8 bases, so the accountability inventory no longer matched them. Rebind both lock SHA-256 values and update the six affected source-RPM records: openssl, openssl-libs -> openssl-3.5.8-1.el9_8.src.rpm systemd, systemd-libs, systemd-pam, systemd-rpm-macros -> systemd-252-67.el9_8.6.src.rpm License, vendor, and source-RPM records were verified against the actual RPM headers of a freshly acquired AMD64 bundle rather than assumed: all 79 packages verify, and no license tag changed across either rebuild. The package set, publisher policies, lifecycle boundaries, and update owners are unchanged. --- artifacts/components.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/artifacts/components.json b/artifacts/components.json index 511fea8..87a4ee3 100644 --- a/artifacts/components.json +++ b/artifacts/components.json @@ -3,11 +3,11 @@ "locks": { "amd64": { "path": "artifacts/locks/amd64.json", - "sha256": "864428143fb11dcf5bedcf2abf6df950fd557c37650410b2f28c56cbd36ae0d5" + "sha256": "ec35b2bcd7f1de1b6f1416a18eed5e3cc186624776f9ad793f22940c9f034456" }, "arm64": { "path": "artifacts/locks/arm64.json", - "sha256": "213747506323650d82d8dd8db946581ad002071d3c61f2a070aa743b88554b13" + "sha256": "9d53e9d6cccb8d291ece70e9e0605248e5e7033c8d7734ea1953d95edf226ad2" } }, "policies": [ @@ -87,10 +87,10 @@ {"name": "ncurses-base", "source_rpm": "ncurses-6.2-12.20210508.el9.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, {"name": "ncurses-libs", "source_rpm": "ncurses-6.2-12.20210508.el9.src.rpm", "license": "MIT", "policy": "redhat-ubi9"}, {"name": "nginx", "source_rpm": "nginx-1.30.4-1.el9.ngx.src.rpm", "license": "2-clause BSD-like license", "policy": "nginx-stable"}, - {"name": "openssl", "source_rpm": "openssl-3.5.5-6.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, + {"name": "openssl", "source_rpm": "openssl-3.5.8-1.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, {"name": "openssl-fips-provider", "source_rpm": "openssl-fips-provider-3.0.7-11.el9_8.src.rpm", "license": "ASL 2.0", "policy": "redhat-ubi9"}, {"name": "openssl-fips-provider-so", "source_rpm": "openssl-fips-provider-3.0.7-11.el9_8.src.rpm", "license": "ASL 2.0", "policy": "redhat-ubi9"}, - {"name": "openssl-libs", "source_rpm": "openssl-3.5.5-6.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, + {"name": "openssl-libs", "source_rpm": "openssl-3.5.8-1.el9_8.src.rpm", "license": "Apache-2.0", "policy": "redhat-ubi9"}, {"name": "p11-kit", "source_rpm": "p11-kit-0.26.4-1.el9_8.src.rpm", "license": "BSD-3-Clause", "policy": "redhat-ubi9"}, {"name": "p11-kit-trust", "source_rpm": "p11-kit-0.26.4-1.el9_8.src.rpm", "license": "BSD-3-Clause", "policy": "redhat-ubi9"}, {"name": "pam", "source_rpm": "pam-1.5.1-28.el9_8.1.src.rpm", "license": "BSD and GPLv2+", "policy": "redhat-ubi9"}, @@ -103,10 +103,10 @@ {"name": "sed", "source_rpm": "sed-4.8-10.el9.src.rpm", "license": "GPLv3+", "policy": "redhat-ubi9"}, {"name": "setup", "source_rpm": "setup-2.13.7-10.el9.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, {"name": "shadow-utils", "source_rpm": "shadow-utils-4.9-16.el9.src.rpm", "license": "BSD and GPLv2+", "policy": "redhat-ubi9"}, - {"name": "systemd", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, - {"name": "systemd-libs", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT", "policy": "redhat-ubi9"}, - {"name": "systemd-pam", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, - {"name": "systemd-rpm-macros", "source_rpm": "systemd-252-67.el9_8.4.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd", "source_rpm": "systemd-252-67.el9_8.6.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd-libs", "source_rpm": "systemd-252-67.el9_8.6.src.rpm", "license": "LGPLv2+ and MIT", "policy": "redhat-ubi9"}, + {"name": "systemd-pam", "source_rpm": "systemd-252-67.el9_8.6.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, + {"name": "systemd-rpm-macros", "source_rpm": "systemd-252-67.el9_8.6.src.rpm", "license": "LGPLv2+ and MIT and GPLv2+", "policy": "redhat-ubi9"}, {"name": "tzdata", "source_rpm": "tzdata-2026c-1.el9_8.src.rpm", "license": "Public Domain", "policy": "redhat-ubi9"}, {"name": "util-linux", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "GPLv2 and GPLv2+ and LGPLv2+ and BSD with advertising and Public Domain", "policy": "redhat-ubi9"}, {"name": "util-linux-core", "source_rpm": "util-linux-2.37.4-25.el9.src.rpm", "license": "GPLv2 and GPLv2+ and LGPLv2+ and BSD with advertising and Public Domain", "policy": "redhat-ubi9"},