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/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", 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= 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..d3f7ac7 --- /dev/null +++ b/docs/architecture/adr/ADR-021-edge-go-jose-v3-exception.md @@ -0,0 +1,206 @@ +# ADR-021: Temporary exception for go-jose v3 in the edge Caddy binary + +- **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) + +## 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 + +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, 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`; + - 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, 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. 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, + 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 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. 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. +4. The exception is listed in the exceptions table of the container + 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 + 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. 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 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 + 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. + 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`, 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`, + 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 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. +- **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/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 bf58b60..533fbbf 100644 --- a/docs/security/container-vulnerabilities.md +++ b/docs/security/container-vulnerabilities.md @@ -24,15 +24,40 @@ 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 + [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, `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; 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; no two entries naming the + 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 `*`, `?`, `[`, `]`, `{` + 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 + `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 @@ -40,6 +65,19 @@ 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 + 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. - Every scan must report the package sources expected for the image (Alpine @@ -50,6 +88,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](../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 Findings of Trivy 0.67.2 (HIGH and CRITICAL), before and after this @@ -77,7 +124,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: @@ -185,6 +234,184 @@ 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` (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: + +| 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. + +**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): + +- 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 + +The finding is acceptable only while no edge configuration runs the code +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 + 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` (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`; + - 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; a glob in a command line that matches the binary's name + (`cadd?`); + - 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"]`; + - 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`; 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 `..`), 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 + 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 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. + 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 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. + +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 - `framework.trusted_hosts` is not configured: behind the edge, Symfony 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..482e460 --- /dev/null +++ b/scripts/ci/guard-edge-config.sh @@ -0,0 +1,1259 @@ +#!/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) 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); +# - 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); +# - 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); +# - 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), 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, 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. +# 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. 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. 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, +# 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:]'; } + +# 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=' ' +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/ || 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 } + 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 +} + +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 + text=${text#"${text%%[![:space:]]*}"} + instr=${text%%[[:space:]]*} rest=${text#"$instr"} + shopt -s nocasematch + case $instr in + from | copy | add | cmd | entrypoint | run | env | shell | workdir | onbuild) instr=$(lower "$instr") ;; + *) instr=other ;; + esac + shopt -u nocasematch + case $instr in + from) + words "$rest" + w=(${WORDS[@]+"${WORDS[@]}"}) + 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" + 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]-} + 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 $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"*) + 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 + +# 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" ;; + 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 [ "${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 + # 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/*) + 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 + 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 + +# 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]}" = '<<' ] && + # "*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 + 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 +} +# 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 && 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 +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 && 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 + 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=' ' +# 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 + # 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 ;; + 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 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 + 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/guard-trivyignore.sh b/scripts/ci/guard-trivyignore.sh index 268689e..8939c46 100755 --- a/scripts/ci/guard-trivyignore.sh +++ b/scripts/ci/guard-trivyignore.sh @@ -4,9 +4,23 @@ # (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; -# - the affected package or path is named: "paths" or "purls" with at -# least one entry and no wildcard (Trivy matches paths as doublestar +# 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; +# - one entry per package: no two entries name the same package (purl +# 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); # - 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 @@ -17,9 +31,16 @@ # 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 entry is listed in docs/security/container-vulnerabilities.md. +# - 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: +# its id starts a row of the table under "## Exceptions" (a mention +# 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. @@ -94,29 +115,87 @@ 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='' 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 "$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 "$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" 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" + 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='' targets='' 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, 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 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 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 + done + [ -n "$ok" ] } # bash's read drops a NUL byte (or ends the line at it), so the loop below @@ -169,8 +248,10 @@ 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" = paths ]; then + paths=yes + elif check_purls "$line_no" "$value"; then + purls=yes fi ;; statement) 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..5963a6f --- /dev/null +++ b/scripts/ci/tests/guard-edge-config.test.sh @@ -0,0 +1,604 @@ +#!/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 compose.test.yaml + 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 +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 . +# Both are passed to awk through files, so backslashes stay literal. +subst() { + 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 + 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 + + +# --- 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 + +# --- 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" + +# --- 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}" +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 +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 ] diff --git a/scripts/ci/tests/guard-trivyignore.test.sh b/scripts/ci/tests/guard-trivyignore.test.sh index 4a577a8..a34117c 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,6 +42,13 @@ expect() { run "$1" "$2" "$repo" } +# expect_error [doc content]: exit +# 1, and the report names the expected violation (not only some other one). +expect_error() { + fixture "$3" ${4+"$4"} + run "$1" 1 "$repo" "$2" +} + # expect_bytes : for bytes a shell string cannot hold (NUL). expect_bytes() { @@ -50,41 +58,53 @@ 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" 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}" -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}" @@ -94,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}" @@ -107,7 +127,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 +144,89 @@ 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 ' 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}" +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}" +# 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" +# 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 run "missing exception file" 2 "$work/nofile" mkdir -p "$work/nogit" diff --git a/security/trivyignore.yaml b/security/trivyignore.yaml index a9d14c1..667a9fc 100644 --- a/security/trivyignore.yaml +++ b/security/trivyignore.yaml @@ -2,16 +2,30 @@ # # 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 -# docs/security/container-vulnerabilities.md. No broad or permanent rules. +# 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 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. # # 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 -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. 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 diff --git a/tests/app/scan.sh b/tests/app/scan.sh index a39d140..8afb953 100755 --- a/tests/app/scan.sh +++ b/tests/app/scan.sh @@ -26,11 +26,17 @@ 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, 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 }} +{{ 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 @@ -84,8 +90,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" } @@ -124,4 +130,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)"