diff --git a/CHANGELOG.md b/CHANGELOG.md index f309809..7ec703e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,3 +51,7 @@ but container releases use the upstream-derived format documented in a sensitive ClickHouse example, and safer variable choices. - Defined a source-independent pipeline contract that downloads and verifies locked artifacts outside a network-disabled container build. +- Expanded native and local runtime tests to prove non-root processes, zero + effective capabilities, `no-new-privileges`, arbitrary-UID operation, + read-only-root behavior, hardened temporary storage, log routing, graceful + reload and shutdown, and actionable negative startup cases. diff --git a/compose.yaml b/compose.yaml index 1d5ed47..385ef3f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ services: - "127.0.0.1:8080:8080" read_only: true tmpfs: - - /tmp:size=64m,mode=1777 + - /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 cap_drop: - ALL security_opt: diff --git a/docs/CI.md b/docs/CI.md index 5e560c6..f9effa1 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,7 +83,10 @@ The stable protected check names are `lint`, `configuration security`, and The implemented image pipeline performs: 1. Trivy build-configuration scanning. -2. Native architecture builds and restricted-runtime smoke tests. +2. Native architecture builds and restricted-runtime scenario tests covering + the declared and arbitrary runtime identities, process privileges, a + read-only root, hardened temporary storage, static content, health behavior, + log streams, reload and shutdown, and actionable startup failures. 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/ROADMAP.md b/docs/ROADMAP.md index 8e06b0c..81d0e8b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -102,9 +102,6 @@ or module set. ownership for every runtime component. - [ ] Define lock refresh, key rotation, artifact mirroring, rollback, and disconnected artifact-transfer procedures. -- [ ] Add negative tests for invalid configuration and unavailable writable - runtime paths with actionable failure diagnostics. -- [ ] Add graceful reload and shutdown assertions to the runtime suite. **Fast-release checkpoint:** after Package 2, a development image is usable for local evaluation but is not yet a supported release. diff --git a/tests/smoke.ps1 b/tests/smoke.ps1 index 813f96a..81e8075 100644 --- a/tests/smoke.ps1 +++ b/tests/smoke.ps1 @@ -7,16 +7,44 @@ $ErrorActionPreference = "Stop" $prefix = "nginx-ubi9-smoke-$PID" $fixedName = "$prefix-fixed" $arbitraryName = "$prefix-arbitrary" +$missingTmpName = "$prefix-missing-tmp" +$invalidConfigName = "$prefix-invalid-config" function Invoke-ContainerRuntime { param([Parameter(ValueFromRemainingArguments)] [string[]]$Arguments) - & $ContainerRuntime @Arguments - if ($LASTEXITCODE -ne 0) { + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $ContainerRuntime @Arguments + $runtimeExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousPreference + } + if ($runtimeExitCode -ne 0) { throw "$ContainerRuntime command failed: $($Arguments -join ' ')" } } +function Get-ContainerLogs { + param([string]$Name) + + $previousPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $records = & $ContainerRuntime logs $Name 2>&1 + $runtimeExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousPreference + } + if ($runtimeExitCode -ne 0) { + throw "Unable to read container logs for $Name" + } + return @($records | ForEach-Object { $_.ToString() }) +} + function Wait-Nginx { param([string]$Name) @@ -32,7 +60,63 @@ function Wait-Nginx { throw "NGINX did not become ready in $Name" } +function Assert-ProcessSecurity { + param([string]$Name) + + $script = 'for status in /proc/[0-9]*/status; do uid=; cap_eff=; no_new_privs=; while IFS=: read -r key value; do case ${key} in Uid) set -- ${value}; uid=$1 ;; CapEff) set -- ${value}; cap_eff=$1 ;; NoNewPrivs) set -- ${value}; no_new_privs=$1 ;; esac; done < ${status}; test -n ${uid}; test ${uid} -ne 0; test ${cap_eff} = 0000000000000000; test ${no_new_privs} = 1; done' + Invoke-ContainerRuntime exec $Name sh -eu -c $script +} + +function Assert-TmpfsSecurity { + param([string]$Name) + + $script = 'found=; while read -r device mount_point filesystem options remainder; do if test ${mount_point} = /tmp; then found=1; case ,${options}, in *,rw,*) : ;; *) exit 1 ;; esac; case ,${options}, in *,noexec,*) : ;; *) exit 1 ;; esac; case ,${options}, in *,nosuid,*) : ;; *) exit 1 ;; esac; case ,${options}, in *,nodev,*) : ;; *) exit 1 ;; esac; fi; done < /proc/mounts; test ${found} = 1; cp /bin/true /tmp/noexec-probe; chmod 0700 /tmp/noexec-probe; ! /tmp/noexec-probe >/dev/null 2>&1; rm -f /tmp/noexec-probe' + Invoke-ContainerRuntime exec $Name sh -eu -c $script +} + +function Assert-FailedContainer { + param([string]$Name) + + foreach ($attempt in 1..15) { + $state = & $ContainerRuntime inspect --format "{{.State.Status}}" $Name + if ($LASTEXITCODE -ne 0) { + throw "Unable to inspect failed container $Name" + } + if ($state -ne "running") { + $containerExitCode = & $ContainerRuntime inspect ` + --format "{{.State.ExitCode}}" $Name + if ($LASTEXITCODE -ne 0 -or [int]$containerExitCode -eq 0) { + throw "Expected container $Name to exit with a failure" + } + return + } + Start-Sleep -Seconds 1 + } + throw "Expected container $Name to exit within 15 seconds" +} + +function Assert-CleanStop { + param([string]$Name) + + Invoke-ContainerRuntime stop --time 10 $Name | Out-Null + $containerExitCode = & $ContainerRuntime inspect ` + --format "{{.State.ExitCode}}" $Name + if ($LASTEXITCODE -ne 0 -or [int]$containerExitCode -ne 0) { + throw "Expected a clean exit from $Name; received $containerExitCode" + } +} + try { + $runtimeVersion = & $ContainerRuntime --version + if ($LASTEXITCODE -ne 0) { + throw "Unable to determine the container runtime version" + } + $missingTmpRuntimeArguments = @() + if (($runtimeVersion -join "`n") -match "podman") { + # Podman otherwise creates writable tmpfs mounts for read-only containers. + $missingTmpRuntimeArguments += "--read-only-tmpfs=false" + } + $configuredUser = & $ContainerRuntime image inspect --format "{{.Config.User}}" $Image if ($LASTEXITCODE -ne 0 -or $configuredUser -ne "999:0") { throw "Expected image user 999:0; received $configuredUser" @@ -40,7 +124,7 @@ try { Invoke-ContainerRuntime run --detach --name $fixedName ` --read-only ` - --tmpfs "/tmp:size=64m,mode=1777" ` + --tmpfs "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777" ` --cap-drop ALL ` --security-opt "no-new-privileges:true" ` --publish "127.0.0.1::8080" ` @@ -53,10 +137,16 @@ try { throw "Expected fixed identity 999:0; received ${fixedUid}:${fixedGid}" } + Assert-ProcessSecurity $fixedName + Assert-TmpfsSecurity $fixedName Invoke-ContainerRuntime exec $fixedName nginx -t -q Invoke-ContainerRuntime exec $fixedName sh -c "test ! -w /etc/nginx/nginx.conf" Invoke-ContainerRuntime exec $fixedName sh -c ` - "! command -v dnf && ! command -v microdnf && ! command -v yum" + "! command -v dnf && ! command -v microdnf && ! command -v rpm && ! command -v yum" + Invoke-ContainerRuntime exec $fixedName sh -c ` + '! (printf probe > /root-filesystem-probe) >/dev/null 2>&1' + Invoke-ContainerRuntime exec $fixedName sh -c ` + 'read -r pid < /tmp/nginx.pid; test "${pid}" = "1"' $binding = & $ContainerRuntime port $fixedName "8080/tcp" if ($LASTEXITCODE -ne 0) { @@ -75,9 +165,42 @@ try { throw "The static landing page was not served" } + $missingStatus = & curl.exe --silent --show-error --output NUL ` + --write-out "%{http_code}" ` + "http://127.0.0.1:$hostPort/missing?smoke-probe=value" + if ($LASTEXITCODE -ne 0 -or $missingStatus -ne "404") { + throw "Expected a 404 response; received $missingStatus" + } + + $headers = & curl.exe --fail --silent --show-error --dump-header - ` + --output NUL "http://127.0.0.1:$hostPort/healthz" + if ($LASTEXITCODE -ne 0 -or ` + -not ($headers | Where-Object { $_.Trim() -ceq "Server: nginx" })) { + throw "The Server header was missing or disclosed the NGINX version" + } + + $fixedLogs = Get-ContainerLogs $fixedName + if (($fixedLogs -join "`n") -notmatch "/missing\?smoke-probe=value") { + throw "Expected access event was not written to container logs" + } + if (($fixedLogs -join "`n") -match "GET /healthz") { + throw "The health endpoint unexpectedly wrote an access event" + } + + Invoke-ContainerRuntime exec $fixedName nginx -s reload + $healthAfterReload = & curl.exe --fail --silent --show-error ` + "http://127.0.0.1:$hostPort/healthz" + if ($LASTEXITCODE -ne 0 -or $healthAfterReload -ne "ok") { + throw "Health request failed after graceful reload" + } + $reloadLogs = Get-ContainerLogs $fixedName + if (($reloadLogs -join "`n") -notmatch "reconfiguring") { + throw "NGINX did not log the graceful reload" + } + Invoke-ContainerRuntime run --detach --name $arbitraryName ` --read-only ` - --tmpfs "/tmp:size=64m,mode=1777" ` + --tmpfs "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777" ` --cap-drop ALL ` --security-opt "no-new-privileges:true" ` --user "10001:0" ` @@ -90,8 +213,45 @@ try { throw "Expected arbitrary identity 10001:0; received ${arbitraryUid}:${arbitraryGid}" } - Write-Output "Restricted-runtime smoke tests passed for $Image" + Assert-ProcessSecurity $arbitraryName + Assert-TmpfsSecurity $arbitraryName + Invoke-ContainerRuntime exec $arbitraryName nginx -t -q + + Invoke-ContainerRuntime run --detach --name $missingTmpName ` + --read-only ` + @missingTmpRuntimeArguments ` + --cap-drop ALL ` + --security-opt "no-new-privileges:true" ` + $Image | Out-Null + Assert-FailedContainer $missingTmpName + $missingTmpLogs = Get-ContainerLogs $missingTmpName + if (($missingTmpLogs -join "`n") -notmatch ` + "read-only file system|/tmp/nginx.pid") { + throw "Missing writable /tmp did not produce an actionable diagnostic" + } + + Invoke-ContainerRuntime run --detach --name $invalidConfigName ` + --read-only ` + --tmpfs "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777" ` + --cap-drop ALL ` + --security-opt "no-new-privileges:true" ` + --entrypoint sh ` + $Image -eu -c ` + 'echo "invalid_directive;" > /tmp/invalid.conf; exec nginx -t -c /tmp/invalid.conf' ` + | Out-Null + Assert-FailedContainer $invalidConfigName + $invalidLogs = Get-ContainerLogs $invalidConfigName + if (($invalidLogs -join "`n") -notmatch ` + "unknown directive.*invalid_directive|emerg") { + throw "Invalid configuration did not produce an actionable diagnostic" + } + + Assert-CleanStop $arbitraryName + Assert-CleanStop $fixedName + + Write-Output "Rootless restricted-runtime scenario tests passed for $Image" } finally { - & $ContainerRuntime rm --force $fixedName $arbitraryName 2>$null | Out-Null + & $ContainerRuntime rm --force $fixedName $arbitraryName ` + $missingTmpName $invalidConfigName 2>$null | Out-Null } diff --git a/tests/smoke.sh b/tests/smoke.sh index 6f37ab9..1e3490f 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -6,9 +6,19 @@ image="${IMAGE:-localhost/nginx-ubi9:development}" prefix="nginx-ubi9-smoke-${RANDOM}-$$" primary="${prefix}-primary" arbitrary="${prefix}-arbitrary" +missing_tmp="${prefix}-missing-tmp" +invalid_config="${prefix}-invalid-config" +missing_tmp_runtime_args=() + +if "${runtime}" --version 2>&1 | grep -qi podman; then + # Podman otherwise creates writable tmpfs mounts for read-only containers. + missing_tmp_runtime_args+=(--read-only-tmpfs=false) +fi cleanup() { - "${runtime}" rm --force "${primary}" "${arbitrary}" >/dev/null 2>&1 || true + "${runtime}" rm --force \ + "${primary}" "${arbitrary}" "${missing_tmp}" "${invalid_config}" \ + >/dev/null 2>&1 || true } trap cleanup EXIT @@ -17,13 +27,94 @@ run_restricted() { shift "${runtime}" run --detach --name "${name}" \ --read-only \ - --tmpfs /tmp:size=64m,mode=1777 \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ --cap-drop ALL \ --security-opt no-new-privileges:true \ "$@" \ "${image}" >/dev/null } +assert_process_security() { + local name="$1" + # The variables expand in the inner container shell, not this script. + # shellcheck disable=SC2016 + "${runtime}" exec "${name}" sh -eu -c ' + for status in /proc/[0-9]*/status; do + uid="" + cap_eff="" + no_new_privs="" + while IFS=: read -r key value; do + case "${key}" in + Uid) + set -- ${value} + uid="$1" + ;; + CapEff) + set -- ${value} + cap_eff="$1" + ;; + NoNewPrivs) + set -- ${value} + no_new_privs="$1" + ;; + esac + done < "${status}" + test -n "${uid}" + test "${uid}" -ne 0 + test "${cap_eff}" = "0000000000000000" + test "${no_new_privs}" = "1" + done + ' +} + +assert_tmpfs_security() { + local name="$1" + # The variables expand in the inner container shell, not this script. + # shellcheck disable=SC2016 + "${runtime}" exec "${name}" sh -eu -c ' + found="" + while read -r _ mount_point _ options _; do + if test "${mount_point}" = "/tmp"; then + found=1 + case ",${options}," in *,rw,*) : ;; *) exit 1 ;; esac + case ",${options}," in *,noexec,*) : ;; *) exit 1 ;; esac + case ",${options}," in *,nosuid,*) : ;; *) exit 1 ;; esac + case ",${options}," in *,nodev,*) : ;; *) exit 1 ;; esac + fi + done < /proc/mounts + test "${found}" = "1" + printf "#!/bin/sh\nexit 0\n" > /tmp/noexec-probe + chmod 0700 /tmp/noexec-probe + ! /tmp/noexec-probe >/dev/null 2>&1 + rm -f /tmp/noexec-probe + ' +} + +assert_clean_exit() { + local name="$1" + "${runtime}" stop --time 10 "${name}" >/dev/null + test "$("${runtime}" inspect --format '{{.State.ExitCode}}' "${name}")" = "0" +} + +wait_for_exit() { + local name="$1" + local state + local exit_code + local _ + for _ in {1..15}; do + state="$("${runtime}" inspect --format '{{.State.Status}}' "${name}")" + if test "${state}" != "running"; then + exit_code="$("${runtime}" inspect --format '{{.State.ExitCode}}' "${name}")" + test "${exit_code}" != "0" + return + fi + sleep 1 + done + "${runtime}" logs "${name}" >&2 + echo "Expected ${name} to exit with a failure" >&2 + return 1 +} + wait_for_nginx() { local name="$1" local _ @@ -43,18 +134,69 @@ run_restricted "${primary}" --publish 127.0.0.1::8080 wait_for_nginx "${primary}" test "$("${runtime}" exec "${primary}" id -u)" = "999" test "$("${runtime}" exec "${primary}" id -g)" = "0" +assert_process_security "${primary}" +assert_tmpfs_security "${primary}" "${runtime}" exec "${primary}" nginx -t -q "${runtime}" exec "${primary}" test ! -w /etc/nginx/nginx.conf -"${runtime}" exec "${primary}" sh -c '! command -v dnf && ! command -v microdnf && ! command -v yum' +"${runtime}" exec "${primary}" sh -c \ + '! command -v dnf && ! command -v microdnf && ! command -v rpm && ! command -v yum' +"${runtime}" exec "${primary}" sh -c \ + '! (printf probe > /root-filesystem-probe) >/dev/null 2>&1' +# The variable expands in the inner container shell, not this script. +# shellcheck disable=SC2016 +"${runtime}" exec "${primary}" sh -c 'read -r pid < /tmp/nginx.pid; test "${pid}" = "1"' binding="$("${runtime}" port "${primary}" 8080/tcp)" host_port="${binding##*:}" test "$(curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")" = "ok" curl --fail --silent --show-error "http://127.0.0.1:${host_port}/" | grep -Fq 'NGINX on UBI 9' +test "$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + "http://127.0.0.1:${host_port}/missing?smoke-probe=value")" = "404" +curl --fail --silent --show-error --dump-header - --output /dev/null \ + "http://127.0.0.1:${host_port}/healthz" | \ + grep -Eiq '^server: nginx[[:space:]]*$' +"${runtime}" logs "${primary}" 2>&1 | grep -Fq '/missing?smoke-probe=value' +if "${runtime}" logs "${primary}" 2>&1 | grep -Fq 'GET /healthz'; then + echo "The health endpoint unexpectedly wrote an access event" >&2 + exit 1 +fi + +"${runtime}" exec "${primary}" nginx -s reload +test "$(curl --fail --silent --show-error "http://127.0.0.1:${host_port}/healthz")" = "ok" +"${runtime}" logs "${primary}" 2>&1 | grep -Fq 'reconfiguring' run_restricted "${arbitrary}" --user 10001:0 wait_for_nginx "${arbitrary}" test "$("${runtime}" exec "${arbitrary}" id -u)" = "10001" test "$("${runtime}" exec "${arbitrary}" id -g)" = "0" +assert_process_security "${arbitrary}" +assert_tmpfs_security "${arbitrary}" +"${runtime}" exec "${arbitrary}" nginx -t -q + +"${runtime}" run --detach --name "${missing_tmp}" \ + --read-only \ + "${missing_tmp_runtime_args[@]}" \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + "${image}" >/dev/null +wait_for_exit "${missing_tmp}" +"${runtime}" logs "${missing_tmp}" 2>&1 | grep -Eiq \ + 'read-only file system|/tmp/nginx.pid' + +"${runtime}" run --detach --name "${invalid_config}" \ + --read-only \ + --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 \ + --cap-drop ALL \ + --security-opt no-new-privileges:true \ + --entrypoint sh \ + "${image}" -eu -c \ + 'printf "invalid_directive;\n" > /tmp/invalid.conf; exec nginx -t -c /tmp/invalid.conf' \ + >/dev/null +wait_for_exit "${invalid_config}" +"${runtime}" logs "${invalid_config}" 2>&1 | grep -Eiq \ + 'unknown directive.*invalid_directive|emerg' + +assert_clean_exit "${arbitrary}" +assert_clean_exit "${primary}" -echo "Rootless restricted-runtime smoke tests passed for ${image}" +echo "Rootless restricted-runtime scenario tests passed for ${image}"