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
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
run: >-
python -m unittest
tests.test_artifacts tests.test_components tests.test_nginx_features
tests.test_transfer -v
tests.test_profile_logs tests.test_tls_material tests.test_transfer -v

- name: Audit GitHub Actions security
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
Expand Down Expand Up @@ -154,6 +154,18 @@ jobs:
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/smoke.sh

- name: Qualify native Podman HTTP profiles
env:
CONTAINER_RUNTIME: podman
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/profiles.sh

- name: Qualify native Podman TLS profiles
env:
CONTAINER_RUNTIME: podman
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/tls.sh

- name: Transfer image to Docker compatibility environment
run: |
podman save --format docker-archive --output /tmp/nginx-ubi-image.tar "${TEST_IMAGE}"
Expand All @@ -173,6 +185,18 @@ jobs:
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/smoke.sh

- name: Qualify Docker compatibility HTTP profiles
env:
CONTAINER_RUNTIME: docker
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/profiles.sh

- name: Qualify Docker compatibility TLS profiles
env:
CONTAINER_RUNTIME: docker
IMAGE: ${{ env.TEST_IMAGE }}
run: bash tests/tls.sh

- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ but container releases use the upstream-derived format documented in
configuration and unwritable-temporary-path diagnostics, worker-replacing
reloads with PID 1 retained, and complete active-request draining on
`SIGQUIT` before a clean exit.
- Added qualified preview static-serving and HTTP reverse-proxy configurations
with bounded request handling, validated correlation IDs, query-free JSON
access events, safe forwarding-header behavior, explicit upstream failure,
and native Podman plus Docker compatibility tests under restricted runtime
controls on AMD64 and ARM64.
- Added qualified preview TLS 1.2/1.3 termination, mandatory mutual-TLS, and
verified HTTPS-upstream profiles with an ephemeral CA-issued rehearsal for
protocol bounds, hostname and chain validation, client authentication, leaf
renewal, untrusted roots, missing keys, restricted runtime, and secret-safe
structured logging on native Podman and Docker compatibility execution.
- Added 14 focused unit tests for structured profile logs, covering exact
schemas, JSON escaping, type confusion, numeric bounds, timestamps,
correlation IDs, query exclusion, TLS results, upstream timing fields,
secret detection, and unique scenario selection.
- Enforced mounted CRLs for mutual-TLS clients and HTTPS upstreams; extended the
ephemeral PKI rehearsal to reject revoked certificates and to prove old,
overlapping, and new-only CA trust states without disabling chain or hostname
verification.
- Added a public-metadata TLS lifecycle checker with stable JSON output and 12
boundary-focused unit tests for certificate expiry, CRL freshness, timezone
handling, exact alert thresholds, malformed output, and fail-closed OpenSSL
inspection errors.
- 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
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ root filesystem, explicit `tmpfs` mounts, dropped capabilities, and
and the security boundary of each one.
- [Logging](docs/LOGGING.md) documents current stream behavior, use-case fields,
sensitive-data rules, and controlled-network responsibilities.
- [Qualified HTTP and TLS profiles](docs/CONFIGURATION-PROFILES.md) provides
tested static-serving, reverse-proxy, TLS termination, mutual-TLS, and
verified-upstream configurations, structured log schemas, and operational
boundaries.
- [TLS lifecycle](docs/TLS-LIFECYCLE.md) defines certificate and CRL
monitoring, renewal, overlapping-CA rotation, revocation response, rollback,
and the remaining platform cryptographic-policy boundary.
- [Deployment](docs/DEPLOYMENT.md) describes standalone rootless Podman with a
user systemd Quadlet, host logging, lifecycle operations, and qualification.
- [Threat model](docs/THREAT-MODEL.md) identifies assets, trust boundaries,
Expand Down Expand Up @@ -167,29 +174,25 @@ cases with:
```console
python -m unittest \
tests.test_artifacts tests.test_components tests.test_nginx_features \
tests.test_transfer -v
tests.test_profile_logs tests.test_tls_material tests.test_transfer -v
python scripts/components.py
```

Acquire and verify the exact AMD64 RPM bundle from the official sources:

```console
python scripts/artifacts.py acquire \
--lock artifacts/locks/amd64.json \
--output .artifact-bundle/amd64
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
Acquire and verify the exact AMD64 RPM bundle from the official sources, then
preload the locked bases and perform a network-disabled build with pulling
forbidden:

```console
python scripts/artifacts.py acquire \
--lock artifacts/locks/amd64.json \
--output .artifact-bundle/amd64
bash scripts/verify-rpm-bundle.sh \
artifacts/locks/amd64.json .artifact-bundle/amd64
bash scripts/build-image.sh \
amd64 localhost/nginx-ubi9:development
```
Expand All @@ -206,6 +209,10 @@ native Linux or WSL2:
```console
CONTAINER_RUNTIME=podman IMAGE=localhost/nginx-ubi9:development \
bash tests/smoke.sh
CONTAINER_RUNTIME=podman IMAGE=localhost/nginx-ubi9:development \
bash tests/profiles.sh
CONTAINER_RUNTIME=podman IMAGE=localhost/nginx-ubi9:development \
bash tests/tls.sh
```

Or start the hardened default service with Compose:
Expand Down
19 changes: 14 additions & 5 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ real bundle prove rejection of tampering, signature removal, signer mismatch,
wrong version, wrong architecture, missing RPMs, and additional RPMs. The
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.
transferred by local archive into Docker solely for the
existing compatibility smoke, HTTP and TLS profile qualification, 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 @@ -107,12 +107,21 @@ 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. They cover
native Podman and Docker-compatibility restricted-runtime, HTTP, and TLS
profile
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.
actionable startup failures. The profile suite additionally qualifies
static-serving and reverse-proxy defaults, structured JSON events,
correlation IDs, query exclusion, forwarded headers, method and path denial,
and upstream failure. The TLS suite generates ephemeral CAs and leaf
certificates to test TLS 1.2/1.3, mTLS, leaf renewal, hostname and chain
verification, client and upstream CRL enforcement, overlapping-CA trust
rotation, lifecycle deadline monitoring, negative trust cases, and secret-
safe diagnostics.
3. Trivy image vulnerability scanning.
4. SPDX inventory generation with Syft.
5. Independent fixed High/Critical vulnerability gating with Grype and a
Expand Down
167 changes: 167 additions & 0 deletions docs/CONFIGURATION-PROFILES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Qualified HTTP and TLS configuration profiles

The repository provides minimal static-serving and HTTP reverse-proxy
configurations under `examples/profiles`. Static serving, HTTP reverse proxy,
TLS termination, mutual TLS, and verified HTTPS upstream profiles are exercised
on native AMD64 and ARM64 runners with rootless Podman and then with Docker
compatibility execution. They remain **preview/unqualified** until an immutable
image release and its platform evidence explicitly name them as supported.

## Common contract

Both profiles:

- listen on unprivileged port `8080` and expose a fixed, unlogged `/healthz`;
- run under the image default identity or an arbitrary non-root UID in group
`0` with all capabilities dropped and `no-new-privileges` enabled;
- use only `/tmp` for PID and temporary state, allowing a read-only root;
- set `server_tokens off`, a `1m` request-body limit, bounded request and
keepalive timeouts, and `X-Content-Type-Options: nosniff`;
- accept `X-Request-ID` only when it contains 1--64 ASCII letters, digits,
periods, underscores, or hyphens and starts with a letter or digit;
- generate an NGINX request ID when the inbound value fails validation; and
- emit one JSON access event per application request to stdout while sending
operational messages at `notice` or higher to stderr.

A connection that never produces a request has no method: a rejected TLS
handshake, a malformed request line, or a client that disconnects before its
request is read. Those are not application requests, so the profiles suppress
their access events rather than emit a structured record whose method, URI, and
protocol are empty. They remain visible in the error stream, which is where a
failed handshake belongs. Deployments that need connection-level accounting
should collect the error stream or the platform's network telemetry rather than
relax this rule, because an access schema that admits empty required fields
cannot be validated.

The access event records `$uri`, never `$request`, `$request_uri`, `$args`,
headers, or bodies. Query strings, credentials, cookies, referrers, user-agent
values, and client-provided forwarding chains are therefore absent. JSON
escaping is enabled for every string field. Runtime or platform logging owns
collection, access control, capacity, rotation, retention, and disposal.

## Static content

[`examples/profiles/static/nginx.conf`](../examples/profiles/static/nginx.conf)
serves a read-only tree mounted at `/srv/www`. It permits `GET` and implicitly
`HEAD`, rejects other methods, disables directory indexes, denies dot-prefixed
path components, and returns `404` for absent content.

Its access-event schema is:

| Field | JSON type | Meaning |
| --- | --- | --- |
| `timestamp` | string | ISO 8601 event time. |
| `request_id` | string | Validated inbound or generated correlation ID. |
| `method` | string | Request method. |
| `uri` | string | Normalized path without query arguments. |
| `protocol` | string | Client HTTP protocol. |
| `status` | integer | Final client-facing response status. |
| `body_bytes_sent` | integer | Response-body bytes sent. |
| `request_time` | number | Total request duration in seconds. |

## HTTP reverse proxy

[`examples/profiles/reverse-proxy/nginx.conf`](../examples/profiles/reverse-proxy/nginx.conf)
proxies to `backend:8080`. Copy the example and replace that endpoint with the
deployment's approved service name. NGINX must be able to resolve it when the
configuration loads.

The profile deliberately overwrites `X-Forwarded-For` with the direct peer
address rather than extending a client-supplied chain. It also sets
`X-Forwarded-Proto`, forwards the validated or generated request ID, uses
HTTP/1.1 upstream keepalive, bounds connect/send/read timeouts, and disables
automatic retry. A failed or timed-out connection therefore produces a
client-facing `502` or `504` without silently attempting another backend.
HTTPS upstreams are outside this profile; use the forthcoming verified-
upstream TLS profile instead of merely changing the scheme.

In addition to the static fields, access events contain string-valued
`upstream_addr`, `upstream_status`, `upstream_connect_time`,
`upstream_header_time`, and `upstream_response_time`. NGINX can use `-` when an
upstream phase has no measurement. Treat backend addresses as operationally
sensitive when choosing log-reader access.

## TLS termination

[`examples/profiles/tls-termination/nginx.conf`](../examples/profiles/tls-termination/nginx.conf)
serves the static profile over port `8443`. Mount the ordered server certificate
chain as `/etc/nginx/tls/server.crt` and its matching private key as
`/etc/nginx/tls/server.key`. The profile permits TLS 1.2 and 1.3, restricts TLS
1.2 to ECDHE-RSA AEAD suites, disables session tickets, and adds HSTS without
claiming control over subdomains. TLS 1.3 cipher selection belongs to the
linked OpenSSL implementation and exact platform cryptographic policy.

TLS access events add `tls_protocol`, `tls_cipher`, `tls_server_name`,
`tls_session_reused`, and `tls_client_verify`. They deliberately exclude
certificate subjects, issuers, serials, fingerprints, and certificate content.

## Mutual TLS

[`examples/profiles/mutual-tls/nginx.conf`](../examples/profiles/mutual-tls/nginx.conf)
adds mandatory client-certificate authentication. Mount the issuing trust
bundle as `/etc/nginx/tls/client-ca.crt` and its current CRLs as
`/etc/nginx/tls/client.crl`. The profile verifies the chain and revocation state
to a maximum depth of two and records only `NONE`, `SUCCESS`, or NGINX's escaped
`FAILED:` result. It does not authorize a client identity: mapping a validated
certificate to application permissions remains a deployment-specific control.

## Verified HTTPS upstream

[`examples/profiles/tls-upstream/nginx.conf`](../examples/profiles/tls-upstream/nginx.conf)
extends the reverse proxy with TLS 1.2/1.3, chain verification, hostname
verification, SNI, and a bounded verification depth. Its example identity is
`backend.test`; copy the file and change `server`, `proxy_set_header Host`, and
`proxy_ssl_name` together to the reviewed service identity. Mount only the
narrow upstream trust bundle at `/etc/nginx/tls/upstream-ca.crt`. Do not reuse a
host-wide trust store merely to make validation succeed.

Mount current issuer CRLs at `/etc/nginx/tls/upstream.crl`; revoked backend
certificates fail closed as gateway errors.

The automated rehearsal generates short-lived private CAs, server and client
certificates outside the repository. It proves both TLS protocol versions,
trusted ingress, rejection of legacy TLS and untrusted chains, required and
untrusted client-certificate behavior, certificate/key readability by an
arbitrary UID, leaf-certificate renewal through validated reload, upstream
chain and hostname verification, absence of private material and client names
from logs, and useful missing-key diagnostics. The generated authorities are
test fixtures, never production trust anchors.

The extended lifecycle rehearsal also rejects revoked client and backend
certificates and proves old-only, old-plus-new overlap, and new-only upstream
trust states. See [TLS lifecycle](TLS-LIFECYCLE.md) for monitoring, renewal,
rotation, rollback, and the residual cryptographic-policy boundary.

## Mount, validate, and operate

Pin an immutable image digest in real deployments. This example shows the
static profile; also mount the content with an SELinux relabel option required
by the exact host policy when applicable:

```console
podman run --rm \
--read-only --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \
--cap-drop ALL --security-opt no-new-privileges \
--volume ./examples/profiles/static/nginx.conf:/etc/nginx/nginx.conf:ro \
--volume ./site:/srv/www:ro \
ghcr.io/datopsis/nginx-ubi@sha256:<digest> -t -q
```

After validation, remove `--rm`, publish `8080`, and replace `-t -q` with
`-g 'daemon off;'`. Apply ingress, egress, DNS, CPU, memory, process, and
connection limits outside the container. The reverse-proxy profile needs an
explicit network path only to approved DNS and backend destinations.

Prefer replacing the container with a validated configuration digest. If an
in-place reload is required, run `nginx -t -q` inside the container before
sending `HUP`, then verify worker replacement, health, error logs, and sample
traffic. Roll back by restoring the last reviewed configuration and replacing
the container; do not edit the mounted configuration inside a running
container.

Use `bash tests/profiles.sh` and `bash tests/tls.sh` against the development
image to reproduce the
positive, negative, forwarding-header, correlation-ID, query-exclusion,
upstream-failure, certificate, structured-log, and restricted-runtime checks.
These harnesses do not qualify a deployment's CA operations, DNS, network
policy, collector, retention, capacity, or host security controls.
Loading
Loading