From e9c22e3fccb198e40546912f64fc8235c7a3d7c5 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 11:19:38 +0100 Subject: [PATCH 01/25] fix(deps): update phpunit/phpunit to 13.4.5 for GHSA-9vw2-f4vp-x7gf PHPUnit 13.4.0 to 13.4.3 let other local users execute code through a shared temporary directory used for PHPT code coverage (PKSA-qhjm-rpqc-qkn6, reported 2026-10-27). composer audit (BE-03) fails on every CI run until the locked version is fixed. Composer's bump-after-update raises the constraint to ^13.4.5, so the vulnerable versions cannot be locked again. No other package changes. --- backend/composer.json | 2 +- backend/composer.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/composer.json b/backend/composer.json index 78addc3..87f950b 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -94,7 +94,7 @@ "phpstan/phpstan-doctrine": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-symfony": "^2.1", - "phpunit/phpunit": "^13", + "phpunit/phpunit": "^13.4.5", "symfony/browser-kit": "7.4.*", "symfony/css-selector": "7.4.*" } diff --git a/backend/composer.lock b/backend/composer.lock index 58967c0..189b382 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ae331b74c56b8d7bd8559fd4e0dd4f03", + "content-hash": "a01d843fa61bb909dc3800682f6ea7a5", "packages": [ { "name": "doctrine/collections", @@ -7970,16 +7970,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.4.3", + "version": "13.4.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2180c47c4229e5da117ff248753370443c074f10" + "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2180c47c4229e5da117ff248753370443c074f10", - "reference": "2180c47c4229e5da117ff248753370443c074f10", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/941457a4f147a60fe2074d40e1fce0d08ea8d930", + "reference": "941457a4f147a60fe2074d40e1fce0d08ea8d930", "shasum": "" }, "require": { @@ -8004,7 +8004,7 @@ "sebastian/environment": "^9.3.3", "sebastian/exporter": "^8.2.1", "sebastian/file-filter": "^1.0", - "sebastian/git-state": "^1.0", + "sebastian/git-state": "^1.1", "sebastian/global-state": "^9.0.1", "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", @@ -8050,7 +8050,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.5" }, "funding": [ { @@ -8058,7 +8058,7 @@ "type": "other" } ], - "time": "2026-10-16T05:56:47+00:00" + "time": "2026-10-27T04:16:22+00:00" }, { "name": "psr/http-factory", From c08a19b83f376989c6068fcb55887cd381004f71 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 12:37:26 +0100 Subject: [PATCH 02/25] fix(edge): build Caddy with go-jose v4.1.6 go-jose v4.1.5 in the rebuilt Caddy binary is affected by CVE-2026-116587 (GHSA-mgcc-cww5-fp2q, DoS when parsing JWKs with RSA keys); 4.1.6 fixes it. go.mod and go.sum updated with go get and go mod tidy; go mod verify is clean. --- docker/edge/caddy/go.mod | 2 +- docker/edge/caddy/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/edge/caddy/go.mod b/docker/edge/caddy/go.mod index fe1e928..270337d 100644 --- a/docker/edge/caddy/go.mod +++ b/docker/edge/caddy/go.mod @@ -47,7 +47,7 @@ require ( github.com/fxamacker/cbor/v2 v2.9.4 // indirect github.com/go-chi/chi/v5 v5.3.2 // indirect github.com/go-jose/go-jose/v3 v3.0.5 // indirect - github.com/go-jose/go-jose/v4 v4.1.5 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.10.1 // indirect diff --git a/docker/edge/caddy/go.sum b/docker/edge/caddy/go.sum index 5f3c502..b123e78 100644 --- a/docker/edge/caddy/go.sum +++ b/docker/edge/caddy/go.sum @@ -161,8 +161,8 @@ github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA= -github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= From 3f2e70b75dd65660b928778bf852a6c162af4115 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 12:37:26 +0100 Subject: [PATCH 03/25] docs(security): triage CVE-2026-116587 in the edge Caddy binary Record that the go-jose v4 path is fixed and that go-jose v3 3.0.5 stays: it is linked through caddypki, which caddytls, httpcaddyfile and therefore caddyhttp import, so no Caddy module selection removes it. State why FlowCRM's configuration does not reach the code and that the finding still fails the edge prod gate. --- docs/security/container-vulnerabilities.md | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index bf58b60..2005574 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -185,6 +185,43 @@ project's own health checks do not use `curl` (they use `cgi-fcgi` and Remove the upgrade when the pinned base images include the fix. No exception was added. +## CVE-2026-116587 / GHSA-mgcc-cww5-fp2q: `go-jose` (edge) + +Published by go-jose on 2026-10-14 and added to the GitHub advisory database +(HIGH) on 2026-10-27, after the last green scan of `main`; not caused by a +change of the project. Parsing a JWK with a crafted RSA key costs excessive +CPU (denial of service). The rebuilt Caddy binary (`usr/bin/caddy`, edge +`prod` and `dev`) contains both affected major versions: + +| Package | Installed | Fixed in | Status | +|---|---|---|---| +| `github.com/go-jose/go-jose/v4` | 4.1.5 | 4.1.6 | **fixed**: `docker/edge/caddy/go.mod` requires 4.1.6 | +| `github.com/go-jose/go-jose/v3` | 3.0.5 | none (no fixed v3 release) | **open** | + +`go-jose/v3` comes from `modules/caddypki` → `github.com/smallstep/certificates/authority` +→ `go.step.sm/crypto/jose` (0.91.0; the newest, 0.91.1, still requires +v3). It cannot be removed by building Caddy without `modules/caddypki`: +`modules/caddytls` (internal issuer and CA pools) and +`caddyconfig/httpcaddyfile` import `caddypki` directly, and +`modules/caddyhttp` imports `caddytls`, so every Caddy build with the HTTP +app links it. A build from the `modules/standard` list without `caddypki` +and `caddypki/acmeserver` still contains `go-jose/v3` 3.0.5 (`go version -m`) +and only drops the `http.handlers.acme_server` module. Removing v3 needs +either a release of `go.step.sm/crypto` and `smallstep/certificates` on +go-jose v4, or a change to Caddy itself. + +FlowCRM's configuration does not reach the vulnerable code: both +Caddyfiles disable automatic HTTPS (`auto_https off`), no configuration +defines a `pki` app, `tls internal` or `acme_server`, and the HTTPS test +edge (`tests/app/https`) loads its certificate from files +(`tls.certificates.load_files`). Only the `acme_server` handler passes +untrusted input (the JWS of an ACME request) to the vulnerable parser; +`tls internal` and the `pki` app parse only keys Caddy creates or loads +from its own configuration. With `admin off` the configuration cannot be +changed at run time. The finding still fails the runtime gate +for the edge `prod` image until it is fixed upstream or an exception is +decided. + ## Deferred hardening items - `framework.trusted_hosts` is not configured: behind the edge, Symfony From 2266471aff22e129f72c785efb68f7bc4c2f1477 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 12:53:36 +0100 Subject: [PATCH 04/25] docs(security): cite the scan runs of CVE-2026-116587 and link it from the Caddy triage --- docs/security/container-vulnerabilities.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 2005574..2c1b28e 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -77,7 +77,9 @@ clean) with `golang:1.26.10-alpine3.24@sha256:9ce231b28998b32e116927c61aa5c406183061d4f3f2d61b292c88e7f6bdf7ca` and `golang.org/x/net` 0.60.0; the official 2.11.7 image provides the base layer. `caddy version` reports `v2.11.7`. Result: **0 HIGH/CRITICAL** (was 24 -with the 2.11.4 release binary, 5 with the 2.11.7 release binary). +with the 2.11.4 release binary, 5 with the 2.11.7 release binary). Since +2026-10-27 the binary has a `go-jose` finding again; see +[CVE-2026-116587](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge). Triage of the 2.11.4 release binary: @@ -188,8 +190,9 @@ was added. ## CVE-2026-116587 / GHSA-mgcc-cww5-fp2q: `go-jose` (edge) Published by go-jose on 2026-10-14 and added to the GitHub advisory database -(HIGH) on 2026-10-27, after the last green scan of `main`; not caused by a -change of the project. Parsing a JWK with a crafted RSA key costs excessive +(HIGH) on 2026-10-27, after the last green scan of `main` (run 40407409241, +2026-10-26); found by the scan of PR #40 (run 40671261985, 2026-10-28), not +caused by a change of the project. Parsing a JWK with a crafted RSA key costs excessive CPU (denial of service). The rebuilt Caddy binary (`usr/bin/caddy`, edge `prod` and `dev`) contains both affected major versions: From cf9dc3e8af0c2bf13e99aa35fb68ef06640f718f Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 12:53:53 +0100 Subject: [PATCH 05/25] docs(security): rewrap the CVE-2026-116587 introduction --- docs/security/container-vulnerabilities.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 2c1b28e..b4c9bac 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -192,9 +192,10 @@ was added. Published by go-jose on 2026-10-14 and added to the GitHub advisory database (HIGH) on 2026-10-27, after the last green scan of `main` (run 40407409241, 2026-10-26); found by the scan of PR #40 (run 40671261985, 2026-10-28), not -caused by a change of the project. Parsing a JWK with a crafted RSA key costs excessive -CPU (denial of service). The rebuilt Caddy binary (`usr/bin/caddy`, edge -`prod` and `dev`) contains both affected major versions: +caused by a change of the project. Parsing a JWK with a crafted RSA key +costs excessive CPU (denial of service). The rebuilt Caddy binary +(`usr/bin/caddy`, edge `prod` and `dev`) contains both affected major +versions: | Package | Installed | Fixed in | Status | |---|---|---|---| From 2307a314132f2a44eb5c36bc415ab4ca42c1ad93 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:52:14 +0100 Subject: [PATCH 06/25] test(ci): guard the edge configuration against ACME server and mutable admin The temporary exception for CVE-2026-116587 (go-jose/v3 in the edge Caddy binary) holds only while no edge configuration runs the code that parses untrusted JWS/JWK. scripts/ci/guard-edge-config.sh enforces that in the Repository guard job. It finds every tracked Caddy file and every place that starts Caddy (Dockerfile COPY into /etc/caddy and the inherited command, Compose commands and read-only mounts, scripts, bin/, the Makefile, workflows). It tokenizes and parses each configuration like Caddy 2.11 (comments, quotes, CR, escaped newlines, snippets, imports and globs resolved against the container files) and rejects acme_server, pki, internal issuers, local_certs, ACME and issuer options, tls other than cert/key files, global blocks without exactly admin off, auto_https off and persist_config off, environment placeholders, unresolved imports, caddy run/adapt/validate without --config --adapter caddyfile, --resume, --watch, --envfile, other subcommands and CADDY_ADMIN. Unsupported constructs fail. scripts/ci/edge-adapted-config.jq checks the JSON that caddy adapt makes of an edge configuration (admin disabled, no persistence, no pki app, no TLS automation or issuer, no acme_server handler, automatic HTTPS off). The regression suite covers each rule with unsafe fixtures, including imported files, snippets, globs, quoted and upper-case forms, second global blocks, Compose environment and commands, and JSON fixtures for the adapted-config check. --- .github/workflows/ci.yml | 7 + scripts/ci/edge-adapted-config.jq | 48 ++ scripts/ci/guard-edge-config.sh | 891 +++++++++++++++++++++ scripts/ci/tests/guard-edge-config.test.sh | 383 +++++++++ 4 files changed, 1329 insertions(+) create mode 100644 scripts/ci/edge-adapted-config.jq create mode 100755 scripts/ci/guard-edge-config.sh create mode 100755 scripts/ci/tests/guard-edge-config.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eed6460..1120940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,12 @@ jobs: - name: Run container scan exception guard regression tests run: scripts/ci/tests/guard-trivyignore.test.sh + - name: Keep the edge configuration free of ACME server, PKI, issuers and mutable admin + run: scripts/ci/guard-edge-config.sh + + - name: Run edge configuration guard regression tests + run: scripts/ci/tests/guard-edge-config.test.sh + - name: ShellCheck run: >- docker run --rm --network none -v "$PWD:/mnt:ro" -w /mnt @@ -50,6 +56,7 @@ jobs: scripts/ci/guard-tracked-files.sh scripts/ci/tests/guard-tracked-files.test.sh scripts/ci/guard-dockerfile-frontend.sh scripts/ci/tests/guard-dockerfile-frontend.test.sh scripts/ci/guard-trivyignore.sh scripts/ci/tests/guard-trivyignore.test.sh + scripts/ci/guard-edge-config.sh scripts/ci/tests/guard-edge-config.test.sh - name: actionlint run: >- diff --git a/scripts/ci/edge-adapted-config.jq b/scripts/ci/edge-adapted-config.jq new file mode 100644 index 0000000..c4385ce --- /dev/null +++ b/scripts/ci/edge-adapted-config.jq @@ -0,0 +1,48 @@ +# Checks the JSON that `caddy adapt` makes of an edge configuration (used by +# tests/app/scan.sh on the built images; regression cases in +# scripts/ci/tests/guard-edge-config.test.sh). Prints one line per +# violation and nothing when the configuration keeps the admin API, config +# persistence, the PKI app, certificate issuers, ACME and the acme_server +# handler off (docs/security/container-vulnerabilities.md, edge +# configuration guard). +# +# Usage: jq -r -f scripts/ci/edge-adapted-config.jq + +def extra($allowed): if type == "object" then keys - $allowed else [] end; +def issuer_module: . == "acme" or . == "internal" or . == "zerossl"; + +if type != "object" then + "the adapted configuration is not a JSON object" +else + . as $c + | (extra(["admin", "apps", "logging"]) | select(length > 0) + | "top-level settings other than admin, apps and logging: \(join(", "))"), + (if $c.admin.disabled != true then "admin.disabled is not true: the admin API is enabled" else empty end), + ($c.admin | extra(["disabled", "config"]) | select(length > 0) + | "admin has other settings: \(join(", "))"), + (if $c.admin.config.persist != false then "admin.config.persist is not false: persist_config is not off" else empty end), + ($c.admin.config | extra(["persist"]) | select(length > 0) + | "admin.config has other settings (load could fetch a configuration): \(join(", "))"), + (if $c.apps.pki != null then "apps.pki is configured: PKI app" else empty end), + ($c.apps | extra(["http", "tls"]) | select(length > 0) + | "apps other than http and tls: \(join(", "))"), + (if $c.apps.tls.automation != null then + "apps.tls.automation is configured (issuer modules: \([$c.apps.tls.automation | .. | objects | .module? | strings] | unique | join(", ")))" + else empty end), + ($c.apps.tls | extra(["certificates"]) | select(length > 0) + | "apps.tls has settings other than certificates: \(join(", "))"), + ($c.apps.tls.certificates | extra(["load_files"]) | select(length > 0) + | "apps.tls.certificates other than load_files: \(join(", "))"), + ([$c | .. | objects | select(.handler? == "acme_server")] | length | select(. > 0) + | "acme_server handler found (\(.)x): parses unauthenticated JWS with go-jose"), + ([$c | .. | objects | .module? | strings | select(issuer_module)] | unique | select(length > 0) + | "issuer modules found: \(join(", "))"), + ([$c | paths | .[-1] | strings | select(. == "issuers" or . == "issuer" or . == "on_demand" or . == "get_certificate")] + | unique | select(length > 0) | "certificate issuance settings found: \(join(", "))"), + (if ($c.apps.http.servers | type) != "object" or ($c.apps.http.servers | length) == 0 then + "no apps.http.servers" + else + $c.apps.http.servers | to_entries[] | select(.value.automatic_https.disable != true) + | "apps.http.servers.\(.key).automatic_https.disable is not true: automatic HTTPS (ACME) is on" + end) +end diff --git a/scripts/ci/guard-edge-config.sh b/scripts/ci/guard-edge-config.sh new file mode 100755 index 0000000..04b46df --- /dev/null +++ b/scripts/ci/guard-edge-config.sh @@ -0,0 +1,891 @@ +#!/usr/bin/env bash +# +# Fail unless every Caddy configuration the repository can run keeps the +# edge out of the code that parses untrusted JWS/JWK (go-jose, +# CVE-2026-116587, docs/security/container-vulnerabilities.md) and keeps +# its configuration immutable at run time: +# - no ACME server (acme_server), no PKI app (pki), no internal issuer +# (tls internal, issuer internal, local_certs) and no certificate +# issuance or ACME client (acme, zerossl, issuer, cert_issuer, acme_ca, +# acme_ca_root, acme_dns, acme_eab, on_demand, on_demand_tls, +# skip_install_trust, get_certificate), anywhere in any Caddy file; +# - tls only as "tls " (no block, no internal); +# - every configuration Caddy starts with has exactly one global options +# block, first, with exactly "admin off", "auto_https off" and +# "persist_config off" and no option outside the allow-list (log); +# - no environment placeholder ({$VAR}, substituted into the raw text +# before parsing, so it could inject any configuration; {env.VAR}), +# no snippet arguments or blocks ({args...}, {block}, {blocks...}), no +# heredoc and no named route (&(name), invoke); +# - every import resolves to a tracked file in the container (or to a +# snippet defined before it), globs included; an unresolved import fails; +# - every caddy command line (Dockerfiles, Compose files, scripts, the +# Makefile, workflows) uses only run, adapt, validate, version, +# list-modules or build-info; run, adapt and validate need +# "--config --adapter caddyfile" and reject --resume, +# --watch, --envfile and any unknown flag or argument; CADDY_ADMIN is +# rejected everywhere; +# - Caddy files reach the container only by a COPY of one tracked file +# to a full /etc/caddy path or a read-only Compose bind mount of one; +# no edge stage sets CMD or ENTRYPOINT (the pinned caddy base image's +# "caddy run --config /etc/caddy/Caddyfile --adapter caddyfile" is +# used; tests/app/scan.sh checks it in the built images); +# - no tracked Caddy file is unused by these start points, and no +# tracked JSON file looks like a Caddy JSON configuration. +# Caddyfiles are tokenized like Caddy 2.11's lexer (comments, quotes, +# backticks, escaped newlines, CR) and parsed like its parser (snippets, +# imports, logical lines), so comments and quotes neither hide a +# directive nor invent one. Anything the guard does not model fails. +# +# Usage: guard-edge-config.sh [repository-path] +# Exit codes: 0 configuration safe, 1 unsafe or unsupported construct, +# 2 inspection error (including no Caddy configuration found). +# +# Paths and values are printed with %q on single, indented lines, so +# crafted content cannot inject CI log commands. bash 3.2 compatible. + +set -u +LC_ALL=C +export LC_ALL + +repo=${1:-.} +self=scripts/ci/guard-edge-config.sh +self_test=scripts/ci/tests/guard-edge-config.test.sh +# Global options the edge may set besides admin, auto_https and +# persist_config. Extend only after reviewing the option (it must not +# enable the pki app, an issuer, ACME or the admin API). +allowed_globals=' log ' +max_tokens=20000 +max_import_depth=16 + +if ! top=$(git -C "$repo" rev-parse --show-toplevel) || [ -z "$top" ]; then + echo "ERROR: '$repo' is not inside a Git work tree." >&2 + exit 2 +fi +tracked=$(mktemp "${TMPDIR:-/tmp}/guard-edge-config.XXXXXX") || { + echo "ERROR: could not create a temporary file." >&2 + exit 2 +} +trap 'rm -f "$tracked"' EXIT +if ! git -C "$top" ls-files -z >"$tracked"; then + echo "ERROR: could not list tracked files in '$top'." >&2 + exit 2 +fi + +errors=0 +# report [value] +report() { + if [ "$errors" -eq 0 ]; then + echo "ERROR: the edge configuration could enable the ACME server, the PKI app, an issuer or mutable admin config:" + fi + if [ $# -ge 3 ]; then + printf ' %q: %s: %q\n' "$1" "$2" "$3" + else + printf ' %q: %s\n' "$1" "$2" + fi + errors=$((errors + 1)) +} + +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } + +# --- Discovery --------------------------------------------------------------- + +CONFIGS=() JSONS=() DOCKERFILES=() COMPOSES=() RUNNERS=() TRACKED=' ' +df_re='(^|/)([Dd][Oo][Cc][Kk][Ee][Rr][Ff][Ii][Ll][Ee](\.[^/]+)?|[^/]+\.[Dd][Oo][Cc][Kk][Ee][Rr][Ff][Ii][Ll][Ee]|[Cc][Oo][Nn][Tt][Aa][Ii][Nn][Ee][Rr][Ff][Ii][Ll][Ee])$' +while IFS= read -r -d '' path; do + TRACKED="$TRACKED$path " + base=${path##*/} + shopt -s nocasematch + case $base in + caddyfile* | *.caddyfile | *.caddy) CONFIGS+=("$path") ;; + *.json | *.json5 | *.jsonc) JSONS+=("$path") ;; + esac + if [ "$path" = "$self" ] || [ "$path" = "$self_test" ]; then + shopt -u nocasematch + continue + fi + if [[ $path =~ $df_re ]]; then + DOCKERFILES+=("$path") RUNNERS+=("$path") + else + case $base in + compose*.yaml | compose*.yml | docker-compose*.yaml | docker-compose*.yml | *.compose.yaml | *.compose.yml) + COMPOSES+=("$path") RUNNERS+=("$path") ;; + *.sh | *.bash | makefile | *.mk) RUNNERS+=("$path") ;; + *) + case $path in + bin/* | .github/workflows/*.yml | .github/workflows/*.yaml) RUNNERS+=("$path") ;; + esac + ;; + esac + fi + shopt -u nocasematch +done <"$tracked" + +is_tracked() { case $TRACKED in *" $1 "*) return 0 ;; esac; return 1; } + +if [ "${#CONFIGS[@]}" -eq 0 ]; then + echo "ERROR: no tracked Caddy configuration found in '$top'." >&2 + exit 2 +fi + +for f in ${JSONS[@]+"${JSONS[@]}"}; do + shopt -s nocasematch + case ${f##*/} in + *caddy*) report "$f" "Caddy JSON configuration (only Caddyfiles are supported by this guard)" ;; + *) grep -qE '"(apps|admin)"[[:space:]]*:' "$top/$f" && + report "$f" "looks like a Caddy JSON configuration (key \"apps\" or \"admin\"); only Caddyfiles are supported" ;; + esac + shopt -u nocasematch +done + +# --- Container paths: Dockerfile COPY and Compose bind mounts ---------------- + +# MAP_* : stage ("#", or "*" for Compose mounts, which +# the guard applies to every stage), container path, tracked source. +MAP_STAGE=() MAP_DEST=() MAP_SRC=() +ST_ID=() ST_PARENT=() + +stage_index() { # id -> index, or 1 + local k + for ((k = 0; k < ${#ST_ID[@]}; k++)); do + [ "${ST_ID[$k]}" = "$1" ] && { printf '%s' "$k"; return 0; } + done + return 1 +} + +# logical_lines [caddy]: joins lines ending in a backslash; prints +# "" per logical line (with "caddy": only +# lines that mention caddy in any case and are not full-line comments). +logical_lines() { + awk -v only="${2-}" ' + function out(t) { if (only == "" || (t !~ /^[[:space:]]*#/ && tolower(t) ~ /caddy/)) print start "\t" t } + { sub(/\r$/, "") } + { if (acc == "") start = NR; else acc = acc " " + if ($0 ~ /\\$/) { acc = acc substr($0, 1, length($0) - 1); next } + acc = acc $0; out(acc); acc = "" } + END { if (acc != "") out(acc) }' "$1" +} + +# normalize : quotes, brackets and commas become spaces; shell +# separators become " ; " (so a command line can be split into words). +# words : WORDS is the normalized text split on whitespace (no globbing). +normalize() { + local t=$1 + t=${t//\"/ } t=${t//\'/ } t=${t//\`/ } t=${t//[/ } t=${t//]/ } t=${t//,/ } + t=${t//;/ ; } t=${t//|/ ; } t=${t//&/ ; } t=${t//(/ ; } t=${t//)/ ; } + NORM=$t +} +words() { + normalize "$1" + set -f + # shellcheck disable=SC2206 # split on purpose; globbing is off + WORDS=($NORM) + set +f +} + +for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do + stage='' has_caddy_copy=no cmd_lines='' + while IFS=$'\t' read -r ln text; do + [[ $text =~ ^[[:space:]]*(#|$) ]] && continue + text=${text#"${text%%[![:space:]]*}"} + instr=${text%%[[:space:]]*} rest=${text#"$instr"} + shopt -s nocasematch + case $instr in + from | copy | add | cmd | entrypoint) instr=$(lower "$instr") ;; + *) instr=other ;; + esac + shopt -u nocasematch + case $instr in + from) + words "$rest" + w=(${WORDS[@]+"${WORDS[@]}"}) + img=$(lower "${w[0]-}") name="#${#ST_ID[@]}" parent='' + if [ "${#w[@]}" -ge 3 ] && [ "$(lower "${w[1]}")" = as ]; then name=$(lower "${w[2]}"); fi + stage_index "$df#$img" >/dev/null && parent="$df#$img" + stage="$df#$name" + ST_ID+=("$stage") ST_PARENT+=("$parent") + ;; + copy | add) + words "$rest" + w=(${WORDS[@]+"${WORDS[@]}"}) + dest=${w[${#w[@]}-1]-} + case $dest in /etc/caddy | /etc/caddy/*) ;; *) continue ;; esac + has_caddy_copy=yes + srcs=() from='' + for x in "${w[@]}"; do + case $x in --from=*) from=$x ;; --*) ;; *) srcs+=("$x") ;; esac + done + unset 'srcs[${#srcs[@]}-1]' + if [ "$instr" = add ]; then + report "$df:$ln" "ADD into /etc/caddy is not supported (use COPY of one tracked file)" "$text" + elif [ -n "$from" ]; then + report "$df:$ln" "COPY --from into /etc/caddy is not supported (the file must be a tracked one the guard can check)" "$text" + elif [ "${#srcs[@]}" -ne 1 ]; then + report "$df:$ln" "COPY into /etc/caddy must copy exactly one file" "$text" + elif ! is_tracked "${srcs[0]}"; then + report "$df:$ln" "COPY into /etc/caddy of a source that is not a tracked file" "${srcs[0]}" + else + case $dest in */) dest=$dest${srcs[0]##*/} ;; /etc/caddy) + report "$df:$ln" "COPY into /etc/caddy must name the full destination path" "$text" + continue + ;; + esac + MAP_STAGE+=("$stage") MAP_DEST+=("$dest") MAP_SRC+=("${srcs[0]}") + fi + ;; + cmd | entrypoint) cmd_lines="$cmd_lines $ln" ;; + *) + case $text in *"/etc/caddy"*) + report "$df:$ln" "only COPY may write to /etc/caddy (the guard checks the copied file)" "$text" + ;; + esac + ;; + esac + done < <(logical_lines "$top/$df") + if [ "$has_caddy_copy" = yes ] && [ -n "$cmd_lines" ]; then + report "$df" "CMD or ENTRYPOINT in an edge Dockerfile (lines$cmd_lines): the edge runs the caddy base image's command, checked by tests/app/scan.sh" + fi +done + +for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do + ln=0 + while IFS= read -r line || [ -n "$line" ]; do + ln=$((ln + 1)) + line=${line%$'\r'} + case $line in *"/etc/caddy"*) ;; *) continue ;; esac + [[ $line =~ ^[[:space:]]*# ]] && continue + if [[ $line =~ ^[[:space:]]*-[[:space:]]*\"?\./([^:\"[:space:]]+):(/etc/caddy/[^:\"[:space:]]+)(:([a-z,]+))?\"?[[:space:]]*$ ]]; then + src=${BASH_REMATCH[1]} dest=${BASH_REMATCH[2]} mode=${BASH_REMATCH[4]} + if ! is_tracked "$src"; then + report "$cf:$ln" "bind mount into /etc/caddy of a source that is not a tracked file" "$src" + elif [ "$mode" != ro ]; then + report "$cf:$ln" "bind mount into /etc/caddy must be read-only (:ro)" "$line" + else + MAP_STAGE+=("*") MAP_DEST+=("$dest") MAP_SRC+=("$src") + fi + elif [[ $line =~ ^[[:space:]]*(command|entrypoint): ]]; then + : # checked as a caddy command line below + else + report "$cf:$ln" "unsupported reference to /etc/caddy (only '- ./:/etc/caddy/:ro' mounts are accepted)" "$line" + fi + done <"$top/$cf" +done + +# --- caddy command lines ------------------------------------------------------- + +CFG_PATHS=' ' +# check_cli : the words after a caddy command word. +check_cli() { + local where=$1 sub=${2-} config='' adapter='' w v + shift + case $sub in + '' | ';') report "$where" "caddy without a subcommand on the same line (unsupported form)"; return ;; + version | list-modules | build-info) return ;; + run | adapt | validate) shift ;; + *) report "$where" "caddy subcommand not allowed (only run, adapt, validate, version, list-modules, build-info)" "$sub"; return ;; + esac + while [ $# -gt 0 ]; do + w=$1 + shift + case $w in + ';' | '>'* | '<'* | [0-9]'>'*) break ;; + --config=* | -c=*) config=${w#*=} ;; + --config | -c) config=${1-}; shift ;; + --adapter=* | -a=*) adapter=${w#*=} ;; + --adapter | -a) adapter=${1-}; shift ;; + --resume | -r | --resume=*) report "$where" "caddy $sub --resume would run the last autosaved (possibly admin-loaded) config" "$w" ;; + --watch | -w | --watch=*) report "$where" "caddy $sub --watch reloads the config at run time" "$w" ;; + --envfile | --envfile=*) report "$where" "caddy $sub --envfile could set CADDY_ADMIN or {\$VAR} values" "$w" ;; + --pidfile | --pingback) [ "$sub" = run ] || report "$where" "unknown flag for caddy $sub" "$w"; shift ;; + --pidfile=* | --pingback=* | --environ | -e) [ "$sub" = run ] || report "$where" "unknown flag for caddy $sub" "$w" ;; + --pretty | -p | --validate) [ "$sub" = adapt ] || report "$where" "unknown flag for caddy $sub" "$w" ;; + -*) report "$where" "unknown flag for caddy $sub (fails closed)" "$w" ;; + *) report "$where" "unexpected argument for caddy $sub" "$w" ;; + esac + done + if [ -z "$config" ]; then + report "$where" "caddy $sub without --config (it would look for ./Caddyfile or the autosave)" + else + case $config in + /etc/caddy/*) case $config in *'$'* | *'*'* | *'?'* | *..*) v=bad ;; *) v=ok ;; esac ;; + *) v=bad ;; + esac + if [ "$v" = ok ]; then + case $CFG_PATHS in *" $config "*) ;; *) CFG_PATHS="$CFG_PATHS$config " ;; esac + else + report "$where" "caddy $sub --config must be a literal path below /etc/caddy" "$config" + fi + fi + [ "$adapter" = caddyfile ] || report "$where" "caddy $sub needs --adapter caddyfile" "$adapter" +} + +# scan_runner : caddy command lines, CADDY_ADMIN and /etc/caddy mounts. +scan_runner() { + local f=$1 kind=other ln text words i w n nextind ind instr + case " ${COMPOSES[*]-} " in *" $f "*) kind=compose ;; esac + case " ${DOCKERFILES[*]-} " in *" $f "*) kind=dockerfile ;; esac + while IFS=$'\t' read -r ln text; do + case $text in *CADDY_ADMIN*) + report "$f:$ln" "CADDY_ADMIN would set the admin API address" "$text" ;; + esac + if [ "$kind" = dockerfile ]; then + instr=${text#"${text%%[![:space:]]*}"} instr=${instr%%[[:space:]]*} + shopt -s nocasematch + case $instr in run | cmd | entrypoint | healthcheck | shell | onbuild) instr=ok ;; esac + shopt -u nocasematch + [ "$instr" = ok ] || continue + fi + words "$text" + words=(${WORDS[@]+"${WORDS[@]}"}) + n=${#words[@]} + for ((i = 0; i < n; i++)); do + w=${words[$i]} + case $w in + *:/etc/caddy*) + v=${w%%:/etc/caddy*} dest=/etc/caddy${w#*:/etc/caddy} dest=${dest%%:*} + v=${v#\$APP_REPO/} v=${v#\$\{APP_REPO\}/} v=${v#\$PWD/} v=${v#./} + mapped=no + for ((k = 0; k < ${#MAP_DEST[@]}; k++)); do + [ "${MAP_STAGE[$k]}" = '*' ] && [ "${MAP_DEST[$k]}" = "$dest" ] && [ "${MAP_SRC[$k]}" = "$v" ] && mapped=yes + done + [ "$mapped" = yes ] || report "$f:$ln" "mount into /etc/caddy that is not a Compose-mounted tracked file at the same path" "$w" + ;; + *target=/etc/caddy* | *dst=/etc/caddy* | *destination=/etc/caddy*) + report "$f:$ln" "--mount into /etc/caddy is not supported" "$w" ;; + esac + case $w in + caddy | /*/caddy) ;; + *) continue ;; + esac + # An output path (go build -o /out/caddy) is not a command. + if [ "$i" -gt 0 ]; then + case ${words[$((i - 1))]} in -o | --output) continue ;; esac + fi + if [ "$kind" = compose ]; then + if ! [[ $text =~ ^[[:space:]]*(command|entrypoint):[[:space:]]*(\[.*\]|[^][|>[:space:]].*)[[:space:]]*$ ]]; then + report "$f:$ln" "caddy command in an unsupported YAML form (use command: [\"caddy\", ...] on one line)" "$text" + break + fi + ind=${text%%[![:space:]]*} + nextind=$(sed -n "$((ln + 1)),\$p" "$top/$f" | awk 'NF && $1 !~ /^#/ { match($0, /^[ \t]*/); print RLENGTH; exit }') + if [ -n "$nextind" ] && [ "$nextind" -gt "${#ind}" ]; then + report "$f:$ln" "caddy command continues on the next line (unsupported YAML form)" "$text" + break + fi + fi + check_cli "$f:$ln" "${words[@]:$((i + 1))}" + break + done + done < <(logical_lines "$top/$f" caddy) +} + +for f in ${RUNNERS[@]+"${RUNNERS[@]}"}; do scan_runner "$f"; done + +# --- Caddyfile lexer (Caddy 2.11 caddyconfig/caddyfile/lexer.go) ------------ + +# lex : LX_TEXT, LX_Q (0, '"' or '`'), LX_LINE; 1 if unsupported. +lex() { + local s n i ch val='' q=0 bt=0 hesc=0 esc=0 comment=0 line=1 skipped=0 tline=1 more=yes + LX_TEXT=() LX_Q=() LX_LINE=() LX_ERR='' + emit() { + LX_TEXT+=("$val") LX_Q+=("$1") LX_LINE+=("$tline") + val='' q=0 bt=0 hesc=0 esc=0 comment=0 + } + [ -r "$top/$1" ] || { LX_ERR="cannot read"; return 1; } + # One physical line at a time (with its LF), so that the substring + # expansions work on short strings. + exec 3<"$top/$1" + while [ "$more" = yes ]; do + if IFS= read -r s <&3; then s=$s$'\n'; else more=no; fi + n=${#s} i=0 + while [ "$i" -lt "$n" ]; do + ch=${s:$i:1} + i=$((i + 1)) + if [ "$q" = 0 ] && [ "$bt" = 0 ] && [ "$hesc" = 0 ] && [ "${#val}" -gt 1 ] && [ "${val:0:2}" = '<<' ]; then + if [ "$ch" = ' ' ]; then emit 0; continue; fi + LX_ERR="line $tline: heredoc (<<) is not supported" + exec 3<&- + return 1 + fi + if [ "$esc" = 0 ] && [ "$bt" = 0 ] && [ "$ch" = "\\" ]; then esc=1; continue; fi + if [ "$q" = 1 ] || [ "$bt" = 1 ]; then + if [ "$q" = 1 ] && [ "$esc" = 1 ]; then + [ "$ch" = '"' ] || val="$val\\" + esc=0 + elif { [ "$q" = 1 ] && [ "$ch" = '"' ]; } || { [ "$bt" = 1 ] && [ "$ch" = '`' ]; }; then + emit "$ch" + continue + fi + if [ "$ch" = $'\n' ]; then line=$((line + 1 + skipped)) skipped=0; fi + val="$val$ch" + continue + fi + case $ch in + ' ' | $'\t' | $'\n' | $'\r' | $'\v' | $'\f') + [ "$ch" = $'\r' ] && continue + if [ "$ch" = $'\n' ]; then + if [ "$esc" = 1 ]; then skipped=$((skipped + 1)) esc=0; else line=$((line + 1 + skipped)) skipped=0; fi + comment=0 + fi + [ "${#val}" -gt 0 ] && emit 0 + continue + ;; + esac + [ "$ch" = '#' ] && [ "${#val}" -eq 0 ] && comment=1 + [ "$comment" = 1 ] && continue + if [ "${#val}" -eq 0 ]; then + tline=$line + if [ "$ch" = '"' ]; then q=1; continue; fi + if [ "$ch" = '`' ]; then bt=1; continue; fi + fi + if [ "$esc" = 1 ]; then + if [ "$ch" = '<' ]; then hesc=1; else val="$val\\"; fi + esc=0 + fi + val="$val$ch" + done + done + exec 3<&- + [ "${#val}" -gt 0 ] && emit 0 + return 0 +} + +# lex_cached : lex once per file (LX_* as lex sets them). +LC_PATH=() LC_START=() LC_LEN=() LC_ERR=() LA_TEXT=() LA_Q=() LA_LINE=() +lex_cached() { + local k st len + for ((k = 0; k < ${#LC_PATH[@]}; k++)); do + [ "${LC_PATH[$k]}" = "$1" ] || continue + LX_ERR=${LC_ERR[$k]} st=${LC_START[$k]} len=${LC_LEN[$k]} + LX_TEXT=() LX_Q=() LX_LINE=() + if [ "$len" -gt 0 ]; then + LX_TEXT=("${LA_TEXT[@]:$st:$len}") LX_Q=("${LA_Q[@]:$st:$len}") LX_LINE=("${LA_LINE[@]:$st:$len}") + fi + [ -z "$LX_ERR" ] + return + done + lex "$1" + LC_PATH+=("$1") LC_START+=("${#LA_TEXT[@]}") LC_LEN+=("${#LX_TEXT[@]}") LC_ERR+=("$LX_ERR") + if [ "${#LX_TEXT[@]}" -gt 0 ]; then + LA_TEXT+=("${LX_TEXT[@]}") LA_Q+=("${LX_Q[@]}") LA_LINE+=("${LX_LINE[@]}") + fi + [ -z "$LX_ERR" ] +} + +# deny : sets DENY to why the token is rejected ('' if allowed). +deny() { + local r='' + shopt -s nocasematch + case $1 in + *acme_server*) r="ACME server (acme_server parses unauthenticated JWS with go-jose, CVE-2026-116587)" ;; + pki) r="PKI app (pki: local certificate authority)" ;; + internal) r="internal issuer (tls internal or issuer internal)" ;; + local_certs) r="local_certs (internal issuer for every site)" ;; + acme | zerossl | issuer | issuers | cert_issuer | acme_ca | acme_ca_root | acme_dns | acme_eab | on_demand | on_demand_tls | skip_install_trust | get_certificate) + r="certificate issuance or ACME client ($1)" ;; + esac + shopt -u nocasematch + case $1 in + *'{args'* | *'{block}'* | *'{blocks.'*) r="snippet arguments or blocks are not supported" ;; + '&('*) r="named routes (&(name)) are not supported" ;; + esac + DENY=$r +} + +# Raw checks of every tracked Caddy file, used or not. +for f in "${CONFIGS[@]}"; do + if [ -n "$(tr -d '\t\n\r -~' <"$top/$f" | head -c 1 | od -An -c)" ]; then + report "$f" "only printable ASCII, tab, CR and LF are accepted (Caddy splits tokens at Unicode spaces)" + continue + fi + ln=0 + while IFS= read -r line || [ -n "$line" ]; do + ln=$((ln + 1)) + case $line in *'{$'*) report "$f:$ln" "environment placeholder {\$...} (substituted into the raw text before parsing, also in comments)" "$line" ;; esac + case $line in *'{env.'*) report "$f:$ln" "environment placeholder {env.*} is not on the allow-list (none is)" "$line" ;; esac + done <"$top/$f" + if ! lex_cached "$f"; then + report "$f" "$LX_ERR" + continue + fi + for ((k = 0; k < ${#LX_TEXT[@]}; k++)); do + deny "${LX_TEXT[$k]}" + [ -z "$DENY" ] || report "$f:${LX_LINE[$k]}" "$DENY" "${LX_TEXT[$k]}" + done + used=no + for ((k = 0; k < ${#MAP_SRC[@]}; k++)); do [ "${MAP_SRC[$k]}" = "$f" ] && used=yes; done + [ "$used" = yes ] || report "$f" "Caddy file not copied or mounted into an edge container by any known start point (unknown configurations fail)" +done + +# --- Parser (Caddy 2.11 caddyconfig/caddyfile/parse.go) per entry ------------- + +# Visible files of the current stage: compose mounts first (they shadow the +# image), then the stage and its ancestors, nearest first, last COPY first. +VIS_DEST=() VIS_SRC=() +set_stage() { + local st=$1 k idx chain='' + VIS_DEST=() VIS_SRC=() + for ((k = ${#MAP_DEST[@]} - 1; k >= 0; k--)); do + [ "${MAP_STAGE[$k]}" = '*' ] && VIS_DEST+=("${MAP_DEST[$k]}") VIS_SRC+=("${MAP_SRC[$k]}") + done + while [ -n "$st" ]; do + for ((k = ${#MAP_DEST[@]} - 1; k >= 0; k--)); do + [ "${MAP_STAGE[$k]}" = "$st" ] && VIS_DEST+=("${MAP_DEST[$k]}") VIS_SRC+=("${MAP_SRC[$k]}") + done + case " $chain " in *" $st "*) break ;; esac + chain="$chain $st" + idx=$(stage_index "$st") || break + st=${ST_PARENT[$idx]} + done +} +resolve() { # container path -> RESOLVED (tracked source); 1 if none + local k + for ((k = 0; k < ${#VIS_DEST[@]}; k++)); do + [ "${VIS_DEST[$k]}" = "$1" ] && { RESOLVED=${VIS_SRC[$k]}; return 0; } + done + return 1 +} +# visible_paths: unique visible container paths, one per line. +visible_paths() { printf '%s\n' ${VIS_DEST[@]+"${VIS_DEST[@]}"} | awk '!seen[$0]++'; } + +clean_path() { # absolute path -> CLEAN, with . and .. segments removed + local p=$1/ out='' seg + while [ -n "$p" ]; do + seg=${p%%/*} p=${p#*/} + case $seg in '' | .) ;; ..) out=${out%/*} ;; *) out="$out/$seg" ;; esac + done + CLEAN=${out:-/} +} + +# Working token stream of the entry being parsed. +W_TEXT=() W_Q=() W_LINE=() W_FILE=() W_CHAIN=() W_SRC=() +# Snippets: name, and their tokens in the S_* arena (start, length). +SN_NAME=() SN_START=() SN_LEN=() +S_TEXT=() S_Q=() S_LINE=() S_FILE=() S_CHAIN=() S_SRC=() + +newline_between() { # i j: is W[j] on a new logical line after W[i]? + [ "${W_FILE[$1]}" != "${W_FILE[$2]}" ] && return 0 + [ "${W_CHAIN[$1]}" != "${W_CHAIN[$2]}" ] && return 0 + local t=${W_TEXT[$1]} nl + nl=${t//[!$'\n']/} + [ $((W_LINE[$1] + ${#nl})) -lt "${W_LINE[$2]}" ] +} + +# splice : replaces W[at..end) with the N_* tokens. +N_TEXT=() N_Q=() N_LINE=() N_FILE=() N_CHAIN=() N_SRC=() +splice() { + local a=$1 e=$2 + W_TEXT=(${W_TEXT[@]+"${W_TEXT[@]:0:$a}"} ${N_TEXT[@]+"${N_TEXT[@]}"} ${W_TEXT[@]+"${W_TEXT[@]:$e}"}) + W_Q=(${W_Q[@]+"${W_Q[@]:0:$a}"} ${N_Q[@]+"${N_Q[@]}"} ${W_Q[@]+"${W_Q[@]:$e}"}) + W_LINE=(${W_LINE[@]+"${W_LINE[@]:0:$a}"} ${N_LINE[@]+"${N_LINE[@]}"} ${W_LINE[@]+"${W_LINE[@]:$e}"}) + W_FILE=(${W_FILE[@]+"${W_FILE[@]:0:$a}"} ${N_FILE[@]+"${N_FILE[@]}"} ${W_FILE[@]+"${W_FILE[@]:$e}"}) + W_CHAIN=(${W_CHAIN[@]+"${W_CHAIN[@]:0:$a}"} ${N_CHAIN[@]+"${N_CHAIN[@]}"} ${W_CHAIN[@]+"${W_CHAIN[@]:$e}"}) + W_SRC=(${W_SRC[@]+"${W_SRC[@]:0:$a}"} ${N_SRC[@]+"${N_SRC[@]}"} ${W_SRC[@]+"${W_SRC[@]:$e}"}) +} + +# load_file -> N_* (appends); 1 on error +load_file() { + local cpath=$1 chain=$2 src k + resolve "$cpath" || return 1 + src=$RESOLVED + if ! lex_cached "$src"; then + report "$src" "$LX_ERR" + return 1 + fi + for ((k = 0; k < ${#LX_TEXT[@]}; k++)); do + N_TEXT+=("${LX_TEXT[$k]}") N_Q+=("${LX_Q[$k]}") N_LINE+=("${LX_LINE[$k]}") + N_FILE+=("$cpath") N_CHAIN+=("$chain") N_SRC+=("$src") + done +} + +# Results of parse_entry: final tokens with block number, depth and +# whether they start a logical line. +F_TEXT=() F_Q=() F_LINE=() F_SRC=() F_BLOCK=() F_DEPTH=() F_START=() +B_KEYS=() + +# parse_entry : 1 if the entry could not be parsed. +parse_entry() { + local entry=$1 ctx=$2 i j k pat depth=0 block=-1 sn found base dir glob p m stars q_marks nest start where chain depth_of_chain matches + W_TEXT=() W_Q=() W_LINE=() W_FILE=() W_CHAIN=() W_SRC=() + SN_NAME=() SN_START=() SN_LEN=() + S_TEXT=() S_Q=() S_LINE=() S_FILE=() S_CHAIN=() S_SRC=() + F_TEXT=() F_Q=() F_LINE=() F_SRC=() F_BLOCK=() F_DEPTH=() F_START=() B_KEYS=() + N_TEXT=() N_Q=() N_LINE=() N_FILE=() N_CHAIN=() N_SRC=() + load_file "$entry" "" || { report "$ctx" "configuration not found in the container" "$entry"; return 1; } + splice 0 0 + i=0 + while [ "$i" -lt "${#W_TEXT[@]}" ]; do + if [ "${#W_TEXT[@]}" -gt "$max_tokens" ]; then + report "$ctx" "more than $max_tokens tokens after imports (import cycle?)" + return 1 + fi + start=no + { [ "$i" -eq 0 ] || newline_between $((i - 1)) "$i"; } && start=yes + where="${W_SRC[$i]}:${W_LINE[$i]}" + # import , at the start of a line, at any depth + if [ "$start" = yes ] && [ "${W_TEXT[$i]}" = import ]; then + j=$((i + 1)) + while [ "$j" -lt "${#W_TEXT[@]}" ] && ! newline_between $((j - 1)) "$j"; do j=$((j + 1)); done + if [ $((j - i)) -ne 2 ]; then + report "$where" "import must have exactly one argument (import arguments and blocks are not supported) ($ctx)" "${W_TEXT[*]:$i:$((j - i))}" + return 1 + fi + pat=${W_TEXT[$((i + 1))]} + chain="${W_CHAIN[$i]}>${W_FILE[$i]}:${W_LINE[$i]}" + depth_of_chain=${chain//[!>]/} + if [ "${#depth_of_chain}" -gt "$max_import_depth" ]; then + report "$where" "imports nested deeper than $max_import_depth (import cycle?) ($ctx)" "$pat" + return 1 + fi + N_TEXT=() N_Q=() N_LINE=() N_FILE=() N_CHAIN=() N_SRC=() + found=no + for ((k = 0; k < ${#SN_NAME[@]}; k++)); do + if [ "${SN_NAME[$k]}" = "$pat" ]; then + found=yes + for ((m = SN_START[k]; m < SN_START[k] + SN_LEN[k]; m++)); do + N_TEXT+=("${S_TEXT[$m]}") N_Q+=("${S_Q[$m]}") N_LINE+=("${S_LINE[$m]}") + N_FILE+=("${S_FILE[$m]}") N_CHAIN+=("${S_CHAIN[$m]}$chain") N_SRC+=("${S_SRC[$m]}") + done + fi + done + if [ "$found" = no ]; then + dir=${W_FILE[$i]%/*} + case $pat in /*) glob=$pat ;; *) glob=$dir/$pat ;; esac + clean_path "$glob" + glob=$CLEAN + case $glob in *'['* | *']'* | *'{'* | *'}'* | *"\\"*) + report "$where" "import pattern with [, ], {, } or backslash is not supported ($ctx)" "$pat" + return 1 + ;; + esac + stars=${glob//[!*]/} q_marks=${glob//[!?]/} + if [ "${#stars}" -gt 1 ] || [ "${#q_marks}" -gt 1 ]; then + report "$where" "import glob with more than one wildcard (Caddy rejects it) ($ctx)" "$pat" + return 1 + fi + matches=() + if [ "${#stars}" -eq 0 ] && [ "${#q_marks}" -eq 0 ]; then + resolve "$glob" && matches=("$glob") + else + base=${glob##*/} + while IFS= read -r p; do + [ -n "$p" ] || continue + # shellcheck disable=SC2053 # $glob is the pattern + [[ $p == $glob ]] || continue + [ "${p//[!\/]/}" = "${glob//[!\/]/}" ] || continue + case $base in '*'*) case ${p##*/} in .*) continue ;; esac ;; esac + matches+=("$p") + done < <(visible_paths | sort) + fi + if [ "${#matches[@]}" -eq 0 ]; then + if [ "$SOFT" = soft ] && [ "${#stars}" -eq 0 ] && [ "${#q_marks}" -eq 0 ]; then + # Caddy refuses to start on this import: not runnable here. + return 3 + fi + report "$where" "import does not resolve to a snippet defined before it or to a tracked file in the container ($ctx)" "$pat" + return 1 + fi + for p in "${matches[@]}"; do load_file "$p" "$chain" || return 1; done + fi + splice "$i" "$j" + continue + fi + if [ "$depth" -eq 0 ]; then + if [ "$start" = no ]; then + report "$where" "unexpected token after a block on the same line ($ctx)" "${W_TEXT[$i]}" + return 1 + fi + j=$((i + 1)) + while [ "$j" -lt "${#W_TEXT[@]}" ] && ! newline_between $((j - 1)) "$j"; do j=$((j + 1)); done + k=$((j - 1)) + if [ "${W_TEXT[$k]}" != '{' ] || [ "${W_Q[$k]}" != 0 ]; then + report "$where" "top-level line must be ' {' or '{' (blocks without braces, multi-line addresses and stray tokens are not supported) ($ctx)" "${W_TEXT[*]:$i:$((j - i))}" + return 1 + fi + if [ $((j - i)) -eq 2 ] && [[ ${W_TEXT[$i]} =~ ^\(.+\)$ ]]; then + # Snippet definition: store its raw tokens (imports expand on use). + sn=${W_TEXT[$i]:1:$((${#W_TEXT[$i]} - 2))} + for ((m = 0; m < ${#SN_NAME[@]}; m++)); do + if [ "${SN_NAME[$m]}" = "$sn" ]; then + report "$where" "redeclaration of snippet ($ctx)" "$sn" + return 1 + fi + done + m=$j nest=1 + while [ "$m" -lt "${#W_TEXT[@]}" ]; do + if [ "${W_Q[$m]}" = 0 ]; then + [ "${W_TEXT[$m]}" = '{' ] && nest=$((nest + 1)) + [ "${W_TEXT[$m]}" = '}' ] && nest=$((nest - 1)) + fi + [ "$nest" -eq 0 ] && break + m=$((m + 1)) + done + if [ "$nest" -ne 0 ]; then + report "$where" "snippet without its closing brace ($ctx)" "$sn" + return 1 + fi + SN_NAME+=("$sn") SN_START+=("${#S_TEXT[@]}") SN_LEN+=($((m - j))) + for ((p = j; p < m; p++)); do + S_TEXT+=("${W_TEXT[$p]}") S_Q+=("${W_Q[$p]}") S_LINE+=("${W_LINE[$p]}") + S_FILE+=("${W_FILE[$p]}") S_CHAIN+=("${W_CHAIN[$p]}") S_SRC+=("${W_SRC[$p]}") + done + i=$((m + 1)) + continue + fi + block=$((block + 1)) + B_KEYS+=("$((j - i - 1))") + for ((m = i; m < j; m++)); do + F_TEXT+=("${W_TEXT[$m]}") F_Q+=("${W_Q[$m]}") F_LINE+=("${W_LINE[$m]}") F_SRC+=("${W_SRC[$m]}") + if [ "$m" -eq "$i" ]; then F_START+=(yes); else F_START+=(no); fi + F_BLOCK+=("$block") F_DEPTH+=(0) + done + depth=1 + i=$j + continue + fi + F_TEXT+=("${W_TEXT[$i]}") F_Q+=("${W_Q[$i]}") F_LINE+=("${W_LINE[$i]}") F_SRC+=("${W_SRC[$i]}") + F_BLOCK+=("$block") F_DEPTH+=("$depth") F_START+=("$start") + if [ "${W_Q[$i]}" = 0 ]; then + if [ "${W_TEXT[$i]}" = '{' ]; then + depth=$((depth + 1)) + elif [ "${W_TEXT[$i]}" = '}' ]; then + depth=$((depth - 1)) + F_DEPTH[${#F_DEPTH[@]} - 1]=$depth + fi + fi + i=$((i + 1)) + done + if [ "$depth" -ne 0 ]; then + report "$ctx" "unbalanced braces after imports" + return 1 + fi + return 0 +} + +# line_end : LINE_END is the index after the logical line at . +line_end() { + LINE_END=$(($1 + 1)) + while [ "$LINE_END" -lt "${#F_TEXT[@]}" ] && [ "${F_START[$LINE_END]}" = no ]; do LINE_END=$((LINE_END + 1)); done +} + +# check_entry [soft]: with soft, an entry whose plain +# (non-glob) import does not resolve in this stage is skipped (status 3): +# Caddy would refuse to start it there. +check_entry() { + local stage=$1 entry=$2 ctx k e name args globals=0 first_global=-1 seen=' ' b rc + ctx="$entry in stage ${stage##*#} of ${stage%#*}" + set_stage "$stage" + SOFT=${3:-no} + parse_entry "$entry" "$ctx" + rc=$? + SOFT=no + [ "$rc" -eq 0 ] || return "$rc" + for ((b = 0; b < ${#B_KEYS[@]}; b++)); do + if [ "${B_KEYS[$b]}" -eq 0 ]; then + globals=$((globals + 1)) + [ "$first_global" -ge 0 ] || first_global=$b + fi + done + if [ "$globals" -eq 0 ]; then + report "$ctx" "no global options block (admin off, auto_https off and persist_config off are required)" + elif [ "$globals" -gt 1 ]; then + report "$ctx" "more than one global options block ($globals)" + elif [ "$first_global" -ne 0 ]; then + report "$ctx" "the global options block is not the first block" + fi + for ((k = 0; k < ${#F_TEXT[@]}; k++)); do + [ "${F_START[$k]}" = yes ] || continue + line_end "$k" + e=$LINE_END + name=${F_TEXT[$k]} + args=$((e - k - 1)) + b=${F_BLOCK[$k]} + where="${F_SRC[$k]}:${F_LINE[$k]}" + if [ "${B_KEYS[$b]}" -eq 0 ] && [ "${F_DEPTH[$k]}" -eq 1 ]; then + case $name in + admin | auto_https | persist_config) + case $seen in *" $name "*) report "$where" "global option $name appears twice ($ctx)" ;; esac + seen="$seen$name " + if [ "$args" -ne 1 ] || [ "${F_TEXT[$((k + 1))]}" != off ]; then + report "$where" "global option must be exactly '$name off' ($ctx)" "${F_TEXT[*]:$k:$((e - k))}" + fi + ;; + '}') ;; + *) + case $allowed_globals in + *" $name "*) ;; + *) report "$where" "global option is not on the edge allow-list (admin, auto_https, persist_config,$allowed_globals) ($ctx)" "$name" ;; + esac + ;; + esac + fi + shopt -s nocasematch + case $name in + tls) + if [ "$args" -ne 2 ] || [ "${F_TEXT[$((e - 1))]}" = '{' ] || [[ ${F_TEXT[$((k + 1))]} == internal ]]; then + report "$where" "tls must be exactly 'tls ' (no block, no internal issuer, no ACME e-mail) ($ctx)" "${F_TEXT[*]:$k:$((e - k))}" + fi + ;; + invoke) report "$where" "named routes (invoke) are not supported ($ctx)" ;; + esac + shopt -u nocasematch + done + if [ "$globals" -eq 1 ]; then + for name in admin auto_https persist_config; do + case $seen in *" $name "*) ;; *) report "$ctx" "global option '$name off' is missing" ;; esac + done + fi + CHECKED="$CHECKED + $ctx" +} + +# Entries: every leaf stage with files in /etc/caddy runs /etc/caddy/Caddyfile +# (the base image's command) and every --config path that resolves in it. +CHECKED='' +RUNNABLE=' ' +SOFT=no +entries=0 +for ((s = 0; s < ${#ST_ID[@]}; s++)); do + st=${ST_ID[$s]} leaf=yes + for ((k = 0; k < ${#ST_ID[@]}; k++)); do [ "${ST_PARENT[$k]}" = "$st" ] && leaf=no; done + [ "$leaf" = yes ] || continue + set_stage "$st" + own=no + for ((k = 0; k < ${#MAP_DEST[@]}; k++)); do + [ "${MAP_STAGE[$k]}" = '*' ] && continue + for x in ${VIS_SRC[@]+"${VIS_SRC[@]}"}; do [ "$x" = "${MAP_SRC[$k]}" ] && own=yes; done + done + [ "$own" = yes ] || continue + if ! resolve /etc/caddy/Caddyfile; then + report "${st%#*}" "stage ${st##*#} has Caddy files but no tracked /etc/caddy/Caddyfile (it would run the base image's default config)" + continue + fi + check_entry "$st" /etc/caddy/Caddyfile + entries=$((entries + 1)) + for p in $CFG_PATHS; do + [ "$p" = /etc/caddy/Caddyfile ] && continue + set_stage "$st" + resolve "$p" || continue + check_entry "$st" "$p" soft + case $? in 3) continue ;; esac + entries=$((entries + 1)) + RUNNABLE="$RUNNABLE$p " + done +done +for p in $CFG_PATHS; do + [ "$p" = /etc/caddy/Caddyfile ] && continue + case $RUNNABLE in *" $p "*) continue ;; esac + report "$p" "caddy --config names a configuration that no edge stage provides with all its imports (no Dockerfile COPY or Compose mount, or an import that resolves nowhere)" +done + +if [ "$entries" -eq 0 ] && [ "$errors" -eq 0 ]; then + echo "ERROR: no edge start point found (no Dockerfile stage copies a Caddyfile to /etc/caddy)." >&2 + exit 2 +fi + +if [ "$errors" -gt 0 ]; then + echo "See the edge configuration guard in docs/security/container-vulnerabilities.md." + exit 1 +fi +printf 'OK: %d Caddy files, %d entry configurations checked:%s\n' "${#CONFIGS[@]}" "$entries" "$CHECKED" +exit 0 diff --git a/scripts/ci/tests/guard-edge-config.test.sh b/scripts/ci/tests/guard-edge-config.test.sh new file mode 100755 index 0000000..a960abf --- /dev/null +++ b/scripts/ci/tests/guard-edge-config.test.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +# +# Regression tests for scripts/ci/guard-edge-config.sh and +# scripts/ci/edge-adapted-config.jq. Every guard case runs in its own +# temporary Git repository holding copies of the repository's edge files +# (Dockerfile, Caddyfiles, Compose files, scan.sh) with one unsafe change; +# the repository under test is only read. The adapted-config cases run the +# jq check on JSON fixtures (tests/app/scan.sh runs it on the built images). +# +# Usage: guard-edge-config.test.sh +# GUARD= or ADAPTED= runs the suite against another +# implementation (used for mutation tests). + +set -u + +here=$(cd "$(dirname "$0")" && pwd) +guard=${GUARD:-"$here/../guard-edge-config.sh"} +adapted=${ADAPTED:-"$here/../edge-adapted-config.jq"} +real=$(git -C "$here" rev-parse --show-toplevel) || exit 2 +work=$(mktemp -d "${TMPDIR:-/tmp}/guard-edge-config-tests.XXXXXX") || exit 2 +trap 'rm -rf "$work"' EXIT +# Never let Git discover a repository above the fixtures. +export GIT_CEILING_DIRECTORIES=$work + +NL=$'\n' +TAB=$'\t' +CR=$'\r' +REAL=(docker/edge/Dockerfile docker/edge/Caddyfile.dev docker/edge/Caddyfile.prod docker/edge/common.caddy + tests/app/https/Caddyfile tests/app/https/compose.https.yaml compose.yaml compose.prodlike.yaml tests/app/scan.sh) +P=docker/edge/Caddyfile.prod +D=docker/edge/Caddyfile.dev +C=docker/edge/common.caddy +H=tests/app/https/Caddyfile +HC=tests/app/https/compose.https.yaml +DF=docker/edge/Dockerfile +pass=0 +fail=0 +case_no=0 + +new_repo() { + case_no=$((case_no + 1)) + repo="$work/repo-$case_no" + git init -q "$repo" || exit 2 +} + +# base: a new repository with copies of the real edge files. +base() { + local f + new_repo + for f in "${REAL[@]}"; do + mkdir -p "$repo/$(dirname "$f")" + cp "$real/$f" "$repo/$f" || exit 2 + done + git -C "$repo" add -f -- "${REAL[@]}" || exit 2 +} + +# put : write the file and add it to the index. +put() { + mkdir -p "$repo/$(dirname "$1")" + printf '%s' "$2" >"$repo/$1" + git -C "$repo" add -f -- "$1" || exit 2 +} + +# subst : replace every literal (one line) by . +subst() { + FROM=$2 TO=$3 awk 'BEGIN { f = ENVIRON["FROM"]; t = ENVIRON["TO"] } + { out = ""; s = $0 + while ((i = index(s, f)) > 0) { out = out substr(s, 1, i - 1) t; s = substr(s, i + length(f)) } + print out s }' "$repo/$1" >"$work/subst" || exit 2 + if cmp -s "$work/subst" "$repo/$1"; then + echo "HARNESS ERROR: '$2' not found in $1" >&2 + exit 2 + fi + cat "$work/subst" >"$repo/$1" +} + +# expect [message]: the guard's exit status, and +# the message (a fixed string) must appear in its output. +expect() { + local name=$1 want=$2 msg=${3-} got + "$guard" "$repo" >"$work/out" 2>&1 + got=$? + if [ "$got" -eq "$want" ] && { [ -z "$msg" ] || grep -qF -- "$msg" "$work/out"; }; then + pass=$((pass + 1)) + printf 'PASS %-74s exit=%s\n' "$name" "$got" + else + fail=$((fail + 1)) + printf 'FAIL %-74s exit=%s want=%s%s\n' "$name" "$got" "$want" "${msg:+ message=$msg}" + sed 's/^/ | /' "$work/out" + fi +} + +# bad : one substitution, must fail. +bad() { + base + subst "$3" "$4" "$5" + expect "$1" 1 "$2" +} + +SITE=':8080 {' +GLOBAL=' persist_config off' + +# --- Accepted ------------------------------------------------------------------ +repo=$real +expect "actual repository" 0 "3 entry configurations checked" +"$guard" "$real" >"$work/out" 2>&1 +for e in "/etc/caddy/Caddyfile in stage dev" "/etc/caddy/Caddyfile in stage prod" "/etc/caddy/https/Caddyfile in stage prod"; do + if grep -qF "$e" "$work/out"; then + pass=$((pass + 1)) + printf 'PASS %-74s\n' "actual repository checks $e" + else + fail=$((fail + 1)) + printf 'FAIL %-74s\n' "actual repository checks $e" + sed 's/^/ | /' "$work/out" + fi +done +base +expect "copy of the actual edge files" 0 +base +subst "$P" "$SITE" "# acme_server, pki and tls internal in a comment are ignored${NL}$SITE" +expect "acme_server, pki, tls internal in a comment are ignored" 0 +base +subst "$P" " admin off" " \"admin\" \"off\"" +expect "quoted admin off (same tokens for Caddy)" 0 +base +subst "$P" " admin off" " admin off${CR}" +subst "$P" " auto_https off" " auto_https off${CR}" +expect "CRLF line endings" 0 + +# --- acme_server, pki, issuers, anywhere ------------------------------------- +bad "acme_server in the production site" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} acme_server" +bad "acme_server in a handle block of the dev site" "ACME server (acme_server" "$D" " reverse_proxy web:5173" " acme_server" +bad "acme_server in an imported snippet (common.caddy baseline)" "ACME server (acme_server" "$C" "(baseline) {" "(baseline) {${NL} acme_server" +bad "acme_server in a snippet nobody imports" "ACME server (acme_server" "$C" "(php) {" "(unused) {${NL} acme_server${NL}}${NL}${NL}(php) {" +bad "acme_server in the HTTPS test edge" "ACME server (acme_server" "$H" " import app" " import app${NL} acme_server" +bad "upper-case ACME_SERVER" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} ACME_SERVER" +bad "quoted \"acme_server\" directive" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} \"acme_server\"" +bad "backtick-quoted acme_server directive" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} \`acme_server\`" +bad "acme_server split by a CR (Caddy drops CR)" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} acme_${CR}server" +bad "acme_server inside a route matcher block" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} route /acme/* {${NL} acme_server${NL} }" +bad "acme_server as part of a token (a#acme_server)" "ACME server (acme_server" "$P" "$SITE" "$SITE${NL} respond a#acme_server" +bad "pki global option" "PKI app (pki" "$P" "$GLOBAL" "$GLOBAL${NL} pki {${NL} ca local${NL} }" +bad "local_certs global option" "local_certs (internal issuer" "$P" "$GLOBAL" "$GLOBAL${NL} local_certs" +bad "acme_ca global option" "certificate issuance or ACME client (acme_ca)" "$P" "$GLOBAL" "$GLOBAL${NL} acme_ca https://ca.example/dir" +bad "acme_dns global option" "certificate issuance or ACME client (acme_dns)" "$P" "$GLOBAL" "$GLOBAL${NL} acme_dns cloudflare x" +bad "cert_issuer global option" "certificate issuance or ACME client (cert_issuer)" "$P" "$GLOBAL" "$GLOBAL${NL} cert_issuer zerossl x" +bad "on_demand_tls global option" "certificate issuance or ACME client (on_demand_tls)" "$P" "$GLOBAL" "$GLOBAL${NL} on_demand_tls {${NL} ask http://x${NL} }" +bad "email global option (not on the allow-list)" "global option is not on the edge allow-list" "$P" "$GLOBAL" "$GLOBAL${NL} email ops@example.test" +bad "servers global option (not on the allow-list)" "global option is not on the edge allow-list" "$P" "$GLOBAL" "$GLOBAL${NL} servers {${NL} protocols h1${NL} }" +bad "tls internal" "tls must be exactly" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls internal" +bad "tls internal: internal is rejected too" "internal issuer (tls internal" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls internal" +bad "tls with an ACME e-mail" "tls must be exactly" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls ops@example.test" +bad "tls block with issuer acme" "certificate issuance or ACME client (issuer)" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls {${NL} issuer acme${NL} }" +bad "tls files plus a block" "tls must be exactly" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls /a.crt /a.key {${NL} protocols tls1.3${NL} }" +bad "tls arguments continued over an escaped newline" "tls must be exactly" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls \\${NL} /a.crt /a.key extra" +bad "tls on_demand" "certificate issuance or ACME client (on_demand)" "$H" " tls /run/flowcrm-https/edge.crt /run/flowcrm-https/edge.key" " tls {${NL} on_demand${NL} }" +bad "zerossl issuer in a reverse_proxy block" "certificate issuance or ACME client (zerossl)" "$P" "$SITE" "$SITE${NL} reverse_proxy x {${NL} transport http {${NL} tls_client_auth zerossl${NL} }${NL} }" + +# --- Global options -------------------------------------------------------------- +bad "admin with an address" "global option must be exactly 'admin off'" "$P" " admin off" " admin localhost:2019" +bad "admin off with a block" "global option must be exactly 'admin off'" "$P" " admin off" " admin off {${NL} origins x${NL} }" +bad "upper-case admin OFF (Caddy reads it as an address)" "global option must be exactly 'admin off'" "$D" " admin off" " admin OFF" +bad "admin missing" "global option 'admin off' is missing" "$P" " admin off" "" +bad "auto_https missing" "global option 'auto_https off' is missing" "$D" " auto_https off" "" +bad "persist_config missing" "global option 'persist_config off' is missing" "$P" " persist_config off" "" +bad "auto_https disable_redirects" "global option must be exactly 'auto_https off'" "$P" " auto_https off" " auto_https disable_redirects" +bad "admin twice (admin off, then an address)" "global option admin appears twice" "$P" " admin off" " admin off${NL} admin :2019" +bad "comment ending in a backslash joins the next line" "global option must be exactly 'admin off'" "$P" " admin off" " admin off # note \\" +bad "second global options block" "more than one global options block" "$P" "$SITE" "{${NL} admin localhost:2019${NL}}${NL}$SITE" +bad "second global options block in the HTTPS test edge" "more than one global options block" "$H" "https://flowcrm.test:8443 {" "{${NL} admin :2019${NL}}${NL}https://flowcrm.test:8443 {" +base +subst "$D" " admin off" " admin localhost:2019" +expect "unsafe dev global block (dev image)" 1 "/etc/caddy/Caddyfile in stage dev" +base +subst "$P" " admin off" " admin localhost:2019" +expect "unsafe production global block also fails the HTTPS test edge" 1 "(/etc/caddy/https/Caddyfile in stage prod" +base +put "$P" "import /etc/caddy/common.caddy${NL}${NL}(app) {${NL} import baseline${NL}}${NL}${NL}:8080 {${NL} import app${NL}}${NL}${NL}{${NL} admin off${NL} auto_https off${NL} persist_config off${NL}}${NL}" +expect "global options block after a site block" 1 "the global options block is not the first block" +base +put "$P" "import /etc/caddy/common.caddy${NL}${NL}(app) {${NL} import baseline${NL}}${NL}${NL}:8080 {${NL} import app${NL}}${NL}" +expect "no global options block" 1 "no global options block" +bad "global options block in common.caddy, before the real one" "more than one global options block" "$C" "(php) {" "{${NL} admin :2019${NL}}${NL}${NL}(php) {" + +# --- Placeholders, snippets, imports, unsupported syntax ------------------------- +bad "{\$VAR} environment placeholder" "environment placeholder {\$...}" "$P" " admin off" " admin {\$CADDY_ADMIN_OFF:off}" +bad "{\$VAR} as a directive" "environment placeholder {\$...}" "$P" "$SITE" "$SITE${NL} {\$EXTRA}" +bad "{\$VAR} hidden in a comment" "environment placeholder {\$...}" "$C" "(php) {" "# {\$EVIL}${NL}(php) {" +bad "{env.VAR} placeholder" "environment placeholder {env.*}" "$P" "$SITE" "$SITE${NL} respond {env.SECRET}" +bad "snippet argument placeholder" "snippet arguments or blocks are not supported" "$C" "(php) {" "(php) {${NL} {args[0]}" +bad "snippet {block} placeholder" "snippet arguments or blocks are not supported" "$C" "(php) {" "(php) {${NL} {block}" +bad "import with arguments" "import must have exactly one argument" "$P" " import baseline" " import baseline acme_srv" +bad "import with a block" "import must have exactly one argument" "$P" " import baseline" " import baseline {${NL} x y${NL} }" +bad "unresolved import (missing file)" "import does not resolve" "$P" "import /etc/caddy/common.caddy" "import /etc/caddy/common.caddy${NL}import /etc/caddy/missing.caddy" +bad "unresolved import (undefined snippet)" "import does not resolve" "$P" " import baseline" " import nosuchsnippet" +bad "import glob that matches nothing" "import does not resolve" "$P" "import /etc/caddy/common.caddy" "import /etc/caddy/common.caddy${NL}import /etc/caddy/conf.d/*.caddy" +bad "import glob with two wildcards" "more than one wildcard" "$P" "import /etc/caddy/common.caddy" "import /etc/caddy/*/*.caddy" +bad "import of a host path outside the container files" "import does not resolve" "$P" "import /etc/caddy/common.caddy" "import /etc/caddy/common.caddy${NL}import ../../srv/x.caddy" +bad "re-import of common.caddy redeclares its snippets" "redeclaration of snippet" "$C" "(php) {" "import /etc/caddy/common.caddy${NL}(php) {" +base +put docker/edge/loop.caddy "import /etc/caddy/loop.caddy${NL}" +subst "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/common.caddy /etc/caddy/common.caddy${NL}COPY docker/edge/loop.caddy /etc/caddy/loop.caddy" +subst "$P" "import /etc/caddy/common.caddy" "import /etc/caddy/common.caddy${NL}import /etc/caddy/loop.caddy" +expect "import cycle (a file that imports itself)" 1 "import cycle?" +bad "heredoc" "heredoc (<<) is not supported" "$P" "$SITE" "$SITE${NL} respond <> /etc/caddy/Caddyfile${NL}USER 65534:65534" +bad "Dockerfile ADD into /etc/caddy" "ADD into /etc/caddy is not supported" "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "ADD docker/edge/common.caddy /etc/caddy/common.caddy" +bad "Dockerfile COPY --from into /etc/caddy" "COPY --from into /etc/caddy is not supported" "$DF" "COPY docker/edge/Caddyfile.prod /etc/caddy/Caddyfile" "COPY --from=spa-build /app/Caddyfile /etc/caddy/Caddyfile" +bad "Dockerfile COPY of a directory into /etc/caddy" "not a tracked file" "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/ /etc/caddy/" +bad "Dockerfile COPY of two files into /etc/caddy" "must copy exactly one file" "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/common.caddy docker/edge/Caddyfile.dev /etc/caddy/" +bad "Dockerfile RUN over a line continuation writes /etc/caddy" "only COPY may write" "$DF" "USER 65534:65534" "RUN true \\${NL} && cp /tmp/x /etc/caddy/Caddyfile${NL}USER 65534:65534" +bad "edge stage without a Caddyfile (base image default config)" "has Caddy files but no tracked /etc/caddy/Caddyfile" "$DF" "FROM caddy-base AS dev" "FROM caddy-base AS bare${NL}${NL}FROM caddy-base AS dev" +bad "Dockerfile RUN caddy reload" "caddy subcommand not allowed" "$DF" "USER 65534:65534" "RUN caddy reload --config /etc/caddy/Caddyfile${NL}USER 65534:65534" + +# --- Start points: Compose, scripts, Makefile, workflows -------------------------------- +base +subst compose.yaml " networks: [front, back]" " environment:${NL} CADDY_ADMIN: 0.0.0.0:2019${NL} networks: [front, back]" +expect "CADDY_ADMIN in the edge's Compose environment" 1 "CADDY_ADMIN would set the admin API address" +bad "--resume in a Compose command" "caddy run --resume" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\", \"caddyfile\", \"--resume\"]" +bad "-r (short --resume) in a Compose command" "caddy run --resume" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\", \"caddyfile\", \"-r\"]" +bad "--watch in a Compose command" "caddy run --watch" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\", \"caddyfile\", \"--watch\"]" +bad "--envfile in a Compose command" "caddy run --envfile" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\", \"caddyfile\", \"--envfile\", \"/run/x\"]" +bad "--config outside /etc/caddy" "--config must be a literal path below /etc/caddy" "$HC" "\"/etc/caddy/https/Caddyfile\"" "\"/srv/Caddyfile\"" +bad "--config=/etc/caddy/... no image or mount provides" "no edge stage provides" "$HC" "\"--config\", \"/etc/caddy/https/Caddyfile\"" "\"--config=/etc/caddy/other/Caddyfile\"" +bad "--config with a variable" "--config must be a literal path" "$HC" "\"/etc/caddy/https/Caddyfile\"" "\"\$CONFIG\"" +bad "--adapter json" "needs --adapter caddyfile" "$HC" "\"--adapter\", \"caddyfile\"" "\"--adapter\", \"json\"" +bad "no --adapter (a non-Caddyfile name would be read as JSON)" "needs --adapter caddyfile" "$HC" ", \"--adapter\", \"caddyfile\"" "" +bad "no --config (./Caddyfile or the autosave)" "without --config" "$HC" "\"--config\", \"/etc/caddy/https/Caddyfile\", " "" +bad "unknown caddy run flag" "unknown flag for caddy run" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\", \"caddyfile\", \"--resume-from\"]" +bad "caddy start in a Compose command" "caddy subcommand not allowed" "$HC" "[\"caddy\", \"run\"" "[\"caddy\", \"start\"" +bad "Compose command as a block sequence" "unsupported YAML form" "$HC" " command: [\"caddy\", \"run\", \"--config\", \"/etc/caddy/https/Caddyfile\", \"--adapter\", \"caddyfile\"]" " command:${NL} - caddy${NL} - run${NL} - --resume" +bad "Compose command flow sequence continued on the next line" "unsupported YAML form" "$HC" "\"--adapter\", \"caddyfile\"]" "\"--adapter\",${NL} \"caddyfile\", \"--resume\"]" +bad "Compose command plain scalar continued on the next line" "continues on the next line" "$HC" " command: [\"caddy\", \"run\", \"--config\", \"/etc/caddy/https/Caddyfile\", \"--adapter\", \"caddyfile\"]" " command: caddy run --config /etc/caddy/https/Caddyfile --adapter caddyfile${NL} --resume" +bad "Compose mount into /etc/caddy without :ro" "must be read-only" "$HC" "Caddyfile:/etc/caddy/https/Caddyfile:ro" "Caddyfile:/etc/caddy/https/Caddyfile" +bad "Compose mount of an untracked file into /etc/caddy" "not a tracked file" "$HC" "./tests/app/https/Caddyfile:/etc/caddy/https/Caddyfile:ro" "./tests/app/https/evil:/etc/caddy/https/Caddyfile:ro" +bad "Compose long-syntax mount into /etc/caddy" "unsupported reference to /etc/caddy" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - type: bind${NL} source: ./x${NL} target: /etc/caddy/Caddyfile" +base +put tests/app/extra.sh "#!/bin/sh${NL}docker exec edge caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile${NL}" +expect "caddy reload in a script" 1 "caddy subcommand not allowed" +base +put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm \\${NL} img caddy run --config /etc/caddy/Caddyfile \\${NL} --adapter caddyfile --resume${NL}" +expect "--resume after a shell line continuation" 1 "caddy run --resume" +base +put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm -v \"\$PWD/evil:/etc/caddy/Caddyfile:ro\" img caddy run --config /etc/caddy/Caddyfile --adapter caddyfile${NL}" +expect "docker run mounting another file over /etc/caddy/Caddyfile" 1 "mount into /etc/caddy that is not a Compose-mounted" +base +put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm --mount type=bind,src=./x,target=/etc/caddy/Caddyfile img${NL}" +expect "docker run --mount into /etc/caddy" 1 "--mount into /etc/caddy is not supported" +base +put tests/app/extra.sh "#!/bin/sh${NL}docker compose run --entrypoint caddy edge reload${NL}" +expect "--entrypoint caddy with another subcommand" 1 "caddy subcommand not allowed" +base +put tests/app/extra.sh "#!/bin/sh${NL}/usr/bin/caddy run --config /etc/caddy/Caddyfile --adapter caddyfile -w${NL}" +expect "absolute /usr/bin/caddy with -w" 1 "caddy run --watch" +base +put tests/app/extra.sh "#!/bin/sh${NL}sh -c 'caddy run --config=/etc/caddy/Caddyfile --adapter=caddyfile --resume'${NL}" +expect "caddy run --resume inside sh -c '...'" 1 "caddy run --resume" +base +put tests/app/extra.sh "#!/bin/sh${NL}export CADDY_ADMIN=localhost:2019${NL}" +expect "export CADDY_ADMIN in a script" 1 "CADDY_ADMIN would set" +base +put Makefile "serve:${NL}${TAB}caddy file-server --listen :8080${NL}" +expect "caddy file-server in the Makefile" 1 "caddy subcommand not allowed" +base +put .github/workflows/edge.yml "jobs:${NL} x:${NL} steps:${NL} - run: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile --resume${NL}" +expect "caddy run --resume in a workflow" 1 "caddy run --resume" +base +put bin/edge "#!/bin/sh${NL}exec caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile --pretty${NL}" +expect "caddy adapt with allowed flags in bin/ is accepted" 0 +base +put tests/app/extra.sh "#!/bin/sh${NL}echo 'the caddy edge'${NL}" +expect "prose with the word caddy fails closed (unknown subcommand)" 1 "caddy subcommand not allowed" +base +put tests/app/extra.sh "#!/bin/sh${NL}# caddy run --resume is never used here${NL}" +expect "caddy command in a full-line comment is ignored" 0 + +# --- Adapted JSON (scripts/ci/edge-adapted-config.jq) --------------------------------------- +SAFE='{"admin":{"disabled":true,"config":{"persist":false}},"logging":{"logs":{"default":{}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"handler":"file_server"}]}],"automatic_https":{"disable":true}}}}}}' +# adapted_case +adapted_case() { + local name=$1 want=$2 out rc + printf '%s' "$SAFE" | jq "$3" >"$work/adapted.json" || exit 2 + out=$(jq -r -f "$adapted" "$work/adapted.json" 2>&1) + rc=$? + if { [ -z "$want" ] && [ "$rc" -eq 0 ] && [ -z "$out" ]; } || + { [ -n "$want" ] && [ -n "$out" ] && printf '%s\n' "$out" | grep -qF -- "$want"; }; then + pass=$((pass + 1)) + printf 'PASS %-74s\n' "adapted: $name" + else + fail=$((fail + 1)) + printf 'FAIL %-74s want=%s\n' "adapted: $name" "${want:-no violation}" + printf '%s\n' "$out" | sed 's/^/ | /' + fi +} +if ! command -v jq >/dev/null 2>&1; then + fail=$((fail + 1)) + echo "FAIL jq is required for the adapted-config cases" +else + adapted_case "safe configuration" "" '.' + adapted_case "safe HTTPS test edge (certificates from files)" "" '.apps.tls = {"certificates":{"load_files":[{"certificate":"/c","key":"/k"}]}}' + adapted_case "admin enabled" "admin.disabled is not true" 'del(.admin.disabled)' + adapted_case "admin with a listen address" "admin has other settings: listen" '.admin.listen = "localhost:2019"' + adapted_case "admin config load" "admin.config has other settings" '.admin.config.load = {"module":"http"}' + adapted_case "persist_config on" "admin.config.persist is not false" '.admin.config.persist = true' + adapted_case "persist_config missing" "admin.config.persist is not false" 'del(.admin.config)' + adapted_case "pki app" "apps.pki is configured" '.apps.pki = {"certificate_authorities":{"local":{}}}' + adapted_case "other app" "apps other than http and tls: layer4" '.apps.layer4 = {}' + adapted_case "tls automation with an internal issuer" "apps.tls.automation is configured (issuer modules: internal)" '.apps.tls = {"automation":{"policies":[{"issuers":[{"module":"internal"}]}]}}' + adapted_case "tls automation with acme and zerossl issuers" "issuer modules found: acme, zerossl" '.apps.tls = {"automation":{"policies":[{"issuers":[{"module":"acme"},{"module":"zerossl"}]}]}}' + adapted_case "tls other than certificates" "apps.tls has settings other than certificates" '.apps.tls = {"session_tickets":{}}' + adapted_case "certificates other than load_files" "apps.tls.certificates other than load_files" '.apps.tls = {"certificates":{"automate":["x"]}}' + adapted_case "acme_server handler, nested in a subroute" "acme_server handler found (1x)" '.apps.http.servers.srv0.routes[0].handle = [{"handler":"subroute","routes":[{"handle":[{"handler":"acme_server"}]}]}]' + adapted_case "issuer key anywhere" "certificate issuance settings found: issuers" '.apps.http.servers.srv0.x = {"issuers":[]}' + adapted_case "on_demand anywhere" "certificate issuance settings found: on_demand" '.apps.http.servers.srv0.tls_connection_policies = [{"on_demand":true}]' + adapted_case "automatic HTTPS on" "automatic_https.disable is not true" 'del(.apps.http.servers.srv0.automatic_https)' + adapted_case "second server with automatic HTTPS" "servers.srv1.automatic_https.disable is not true" '.apps.http.servers.srv1 = {"listen":[":8443"]}' + adapted_case "no servers" "no apps.http.servers" '.apps.http.servers = {}' + adapted_case "storage setting" "top-level settings other than admin, apps and logging: storage" '.storage = {"module":"file_system"}' + adapted_case "not an object" "not a JSON object" '[.]' +fi + +# Output safety: crafted names and values stay on escaped, indented lines. +base +put "docker/edge/a${NL}::error::x.caddy" "::warning::x${NL}" +"$guard" "$repo" >"$work/out" 2>&1 +if grep -q '^::' "$work/out"; then + fail=$((fail + 1)) + printf 'FAIL %-74s\n' "crafted path and value printed on escaped lines" + sed 's/^/ | /' "$work/out" +else + pass=$((pass + 1)) + printf 'PASS %-74s\n' "crafted path and value printed on escaped lines" +fi + +echo +echo "TOTAL $((pass + fail)) passed=$pass failed=$fail" +[ "$fail" -eq 0 ] From 955449aa0b9c180055658fe28f4e441eee22d92a Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:52:14 +0100 Subject: [PATCH 07/25] test(app): check the adapted edge configuration in the built images tests/app/scan.sh already builds the edge prod and dev images. It now checks that they run the Caddy base image's command and set no CADDY_* variable other than CADDY_VERSION. It also adapts the configurations they run (prod, dev, and the HTTPS test edge of prodlike.sh) with the built binary and checks the JSON with scripts/ci/edge-adapted-config.jq. A control image derived from edge-prod with an unsafe Caddyfile (admin on, local_certs, acme_server) must be rejected for each of those settings. --- tests/app/scan.sh | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/app/scan.sh b/tests/app/scan.sh index a39d140..188af6a 100755 --- a/tests/app/scan.sh +++ b/tests/app/scan.sh @@ -124,4 +124,56 @@ rc=$? v "SC control: the gate rejects the known-vulnerable PostgreSQL base image" $? "(exit $rc)" say " control: $(grep -cE '^(HIGH|CRITICAL) ' "$work/control.log") HIGH/CRITICAL findings, e.g. $(grep -m1 -E '^CRITICAL ' "$work/control.log")" +# Edge configuration (docs/security/container-vulnerabilities.md, edge +# configuration guard): the JSON the built binary adapts from every +# configuration the edge images run keeps the admin API, config +# persistence, the PKI app, issuers, ACME and the acme_server handler off +# (scripts/ci/edge-adapted-config.jq). The images run the Caddy base +# image's command unchanged, without a CADDY_* variable (the admin address). +say "== edge configuration (adapted to JSON by the Caddy of the built images)" +adapt() { # id docker-run-args...: the adapted JSON goes to $work/.json + local id=$1 + shift + docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges:true "$@" \ + >"$work/$id.json" 2>"$work/$id.adapt.log" +} +adapted_ok() { # id: no violation in the adapted JSON + jq -r -f "$APP_REPO/scripts/ci/edge-adapted-config.jq" "$work/$1.json" >"$work/$1.violations" 2>&1 && + [ ! -s "$work/$1.violations" ] +} +edge_cmd='null ["caddy","run","--config","/etc/caddy/Caddyfile","--adapter","caddyfile"]' +if need "jq is available" command -v jq >/dev/null; then + for image in edge-prod edge-dev; do + run_cmd=$(docker image inspect -f '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' "$tag:$image") + env=$(docker image inspect -f '{{json .Config.Env}}' "$tag:$image") + [ "$run_cmd" = "$edge_cmd" ] && + printf '%s' "$env" | jq -e 'map(select(startswith("CADDY_") and (startswith("CADDY_VERSION=") | not))) == []' >/dev/null + v "SC $image: runs the Caddy base image's command on /etc/caddy/Caddyfile; no CADDY_* variable but CADDY_VERSION" $? "$run_cmd $env" + done + adapt edge-prod "$tag:edge-prod" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile && + adapted_ok edge-prod + v "SC edge-prod config: admin off, persist_config off, automatic HTTPS off, no pki, issuer or acme_server" $? "$(cat "$work/edge-prod.violations" "$work/edge-prod.adapt.log" 2>/dev/null | head -n 20)" + adapt edge-dev "$tag:edge-dev" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile && + adapted_ok edge-dev + v "SC edge-dev config: admin off, persist_config off, automatic HTTPS off, no pki, issuer or acme_server" $? "$(cat "$work/edge-dev.violations" "$work/edge-dev.adapt.log" 2>/dev/null | head -n 20)" + # The HTTPS test edge of prodlike.sh (tests/app/https/compose.https.yaml). + adapt edge-https -v "$APP_REPO/tests/app/https/Caddyfile:/etc/caddy/https/Caddyfile:ro" "$tag:edge-prod" \ + caddy adapt --config /etc/caddy/https/Caddyfile --adapter caddyfile && + adapted_ok edge-https && [ "$(jq -c '.apps.tls.certificates | keys' "$work/edge-https.json")" = '["load_files"]' ] + v "SC edge-https config: as edge-prod, certificates from files only" $? "$(cat "$work/edge-https.violations" "$work/edge-https.adapt.log" 2>/dev/null | head -n 20)" + # Control: an unsafe configuration in an image derived from edge-prod + # (its own command) must be rejected for each of its unsafe settings. + mkdir -p "$work/control-edge" + printf '%s\n' '{' ' admin localhost:2019' ' local_certs' '}' ':8080 {' ' acme_server' '}' >"$work/control-edge/Caddyfile" + printf 'FROM %s\nCOPY Caddyfile /etc/caddy/Caddyfile\n' "$tag:edge-prod" | + docker build --quiet -t "$tag:edge-control" -f - "$work/control-edge" >/dev/null && + adapt edge-control "$tag:edge-control" caddy adapt --config /etc/caddy/Caddyfile --adapter caddyfile && + ! adapted_ok edge-control && grep -q 'admin.disabled is not true' "$work/edge-control.violations" && + grep -q 'admin.config.persist is not false' "$work/edge-control.violations" && + grep -q 'acme_server handler found' "$work/edge-control.violations" && + grep -q 'issuer modules found: internal' "$work/edge-control.violations" && + grep -q 'automatic_https.disable is not true' "$work/edge-control.violations" + v "SC control: the adapted-config check rejects admin, persistence, local_certs, acme_server and automatic HTTPS" $? "$(cat "$work/edge-control.violations" "$work/edge-control.adapt.log" 2>/dev/null | head -n 20)" +fi + tally_finish "(container scan)" From b6e78c9d3aeaabed160190badc13dd35aadebc32 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:52:14 +0100 Subject: [PATCH 08/25] docs(security): document the edge configuration guard Adds an "Edge configuration guard" subsection under CVE-2026-116587 in docs/security/container-vulnerabilities.md, covering what the static guard and the adapted-config check enforce and their known limits. The scan.sh entry in docs/dev-environment.md and the CI rules in the development lifecycle contract now mention it too. --- .../development-lifecycle-contract.md | 5 ++ docs/dev-environment.md | 9 +++- docs/security/container-vulnerabilities.md | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/architecture/development-lifecycle-contract.md b/docs/architecture/development-lifecycle-contract.md index 512599a..7130988 100644 --- a/docs/architecture/development-lifecycle-contract.md +++ b/docs/architecture/development-lifecycle-contract.md @@ -255,6 +255,11 @@ apply unchanged to FND-004. Read-only commands (`status`, `url`, `ports`, reported ([container vulnerabilities](../security/container-vulnerabilities.md)). Every scan must also inventory the expected package sources of the image, so an unrecognised image cannot pass as "no findings". +- The edge configuration must not enable the ACME server, the PKI app, an + issuer or a mutable admin configuration: `scripts/ci/guard-edge-config.sh` + in `Repository guard` checks the sources, and `tests/app/scan.sh` checks + the configuration adapted by the built images + ([container vulnerabilities](../security/container-vulnerabilities.md)). - Image references, including the Dockerfile `# syntax=` frontend, stay pinned by digest (the frontend is enforced by `scripts/ci/guard-dockerfile-frontend.sh` in `Repository guard`); actions diff --git a/docs/dev-environment.md b/docs/dev-environment.md index c44a60a..039abdb 100644 --- a/docs/dev-environment.md +++ b/docs/dev-environment.md @@ -420,8 +420,13 @@ Application suites (`tests/app/`), each in its own Compose projects: development images, which are reported; each scan must inventory the expected package sources (Alpine packages, Composer, the Caddy binary, Node packages), and control scans prove the gate fails on a vulnerable - image and on an image without inventoried packages (see - [security/container-vulnerabilities.md](security/container-vulnerabilities.md)). + image and on an image without inventoried packages; it also adapts the + edge configurations (prod, dev, HTTPS test edge) with the built Caddy + binary and rejects admin, config persistence, PKI, issuers, ACME and the + `acme_server` handler, with a control image that must fail (see + [security/container-vulnerabilities.md](security/container-vulnerabilities.md), + "Edge configuration guard"; the static half is + `scripts/ci/guard-edge-config.sh` in `Repository guard`). - `backend.sh` (BE-01 to BE-18): PHP 8.5 runtime, `composer validate` and `composer audit`, PHP-CS-Fixer, PHPStan (level max), migrations from an empty database, `doctrine:schema:validate`, a full down/up migration round diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index b4c9bac..1476e99 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -226,6 +226,58 @@ changed at run time. The finding still fails the runtime gate for the edge `prod` image until it is fixed upstream or an exception is decided. +### Edge configuration guard + +The finding is acceptable only while no edge configuration runs the code +that parses untrusted JWS/JWK. Two checks enforce this, and both fail +closed: + +- `scripts/ci/guard-edge-config.sh` (job `Repository guard`, regression + suite `scripts/ci/tests/guard-edge-config.test.sh`) reads every tracked + Caddy file (`Caddyfile*`, `*.caddyfile`, `*.caddy`) and every place that + starts Caddy: the edge Dockerfile (its `COPY` into `/etc/caddy`; no `CMD` + or `ENTRYPOINT`, so the pinned base image's + `caddy run --config /etc/caddy/Caddyfile --adapter caddyfile` runs), + Compose files (commands and read-only bind mounts into `/etc/caddy`, such + as `tests/app/https/compose.https.yaml`), scripts, `bin/`, the Makefile + and the workflows. It tokenizes and parses each configuration like Caddy + 2.11 does (comments, quotes, escaped newlines, snippets, imports and import + globs, resolved against the files in the container) and rejects: + - `acme_server`, `pki`, `internal`, `local_certs`, `acme`, `zerossl`, + `issuer`, `cert_issuer`, `acme_ca`, `acme_ca_root`, `acme_dns`, + `acme_eab`, `on_demand`, `on_demand_tls`, `skip_install_trust` and + `get_certificate` anywhere (in any case, quoted or not), and `tls` in + any form other than `tls `; + - a configuration Caddy starts with that does not have exactly one global + options block, first, with exactly `admin off`, `auto_https off` and + `persist_config off`; options other than these and `log`; + - `{$VAR}` (replaced in the raw text before parsing, even in a comment), + `{env.*}`, snippet arguments and blocks, heredocs, named routes, + non-ASCII text, and an import that does not resolve; + - a `caddy` command other than `run`, `adapt`, `validate`, `version`, + `list-modules` and `build-info`; `run`, `adapt` and `validate` without + `--config --adapter caddyfile`, or + with `--resume`, `--watch`, `--envfile` or an unknown flag; `CADDY_ADMIN` + anywhere; + - a Caddy file that no start point uses, and a tracked JSON file that + looks like a Caddy JSON configuration. +- `tests/app/scan.sh` (job `Dev environment`) runs `caddy adapt` with the + built binary in the built edge `prod` and `dev` images and on the HTTPS + test edge, checks the images' command and that they set no `CADDY_*` + variable other than `CADDY_VERSION`, and checks the adapted JSON with + `scripts/ci/edge-adapted-config.jq`: `admin.disabled` is true, + `admin.config.persist` is false, there is no other admin setting, no + `pki` app, no `tls.automation`, no issuer module (`acme`, `internal`, + `zerossl`), no `acme_server` handler at any depth, and every HTTP server + has `automatic_https.disable`. A control image with an unsafe + configuration must be rejected. + +Known limits: the guard reads command lines as written. A command built at +run time (shell variables, `env_file:`) is not seen, and a +`docker compose` bind mount applies to every edge stage in its model. A +Caddy plugin added to `docker/edge/caddy/main.go` would need its own +review. Changing a rule means reviewing the reason above. + ## Deferred hardening items - `framework.trusted_hosts` is not configured: behind the edge, Symfony From 102eda69bc2ab87eb3aa188ab2a12a30d5de7aed Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:24:54 +0100 Subject: [PATCH 09/25] fix(ci): require exact single-package purls and reject expired scan exceptions The exception guard accepted entries that Trivy would apply more broadly than reviewed: an entry with paths only (it also suppresses every other package with the same id in that path), a purl without a version (it matches every version), and purls of several packages in one entry (for example both go-jose major versions). An expired entry also passed, although Trivy no longer applies it, so it could linger in the file. The guard now requires purls on every entry, each with an exact version and without qualifiers, subpath or percent-encoding, all naming the same package, and fails once the check date reaches expired_at (Trivy stops applying an entry at 00:00 UTC of that day). An expired exception thus breaks the Repository guard job until it is renewed after a review or removed. The regression suite gains 23 cases (69 in total). The existing case for an already expired entry now expects a failure, the entry fixtures carry a purl so that each case still fails for its own reason, and failing cases can assert the reported violation. --- docs/security/container-vulnerabilities.md | 29 ++++++-- scripts/ci/guard-trivyignore.sh | 52 +++++++++++--- scripts/ci/tests/guard-trivyignore.test.sh | 83 ++++++++++++++++++---- security/trivyignore.yaml | 11 ++- 4 files changed, 141 insertions(+), 34 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 1476e99..24d40d0 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -24,15 +24,30 @@ downloaded on every run. - An exception for a runtime image is allowed only for one CVE that cannot be fixed yet, in `security/trivyignore.yaml`, with the evidence for the - decision, the affected path and an expiry no more than 90 days ahead, and - it is listed in the table below. There are currently **no exceptions**. + decision, the affected package at its exact version, the affected path and + an expiry no more than 90 days ahead, and it is listed in this document. + There are currently **no exceptions**. An exception does **not** fix the + vulnerable code: it only stops the gate from failing on that one finding. `scripts/ci/guard-trivyignore.sh` (job `Repository guard`) enforces these rules on the file: one CVE or GHSA id per entry, no duplicate id and no - key twice in one entry, `paths` or `purls` with non-blank values and without - wildcards (Trivy matches `paths` as glob patterns, so `*`, `?`, `[`, `]`, - `{` and `}` are all rejected), a non-empty `statement`, a valid `expired_at` - no more than 90 days after the day of the check, the id mentioned in this - document, and only the documented form (any other section or key fails). + key twice in one entry; `purls` with at least one value, each of the form + `pkg://@` with a lowercase type and an + exact version (no purl without a version, which Trivy would apply to every + version; no range, list or blank; no qualifiers, subpath or + percent-encoding), and all purls of an entry naming the same package + (type, namespace and name), so one entry cannot also cover another package + such as a second major version of a Go module; `paths` optional, but if + given with at least one value; non-blank values without wildcards in both + lists (Trivy matches `paths` as glob patterns, so `*`, `?`, `[`, `]`, `{` + and `}` are all rejected); a non-empty `statement`; a valid `expired_at` + after the day of the check and no more than 90 days after it; the id + mentioned in this document; and only the documented form (any other + section or key fails). Trivy stops applying an entry at 00:00 UTC of its + `expired_at` day, and from that day on the guard fails too, so an expired + exception breaks the `Repository guard` job until it is renewed after a + new review or removed, instead of lingering in the file. Trivy applies an + entry only to a finding that matches its id, one of its `paths` and one of + its `purls`. So that the check reads the file exactly as Trivy's YAML parser does, it accepts printable ASCII only (YAML also breaks lines at CR, NEL and U+2028, which could hide an entry inside a comment; a NUL byte, which the diff --git a/scripts/ci/guard-trivyignore.sh b/scripts/ci/guard-trivyignore.sh index 268689e..b7377c8 100755 --- a/scripts/ci/guard-trivyignore.sh +++ b/scripts/ci/guard-trivyignore.sh @@ -5,9 +5,15 @@ # in docs/security/container-vulnerabilities.md: # - one specific vulnerability per entry: id is CVE-- or a # GHSA id, and no id appears twice; -# - the affected package or path is named: "paths" or "purls" with at -# least one entry and no wildcard (Trivy matches paths as doublestar -# patterns, so * ? [ ] { } are all rejected); +# - the affected package is named exactly: "purls" with at least one +# entry, each pkg://@ with an exact +# version and no qualifiers, subpath or percent-encoding (a purl +# without a version matches every version), and all purls of an entry +# name the same package (type, namespace and name), so one entry cannot +# cover a second package such as another major version of a Go module; +# - "paths", if given, has at least one entry; neither list has a +# wildcard (Trivy matches paths as doublestar patterns, so * ? [ ] { } +# are all rejected); # - the evidence for the risk decision is given: a non-empty "statement"; # - no key appears twice in one entry (YAML parsers differ in which value # of a repeated key they keep); @@ -17,8 +23,11 @@ # NUL byte, so it is searched separately), and no backslash or # inner double quote in a quoted value (an escape could add a wildcard # or continue the value over the next lines); -# - the decision is reviewed again: "expired_at" is a valid date no more -# than 90 days after today (Trivy stops applying an expired entry); +# - the decision is reviewed again: "expired_at" is a valid date after +# today and no more than 90 days after it. An entry on or past its +# expired_at fails, so it is renewed after a new review or removed +# instead of lingering (Trivy stops applying it at 00:00 UTC of that +# day, and the gate fails on the finding again); # - the entry is listed in docs/security/container-vulnerabilities.md. # Only the documented form is accepted (see the example in the file): # "vulnerabilities: []", or "vulnerabilities:" followed by entries of @@ -94,29 +103,52 @@ report() { # line message [value] errors=$((errors + 1)) } +# An exact purl: lowercase type, name segments without '@', '%', '?' or +# '#', and one '@' with a version of plain version characters. +purl_re='^pkg:[a-z][a-z0-9.+-]*/([A-Za-z0-9._~-]+/)*[A-Za-z0-9._~-]+@[A-Za-z0-9][A-Za-z0-9.:_+~-]*$' id_re='^(CVE-[0-9]{4}-[0-9]{4,}|GHSA(-[23456789cfghjmpqrvwx]{4}){3})$' # Quoted values with at least one character that is not blank, and without # backslash (YAML escapes) or inner double quote. item='"[^"\\]*[^"\\[:space:]][^"\\]*"' list_re="^\\[[[:space:]]*$item([[:space:]]*,[[:space:]]*$item)*[[:space:]]*\\]$" -state=start entries=0 entry_line=0 id='' targets='' statement='' expiry='' ids=' ' keys=' ' +state=start entries=0 entry_line=0 id='' purls='' statement='' expiry='' ids=' ' keys=' ' # Checks the entry collected so far (called at the next entry and at the end). finish_entry() { [ "$entry_line" -gt 0 ] || return 0 - [ -n "$targets" ] || report "$entry_line" "no affected package or path (paths or purls)" "$id" + [ -n "$purls" ] || report "$entry_line" "no purls (the affected package with its exact version)" "$id" [ -n "$statement" ] || report "$entry_line" "no statement with the evidence for the decision" "$id" if [ -z "$expiry" ]; then report "$entry_line" "no expired_at date (the exception would be permanent)" "$id" elif ! e=$(days_of "$expiry"); then report "$entry_line" "expired_at is not a valid YYYY-MM-DD date" "$expiry" + elif [ "$e" -le "$today_days" ]; then + report "$entry_line" "expired_at is not after $today (expired: renew it after a new review, or remove it)" "$expiry" elif [ $((e - today_days)) -gt "$max_days" ]; then report "$entry_line" "expired_at is more than $max_days days after $today" "$expiry" fi if [ -n "$id" ] && ! grep -qF -- "$id" "$doc"; then report "$entry_line" "not listed in docs/security/container-vulnerabilities.md" "$id" fi - entry_line=0 id='' targets='' statement='' expiry='' keys=' ' + entry_line=0 id='' purls='' statement='' expiry='' keys=' ' +} + +# check_purls : every purl is exact and all name one package. +check_purls() { + local rest=$2 purl pkg='' ok=yes + while [[ $rest =~ ^[^\"]*\"([^\"]*)\"(.*)$ ]]; do + purl=${BASH_REMATCH[1]} rest=${BASH_REMATCH[2]} + if [[ ! $purl =~ $purl_re ]]; then + report "$1" "purl is not pkg:/@ without qualifiers, subpath or encoding" "$purl" + ok='' + elif [ -z "$pkg" ]; then + pkg=${purl%@*} + elif [ "${purl%@*}" != "$pkg" ]; then + report "$1" "purls name more than one package (one package per entry)" "$2" + ok='' + fi + done + [ -n "$ok" ] } # bash's read drops a NUL byte (or ends the line at it), so the loop below @@ -169,8 +201,8 @@ while IFS= read -r line || [ -n "$line" ]; do report "$line_no" "$key must be a list of quoted, non-blank values without backslash" "$value" elif inner=${value#\[} inner=${inner%\]} && [[ $inner == *[][*?{}]* ]]; then report "$line_no" "$key must not contain wildcards" "$value" - else - targets=yes + elif [ "$key" = purls ] && check_purls "$line_no" "$value"; then + purls=yes fi ;; statement) diff --git a/scripts/ci/tests/guard-trivyignore.test.sh b/scripts/ci/tests/guard-trivyignore.test.sh index 4a577a8..7d2673b 100755 --- a/scripts/ci/tests/guard-trivyignore.test.sh +++ b/scripts/ci/tests/guard-trivyignore.test.sh @@ -41,6 +41,13 @@ expect() { run "$1" "$2" "$repo" } +# expect_error : exit 1, and the +# report names the expected violation (not only some other one). +expect_error() { + fixture "$3" + run "$1" 1 "$repo" "$2" +} + # expect_bytes : for bytes a shell string cannot hold (NUL). expect_bytes() { @@ -50,41 +57,51 @@ expect_bytes() { run "$1" "$2" "$repo" } -run() { # name expected-exit repo +run() { # name expected-exit repo [expected message] local name=$1 want=$2 got "$guard" "$3" >"$work/out" 2>&1 got=$? - if [ "$got" -eq "$want" ]; then + if [ "$got" -eq "$want" ] && { [ -z "${4-}" ] || grep -qF -- "$4" "$work/out"; }; then pass=$((pass + 1)) printf 'PASS %-66s exit=%s\n' "$name" "$got" else fail=$((fail + 1)) - printf 'FAIL %-66s exit=%s want=%s\n' "$name" "$got" "$want" + printf 'FAIL %-66s exit=%s want=%s%s\n' "$name" "$got" "$want" "${4+ message=$4}" sed 's/^/ | /' "$work/out" fi } -# entry [paths-line] [statement-line]: one documented entry. +STATEMENT=' statement: "Not reachable: the binary is never executed (evidence in the docs)."' +PURLS=' purls: ["pkg:golang/example.com/example@v1.2.3"]' +# entry [paths-line] [statement-line] [purls-line]: one +# documented entry; an empty argument leaves the line out (a blank line). entry() { - printf ' - id: %s\n%s\n%s\n expired_at: %s\n' "$1" \ - "${3- paths: [\"usr/local/bin/example\"]}" \ - "${4- statement: \"Not reachable: the binary is never executed (evidence in the docs).\"}" "$2" + printf ' - id: %s\n%s\n%s\n%s\n expired_at: %s\n' "$1" \ + "${3- paths: [\"usr/local/bin/example\"]}" "${5-$PURLS}" "${4-$STATEMENT}" "$2" +} +# purls ...: a purls line with the given values. +purls() { + local out='' p + for p in "$@"; do out="$out${out:+, }\"$p\""; done + printf ' purls: [%s]' "$out" } run "the repository's own exception file" 0 "$real" expect "no exceptions" 0 "${HEADER}vulnerabilities: []${NL}" expect "no exceptions, CRLF line endings" 0 "# c"$'\r'"${NL}vulnerabilities: []"$'\r'"${NL}" expect "one documented entry" 0 "${HEADER}vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL}" -expect "a GHSA entry with purls" 0 "vulnerabilities:${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" +expect "a GHSA entry with purls and no paths" 0 "vulnerabilities:${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 '' "$STATEMENT" ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" expect "expiry exactly 90 days ahead" 0 "vulnerabilities:${NL}$(entry CVE-2026-0001 2027-01-22)${NL}" -expect "an already expired entry (Trivy no longer applies it)" 0 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-10-01)${NL}" +# An expired entry used to pass because Trivy no longer applies it; it now +# fails, so an exception is renewed after a review or removed, not kept. +expect_error "an already expired entry (renew or remove it)" "expired_at is not after 2026-10-24" "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-10-01)${NL}" expect "expiry 91 days ahead" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2027-01-23)${NL}" -expect "no expiry (permanent)" 1 "vulnerabilities:${NL} - id: CVE-2026-0001${NL} paths: [\"a\"]${NL} statement: \"why\"${NL}" +expect_error "no expiry (permanent)" "no expired_at date" "vulnerabilities:${NL} - id: CVE-2026-0001${NL} paths: [\"a\"]${NL}${PURLS}${NL} statement: \"why\"${NL}" expect "invalid date (30 February)" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-02-30)${NL}" expect "date in another format" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 01.12.2026)${NL}" -expect "no statement" 1 "vulnerabilities:${NL} - id: CVE-2026-0001${NL} paths: [\"a\"]${NL} expired_at: 2026-12-01${NL}" +expect_error "no statement" "no statement" "vulnerabilities:${NL} - id: CVE-2026-0001${NL} paths: [\"a\"]${NL}${PURLS}${NL} expired_at: 2026-12-01${NL}" expect "empty statement" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' ' statement: ""')${NL}" -expect "no paths or purls" 1 "vulnerabilities:${NL} - id: CVE-2026-0001${NL} statement: \"why\"${NL} expired_at: 2026-12-01${NL}" +expect_error "no paths or purls" "no purls" "vulnerabilities:${NL} - id: CVE-2026-0001${NL} statement: \"why\"${NL} expired_at: 2026-12-01${NL}" expect "wildcard path" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["usr/**"]')${NL}" expect "empty paths list" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: []')${NL}" expect "wildcard id" 1 "vulnerabilities:${NL}$(entry 'CVE-2026-*' 2026-12-01)${NL}" @@ -107,7 +124,7 @@ expect "no section at all" 1 "# only comments${NL}" expect "an escaped quote continues the statement over expired_at (YAML: no expiry)" 1 \ "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' ' statement: "why\"')${NL} # \"${NL}" expect "an escaped wildcard in paths (YAML: \\x2a is *)" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["\x2a"]')${NL}" -expect "an escape in purls" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' purls: ["pkg:npm/a@1\u002a"]')${NL}" +expect "an escape in purls" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' "$STATEMENT" ' purls: ["pkg:npm/a@1\u002a"]')${NL}" expect "an entry hidden behind a CR in a comment" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL}# note"$'\r'" - id: CVE-2026-0002${NL}" expect "an entry hidden behind a CR in a statement" 1 \ "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' ' statement: "why" #'$'\r'' - id: CVE-2026-0002 #"')${NL}" @@ -124,11 +141,47 @@ expect "a negated class in paths" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 # (in a variable: bash 3.2 brace-expands a quoted word inside "$(...)") braces=' paths: ["{package-lock.json,other}"]' expect "braces in paths (doublestar alternatives)" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 "$braces")${NL}" -expect "a closing bracket alone in purls" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' purls: ["pkg:npm/a@1]"]')${NL}" +expect "a closing bracket alone in purls" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' "$STATEMENT" ' purls: ["pkg:npm/a@1]"]')${NL}" # bash drops a NUL byte when it reads a line; Trivy refuses the file. expect_bytes "a NUL byte in a comment" 1 'vulnerabilities: []\n# a\000b\n' -expect_bytes "a NUL byte in a statement" 1 'vulnerabilities:\n - id: CVE-2026-0001\n paths: ["a"]\n statement: "wh\000y"\n expired_at: 2026-12-01\n' +expect_bytes "a NUL byte in a statement" 1 'vulnerabilities:\n - id: CVE-2026-0001\n paths: ["a"]\n purls: ["pkg:npm/a@1.0.0"]\n statement: "wh\000y"\n expired_at: 2026-12-01\n' expect "a different YAML style" 1 "vulnerabilities:${NL}- id: CVE-2026-0001${NL} paths: [\"a\"]${NL} statement: \"why\"${NL} expired_at: 2026-12-01${NL}" +# Package scope (FCRM-SEC-042): Trivy applies an entry only if id, path and +# purl all match, and a purl without a version matches every version. An +# entry must name exactly one package at exact versions, so a finding of +# another package or version (the go-jose v4 module next to v3 in the same +# binary, or another v3 release) still fails the gate. +J3=pkg:golang/github.com/go-jose/go-jose/v3 +J4=pkg:golang/github.com/go-jose/go-jose/v4 +go_jose() { # [purls-line] [expired_at] + entry CVE-2026-0001 "${2-2026-11-30}" ' paths: ["usr/bin/caddy"]' "$STATEMENT" "${1-$(purls "$J3@v3.0.5")}" +} +expect "one package at an exact version, with paths" 0 "vulnerabilities:${NL}$(go_jose)${NL}" +expect "one package at two exact versions" 0 "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.4" "$J3@v3.0.5")")${NL}" +expect "a Go pseudo-version and an Alpine package version" 0 \ + "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v0.0.0-20260101000000-0123456789ab")")${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 '' "$STATEMENT" "$(purls 'pkg:apk/alpine/curl@8.22.0-r0')")${NL}" +expect_error "paths only, no purls (would also hide the v4 finding)" "no purls" "vulnerabilities:${NL}$(go_jose '')${NL}" +expect_error "an empty purls list" "purls must be a list" "vulnerabilities:${NL}$(go_jose ' purls: []')${NL}" +expect_error "a purl without a version (matches every version)" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3")")${NL}" +expect_error "a purl with an empty version" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@")")${NL}" +expect_error "a version range" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@>=v3.0.0")")${NL}" +expect_error "a version list in one purl" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.4,v3.0.5")")${NL}" +expect_error "a version with a blank" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5 || v3.0.6")")${NL}" +expect_error "a wildcard version" "must not contain wildcards" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.*")")${NL}" +expect_error "qualifiers" "must not contain wildcards" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5?goos=linux")")${NL}" +expect_error "a subpath" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5#cipher")")${NL}" +expect_error "percent-encoding" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "pkg:golang/github.com/go-jose/go-jose/v3@v3.0%2E5")")${NL}" +expect_error "a second @ in the name" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "pkg:golang/github.com/go-jose@x/v3@v3.0.5")")${NL}" +expect_error "not a purl" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "github.com/go-jose/go-jose/v3@v3.0.5")")${NL}" +expect_error "an upper-case purl type" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "pkg:Golang/github.com/go-jose/go-jose/v3@v3.0.5")")${NL}" +expect_error "go-jose v3 and v4 in one entry" "more than one package" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5" "$J4@v4.1.5")")${NL}" +expect_error "the same name in two ecosystems" "more than one package" "vulnerabilities:${NL}$(go_jose "$(purls 'pkg:npm/example@1.0.0' 'pkg:golang/example@v1.0.0')")${NL}" +expect_error "a valid purl next to one without a version" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5" "$J4")")${NL}" +# Expiry is fail-closed: Trivy stops applying an entry at 00:00 UTC of its +# expired_at day, so the guard fails from that day on. +expect "expiry the day after the check" 0 "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5")" 2026-10-25)${NL}" +expect_error "expiry on the day of the check" "expired_at is not after 2026-10-24" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5")" 2026-10-24)${NL}" +expect_error "an expired entry next to a valid one" "expired_at is not after" "vulnerabilities:${NL}$(go_jose)${NL}$(entry GHSA-xjj6-f33q-929c 2026-10-23)${NL}" mkdir -p "$work/nofile" && git init -q "$work/nofile" || exit 2 run "missing exception file" 2 "$work/nofile" mkdir -p "$work/nogit" diff --git a/security/trivyignore.yaml b/security/trivyignore.yaml index a9d14c1..1c2515b 100644 --- a/security/trivyignore.yaml +++ b/security/trivyignore.yaml @@ -3,14 +3,21 @@ # The gate fails on every HIGH or CRITICAL finding in a runtime image. An # entry here is allowed only for one specific CVE that cannot be fixed yet, # with the evidence for the risk decision in `statement`, the affected -# package or path, and an `expired_at` date no more than 90 days ahead, so -# the decision is reviewed again. Each entry is also listed in +# package as `purls` with exact versions (one package per entry; Trivy then +# applies the entry only to those versions), the affected path, and an +# `expired_at` date after today and no more than 90 days ahead, so the +# decision is reviewed again. From its expired_at day on, Trivy no longer +# applies the entry and scripts/ci/guard-trivyignore.sh fails until it is +# renewed after a new review or removed. Each entry is also listed in # docs/security/container-vulnerabilities.md. No broad or permanent rules. +# An exception does not fix the vulnerable code; it only stops the gate +# from failing on that finding. # # Example: # vulnerabilities: # - id: CVE-0000-00000 # paths: ["usr/bin/example"] +# purls: ["pkg:golang/example.com/module@v1.2.3"] # statement: "Why it is not exploitable here, with evidence." # expired_at: 2026-12-31 From c0a1d89de5ab85bdb3bbb3cf33c746c82d457b97 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:25:01 +0100 Subject: [PATCH 10/25] test(app): list suppressed findings in the container scan log The gate scans printed only the findings that fail them, so a finding suppressed by an exception in security/trivyignore.yaml left no trace in the CI log of the runtime image. The gate and control scans now run with Trivy's --show-suppressed, and the template prints each suppressed finding as a SUPPRESSED line (status, severity, id, package, version, target, source file), counted next to the HIGH/CRITICAL findings. --show-suppressed changes the output only. With Trivy 0.67.2 the exit codes of the edge, api, db and control gate scans were the same with and without it, with and without an exception. --- docs/security/container-vulnerabilities.md | 6 ++++++ tests/app/scan.sh | 16 ++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 24d40d0..80a9cbd 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -55,6 +55,12 @@ downloaded on every run. inner double quote in a quoted value (an escape could add a wildcard or continue the statement over `expired_at`). Its regression suite is `scripts/ci/tests/guard-trivyignore.test.sh`. +- The gate scans (runtime images and the control scan) run with + `--show-suppressed`, and the scan log lists every finding an exception + suppressed as a `SUPPRESSED ignored ` + line, so an excepted finding stays visible in CI. The option changes the + output only, not the exit code. The development-image scans do not read + the exception file and report the finding as usual. - A control scan of the unmodified PostgreSQL base image (with its `gosu`) must fail; it proves the gate is not passing vacuously. - Every scan must report the package sources expected for the image (Alpine diff --git a/tests/app/scan.sh b/tests/app/scan.sh index 188af6a..076ff5d 100755 --- a/tests/app/scan.sh +++ b/tests/app/scan.sh @@ -26,11 +26,15 @@ TRIVY_IMAGE=aquasec/trivy:0.67.2@sha256:e2b22eac59c02003d8749f5b8d9bd073b62e30fe # The PostgreSQL base image with its gosu binary (see docker/db/Dockerfile). CONTROL_IMAGE=postgres:18.6-alpine3.24@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873 IGNORE_FILE=$APP_REPO/security/trivyignore.yaml -# One line per scanned target (class, type, path, number of packages) and -# one per finding: severity, CVE, package, installed and fixed versions, target. +# One line per scanned target (class, type, path, number of packages), one +# per finding: severity, CVE, package, installed and fixed versions, target, +# and one per finding suppressed by an exception of the ignore file (gate +# scans run with --show-suppressed, which changes the output only, not the +# exit code), so an excepted finding stays visible in the log. # shellcheck disable=SC2016 # a Go template, not shell expansion TEMPLATE='{{- range . }}{{- $t := .Target }}TARGET {{ .Class }} {{ .Type }} {{ $t }} pkgs={{ len .Packages }} {{ range .Vulnerabilities }}{{ .Severity }} {{ .VulnerabilityID }} {{ .PkgName }} {{ .InstalledVersion }} fixed={{ if .FixedVersion }}{{ .FixedVersion }}{{ else }}none{{ end }} in={{ $t }} +{{ end }}{{ range .ModifiedFindings }}SUPPRESSED {{ .Status }} {{ .Finding.Severity }} {{ .Finding.VulnerabilityID }} {{ .Finding.PkgName }} {{ .Finding.InstalledVersion }} in={{ $t }} by={{ .Source }} {{ end }}{{- end }}' work=$(mktemp -d "${TMPDIR:-/tmp}/flowcrm-scan.XXXXXX") || exit 2 @@ -74,7 +78,7 @@ scan() { shift 2 docker save -o "$work/images/$archive" "$tag:$name" || { bad "SC $name: could not export the image"; return; } if [ "$mode" = gate ]; then - trivy "$archive" --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --format template --template "$TEMPLATE" >"$log" 2>&1 + trivy "$archive" --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --show-suppressed --format template --template "$TEMPLATE" >"$log" 2>&1 rc=$? if [ "$rc" = 0 ]; then ok "SC $name (runtime): no HIGH or CRITICAL vulnerabilities"; else bad "SC $name (runtime): HIGH or CRITICAL vulnerabilities (exit $rc)"; fi else @@ -84,8 +88,8 @@ scan() { fi inventoried "$log" "$@" v "SC $name: Trivy inventoried the expected package sources ($*)" $? - say " $(grep -cE '^(HIGH|CRITICAL) ' "$log") HIGH/CRITICAL findings in $name" - grep -E '^(HIGH|CRITICAL) |FATAL|ERROR' "$log" | sed 's/^/ /' | head -n 40 + say " $(grep -cE '^(HIGH|CRITICAL) ' "$log") HIGH/CRITICAL findings in $name, $(grep -c '^SUPPRESSED ' "$log") suppressed by an exception" + grep -E '^(HIGH|CRITICAL|SUPPRESSED) |FATAL|ERROR' "$log" | sed 's/^/ /' | head -n 40 rm -f "$work/images/$archive" } @@ -118,7 +122,7 @@ v "SC control: an image without inventoried packages is rejected" $? if ! docker image inspect "$CONTROL_IMAGE" >/dev/null 2>&1; then docker pull -q "$CONTROL_IMAGE" >/dev/null; fi need "prepare the control image" docker tag "$CONTROL_IMAGE" "$tag:control" || { tally_finish; exit; } docker save -o "$work/images/control.tar" "$tag:control" -trivy control.tar --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --format template --template "$TEMPLATE" >"$work/control.log" 2>&1 +trivy control.tar --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --show-suppressed --format template --template "$TEMPLATE" >"$work/control.log" 2>&1 rc=$? [ "$rc" = 1 ] && grep -q 'in=usr/local/bin/gosu' "$work/control.log" v "SC control: the gate rejects the known-vulnerable PostgreSQL base image" $? "(exit $rc)" From c4e5bca5d50f999e48b7965247d14289651585d4 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 13:25:10 +0100 Subject: [PATCH 11/25] fix(security): except go-jose v3 in the edge binary until 2026-11-30 (ADR-021) CVE-2026-116587 (GHSA-mgcc-cww5-fp2q, HIGH) has no fixed go-jose v3 release, and every Caddy build with the HTTP app links go-jose/v3 v3.0.5 through caddypki and smallstep, so the edge prod gate cannot pass. The go-jose/v4 finding is fixed by the upgrade to 4.1.6 and is not excepted. The entry is limited to the id as Trivy 0.67.2 reports it, the path usr/bin/caddy and the purl of go-jose/v3 at v3.0.5, and expires on 2026-11-30. It does not fix the vulnerable code. The decision rests on the configuration not reaching it (no acme_server, pki app or tls internal, admin API off), which the edge configuration guard is to enforce. It is proposed in ADR-021 and not accepted yet. Verified with Trivy 0.67.2 and the flags of tests/app/scan.sh: the edge prod gate passes and lists the finding as suppressed; it still fails for go-jose v4 4.1.5 (main), for a build with v3 3.0.4 and with an expired entry; api, db and the control scan are unaffected; the edge dev report still lists the finding. --- docs/security/container-vulnerabilities.md | 46 +++++++++++++++++++--- security/trivyignore.yaml | 7 +++- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 80a9cbd..b1a4ba0 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -25,9 +25,9 @@ downloaded on every run. - An exception for a runtime image is allowed only for one CVE that cannot be fixed yet, in `security/trivyignore.yaml`, with the evidence for the decision, the affected package at its exact version, the affected path and - an expiry no more than 90 days ahead, and it is listed in this document. - There are currently **no exceptions**. An exception does **not** fix the - vulnerable code: it only stops the gate from failing on that one finding. + an expiry no more than 90 days ahead, and it is listed in + [Exceptions](#exceptions). An exception does **not** fix the vulnerable + code: it only stops the gate from failing on that one finding. `scripts/ci/guard-trivyignore.sh` (job `Repository guard`) enforces these rules on the file: one CVE or GHSA id per entry, no duplicate id and no key twice in one entry; `purls` with at least one value, each of the form @@ -71,6 +71,15 @@ downloaded on every run. - Development-image findings are reviewed with every dependency update; they never ship in a runtime image. +## Exceptions + +Each entry of `security/trivyignore.yaml`. The finding is not fixed by the +exception; the vulnerable code is still in the image. + +| Id | Package and path | Image | Fixed in | Reachable here | Expires | Decision | +|---|---|---|---|---|---|---| +| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | `github.com/go-jose/go-jose/v3` v3.0.5 (`pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`) in `usr/bin/caddy` | edge (`prod`; `dev` is report-only) | none for v3 (v4 fixed in 4.1.6 and upgraded, not excepted) | no: no `acme_server`, `pki` app or `tls internal`, admin API off | 2026-11-30 (gate fails again from 00:00 UTC that day) | ADR-021, **proposed, not accepted** ([details](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge)) | + ## Triage of the FCRM-FND-004V findings Findings of Trivy 0.67.2 (HIGH and CRITICAL), before and after this @@ -243,9 +252,34 @@ edge (`tests/app/https`) loads its certificate from files untrusted input (the JWS of an ACME request) to the vulnerable parser; `tls internal` and the `pki` app parse only keys Caddy creates or loads from its own configuration. With `admin off` the configuration cannot be -changed at run time. The finding still fails the runtime gate -for the edge `prod` image until it is fixed upstream or an exception is -decided. +changed at run time. + +**Exception (proposed, not accepted):** `security/trivyignore.yaml` excepts +this id for `go-jose/v3` v3.0.5 in `usr/bin/caddy` only, until 2026-11-30 +(see [Exceptions](#exceptions); decision proposed in ADR-021). It does not +fix the vulnerable code: go-jose v3 3.0.5 stays in the edge binary, and the +exception only rests on the configuration above, which the edge +configuration guard is to enforce. Scope, verified with Trivy 0.67.2 and the +flags of `tests/app/scan.sh` (FCRM-SEC-042): + +- The edge `prod` gate passes with the exception; the finding is listed as + `SUPPRESSED ignored HIGH CVE-2026-116587 github.com/go-jose/go-jose/v3 + v3.0.5`, and the edge `dev` report scan still lists it as a HIGH finding. +- The same file does not cover go-jose v4: the edge `prod` image of `main` + (v4 4.1.5) still fails the gate on the v4 finding. +- It does not cover another v3 version: a build with v3 3.0.4 fails the gate + (CVE-2026-116587 and CVE-2026-34986 for 3.0.4). +- It suppresses nothing in the api, db and control scans; the control scan + still fails. +- With an `expired_at` on or before the day of the scan, Trivy no longer + applies the entry and the gate fails; the guard fails as well. + +Review it again, at the latest at the expiry, on any of: a go-jose v3 +release that fixes the CVE; `go.step.sm/crypto` or `smallstep/certificates` +moving to go-jose v4, or a Caddy release without v3; any change to +`docker/edge` or `tests/app/https`; a change of the advisory's severity or +a new advisory for go-jose v3; a deployment beyond the loopback binding; a +Trivy upgrade (the exception file format is experimental in Trivy). ### Edge configuration guard diff --git a/security/trivyignore.yaml b/security/trivyignore.yaml index 1c2515b..f8fa1dc 100644 --- a/security/trivyignore.yaml +++ b/security/trivyignore.yaml @@ -21,4 +21,9 @@ # statement: "Why it is not exploitable here, with evidence." # expired_at: 2026-12-31 -vulnerabilities: [] +vulnerabilities: + - id: CVE-2026-116587 + paths: ["usr/bin/caddy"] + purls: ["pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5"] + statement: "GHSA-mgcc-cww5-fp2q, go-jose/v3 v3.0.5 in the edge Caddy binary: no fixed v3 release exists (go-jose/v4 is upgraded to 4.1.6 and not covered). Not reachable: no acme_server, pki app or tls internal is configured and the admin API is off, enforced by the edge configuration guard. Decision proposed in ADR-021 (not accepted yet). Review on a fixed v3 release, smallstep or Caddy dropping v3, any change to docker/edge or tests/app/https, a severity change, a Trivy upgrade, or the expiry." + expired_at: 2026-11-30 From 9ab7bfb4088a3cb3447e92de3c48c9d88573888d Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 14:08:52 +0100 Subject: [PATCH 12/25] docs(adr): propose ADR-021 for the temporary go-jose v3 exception Records the evidence and the proposed decision for CVE-2026-116587 in the edge Caddy binary: go-jose v4 upgraded, a v3-only exception until 2026-11-30, the edge configuration guard and the stricter exception guard as compensating controls, review triggers and exit criteria. Status: proposed, not accepted. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 159 ++++++++++++++++++ docs/security/container-vulnerabilities.md | 2 +- 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md new file mode 100644 index 0000000..117a8cc --- /dev/null +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -0,0 +1,159 @@ +# ADR-021: Temporary exception for go-jose v3 in the edge Caddy binary + +- **Status:** proposed (FCRM-SEC-042), **not accepted**. The owner decides. + Until it is accepted, the exception in `security/trivyignore.yaml` must not + reach `main`. +- **Related:** [container vulnerabilities](../../security/container-vulnerabilities.md) + (exception policy, CVE-2026-116587 triage, edge configuration guard), + ADR-017 (edge routing) + +## Context + +Trivy reports CVE-2026-116587 / GHSA-mgcc-cww5-fp2q (HIGH, CVSS 7.5, +CWE-407/770) in `usr/bin/caddy` of the edge images. go-jose parses the RSA +modulus and primes of a JWK without bounds before `crypto/rsa` validation, +so a crafted key costs cubic CPU time: a denial of service, with no impact +on confidentiality or integrity. + +| Module | Version | Fixed in | Handling | +|---|---|---|---| +| `github.com/go-jose/go-jose/v4` | 4.1.6 | 4.1.6 | upgraded (was 4.1.5); not excepted | +| `github.com/go-jose/go-jose/v3` | 3.0.5 | no fixed release (3.0.5 is the newest v3) | this decision | + +go-jose v3 cannot be removed today: + +- `modules/caddytls` (internal issuer, CA pools) and + `caddyconfig/httpcaddyfile` import `modules/caddypki`, and + `modules/caddyhttp` imports `caddytls`. Every Caddy build with the HTTP + app therefore links `caddypki` → `smallstep/certificates/authority` → + `go.step.sm/crypto/jose` → go-jose v3. A build from the + `modules/standard` list without `caddypki` still contains v3 3.0.5. +- The newest releases still require v3: Caddy 2.11.7, + `smallstep/certificates` 0.30.2 and `go.step.sm/crypto` 0.91.1. The move + of `smallstep/crypto` to go-jose v4 is open as smallstep/crypto#1157 and + needs a `smallstep/certificates` release on top. +- go-jose v3 receives only critical security updates, and no backport of + the fix exists. + +Reachability in FlowCRM: + +- **Caddy code:** only Caddy's `acme_server` handler passes untrusted input + to the vulnerable parser. It parses the JWS of an unauthenticated ACME + request before any check. `tls internal` and the `pki` app parse only + keys that Caddy creates or loads from its own configuration, and Caddy's + ACME client does not link go-jose. +- **FlowCRM's configuration:** no edge configuration uses `acme_server`, a + `pki` app, `tls internal`, `local_certs` or an issuer. Both Caddyfiles set + `admin off`, `auto_https off` and `persist_config off`, and the HTTPS test + edge loads a static certificate. +- **Runtime check:** the advisory's proof of concept (a JWK with a + 16384-bit RSA prime) got a 404 from every HTTPS test edge port, using + about 0 s of CPU. With `acme_server` enabled, one unauthenticated request + cost about 2.2 s of CPU. + +The runtime gate (`tests/app/scan.sh`) fails on every HIGH or CRITICAL +finding, so this finding blocks every pull request and `main`. + +## Decision (proposed) + +1. Upgrade go-jose v4 to 4.1.6 (PR #41). v4 gets no exception. +2. Add one temporary exception to `security/trivyignore.yaml` that matches + only this finding: + + ```yaml + - id: CVE-2026-116587 + paths: ["usr/bin/caddy"] + purls: ["pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5"] + expired_at: 2026-11-30 + ``` + + Trivy applies an entry only when the id, path and purl all match. The + pinned Trivy 0.67.2 was run on real images to check this: + - the v4 4.1.5 finding still fails the gate; + - a v3 3.0.4 build still fails it; + - the control scan keeps its 28 HIGH and CRITICAL findings; + - api-prod and db suppress nothing; + - with an expiry in the past, the finding blocks again. +3. Compensating controls, which land in the same change as the exception: + - **Edge configuration guard**, `scripts/ci/guard-edge-config.sh` in the + `Repository guard` job. It parses every tracked Caddy file like Caddy + does, including imports, import globs and snippets, and every place + that starts Caddy. It rejects: + - `acme_server`, `pki`, issuers, `tls internal`, `local_certs` and + other ACME options; + - a global block other than exactly `admin off`, `auto_https off` and + `persist_config off`; + - environment placeholders; + - `--resume` and `--watch`; + - other configuration files, and `CADDY_ADMIN`. + + Unknown constructs fail. + - **Adapted-configuration check**, in `tests/app/scan.sh`. It runs + `caddy adapt` with the built binary in the built edge images and on + the HTTPS test edge, and checks the JSON: the admin API is disabled, + there is no `pki` app, no TLS automation or issuer and no + `acme_server` handler, automatic HTTPS is off and persistence is off. + An unsafe control configuration must be rejected. + - **Stricter exception guard**, `scripts/ci/guard-trivyignore.sh`. Every + entry must name exactly one package with an exactly pinned purl, and an + entry that expires on or before the check date fails the guard. + - **Visibility:** the suppressed finding is printed as `SUPPRESSED` in + the edge-prod scan log. The edge `dev` report scan does not use the + ignore file and keeps listing it. +4. The exception is listed in the exceptions table of the container + vulnerability document, which states that it does not fix the vulnerable + code. + +## Consequences + +- **The vulnerable code stays in the binary.** The exception only stops the + gate from blocking on it. +- **Residual risk:** availability only. If a configuration enabled an ACME + server, or a mutable admin API, a remote client could exhaust the edge's + CPU. The edge has no CPU or memory limits, so the API and database on the + same host could suffer too. Today such a change fails CI through both + checks. The checks do not see commands built at run time (shell + variables, an `env_file`) or Caddy plugins added to + `docker/edge/caddy/main.go`. +- **Expiry:** on 2026-11-30 Trivy stops applying the entry, and the + exception guard fails the `Repository guard` job, so every pull request + is blocked until the exception is renewed after a new review or removed. + The maximum renewal under the policy is 90 days. +- **Review triggers.** The exception is reviewed early, and removed as soon + as it is no longer needed, on any of: + - a go-jose v3 release with the fix; + - a `go.step.sm/crypto` and `smallstep/certificates` release on go-jose + v4, or a Caddy release without v3; + - the further go-jose v3 advisories GHSA-4w3g-jfj2-cw2q and + GHSA-vvm8-gf78-28g4 reaching the advisory database; + - a Go vulnerability database entry with affected symbols, for a + govulncheck check; + - a severity change; + - any change under `docker/edge` or `tests/app/https`, or a new Caddy + plugin; + - a Trivy upgrade; + - a deployment beyond the loopback-only development and test stacks. +- **Exit criteria:** go-jose v3 is gone from `go version -m usr/bin/caddy`, + or replaced by a fixed version. The exception entry and its table row are + then removed, and this ADR is superseded. +- **Operations:** + - the edge configuration guard takes about 1 s, and its regression + suite about 90 s, in the `Repository guard` job; + - the adapted check adds seconds to the scan step; + - a renewal is a reviewed change to `security/trivyignore.yaml` and the + exceptions table. +- **Rollback:** revert the exception commit. The gate then fails on the + finding again, and the guards stay in place. + +## Alternatives considered + +- **Wait for an upstream fix without an exception:** keeps the gate strict, + but blocks every merge, including unrelated security fixes, for an + unknown time. +- **A Caddy build without `caddypki`:** does not remove v3 (see Context). +- **A `replace` with a patched go-jose v3, or a Caddy fork:** a + self-maintained fork of a cryptographic library or of Caddy's TLS stack, + in the TLS edge. This is an architecture change, and it is not proposed. +- **An exception by id or by path only, a 90-day expiry, a report-only + edge-prod scan, or a lower threshold:** rejected. They would also hide + the fixable v4 finding, or weaken the runtime gate. diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index b1a4ba0..ff3a502 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -78,7 +78,7 @@ exception; the vulnerable code is still in the image. | Id | Package and path | Image | Fixed in | Reachable here | Expires | Decision | |---|---|---|---|---|---|---| -| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | `github.com/go-jose/go-jose/v3` v3.0.5 (`pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`) in `usr/bin/caddy` | edge (`prod`; `dev` is report-only) | none for v3 (v4 fixed in 4.1.6 and upgraded, not excepted) | no: no `acme_server`, `pki` app or `tls internal`, admin API off | 2026-11-30 (gate fails again from 00:00 UTC that day) | ADR-021, **proposed, not accepted** ([details](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge)) | +| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | `github.com/go-jose/go-jose/v3` v3.0.5 (`pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`) in `usr/bin/caddy` | edge (`prod`; `dev` is report-only) | none for v3 (v4 fixed in 4.1.6 and upgraded, not excepted) | no: no `acme_server`, `pki` app or `tls internal`, admin API off | 2026-11-30 (gate fails again from 00:00 UTC that day) | [ADR-021](../architecture/adr/ADR-021-edge-go-jose-v3-exception.md), **proposed, not accepted** ([details](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge)) | ## Triage of the FCRM-FND-004V findings From 820b100e21cb86b9b6bc608ae777c32b8d5a22b4 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:04:47 +0100 Subject: [PATCH 13/25] fix(ci): check every file the edge can import and its edge service commands The forbidden-construct and raw checks (acme_server, pki, issuers, {$VAR}, {env.*}, non-ASCII) ran only on files named like Caddy files. A file copied or mounted into /etc/caddy under another name (acme.conf reached by "import /etc/caddy/*.conf") was imported but never checked. They now run on every tracked Caddy file and every file copied or mounted into /etc/caddy, whatever its name. Obfuscated start commands are closed too: - Compose files may not contain a backslash, because a YAML escape such as caddy hides a command or path that Compose decodes; - edge services (they build docker/edge/Dockerfile, run a caddy image, directly or through a <<: merge, or override such a service) have no entrypoint or extends, and a command only in the exact caddy run form; - mounts over /, /etc, /usr or a bin directory are rejected; - edge runtime Dockerfile stages may only RUN a plain apk add, copy the binary only to /usr/bin/caddy, and not put other files in bin directories or change PATH or SHELL; - command lines may not contain a glob that matches the binary's name. The header now states what the guard cannot see (commands assembled at run time, plugins). Regression fixtures cover every reproducer of the FCRM-SEC-042 audit and each new rule. The fixture substitution now passes text literally, so escape fixtures keep their backslash. --- scripts/ci/guard-edge-config.sh | 217 ++++++++++++++++++++- scripts/ci/tests/guard-edge-config.test.sh | 114 ++++++++++- 2 files changed, 320 insertions(+), 11 deletions(-) diff --git a/scripts/ci/guard-edge-config.sh b/scripts/ci/guard-edge-config.sh index 04b46df..ab56efb 100755 --- a/scripts/ci/guard-edge-config.sh +++ b/scripts/ci/guard-edge-config.sh @@ -8,12 +8,13 @@ # (tls internal, issuer internal, local_certs) and no certificate # issuance or ACME client (acme, zerossl, issuer, cert_issuer, acme_ca, # acme_ca_root, acme_dns, acme_eab, on_demand, on_demand_tls, -# skip_install_trust, get_certificate), anywhere in any Caddy file; +# skip_install_trust, get_certificate) in any tracked Caddy file and in +# any file copied or mounted into /etc/caddy, whatever its name; # - tls only as "tls " (no block, no internal); # - every configuration Caddy starts with has exactly one global options # block, first, with exactly "admin off", "auto_https off" and # "persist_config off" and no option outside the allow-list (log); -# - no environment placeholder ({$VAR}, substituted into the raw text +# - in the same files: no environment placeholder ({$VAR}, substituted into the raw text # before parsing, so it could inject any configuration; {env.VAR}), # no snippet arguments or blocks ({args...}, {block}, {blocks...}), no # heredoc and no named route (&(name), invoke); @@ -30,12 +31,26 @@ # no edge stage sets CMD or ENTRYPOINT (the pinned caddy base image's # "caddy run --config /etc/caddy/Caddyfile --adapter caddyfile" is # used; tests/app/scan.sh checks it in the built images); +# - edge services in Compose (build docker/edge/Dockerfile, a caddy +# image, or an override of such a service) have no entrypoint or +# extends, and a command only in the exact form +# ["caddy", "run", "--config", "/etc/caddy/", "--adapter", "caddyfile"]; +# Compose files contain no backslash (YAML escapes) and no mount over +# /, /etc, /usr or a bin directory; +# - edge runtime stages of a Dockerfile only RUN "apk add --no-cache +# ", copy the binary only to /usr/bin/caddy, put nothing else +# in a bin directory and do not change PATH or SHELL; command lines +# contain no glob that matches the binary's name; # - no tracked Caddy file is unused by these start points, and no # tracked JSON file looks like a Caddy JSON configuration. # Caddyfiles are tokenized like Caddy 2.11's lexer (comments, quotes, # backticks, escaped newlines, CR) and parsed like its parser (snippets, # imports, logical lines), so comments and quotes neither hide a -# directive nor invent one. Anything the guard does not model fails. +# directive nor invent one. Caddyfile constructs the guard does not model +# fail. Shell and YAML are only read line by line: the guard catches +# ordinary and accidental changes and the obfuscations above, but not a +# command or path assembled at run time (shell variables, eval, env_file, +# docker cp) or a Caddy plugin in docker/edge/caddy/main.go. # # Usage: guard-edge-config.sh [repository-path] # Exit codes: 0 configuration safe, 1 unsafe or unsupported construct, @@ -158,7 +173,7 @@ stage_index() { # id -> index, or 1 # lines that mention caddy in any case and are not full-line comments). logical_lines() { awk -v only="${2-}" ' - function out(t) { if (only == "" || (t !~ /^[[:space:]]*#/ && tolower(t) ~ /caddy/)) print start "\t" t } + function out(t) { if (only == "" || (t !~ /^[[:space:]]*#/ && (tolower(t) ~ /caddy/ || t ~ /[*?[]/))) print start "\t" t } { sub(/\r$/, "") } { if (acc == "") start = NR; else acc = acc " " if ($0 ~ /\\$/) { acc = acc substr($0, 1, length($0) - 1); next } @@ -183,6 +198,7 @@ words() { set +f } +ST_FROM=() DL_STAGE=() DL_WHERE=() DL_INSTR=() DL_TEXT=() for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do stage='' has_caddy_copy=no cmd_lines='' while IFS=$'\t' read -r ln text; do @@ -191,7 +207,7 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do instr=${text%%[[:space:]]*} rest=${text#"$instr"} shopt -s nocasematch case $instr in - from | copy | add | cmd | entrypoint) instr=$(lower "$instr") ;; + from | copy | add | cmd | entrypoint | run | env | shell) instr=$(lower "$instr") ;; *) instr=other ;; esac shopt -u nocasematch @@ -203,9 +219,10 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do if [ "${#w[@]}" -ge 3 ] && [ "$(lower "${w[1]}")" = as ]; then name=$(lower "${w[2]}"); fi stage_index "$df#$img" >/dev/null && parent="$df#$img" stage="$df#$name" - ST_ID+=("$stage") ST_PARENT+=("$parent") + ST_ID+=("$stage") ST_PARENT+=("$parent") ST_FROM+=("${w[0]-}") ;; copy | add) + DL_STAGE+=("$stage") DL_WHERE+=("$df:$ln") DL_INSTR+=("$instr") DL_TEXT+=("$text") words "$rest" w=(${WORDS[@]+"${WORDS[@]}"}) dest=${w[${#w[@]}-1]-} @@ -235,6 +252,9 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do ;; cmd | entrypoint) cmd_lines="$cmd_lines $ln" ;; *) + case $instr in run | env | shell) + DL_STAGE+=("$stage") DL_WHERE+=("$df:$ln") DL_INSTR+=("$instr") DL_TEXT+=("$text") ;; + esac case $text in *"/etc/caddy"*) report "$df:$ln" "only COPY may write to /etc/caddy (the guard checks the copied file)" "$text" ;; @@ -247,13 +267,88 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do fi done +# Edge runtime stages: a stage FROM a caddy image, a stage that copies into +# /etc/caddy, and every stage built on one. Their RUN may only install +# Alpine packages, the binary may only be copied to /usr/bin/caddy, and +# nothing else may be placed in a bin directory or change PATH or SHELL: a +# renamed or linked copy of the binary would escape the command checks. +is_runtime_stage() { # stage id + local st=$1 idx k seen=' ' + while [ -n "$st" ]; do + case $seen in *" $st "*) return 1 ;; esac + seen="$seen$st " + for ((k = 0; k < ${#MAP_STAGE[@]}; k++)); do [ "${MAP_STAGE[$k]}" = "$st" ] && return 0; done + idx=$(stage_index "$st") || return 1 + shopt -s nocasematch + case ${ST_FROM[$idx]} in *caddy*) [ -z "${ST_PARENT[$idx]}" ] && { shopt -u nocasematch; return 0; } ;; esac + shopt -u nocasematch + st=${ST_PARENT[$idx]} + done + return 1 +} +# Descendants count too: a stage is runtime if it or an ancestor is. +bin_dir_re='^(/usr/local|/usr)?/s?bin(/|$)' +for ((k = 0; k < ${#DL_STAGE[@]}; k++)); do + is_runtime_stage "${DL_STAGE[$k]}" || continue + where=${DL_WHERE[$k]} text=${DL_TEXT[$k]} + case ${DL_INSTR[$k]} in + run) + body=${text#*[[:space:]]} + body=${body#"${body%%[![:space:]]*}"} + case $body in + 'apk add --no-cache '*) + set -f + # shellcheck disable=SC2086 # split on purpose; globbing is off + set -- $body + set +f + for x in "$@"; do + [[ $x =~ ^(apk|add|--[a-z-]+|[A-Za-z0-9._+=~-]+|\'[A-Za-z0-9._+=~\<\>-]+\')$ ]] && continue + report "$where" "RUN in an edge runtime stage may only be a plain 'apk add --no-cache ' (no shell operators)" "$text" + break + done + ;; + *) report "$where" "RUN in an edge runtime stage may only be 'apk add --no-cache ' (a link, copy or rename of the binary would escape the command checks)" "$text" ;; + esac + ;; + env) + case $text in *PATH* | *path* | *SHELL*) report "$where" "ENV changing PATH or SHELL in an edge runtime stage" "$text" ;; esac + ;; + shell) report "$where" "SHELL in an edge runtime stage" "$text" ;; + copy | add) + words "${text#*[[:space:]]}" + w=(${WORDS[@]+"${WORDS[@]}"}) + dest=${w[${#w[@]}-1]-} src='' + for x in "${w[@]}"; do case $x in --*) ;; *) [ -n "$src" ] || src=$x ;; esac; done + if [[ $dest =~ $bin_dir_re ]] || [ "${src##*/}" = caddy ]; then + case $text in + [Cc][Oo][Pp][Yy]' --from='*' '*/caddy' /usr/bin/caddy') [ "${src##*/}" = caddy ] && [ "${#w[@]}" -eq 3 ] || report "$where" "only the Caddy binary may be copied into a bin directory, to /usr/bin/caddy" "$text" ;; + *) report "$where" "only 'COPY --from= /caddy /usr/bin/caddy' may place the binary or anything in a bin directory of an edge runtime stage" "$text" ;; + esac + fi + ;; + esac +done + for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do ln=0 while IFS= read -r line || [ -n "$line" ]; do ln=$((ln + 1)) line=${line%$'\r'} - case $line in *"/etc/caddy"*) ;; *) continue ;; esac [[ $line =~ ^[[:space:]]*# ]] && continue + # YAML escapes (\u0063addy, \x2F) would hide a command or a path from + # these line checks; Compose decodes them. + case $line in *"\\"*) report "$cf:$ln" "backslash in a Compose file (YAML escapes are not supported)" "$line" ;; esac + # A mount over /, /etc, /usr or a bin directory replaces /etc/caddy or + # the binary without naming them. + if [[ $line =~ ^[[:space:]]*-[[:space:]]*[\"\']?[^:\"\'[:space:]]+:(/[^:\"\'[:space:]]*)(:[a-z,]+)?[\"\']?[[:space:]]*$ ]]; then + t=${BASH_REMATCH[1]} + t=${t%/} + case $t in + '' | /etc | /usr | /usr/local | /bin | /sbin | /usr/bin | /usr/sbin | /usr/local/bin | /usr/local/sbin | /usr/bin/* | /usr/local/bin/* | /bin/*) + report "$cf:$ln" "mount over a directory that holds /etc/caddy or the Caddy binary" "$line" ;; + esac + fi + case $line in *"/etc/caddy"*) ;; *) continue ;; esac if [[ $line =~ ^[[:space:]]*-[[:space:]]*\"?\./([^:\"[:space:]]+):(/etc/caddy/[^:\"[:space:]]+)(:([a-z,]+))?\"?[[:space:]]*$ ]]; then src=${BASH_REMATCH[1]} dest=${BASH_REMATCH[2]} mode=${BASH_REMATCH[4]} if ! is_tracked "$src"; then @@ -271,6 +366,91 @@ for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do done <"$top/$cf" done +# Edge services in Compose files: a service that builds docker/edge/Dockerfile +# or runs a caddy image (directly or through a "<<: *anchor" merge), or an +# override (no image and no dockerfile of its own) of a service that is an +# edge service in another file. An edge service has no entrypoint and no extends, and its +# command, if set, is exactly +# command: ["caddy", "run", "--config", "/etc/caddy/", "--adapter", "caddyfile"] +# compose_keys : "\t\t\t" for the +# keys image, dockerfile, build, command, entrypoint, extends and <<. +compose_keys() { + awk ' + { sub(/\r$/, "") } + /^[[:space:]]*(#|$)/ { next } + { match($0, /^ */); ind = RLENGTH; t = substr($0, ind + 1) } + ind == 0 { top = t; sub(/:.*/, "", top); owner = ""; svc_ind = -1; key_ind = -1 + if (top ~ /^x-/ && match(t, /&[A-Za-z0-9_.-]+/)) owner = substr(t, RSTART, RLENGTH) + next } + top == "services" && (svc_ind < 0 || ind <= svc_ind) && t ~ /^[A-Za-z0-9_.-]+:/ { + svc_ind = ind; key_ind = -1; owner = t; sub(/:.*/, "", owner); next } + owner != "" { + if (key_ind < 0) key_ind = ind + k = t; sub(/:.*/, "", k); v = t; sub(/^[^:]*:[[:space:]]*/, "", v) + if (ind == key_ind && (k == "image" || k == "build" || k == "command" || k == "entrypoint" || k == "extends" || k == "<<")) + print NR "\t" owner "\t" k "\t" v + else if (k == "dockerfile") print NR "\t" owner "\tdockerfile\t" v + }' "$1" +} +CK_FILE=() CK_LINE=() CK_OWNER=() CK_KEY=() CK_VAL=() +for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do + while IFS=$'\t' read -r ln owner key val; do + CK_FILE+=("$cf") CK_LINE+=("$ln") CK_OWNER+=("$owner") CK_KEY+=("$key") CK_VAL+=("$val") + done < <(compose_keys "$top/$cf") +done +# svc_has [value-pattern]: the key, directly or through +# a merged anchor of the same file; sets SVC_VAL, SVC_LINE. +svc_has() { + local f=$1 o=$2 key=$3 pat=${4-*} k m anchors='' + for ((k = 0; k < ${#CK_KEY[@]}; k++)); do + [ "${CK_FILE[$k]}" = "$f" ] && [ "${CK_OWNER[$k]}" = "$o" ] && [ "${CK_KEY[$k]}" = '<<' ] && + anchors="$anchors ${CK_VAL[$k]//\*/&}" + done + for ((k = 0; k < ${#CK_KEY[@]}; k++)); do + [ "${CK_FILE[$k]}" = "$f" ] && [ "${CK_KEY[$k]}" = "$key" ] || continue + m=no + [ "${CK_OWNER[$k]}" = "$o" ] && m=yes + case " $anchors " in *" ${CK_OWNER[$k]} "*) m=yes ;; esac + [ "$m" = yes ] || continue + shopt -s nocasematch + # shellcheck disable=SC2053 # $pat is a pattern + if [[ ${CK_VAL[$k]} == $pat ]]; then + shopt -u nocasematch + SVC_VAL=${CK_VAL[$k]} SVC_LINE=${CK_LINE[$k]} + return 0 + fi + shopt -u nocasematch + done + return 1 +} +EDGE_NAMES=' ' +for ((k = 0; k < ${#CK_KEY[@]}; k++)); do + case ${CK_OWNER[$k]} in '&'*) continue ;; esac + if svc_has "${CK_FILE[$k]}" "${CK_OWNER[$k]}" dockerfile '*docker/edge/Dockerfile*' || + svc_has "${CK_FILE[$k]}" "${CK_OWNER[$k]}" image '*caddy*'; then + case $EDGE_NAMES in *" ${CK_OWNER[$k]} "*) ;; *) EDGE_NAMES="$EDGE_NAMES${CK_OWNER[$k]} " ;; esac + fi +done +edge_cmd_re='^\[ *"caddy" *, *"run" *, *"--config" *, *"/etc/caddy/[A-Za-z0-9._/-]+" *, *"--adapter" *, *"caddyfile" *\]$' +for ((k = 0; k < ${#CK_KEY[@]}; k++)); do + f=${CK_FILE[$k]} o=${CK_OWNER[$k]} + case $o in '&'*) continue ;; esac + case " ${CHECKED_SVC-} " in *" $f:$o "*) continue ;; esac + CHECKED_SVC="${CHECKED_SVC-} $f:$o" + edge=no + if svc_has "$f" "$o" dockerfile '*docker/edge/Dockerfile*' || svc_has "$f" "$o" image '*caddy*'; then + edge=yes + elif ! svc_has "$f" "$o" image && ! svc_has "$f" "$o" dockerfile; then + case $EDGE_NAMES in *" $o "*) edge=yes ;; esac + fi + [ "$edge" = yes ] || continue + svc_has "$f" "$o" entrypoint && report "$f:$SVC_LINE" "entrypoint of the edge service $o (only the image's command, or the exact caddy run form, is accepted)" "$SVC_VAL" + svc_has "$f" "$o" extends && report "$f:$SVC_LINE" "extends in the edge service $o is not supported" "$SVC_VAL" + if svc_has "$f" "$o" command && ! [[ $SVC_VAL =~ $edge_cmd_re ]]; then + report "$f:$SVC_LINE" "command of the edge service $o must be exactly [\"caddy\", \"run\", \"--config\", \"/etc/caddy/\", \"--adapter\", \"caddyfile\"]" "$SVC_VAL" + fi +done + # --- caddy command lines ------------------------------------------------------- CFG_PATHS=' ' @@ -353,6 +533,19 @@ scan_runner() { *target=/etc/caddy* | *dst=/etc/caddy* | *destination=/etc/caddy*) report "$f:$ln" "--mount into /etc/caddy is not supported" "$w" ;; esac + # A glob that matches the binary's name (cadd?, /usr/bin/c*dy) could + # run, link or copy it under another name. + case $w in + *[*?[]*) + b=${w##*/} + if [ "${#b}" -gt 0 ] && [[ ${b//[^A-Za-z0-9]/} =~ [A-Za-z0-9]{2} ]]; then + shopt -s nocasematch + # shellcheck disable=SC2053 # $b is the pattern + [[ caddy == $b ]] && report "$f:$ln" "glob that matches the Caddy binary's name" "$w" + shopt -u nocasematch + fi + ;; + esac case $w in caddy | /*/caddy) ;; *) continue ;; @@ -492,8 +685,14 @@ deny() { DENY=$r } -# Raw checks of every tracked Caddy file, used or not. -for f in "${CONFIGS[@]}"; do +# Raw checks and forbidden tokens: every tracked Caddy file, used or not, +# and every file copied or mounted into /etc/caddy, whatever its name (any +# of them can be imported by a glob or a path). +CHECK_FILES=("${CONFIGS[@]}") +for ((k = 0; k < ${#MAP_SRC[@]}; k++)); do + case " ${CHECK_FILES[*]} " in *" ${MAP_SRC[$k]} "*) ;; *) CHECK_FILES+=("${MAP_SRC[$k]}") ;; esac +done +for f in "${CHECK_FILES[@]}"; do if [ -n "$(tr -d '\t\n\r -~' <"$top/$f" | head -c 1 | od -An -c)" ]; then report "$f" "only printable ASCII, tab, CR and LF are accepted (Caddy splits tokens at Unicode spaces)" continue diff --git a/scripts/ci/tests/guard-edge-config.test.sh b/scripts/ci/tests/guard-edge-config.test.sh index a960abf..c64fcd5 100755 --- a/scripts/ci/tests/guard-edge-config.test.sh +++ b/scripts/ci/tests/guard-edge-config.test.sh @@ -26,7 +26,8 @@ NL=$'\n' TAB=$'\t' CR=$'\r' REAL=(docker/edge/Dockerfile docker/edge/Caddyfile.dev docker/edge/Caddyfile.prod docker/edge/common.caddy - tests/app/https/Caddyfile tests/app/https/compose.https.yaml compose.yaml compose.prodlike.yaml tests/app/scan.sh) + tests/app/https/Caddyfile tests/app/https/compose.https.yaml compose.yaml compose.prodlike.yaml compose.test.yaml + compose.fixture.yaml tests/app/scan.sh) P=docker/edge/Caddyfile.prod D=docker/edge/Caddyfile.dev C=docker/edge/common.caddy @@ -62,8 +63,13 @@ put() { } # subst : replace every literal (one line) by . +# Both are passed to awk through files, so backslashes stay literal. subst() { - FROM=$2 TO=$3 awk 'BEGIN { f = ENVIRON["FROM"]; t = ENVIRON["TO"] } + printf '%s' "$2" >"$work/from" + printf '%s' "$3" >"$work/to" + awk -v ff="$work/from" -v tf="$work/to" ' + BEGIN { getline f < ff; n = 0; t = "" + while ((getline l < tf) > 0) { t = t (n++ ? "\n" : "") l } } { out = ""; s = $0 while ((i = index(s, f)) > 0) { out = out substr(s, 1, i - 1) t; s = substr(s, i + length(f)) } print out s }' "$repo/$1" >"$work/subst" || exit 2 @@ -320,6 +326,110 @@ base put tests/app/extra.sh "#!/bin/sh${NL}# caddy run --resume is never used here${NL}" expect "caddy command in a full-line comment is ignored" 0 + +# --- Audit reproducers (FCRM-SEC-042 E, MEDIUM-1 and LOW-1) --------------------------- +# shellcheck disable=SC2016 # the Compose text itself, not an expansion +EDGE_VOL=' - ${FLOWCRM_GATE_DIR:-./docker/edge/no-gate}:/run/flowcrm-gate:ro' +UNSAFE="{${NL} admin off${NL}}${NL}:8080 {${NL} acme_server${NL}}${NL}" +base +put docker/edge/acme.conf "acme_server${NL}" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - ./docker/edge/acme.conf:/etc/caddy/acme.conf:ro" +subst "$D" " import baseline" " import baseline${NL} import /etc/caddy/*.conf" +expect "E conf-compose-glob: acme_server in a Compose-mounted .conf reached by a glob" 1 "docker/edge/acme.conf:1: ACME server (acme_server" +base +put docker/edge/acme.conf "acme_server${NL}" +subst "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/common.caddy /etc/caddy/common.caddy${NL}COPY docker/edge/acme.conf /etc/caddy/acme.conf" +subst "$P" " import baseline" " import baseline${NL} import acme.conf" +expect "E conf-dockerfile-copy: acme_server in a copied .conf imported by path" 1 "docker/edge/acme.conf:1: ACME server (acme_server" +base +put docker/edge/extra.conf "{\$EDGE_EXTRA}${NL}respond {env.X}${NL}" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - ./docker/edge/extra.conf:/etc/caddy/extra.conf:ro" +subst "$D" " import baseline" " import baseline${NL} import /etc/caddy/*.conf" +expect "E conf-envvar: {\$VAR} in an imported .conf" 1 "docker/edge/extra.conf:1: environment placeholder {\$...}" +base +put docker/edge/extra.conf "{\$EDGE_EXTRA}${NL}respond {env.X}${NL}" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - ./docker/edge/extra.conf:/etc/caddy/extra.conf:ro" +subst "$D" " import baseline" " import baseline${NL} import /etc/caddy/*.conf" +expect "E conf-envvar: {env.*} in an imported .conf" 1 "docker/edge/extra.conf:2: environment placeholder {env.*}" +base +put docker/edge/plain.conf "respond "$'\xc2\xa0'"x${NL}" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - ./docker/edge/plain.conf:/etc/caddy/plain.conf:ro" +expect "non-ASCII in a mounted file with any name" 1 "only printable ASCII" +base +put docker/edge/e.conf "$UNSAFE" +subst "$HC" ' command: ["caddy", "run", "--config", "/etc/caddy/https/Caddyfile", "--adapter", "caddyfile"]' ' command: ["\u0063addy", "run", "--config", "/srv/e.conf", "--adapter", "caddyfile"]' +subst "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/srv/attacker:ro${NL} - ./docker/edge/e.conf:/srv/e.conf:ro" +expect "E yaml-escape-cmd: \\u0063addy in a Compose command" 1 "backslash in a Compose file" +base +put docker/edge/e.conf "$UNSAFE" +subst "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/srv/attacker:ro${NL}"' - "./docker/edge/e.conf:/etc/\u0063addy/Caddyfile:ro"' +expect "E yaml-escape-mount: \\u0063addy in a Compose mount target" 1 "backslash in a Compose file" +base +put docker/edge/e.conf "$UNSAFE" +subst "$DF" "USER 65534:65534" "RUN ln -s /usr/bin/cadd? /usr/local/bin/edge${NL}USER 65534:65534" +subst "$HC" ' command: ["caddy", "run", "--config", "/etc/caddy/https/Caddyfile", "--adapter", "caddyfile"]' ' command: ["edge", "run", "--config", "/srv/e.conf", "--adapter", "caddyfile"]' +subst "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/srv/attacker:ro${NL} - ./docker/edge/e.conf:/srv/e.conf:ro" +expect "E alias-binary: RUN ln of the binary in an edge stage" 1 "RUN in an edge runtime stage may only be" +base +put docker/edge/e.conf "$UNSAFE" +subst "$HC" ' command: ["caddy", "run", "--config", "/etc/caddy/https/Caddyfile", "--adapter", "caddyfile"]' ' command: ["edge", "run", "--config", "/srv/e.conf", "--adapter", "caddyfile"]' +expect "E alias-binary: another executable in the edge service command" 1 "command of the edge service edge must be exactly" +bad "E caddyfile-name-elsewhere: sh -c 'exec caddy run ... --resume' as the edge command" "command of the edge service edge must be exactly" "$HC" ' command: ["caddy", "run", "--config", "/etc/caddy/https/Caddyfile", "--adapter", "caddyfile"]' ' command: ["sh", "-c", "exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile --resume"]' +bad "E json-config-noadapter: caddy run --config /srv/edge.cfg" "command of the edge service edge must be exactly" "$HC" ' command: ["caddy", "run", "--config", "/etc/caddy/https/Caddyfile", "--adapter", "caddyfile"]' ' command: ["caddy", "run", "--config", "/srv/edge.cfg"]' + +# --- Edge services in Compose ----------------------------------------------------------- +base +subst compose.yaml " target: dev" " target: dev${NL} entrypoint: [\"/usr/bin/caddy\"]" +expect "entrypoint in the edge service (compose.yaml)" 1 "entrypoint of the edge service edge" +base +subst compose.prodlike.yaml " ports: !reset []" " ports: !reset []${NL} command: [\"edge\", \"run\"]" +expect "command in a build-target override of the edge (compose.prodlike.yaml)" 1 "command of the edge service edge must be exactly" +base +subst compose.test.yaml " ports: !reset []" " ports: !reset []${NL} entrypoint: [\"sh\", \"-c\"]" +expect "entrypoint in a plain override of the edge (compose.test.yaml)" 1 "entrypoint of the edge service edge" +base +subst compose.yaml "x-hardening: &hardening" "x-hardening: &hardening${NL} command: [\"edge\", \"run\"]" +expect "command merged into the edge from an x- anchor" 1 "command of the edge service edge must be exactly" +base +subst compose.yaml " target: dev" " target: dev${NL} extends:${NL} file: other.yaml" +expect "extends in the edge service" 1 "extends in the edge service edge is not supported" +base +put compose.extra.yaml "services:${NL} proxy:${NL} image: caddy:2.11.7-alpine${NL} command: [\"caddy\", \"run\", \"--resume\"]${NL}" +expect "another service running a caddy image" 1 "command of the edge service proxy must be exactly" +base +put compose.extra.yaml "services:${NL} edge2:${NL} build:${NL} context: .${NL} dockerfile: docker/edge/Dockerfile${NL} entrypoint: [\"sh\"]${NL}" +expect "another service building the edge Dockerfile" 1 "entrypoint of the edge service edge2" +bad "Compose mount over /etc" "mount over a directory that holds /etc/caddy or the Caddy binary" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/etc:ro" +bad "Compose mount over /usr/bin/caddy" "mount over a directory that holds /etc/caddy or the Caddy binary" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/usr/bin/caddy:ro" +bad "Compose mount over / (quoted)" "mount over a directory that holds /etc/caddy or the Caddy binary" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - \"./tests/app/https/attacker:/:ro\"" +bad "backslash anywhere in a Compose file" "backslash in a Compose file" compose.yaml " read_only: true" " read_only: \"tr\\x75e\"" + +# --- Edge runtime stages of the Dockerfile ---------------------------------------------- +bad "RUN cp of the binary in an edge stage" "RUN in an edge runtime stage may only be" "$DF" "USER 65534:65534" "RUN cp /usr/bin/caddy /usr/local/bin/edge${NL}USER 65534:65534" +bad "RUN in a stage built on caddy-base (prod)" "RUN in an edge runtime stage may only be" "$DF" "COPY docker/edge/Caddyfile.prod /etc/caddy/Caddyfile" "COPY docker/edge/Caddyfile.prod /etc/caddy/Caddyfile${NL}RUN mv /usr/bin/caddy /usr/bin/edge" +base +printf 'FROM prod AS extra\nRUN ln -s /usr/bin/caddy /usr/local/bin/edge\n' >>"$repo/$DF" +expect "RUN in a stage built on prod without files of its own" 1 "RUN in an edge runtime stage may only be" +bad "apk add with a shell operator" "no shell operators" "$DF" "'libcurl>=8.23.0-r0'" "'libcurl>=8.23.0-r0' && ln -s /usr/bin/caddy /bin/e" +bad "COPY of the binary under another name" "may place the binary or anything in a bin directory" "$DF" "COPY --from=caddy-build /out/caddy /usr/bin/caddy" "COPY --from=caddy-build /out/caddy /usr/local/bin/edge" +bad "COPY of the binary outside a bin directory" "only 'COPY --from= /caddy /usr/bin/caddy'" "$DF" "COPY --from=caddy-build /out/caddy /usr/bin/caddy" "COPY --from=caddy-build /out/caddy /usr/bin/caddy${NL}COPY --from=caddy-build /out/caddy /srv/edge" +bad "COPY of another file into a bin directory" "may place the binary or anything in a bin directory" "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/common.caddy /etc/caddy/common.caddy${NL}COPY docker/edge/no-gate/README.md /usr/local/bin/caddy-wrapper" +bad "ENV PATH in an edge stage" "ENV changing PATH or SHELL" "$DF" "USER 65534:65534" "ENV PATH=/srv/bin:/usr/bin${NL}USER 65534:65534" +bad "SHELL in an edge stage" "SHELL in an edge runtime stage" "$DF" "USER 65534:65534" "SHELL [\"/bin/sh\", \"-c\"]${NL}USER 65534:65534" +base +subst "$DF" "RUN npm run build" "RUN npm run build && cp /app/dist/index.html /tmp/x" +expect "RUN in a non-edge stage (spa-build) is not restricted" 0 + +# --- Globs that match the binary's name --------------------------------------------------- +base +put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm img sh -c 'cp /usr/bin/cadd? /tmp/e && /tmp/e run --resume'${NL}" +expect "cp /usr/bin/cadd? in a script" 1 "glob that matches the Caddy binary's name" +base +put tests/app/extra.sh "#!/bin/sh${NL}ls /usr/bin/c*dy${NL}" +expect "c*dy glob in a script" 1 "glob that matches the Caddy binary's name" +base +put tests/app/extra.sh "#!/bin/sh${NL}rm -f /tmp/build/*.log${NL}case \$x in *\" \$y \"*) ;; esac${NL}" +expect "ordinary globs are accepted" 0 # --- Adapted JSON (scripts/ci/edge-adapted-config.jq) --------------------------------------- SAFE='{"admin":{"disabled":true,"config":{"persist":false}},"logging":{"logs":{"default":{}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"handler":"file_server"}]}],"automatic_https":{"disable":true}}}}}}' # adapted_case From 8fcd045866987fc872c0d0734554b619c65fd5ae Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:04:47 +0100 Subject: [PATCH 14/25] docs(security): state precisely what the edge guard covers and misses The edge configuration guard subsection and ADR-021 claimed that both checks fail closed and that unknown constructs fail. They now list what is checked: every file copied or mounted into /etc/caddy, Compose edge services, edge Dockerfile stages and binary-name globs. They also list what remains open: commands and paths assembled at run time, env_file, docker run --entrypoint in scripts, Compose merging, Caddy plugins, apk package contents, and Compose mounts in the adapted check. The ADR adds Compose edge-service changes to its review triggers and updates the guard timings. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 37 +++++++++---- docs/security/container-vulnerabilities.md | 55 ++++++++++++++----- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 117a8cc..2e05ac0 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -80,14 +80,21 @@ finding, so this finding blocks every pull request and `main`. does, including imports, import globs and snippets, and every place that starts Caddy. It rejects: - `acme_server`, `pki`, issuers, `tls internal`, `local_certs` and - other ACME options; + other ACME options, in every Caddy file and in every file copied or + mounted into `/etc/caddy`, whatever its name; - a global block other than exactly `admin off`, `auto_https off` and `persist_config off`; - environment placeholders; - `--resume` and `--watch`; - - other configuration files, and `CADDY_ADMIN`. - - Unknown constructs fail. + - other configuration files, and `CADDY_ADMIN`; + - edge service commands in Compose other than the exact `caddy run` + form, entrypoints, YAML escapes, and mounts over `/etc` or the + binary; + - edge Dockerfile stages that could link, copy or rename the binary. + + Unknown Caddyfile constructs fail. Shell, YAML and Dockerfile text is + read line by line. The guard catches ordinary and accidental changes, + but not a deliberate evasion assembled at run time (see Consequences). - **Adapted-configuration check**, in `tests/app/scan.sh`. It runs `caddy adapt` with the built binary in the built edge images and on the HTTPS test edge, and checks the JSON: the admin API is disabled, @@ -111,10 +118,16 @@ finding, so this finding blocks every pull request and `main`. - **Residual risk:** availability only. If a configuration enabled an ACME server, or a mutable admin API, a remote client could exhaust the edge's CPU. The edge has no CPU or memory limits, so the API and database on the - same host could suffer too. Today such a change fails CI through both - checks. The checks do not see commands built at run time (shell - variables, an `env_file`) or Caddy plugins added to - `docker/edge/caddy/main.go`. + same host could suffer too. An ordinary change that enables such a + configuration fails the static guard. The adapted check also catches it + when the change is in the built image. The checks do not see commands or + paths assembled at run time (shell variables, `eval`, an `env_file` that + sets `CADDY_ADMIN` or a `{$VAR}` value, `docker cp`). They do not see + `docker run --entrypoint` with another executable in a script outside + Compose, a Caddy plugin added to `docker/edge/caddy/main.go`, or what an + `apk add` package installs. The adapted check does not apply Compose + mounts. These remain review items: every change under `docker/edge`, + `tests/app/https` or a Compose file is a review trigger below. - **Expiry:** on 2026-11-30 Trivy stops applying the entry, and the exception guard fails the `Repository guard` job, so every pull request is blocked until the exception is renewed after a new review or removed. @@ -129,16 +142,16 @@ finding, so this finding blocks every pull request and `main`. - a Go vulnerability database entry with affected symbols, for a govulncheck check; - a severity change; - - any change under `docker/edge` or `tests/app/https`, or a new Caddy - plugin; + - any change under `docker/edge` or `tests/app/https`, to the edge + service in a Compose file, or a new Caddy plugin; - a Trivy upgrade; - a deployment beyond the loopback-only development and test stacks. - **Exit criteria:** go-jose v3 is gone from `go version -m usr/bin/caddy`, or replaced by a fixed version. The exception entry and its table row are then removed, and this ADR is superseded. - **Operations:** - - the edge configuration guard takes about 1 s, and its regression - suite about 90 s, in the `Repository guard` job; + - the edge configuration guard takes about 2 s, and its regression + suite about 2.5 minutes, in the `Repository guard` job; - the adapted check adds seconds to the scan step; - a renewal is a reviewed change to `security/trivyignore.yaml` and the exceptions table. diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index ff3a502..ee91293 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -284,8 +284,9 @@ Trivy upgrade (the exception file format is experimental in Trivy). ### Edge configuration guard The finding is acceptable only while no edge configuration runs the code -that parses untrusted JWS/JWK. Two checks enforce this, and both fail -closed: +that parses untrusted JWS/JWK. Two checks enforce this. They catch +ordinary and accidental changes and the obfuscations listed below, not every +deliberate evasion (see "Known limits"): - `scripts/ci/guard-edge-config.sh` (job `Repository guard`, regression suite `scripts/ci/tests/guard-edge-config.test.sh`) reads every tracked @@ -301,19 +302,37 @@ closed: - `acme_server`, `pki`, `internal`, `local_certs`, `acme`, `zerossl`, `issuer`, `cert_issuer`, `acme_ca`, `acme_ca_root`, `acme_dns`, `acme_eab`, `on_demand`, `on_demand_tls`, `skip_install_trust` and - `get_certificate` anywhere (in any case, quoted or not), and `tls` in - any form other than `tls `; + `get_certificate` (in any case, quoted or not) in every tracked Caddy + file and in every file copied or mounted into `/etc/caddy`, whatever + its name (an import glob can reach any of them), and `tls` in any form + other than `tls `; - a configuration Caddy starts with that does not have exactly one global options block, first, with exactly `admin off`, `auto_https off` and `persist_config off`; options other than these and `log`; - - `{$VAR}` (replaced in the raw text before parsing, even in a comment), - `{env.*}`, snippet arguments and blocks, heredocs, named routes, - non-ASCII text, and an import that does not resolve; + - in the same files: `{$VAR}` (replaced in the raw text before parsing, + even in a comment), `{env.*}` and non-ASCII text; snippet arguments and + blocks, heredocs, named routes, and an import that does not resolve to + a copied or mounted file or a snippet; - a `caddy` command other than `run`, `adapt`, `validate`, `version`, `list-modules` and `build-info`; `run`, `adapt` and `validate` without `--config --adapter caddyfile`, or with `--resume`, `--watch`, `--envfile` or an unknown flag; `CADDY_ADMIN` - anywhere; + anywhere; a glob in a command line that matches the binary's name + (`cadd?`); + - an edge service in Compose (it builds `docker/edge/Dockerfile`, runs a + caddy image, directly or through a `<<: *anchor` merge, or overrides + such a service) with an `entrypoint`, `extends`, or a `command` other + than exactly + `["caddy", "run", "--config", "/etc/caddy/", "--adapter", "caddyfile"]`; + a backslash anywhere in a Compose file (YAML escapes such as + `\u0063addy` would hide a command or a path); a mount over `/`, `/etc`, + `/usr` or a bin directory; + - in the edge runtime stages of the Dockerfile (from the caddy image or a + stage with files in `/etc/caddy`, and every stage built on them): a + `RUN` other than a plain `apk add --no-cache `, a copy of the + binary anywhere but `COPY --from= /caddy /usr/bin/caddy`, + anything else in a bin directory, and `ENV PATH` or `SHELL`, so the + binary cannot be linked, copied or renamed; - a Caddy file that no start point uses, and a tracked JSON file that looks like a Caddy JSON configuration. - `tests/app/scan.sh` (job `Dev environment`) runs `caddy adapt` with the @@ -327,11 +346,21 @@ closed: has `automatic_https.disable`. A control image with an unsafe configuration must be rejected. -Known limits: the guard reads command lines as written. A command built at -run time (shell variables, `env_file:`) is not seen, and a -`docker compose` bind mount applies to every edge stage in its model. A -Caddy plugin added to `docker/edge/caddy/main.go` would need its own -review. Changing a rule means reviewing the reason above. +Known limits. The guard reads Caddyfiles like Caddy does, but shell, YAML +and Dockerfile text only line by line, and some evasions remain open: +- commands and paths assembled at run time: shell variables, `eval`, + `$(...)` in scripts, an `env_file:` that sets `CADDY_ADMIN` or a `{$VAR}` + value, `docker cp` or `docker exec` with a variable command; +- `docker run --entrypoint ` in a script, outside Compose (only + caddy command words and globs that match its name are checked there); +- Compose files are not merged as Compose merges them. Bind mounts count + for every edge stage, and an override is recognised by its service name; +- a Caddy plugin added to `docker/edge/caddy/main.go`, or a package an + `apk add` installs. + +The adapted-config check in `tests/app/scan.sh` sees what the built images +contain and the one HTTPS test mount. It does not apply Compose mounts. +Changing a rule means reviewing the reason above. ## Deferred hardening items From 7230a3a20b9341de6c02a70b7c685fed13aedff1 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 14:26:35 +0100 Subject: [PATCH 15/25] fix(ci): require paths and one entry per package and vulnerability in scan exceptions The exception guard still accepted an entry without paths, which Trivy applies at any path in every gated image, although the policy requires the affected path. It also accepted a second entry for the same vulnerability under its alias id (the GHSA id of an excepted CVE) with another package, and the same package under two different ids. Trivy 0.67.2 matches only the id it reports, so such an alias entry has no effect today, but one vulnerability could then cover two packages. The guard now requires paths in every entry, rejects an entry whose package (purl without version) is already excepted by another entry, and reads the Exceptions table of docs/security/container-vulnerabilities.md: the id must start a row of that table (a mention elsewhere in the document no longer counts), and an id the table lists as an alias in parentheses is rejected. This needs no network and stays deterministic. The suite has 81 cases (12 new). The case that accepted an entry without paths now expects a failure, the fixture documentation is an Exceptions table, and the cases with two entries use two packages. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 7 +- docs/security/container-vulnerabilities.md | 17 +++-- scripts/ci/guard-trivyignore.sh | 65 +++++++++++++++---- scripts/ci/tests/guard-trivyignore.test.sh | 43 ++++++++++-- security/trivyignore.yaml | 12 ++-- 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 2e05ac0..357ac36 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -102,8 +102,11 @@ finding, so this finding blocks every pull request and `main`. `acme_server` handler, automatic HTTPS is off and persistence is off. An unsafe control configuration must be rejected. - **Stricter exception guard**, `scripts/ci/guard-trivyignore.sh`. Every - entry must name exactly one package with an exactly pinned purl, and an - entry that expires on or before the check date fails the guard. + entry must name its path and exactly one package with an exactly + pinned purl; no package may appear in two entries, and an id that the + exceptions table lists as an alias (the GHSA id of this CVE) is + rejected, because Trivy matches only the id it reports. An entry that + expires on or before the check date fails the guard. - **Visibility:** the suppressed finding is printed as `SUPPRESSED` in the edge-prod scan log. The edge `dev` report scan does not use the ignore file and keeps listing it. diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index ee91293..6f946e8 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -30,18 +30,25 @@ downloaded on every run. code: it only stops the gate from failing on that one finding. `scripts/ci/guard-trivyignore.sh` (job `Repository guard`) enforces these rules on the file: one CVE or GHSA id per entry, no duplicate id and no - key twice in one entry; `purls` with at least one value, each of the form + key twice in one entry; the id is the one Trivy reports (the CVE when one + exists: Trivy matches only that id, not its aliases), so an id listed as an + alias in the first column of the [Exceptions](#exceptions) table, as in + `CVE-2026-116587 (GHSA-mgcc-cww5-fp2q)`, is rejected; `purls` with at least one value, each of the form `pkg://@` with a lowercase type and an exact version (no purl without a version, which Trivy would apply to every version; no range, list or blank; no qualifiers, subpath or percent-encoding), and all purls of an entry naming the same package (type, namespace and name), so one entry cannot also cover another package - such as a second major version of a Go module; `paths` optional, but if - given with at least one value; non-blank values without wildcards in both + such as a second major version of a Go module; no two entries naming the + same package (purl without its version), whatever their ids, so one + package is never excepted under a second id or alias; `paths` with at + least one value (without `paths` Trivy would apply the entry at any path + in every gated image); non-blank values without wildcards in both lists (Trivy matches `paths` as glob patterns, so `*`, `?`, `[`, `]`, `{` and `}` are all rejected); a non-empty `statement`; a valid `expired_at` - after the day of the check and no more than 90 days after it; the id - mentioned in this document; and only the documented form (any other + after the day of the check and no more than 90 days after it; the id at + the start of a row of the [Exceptions](#exceptions) table (a mention + elsewhere in this document does not count); and only the documented form (any other section or key fails). Trivy stops applying an entry at 00:00 UTC of its `expired_at` day, and from that day on the guard fails too, so an expired exception breaks the `Repository guard` job until it is renewed after a diff --git a/scripts/ci/guard-trivyignore.sh b/scripts/ci/guard-trivyignore.sh index b7377c8..b11f6c1 100755 --- a/scripts/ci/guard-trivyignore.sh +++ b/scripts/ci/guard-trivyignore.sh @@ -4,16 +4,22 @@ # (security/trivyignore.yaml, used by tests/app/scan.sh) follows the policy # in docs/security/container-vulnerabilities.md: # - one specific vulnerability per entry: id is CVE-- or a -# GHSA id, and no id appears twice; +# GHSA id, and no id appears twice. Trivy matches only the id it +# reports (the CVE when one exists), so an id that the Exceptions table +# of the docs lists as an alias in parentheses, as in +# "| CVE-2026-0001 (GHSA-xxxx-xxxx-xxxx) |", is rejected: one +# vulnerability cannot be spread over a second entry under its alias; # - the affected package is named exactly: "purls" with at least one # entry, each pkg://@ with an exact # version and no qualifiers, subpath or percent-encoding (a purl # without a version matches every version), and all purls of an entry # name the same package (type, namespace and name), so one entry cannot # cover a second package such as another major version of a Go module; -# - "paths", if given, has at least one entry; neither list has a -# wildcard (Trivy matches paths as doublestar patterns, so * ? [ ] { } -# are all rejected); +# - one entry per package: no two entries name the same package (purl +# without its version), whatever their ids; +# - the affected path is named exactly: "paths" with at least one entry; +# neither list has a wildcard (Trivy matches paths as doublestar +# patterns, so * ? [ ] { } are all rejected); # - the evidence for the risk decision is given: a non-empty "statement"; # - no key appears twice in one entry (YAML parsers differ in which value # of a repeated key they keep); @@ -28,7 +34,9 @@ # expired_at fails, so it is renewed after a new review or removed # instead of lingering (Trivy stops applying it at 00:00 UTC of that # day, and the gate fails on the finding again); -# - the entry is listed in docs/security/container-vulnerabilities.md. +# - the entry is listed in docs/security/container-vulnerabilities.md: +# its id starts a row of the table under "## Exceptions" (a mention +# elsewhere in the document does not count). # Only the documented form is accepted (see the example in the file): # "vulnerabilities: []", or "vulnerabilities:" followed by entries of # " - id: ..." and " : " lines, with full-line comments. @@ -111,12 +119,40 @@ id_re='^(CVE-[0-9]{4}-[0-9]{4,}|GHSA(-[23456789cfghjmpqrvwx]{4}){3})$' # backslash (YAML escapes) or inner double quote. item='"[^"\\]*[^"\\[:space:]][^"\\]*"' list_re="^\\[[[:space:]]*$item([[:space:]]*,[[:space:]]*$item)*[[:space:]]*\\]$" -state=start entries=0 entry_line=0 id='' purls='' statement='' expiry='' ids=' ' keys=' ' +state=start entries=0 entry_line=0 id='' purls='' paths='' pkg='' statement='' expiry='' ids=' ' keys=' ' +# Packages of the entries so far, as " = ". +packages=' ' + +# The Exceptions table of the docs: the id that starts each row, and the +# aliases listed after it in parentheses, as " = ". +listed=' ' aliases=' ' in_table='' +while IFS= read -r dline || [ -n "$dline" ]; do + case $dline in + '## Exceptions') in_table=yes ;; + '## '*) in_table='' ;; + esac + [ -n "$in_table" ] || continue + [[ $dline =~ ^\|\ *([A-Za-z0-9-]+)(\ \(([A-Za-z0-9, -]*)\))?\ *\| ]] || continue + primary=${BASH_REMATCH[1]} rest=${BASH_REMATCH[3]} + [[ $primary =~ $id_re ]] || continue + listed="$listed$primary " + while [[ $rest =~ ([A-Za-z0-9-]+)(.*)$ ]]; do + aliases="$aliases${BASH_REMATCH[1]}=$primary " rest=${BASH_REMATCH[2]} + done +done <"$doc" # Checks the entry collected so far (called at the next entry and at the end). finish_entry() { [ "$entry_line" -gt 0 ] || return 0 [ -n "$purls" ] || report "$entry_line" "no purls (the affected package with its exact version)" "$id" + [ -n "$paths" ] || report "$entry_line" "no paths (the affected path)" "$id" + if [ -n "$pkg" ]; then + if [[ $packages == *" $pkg="* ]]; then + local other=${packages#*" $pkg="} + report "$entry_line" "package already excepted by the entry at line ${other%% *} (one entry per package)" "$pkg" + fi + packages="$packages$pkg=$entry_line " + fi [ -n "$statement" ] || report "$entry_line" "no statement with the evidence for the decision" "$id" if [ -z "$expiry" ]; then report "$entry_line" "no expired_at date (the exception would be permanent)" "$id" @@ -127,15 +163,20 @@ finish_entry() { elif [ $((e - today_days)) -gt "$max_days" ]; then report "$entry_line" "expired_at is more than $max_days days after $today" "$expiry" fi - if [ -n "$id" ] && ! grep -qF -- "$id" "$doc"; then - report "$entry_line" "not listed in docs/security/container-vulnerabilities.md" "$id" + if [ -n "$id" ] && [[ $aliases == *" $id="* ]]; then + local primary=${aliases#*" $id="} + report "$entry_line" "id is listed as an alias of ${primary%% *} in docs/security/container-vulnerabilities.md (Trivy matches the id it reports; use that id in one entry)" "$id" + elif [ -n "$id" ] && [[ $listed != *" $id "* ]]; then + report "$entry_line" "not listed in the Exceptions table of docs/security/container-vulnerabilities.md" "$id" fi - entry_line=0 id='' purls='' statement='' expiry='' keys=' ' + entry_line=0 id='' purls='' paths='' pkg='' statement='' expiry='' keys=' ' } # check_purls : every purl is exact and all name one package. +# Sets pkg to the package of the entry. check_purls() { - local rest=$2 purl pkg='' ok=yes + local rest=$2 purl ok=yes + pkg='' while [[ $rest =~ ^[^\"]*\"([^\"]*)\"(.*)$ ]]; do purl=${BASH_REMATCH[1]} rest=${BASH_REMATCH[2]} if [[ ! $purl =~ $purl_re ]]; then @@ -201,7 +242,9 @@ while IFS= read -r line || [ -n "$line" ]; do report "$line_no" "$key must be a list of quoted, non-blank values without backslash" "$value" elif inner=${value#\[} inner=${inner%\]} && [[ $inner == *[][*?{}]* ]]; then report "$line_no" "$key must not contain wildcards" "$value" - elif [ "$key" = purls ] && check_purls "$line_no" "$value"; then + elif [ "$key" = paths ]; then + paths=yes + elif check_purls "$line_no" "$value"; then purls=yes fi ;; diff --git a/scripts/ci/tests/guard-trivyignore.test.sh b/scripts/ci/tests/guard-trivyignore.test.sh index 7d2673b..8f4c100 100755 --- a/scripts/ci/tests/guard-trivyignore.test.sh +++ b/scripts/ci/tests/guard-trivyignore.test.sh @@ -20,7 +20,8 @@ export GUARD_TODAY=2026-10-24 NL=$'\n' HEADER="# Exceptions (fixture).${NL}#${NL}" -DOC="# Container vulnerabilities${NL}${NL}CVE-2026-0001 and GHSA-xjj6-f33q-929c are documented here.${NL}" +TABLE="| Id | Package |${NL}|---|---|${NL}" +DOC="# Container vulnerabilities${NL}${NL}## Exceptions${NL}${NL}${TABLE}| CVE-2026-0001 | example |${NL}| GHSA-xjj6-f33q-929c | undici |${NL}${NL}## Other${NL}" pass=0 fail=0 case_no=0 @@ -41,10 +42,10 @@ expect() { run "$1" "$2" "$repo" } -# expect_error : exit 1, and the -# report names the expected violation (not only some other one). +# expect_error [doc content]: exit +# 1, and the report names the expected violation (not only some other one). expect_error() { - fixture "$3" + fixture "$3" ${4+"$4"} run "$1" 1 "$repo" "$2" } @@ -90,7 +91,9 @@ run "the repository's own exception file" 0 "$real" expect "no exceptions" 0 "${HEADER}vulnerabilities: []${NL}" expect "no exceptions, CRLF line endings" 0 "# c"$'\r'"${NL}vulnerabilities: []"$'\r'"${NL}" expect "one documented entry" 0 "${HEADER}vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL}" -expect "a GHSA entry with purls and no paths" 0 "vulnerabilities:${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 '' "$STATEMENT" ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" +expect "a GHSA entry with purls" 0 "vulnerabilities:${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 ' paths: ["usr/local/lib/node_modules/npm/node_modules/undici/package.json"]' "$STATEMENT" ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" +# Without paths Trivy applies the entry at every path of every gated image. +expect_error "purls and no paths" "no paths" "vulnerabilities:${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 '' "$STATEMENT" ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" expect "expiry exactly 90 days ahead" 0 "vulnerabilities:${NL}$(entry CVE-2026-0001 2027-01-22)${NL}" # An expired entry used to pass because Trivy no longer applies it; it now # fails, so an exception is renewed after a review or removed, not kept. @@ -111,7 +114,7 @@ expect "entry not listed in the docs" 1 "vulnerabilities:${NL}$(entry CVE-2026-0 expect "a key repeated in an entry (a parser taking the first would apply 2099)" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2099-01-01)${NL} expired_at: 2026-12-01${NL}" expect "paths repeated in an entry" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL} paths: [\"other\"]${NL}" expect "a blank path" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: [" "]')${NL}" -expect "the same key in two entries" 0 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL}$(entry GHSA-xjj6-f33q-929c 2026-12-02)${NL}" +expect "the same key in two entries" 0 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL}$(entry GHSA-xjj6-f33q-929c 2026-12-02 ' paths: ["usr/local/bin/other"]' "$STATEMENT" ' purls: ["pkg:npm/undici@6.29.0"]')${NL}" expect "unknown key in an entry" 1 "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01)${NL} severity: HIGH${NL}" expect "another top-level section" 1 "vulnerabilities: []${NL}misconfigurations:${NL} - id: AVD-DS-0001${NL}" expect "section without entries" 1 "vulnerabilities:${NL}" @@ -159,7 +162,7 @@ go_jose() { # [purls-line] [expired_at] expect "one package at an exact version, with paths" 0 "vulnerabilities:${NL}$(go_jose)${NL}" expect "one package at two exact versions" 0 "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.4" "$J3@v3.0.5")")${NL}" expect "a Go pseudo-version and an Alpine package version" 0 \ - "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v0.0.0-20260101000000-0123456789ab")")${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 '' "$STATEMENT" "$(purls 'pkg:apk/alpine/curl@8.22.0-r0')")${NL}" + "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v0.0.0-20260101000000-0123456789ab")")${NL}$(entry GHSA-xjj6-f33q-929c 2026-11-30 ' paths: ["usr/lib/libcurl.so.4"]' "$STATEMENT" "$(purls 'pkg:apk/alpine/curl@8.22.0-r0')")${NL}" expect_error "paths only, no purls (would also hide the v4 finding)" "no purls" "vulnerabilities:${NL}$(go_jose '')${NL}" expect_error "an empty purls list" "purls must be a list" "vulnerabilities:${NL}$(go_jose ' purls: []')${NL}" expect_error "a purl without a version (matches every version)" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3")")${NL}" @@ -182,6 +185,32 @@ expect_error "a valid purl next to one without a version" "purl is not" "vulnera expect "expiry the day after the check" 0 "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5")" 2026-10-25)${NL}" expect_error "expiry on the day of the check" "expired_at is not after 2026-10-24" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5")" 2026-10-24)${NL}" expect_error "an expired entry next to a valid one" "expired_at is not after" "vulnerabilities:${NL}$(go_jose)${NL}$(entry GHSA-xjj6-f33q-929c 2026-10-23)${NL}" +# One entry per package and per vulnerability (audit LOW-2): Trivy matches +# only the id it reports, so the alias of an excepted CVE must not carry a +# second entry, and no package may be excepted twice under different ids. +ALIAS_DOC="# Container vulnerabilities${NL}${NL}CVE-2026-116587 / GHSA-mgcc-cww5-fp2q in prose.${NL}${NL}## Exceptions${NL}${NL}${TABLE}| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | go-jose v3 |${NL}| CVE-2026-0001 | example |${NL}| CVE-2026-0003 (GHSA-xjj6-f33q-929c, GHSA-2222-3333-4444) | two aliases |${NL}${NL}## Other${NL}${NL}| CVE-2026-0002 | outside the table |${NL}" +jose() { # id purl [expired_at] + entry "$1" "${3-2026-11-30}" ' paths: ["usr/bin/caddy"]' "$STATEMENT" "$(purls "$2")" +} +expect "the real go-jose entry with the real table form" 0 "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect_error "the GHSA alias of an excepted CVE with the v4 purl" "id is listed as an alias of CVE-2026-116587" \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose GHSA-mgcc-cww5-fp2q "$J4@v4.1.5")${NL}" "$ALIAS_DOC" +expect_error "the GHSA alias alone (Trivy reports the CVE)" "id is listed as an alias of CVE-2026-116587" \ + "vulnerabilities:${NL}$(jose GHSA-mgcc-cww5-fp2q "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect_error "the second of two aliases" "id is listed as an alias of CVE-2026-0003" \ + "vulnerabilities:${NL}$(jose GHSA-2222-3333-4444 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect_error "an id mentioned only in prose, not in the table" "not listed in the Exceptions table" \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}" "# Container vulnerabilities${NL}${NL}No exception for CVE-2026-116587.${NL}" +expect_error "an id in a table of another section" "not listed in the Exceptions table" \ + "vulnerabilities:${NL}$(jose CVE-2026-0002 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect_error "the same package under two ids" "package already excepted by the entry at line 2" \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J3@v3.0.4")${NL}" "$ALIAS_DOC" +expect_error "the same package and version under two ids" "package already excepted" \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect "two ids for two different packages" 0 \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J4@v4.1.5")${NL}" "$ALIAS_DOC" +expect_error "an empty paths list" "paths must be a list" "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: []')${NL}" +expect_error "a blank path only" "paths must be a list" "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: [" "]')${NL}" mkdir -p "$work/nofile" && git init -q "$work/nofile" || exit 2 run "missing exception file" 2 "$work/nofile" mkdir -p "$work/nogit" diff --git a/security/trivyignore.yaml b/security/trivyignore.yaml index f8fa1dc..478e9a8 100644 --- a/security/trivyignore.yaml +++ b/security/trivyignore.yaml @@ -2,14 +2,16 @@ # # The gate fails on every HIGH or CRITICAL finding in a runtime image. An # entry here is allowed only for one specific CVE that cannot be fixed yet, -# with the evidence for the risk decision in `statement`, the affected -# package as `purls` with exact versions (one package per entry; Trivy then -# applies the entry only to those versions), the affected path, and an +# under the id Trivy reports (the CVE, not an alias), with the evidence for +# the risk decision in `statement`, the affected package as `purls` with +# exact versions (one package per entry and one entry per package; Trivy +# then applies the entry only to those versions), the affected `paths`, and an # `expired_at` date after today and no more than 90 days ahead, so the # decision is reviewed again. From its expired_at day on, Trivy no longer # applies the entry and scripts/ci/guard-trivyignore.sh fails until it is -# renewed after a new review or removed. Each entry is also listed in -# docs/security/container-vulnerabilities.md. No broad or permanent rules. +# renewed after a new review or removed. Each entry is also listed in the +# Exceptions table of docs/security/container-vulnerabilities.md. No broad +# or permanent rules. # An exception does not fix the vulnerable code; it only stops the gate # from failing on that finding. # From 577112962f8f8bbb7f1ff5499c9212721f61ef0d Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 14:26:35 +0100 Subject: [PATCH 16/25] test(app): drop the no-op --show-suppressed from the template scans With --format template, Trivy 0.67.2 always passes the suppressed findings to the template as .ModifiedFindings; --show-suppressed only affects the table and JSON writers. Measured with the pinned image on the edge prod images of this branch and of main: the template output was byte-identical and the exit code the same (0 and 1) with and without the option, while the JSON and table output changed. The SUPPRESSED lines come from the template itself. Remove the option from the gate and control scans and state the actual reason in the scan.sh comment, the container vulnerability document and ADR-021. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 4 ++-- docs/security/container-vulnerabilities.md | 12 +++++++----- tests/app/scan.sh | 12 +++++++----- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 357ac36..4b2d4b0 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -107,8 +107,8 @@ finding, so this finding blocks every pull request and `main`. exceptions table lists as an alias (the GHSA id of this CVE) is rejected, because Trivy matches only the id it reports. An entry that expires on or before the check date fails the guard. - - **Visibility:** the suppressed finding is printed as `SUPPRESSED` in - the edge-prod scan log. The edge `dev` report scan does not use the + - **Visibility:** the scan template prints the suppressed finding as + `SUPPRESSED` in the edge-prod scan log. The edge `dev` report scan does not use the ignore file and keeps listing it. 4. The exception is listed in the exceptions table of the container vulnerability document, which states that it does not fix the vulnerable diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 6f946e8..55d52a0 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -62,11 +62,13 @@ downloaded on every run. inner double quote in a quoted value (an escape could add a wildcard or continue the statement over `expired_at`). Its regression suite is `scripts/ci/tests/guard-trivyignore.test.sh`. -- The gate scans (runtime images and the control scan) run with - `--show-suppressed`, and the scan log lists every finding an exception - suppressed as a `SUPPRESSED ignored ` - line, so an excepted finding stays visible in CI. The option changes the - output only, not the exit code. The development-image scans do not read +- The scan template prints every finding an exception suppressed as a + `SUPPRESSED ignored ` line in the log + of the gate scans (runtime images and the control scan), so an excepted + finding stays visible in CI. Trivy passes suppressed findings to a + template whether or not `--show-suppressed` is given (that option only + affects the table and JSON formats), and they do not count towards the + exit code. The development-image scans do not read the exception file and report the finding as usual. - A control scan of the unmodified PostgreSQL base image (with its `gosu`) must fail; it proves the gate is not passing vacuously. diff --git a/tests/app/scan.sh b/tests/app/scan.sh index 076ff5d..8afb953 100755 --- a/tests/app/scan.sh +++ b/tests/app/scan.sh @@ -28,9 +28,11 @@ CONTROL_IMAGE=postgres:18.6-alpine3.24@sha256:77f585114c32fbca283dc835b0596f4e52 IGNORE_FILE=$APP_REPO/security/trivyignore.yaml # One line per scanned target (class, type, path, number of packages), one # per finding: severity, CVE, package, installed and fixed versions, target, -# and one per finding suppressed by an exception of the ignore file (gate -# scans run with --show-suppressed, which changes the output only, not the -# exit code), so an excepted finding stays visible in the log. +# and one per finding suppressed by an exception of the ignore file, so an +# excepted finding stays visible in the log. Trivy passes the suppressed +# findings to a template as .ModifiedFindings whatever --show-suppressed +# says (that option only affects the table and JSON formats), and they do +# not count towards --exit-code. # shellcheck disable=SC2016 # a Go template, not shell expansion TEMPLATE='{{- range . }}{{- $t := .Target }}TARGET {{ .Class }} {{ .Type }} {{ $t }} pkgs={{ len .Packages }} {{ range .Vulnerabilities }}{{ .Severity }} {{ .VulnerabilityID }} {{ .PkgName }} {{ .InstalledVersion }} fixed={{ if .FixedVersion }}{{ .FixedVersion }}{{ else }}none{{ end }} in={{ $t }} @@ -78,7 +80,7 @@ scan() { shift 2 docker save -o "$work/images/$archive" "$tag:$name" || { bad "SC $name: could not export the image"; return; } if [ "$mode" = gate ]; then - trivy "$archive" --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --show-suppressed --format template --template "$TEMPLATE" >"$log" 2>&1 + trivy "$archive" --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --format template --template "$TEMPLATE" >"$log" 2>&1 rc=$? if [ "$rc" = 0 ]; then ok "SC $name (runtime): no HIGH or CRITICAL vulnerabilities"; else bad "SC $name (runtime): HIGH or CRITICAL vulnerabilities (exit $rc)"; fi else @@ -122,7 +124,7 @@ v "SC control: an image without inventoried packages is rejected" $? if ! docker image inspect "$CONTROL_IMAGE" >/dev/null 2>&1; then docker pull -q "$CONTROL_IMAGE" >/dev/null; fi need "prepare the control image" docker tag "$CONTROL_IMAGE" "$tag:control" || { tally_finish; exit; } docker save -o "$work/images/control.tar" "$tag:control" -trivy control.tar --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --show-suppressed --format template --template "$TEMPLATE" >"$work/control.log" 2>&1 +trivy control.tar --severity HIGH,CRITICAL --exit-code 1 --ignorefile /policy/trivyignore.yaml --format template --template "$TEMPLATE" >"$work/control.log" 2>&1 rc=$? [ "$rc" = 1 ] && grep -q 'in=usr/local/bin/gosu' "$work/control.log" v "SC control: the gate rejects the known-vulnerable PostgreSQL base image" $? "(exit $rc)" From 5e5a2519f66ebc941dc479c208e637b1fe417bb1 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 14:26:52 +0100 Subject: [PATCH 17/25] docs(security): rewrap the exception policy and ADR-021 visibility text --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 4 ++-- docs/security/container-vulnerabilities.md | 11 ++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 4b2d4b0..22ac2db 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -108,8 +108,8 @@ finding, so this finding blocks every pull request and `main`. rejected, because Trivy matches only the id it reports. An entry that expires on or before the check date fails the guard. - **Visibility:** the scan template prints the suppressed finding as - `SUPPRESSED` in the edge-prod scan log. The edge `dev` report scan does not use the - ignore file and keeps listing it. + `SUPPRESSED` in the edge-prod scan log. The edge `dev` report scan + does not use the ignore file and keeps listing it. 4. The exception is listed in the exceptions table of the container vulnerability document, which states that it does not fix the vulnerable code. diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 55d52a0..34cc3c1 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -33,7 +33,8 @@ downloaded on every run. key twice in one entry; the id is the one Trivy reports (the CVE when one exists: Trivy matches only that id, not its aliases), so an id listed as an alias in the first column of the [Exceptions](#exceptions) table, as in - `CVE-2026-116587 (GHSA-mgcc-cww5-fp2q)`, is rejected; `purls` with at least one value, each of the form + `CVE-2026-116587 (GHSA-mgcc-cww5-fp2q)`, is rejected; `purls` with at + least one value, each of the form `pkg://@` with a lowercase type and an exact version (no purl without a version, which Trivy would apply to every version; no range, list or blank; no qualifiers, subpath or @@ -48,8 +49,8 @@ downloaded on every run. and `}` are all rejected); a non-empty `statement`; a valid `expired_at` after the day of the check and no more than 90 days after it; the id at the start of a row of the [Exceptions](#exceptions) table (a mention - elsewhere in this document does not count); and only the documented form (any other - section or key fails). Trivy stops applying an entry at 00:00 UTC of its + elsewhere in this document does not count); and only the documented form + (any other section or key fails). Trivy stops applying an entry at 00:00 UTC of its `expired_at` day, and from that day on the guard fails too, so an expired exception breaks the `Repository guard` job until it is renewed after a new review or removed, instead of lingering in the file. Trivy applies an @@ -68,8 +69,8 @@ downloaded on every run. finding stays visible in CI. Trivy passes suppressed findings to a template whether or not `--show-suppressed` is given (that option only affects the table and JSON formats), and they do not count towards the - exit code. The development-image scans do not read - the exception file and report the finding as usual. + exit code. The development-image scans do not read the exception file + and report the finding as usual. - A control scan of the unmodified PostgreSQL base image (with its `gosu`) must fail; it proves the gate is not passing vacuously. - Every scan must report the package sources expected for the image (Alpine From df50f91b73a559a4e6beea7353ffbbd1be9c7ca9 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:42:42 +0100 Subject: [PATCH 18/25] fix(ci): resolve Compose anchors on bash 5.2 and close edge guard evasions With bash 5.2 patsub_replacement, the & in ${CK_VAL[$k]//\*/&} expands to the matched text, so "<<: *anchor" merges never resolved. On Ubuntu the busybox fixture edge in compose.fixture.yaml was therefore treated as an edge service, and the Repository guard failed. The anchor name is now built without a replacement string. No other substitution in the script puts & in a replacement. Also closed: - edge runtime Dockerfile stages reject WORKDIR, ONBUILD, and any COPY or ADD whose destination is not an absolute literal path (relative to WORKDIR, built from an ARG, or with //, . or ..); - Compose files reject quoted keys, flow mappings other than {}, and paths with //, /./ or ..; - long-syntax target: mounts over /etc/caddy, /etc, /usr or a bin directory are rejected, and short mount targets must be canonical; - a service that builds any Dockerfile with an edge runtime stage, or a Dockerfile the guard does not know, is an edge service; - unnamed Dockerfile stages get an id without "#", so their Dockerfile is recognised. Regression fixtures cover every re-audit reproducer. The fixture base now also tracks the api, db and web Dockerfiles. --- scripts/ci/guard-edge-config.sh | 86 +++++++++++++++++++--- scripts/ci/tests/guard-edge-config.test.sh | 44 ++++++++++- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/scripts/ci/guard-edge-config.sh b/scripts/ci/guard-edge-config.sh index ab56efb..6c2bda8 100755 --- a/scripts/ci/guard-edge-config.sh +++ b/scripts/ci/guard-edge-config.sh @@ -35,11 +35,13 @@ # image, or an override of such a service) have no entrypoint or # extends, and a command only in the exact form # ["caddy", "run", "--config", "/etc/caddy/", "--adapter", "caddyfile"]; -# Compose files contain no backslash (YAML escapes) and no mount over -# /, /etc, /usr or a bin directory; +# Compose files contain no backslash (YAML escapes), no quoted key, no +# flow mapping but {}, no path with //, /./ or .., and no short or +# long-syntax (target:) mount over /, /etc, /usr or a bin directory; # - edge runtime stages of a Dockerfile only RUN "apk add --no-cache # ", copy the binary only to /usr/bin/caddy, put nothing else -# in a bin directory and do not change PATH or SHELL; command lines +# in a bin directory, use only absolute literal COPY/ADD destinations, +# no WORKDIR or ONBUILD, and do not change PATH or SHELL; command lines # contain no glob that matches the binary's name; # - no tracked Caddy file is unused by these start points, and no # tracked JSON file looks like a Caddy JSON configuration. @@ -50,7 +52,9 @@ # fail. Shell and YAML are only read line by line: the guard catches # ordinary and accidental changes and the obfuscations above, but not a # command or path assembled at run time (shell variables, eval, env_file, -# docker cp) or a Caddy plugin in docker/edge/caddy/main.go. +# docker cp) or a Caddy plugin in docker/edge/caddy/main.go. It stops +# accidental and casual unsafe changes and makes deliberate ones visible in +# review; a committer can change this guard too, so review stays required. # # Usage: guard-edge-config.sh [repository-path] # Exit codes: 0 configuration safe, 1 unsafe or unsupported construct, @@ -103,6 +107,19 @@ report() { lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +# canonical_abs : an absolute literal path without $, //, /./, /../, +# a trailing /. or /..; the guard compares paths only in this form. +canonical_abs() { + case $1 in + /*) ;; + *) return 1 ;; + esac + case $1 in + *'$'* | *'//'* | *'/./'* | *'/../'* | */. | */.. | *"\\"*) return 1 ;; + esac + return 0 +} + # --- Discovery --------------------------------------------------------------- CONFIGS=() JSONS=() DOCKERFILES=() COMPOSES=() RUNNERS=() TRACKED=' ' @@ -207,7 +224,7 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do instr=${text%%[[:space:]]*} rest=${text#"$instr"} shopt -s nocasematch case $instr in - from | copy | add | cmd | entrypoint | run | env | shell) instr=$(lower "$instr") ;; + from | copy | add | cmd | entrypoint | run | env | shell | workdir | onbuild) instr=$(lower "$instr") ;; *) instr=other ;; esac shopt -u nocasematch @@ -215,7 +232,7 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do from) words "$rest" w=(${WORDS[@]+"${WORDS[@]}"}) - img=$(lower "${w[0]-}") name="#${#ST_ID[@]}" parent='' + img=$(lower "${w[0]-}") name="stage-${#ST_ID[@]}" parent='' if [ "${#w[@]}" -ge 3 ] && [ "$(lower "${w[1]}")" = as ]; then name=$(lower "${w[2]}"); fi stage_index "$df#$img" >/dev/null && parent="$df#$img" stage="$df#$name" @@ -252,7 +269,7 @@ for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do ;; cmd | entrypoint) cmd_lines="$cmd_lines $ln" ;; *) - case $instr in run | env | shell) + case $instr in run | env | shell | workdir | onbuild) DL_STAGE+=("$stage") DL_WHERE+=("$df:$ln") DL_INSTR+=("$instr") DL_TEXT+=("$text") ;; esac case $text in *"/etc/caddy"*) @@ -314,11 +331,17 @@ for ((k = 0; k < ${#DL_STAGE[@]}; k++)); do case $text in *PATH* | *path* | *SHELL*) report "$where" "ENV changing PATH or SHELL in an edge runtime stage" "$text" ;; esac ;; shell) report "$where" "SHELL in an edge runtime stage" "$text" ;; + workdir) report "$where" "WORKDIR in an edge runtime stage (a relative COPY destination would land in it)" "$text" ;; + onbuild) report "$where" "ONBUILD in an edge runtime stage" "$text" ;; copy | add) words "${text#*[[:space:]]}" w=(${WORDS[@]+"${WORDS[@]}"}) dest=${w[${#w[@]}-1]-} src='' for x in "${w[@]}"; do case $x in --*) ;; *) [ -n "$src" ] || src=$x ;; esac; done + if ! canonical_abs "$dest"; then + report "$where" "COPY or ADD in an edge runtime stage needs an absolute literal destination (no relative path, variable, //, . or ..)" "$text" + continue + fi if [[ $dest =~ $bin_dir_re ]] || [ "${src##*/}" = caddy ]; then case $text in [Cc][Oo][Pp][Yy]' --from='*' '*/caddy' /usr/bin/caddy') [ "${src##*/}" = caddy ] && [ "${#w[@]}" -eq 3 ] || report "$where" "only the Caddy binary may be copied into a bin directory, to /usr/bin/caddy" "$text" ;; @@ -338,10 +361,38 @@ for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do # YAML escapes (\u0063addy, \x2F) would hide a command or a path from # these line checks; Compose decodes them. case $line in *"\\"*) report "$cf:$ln" "backslash in a Compose file (YAML escapes are not supported)" "$line" ;; esac + # The service model below reads block-style YAML with plain keys only. + if [[ $line =~ ^[[:space:]]*(-[[:space:]]+)?[\"\'][^\"\']*[\"\'][[:space:]]*: ]]; then + report "$cf:$ln" "quoted key in a Compose file (not supported: it would hide a key from the checks)" "$line" + fi + if [[ $line =~ (^|[^\$])\{[[:space:]]*[^}[:space:]] ]]; then + report "$cf:$ln" "flow mapping in a Compose file (only {} is supported)" "$line" + fi + # Long-syntax mounts and other path keys. + if [[ $line =~ ^[[:space:]]*(-[[:space:]]+)?(target|destination|dst|source|src|file|dockerfile):[[:space:]]*[\"\']?([^\"\'[:space:]]*) ]]; then + t=${BASH_REMATCH[3]} + case $t in + */* | .*) + case $t in *'//'* | *'/./'* | *'..'* | */.) + report "$cf:$ln" "path with //, /./ or .. in a Compose file (write it in canonical form)" "$line" ;; + esac + ;; + esac + case ${BASH_REMATCH[2]} in + target | destination | dst) + t=${t%/} + case $t in + '' | /etc | /etc/caddy | /etc/caddy/* | /usr | /usr/local | /bin | /sbin | /usr/bin | /usr/sbin | /usr/local/bin | /usr/local/sbin | /usr/bin/* | /usr/local/bin/* | /bin/*) + case $t in '' | /*) report "$cf:$ln" "long-syntax mount over /etc/caddy, the Caddy binary or a directory that holds them" "$line" ;; esac ;; + esac + ;; + esac + fi # A mount over /, /etc, /usr or a bin directory replaces /etc/caddy or # the binary without naming them. if [[ $line =~ ^[[:space:]]*-[[:space:]]*[\"\']?[^:\"\'[:space:]]+:(/[^:\"\'[:space:]]*)(:[a-z,]+)?[\"\']?[[:space:]]*$ ]]; then t=${BASH_REMATCH[1]} + canonical_abs "$t" || [ "$t" = / ] || report "$cf:$ln" "mount target must be a canonical absolute path (no //, . or ..)" "$line" t=${t%/} case $t in '' | /etc | /usr | /usr/local | /bin | /sbin | /usr/bin | /usr/sbin | /usr/local/bin | /usr/local/sbin | /usr/bin/* | /usr/local/bin/* | /bin/*) @@ -404,7 +455,8 @@ svc_has() { local f=$1 o=$2 key=$3 pat=${4-*} k m anchors='' for ((k = 0; k < ${#CK_KEY[@]}; k++)); do [ "${CK_FILE[$k]}" = "$f" ] && [ "${CK_OWNER[$k]}" = "$o" ] && [ "${CK_KEY[$k]}" = '<<' ] && - anchors="$anchors ${CK_VAL[$k]//\*/&}" + # "*name" -> "&name" without & in a replacement (bash >= 5.2 expands it). + anchors="$anchors &${CK_VAL[$k]#\*}" done for ((k = 0; k < ${#CK_KEY[@]}; k++)); do [ "${CK_FILE[$k]}" = "$f" ] && [ "${CK_KEY[$k]}" = "$key" ] || continue @@ -423,10 +475,24 @@ svc_has() { done return 1 } +# Dockerfiles with an edge runtime stage; a Compose service that builds one +# (or a Dockerfile the guard does not know) is an edge service. +EDGE_DOCKERFILES=' ' +for ((k = 0; k < ${#ST_ID[@]}; k++)); do + is_runtime_stage "${ST_ID[$k]}" && EDGE_DOCKERFILES="$EDGE_DOCKERFILES${ST_ID[$k]%#*} " +done +edge_dockerfile() { # dockerfile value from Compose + local v=${1#./} + v=${v#\"} v=${v%\"} v=${v#\'} v=${v%\'} + v=${v#./} + case $EDGE_DOCKERFILES in *" $v "*) return 0 ;; esac + is_tracked "$v" || return 0 + return 1 +} EDGE_NAMES=' ' for ((k = 0; k < ${#CK_KEY[@]}; k++)); do case ${CK_OWNER[$k]} in '&'*) continue ;; esac - if svc_has "${CK_FILE[$k]}" "${CK_OWNER[$k]}" dockerfile '*docker/edge/Dockerfile*' || + if { svc_has "${CK_FILE[$k]}" "${CK_OWNER[$k]}" dockerfile && edge_dockerfile "$SVC_VAL"; } || svc_has "${CK_FILE[$k]}" "${CK_OWNER[$k]}" image '*caddy*'; then case $EDGE_NAMES in *" ${CK_OWNER[$k]} "*) ;; *) EDGE_NAMES="$EDGE_NAMES${CK_OWNER[$k]} " ;; esac fi @@ -438,7 +504,7 @@ for ((k = 0; k < ${#CK_KEY[@]}; k++)); do case " ${CHECKED_SVC-} " in *" $f:$o "*) continue ;; esac CHECKED_SVC="${CHECKED_SVC-} $f:$o" edge=no - if svc_has "$f" "$o" dockerfile '*docker/edge/Dockerfile*' || svc_has "$f" "$o" image '*caddy*'; then + if { svc_has "$f" "$o" dockerfile && edge_dockerfile "$SVC_VAL"; } || svc_has "$f" "$o" image '*caddy*'; then edge=yes elif ! svc_has "$f" "$o" image && ! svc_has "$f" "$o" dockerfile; then case $EDGE_NAMES in *" $o "*) edge=yes ;; esac diff --git a/scripts/ci/tests/guard-edge-config.test.sh b/scripts/ci/tests/guard-edge-config.test.sh index c64fcd5..4b35d55 100755 --- a/scripts/ci/tests/guard-edge-config.test.sh +++ b/scripts/ci/tests/guard-edge-config.test.sh @@ -27,7 +27,7 @@ TAB=$'\t' CR=$'\r' REAL=(docker/edge/Dockerfile docker/edge/Caddyfile.dev docker/edge/Caddyfile.prod docker/edge/common.caddy tests/app/https/Caddyfile tests/app/https/compose.https.yaml compose.yaml compose.prodlike.yaml compose.test.yaml - compose.fixture.yaml tests/app/scan.sh) + compose.fixture.yaml tests/app/scan.sh docker/api/Dockerfile docker/db/Dockerfile docker/web/Dockerfile) P=docker/edge/Caddyfile.prod D=docker/edge/Caddyfile.dev C=docker/edge/common.caddy @@ -420,6 +420,48 @@ base subst "$DF" "RUN npm run build" "RUN npm run build && cp /app/dist/index.html /tmp/x" expect "RUN in a non-edge stage (spa-build) is not restricted" 0 +# --- Re-audit (FCRM-SEC-042 E, HIGH-R1 and LOW-R1) ---------------------------------------- +base +put docker/edge/launcher "#!/bin/sh${NL}exec /usr/bin/caddy run --config /srv/e.conf --adapter caddyfile${NL}" +subst "$DF" "USER 65534:65534" "WORKDIR /usr/local/bin${NL}COPY --chmod=755 docker/edge/launcher ./caddy${NL}WORKDIR /${NL}USER 65534:65534" +expect "E workdir-dest: WORKDIR in an edge stage" 1 "WORKDIR in an edge runtime stage" +base +put docker/edge/launcher "#!/bin/sh${NL}exec /usr/bin/caddy run --config /srv/e.conf --adapter caddyfile${NL}" +subst "$DF" "USER 65534:65534" "COPY --chmod=755 docker/edge/launcher ./caddy${NL}USER 65534:65534" +expect "relative COPY destination in an edge stage" 1 "needs an absolute literal destination" +base +put docker/edge/launcher "#!/bin/sh${NL}exec /usr/bin/caddy run --config /srv/e.conf --adapter caddyfile${NL}" +subst "$DF" "USER 65534:65534" "ARG D=/usr/local/bin${NL}COPY --chmod=755 docker/edge/launcher \${D}/caddy${NL}USER 65534:65534" +expect "E arg-dest: ARG-built COPY destination" 1 "needs an absolute literal destination" +bad "COPY to /etc//caddy/Caddyfile (non-canonical)" "needs an absolute literal destination" "$DF" "COPY docker/edge/Caddyfile.prod /etc/caddy/Caddyfile" "COPY docker/edge/Caddyfile.prod /etc/caddy/Caddyfile${NL}COPY docker/edge/no-gate/README.md /etc//caddy/Caddyfile" +bad "ONBUILD in an edge stage" "ONBUILD in an edge runtime stage" "$DF" "USER 65534:65534" "ONBUILD RUN true${NL}USER 65534:65534" +bad "E long-mount-bin: long-syntax mount over /usr/local/bin" "long-syntax mount over" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - type: bind${NL} source: ./tests/app/https/attacker${NL} target: /usr/local/bin" +bad "long-syntax mount over /etc" "long-syntax mount over" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - type: bind${NL} source: ./tests/app/https/attacker${NL} target: /etc" +bad "short mount target /usr//bin (non-canonical)" "canonical absolute path" "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - ./tests/app/https/attacker:/usr//bin:ro" +bad "E quoted-key-launcher: quoted \"entrypoint\" key" "quoted key in a Compose file" "$HC" " command: [" " \"entrypoint\": [\"/srv/l/launch\"]${NL} command: [" +bad "quoted key in single quotes" "quoted key in a Compose file" "$HC" " command: [" " 'entrypoint': ['/bin/sh']${NL} command: [" +base +put compose.override.yaml "services:${NL} edge: {entrypoint: [\"/bin/sh\", \"-c\", \"exec /usr/bin/caddy run --resume\"]}${NL}" +expect "E flow-service: edge service as a flow mapping" 1 "flow mapping in a Compose file" +base +put compose.extra.yaml "services:${NL} edge2:${NL} build:${NL} context: .${NL} dockerfile: docker//edge/Dockerfile${NL} entrypoint: [\"sh\"]${NL}" +expect "E dslash-dockerfile: docker//edge/Dockerfile (block style)" 1 "path with //, /./ or .. in a Compose file" +base +put compose.extra.yaml "services:${NL} edge2:${NL} build: {context: ., dockerfile: docker/edge/Dockerfile}${NL}" +expect "build as a flow mapping" 1 "flow mapping in a Compose file" +base +mkdir -p "$repo/docker/edge2" +printf '%s\n' "# syntax=docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e" "FROM caddy:2.11.7-alpine" "USER 65534:65534" >"$repo/docker/edge2/Dockerfile" +git -C "$repo" add -f docker/edge2/Dockerfile || exit 2 +put compose.extra.yaml "services:${NL} proxy:${NL} build:${NL} context: .${NL} dockerfile: docker/edge2/Dockerfile${NL} command: [\"edge\", \"run\"]${NL}" +expect "service building another Dockerfile with a caddy stage" 1 "command of the edge service proxy must be exactly" +base +put compose.extra.yaml "services:${NL} proxy:${NL} build:${NL} context: docker/edge${NL} dockerfile: Dockerfile${NL} command: [\"edge\", \"run\"]${NL}" +expect "service building an untracked or context-relative Dockerfile is treated as edge" 1 "command of the edge service proxy must be exactly" +base +subst compose.yaml " networks: [front, back]" " networks: [front, back]${NL} entrypoint: [\"sh\"]" +expect "HIGH-R1: <<: anchor merge resolves (edge with merged hardening)" 1 "entrypoint of the edge service edge" + # --- Globs that match the binary's name --------------------------------------------------- base put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm img sh -c 'cp /usr/bin/cadd? /tmp/e && /tmp/e run --resume'${NL}" From e5fa201b9720a3851404d26aa0e68b56b23cad76 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:42:42 +0100 Subject: [PATCH 19/25] docs(security): state the edge guard threat model and remaining evasions The guard subsection and ADR-021 now say what the checks are for: they stop accidental and casual unsafe changes and make deliberate ones visible in review. A committer can also change the guards, so review of docker/edge, tests/app/https, Compose and scripts/ci changes remains required, and these paths are review triggers. They also list the new Dockerfile and Compose rules, and the evasions left open: commands assembled at run time, docker run --entrypoint in scripts, Compose merging, edge images used under a tag without "caddy", plugins and apk package contents. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 25 ++++++---- docs/security/container-vulnerabilities.md | 48 +++++++++++++------ 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 22ac2db..f1781ba 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -90,11 +90,17 @@ finding, so this finding blocks every pull request and `main`. - edge service commands in Compose other than the exact `caddy run` form, entrypoints, YAML escapes, and mounts over `/etc` or the binary; - - edge Dockerfile stages that could link, copy or rename the binary. + - edge Dockerfile stages that could link, copy, rename or shadow the + binary (RUN other than `apk add`, WORKDIR, relative or variable + COPY destinations, anything else in a bin directory, PATH); + - quoted keys, flow mappings and non-canonical paths in Compose + files, and long-syntax mounts over `/etc` or a bin directory. Unknown Caddyfile constructs fail. Shell, YAML and Dockerfile text is - read line by line. The guard catches ordinary and accidental changes, - but not a deliberate evasion assembled at run time (see Consequences). + read line by line. Threat model: the guards stop accidental and casual + unsafe changes and make deliberate ones visible in review. A committer + can also change the guard itself, so they are not a boundary against + one (see Consequences). - **Adapted-configuration check**, in `tests/app/scan.sh`. It runs `caddy adapt` with the built binary in the built edge images and on the HTTPS test edge, and checks the JSON: the admin API is disabled, @@ -128,9 +134,12 @@ finding, so this finding blocks every pull request and `main`. sets `CADDY_ADMIN` or a `{$VAR}` value, `docker cp`). They do not see `docker run --entrypoint` with another executable in a script outside Compose, a Caddy plugin added to `docker/edge/caddy/main.go`, or what an - `apk add` package installs. The adapted check does not apply Compose - mounts. These remain review items: every change under `docker/edge`, - `tests/app/https` or a Compose file is a review trigger below. + `apk add` package installs. They do not recognise a service that runs an + already built edge image under a tag without "caddy". The adapted check + does not apply Compose mounts. A committer can also edit the guards. So + review of every change under `docker/edge`, `tests/app/https`, the Compose + files and `scripts/ci` remains required, and such a change is a review + trigger below. - **Expiry:** on 2026-11-30 Trivy stops applying the entry, and the exception guard fails the `Repository guard` job, so every pull request is blocked until the exception is renewed after a new review or removed. @@ -145,8 +154,8 @@ finding, so this finding blocks every pull request and `main`. - a Go vulnerability database entry with affected symbols, for a govulncheck check; - a severity change; - - any change under `docker/edge` or `tests/app/https`, to the edge - service in a Compose file, or a new Caddy plugin; + - any change under `docker/edge` or `tests/app/https`, to a Compose file, + to `scripts/ci`, or a new Caddy plugin; - a Trivy upgrade; - a deployment beyond the loopback-only development and test stacks. - **Exit criteria:** go-jose v3 is gone from `go version -m usr/bin/caddy`, diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 34cc3c1..80f366a 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -294,9 +294,14 @@ Trivy upgrade (the exception file format is experimental in Trivy). ### Edge configuration guard The finding is acceptable only while no edge configuration runs the code -that parses untrusted JWS/JWK. Two checks enforce this. They catch -ordinary and accidental changes and the obfuscations listed below, not every -deliberate evasion (see "Known limits"): +that parses untrusted JWS/JWK. Two checks enforce this. + +Threat model: the checks stop accidental and casual unsafe changes, and +they make deliberate ones visible in review, because such a change has to +use one of the forms listed under "Known limits". They are not a security +boundary against a committer, who can also change the guard itself. Review +of every change under `docker/edge`, `tests/app/https`, the Compose files +and `scripts/ci` therefore remains required. - `scripts/ci/guard-edge-config.sh` (job `Repository guard`, regression suite `scripts/ci/tests/guard-edge-config.test.sh`) reads every tracked @@ -329,20 +334,25 @@ deliberate evasion (see "Known limits"): with `--resume`, `--watch`, `--envfile` or an unknown flag; `CADDY_ADMIN` anywhere; a glob in a command line that matches the binary's name (`cadd?`); - - an edge service in Compose (it builds `docker/edge/Dockerfile`, runs a - caddy image, directly or through a `<<: *anchor` merge, or overrides - such a service) with an `entrypoint`, `extends`, or a `command` other - than exactly + - an edge service in Compose (it builds a Dockerfile with an edge runtime + stage or a Dockerfile the guard does not know, runs a caddy image, + directly or through a `<<: *anchor` merge, or overrides such a service) + with an `entrypoint`, `extends`, or a `command` other than exactly `["caddy", "run", "--config", "/etc/caddy/", "--adapter", "caddyfile"]`; - a backslash anywhere in a Compose file (YAML escapes such as - `\u0063addy` would hide a command or a path); a mount over `/`, `/etc`, - `/usr` or a bin directory; + - in Compose files: a backslash anywhere (YAML escapes such as + `\u0063addy` would hide a command or a path); quoted keys; flow + mappings other than `{}`; paths with `//`, `/./` or `..`; a short or + long-syntax (`target:`) mount over `/`, `/etc`, `/etc/caddy`, `/usr` or + a bin directory, other than the read-only short mounts of tracked + files into `/etc/caddy`; - in the edge runtime stages of the Dockerfile (from the caddy image or a stage with files in `/etc/caddy`, and every stage built on them): a `RUN` other than a plain `apk add --no-cache `, a copy of the binary anywhere but `COPY --from= /caddy /usr/bin/caddy`, - anything else in a bin directory, and `ENV PATH` or `SHELL`, so the - binary cannot be linked, copied or renamed; + anything else in a bin directory, a `COPY`/`ADD` destination that is + not an absolute literal path (relative to `WORKDIR`, built from an + `ARG`, or with `//`, `.` or `..`), `WORKDIR`, `ONBUILD`, and `ENV PATH` + or `SHELL`, so the binary cannot be linked, copied, renamed or shadowed; - a Caddy file that no start point uses, and a tracked JSON file that looks like a Caddy JSON configuration. - `tests/app/scan.sh` (job `Dev environment`) runs `caddy adapt` with the @@ -356,15 +366,23 @@ deliberate evasion (see "Known limits"): has `automatic_https.disable`. A control image with an unsafe configuration must be rejected. -Known limits. The guard reads Caddyfiles like Caddy does, but shell, YAML -and Dockerfile text only line by line, and some evasions remain open: +Known limits. The guard reads Caddyfiles like Caddy does. Shell, YAML and +Dockerfile text it reads only line by line (block-style YAML with plain +keys; anything else is rejected). The following evasions remain open +deliberately: - commands and paths assembled at run time: shell variables, `eval`, `$(...)` in scripts, an `env_file:` that sets `CADDY_ADMIN` or a `{$VAR}` value, `docker cp` or `docker exec` with a variable command; - `docker run --entrypoint ` in a script, outside Compose (only caddy command words and globs that match its name are checked there); - Compose files are not merged as Compose merges them. Bind mounts count - for every edge stage, and an override is recognised by its service name; + for every edge stage, and an override is recognised by its service name. + A service that runs an already built edge image under a tag without + "caddy" (`image: flowcrm-edge:x`) is not recognised as an edge service; +- executables the guard does not read: a wrapper script without a `.sh` + extension outside `bin/` is only a risk when something starts it. Compose + edge commands and edge Dockerfile commands cannot, but a script's + `docker run --entrypoint` can (see above); - a Caddy plugin added to `docker/edge/caddy/main.go`, or a package an `apk add` installs. From 6364e82f80abdd374e78344aedf84fb17f7d56d4 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:22:34 +0100 Subject: [PATCH 20/25] fix(ci): compare exception packages without regard to case Trivy matches the namespace of a Go purl whatever its case, so pkg:golang/github.com/Go-Jose/go-jose/v3 under a second id passed the "one entry per package" check although Trivy treats it as the excepted package. The guard now compares the package part of every purl (type, namespace and name) in lower case, both within an entry and across entries. Comparing in lower case can only merge packages, never split one, so it errs towards rejecting. The header comment also records that alias rejection relies on the Exceptions table of the docs and that edits to it are checked in review. New regression cases cover the case variants and values containing & (special in a bash 5.2 pattern substitution; the guard uses none). The suite has 87 cases and passes under bash 3.2 and bash 5.2. --- scripts/ci/guard-trivyignore.sh | 20 +++++++++++++------- scripts/ci/tests/guard-trivyignore.test.sh | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/ci/guard-trivyignore.sh b/scripts/ci/guard-trivyignore.sh index b11f6c1..8939c46 100755 --- a/scripts/ci/guard-trivyignore.sh +++ b/scripts/ci/guard-trivyignore.sh @@ -16,7 +16,9 @@ # name the same package (type, namespace and name), so one entry cannot # cover a second package such as another major version of a Go module; # - one entry per package: no two entries name the same package (purl -# without its version), whatever their ids; +# without its version), whatever their ids. Packages are compared +# without regard to case, as Trivy matches a Go module path whatever its +# case; # - the affected path is named exactly: "paths" with at least one entry; # neither list has a wildcard (Trivy matches paths as doublestar # patterns, so * ? [ ] { } are all rejected); @@ -36,7 +38,9 @@ # day, and the gate fails on the finding again); # - the entry is listed in docs/security/container-vulnerabilities.md: # its id starts a row of the table under "## Exceptions" (a mention -# elsewhere in the document does not count). +# elsewhere in the document does not count). The alias check knows only +# the aliases that table lists, so editing its first column can bypass +# it; such an edit is visible in review and must be checked there. # Only the documented form is accepted (see the example in the file): # "vulnerabilities: []", or "vulnerabilities:" followed by entries of # " - id: ..." and " : " lines, with full-line comments. @@ -173,18 +177,20 @@ finish_entry() { } # check_purls : every purl is exact and all name one package. -# Sets pkg to the package of the entry. +# Sets pkg to the package of the entry, in lower case (Trivy matches the +# namespace of a Go purl whatever its case, so case must not make two +# packages out of one). check_purls() { - local rest=$2 purl ok=yes + local rest=$2 purl ok=yes name pkg='' while [[ $rest =~ ^[^\"]*\"([^\"]*)\"(.*)$ ]]; do purl=${BASH_REMATCH[1]} rest=${BASH_REMATCH[2]} if [[ ! $purl =~ $purl_re ]]; then report "$1" "purl is not pkg:/@ without qualifiers, subpath or encoding" "$purl" ok='' - elif [ -z "$pkg" ]; then - pkg=${purl%@*} - elif [ "${purl%@*}" != "$pkg" ]; then + elif name=$(printf '%s' "${purl%@*}" | tr '[:upper:]' '[:lower:]') && [ -z "$pkg" ]; then + pkg=$name + elif [ "$name" != "$pkg" ]; then report "$1" "purls name more than one package (one package per entry)" "$2" ok='' fi diff --git a/scripts/ci/tests/guard-trivyignore.test.sh b/scripts/ci/tests/guard-trivyignore.test.sh index 8f4c100..a34117c 100755 --- a/scripts/ci/tests/guard-trivyignore.test.sh +++ b/scripts/ci/tests/guard-trivyignore.test.sh @@ -209,6 +209,22 @@ expect_error "the same package and version under two ids" "package already excep "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" expect "two ids for two different packages" 0 \ "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J4@v4.1.5")${NL}" "$ALIAS_DOC" +# Trivy matches the namespace of a Go purl whatever its case (re-audit +# INFO-R1), so packages are compared in lower case. +J3_UPPER=pkg:golang/github.com/Go-Jose/go-jose/v3 +expect_error "the same package in another case under a second id" "package already excepted by the entry at line 2" \ + "vulnerabilities:${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}$(jose CVE-2026-0001 "$J3_UPPER@v3.0.5")${NL}" "$ALIAS_DOC" +expect_error "the same package in upper case first, lower case second" "package already excepted" \ + "vulnerabilities:${NL}$(jose CVE-2026-0001 "pkg:golang/GitHub.com/Go-Jose/Go-Jose/V3@v3.0.4")${NL}$(jose CVE-2026-116587 "$J3@v3.0.5")${NL}" "$ALIAS_DOC" +expect "one package in two cases within one entry" 0 \ + "vulnerabilities:${NL}$(entry CVE-2026-116587 2026-11-30 ' paths: ["usr/bin/caddy"]' "$STATEMENT" "$(purls "$J3@v3.0.5" "$J3_UPPER@v3.0.4")")${NL}" "$ALIAS_DOC" +# Characters that bash 5.2 treats specially in ${x//pattern/replacement} +# (patsub_replacement: &) must not change what the guard reads. +# shellcheck disable=SC2016 # the backtick is literal fixture text +expect "a statement with & and a backtick" 0 \ + "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: ["a"]' ' statement: "Not reachable & not run (see `docs` & ADR-021)."')${NL}" +expect_error "an & in a purl" "purl is not" "vulnerabilities:${NL}$(go_jose "$(purls "$J3@v3.0.5&x")")${NL}" +expect_error "an & in an id" "id is not one CVE or GHSA identifier" "vulnerabilities:${NL}$(entry 'CVE-2026-0001&' 2026-12-01)${NL}" expect_error "an empty paths list" "paths must be a list" "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: []')${NL}" expect_error "a blank path only" "paths must be a list" "vulnerabilities:${NL}$(entry CVE-2026-0001 2026-12-01 ' paths: [" "]')${NL}" mkdir -p "$work/nofile" && git init -q "$work/nofile" || exit 2 From 84c443dc449748cb27f8d0effa0e38041fcf87d5 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 15:22:34 +0100 Subject: [PATCH 21/25] docs(security): state the review-dependent limit of the alias check The guard rejects an alias id only if the Exceptions table lists it, so removing an alias from a row or adding a row for it lets an entry under the alias pass. Document that every change to the first column of the table is checked against the advisory in review, in the exception policy and in ADR-021, and that packages are compared without regard to case. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 6 ++++-- docs/security/container-vulnerabilities.md | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index f1781ba..e7c5497 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -111,8 +111,10 @@ finding, so this finding blocks every pull request and `main`. entry must name its path and exactly one package with an exactly pinned purl; no package may appear in two entries, and an id that the exceptions table lists as an alias (the GHSA id of this CVE) is - rejected, because Trivy matches only the id it reports. An entry that - expires on or before the check date fails the guard. + rejected, because Trivy matches only the id it reports. The alias + check relies on the table, so a change to its first column is checked + in review. An entry that expires on or before the check date fails + the guard. - **Visibility:** the scan template prints the suppressed finding as `SUPPRESSED` in the edge-prod scan log. The edge `dev` report scan does not use the ignore file and keeps listing it. diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 80f366a..8c29f0d 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -41,8 +41,10 @@ downloaded on every run. percent-encoding), and all purls of an entry naming the same package (type, namespace and name), so one entry cannot also cover another package such as a second major version of a Go module; no two entries naming the - same package (purl without its version), whatever their ids, so one - package is never excepted under a second id or alias; `paths` with at + same package (purl without its version, compared without regard to case, + because Trivy matches a Go module path whatever its case), whatever their + ids, so one package is never excepted under a second id or alias; + `paths` with at least one value (without `paths` Trivy would apply the entry at any path in every gated image); non-blank values without wildcards in both lists (Trivy matches `paths` as glob patterns, so `*`, `?`, `[`, `]`, `{` @@ -63,6 +65,11 @@ downloaded on every run. inner double quote in a quoted value (an escape could add a wildcard or continue the statement over `expired_at`). Its regression suite is `scripts/ci/tests/guard-trivyignore.test.sh`. + Limit that depends on review: the guard knows only the aliases the + [Exceptions](#exceptions) table lists. Removing an alias from a row, or + adding a row for it, lets an entry under that alias pass the guard, so + every change to the first column of the table must be checked against the + advisory in review (the change is visible in the diff of this document). - The scan template prints every finding an exception suppressed as a `SUPPRESSED ignored ` line in the log of the gate scans (runtime images and the control scan), so an excepted From 2d2a6c6a5e6a82b5c1bb3f57e3d1a5d2a3616fcc Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 16:23:16 +0100 Subject: [PATCH 22/25] docs(security): list a renamed second Caddy binary as a known limit of the edge guard --- docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md | 5 +++-- docs/security/container-vulnerabilities.md | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index e7c5497..9d3767f 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -135,8 +135,9 @@ finding, so this finding blocks every pull request and `main`. paths assembled at run time (shell variables, `eval`, an `env_file` that sets `CADDY_ADMIN` or a `{$VAR}` value, `docker cp`). They do not see `docker run --entrypoint` with another executable in a script outside - Compose, a Caddy plugin added to `docker/edge/caddy/main.go`, or what an - `apk add` package installs. They do not recognise a service that runs an + Compose, a second Caddy binary copied under another name into a new + Dockerfile and started with its own configuration, a Caddy plugin added + to `docker/edge/caddy/main.go`, or what an `apk add` package installs. They do not recognise a service that runs an already built edge image under a tag without "caddy". The adapted check does not apply Compose mounts. A committer can also edit the guards. So review of every change under `docker/edge`, `tests/app/https`, the Compose diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 8c29f0d..dbfef1d 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -390,6 +390,10 @@ deliberately: extension outside `bin/` is only a risk when something starts it. Compose edge commands and edge Dockerfile commands cannot, but a script's `docker run --entrypoint` can (see above); +- a second Caddy under another name in a new Dockerfile: a stage that + copies the binary out of a Caddy image (`COPY --from=caddy:… /usr/bin/caddy + /opt/x/web`) and starts it with its own configuration is not an edge + stage, and `tests/app/scan.sh` does not scan that image; - a Caddy plugin added to `docker/edge/caddy/main.go`, or a package an `apk add` installs. From e82ac33863f81d01f3e52e15047c6dd6fbbee84b Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 17:24:54 +0100 Subject: [PATCH 23/25] docs(adr): record the owner's acceptance of ADR-021 The owner accepted ADR-021 Option A on 2026-10-28: the temporary exception for CVE-2026-116587 in go-jose/v3 v3.0.5 (usr/bin/caddy only, until 2026-11-30) with the edge configuration guard, the stricter exception guard and visible reporting as conditions. Records the scope, the conditions and the removal procedure, and updates the exceptions table and the exception statement. The vulnerable code is not fixed. --- .../adr/ADR-021-edge-go-jose-v3-exception.md | 27 ++++++++++++++++--- docs/security/container-vulnerabilities.md | 10 +++---- security/trivyignore.yaml | 2 +- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md index 9d3767f..d3f7ac7 100644 --- a/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -1,8 +1,9 @@ # ADR-021: Temporary exception for go-jose v3 in the edge Caddy binary -- **Status:** proposed (FCRM-SEC-042), **not accepted**. The owner decides. - Until it is accepted, the exception in `security/trivyignore.yaml` must not - reach `main`. +- **Status:** accepted by the owner on 2026-10-28 (FCRM-SEC-042M), Option A + with the conditions under "Acceptance". Proposed in FCRM-SEC-041 and + FCRM-SEC-042. The vulnerable code is not fixed; the decision accepts a + temporary risk. - **Related:** [container vulnerabilities](../../security/container-vulnerabilities.md) (exception policy, CVE-2026-116587 triage, edge configuration guard), ADR-017 (edge routing) @@ -54,7 +55,7 @@ Reachability in FlowCRM: The runtime gate (`tests/app/scan.sh`) fails on every HIGH or CRITICAL finding, so this finding blocks every pull request and `main`. -## Decision (proposed) +## Decision 1. Upgrade go-jose v4 to 4.1.6 (PR #41). v4 gets no exception. 2. Add one temporary exception to `security/trivyignore.yaml` that matches @@ -122,6 +123,24 @@ finding, so this finding blocks every pull request and `main`. vulnerability document, which states that it does not fix the vulnerable code. +## Acceptance + +Accepted by the owner on 2026-10-28 with the following scope and conditions: + +- **Scope:** only CVE-2026-116587 in + `pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`, path `usr/bin/caddy`, + in the edge runtime image, until 2026-11-30. go-jose v4, other v3 + versions, other packages, other binaries and every other HIGH or CRITICAL + finding stay blocking. The finding stays visible in the scan logs. +- **Conditions:** the compensating controls above stay in place and pass in + CI. The exception is reassessed immediately on any review trigger listed + under Consequences, and before it expires. +- **Removal:** when an exit criterion is met, delete the entry from + `security/trivyignore.yaml` and its row from the exceptions table, and + mark this ADR superseded. Renewing it after the expiry needs a new review + and a new owner decision. Without either, the exception guard and the + gate fail from 2026-11-30. + ## Consequences - **The vulnerable code stays in the binary.** The exception only stops the diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index dbfef1d..1184ec4 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -95,7 +95,7 @@ exception; the vulnerable code is still in the image. | Id | Package and path | Image | Fixed in | Reachable here | Expires | Decision | |---|---|---|---|---|---|---| -| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | `github.com/go-jose/go-jose/v3` v3.0.5 (`pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`) in `usr/bin/caddy` | edge (`prod`; `dev` is report-only) | none for v3 (v4 fixed in 4.1.6 and upgraded, not excepted) | no: no `acme_server`, `pki` app or `tls internal`, admin API off | 2026-11-30 (gate fails again from 00:00 UTC that day) | [ADR-021](../architecture/adr/ADR-021-edge-go-jose-v3-exception.md), **proposed, not accepted** ([details](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge)) | +| CVE-2026-116587 (GHSA-mgcc-cww5-fp2q) | `github.com/go-jose/go-jose/v3` v3.0.5 (`pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5`) in `usr/bin/caddy` | edge (`prod`; `dev` is report-only) | none for v3 (v4 fixed in 4.1.6 and upgraded, not excepted) | no: no `acme_server`, `pki` app or `tls internal`, admin API off | 2026-11-30 (gate fails again from 00:00 UTC that day) | [ADR-021](../architecture/adr/ADR-021-edge-go-jose-v3-exception.md), **accepted by the owner on 2026-10-28** ([details](#cve-2026-116587--ghsa-mgcc-cww5-fp2q-go-jose-edge)) | ## Triage of the FCRM-FND-004V findings @@ -271,10 +271,10 @@ untrusted input (the JWS of an ACME request) to the vulnerable parser; from its own configuration. With `admin off` the configuration cannot be changed at run time. -**Exception (proposed, not accepted):** `security/trivyignore.yaml` excepts -this id for `go-jose/v3` v3.0.5 in `usr/bin/caddy` only, until 2026-11-30 -(see [Exceptions](#exceptions); decision proposed in ADR-021). It does not -fix the vulnerable code: go-jose v3 3.0.5 stays in the edge binary, and the +**Exception (accepted 2026-10-28, ADR-021):** `security/trivyignore.yaml` +excepts this id for `go-jose/v3` v3.0.5 in `usr/bin/caddy` only, until +2026-11-30 (see [Exceptions](#exceptions); decision in ADR-021). It does +not fix the vulnerable code: go-jose v3 3.0.5 stays in the edge binary, and the exception only rests on the configuration above, which the edge configuration guard is to enforce. Scope, verified with Trivy 0.67.2 and the flags of `tests/app/scan.sh` (FCRM-SEC-042): diff --git a/security/trivyignore.yaml b/security/trivyignore.yaml index 478e9a8..667a9fc 100644 --- a/security/trivyignore.yaml +++ b/security/trivyignore.yaml @@ -27,5 +27,5 @@ vulnerabilities: - id: CVE-2026-116587 paths: ["usr/bin/caddy"] purls: ["pkg:golang/github.com/go-jose/go-jose/v3@v3.0.5"] - statement: "GHSA-mgcc-cww5-fp2q, go-jose/v3 v3.0.5 in the edge Caddy binary: no fixed v3 release exists (go-jose/v4 is upgraded to 4.1.6 and not covered). Not reachable: no acme_server, pki app or tls internal is configured and the admin API is off, enforced by the edge configuration guard. Decision proposed in ADR-021 (not accepted yet). Review on a fixed v3 release, smallstep or Caddy dropping v3, any change to docker/edge or tests/app/https, a severity change, a Trivy upgrade, or the expiry." + statement: "GHSA-mgcc-cww5-fp2q, go-jose/v3 v3.0.5 in the edge Caddy binary: no fixed v3 release exists (go-jose/v4 is upgraded to 4.1.6 and not covered). Not reachable: no acme_server, pki app or tls internal is configured and the admin API is off, enforced by the edge configuration guard. Accepted by the owner in ADR-021 on 2026-10-28. Review on a fixed v3 release, smallstep or Caddy dropping v3, any change to docker/edge or tests/app/https, a severity change, a Trivy upgrade, or the expiry." expired_at: 2026-11-30 From 3ae029273dc8bd3f5353df028f628f0736303b42 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 18:43:19 +0100 Subject: [PATCH 24/25] fix(ci): reject YAML aliases, interpolation and parser directives that evade the edge guard The SEC-042M audit found evasions that pass the edge guard and take effect in docker compose config or docker build. Compose files now accept only the YAML features the repository uses: - anchors only on top-level x-* keys; - aliases only as "<<: *name" and "logging: *name" (no alias lists, no alias list items); - tags only !reset and !override; - no tabs and no document markers; - ${VAR} or $VAR only in ports, in the source of a short volume mount with a literal target, and in the file of a top-level secret. Compose configs, content, dockerfile_inline and secrets from the environment are rejected. This covers B1 (alias mount over /etc), B2/B3 (interpolated mount targets), B9 (aliased or interpolated caddy image with inline configs), B10 (dockerfile_inline) and B15 (merge list adding an entrypoint). Dockerfiles may not use a parser directive other than syntax=, because escape= changes line continuation and hid a RUN from the guard (B6). Edge runtime stages reject ADD, and only copy to a file in /etc/caddy, to /usr/bin/caddy or below /srv, so a COPY to / or /etc is rejected (L-1 B4, B5). Regression fixtures cover every reproducer and each new rule. --- scripts/ci/guard-edge-config.sh | 105 ++++++++++++++++++++- scripts/ci/tests/guard-edge-config.test.sh | 69 ++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/scripts/ci/guard-edge-config.sh b/scripts/ci/guard-edge-config.sh index 6c2bda8..482e460 100755 --- a/scripts/ci/guard-edge-config.sh +++ b/scripts/ci/guard-edge-config.sh @@ -38,10 +38,16 @@ # Compose files contain no backslash (YAML escapes), no quoted key, no # flow mapping but {}, no path with //, /./ or .., and no short or # long-syntax (target:) mount over /, /etc, /usr or a bin directory; +# anchors only on top-level x-* keys, aliases only as "<<: *name" and +# "logging: *name", tags only !reset/!override, ${VAR} only in ports, a +# short volume source and a top-level secret file, and no configs, +# content, dockerfile_inline or secrets from the environment; # - edge runtime stages of a Dockerfile only RUN "apk add --no-cache # ", copy the binary only to /usr/bin/caddy, put nothing else # in a bin directory, use only absolute literal COPY/ADD destinations, -# no WORKDIR or ONBUILD, and do not change PATH or SHELL; command lines +# no WORKDIR, ONBUILD or ADD, copy only to /etc/caddy/, +# /usr/bin/caddy or below /srv, and do not change PATH or SHELL; no +# Dockerfile has a parser directive other than syntax=; command lines # contain no glob that matches the binary's name; # - no tracked Caddy file is unused by these start points, and no # tracked JSON file looks like a Caddy JSON configuration. @@ -217,6 +223,23 @@ words() { ST_FROM=() DL_STAGE=() DL_WHERE=() DL_INSTR=() DL_TEXT=() for df in ${DOCKERFILES[@]+"${DOCKERFILES[@]}"}; do + # Parser directives (# key=value before the first instruction) change how + # BuildKit reads the file (escape= changes line continuation): only + # syntax= is accepted, so the guard reads the file as BuildKit does. + awk '{ sub(/\r$/, ""); if (NR == 1) sub(/^\xef\xbb\xbf/, "") } + /^[[:space:]]*$/ { next } + /^[[:space:]]*#/ { + t = $0; sub(/^[[:space:]]*#[[:space:]]*/, "", t) + if (t ~ /^[A-Za-z][A-Za-z0-9_-]*[[:space:]]*=/) { + k = t; sub(/[[:space:]]*=.*/, "", k) + if (tolower(k) != "syntax") print NR "\t" $0 + } + next } + { exit }' "$top/$df" >"$tracked.dir" + while IFS=$'\t' read -r ln text; do + report "$df:$ln" "Dockerfile parser directive other than syntax= (escape= or check= change how the file is read)" "$text" + done <"$tracked.dir" + rm -f "$tracked.dir" stage='' has_caddy_copy=no cmd_lines='' while IFS=$'\t' read -r ln text; do [[ $text =~ ^[[:space:]]*(#|$) ]] && continue @@ -342,22 +365,102 @@ for ((k = 0; k < ${#DL_STAGE[@]}; k++)); do report "$where" "COPY or ADD in an edge runtime stage needs an absolute literal destination (no relative path, variable, //, . or ..)" "$text" continue fi + if [ "${DL_INSTR[$k]}" = add ]; then + report "$where" "ADD in an edge runtime stage (it extracts archives and fetches URLs; use COPY)" "$text" + continue + fi if [[ $dest =~ $bin_dir_re ]] || [ "${src##*/}" = caddy ]; then case $text in [Cc][Oo][Pp][Yy]' --from='*' '*/caddy' /usr/bin/caddy') [ "${src##*/}" = caddy ] && [ "${#w[@]}" -eq 3 ] || report "$where" "only the Caddy binary may be copied into a bin directory, to /usr/bin/caddy" "$text" ;; *) report "$where" "only 'COPY --from= /caddy /usr/bin/caddy' may place the binary or anything in a bin directory of an edge runtime stage" "$text" ;; esac fi + # Only these destinations: a file in /etc/caddy (checked above as a + # Caddy file), the binary at /usr/bin/caddy, and the site below /srv. + case ${dest%/} in + /etc/caddy/?* | /usr/bin/caddy | /srv/?*) ;; + *) + report "$where" "COPY destination in an edge runtime stage outside /etc/caddy/, /usr/bin/caddy and /srv/ (an ancestor such as / or /etc would replace /etc/caddy or a bin directory)" "$text" + ;; + esac ;; esac done +# compose_line_rules : YAML features that would +# let a value reach a service without the line checks seeing it. Allowed +# are exactly the forms the repository uses: +# - anchors only on top-level x-* keys ("x-name: &name"); +# - aliases only as "<<: *name" (one alias, no list) and "logging: *name"; +# - tags only !reset and !override; +# - ${VAR} interpolation only in ports, in the source of a short volume +# mount with a literal target ("- ${VAR...}:/path[:mode]"), and in the +# file of a top-level secret; +# - no configs, no content (inline configs or secrets), no +# dockerfile_inline, and top-level secrets only from a file. +CTX_IND=() CTX_KEY=() +compose_line_rules() { + local f=$1 n=$2 l=$3 ind body key path k item=no v + body=${l#"${l%%[! ]*}"} + [ -n "$body" ] || return 0 + ind=$((${#l} - ${#body})) + case $body in '---'* | '...'*) report "$f:$n" "YAML document marker in a Compose file (one document only)" "$l"; return ;; esac + if [[ $body =~ ^-([[:space:]]+|$) ]]; then + item=yes + body=${body#-} + v=${body#"${body%%[! ]*}"} + ind=$((ind + 1 + ${#body} - ${#v})) + body=$v + fi + key='' + if [[ $body =~ ^([A-Za-z0-9_.<-]+):([[:space:]]|$) ]]; then key=${BASH_REMATCH[1]}; fi + # Pop to the parent of this line, then push its key. + while [ "${#CTX_IND[@]}" -gt 0 ] && [ "${CTX_IND[${#CTX_IND[@]} - 1]}" -ge "$ind" ]; do + unset 'CTX_IND[${#CTX_IND[@]}-1]' 'CTX_KEY[${#CTX_KEY[@]}-1]' + done + path='' + for ((k = 0; k < ${#CTX_KEY[@]}; k++)); do path="$path${CTX_KEY[$k]}."; done + if [ -n "$key" ]; then CTX_IND+=("$ind") CTX_KEY+=("$key"); fi + case $key in + configs | content | dockerfile_inline | environment) + case $key in environment) [ "${path%%.*}" = secrets ] || key='' ;; esac + [ -n "$key" ] && report "$f:$n" "Compose $key is not supported (an inline or external config could replace /etc/caddy or the binary)" "$l" + ;; + esac + if [[ $body =~ (^|[[:space:]:,\[])\&[A-Za-z0-9_.-]+ ]]; then + [ "$ind" -eq 0 ] && [[ $body =~ ^x-[A-Za-z0-9_.-]+:[[:space:]]+\&[A-Za-z0-9_.-]+[[:space:]]*$ ]] || + report "$f:$n" "YAML anchor in a Compose file (only on a top-level x-* key)" "$l" + fi + if [[ $body =~ (^|[[:space:]:,\[])\*[A-Za-z0-9_.-]+ ]]; then + [[ $body =~ ^(\<\<|logging):[[:space:]]+\*[A-Za-z0-9_.-]+[[:space:]]*$ ]] && [ "$item" = no ] || + report "$f:$n" "YAML alias in a Compose file (only '<<: *name' and 'logging: *name')" "$l" + fi + if [[ $body =~ (^|[[:space:]:,\[])\!+([A-Za-z0-9_.-]*) ]]; then + case ${BASH_REMATCH[2]} in reset | override) ;; *) report "$f:$n" "YAML tag in a Compose file (only !reset and !override)" "$l" ;; esac + fi + case $body in + *'$'*) + v=no + case $path in *ports.) [ "$item" = yes ] && v=yes ;; esac + case $path in + *volumes.) + [ "$item" = yes ] && [[ $body =~ ^\"?\$\{[A-Za-z_][A-Za-z0-9_]*(:?[-?][^}\$]*)?\}[^:\$\"]*:/[^:\$\"]+(:[a-z,]+)?\"?[[:space:]]*$ ]] && v=yes ;; + esac + [ "$path" = "secrets.${path#secrets.}" ] && [ "$key" = file ] && [ "${path//[!.]/}" = .. ] && v=yes + [ "$v" = yes ] || report "$f:$n" "interpolation in a Compose file outside ports, a volume source and a secret file (it could change an image, command, mount or build)" "$l" + ;; + esac +} + for cf in ${COMPOSES[@]+"${COMPOSES[@]}"}; do ln=0 + CTX_IND=() CTX_KEY=() while IFS= read -r line || [ -n "$line" ]; do ln=$((ln + 1)) line=${line%$'\r'} [[ $line =~ ^[[:space:]]*# ]] && continue + case $line in *$'\t'*) report "$cf:$ln" "tab in a Compose file" "$line" ;; esac + compose_line_rules "$cf" "$ln" "$line" # YAML escapes (\u0063addy, \x2F) would hide a command or a path from # these line checks; Compose decodes them. case $line in *"\\"*) report "$cf:$ln" "backslash in a Compose file (YAML escapes are not supported)" "$line" ;; esac diff --git a/scripts/ci/tests/guard-edge-config.test.sh b/scripts/ci/tests/guard-edge-config.test.sh index 4b35d55..5963a6f 100755 --- a/scripts/ci/tests/guard-edge-config.test.sh +++ b/scripts/ci/tests/guard-edge-config.test.sh @@ -462,6 +462,75 @@ base subst compose.yaml " networks: [front, back]" " networks: [front, back]${NL} entrypoint: [\"sh\"]" expect "HIGH-R1: <<: anchor merge resolves (edge with merged hardening)" 1 "entrypoint of the edge service edge" +# --- SEC-042M final audit (M-1, M-2, L-1) ---------------------------------------------- +base +put docker/edge/evil "x${NL}" +subst compose.yaml "x-hardening: &hardening" "x-etc: &etcmount \"./docker/edge/evil:/etc:ro\"${NL}${NL}x-hardening: &hardening" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - *etcmount" +expect "M B1: YAML alias mount over /etc" 1 "YAML anchor in a Compose file" +base +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - *etcmount" +expect "YAML alias as a list item" 1 "YAML alias in a Compose file" +base +put docker/edge/evil "x${NL}" +subst compose.yaml "$EDGE_VOL" "$EDGE_VOL${NL} - ./docker/edge/evil:\${EDGE_ETC:-/etc}:ro" +expect "M B2: interpolated short mount target \${X:-/etc}" 1 "interpolation in a Compose file" +base +subst "$HC" " - ./tests/app/https/attacker:/srv/attacker:ro" " - type: bind${NL} source: ./tests/app/https/attacker${NL} target: \${B:-/usr/bin}" +expect "M B3: long-syntax target \${B:-/usr/bin}" 1 "interpolation in a Compose file" +base +subst compose.yaml "x-hardening: &hardening" "x-ep: &ep${NL} entrypoint: [\"sh\", \"-c\", \"exec cadd\${Y:-y} run --resume\"]${NL}${NL}x-hardening: &hardening" +subst compose.yaml " <<: *hardening" " <<: [*hardening, *ep]" +expect "M B15: <<: with a list of aliases" 1 "YAML alias in a Compose file" +base +subst compose.yaml "x-hardening: &hardening" "x-ep: &ep${NL} entrypoint: [\"sh\", \"-c\", \"exec cadd\${Y:-y} run --resume\"]${NL}${NL}x-hardening: &hardening" +expect "M B15: interpolation in an x- anchor's entrypoint" 1 "interpolation in a Compose file" +base +put compose.extra.yaml "x-img: &edgeimg docker.io/library/cadd\${Y:-y}:2.11.7-alpine${NL}services:${NL} side:${NL} image: *edgeimg${NL} configs:${NL} - source: sidecfg${NL} target: \${C:-/etc}/cadd\${Y:-y}/Caddyfile${NL}configs:${NL} sidecfg:${NL} content: |${NL} {${NL} admin 0.0.0.0:2019${NL} }${NL} :2015 {${NL} acme_server${NL} }${NL}" +expect "M B9: aliased and interpolated caddy image with inline configs" 1 "Compose configs is not supported" +base +put compose.extra.yaml "services:${NL} side:${NL} image: *edgeimg${NL}" +expect "alias as an image value" 1 "YAML alias in a Compose file" +base +put compose.extra.yaml "services:${NL} side:${NL} image: docker.io/library/cadd\${Y:-y}:2.11.7-alpine${NL}" +expect "interpolated image" 1 "interpolation in a Compose file" +base +put compose.extra.yaml "services:${NL} side:${NL} image: busybox${NL} command: [\"sh\", \"-c\", \"\$CMD\"]${NL}" +expect "interpolation without braces in a command" 1 "interpolation in a Compose file" +base +put compose.extra.yaml "services:${NL} side:${NL} image: busybox${NL} secrets: [s]${NL}secrets:${NL} s:${NL} content: \"acme_server\"${NL}" +expect "inline secret content" 1 "Compose content is not supported" +base +put compose.extra.yaml "services:${NL} side:${NL} image: busybox${NL}secrets:${NL} s:${NL} environment: EVIL${NL}" +expect "secret from an environment variable" 1 "Compose environment is not supported" +base +put compose.extra.yaml "services:${NL} side:${NL} image: busybox${NL}secrets:${NL} s:${NL} file: \${D:-/x}/s${NL}" +expect "secret file with interpolation (the form the repository uses) is accepted" 0 +base +put docker/edge/side.txt "acme_server${NL}" +put compose.extra.yaml "services:${NL} side2:${NL} build:${NL} context: .${NL} dockerfile_inline: |${NL} FROM docker.io/library/cadd\${Y:-y}:2.11.7-alpine${NL} WORKDIR /etc${NL} COPY docker/edge/side.txt cadd\${Y:-y}/Caddyfile${NL}" +expect "M B10: build.dockerfile_inline" 1 "Compose dockerfile_inline is not supported" +bad "YAML tag other than !reset and !override" "YAML tag in a Compose file" compose.prodlike.yaml " ports: !reset []" " ports: !!seq []" +bad "tab in a Compose file" "tab in a Compose file" compose.prodlike.yaml " ports: !reset []" " ports:${TAB}!reset []" +bad "YAML document marker" "YAML document marker" compose.prodlike.yaml "services:" "---${NL}services:" +bad "anchor on a service key" "YAML anchor in a Compose file" compose.yaml " edge:" " edge: &edge" +base +put docker/edge/e.conf "$UNSAFE" +subst "$DF" "# syntax=docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e" "# syntax=docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e${NL}# escape=\`" +subst "$DF" "COPY --from=caddy-build /out/caddy /usr/bin/caddy" "COPY --from=caddy-build /out/caddy /usr/bin/caddy${NL}ENV HIDE=1\\${NL}RUN ln -s /usr/bin/caddy /usr/local/bin/edge && cd /etc && printf acme_server > caddy/x" +expect "M B6: # escape=\` parser directive hides a RUN" 1 "Dockerfile parser directive other than syntax=" +bad "# check= parser directive" "Dockerfile parser directive other than syntax=" "$DF" "# syntax=docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e" "# syntax=docker/dockerfile:1.27.1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e${NL}# ESCAPE = \`" +base +put docker/edge/conf.tar "x" +subst "$DF" "COPY docker/edge/common.caddy /etc/caddy/common.caddy" "COPY docker/edge/common.caddy /etc/caddy/common.caddy${NL}ADD docker/edge/conf.tar /etc/" +expect "M L-1 B4: ADD of an archive to /etc/ in an edge stage" 1 "ADD in an edge runtime stage" +bad "M L-1 B5: COPY --from to /etc/ in an edge stage" "outside /etc/caddy/, /usr/bin/caddy and /srv/" "$DF" "COPY --from=spa-build /app/dist /srv/www" "COPY --from=spa-build /app/dist /srv/www${NL}COPY --from=spa-build /gen/ /etc/" +bad "COPY to / in an edge stage" "outside /etc/caddy/, /usr/bin/caddy and /srv/" "$DF" "COPY --from=spa-build /app/dist /srv/www" "COPY --from=spa-build /app/dist /" +bad "COPY to /usr/local/lib in an edge stage" "outside /etc/caddy/, /usr/bin/caddy and /srv/" "$DF" "COPY --from=spa-build /app/dist /srv/www" "COPY --from=spa-build /app/dist /usr/local/lib/x" +base +subst "$DF" "COPY --from=spa-build /app/dist /srv/www" "COPY --from=spa-build /app/dist /srv/www${NL}COPY --from=spa-build /app/dist/index.html /srv/extra/index.html" +expect "COPY below /srv in an edge stage is accepted" 0 + # --- Globs that match the binary's name --------------------------------------------------- base put tests/app/extra.sh "#!/bin/sh${NL}docker run --rm img sh -c 'cp /usr/bin/cadd? /tmp/e && /tmp/e run --resume'${NL}" From 1dbeab62702108b15be22420cd4553e497e399e2 Mon Sep 17 00:00:00 2001 From: Sebastian Szyja Date: Wed, 28 Oct 2026 18:43:19 +0100 Subject: [PATCH 25/25] docs(security): list the Compose and Dockerfile forms the edge guard rejects The guard subsection now describes the new Compose rules (anchors, aliases, tags, interpolation, configs, content, dockerfile_inline) and the Dockerfile rules (parser directives, ADD and the allowed COPY destinations in edge stages). No evasion was left open, so the known limits and ADR-021 are unchanged. --- docs/security/container-vulnerabilities.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/security/container-vulnerabilities.md b/docs/security/container-vulnerabilities.md index 1184ec4..533fbbf 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -351,15 +351,26 @@ and `scripts/ci` therefore remains required. mappings other than `{}`; paths with `//`, `/./` or `..`; a short or long-syntax (`target:`) mount over `/`, `/etc`, `/etc/caddy`, `/usr` or a bin directory, other than the read-only short mounts of tracked - files into `/etc/caddy`; + files into `/etc/caddy`; YAML anchors other than on a top-level `x-*` + key, aliases other than `<<: *name` and `logging: *name` (no merge + lists), tags other than `!reset` and `!override`, tabs and document + markers; `${VAR}`/`$VAR` interpolation anywhere but in ports, in the + source of a short volume mount with a literal target, and in the file + of a top-level secret; `configs`, `content`, `dockerfile_inline` and + secrets from the environment; - in the edge runtime stages of the Dockerfile (from the caddy image or a stage with files in `/etc/caddy`, and every stage built on them): a `RUN` other than a plain `apk add --no-cache `, a copy of the binary anywhere but `COPY --from= /caddy /usr/bin/caddy`, anything else in a bin directory, a `COPY`/`ADD` destination that is not an absolute literal path (relative to `WORKDIR`, built from an - `ARG`, or with `//`, `.` or `..`), `WORKDIR`, `ONBUILD`, and `ENV PATH` - or `SHELL`, so the binary cannot be linked, copied, renamed or shadowed; + `ARG`, or with `//`, `.` or `..`), any destination other than a file in + `/etc/caddy`, `/usr/bin/caddy` and paths below `/srv` (so no `/`, `/etc` + or other ancestor of `/etc/caddy` or a bin directory), `ADD`, + `WORKDIR`, `ONBUILD`, and `ENV PATH` or `SHELL`, so the binary cannot be + linked, copied, renamed or shadowed; + - in every Dockerfile: a parser directive other than `# syntax=` (such + as `# escape=`, which changes line continuation); - a Caddy file that no start point uses, and a tracked JSON file that looks like a Caddy JSON configuration. - `tests/app/scan.sh` (job `Dev environment`) runs `caddy adapt` with the