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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
174 changes: 167 additions & 7 deletions tests/smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -32,15 +60,71 @@ 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"
}

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" `
Expand All @@ -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) {
Expand All @@ -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" `
Expand All @@ -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
}
Loading
Loading