diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8502537..9ec7874 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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}" @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index d12751c..5022e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 16f766d..496c4a3 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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 ``` @@ -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: diff --git a/docs/CI.md b/docs/CI.md index 4bf6132..801bdef 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -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, @@ -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 diff --git a/docs/CONFIGURATION-PROFILES.md b/docs/CONFIGURATION-PROFILES.md new file mode 100644 index 0000000..73161b9 --- /dev/null +++ b/docs/CONFIGURATION-PROFILES.md @@ -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: -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. diff --git a/docs/LOGGING.md b/docs/LOGGING.md index dad40f2..bb2b7f6 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -81,6 +81,27 @@ but the planned structured format will prefer `$time_iso8601`. The relevant source files are [`container/nginx.conf`](../container/nginx.conf) and [`container/conf.d/default.conf`](../container/conf.d/default.conf). +## Qualified preview HTTP and TLS formats + +The static, HTTP reverse-proxy, TLS termination, mutual-TLS, and verified- +upstream examples implement the safer structured contract described above. +They use JSON escaping, validate a bounded +`X-Request-ID` or generate `$request_id`, and log `$uri` rather than the query- +bearing request target. Tests parse every emitted access event, exercise JSON +escaping, reject leaked query markers, and verify correlation-ID forwarding. +The reverse-proxy test also proves that a client-supplied forwarding chain is +overwritten and that upstream failure is represented in the structured event. + +TLS ingress events additionally record protocol, cipher, requested server name, +session reuse, and the client-certificate verification result. They omit client +certificate identity and content. The exact field schemas and operational +boundary are documented in [Qualified HTTP and TLS configuration profiles](CONFIGURATION-PROFILES.md). +These examples do not change the generic development default described above +and do not qualify a runtime collector or its retention controls. They emit an +access event only for a real application request; a rejected TLS handshake or a +malformed request line is reported through the error stream instead of a +structured event with empty method, URI, and protocol fields. + ## Collection by runtime The runtime captures stdout and stderr. NGINX log rotation is therefore not a @@ -146,11 +167,10 @@ node, container, image digest, and restart identity. | Health/readiness | Suppress routine access events or use a dedicated minimal stream. | High-volume probe noise and backend detail. | | Rate/connection limits | Policy name, trusted limit key or pseudonym, outcome, status. | Raw identifiers when aggregation is enough; repetitive error-log alerts. | -NGINX provides upstream timing variables and TLS variables for these profiles, -but a variable's availability will be verified against the exact Red Hat RPM -build before its configuration is supported. Initial profile examples will use -JSON escaping and stable field names so collectors do not need to parse the -human-oriented development format. +NGINX provides upstream timing variables and TLS variables for these profiles. +The static and reverse-proxy variables are verified against the selected +official NGINX RPM build and use stable JSON field names. Variables needed by +the remaining profiles will be verified before those configurations qualify. ## Sensitive-data rules diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e08aab8..05a04dd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,9 +51,9 @@ are separately approved. They must not delay the core first release. Work proceeds in this dependency order: -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. +1. Close the remaining operational and platform evidence for the minimum + qualified HTTP and TLS profiles; keep additional profiles explicitly + preview until their tests close. 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. @@ -70,24 +70,20 @@ parallel only where it does not assume an unfrozen NGINX package or module set. ## Package 3: supported configurations and TLS -- [ ] Provide tested, minimal examples for static content, reverse proxying, - load balancing, WebSocket proxying, health/readiness endpoints, rate limits, - connection limits, and ClickHouse HTTP proxying. -- [ ] Establish safe defaults for request limits, timeouts, headers, server - tokens, method handling, DNS resolution, upstream verification, and failure - behavior without silently breaking general-purpose use. +- [ ] Provide tested, minimal examples for load balancing, WebSocket proxying, + extended health/readiness endpoints, rate limits, connection limits, and + ClickHouse HTTP proxying. +- [ ] Extend the qualified defaults to DNS resolution, upstream verification, + and the remaining profiles without silently breaking general-purpose use. - [ ] Document configuration mounting, validation, reload, rollback, logging, troubleshooting, and secret redaction. -- [ ] Implement and test structured logging profiles for static content, - reverse proxying, load balancing, TLS and mTLS, WebSockets, ClickHouse, - health endpoints, and request and connection limiting. -- [ ] Qualify log escaping, correlation IDs, query-string exclusion, runtime - collection, rotation ownership, pipeline failure, and retention evidence. -- [ ] Provide TLS 1.2/1.3 examples for ingress termination, upstream TLS with - hostname and chain verification, client-certificate authentication, trust - rotation, certificate renewal, revocation limitations, and negative cases. -- [ ] Automate CA-issued TLS rehearsal without committing certificates or - private keys. +- [ ] Implement and test structured logging for load balancing, TLS and mTLS, + WebSockets, ClickHouse, extended health endpoints, and request and connection + limiting. +- [ ] Qualify runtime collection, rotation ownership, pipeline failure, and + retention evidence for the selected logging platform. +- [ ] Qualify lifecycle-alert delivery and exact platform cryptographic-policy + behavior for the TLS profiles on the selected supported host. ## Package 4: CI and supply-chain controls diff --git a/docs/SECURITY-CONTROLS.md b/docs/SECURITY-CONTROLS.md index 7544796..518d233 100644 --- a/docs/SECURITY-CONTROLS.md +++ b/docs/SECURITY-CONTROLS.md @@ -32,7 +32,7 @@ what remains for the system owner. | Least privilege and isolation | UID `999:0`, high ports, no privileged entrypoint, no package manager in final image. | Rootless runtime, dedicated account, drop all capabilities, no new privileges, seccomp, SELinux, read-only root, narrow mounts. | Image config, `/proc` status, mount/capability inspection, SELinux/runtime record, negative writes. | | Secure configuration and change control | Reviewed baseline and tested examples; configuration test and reload interface. | Approve and hash mounted files; protect writers; stage, validate, reload, monitor, and roll back. | Configuration digest/review, `nginx -t`, functional/negative tests, change ticket and rollback result. | | Identification, authentication, and authorization | Does not invent application identity; can enforce mTLS in a qualified profile. | Application identity, trusted proxy chain, PKI, secret store, administrator RBAC, and break-glass controls. | Architecture/data-flow review, identity configuration, certificate tests, access review. | -| Communications protection | Planned TLS 1.2/1.3 ingress and verified upstream examples. | Certificates/keys/trust, renewal, revocation decision, firewall and egress policy, approved termination boundary. | TLS scans and negative tests, key permissions, expiry/rotation evidence, network rules. | +| Communications protection | Tested preview TLS 1.2/1.3 ingress, mTLS, verified upstream, CRL, and trust-rotation examples. | Production certificates/keys/trust, CA operations, alert delivery, firewall and egress policy, approved termination boundary. | TLS scans and negative tests, key permissions, expiry/rotation evidence, network rules. | | Audit and accountability | Access/error streams, reviewed structured fields, health-log suppression, sensitive-data rules. | Journald/collector/SIEM, time sync, access, forwarding, capacity, retention, alerting, integrity protection, disposal. | Sample events, schema/config digest, journal/collector settings, failure and access tests. | | Vulnerability and flaw remediation | Scheduled independent scans, full inventory, advisory triage, rebuild and exception policy. | Host/platform scanning, deployment exposure analysis, promotion cadence, patch window, incident process. | Scanner/tool/database metadata, vendor advisory analysis, owner/expiry, rebuilt digest. | | Resource protection and availability | Small image, bounded writable-path contract, health metadata, graceful stop signal. | CPU/memory/PID/file/tmpfs/connection limits, external rate and DDoS controls, redundancy, capacity and failure tests. | Runtime limits, load/soak results, restart/health/shutdown tests, alerts. | diff --git a/docs/SUPPORT.md b/docs/SUPPORT.md index adda8bb..60991ca 100644 --- a/docs/SUPPORT.md +++ b/docs/SUPPORT.md @@ -25,6 +25,10 @@ Absence from a matrix means unqualified, not implicitly compatible. | --- | --- | --- | | Published images | Unsupported | No release has been published. | | Repository development image | Preview/unqualified | Rootless smoke tests exist; release inputs and evidence are not frozen. | +| Static HTTP profile | Preview/unqualified | Native AMD64/ARM64 Podman and Docker compatibility tests exist; exact host and release evidence remain incomplete. | +| HTTP reverse-proxy profile | Preview/unqualified | Restricted-runtime, safe-header, logging, and upstream-failure tests exist; HTTPS upstreams and platform controls are outside this profile. | +| TLS termination and mTLS profiles | Preview/unqualified | TLS 1.2/1.3, client authentication, leaf renewal, CRL enforcement, and negative cases are tested; production PKI operations and exact-host cryptographic policy remain unqualified. | +| Verified HTTPS upstream profile | Preview/unqualified | Chain, hostname, SNI, revocation, overlapping-CA rotation, and restricted-runtime behavior are tested; deployment DNS, egress, and PKI remain operator-owned. | | Linux AMD64 and ARM64 | Preview/unqualified | Native CI exists; release-candidate evidence is not complete. | | Ubuntu WSL2 | Compatible for contributor development | The recorded environment passes build, smoke, and Quadlet tests but is not a deployment target. | | Standalone RHEL/Podman | Preview/unqualified | Exact SELinux-enforcing host qualification remains future work. | diff --git a/docs/TLS-LIFECYCLE.md b/docs/TLS-LIFECYCLE.md new file mode 100644 index 0000000..2317678 --- /dev/null +++ b/docs/TLS-LIFECYCLE.md @@ -0,0 +1,116 @@ +# TLS certificate, trust, and revocation lifecycle + +This runbook covers the repository's preview TLS termination, mutual-TLS, and +verified HTTPS-upstream profiles. The deployment's PKI owner remains +responsible for issuance, authorization, revocation decisions, protected key +storage, and incident response. The image consumes mounted material and does +not enroll with a CA or generate production keys. + +## Mounted material contract + +| Profile | Required files | Purpose | +| --- | --- | --- | +| TLS termination | `server.crt`, `server.key` | Ordered server chain and matching private key. | +| Mutual TLS | Termination files plus `client-ca.crt`, `client.crl` | Narrow client trust bundle and current issuer CRLs. | +| Verified upstream | `upstream-ca.crt`, `upstream.crl` | Narrow backend trust bundle and current issuer CRLs. | + +Mount the directory read-only. Grant the runtime UID read access to the private +key through a narrowly assigned group or secret projection; do not make a +production key world-readable. Trust and CRL bundles may contain multiple PEM +objects during a controlled rotation. Include the CRL for every relevant +issuer, including intermediates. + +The mTLS profile uses NGINX's `ssl_crl`; the upstream profile uses +`proxy_ssl_crl`. A revoked client certificate is rejected before application +content is served, and a revoked backend certificate produces a gateway +failure. CRLs are local deployment inputs: the image does not download them, +use an implicit network responder, or define a fail-open path. + +## Lifecycle monitoring + +Run the public-metadata checker against every deployed leaf certificate and +CRL. It never accepts a private-key path and emits one JSON document suitable +for a monitoring wrapper: + +```console +python scripts/tls_material.py --warning-hours 720 \ + --certificate /run/nginx-tls/server.crt \ + --crl /run/nginx-tls/client.crl +``` + +Exit status `0` means every deadline is outside the warning window, `1` means +at least one item is warning or expired, and `2` means input or inspection +failed. Exact expiry is expired, not warning. Alert routing, acknowledgement, +escalation, maintenance suppression, clock monitoring, and proof of delivery +belong to the deployment platform. Treat status `2` as loss of monitoring, not +as evidence that material is healthy. + +Monitor CRL `nextUpdate` as well as certificate `notAfter`. Choose a warning +window longer than the combined CA issuance, approval, deployment, validation, +and rollback time. The test harness uses intentionally short-lived material +only to keep the rehearsal self-contained. + +## Leaf renewal + +1. Issue a new leaf certificate with the same reviewed service identity and + required extended-key usage. +2. Verify its chain, hostname, validity, key match, and file permissions before + changing the mount source. +3. Replace the certificate and key together using the platform's atomic secret + update mechanism. +4. Run `nginx -t -q`, reload or replace the container, and confirm the served + serial from an independent client. +5. Retain the previous secret version until rollback and active-connection + requirements are satisfied, then dispose of it under the key policy. + +The automated rehearsal proves that a validated reload serves a new leaf +serial while the non-root master process and read-only mount remain intact. + +## CA trust rotation + +Use an overlap sequence; never replace the old CA before all peers present a +certificate chaining to the new CA: + +1. Deploy a bundle containing the old and new CA certificates and both current + CRL sets. Validate and reload or replace every verifier. +2. Prove the overlap bundle still accepts a peer under the old issuer. +3. Roll peer leaf certificates to the new issuer and prove the overlap bundle + accepts them. +4. Deploy the new-only CA and CRL bundle after the rollback window closes. +5. Prove the retired old-only bundle rejects the new peer and the new-only + bundle accepts it. Archive only the public evidence required by policy. + +The TLS harness exercises all three trust states with isolated CAs. If a phase +fails, keep or restore the last working overlap bundle; do not disable hostname, +chain, or revocation verification to recover service. + +## Revocation updates and incidents + +Publish a new signed CRL after a revocation decision, validate its issuer and +freshness, atomically update the mounted bundle, run `nginx -t -q`, and reload +or replace the verifier. Confirm a non-revoked credential still works and the +revoked serial fails. The rehearsal covers revoked client and upstream server +certificates plus untrusted and wrong-hostname certificates. + +CRL checking is not real-time. Exposure remains between the revocation decision +and successful CRL deployment, and stale CRLs become an availability risk. +Document maximum publication and deployment latency, `nextUpdate`, outage +behavior, emergency rollback authority, and how CA compromise changes the +normal overlap process. + +## Cryptographic-policy boundary + +The profiles constrain protocol versions and TLS 1.2 cipher suites, but the +effective algorithms, providers, implementation validation, and system-wide +policy depend on the exact UBI libraries, host/runtime, and deployment mode. +Record the image digest, NGINX build output, RPM manifest, negotiated protocol +and cipher evidence, host cryptographic policy, certificate algorithms, and +scanner versions for qualification. + +These profiles do not establish that the image or deployment is FIPS validated. +Exact platform cryptographic-policy and FIPS-boundary qualification remains a +separate roadmap item. + +Authoritative directive behavior is documented by NGINX for +[`ssl_crl`](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_crl) +and [`proxy_ssl_crl`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ssl_crl). diff --git a/docs/USE-CASES.md b/docs/USE-CASES.md index db9fde4..31a6764 100644 --- a/docs/USE-CASES.md +++ b/docs/USE-CASES.md @@ -6,9 +6,12 @@ not yet a support matrix. A use case becomes supported only after its example, negative cases, runtime restrictions, and operational guidance are tested against the released image. -The current development baseline serves static content and exposes a health -endpoint on unprivileged port `8080`. The other profiles below are design -targets for the first release unless stated otherwise. +The development image has tested preview profiles for static content, HTTP and +verified-HTTPS upstream proxying, TLS termination, and mutual TLS, including +health endpoints and structured logging on unprivileged ports. The other +profiles below are design targets for the first release unless stated +otherwise. See [Qualified HTTP and TLS configuration profiles](CONFIGURATION-PROFILES.md) +for the exact implemented boundary. ## Profile summary @@ -25,10 +28,12 @@ targets for the first release unless stated otherwise. ## Static web server -Mount site content read-only beneath `/usr/share/nginx/html`, or build a -derived image when immutable application content must travel with the image. -The default configuration serves this directory and returns `404` when a path -does not exist. +The qualified preview example mounts site content read-only beneath `/srv/www`. +The image's generic development default continues to serve +`/usr/share/nginx/html`. Build a derived image when immutable application +content must travel with the image. The profile returns `404` when a path does +not exist, denies dot-prefixed paths, disables indexes, and rejects methods +other than `GET` and implicit `HEAD`. Access logs help identify missing assets, large responses, abusive clients, and unexpected methods. Query strings may contain signed-link credentials or @@ -38,16 +43,17 @@ Do not enable directory indexes by default. ## Reverse proxy -A reverse-proxy profile will define an explicit upstream and forward only the -headers required by the application. It must distinguish the client-facing -response from the upstream attempts that produced it. At minimum, logs should -carry a generated or validated request ID, total request time, upstream -address, upstream status, connection time, header time, and response time. +The qualified preview reverse-proxy example defines one explicit HTTP upstream +and forwards only reviewed headers. It distinguishes the client-facing +response from the upstream result. Logs carry a generated or validated request +ID, total request time, upstream address, upstream status, connection time, +header time, and response time. -Do not trust a client-supplied forwarding chain until a deployment has defined -its trusted proxy hops. Upstream HTTPS must enable certificate-chain and -hostname verification; merely using an `https://` upstream is insufficient. -Network policy should restrict the container to approved upstreams and DNS. +The example replaces a client-supplied forwarding chain with the direct peer +address and performs no automatic retry. Upstream HTTPS must enable +certificate-chain and hostname verification; merely using an `https://` +upstream is insufficient and is not part of this profile. Network policy +should restrict the container to approved upstreams and DNS. ## HTTP load balancer @@ -68,6 +74,15 @@ content. Mount them read-only and make them readable by the runtime identity without starting the container as root. TLS logging should support protocol, cipher, requested server name, session reuse, and verification result. +The qualified preview examples implement TLS 1.2/1.3 ingress termination, +mandatory client-certificate authentication, and HTTPS upstream hostname and +chain verification. Their automated rehearsal generates all CA and leaf key +material outside the repository, rejects legacy protocol and untrusted-chain +cases, and verifies leaf renewal. CA trust rotation, revocation enforcement, +and lifecycle monitoring are also exercised with generated material. Production +PKI operations, alert delivery, and exact-host cryptographic policy still +require qualification. + Client-certificate subjects, issuers, serial numbers, and fingerprints are identifiers. Collect only the field needed to meet an authentication or audit requirement, restrict access to it, and assign a retention period. Never log a diff --git a/examples/profiles/mutual-tls/nginx.conf b/examples/profiles/mutual-tls/nginx.conf new file mode 100644 index 0000000..f8fcfb6 --- /dev/null +++ b/examples/profiles/mutual-tls/nginx.conf @@ -0,0 +1,96 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_x_request_id $correlation_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; + } + + # A connection that never produced a request has no method: a failed TLS + # handshake, a malformed request line, or a client that disconnects before + # the request is read. Those are not application requests, so they stay in + # the error stream instead of emitting a structured access event with empty + # fields. + map $request_method $is_application_request { + default 1; + "" 0; + } + + log_format tls_json escape=json + '{"timestamp":"$time_iso8601",' + '"request_id":"$correlation_id",' + '"method":"$request_method",' + '"uri":"$uri",' + '"protocol":"$server_protocol",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"tls_protocol":"$ssl_protocol",' + '"tls_cipher":"$ssl_cipher",' + '"tls_server_name":"$ssl_server_name",' + '"tls_session_reused":"$ssl_session_reused",' + '"tls_client_verify":"$ssl_client_verify"}'; + access_log /dev/stdout tls_json if=$is_application_request; + + 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_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 30s; + send_timeout 30s; + + server { + listen 8443 ssl default_server; + listen [::]:8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/tls/server.crt; + ssl_certificate_key /etc/nginx/tls/server.key; + ssl_client_certificate /etc/nginx/tls/client-ca.crt; + ssl_crl /etc/nginx/tls/client.crl; + ssl_verify_client on; + ssl_verify_depth 2; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256; + ssl_ecdh_curve X25519:secp256r1; + ssl_session_cache shared:TLS:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + root /srv/www; + index index.html; + autoindex off; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Request-ID $correlation_id always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location / { + limit_except GET { + deny all; + } + try_files $uri $uri/ =404; + } + } +} diff --git a/examples/profiles/reverse-proxy/nginx.conf b/examples/profiles/reverse-proxy/nginx.conf new file mode 100644 index 0000000..b1c2e7b --- /dev/null +++ b/examples/profiles/reverse-proxy/nginx.conf @@ -0,0 +1,90 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_x_request_id $correlation_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; + } + + # A connection that never produced a request has no method: a failed TLS + # handshake, a malformed request line, or a client that disconnects before + # the request is read. Those are not application requests, so they stay in + # the error stream instead of emitting a structured access event with empty + # fields. + map $request_method $is_application_request { + default 1; + "" 0; + } + + log_format profile_json escape=json + '{"timestamp":"$time_iso8601",' + '"request_id":"$correlation_id",' + '"method":"$request_method",' + '"uri":"$uri",' + '"protocol":"$server_protocol",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"upstream_addr":"$upstream_addr",' + '"upstream_status":"$upstream_status",' + '"upstream_connect_time":"$upstream_connect_time",' + '"upstream_header_time":"$upstream_header_time",' + '"upstream_response_time":"$upstream_response_time"}'; + access_log /dev/stdout profile_json if=$is_application_request; + + 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_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 30s; + send_timeout 30s; + + upstream application_backend { + server backend:8080; + keepalive 16; + } + + server { + listen 8080 default_server; + listen [::]:8080 default_server; + server_name _; + + add_header X-Content-Type-Options nosniff always; + add_header X-Request-ID $correlation_id always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location / { + proxy_pass http://application_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $proxy_host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $correlation_id; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_next_upstream off; + } + } +} diff --git a/examples/profiles/static/nginx.conf b/examples/profiles/static/nginx.conf new file mode 100644 index 0000000..e05a636 --- /dev/null +++ b/examples/profiles/static/nginx.conf @@ -0,0 +1,82 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_x_request_id $correlation_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; + } + + # A connection that never produced a request has no method: a failed TLS + # handshake, a malformed request line, or a client that disconnects before + # the request is read. Those are not application requests, so they stay in + # the error stream instead of emitting a structured access event with empty + # fields. + map $request_method $is_application_request { + default 1; + "" 0; + } + + log_format profile_json escape=json + '{"timestamp":"$time_iso8601",' + '"request_id":"$correlation_id",' + '"method":"$request_method",' + '"uri":"$uri",' + '"protocol":"$server_protocol",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time}'; + access_log /dev/stdout profile_json if=$is_application_request; + + 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; + + sendfile on; + server_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 30s; + send_timeout 30s; + + server { + listen 8080 default_server; + listen [::]:8080 default_server; + server_name _; + + root /srv/www; + index index.html; + autoindex off; + + add_header X-Content-Type-Options nosniff always; + add_header X-Request-ID $correlation_id always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location ~ (^|/)\. { + deny all; + } + + location / { + limit_except GET { + deny all; + } + try_files $uri $uri/ =404; + } + } +} diff --git a/examples/profiles/tls-termination/nginx.conf b/examples/profiles/tls-termination/nginx.conf new file mode 100644 index 0000000..020432a --- /dev/null +++ b/examples/profiles/tls-termination/nginx.conf @@ -0,0 +1,96 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_x_request_id $correlation_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; + } + + # A connection that never produced a request has no method: a failed TLS + # handshake, a malformed request line, or a client that disconnects before + # the request is read. Those are not application requests, so they stay in + # the error stream instead of emitting a structured access event with empty + # fields. + map $request_method $is_application_request { + default 1; + "" 0; + } + + log_format tls_json escape=json + '{"timestamp":"$time_iso8601",' + '"request_id":"$correlation_id",' + '"method":"$request_method",' + '"uri":"$uri",' + '"protocol":"$server_protocol",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"tls_protocol":"$ssl_protocol",' + '"tls_cipher":"$ssl_cipher",' + '"tls_server_name":"$ssl_server_name",' + '"tls_session_reused":"$ssl_session_reused",' + '"tls_client_verify":"$ssl_client_verify"}'; + access_log /dev/stdout tls_json if=$is_application_request; + + 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_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 30s; + send_timeout 30s; + + server { + listen 8443 ssl default_server; + listen [::]:8443 ssl default_server; + server_name _; + + ssl_certificate /etc/nginx/tls/server.crt; + ssl_certificate_key /etc/nginx/tls/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256; + ssl_ecdh_curve X25519:secp256r1; + ssl_session_cache shared:TLS:10m; + ssl_session_timeout 10m; + ssl_session_tickets off; + + root /srv/www; + index index.html; + autoindex off; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Request-ID $correlation_id always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location ~ (^|/)\. { + deny all; + } + + location / { + limit_except GET { + deny all; + } + try_files $uri $uri/ =404; + } + } +} diff --git a/examples/profiles/tls-upstream/nginx.conf b/examples/profiles/tls-upstream/nginx.conf new file mode 100644 index 0000000..7bbd382 --- /dev/null +++ b/examples/profiles/tls-upstream/nginx.conf @@ -0,0 +1,99 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + map $http_x_request_id $correlation_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; + } + + # A connection that never produced a request has no method: a failed TLS + # handshake, a malformed request line, or a client that disconnects before + # the request is read. Those are not application requests, so they stay in + # the error stream instead of emitting a structured access event with empty + # fields. + map $request_method $is_application_request { + default 1; + "" 0; + } + + log_format profile_json escape=json + '{"timestamp":"$time_iso8601",' + '"request_id":"$correlation_id",' + '"method":"$request_method",' + '"uri":"$uri",' + '"protocol":"$server_protocol",' + '"status":$status,' + '"body_bytes_sent":$body_bytes_sent,' + '"request_time":$request_time,' + '"upstream_addr":"$upstream_addr",' + '"upstream_status":"$upstream_status",' + '"upstream_connect_time":"$upstream_connect_time",' + '"upstream_header_time":"$upstream_header_time",' + '"upstream_response_time":"$upstream_response_time"}'; + access_log /dev/stdout profile_json if=$is_application_request; + + 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_tokens off; + client_max_body_size 1m; + client_body_timeout 10s; + client_header_timeout 10s; + keepalive_timeout 30s; + send_timeout 30s; + + upstream secure_backend { + server backend:8443; + keepalive 16; + } + + server { + listen 8080 default_server; + listen [::]:8080 default_server; + server_name _; + + add_header X-Content-Type-Options nosniff always; + add_header X-Request-ID $correlation_id always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location / { + proxy_pass https://secure_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host backend.test; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $correlation_id; + proxy_connect_timeout 5s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_next_upstream off; + + proxy_ssl_server_name on; + proxy_ssl_name backend.test; + proxy_ssl_protocols TLSv1.2 TLSv1.3; + proxy_ssl_trusted_certificate /etc/nginx/tls/upstream-ca.crt; + proxy_ssl_crl /etc/nginx/tls/upstream.crl; + proxy_ssl_verify on; + proxy_ssl_verify_depth 2; + proxy_ssl_session_reuse on; + } + } +} diff --git a/scripts/tls_material.py b/scripts/tls_material.py new file mode 100644 index 0000000..5f8d034 --- /dev/null +++ b/scripts/tls_material.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Check certificate expiry and CRL freshness for TLS lifecycle alerting.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import subprocess +import sys + +OPENSSL_TIME = "%b %d %H:%M:%S %Y %Z" + + +class TLSMaterialError(ValueError): + """TLS material cannot be evaluated against the lifecycle policy.""" + + +def parse_deadline(output: str, label: str) -> datetime: + """Parse one OpenSSL name=value deadline as an aware UTC datetime.""" + lines = output.splitlines() + prefix = f"{label}=" + if len(lines) != 1 or not lines[0].startswith(prefix): + raise TLSMaterialError(f"expected exactly one {prefix} line") + value = lines[0][len(prefix):] + try: + parsed = datetime.strptime(value, OPENSSL_TIME) + except ValueError as exc: + raise TLSMaterialError(f"invalid {label} value: {exc}") from exc + return parsed.replace(tzinfo=timezone.utc) + + +def evaluate_deadline( + deadline: datetime, now: datetime, warning_seconds: int +) -> tuple[str, int]: + """Classify a certificate or CRL deadline for monitoring.""" + if deadline.tzinfo is None or now.tzinfo is None: + raise TLSMaterialError("deadline and current time must include time zones") + if warning_seconds <= 0: + raise TLSMaterialError("warning window must be positive") + remaining = int((deadline.astimezone(timezone.utc) - now.astimezone(timezone.utc)).total_seconds()) + if remaining <= 0: + return "expired", remaining + if remaining <= warning_seconds: + return "warning", remaining + return "healthy", remaining + + +def inspect_material( + kind: str, + path: Path, + openssl: str, + now: datetime, + warning_seconds: int, +) -> dict[str, object]: + """Ask OpenSSL for a public deadline and return its policy result.""" + if kind == "certificate": + command = [openssl, "x509", "-in", str(path), "-noout", "-enddate"] + label = "notAfter" + elif kind == "crl": + command = [openssl, "crl", "-in", str(path), "-noout", "-nextupdate"] + label = "nextUpdate" + else: + raise TLSMaterialError(f"unknown TLS material kind: {kind}") + try: + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + except (OSError, subprocess.CalledProcessError) as exc: + raise TLSMaterialError(f"cannot inspect {kind} {path}: {exc}") from exc + deadline = parse_deadline(result.stdout.strip(), label) + status, remaining = evaluate_deadline(deadline, now, warning_seconds) + return { + "kind": kind, + "path": str(path), + "deadline": deadline.isoformat(), + "seconds_remaining": remaining, + "status": status, + } + + +def parse_now(value: str | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + try: + parsed = datetime.fromisoformat(value) + except ValueError as exc: + raise TLSMaterialError(f"invalid --now value: {exc}") from exc + if parsed.tzinfo is None: + raise TLSMaterialError("--now must include a UTC offset") + return parsed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--certificate", action="append", type=Path, default=[]) + parser.add_argument("--crl", action="append", type=Path, default=[]) + parser.add_argument("--warning-hours", type=int, default=720) + parser.add_argument("--openssl", default="openssl") + parser.add_argument("--now", help="ISO 8601 override for reproducible checks") + args = parser.parse_args() + if not args.certificate and not args.crl: + parser.error("at least one --certificate or --crl is required") + if args.warning_hours <= 0: + parser.error("--warning-hours must be positive") + try: + now = parse_now(args.now) + results = [ + inspect_material(kind, path, args.openssl, now, args.warning_hours * 3600) + for kind, paths in (("certificate", args.certificate), ("crl", args.crl)) + for path in paths + ] + except TLSMaterialError as exc: + print(f"TLS material check failed: {exc}", file=sys.stderr) + return 2 + print(json.dumps({"schema_version": 1, "results": results}, sort_keys=True)) + return 0 if all(item["status"] == "healthy" for item in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/profile-backend/nginx.conf b/tests/fixtures/profile-backend/nginx.conf new file mode 100644 index 0000000..4e2d170 --- /dev/null +++ b/tests/fixtures/profile-backend/nginx.conf @@ -0,0 +1,24 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 128; +} + +http { + access_log off; + 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; + server_name _; + + default_type application/json; + return 200 '{"xff":"$http_x_forwarded_for","proto":"$http_x_forwarded_proto","request_id":"$http_x_request_id","host":"$host"}\n'; + } +} diff --git a/tests/fixtures/profile-site/index.html b/tests/fixtures/profile-site/index.html new file mode 100644 index 0000000..ea4a0e8 --- /dev/null +++ b/tests/fixtures/profile-site/index.html @@ -0,0 +1 @@ +static-profile-ok diff --git a/tests/fixtures/tls-backend/nginx.conf b/tests/fixtures/tls-backend/nginx.conf new file mode 100644 index 0000000..94fe2f4 --- /dev/null +++ b/tests/fixtures/tls-backend/nginx.conf @@ -0,0 +1,29 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr notice; + +events { + worker_connections 128; +} + +http { + access_log off; + 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 8443 ssl; + server_name backend.test; + + ssl_certificate /etc/nginx/tls/server.crt; + ssl_certificate_key /etc/nginx/tls/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_tickets off; + + default_type application/json; + return 200 '{"request_id":"$http_x_request_id","forwarded_proto":"$http_x_forwarded_proto"}\n'; + } +} diff --git a/tests/profiles.sh b/tests/profiles.sh new file mode 100644 index 0000000..53dc8cd --- /dev/null +++ b/tests/profiles.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +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)}" +examples_dir="${EXAMPLES_DIR:-${repository}/examples/profiles}" +null_device="${NULL_DEVICE:-/dev/null}" +prefix="nginx-ubi9-profile-${RANDOM}-$$" +network="${prefix}-network" +static="${prefix}-static" +backend="${prefix}-backend" +proxy="${prefix}-proxy" +tmp_root="${PROFILE_TMPDIR:-/tmp}" +static_headers=$(mktemp "${tmp_root}/nginx-static-headers.XXXXXX") +proxy_headers=$(mktemp "${tmp_root}/nginx-proxy-headers.XXXXXX") +proxy_body=$(mktemp "${tmp_root}/nginx-proxy-body.XXXXXX") +no_new_privileges="no-new-privileges:true" + +if grep -qi podman <<< "$("${runtime}" --version 2>&1)"; then + no_new_privileges="no-new-privileges" +fi + +cleanup() { + "${runtime}" rm --force "${static}" "${proxy}" "${backend}" \ + >/dev/null 2>&1 || true + "${runtime}" network rm "${network}" >/dev/null 2>&1 || true + rm -f -- "${static_headers}" "${proxy_headers}" "${proxy_body}" +} +trap cleanup EXIT + +run_restricted() { + local name="$1" + local uid="$2" + shift 2 + "${runtime}" run --detach --name "${name}" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ + --cap-drop ALL \ + --security-opt "${no_new_privileges}" \ + --user "${uid}:0" \ + "$@" \ + "${image}" -c /etc/nginx/nginx.conf -g 'daemon off;' \ + >/dev/null +} + +wait_for_http() { + local url="$1" + local name="$2" + local _ + for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${url}" || true)" = 200; then + return + fi + sleep 1 + done + "${runtime}" logs "${name}" >&2 + echo "Timed out waiting for ${name}" >&2 + return 1 +} + +assert_process_security() { + local name="$1" + # Values expand inside the container, not in this test process. + # shellcheck disable=SC2016 + "${runtime}" exec "${name}" sh -eu -c ' + test "$(id -u)" -ne 0 + test "$(id -g)" -eq 0 + while IFS=: read -r key value; do + case "${key}" in + CapEff) set -- ${value}; test "$1" = 0000000000000000 ;; + NoNewPrivs) set -- ${value}; test "$1" = 1 ;; + esac + done < /proc/1/status + ' +} + +header_value() { + local header="$1" + local file="$2" + sed -n "s/^${header}:[[:space:]]*//Ip" "${file}" | tr -d '\r' | head -n 1 +} + +"${runtime}" image inspect "${image}" >/dev/null +"${runtime}" network create "${network}" >/dev/null + +run_restricted "${static}" 10001 \ + --publish 127.0.0.1::8080 \ + --volume "${examples_dir}/static/nginx.conf:/etc/nginx/nginx.conf:ro" \ + --volume "${script_dir}/fixtures/profile-site:/srv/www:ro" +static_binding=$("${runtime}" port "${static}" 8080/tcp) +static_port=${static_binding##*:} +static_url="http://127.0.0.1:${static_port}" +wait_for_http "${static_url}/healthz" "${static}" +assert_process_security "${static}" +"${runtime}" exec "${static}" nginx -t -q -c /etc/nginx/nginx.conf + +test "$(curl --fail --silent --show-error "${static_url}/")" = \ + "static-profile-ok" +curl --silent --show-error \ + --header 'X-Request-ID: static.valid-1' \ + --dump-header "${static_headers}" \ + --output "${null_device}" \ + "${static_url}/missing?profile-secret=do-not-log" +test "$(header_value X-Request-ID "${static_headers}")" = "static.valid-1" + +curl --silent --show-error \ + --header 'X-Request-ID: invalid/request/id' \ + --dump-header "${static_headers}" \ + --output "${null_device}" \ + "${static_url}/" +generated_request_id=$(header_value X-Request-ID "${static_headers}") +[[ "${generated_request_id}" =~ ^[0-9a-f]{32}$ ]] + +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + --request POST "${static_url}/")" = 403 +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${static_url}/.hidden")" = 403 +static_logs=$("${runtime}" logs "${static}" 2>&1) +if grep -Fq '/healthz' <<< "${static_logs}"; then + echo "The static health endpoint unexpectedly wrote an access event" >&2 + exit 1 +fi +printf '%s\n' "${static_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile static \ + --uri /missing \ + --request-id static.valid-1 \ + --status 404 \ + --forbidden profile-secret \ + --forbidden do-not-log + +run_restricted "${backend}" 10002 \ + --network "${network}" \ + --network-alias backend \ + --volume "${script_dir}/fixtures/profile-backend/nginx.conf:/etc/nginx/nginx.conf:ro" +"${runtime}" exec "${backend}" nginx -t -q -c /etc/nginx/nginx.conf +assert_process_security "${backend}" + +run_restricted "${proxy}" 10003 \ + --network "${network}" \ + --publish 127.0.0.1::8080 \ + --volume "${examples_dir}/reverse-proxy/nginx.conf:/etc/nginx/nginx.conf:ro" +proxy_binding=$("${runtime}" port "${proxy}" 8080/tcp) +proxy_port=${proxy_binding##*:} +proxy_url="http://127.0.0.1:${proxy_port}" +wait_for_http "${proxy_url}/healthz" "${proxy}" +assert_process_security "${proxy}" +"${runtime}" exec "${proxy}" nginx -t -q -c /etc/nginx/nginx.conf + +curl --fail --silent --show-error \ + --header 'X-Request-ID: proxy.valid-1' \ + --header 'X-Forwarded-For: 203.0.113.9' \ + --dump-header "${proxy_headers}" \ + --output "${proxy_body}" \ + "${proxy_url}/application?reverse-secret=do-not-log" +test "$(header_value X-Request-ID "${proxy_headers}")" = "proxy.valid-1" +"${python}" -c \ + 'import json, sys +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload["xff"] != "203.0.113.9" +assert payload["proto"] == "http" +assert payload["request_id"] == "proxy.valid-1"' \ + "${proxy_body}" + +"${runtime}" stop --time 10 "${backend}" >/dev/null +failure_status=$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + --header 'X-Request-ID: proxy.failure-1' \ + "${proxy_url}/unavailable") +[[ "${failure_status}" =~ ^50[24]$ ]] +proxy_logs=$("${runtime}" logs "${proxy}" 2>&1) +if grep -Fq '/healthz' <<< "${proxy_logs}"; then + echo "The reverse-proxy health endpoint unexpectedly wrote an access event" >&2 + exit 1 +fi +printf '%s\n' "${proxy_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile reverse-proxy \ + --uri /application \ + --request-id proxy.valid-1 \ + --status 200 \ + --forbidden reverse-secret \ + --forbidden do-not-log +printf '%s\n' "${proxy_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile reverse-proxy \ + --uri /unavailable \ + --request-id proxy.failure-1 \ + --status "${failure_status}" + +"${runtime}" stop --time 10 "${static}" "${proxy}" >/dev/null +test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${static}")" = 0 +test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${proxy}")" = 0 + +echo "Static and reverse-proxy profile qualification passed for ${image}" diff --git a/tests/test_profile_logs.py b/tests/test_profile_logs.py new file mode 100644 index 0000000..4cf43f7 --- /dev/null +++ b/tests/test_profile_logs.py @@ -0,0 +1,158 @@ +"""Unit tests for the deployable profile logging contract.""" + +from __future__ import annotations + +import json +import math +import unittest + +from tests import validate_profile_logs as logs + + +def event(**changes: object) -> dict[str, object]: + value: dict[str, object] = { + "timestamp": "2026-09-15T01:02:03+00:00", + "request_id": "request.valid-1", + "method": "GET", + "uri": "/resource", + "protocol": "HTTP/1.1", + "status": 200, + "body_bytes_sent": 12, + "request_time": 0.125, + } + value.update(changes) + return value + + +def encoded(value: object) -> str: + return json.dumps(value, separators=(",", ":"), allow_nan=True) + + +class ProfileLogTests(unittest.TestCase): + def test_non_access_output_is_ignored_but_valid_event_is_parsed(self) -> None: + raw = "notice: worker started\n" + encoded(event()) + self.assertEqual(logs.parse_events(raw, "static"), [event()]) + + def test_json_escaped_path_round_trips(self) -> None: + expected = event(uri='/quote"and\\slash') + self.assertEqual(logs.parse_events(encoded(expected), "static"), [expected]) + + def test_forbidden_value_is_rejected_even_outside_access_event(self) -> None: + raw = "error detail contains query-secret\n" + encoded(event()) + with self.assertRaisesRegex(logs.ProfileLogError, "forbidden value"): + logs.parse_events(raw, "static", ["query-secret"]) + + def test_malformed_json_event_is_rejected(self) -> None: + with self.assertRaisesRegex(logs.ProfileLogError, "invalid JSON"): + logs.parse_events('{"status":', "static") + + def test_missing_and_extra_fields_are_rejected(self) -> None: + missing = event() + del missing["request_time"] + extra = event(unreviewed_header="secret") + for candidate in (missing, extra): + with self.subTest(candidate=candidate): + with self.assertRaisesRegex(logs.ProfileLogError, "fields differ"): + logs.parse_events(encoded(candidate), "static") + + def test_boolean_is_not_accepted_as_an_integer_or_number(self) -> None: + for field in ("status", "body_bytes_sent", "request_time"): + with self.subTest(field=field): + with self.assertRaises(logs.ProfileLogError): + logs.parse_events(encoded(event(**{field: True})), "static") + + def test_invalid_numeric_ranges_are_rejected(self) -> None: + candidates = ( + event(status=99), + event(status=600), + event(body_bytes_sent=-1), + event(request_time=-0.1), + event(request_time=math.inf), + ) + for candidate in candidates: + with self.subTest(candidate=candidate): + with self.assertRaises(logs.ProfileLogError): + logs.parse_events(encoded(candidate), "static") + + def test_timestamp_requires_iso_8601_offset(self) -> None: + for timestamp in ("not-a-date", "2026-09-15T01:02:03"): + with self.subTest(timestamp=timestamp): + with self.assertRaisesRegex(logs.ProfileLogError, "timestamp"): + logs.parse_events(encoded(event(timestamp=timestamp)), "static") + + def test_request_id_policy_is_enforced(self) -> None: + invalid = ("/slash", "a b", "-leading", "a" * 65) + for request_id in invalid: + with self.subTest(request_id=request_id): + with self.assertRaisesRegex(logs.ProfileLogError, "request_id"): + logs.parse_events(encoded(event(request_id=request_id)), "static") + + def test_uri_must_be_absolute_and_query_free(self) -> None: + for uri in ("relative", "/path?credential=secret"): + with self.subTest(uri=uri): + with self.assertRaisesRegex(logs.ProfileLogError, "uri"): + logs.parse_events(encoded(event(uri=uri)), "static") + + def test_upstream_profiles_require_exact_timing_fields(self) -> None: + upstream = { + "upstream_addr": "10.0.0.2:8443", + "upstream_status": "200", + "upstream_connect_time": "0.001", + "upstream_header_time": "0.002", + "upstream_response_time": "0.003", + } + expected = event(**upstream) + self.assertEqual( + logs.parse_events(encoded(expected), "tls-upstream"), [expected] + ) + invalid = expected.copy() + invalid["upstream_status"] = 200 + with self.assertRaisesRegex(logs.ProfileLogError, "upstream_status"): + logs.parse_events(encoded(invalid), "tls-upstream") + + def test_static_profile_rejects_upstream_fields(self) -> None: + with self.assertRaisesRegex(logs.ProfileLogError, "fields differ"): + logs.parse_events( + encoded(event(upstream_status="200")), "static" + ) + + def test_tls_profiles_require_qualified_protocol_and_verification_result(self) -> None: + tls = { + "tls_protocol": "TLSv1.3", + "tls_cipher": "TLS_AES_256_GCM_SHA384", + "tls_server_name": "localhost", + "tls_session_reused": ".", + "tls_client_verify": "SUCCESS", + } + expected = event(**tls) + self.assertEqual( + logs.parse_events(encoded(expected), "mutual-tls"), [expected] + ) + failed = expected | {"tls_client_verify": "FAILED:self-signed certificate"} + self.assertEqual( + logs.parse_events(encoded(failed), "mutual-tls"), [failed] + ) + for changes in ( + {"tls_protocol": "TLSv1.1"}, + {"tls_cipher": ""}, + {"tls_client_verify": "unexpected"}, + ): + with self.subTest(changes=changes): + invalid = expected | changes + with self.assertRaises(logs.ProfileLogError): + logs.parse_events(encoded(invalid), "mutual-tls") + + def test_exactly_one_scenario_event_is_required(self) -> None: + observed = [event(), event(request_id="other")] + self.assertEqual( + logs.select_event(observed, "/resource", "request.valid-1", 200), + observed[0], + ) + with self.assertRaisesRegex(logs.ProfileLogError, "found 0"): + logs.select_event(observed, "/missing", "request.valid-1", 200) + with self.assertRaisesRegex(logs.ProfileLogError, "found 2"): + logs.select_event([event(), event()], "/resource", "request.valid-1", 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tls_material.py b/tests/test_tls_material.py new file mode 100644 index 0000000..1fb1b99 --- /dev/null +++ b/tests/test_tls_material.py @@ -0,0 +1,129 @@ +"""Unit tests for TLS lifecycle deadline monitoring.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import subprocess +import unittest +from unittest import mock + +from scripts import tls_material + + +NOW = datetime(2026, 9, 15, 1, 2, 3, tzinfo=timezone.utc) + + +class TLSMaterialTests(unittest.TestCase): + def test_parse_certificate_deadline(self) -> None: + observed = tls_material.parse_deadline( + "notAfter=Sep 16 01:02:03 2026 GMT", "notAfter" + ) + self.assertEqual(observed, NOW + timedelta(days=1)) + + def test_parse_crl_deadline_accepts_single_digit_day_spacing(self) -> None: + observed = tls_material.parse_deadline( + "nextUpdate=Sep 5 01:02:03 2026 GMT", "nextUpdate" + ) + self.assertEqual(observed.day, 5) + + def test_deadline_rejects_wrong_label_extra_lines_and_bad_time(self) -> None: + values = ( + "notBefore=Sep 16 01:02:03 2026 GMT", + "notAfter=Sep 16 01:02:03 2026 GMT\nextra", + "notAfter=not-a-time", + ) + for value in values: + with self.subTest(value=value): + with self.assertRaises(tls_material.TLSMaterialError): + tls_material.parse_deadline(value, "notAfter") + + def test_healthy_deadline_is_outside_warning_window(self) -> None: + self.assertEqual( + tls_material.evaluate_deadline( + NOW + timedelta(seconds=101), NOW, 100 + ), + ("healthy", 101), + ) + + def test_exact_warning_boundary_is_actionable(self) -> None: + self.assertEqual( + tls_material.evaluate_deadline( + NOW + timedelta(seconds=100), NOW, 100 + ), + ("warning", 100), + ) + + def test_exact_expiry_and_past_deadlines_are_expired(self) -> None: + self.assertEqual( + tls_material.evaluate_deadline(NOW, NOW, 100), ("expired", 0) + ) + self.assertEqual( + tls_material.evaluate_deadline( + NOW - timedelta(seconds=5), NOW, 100 + ), + ("expired", -5), + ) + + def test_naive_times_and_nonpositive_warning_are_rejected(self) -> None: + naive = NOW.replace(tzinfo=None) + with self.assertRaises(tls_material.TLSMaterialError): + tls_material.evaluate_deadline(naive, NOW, 100) + with self.assertRaises(tls_material.TLSMaterialError): + tls_material.evaluate_deadline(NOW, NOW, 0) + + def test_parse_now_requires_an_offset(self) -> None: + self.assertEqual(tls_material.parse_now(NOW.isoformat()), NOW) + with self.assertRaises(tls_material.TLSMaterialError): + tls_material.parse_now("2026-09-15T01:02:03") + + @mock.patch("scripts.tls_material.subprocess.run") + def test_certificate_inspection_uses_public_metadata_only(self, run: mock.Mock) -> None: + run.return_value = subprocess.CompletedProcess( + [], 0, stdout="notAfter=Sep 16 01:02:03 2026 GMT\n", stderr="" + ) + result = tls_material.inspect_material( + "certificate", Path("server.crt"), "openssl", NOW, 3600 + ) + self.assertEqual(result["status"], "healthy") + run.assert_called_once_with( + ["openssl", "x509", "-in", "server.crt", "-noout", "-enddate"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + + @mock.patch("scripts.tls_material.subprocess.run") + def test_crl_inspection_reports_warning(self, run: mock.Mock) -> None: + run.return_value = subprocess.CompletedProcess( + [], 0, stdout="nextUpdate=Sep 15 02:02:03 2026 GMT\n", stderr="" + ) + result = tls_material.inspect_material( + "crl", Path("client.crl"), "openssl", NOW, 7200 + ) + self.assertEqual(result["status"], "warning") + self.assertEqual(result["seconds_remaining"], 3600) + + @mock.patch("scripts.tls_material.subprocess.run") + def test_openssl_failure_is_fail_closed_without_stderr_leak(self, run: mock.Mock) -> None: + run.side_effect = subprocess.CalledProcessError( + 1, ["openssl"], stderr="sensitive diagnostic" + ) + with self.assertRaisesRegex( + tls_material.TLSMaterialError, "cannot inspect certificate" + ) as raised: + tls_material.inspect_material( + "certificate", Path("server.crt"), "openssl", NOW, 3600 + ) + self.assertNotIn("sensitive diagnostic", str(raised.exception)) + + def test_unknown_material_kind_is_rejected(self) -> None: + with self.assertRaisesRegex(tls_material.TLSMaterialError, "unknown"): + tls_material.inspect_material( + "private-key", Path("server.key"), "openssl", NOW, 3600 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/tls.sh b/tests/tls.sh new file mode 100644 index 0000000..9292dd8 --- /dev/null +++ b/tests/tls.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +runtime="${CONTAINER_RUNTIME:-podman}" +image="${IMAGE:-localhost/nginx-ubi9:development}" +python="${PYTHON:-python3}" +openssl="${OPENSSL:-openssl}" +openssl_for_python="${OPENSSL_FOR_PYTHON:-${openssl}}" +script_dir="${SCRIPT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}" +repository="${REPOSITORY:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)}" +examples_dir="${EXAMPLES_DIR:-${repository}/examples/profiles}" +null_device="${NULL_DEVICE:-/dev/null}" +tmp_root="${TLS_TMPDIR:-/tmp}" +evidence=$(mktemp -d "${tmp_root}/nginx-ubi-tls.XXXXXX") +prefix="nginx-ubi9-tls-${RANDOM}-$$" +network="${prefix}-network" +termination="${prefix}-termination" +mtls="${prefix}-mtls" +missing_key="${prefix}-missing-key" +backend="${prefix}-backend" +proxy="${prefix}-proxy" +wrong_trust="${prefix}-wrong-trust" +wrong_host="${prefix}-wrong-host" +revoked_upstream="${prefix}-revoked-upstream" +rotation_overlap="${prefix}-rotation-overlap" +rotation_old_trust="${prefix}-rotation-old-trust" +rotation_new_only="${prefix}-rotation-new-only" +no_new_privileges="no-new-privileges:true" + +if grep -qi podman <<< "$("${runtime}" --version 2>&1)"; then + no_new_privileges="no-new-privileges" +fi +if command -v cygpath >/dev/null 2>&1 && [[ "${openssl_for_python}" = /* ]]; then + openssl_for_python=$(cygpath -w "${openssl_for_python}") +fi + +cleanup() { + "${runtime}" rm --force \ + "${termination}" "${mtls}" "${missing_key}" "${proxy}" \ + "${wrong_trust}" "${wrong_host}" "${revoked_upstream}" \ + "${rotation_overlap}" "${rotation_old_trust}" \ + "${rotation_new_only}" "${backend}" >/dev/null 2>&1 || true + "${runtime}" network rm "${network}" >/dev/null 2>&1 || true + rm -rf -- "${evidence}" +} +trap cleanup EXIT + +make_ca() { + local name="$1" + local directory="${evidence}/${name}" + mkdir -p "${directory}" + "${openssl}" req -x509 -newkey rsa:2048 -nodes -sha256 -days 2 \ + -subj "/CN=${name}" \ + -keyout "${directory}/ca.key" \ + -out "${directory}/ca.crt" >/dev/null 2>&1 + mkdir -p "${directory}/newcerts" + : > "${directory}/index.txt" + printf '1000\n' > "${directory}/serial" + printf '1000\n' > "${directory}/crlnumber" + # OpenSSL expands the literal $dir references in its configuration. + # shellcheck disable=SC2016 + printf '%s\n' \ + '[ca]' \ + 'default_ca = CA_default' \ + '[CA_default]' \ + "dir = ${directory}" \ + 'database = $dir/index.txt' \ + 'new_certs_dir = $dir/newcerts' \ + 'certificate = $dir/ca.crt' \ + 'private_key = $dir/ca.key' \ + 'serial = $dir/serial' \ + 'crlnumber = $dir/crlnumber' \ + 'default_md = sha256' \ + 'default_days = 2' \ + 'default_crl_days = 1' \ + 'unique_subject = no' \ + 'policy = policy_any' \ + '[policy_any]' \ + 'commonName = supplied' > "${directory}/ca.conf" +} + +make_cert() { + local name="$1" + local common_name="$2" + local san="$3" + local eku="$4" + local ca_name="$5" + local directory="${evidence}/${name}" + local ca_directory="${evidence}/${ca_name}" + mkdir -p "${directory}" + printf '[leaf]\nsubjectAltName=%s\nextendedKeyUsage=%s\nkeyUsage=digitalSignature,keyEncipherment\nbasicConstraints=critical,CA:false\n' \ + "${san}" "${eku}" > "${directory}/extensions.conf" + "${openssl}" req -new -newkey rsa:2048 -nodes -sha256 \ + -subj "/CN=${common_name}" \ + -keyout "${directory}/server.key" \ + -out "${directory}/server.csr" >/dev/null 2>&1 + "${openssl}" ca -batch -notext \ + -config "${ca_directory}/ca.conf" \ + -in "${directory}/server.csr" \ + -extfile "${directory}/extensions.conf" \ + -extensions leaf \ + -out "${directory}/server.crt" >/dev/null 2>&1 +} + +make_crl() { + local ca_name="$1" + local ca_directory="${evidence}/${ca_name}" + "${openssl}" ca -gencrl -config "${ca_directory}/ca.conf" \ + -out "${ca_directory}/ca.crl" >/dev/null 2>&1 +} + +revoke_cert() { + local ca_name="$1" + local cert_name="$2" + local ca_directory="${evidence}/${ca_name}" + "${openssl}" ca -batch -config "${ca_directory}/ca.conf" \ + -revoke "${evidence}/${cert_name}/server.crt" >/dev/null 2>&1 + make_crl "${ca_name}" +} + +run_restricted() { + local name="$1" + local uid="$2" + local config="$3" + local tls_directory="$4" + shift 4 + "${runtime}" run --detach --name "${name}" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ + --cap-drop ALL \ + --security-opt "${no_new_privileges}" \ + --user "${uid}:0" \ + --volume "${config}:/etc/nginx/nginx.conf:ro" \ + --volume "${tls_directory}:/etc/nginx/tls:ro" \ + "$@" \ + "${image}" -c /etc/nginx/nginx.conf -g 'daemon off;' \ + >/dev/null +} + +wait_for_tls() { + local url="$1" + local ca="$2" + local name="$3" + shift 3 + local _ + for _ in {1..30}; do + if curl --fail --silent --output "${null_device}" \ + --cacert "${ca}" "$@" "${url}"; then + return + fi + sleep 1 + done + "${runtime}" logs "${name}" >&2 + echo "Timed out waiting for TLS endpoint ${name}" >&2 + return 1 +} + +wait_for_exit() { + local name="$1" + local _ + for _ in {1..15}; do + if test "$("${runtime}" inspect --format '{{.State.Status}}' "${name}")" \ + != running; then + test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${name}")" \ + != 0 + return + fi + sleep 1 + done + "${runtime}" logs "${name}" >&2 + return 1 +} + +assert_process_security() { + local name="$1" + # Values expand inside the container, not in this test process. + # shellcheck disable=SC2016 + "${runtime}" exec "${name}" sh -eu -c ' + test "$(id -u)" -ne 0 + test "$(id -g)" -eq 0 + while IFS=: read -r key value; do + case "${key}" in + CapEff) set -- ${value}; test "$1" = 0000000000000000 ;; + NoNewPrivs) set -- ${value}; test "$1" = 1 ;; + esac + done < /proc/1/status + test ! -w /etc/nginx/tls/server.key + ' +} + +make_ca ingress-ca +make_ca client-ca +make_ca untrusted-ca +make_ca upstream-ca +make_ca upstream-next-ca +make_cert ingress localhost DNS:localhost serverAuth ingress-ca +make_cert ingress-renewed localhost DNS:localhost serverAuth ingress-ca +make_cert client client.test DNS:client.test clientAuth client-ca +make_cert revoked-client revoked.test DNS:revoked.test clientAuth client-ca +make_cert untrusted-client intruder.test DNS:intruder.test clientAuth untrusted-ca +make_cert backend backend.test DNS:backend.test serverAuth upstream-ca +make_cert wrong-host wrong.test DNS:wrong.test serverAuth upstream-ca +make_cert revoked-backend backend.test DNS:backend.test serverAuth upstream-ca +make_cert backend-next backend.test DNS:backend.test serverAuth upstream-next-ca +revoke_cert client-ca revoked-client +revoke_cert upstream-ca revoked-backend +make_crl ingress-ca +make_crl untrusted-ca +make_crl upstream-next-ca + +cp "${evidence}/ingress-ca/ca.crt" "${evidence}/ingress/ca.crt" +cp "${evidence}/client-ca/ca.crt" "${evidence}/ingress/client-ca.crt" +cp "${evidence}/client-ca/ca.crl" "${evidence}/ingress/client.crl" +cp "${evidence}/upstream-ca/ca.crt" "${evidence}/backend/upstream-ca.crt" +cp "${evidence}/upstream-ca/ca.crl" "${evidence}/backend/upstream.crl" +cp "${evidence}/upstream-ca/ca.crt" "${evidence}/wrong-host/upstream-ca.crt" +cp "${evidence}/upstream-ca/ca.crl" "${evidence}/wrong-host/upstream.crl" +cp "${evidence}/upstream-ca/ca.crt" \ + "${evidence}/revoked-backend/upstream-ca.crt" +cp "${evidence}/upstream-ca/ca.crl" \ + "${evidence}/revoked-backend/upstream.crl" +cp "${evidence}/upstream-next-ca/ca.crt" \ + "${evidence}/backend-next/upstream-ca.crt" +cp "${evidence}/upstream-next-ca/ca.crl" \ + "${evidence}/backend-next/upstream.crl" +mkdir -p "${evidence}/proxy-trust" "${evidence}/wrong-trust" \ + "${evidence}/overlap-trust" "${evidence}/next-trust" \ + "${evidence}/missing-key" +cp "${evidence}/upstream-ca/ca.crt" "${evidence}/proxy-trust/upstream-ca.crt" +cp "${evidence}/upstream-ca/ca.crl" "${evidence}/proxy-trust/upstream.crl" +cp "${evidence}/untrusted-ca/ca.crt" \ + "${evidence}/wrong-trust/upstream-ca.crt" +cp "${evidence}/untrusted-ca/ca.crl" \ + "${evidence}/wrong-trust/upstream.crl" +cp "${evidence}/upstream-ca/ca.crt" \ + "${evidence}/overlap-trust/upstream-ca.crt" +cp "${evidence}/upstream-next-ca/ca.crt" \ + "${evidence}/overlap-trust/upstream-next-ca.crt" +cat "${evidence}/overlap-trust/upstream-next-ca.crt" >> \ + "${evidence}/overlap-trust/upstream-ca.crt" +cp "${evidence}/upstream-ca/ca.crl" \ + "${evidence}/overlap-trust/upstream.crl" +cat "${evidence}/upstream-next-ca/ca.crl" >> \ + "${evidence}/overlap-trust/upstream.crl" +cp "${evidence}/upstream-next-ca/ca.crt" \ + "${evidence}/next-trust/upstream-ca.crt" +cp "${evidence}/upstream-next-ca/ca.crl" \ + "${evidence}/next-trust/upstream.crl" +cp "${evidence}/ingress/server.crt" "${evidence}/missing-key/server.crt" +chmod 0644 "${evidence}"/*/*.crt +chmod 0644 "${evidence}"/*/*.crl +chmod 0640 "${evidence}"/*/*.key + +# Rootless Podman maps the invoking user onto container GID 0, so the runtime +# identity reads these keys through the group bit while they stay unreadable to +# other host users. Rootful Docker preserves host ownership instead, and its +# container GID 0 is host root, so the identical file is unreadable there. +# Setting group 0 on the host would fix Docker and break rootless Podman, so +# the compatibility leg widens the mode on this throwaway rehearsal material +# rather than weakening the primary runtime. These keys are generated per run +# outside the repository and destroyed with the evidence directory. +# +# This is a harness accommodation, not deployment guidance. A deployment makes +# keys readable by the runtime identity through ownership, as described in +# docs/TLS-LIFECYCLE.md, and never by making them readable to every user. +if ! grep -qi podman <<< "$("${runtime}" --version 2>&1)"; then + chmod 0644 "${evidence}"/*/*.key +fi + +"${openssl}" verify -CAfile "${evidence}/ingress-ca/ca.crt" \ + -purpose sslserver -verify_hostname localhost \ + "${evidence}/ingress/server.crt" >/dev/null +"${openssl}" verify -CAfile "${evidence}/client-ca/ca.crt" \ + -purpose sslclient "${evidence}/client/server.crt" >/dev/null +"${openssl}" verify -CAfile "${evidence}/upstream-ca/ca.crt" \ + -purpose sslserver -verify_hostname backend.test \ + "${evidence}/backend/server.crt" >/dev/null +"${python}" "${repository}/scripts/tls_material.py" --warning-hours 1 \ + --openssl "${openssl_for_python}" \ + --certificate "${evidence}/ingress/server.crt" \ + --certificate "${evidence}/client/server.crt" \ + --certificate "${evidence}/backend/server.crt" \ + --crl "${evidence}/client-ca/ca.crl" \ + --crl "${evidence}/upstream-ca/ca.crl" >/dev/null + +run_restricted "${termination}" 11001 \ + "${examples_dir}/tls-termination/nginx.conf" "${evidence}/ingress" \ + --publish 127.0.0.1::8443 \ + --volume "${script_dir}/fixtures/profile-site:/srv/www:ro" +termination_binding=$("${runtime}" port "${termination}" 8443/tcp) +termination_port=${termination_binding##*:} +termination_url="https://localhost:${termination_port}" +wait_for_tls "${termination_url}/healthz" "${evidence}/ingress-ca/ca.crt" \ + "${termination}" --resolve "localhost:${termination_port}:127.0.0.1" +assert_process_security "${termination}" + +for tls_version in 1.2 1.3; do + test "$(curl --fail --silent --show-error \ + --tlsv${tls_version} --tls-max "${tls_version}" \ + --cacert "${evidence}/ingress-ca/ca.crt" \ + --resolve "localhost:${termination_port}:127.0.0.1" \ + --header "X-Request-ID: tls.valid-${tls_version}" \ + "${termination_url}/")" = static-profile-ok +done +if curl --silent --show-error --tls-max 1.1 \ + --cacert "${evidence}/ingress-ca/ca.crt" \ + --resolve "localhost:${termination_port}:127.0.0.1" \ + --output "${null_device}" "${termination_url}/" 2>/dev/null; then + echo "TLS 1.1 unexpectedly succeeded" >&2 + exit 1 +fi +if curl --silent --show-error \ + --cacert "${evidence}/untrusted-ca/ca.crt" \ + --resolve "localhost:${termination_port}:127.0.0.1" \ + --output "${null_device}" "${termination_url}/" 2>/dev/null; then + echo "An untrusted ingress certificate unexpectedly succeeded" >&2 + exit 1 +fi +termination_logs=$("${runtime}" logs "${termination}" 2>&1) +printf '%s\n' "${termination_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile tls-termination --uri /index.html \ + --request-id tls.valid-1.3 --status 200 +if grep -Eq 'BEGIN .*PRIVATE KEY|client\.test' <<< "${termination_logs}"; then + echo "TLS termination logs exposed private-key or client identity data" >&2 + exit 1 +fi + +old_serial=$("${openssl}" x509 -in "${evidence}/ingress/server.crt" \ + -noout -serial) +new_serial=$("${openssl}" x509 -in "${evidence}/ingress-renewed/server.crt" \ + -noout -serial) +test "${old_serial}" != "${new_serial}" +cp "${evidence}/ingress-renewed/server.crt" "${evidence}/ingress/server.crt" +cp "${evidence}/ingress-renewed/server.key" "${evidence}/ingress/server.key" +"${runtime}" exec "${termination}" nginx -t -q -c /etc/nginx/nginx.conf +"${runtime}" kill --signal HUP "${termination}" >/dev/null +sleep 1 +observed_serial=$(printf '' | "${openssl}" s_client \ + -connect "127.0.0.1:${termination_port}" -servername localhost \ + -CAfile "${evidence}/ingress-ca/ca.crt" 2>/dev/null \ + | "${openssl}" x509 -noout -serial) +test "${observed_serial}" = "${new_serial}" + +run_restricted "${mtls}" 11002 \ + "${examples_dir}/mutual-tls/nginx.conf" "${evidence}/ingress" \ + --publish 127.0.0.1::8443 \ + --volume "${script_dir}/fixtures/profile-site:/srv/www:ro" +mtls_binding=$("${runtime}" port "${mtls}" 8443/tcp) +mtls_port=${mtls_binding##*:} +mtls_url="https://localhost:${mtls_port}" +wait_for_tls "${mtls_url}/healthz" "${evidence}/ingress-ca/ca.crt" "${mtls}" \ + --resolve "localhost:${mtls_port}:127.0.0.1" \ + --cert "${evidence}/client/server.crt" \ + --key "${evidence}/client/server.key" +assert_process_security "${mtls}" +for client_options in \ + "" \ + "--cert ${evidence}/untrusted-client/server.crt --key ${evidence}/untrusted-client/server.key" \ + "--cert ${evidence}/revoked-client/server.crt --key ${evidence}/revoked-client/server.key"; do + # Word splitting is intentional for the two fixed curl option strings. + # shellcheck disable=SC2086 + if curl --fail --silent --show-error \ + --cacert "${evidence}/ingress-ca/ca.crt" \ + --resolve "localhost:${mtls_port}:127.0.0.1" \ + ${client_options} --output "${null_device}" "${mtls_url}/" \ + 2>/dev/null; then + echo "mTLS accepted a missing or untrusted client certificate" >&2 + exit 1 + fi +done +test "$(curl --fail --silent --show-error \ + --cacert "${evidence}/ingress-ca/ca.crt" \ + --resolve "localhost:${mtls_port}:127.0.0.1" \ + --cert "${evidence}/client/server.crt" \ + --key "${evidence}/client/server.key" \ + --header 'X-Request-ID: mtls.valid-1' "${mtls_url}/")" = static-profile-ok +mtls_logs=$("${runtime}" logs "${mtls}" 2>&1) +printf '%s\n' "${mtls_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile mutual-tls --uri /index.html \ + --request-id mtls.valid-1 --status 200 +if grep -Eq 'BEGIN .*PRIVATE KEY|client\.test' <<< "${mtls_logs}"; then + echo "mTLS logs exposed private-key or client identity data" >&2 + exit 1 +fi + +"${runtime}" run --detach --name "${missing_key}" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ + --cap-drop ALL --security-opt "${no_new_privileges}" --user 11003:0 \ + --volume "${examples_dir}/tls-termination/nginx.conf:/etc/nginx/nginx.conf:ro" \ + --volume "${evidence}/missing-key:/etc/nginx/tls:ro" \ + "${image}" -c /etc/nginx/nginx.conf -g 'daemon off;' >/dev/null +wait_for_exit "${missing_key}" +grep -Fq '/etc/nginx/tls/server.key' <<< \ + "$("${runtime}" logs "${missing_key}" 2>&1)" + +"${runtime}" network create "${network}" >/dev/null +run_restricted "${backend}" 11004 \ + "${script_dir}/fixtures/tls-backend/nginx.conf" "${evidence}/backend" \ + --network "${network}" --network-alias backend +"${runtime}" exec "${backend}" nginx -t -q -c /etc/nginx/nginx.conf +assert_process_security "${backend}" + +run_restricted "${proxy}" 11005 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/proxy-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +proxy_binding=$("${runtime}" port "${proxy}" 8080/tcp) +proxy_port=${proxy_binding##*:} +proxy_url="http://127.0.0.1:${proxy_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${proxy_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +assert_process_security "${proxy}" +proxy_response=$(curl --fail --silent --show-error \ + --header 'X-Request-ID: upstream-tls.valid-1' "${proxy_url}/application") +grep -Fq '"request_id":"upstream-tls.valid-1"' <<< "${proxy_response}" +proxy_logs=$("${runtime}" logs "${proxy}" 2>&1) +printf '%s\n' "${proxy_logs}" | "${python}" \ + "${script_dir}/validate_profile_logs.py" \ + --profile tls-upstream --uri /application \ + --request-id upstream-tls.valid-1 --status 200 + +run_restricted "${rotation_overlap}" 11011 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/overlap-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +rotation_binding=$("${runtime}" port "${rotation_overlap}" 8080/tcp) +rotation_port=${rotation_binding##*:} +rotation_url="http://127.0.0.1:${rotation_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${rotation_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${rotation_url}/old-issuer")" = 200 +"${runtime}" rm --force "${rotation_overlap}" >/dev/null + +run_restricted "${wrong_trust}" 11006 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/wrong-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +wrong_binding=$("${runtime}" port "${wrong_trust}" 8080/tcp) +wrong_port=${wrong_binding##*:} +wrong_url="http://127.0.0.1:${wrong_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${wrong_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +wrong_status=$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + --header 'X-Request-ID: upstream-tls.untrusted-1' "${wrong_url}/") +test "${wrong_status}" = 502 +grep -Fq 'upstream SSL certificate verify error' <<< \ + "$("${runtime}" logs "${wrong_trust}" 2>&1)" + +"${runtime}" rm --force "${backend}" >/dev/null +run_restricted "${backend}" 11007 \ + "${script_dir}/fixtures/tls-backend/nginx.conf" "${evidence}/wrong-host" \ + --network "${network}" --network-alias backend +run_restricted "${wrong_host}" 11008 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/proxy-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +wrong_host_binding=$("${runtime}" port "${wrong_host}" 8080/tcp) +wrong_host_port=${wrong_host_binding##*:} +wrong_host_url="http://127.0.0.1:${wrong_host_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${wrong_host_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +wrong_host_status=$(curl --silent --output "${null_device}" \ + --write-out '%{http_code}' "${wrong_host_url}/") +test "${wrong_host_status}" = 502 +grep -Fq 'upstream SSL certificate does not match "backend.test"' <<< \ + "$("${runtime}" logs "${wrong_host}" 2>&1)" + +"${runtime}" rm --force "${backend}" >/dev/null +run_restricted "${backend}" 11009 \ + "${script_dir}/fixtures/tls-backend/nginx.conf" \ + "${evidence}/revoked-backend" \ + --network "${network}" --network-alias backend +run_restricted "${revoked_upstream}" 11010 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/proxy-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +revoked_binding=$("${runtime}" port "${revoked_upstream}" 8080/tcp) +revoked_port=${revoked_binding##*:} +revoked_url="http://127.0.0.1:${revoked_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${revoked_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +revoked_status=$(curl --silent --output "${null_device}" \ + --write-out '%{http_code}' "${revoked_url}/") +test "${revoked_status}" = 502 +grep -Fq 'certificate revoked' <<< \ + "$("${runtime}" logs "${revoked_upstream}" 2>&1)" + +"${runtime}" rm --force "${backend}" >/dev/null +run_restricted "${backend}" 11012 \ + "${script_dir}/fixtures/tls-backend/nginx.conf" "${evidence}/backend-next" \ + --network "${network}" --network-alias backend +run_restricted "${rotation_overlap}" 11013 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/overlap-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +rotation_binding=$("${runtime}" port "${rotation_overlap}" 8080/tcp) +rotation_port=${rotation_binding##*:} +rotation_url="http://127.0.0.1:${rotation_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${rotation_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${rotation_url}/new-issuer")" = 200 + +run_restricted "${rotation_old_trust}" 11014 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/proxy-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +old_trust_binding=$("${runtime}" port "${rotation_old_trust}" 8080/tcp) +old_trust_port=${old_trust_binding##*:} +old_trust_url="http://127.0.0.1:${old_trust_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${old_trust_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${old_trust_url}/retired-issuer")" = 502 + +run_restricted "${rotation_new_only}" 11015 \ + "${examples_dir}/tls-upstream/nginx.conf" "${evidence}/next-trust" \ + --network "${network}" --publish 127.0.0.1::8080 +new_only_binding=$("${runtime}" port "${rotation_new_only}" 8080/tcp) +new_only_port=${new_only_binding##*:} +new_only_url="http://127.0.0.1:${new_only_port}" +for _ in {1..30}; do + if test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${new_only_url}/healthz" || true)" = 200; then + break + fi + sleep 1 +done +test "$(curl --silent --output "${null_device}" --write-out '%{http_code}' \ + "${new_only_url}/new-only")" = 200 + +"${runtime}" stop --time 10 \ + "${termination}" "${mtls}" "${proxy}" "${wrong_trust}" \ + "${wrong_host}" "${revoked_upstream}" "${rotation_overlap}" \ + "${rotation_old_trust}" "${rotation_new_only}" "${backend}" >/dev/null + +echo "Ingress TLS, mutual TLS, and verified-upstream TLS qualification passed for ${image}" diff --git a/tests/validate_profile_logs.py b/tests/validate_profile_logs.py new file mode 100644 index 0000000..5a11233 --- /dev/null +++ b/tests/validate_profile_logs.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Validate structured access events emitted by tested NGINX profiles.""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import json +import math +import re +import sys +from collections.abc import Iterable + +COMMON_FIELDS = { + "timestamp", + "request_id", + "method", + "uri", + "protocol", + "status", + "body_bytes_sent", + "request_time", +} +UPSTREAM_FIELDS = { + "upstream_addr", + "upstream_status", + "upstream_connect_time", + "upstream_header_time", + "upstream_response_time", +} +TLS_FIELDS = { + "tls_protocol", + "tls_cipher", + "tls_server_name", + "tls_session_reused", + "tls_client_verify", +} +PROFILES = {"static", "reverse-proxy", "tls-termination", "mutual-tls", "tls-upstream"} +REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + + +class ProfileLogError(ValueError): + """An access event differs from the reviewed profile contract.""" + + +def fail(message: str) -> None: + raise ProfileLogError(message) + + +def _number(value: object, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + fail(f"{label} must be a JSON number") + if not math.isfinite(value) or value < 0: + fail(f"{label} must be finite and non-negative") + return float(value) + + +def _validate_common(event: dict[str, object]) -> None: + for field in ("timestamp", "request_id", "method", "uri", "protocol"): + if not isinstance(event[field], str) or not event[field]: + fail(f"{field} must be a non-empty JSON string") + try: + timestamp = datetime.fromisoformat(event["timestamp"]) + except ValueError as exc: + fail(f"timestamp must be ISO 8601: {exc}") + if timestamp.tzinfo is None: + fail("timestamp must contain a UTC offset") + if not REQUEST_ID.fullmatch(event["request_id"]): + fail("request_id differs from the correlation-ID policy") + if not event["method"].isupper() or not event["method"].isalpha(): + fail("method must be an uppercase HTTP token") + if not event["uri"].startswith("/") or "?" in event["uri"]: + fail("uri must be an absolute path without query arguments") + if not event["protocol"].startswith("HTTP/"): + fail("protocol must identify HTTP") + status = event["status"] + if isinstance(status, bool) or not isinstance(status, int) or not 100 <= status <= 599: + fail("status must be an integer from 100 through 599") + body_bytes = event["body_bytes_sent"] + if isinstance(body_bytes, bool) or not isinstance(body_bytes, int) or body_bytes < 0: + fail("body_bytes_sent must be a non-negative JSON integer") + _number(event["request_time"], "request_time") + + +def parse_events( + raw: str, profile: str, forbidden: Iterable[str] = () +) -> list[dict[str, object]]: + """Parse and validate every access event mixed into container logs.""" + if profile not in PROFILES: + fail(f"unknown profile: {profile}") + for value in forbidden: + if value and value in raw: + fail(f"forbidden value occurred in logs: {value}") + upstream = profile in {"reverse-proxy", "tls-upstream"} + tls = profile in {"tls-termination", "mutual-tls"} + fields = ( + COMMON_FIELDS + | (UPSTREAM_FIELDS if upstream else set()) + | (TLS_FIELDS if tls else set()) + ) + events = [] + for line in raw.splitlines(): + if not line.startswith("{"): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + fail(f"invalid JSON access event: {exc}") + if not isinstance(event, dict): + fail("access event must be one JSON object") + if set(event) != fields: + fail("access event fields differ from the profile contract") + _validate_common(event) + if upstream: + for field in UPSTREAM_FIELDS: + if not isinstance(event[field], str) or not event[field]: + fail(f"{field} must be a non-empty JSON string") + if tls: + for field in TLS_FIELDS: + if not isinstance(event[field], str): + fail(f"{field} must be a JSON string") + if event["tls_protocol"] not in {"TLSv1.2", "TLSv1.3"}: + fail("tls_protocol is outside the qualified versions") + if not event["tls_cipher"]: + fail("tls_cipher must be a non-empty JSON string") + client_verify = event["tls_client_verify"] + if client_verify not in {"NONE", "SUCCESS"} and not client_verify.startswith( + "FAILED:" + ): + fail("tls_client_verify has an unexpected result") + events.append(event) + return events + + +def select_event( + events: Iterable[dict[str, object]], uri: str, request_id: str, status: int +) -> dict[str, object]: + """Require exactly one event matching the scenario identity.""" + matches = [ + event + for event in events + if event["uri"] == uri + and event["request_id"] == request_id + and event["status"] == status + ] + if len(matches) != 1: + fail(f"expected exactly one matching event; found {len(matches)}") + return matches[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--profile", choices=sorted(PROFILES), required=True + ) + parser.add_argument("--uri", required=True) + parser.add_argument("--request-id", required=True) + parser.add_argument("--status", required=True, type=int) + parser.add_argument("--forbidden", action="append", default=[]) + args = parser.parse_args() + + try: + events = parse_events(sys.stdin.read(), args.profile, args.forbidden) + event = select_event(events, args.uri, args.request_id, args.status) + except ProfileLogError as exc: + parser.error(str(exc)) + if event["method"] != "GET": + parser.error("matching event has an unexpected request method") + + print(f"validated {args.profile} structured access event") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())