Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 48 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,9 @@ jobs:
include:
- architecture: amd64
runner: ubuntu-24.04
platform: linux/amd64
machine: x86_64
- architecture: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
machine: aarch64
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
Expand All @@ -100,25 +98,58 @@ jobs:
with:
persist-credentials: false

- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0

- name: Confirm native runner architecture
env:
EXPECTED_MACHINE: ${{ matrix.machine }}
run: test "$(uname -m)" = "${EXPECTED_MACHINE}"

- name: Build test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Containerfile
platforms: ${{ matrix.platform }}
load: true
push: false
tags: ${{ env.TEST_IMAGE }}
cache-from: type=gha,scope=image-${{ matrix.architecture }}
cache-to: type=gha,mode=max,scope=image-${{ matrix.architecture }}
- name: Confirm hermetic-build tools
run: |
podman version
rpm --version
rpmsign --version
gpg --version | head -n 1

- name: Acquire architecture artifact bundle
run: >-
python scripts/artifacts.py acquire
--lock artifacts/locks/${{ matrix.architecture }}.json
--output .artifact-bundle/${{ matrix.architecture }}

- name: Verify architecture artifact bundle
run: >-
bash scripts/verify-rpm-bundle.sh
artifacts/locks/${{ matrix.architecture }}.json
.artifact-bundle/${{ matrix.architecture }}

- name: Reject invalid RPM bundles
run: >-
python tests/rpm-bundle-negative.py
--lock artifacts/locks/${{ matrix.architecture }}.json
--bundle .artifact-bundle/${{ matrix.architecture }}

- name: Build without network access or image pulling
env:
PULL_BASES: "1"
run: >-
bash scripts/build-image.sh
${{ matrix.architecture }}
"${TEST_IMAGE}"

- name: Prove hermetic build boundaries fail closed
run: bash tests/hermetic-build-negative.sh ${{ matrix.architecture }}

- name: Run native Podman restricted-runtime tests
env:
CONTAINER_RUNTIME: podman
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/smoke.sh

- name: Transfer image to Docker compatibility environment
run: |
podman save --format docker-archive --output /tmp/nginx-ubi-image.tar "${TEST_IMAGE}"
docker load --input /tmp/nginx-ubi-image.tar
rm --force /tmp/nginx-ubi-image.tar

- name: Confirm loaded image architecture
env:
Expand All @@ -127,7 +158,7 @@ jobs:
test "$(docker image inspect --format '{{.Architecture}}' "${TEST_IMAGE}")"
= "${EXPECTED_ARCHITECTURE}"

- name: Run restricted-runtime smoke tests
- name: Run Docker compatibility restricted-runtime tests
env:
CONTAINER_RUNTIME: docker
IMAGE: ${{ env.TEST_IMAGE }}
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ but container releases use the upstream-derived format documented in
artifact bundles, including exact inventory, digest, RPM signature, signer,
NEVRA, architecture, and lock-manifest verification without storing source
credentials or private trust material in the repository or image build.
- Migrated the development image to the selected official NGINX 1.30.4 RPM and
its exact architecture lock, with local-only RPM installation, installed
inventory comparison, digest-pinned base preloading, network-disabled
assembly, and an enforceable no-pull policy in local and native CI builds.
- Added hermetic-build rejection checks for a wrong lock identity and an
unavailable base, and removed repository configuration from the final
filesystem.
- Added native real-RPM negative tests for modified, unsigned, signer-mismatched,
wrong-version, wrong-architecture, missing, and unexpected bundle content;
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.
- 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
Expand Down
50 changes: 20 additions & 30 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,27 @@
ARG UBI_MINIMAL_IMAGE="registry.access.redhat.com/ubi9/ubi-minimal:9.8@sha256:e5161a7d7d99cf22e4f34b72e111211a399d956d9b0e8714da18e9c4c8151041"
ARG UBI_MICRO_IMAGE="registry.access.redhat.com/ubi9/ubi-micro:9.8@sha256:7a0454cbd9bd847e8f6a63b6f0254a6efbeb6e0ed71a5d824a4f6cccbe626650"

# The caller overrides this empty stage with a verified local named context.
# Keeping the fallback empty makes a missing bundle fail instead of pulling an
# image that happens to use the context name.
FROM scratch AS artifact_bundle

FROM ${UBI_MINIMAL_IMAGE} AS builder

ARG NGINX_MODULE_STREAM="1.26"
ARG NGINX_RPM_VERSION="2:1.26.3-9.module+el9.8.0+24845+a897661e.4"
ARG ARTIFACT_LOCK_SHA256

COPY --from=artifact_bundle / /bundle/
COPY --chmod=0755 scripts/install-rpm-bundle.sh /usr/local/bin/install-rpm-bundle

# Install the exact Red Hat NGINX build and its runtime dependency closure into
# a separate root. The UBI Micro final stage receives no package-management
# commands or builder caches.
# hadolint ignore=DL3041
RUN microdnf install -y dnf \
&& mkdir -p /runtime \
&& dnf module enable -y \
--installroot=/runtime \
--releasever=9 \
"nginx:${NGINX_MODULE_STREAM}" \
&& dnf install -y \
--installroot=/runtime \
--releasever=9 \
--setopt=install_weak_deps=0 \
--setopt=keepcache=0 \
"nginx-core-${NGINX_RPM_VERSION}" \
ca-certificates tzdata \
&& dnf clean all \
&& microdnf clean all \
# Installation consumes only the complete local RPM closure. Network access
# and base-image pulling are disabled by the invoking build command.
RUN test -n "${ARTIFACT_LOCK_SHA256}" \
&& test "$(tr -d '\n' </bundle/LOCK-SHA256)" = "${ARTIFACT_LOCK_SHA256}" \
&& /usr/local/bin/install-rpm-bundle /bundle /runtime \
&& rm -rf \
/runtime/run/* \
/runtime/tmp/* \
/runtime/var/cache/dnf \
/runtime/var/cache/nginx \
/runtime/var/cache/* \
/runtime/var/log/* \
/runtime/var/tmp/* \
&& mkdir -p /runtime/var/log/nginx \
Expand All @@ -42,19 +34,17 @@ RUN microdnf install -y dnf \

FROM ${UBI_MICRO_IMAGE}

ARG NGINX_VERSION="1.26.3"
ARG NGINX_RPM_VERSION="2:1.26.3-9.module+el9.8.0+24845+a897661e.4"

LABEL org.opencontainers.image.title="NGINX on Red Hat UBI 9" \
org.opencontainers.image.description="A security-oriented, rootless NGINX image built on Red Hat UBI 9 Micro" \
org.opencontainers.image.source="https://github.com/datopsis/nginx-ubi9" \
org.opencontainers.image.documentation="https://github.com/datopsis/nginx-ubi9#readme" \
org.opencontainers.image.source="https://github.com/datopsis/nginx-ubi" \
org.opencontainers.image.documentation="https://github.com/datopsis/nginx-ubi#readme" \
org.opencontainers.image.licenses="BSD-2-Clause AND Apache-2.0" \
org.opencontainers.image.vendor="Datopsis" \
org.opencontainers.image.version="${NGINX_VERSION}" \
io.datopsis.nginx.rpm-version="${NGINX_RPM_VERSION}"
org.opencontainers.image.version="1.30.4" \
io.datopsis.nginx.rpm-version="2:1.30.4-1.el9.ngx"

COPY --from=builder /runtime/ /
RUN rm -rf /etc/yum.repos.d
COPY --chown=0:0 --chmod=0644 container/nginx.conf /etc/nginx/nginx.conf
COPY --chown=0:0 --chmod=0644 container/conf.d/default.conf /etc/nginx/conf.d/default.conf
COPY --chown=0:0 --chmod=0644 container/html/index.html /usr/share/nginx/html/index.html
Expand Down
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,24 +168,37 @@ bash scripts/verify-rpm-bundle.sh \
artifacts/locks/amd64.json .artifact-bundle/amd64
```

Native CI additionally mutates isolated copies of each acquired real-RPM
bundle to prove rejection of invalid signatures, signer mismatches, metadata,
architecture, and inventory. Those tests require `rpmsign` and are not part of
the download-free unit suite.

Preload the locked bases and perform a network-disabled build with pulling
forbidden:

```console
bash scripts/build-image.sh \
amd64 localhost/nginx-ubi9:development
```

The acquisition guide documents ARM64, optional source RPMs, and protected
alternate-source configuration. Python 3, RPM, and GnuPG are required for
preparation.
alternate-source configuration. Python 3, RPM, GnuPG, Bash, and Podman are
required for preparation and assembly. Set `PULL_BASES=0` to require already
present bases for a disconnected build; assembly always uses `--pull=never`
and `--network none`.

Build and exercise the current AMD64 development image with rootless Podman on
native Linux or WSL2:

```console
podman build --format docker --file Containerfile \
--tag localhost/nginx-ubi9:development .
CONTAINER_RUNTIME=podman IMAGE=localhost/nginx-ubi9:development \
bash tests/smoke.sh
```

Or start the hardened default service with Compose:

```console
podman compose up --build
podman compose up
curl --fail http://127.0.0.1:8080/healthz
```

Expand Down
3 changes: 0 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
services:
nginx:
image: localhost/nginx-ubi9:development
build:
context: .
dockerfile: Containerfile
ports:
- "127.0.0.1:8080:8080"
read_only: true
Expand Down
40 changes: 30 additions & 10 deletions docs/ARTIFACT-ACQUISITION.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# External artifact acquisition

Status: reviewed architecture locks, lock-update tooling, and verified
official and alternate-source acquisition are implemented. The `Containerfile`
still resolves RPMs during the builder stage and must be migrated to consume
the verified bundle.
Status: locked-file acquisition, verification, and network-disabled local
assembly are implemented. Native CI exercises the same architecture-specific
path.

## Build contract

Expand Down Expand Up @@ -177,6 +176,20 @@ Assembly must enforce:
The build may validate the shape of its local input for defensive diagnostics,
but publisher and download verification remain CI preparation responsibilities.

`scripts/build-image.sh` re-verifies the selected bundle, explicitly preloads
the two digest-pinned UBI bases, confirms they are present, and invokes Podman
with `--pull=never`, `--network none`, and a named local artifact context. Set
`PULL_BASES=0` only after transferring both locked bases into the local image
store. The `Containerfile` installs the complete RPM transaction without DNF,
repository metadata, or package-network access and compares the installed RPM
inventory with the lock-derived manifest.

The regular build context excludes all artifact staging directories. Only the
verified bundle is supplied as the named context, and only its public signing
keys, RPMs, and generated manifests are visible to the temporary builder. The
final image receives the installed runtime tree—not the input bundle—and
removes repository configuration inherited from the base or RPM closure.

### 5. Prove hermetic behavior

CI will run an assembly with network disabled and inspect image history and
Expand Down Expand Up @@ -240,16 +253,23 @@ dependency.

## Required tests

Unit tests exercise schema, reviewed-input, digest, and inventory rejection
without downloads.

The implementation is incomplete until automated tests additionally
demonstrate rejection of:
The lock and bundle tests demonstrate rejection of:

- a modified RPM;
- an RPM signed by an unapproved key;
- the wrong NEVRA or architecture;
- an extra or missing dependency RPM;
- a base image with the wrong digest;
- a base image with the wrong digest.

Unit tests exercise schema, reviewed-input, digest, and inventory rejection
without downloads. After native acquisition, `tests/rpm-bundle-negative.py`
creates isolated variants of the real bundle and proves that byte tampering,
signature removal, signer mismatch, wrong version, wrong architecture, missing
RPMs, and additional RPMs are rejected. Test-only mutations and stripped RPMs
remain in temporary directories and are deleted after the run.

The broader hermetic and secret-isolation gate still requires automated
evidence for:

- any attempted network access during assembly; and
- repository credentials or trust material found in the image or its history.
15 changes: 11 additions & 4 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,15 @@ identity before a bundle is exposed to assembly. Unit tests cover atomic
publication and fail-closed inventory and source-map behavior without
downloading artifacts.

The current development build still resolves RPMs from inside the builder
stage. Migrating it to consume the verified bundle with networking and pulling
disabled remains the next step in the acquisition contract.
Native image jobs acquire and verify their matching bundle, preload the exact
digest-pinned UBI bases, and build with Podman using `--network none` and
`--pull=never`. They also prove that the build rejects a wrong lock identity
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.

The official public source is the default. An alternate approved source can be
selected through protected CI configuration, but private endpoints,
Expand All @@ -94,7 +100,8 @@ The stable protected check names are `lint`, `configuration security`, and
The implemented image pipeline performs:

1. Trivy build-configuration scanning.
2. Native architecture builds and restricted-runtime scenario tests covering
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.
Expand Down
6 changes: 4 additions & 2 deletions docs/CONTRIBUTOR-ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ stat -fc %T /sys/fs/cgroup
Then use the canonical repository workflow:

```console
podman build --format docker --file Containerfile \
--tag localhost/nginx-ubi9:development .
python scripts/artifacts.py acquire \
--lock artifacts/locks/amd64.json \
--output .artifact-bundle/amd64
bash scripts/build-image.sh amd64 localhost/nginx-ubi9:development
CONTAINER_RUNTIME=podman IMAGE=localhost/nginx-ubi9:development \
bash tests/smoke.sh
```
Expand Down
24 changes: 9 additions & 15 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,34 +51,28 @@ are separately approved. They must not delay the core first release.

Work proceeds in this dependency order:

1. Migrate the image to the exact official NGINX RPM and require
network-disabled, no-pull assembly from a verified local bundle.
2. Close rootless failure diagnostics, graceful lifecycle tests, and
1. Close rootless failure diagnostics, graceful lifecycle tests, and
package/module inventory checks.
3. Qualify the minimum static, reverse-proxy, structured-logging, and TLS
2. 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.
4. Complete the repository policy files, support boundary, threat model,
3. 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.
5. Qualify standalone rootless Podman/Quadlet deployment, systemd lifecycle,
4. Qualify standalone rootless Podman/Quadlet deployment, systemd lifecycle,
journald collection, controlled-network operation, and rollback on an exact
supported Linux host.
6. Rehearse the multi-architecture publish, provenance, SBOM, signing, and
5. Rehearse the multi-architecture publish, provenance, SBOM, signing, and
verification workflow from an untagged release candidate.
7. Freeze inputs, regenerate release-candidate evidence, approve findings,
6. Freeze inputs, regenerate release-candidate evidence, approve findings,
create the immutable tag, publish by digest, and verify the release.

Steps 1 and 2 are the immediate engineering critical path. Steps 3 and 4 can
proceed in parallel only where they do not assume an unfrozen NGINX package or
module set.
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

- [ ] Make ordinary CI and local builds consume verified local bundles with
build networking and image pulling disabled.
- [ ] Add negative tests for tampered, unsigned, wrong-version,
wrong-architecture, missing, and unexpected bundle contents.
- [ ] Record source, redistribution, licensing, support lifecycle, and update
ownership for every runtime component.
- [ ] Define lock refresh, key rotation, artifact mirroring, rollback, and
Expand Down
Loading
Loading