diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml index 094b290..70a78c1 100644 --- a/.github/workflows/container-validation.yml +++ b/.github/workflows/container-validation.yml @@ -104,6 +104,21 @@ jobs: - name: simplerisk (full-stack) generator run: ./simplerisk/test_generate_dockerfile.sh + - name: simplerisk-minimal *_FILE secrets support + run: ./simplerisk-minimal/test_file_env.sh + + - name: simplerisk-minimal credential escaping + run: ./simplerisk-minimal/test_escaping.sh + + - name: simplerisk-minimal DB TLS flags per mysql client + run: ./simplerisk-minimal/test_db_ssl_flags.sh + + - name: simplerisk-minimal vendored RDS CA bundle + run: ./simplerisk-minimal/test_rds_bundle.sh + + - name: simplerisk-minimal Apache worker setting + run: ./simplerisk-minimal/test_set_apache_workers.sh + - name: Fail if a generator left the committed Dockerfile modified run: | set -euo pipefail diff --git a/.github/workflows/publish-testing.yml b/.github/workflows/publish-testing.yml index 516e94d..38093a5 100644 --- a/.github/workflows/publish-testing.yml +++ b/.github/workflows/publish-testing.yml @@ -14,6 +14,22 @@ name: Publish testing images + promote # same recipe the code-development `test_docker_deploy` smoke uses, but pushed # to Docker Hub. # +# The minimal image is built NATIVELY per architecture: a matrix of +# {php83, php84, php85} x {amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm} +# pushes each by digest, then merge-minimal stitches the two digests of each PHP +# variant into one multi-arch tag with `docker buildx imagetools create`. The +# arm64 half used to be QEMU-emulated in one job (about 27 min per variant, 88 min +# total, 24 of every 27 minutes being the PHP extension compile); natively it is +# about the amd64 time (about 2.5 min of build work per variant). The published +# tags, their multi-arch shape (amd64 + arm64 + attestations), the Grype gate and +# the single SSM promote are unchanged. +# +# Dry run: dispatch this workflow from `testing` with dry_run=true to build, +# merge and scan under -nativetest-php8x tags. It moves no real tag (no +# bare , no :testing) and does not promote. The `testing` environment +# only admits the `testing` branch, so a dry run cannot be dispatched from a +# feature branch. +# # Tags (see design code-development docs/superpowers/specs/2026-07-10-release-image- # promotion-design): every release image is built ONCE here from the testing # bundle and later PROMOTED (not rebuilt) to prod by promote-latest.yml on @@ -36,6 +52,13 @@ on: push: branches: [testing] workflow_dispatch: + inputs: + dry_run: + description: > + Build, merge and scan under -nativetest-php8x tags only: no bare + , no :testing, no SSM promote. + type: boolean + default: false permissions: contents: read @@ -60,8 +83,14 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.ver.outputs.version }} + # "" for a real publish, "-nativetest" for a dry run: tags are + # -php8x, so a dry run can never collide with a real tag. + infix: ${{ steps.ver.outputs.infix }} + dry_run: ${{ steps.ver.outputs.dry_run }} steps: - name: Derive VERSION from the testing channel + env: + DRY_RUN: ${{ inputs.dry_run }} id: ver run: | set -euo pipefail @@ -80,14 +109,35 @@ jobs: fi echo "testing bundle: $BUNDLE (version $VERSION)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [ "${DRY_RUN:-}" = "true" ]; then + echo "infix=-nativetest" >> "$GITHUB_OUTPUT" + echo "dry_run=true" >> "$GITHUB_OUTPUT" + echo "::notice::DRY RUN: tags will be ${VERSION}-nativetest-php8x; no real tag moves and nothing is promoted" + else + echo "infix=" >> "$GITHUB_OUTPUT" + echo "dry_run=false" >> "$GITHUB_OUTPUT" + fi - publish: - name: Publish simplerisk-minimal RC images + # One leg per {PHP variant} x {architecture}, each on a runner of that architecture, so + # nothing is emulated. Each leg pushes its image BY DIGEST (no tag) and uploads the digest; + # merge-minimal below turns the two digests of a variant into the multi-arch tag. + build-minimal: + name: Build minimal php${{ matrix.php }} ${{ matrix.arch }} needs: resolve environment: testing - runs-on: ubuntu-latest - # 3 sequential multi-arch (arm64 = QEMU-emulated, slow) variant builds run here. - timeout-minutes: 120 + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + # One failed leg means there is no complete release; do not spend the rest. + fail-fast: true + matrix: + php: ['83', '84', '85'] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm steps: - name: Checkout (docker@testing) uses: actions/checkout@v6 @@ -131,9 +181,7 @@ jobs: cp /tmp/testing.sql common/simplerisk.sql test -d simplerisk || { echo "::error::bundle did not extract a simplerisk/ dir"; exit 1; } - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - + # No QEMU: this leg builds only its own architecture, on a runner of that architecture. - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -143,76 +191,134 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - - name: Build and push php83 — -php83 - uses: docker/build-push-action@v7 - with: - context: simplerisk-minimal - file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.3 - tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83 - cache-from: type=gha,scope=minimal-testing-php83 - cache-to: type=gha,mode=max,scope=minimal-testing-php83 + - name: Derive the PHP version (8.3 from 83) + id: php + env: + PHP: ${{ matrix.php }} + run: echo "version=${PHP:0:1}.${PHP:1}" >> "$GITHUB_OUTPUT" - - name: Build and push php84 — -php84 + - name: Build and push by digest — php${{ matrix.php }} ${{ matrix.arch }} + id: build uses: docker/build-push-action@v7 with: context: simplerisk-minimal file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.4 - tags: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84 - cache-from: type=gha,scope=minimal-testing-php84 - cache-to: type=gha,mode=max,scope=minimal-testing-php84 + platforms: linux/${{ matrix.arch }} + build-args: php_version=${{ steps.php.outputs.version }} + # No tag: the digest is merged into the tags by merge-minimal. + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=minimal-testing-php${{ matrix.php }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=minimal-testing-php${{ matrix.php }}-${{ matrix.arch }} - - name: Build and push php85 (default) — -php85 + + :testing - uses: docker/build-push-action@v7 + - name: Record the digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + printf '%s' "$DIGEST" | grep -qE '^sha256:[a-f0-9]{64}$' \ + || { echo "::error::build produced no image digest (got '$DIGEST')"; exit 1; } + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload the digest + uses: actions/upload-artifact@v4 with: - context: simplerisk-minimal - file: simplerisk-minimal/Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - build-args: php_version=8.5 - tags: | - ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85 - ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }} - ${{ env.IMAGE_NAME }}:testing - cache-from: type=gha,scope=minimal-testing-php85 - cache-to: type=gha,mode=max,scope=minimal-testing-php85 - - # Scan the images exactly as published, BEFORE the SSM promote below rolls them - # out to tier=testing customers. Each scan is continue-on-error so all three - # reports are produced and uploaded; the gate step then fails the job (and so - # skips the promote) if any of them hit a critical finding. - - name: Scan php83 (published) - id: scan83 - continue-on-error: true - uses: ./.github/actions/grype-scan + name: digests-php${{ matrix.php }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Stitch the amd64 and arm64 digests of each PHP variant into the published tags. + merge-minimal: + name: Merge minimal php${{ matrix.php }} (amd64 + arm64) + needs: [resolve, build-minimal] + environment: testing + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: true + matrix: + php: ['83', '84', '85'] + steps: + - name: Download the digests + uses: actions/download-artifact@v4 with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php83 - label: published-minimal-php83 - - name: Scan php84 (published) - id: scan84 - continue-on-error: true - uses: ./.github/actions/grype-scan + path: /tmp/digests + pattern: digests-php${{ matrix.php }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php84 - label: published-minimal-php84 - - name: Scan php85 (published) - id: scan85 - continue-on-error: true + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Create the multi-arch tags + env: + VERSION: ${{ needs.resolve.outputs.version }} + INFIX: ${{ needs.resolve.outputs.infix }} + PHP: ${{ matrix.php }} + run: | + set -euo pipefail + cd /tmp/digests + mapfile -t digests < <(ls) + if [ "${#digests[@]}" -ne 2 ]; then + echo "::error::php${PHP}: expected 2 digests (amd64, arm64), found ${#digests[@]}: ${digests[*]:-none}"; exit 1 + fi + refs=() + for d in "${digests[@]}"; do refs+=("${IMAGE_NAME}@sha256:${d}"); done + + # -php8x always; php85 (the default) also carries the bare and + # :testing. A dry run (INFIX=-nativetest) never touches the bare tag or :testing. + tags=("${IMAGE_NAME}:${VERSION}${INFIX}-php${PHP}") + if [ "$PHP" = "85" ] && [ -z "$INFIX" ]; then + tags+=("${IMAGE_NAME}:${VERSION}" "${IMAGE_NAME}:testing") + fi + args=(); for t in "${tags[@]}"; do args+=(-t "$t"); done + docker buildx imagetools create "${args[@]}" "${refs[@]}" + + # Do not publish a half-built release: every tag must carry BOTH architectures. + for t in "${tags[@]}"; do + plats=$(docker buildx imagetools inspect "$t" --format '{{json .Manifest}}' \ + | jq -r '[.manifests[].platform | select(.os == "linux") | "\(.os)/\(.architecture)"] | unique | join(",")') + echo "$t -> $plats" + [ "$plats" = "linux/amd64,linux/arm64" ] \ + || { echo "::error::$t is missing an architecture (got '$plats')"; exit 1; } + done + + # Scan the images exactly as published, BEFORE the SSM promote rolls them out to + # tier=testing customers. Every leg uploads its report; any critical finding fails the + # leg, and promote needs all three legs, so a critical finding blocks the promote. + scan-minimal: + name: Scan minimal php${{ matrix.php }} (published) + needs: [resolve, merge-minimal] + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + php: ['83', '84', '85'] + steps: + - name: Checkout (docker@testing) + uses: actions/checkout@v6 + - name: Scan php${{ matrix.php }} (published) uses: ./.github/actions/grype-scan with: - image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}-php85 - label: published-minimal-php85 - - name: Block the promote on critical findings - if: ${{ steps.scan83.outcome == 'failure' || steps.scan84.outcome == 'failure' || steps.scan85.outcome == 'failure' }} - run: | - echo "::error::Grype found critical vulnerabilities in the published images; not promoting to the testing tier. See the scan steps above." - exit 1 + image: ${{ env.IMAGE_NAME }}:${{ needs.resolve.outputs.version }}${{ needs.resolve.outputs.infix }}-php${{ matrix.php }} + label: published-minimal-php${{ matrix.php }} + promote: + name: Promote — SSM /image-tag/testing + needs: [resolve, scan-minimal] + # A dry run publishes under -nativetest tags only; nothing may roll out to tier=testing. + if: ${{ needs.resolve.outputs.dry_run != 'true' }} + environment: testing + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: - name: Configure AWS credentials (OIDC → customers account) uses: aws-actions/configure-aws-credentials@v4 with: @@ -340,7 +446,7 @@ jobs: # always(): a critical finding fails a publish job (and blocks the promote), and # that is exactly when the report matters. if: ${{ always() }} - needs: [publish, publish-full] + needs: [scan-minimal, publish-full] runs-on: ubuntu-latest permissions: contents: read @@ -353,8 +459,8 @@ jobs: slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} context: ${{ github.workflow }} · ${{ github.ref_name }} results: | - published-minimal-php83=${{ needs.publish.result }} - published-minimal-php84=${{ needs.publish.result }} - published-minimal-php85=${{ needs.publish.result }} + published-minimal-php83=${{ needs.scan-minimal.result }} + published-minimal-php84=${{ needs.scan-minimal.result }} + published-minimal-php85=${{ needs.scan-minimal.result }} published-full-jammy=${{ needs.publish-full.result }} published-full-noble=${{ needs.publish-full.result }} diff --git a/.github/workflows/rds-bundle-drift.yml b/.github/workflows/rds-bundle-drift.yml new file mode 100644 index 0000000..324a2fb --- /dev/null +++ b/.github/workflows/rds-bundle-drift.yml @@ -0,0 +1,63 @@ +name: RDS CA bundle drift + +# The image ships AWS's RDS CA bundle (simplerisk-minimal/common/etc/ssl/rds/global-bundle.pem) so the setup client +# and the app can verify the database server's certificate. AWS publishes the bundle without a checksum, and adds +# certificates when it launches a region or replaces a root. This compares the vendored copy with the file AWS +# serves today once a week: a mismatch does not mean anything is broken (a bundle that lacks a NEW region's root +# only matters if we run RDS there), it means someone should refresh the file and its pin deliberately. +# +# Refresh: download https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem over the vendored file, +# update simplerisk-minimal/rds-global-bundle.sha256 (`sha256sum`), run simplerisk-minimal/test_rds_bundle.sh. + +on: + schedule: + - cron: '0 14 * * 1' # Mondays 14:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + name: Compare the vendored RDS CA bundle with AWS's + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Compare + id: compare + run: | + set -euo pipefail + vendored=simplerisk-minimal/common/etc/ssl/rds/global-bundle.pem + curl -fsSL --retry 3 --max-time 60 -o /tmp/aws-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem + grep -q 'BEGIN CERTIFICATE' /tmp/aws-bundle.pem || { echo "::error::AWS returned something that is not a certificate bundle"; exit 1; } + have=$(sha256sum "$vendored" | cut -d' ' -f1); now=$(sha256sum /tmp/aws-bundle.pem | cut -d' ' -f1) + subjects() { awk '/BEGIN CERTIFICATE/{n++} {print > ("/tmp/c" n ".pem")}' "$1"; for f in /tmp/c[0-9]*.pem; do openssl x509 -in "$f" -noout -subject -enddate | tr '\n' ' '; echo; rm -f "$f"; done | sort; } + if [ "$have" = "$now" ]; then + echo "vendored bundle is current ($(grep -c 'BEGIN CERTIFICATE' "$vendored") certificates, sha256 ${have:0:16}...)" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + subjects "$vendored" > /tmp/have.txt; subjects /tmp/aws-bundle.pem > /tmp/now.txt + added=$(comm -13 /tmp/have.txt /tmp/now.txt | wc -l | tr -d ' '); removed=$(comm -23 /tmp/have.txt /tmp/now.txt | wc -l | tr -d ' ') + { + echo "### The vendored RDS CA bundle differs from AWS's" + echo "- vendored: sha256 \`${have:0:16}...\` ($(grep -c 'BEGIN CERTIFICATE' "$vendored") certificates)" + echo "- AWS now: sha256 \`${now:0:16}...\` ($(grep -c 'BEGIN CERTIFICATE' /tmp/aws-bundle.pem) certificates)" + echo "- certificates only in AWS's: $added; only in ours: $removed" + echo ""; echo "Certificates only in AWS's file:"; comm -13 /tmp/have.txt /tmp/now.txt | sed 's/^/- /' | cut -c1-200 | head -20 + echo ""; echo "Refresh deliberately: see the header of .github/workflows/rds-bundle-drift.yml." + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::The vendored RDS CA bundle differs from AWS's ($added certificate(s) only in AWS's, $removed only in ours). See the job summary." + exit 1 + + - name: Tell Slack + if: failure() && steps.compare.outcome == 'failure' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + [ -n "${SLACK_WEBHOOK_URL:-}" ] || exit 0 + curl -fsS -X POST -H 'Content-type: application/json' \ + --data "{\"text\":\"The RDS CA bundle vendored in simplerisk/docker differs from AWS's. Refresh it deliberately: ${RUN_URL}\"}" \ + "$SLACK_WEBHOOK_URL" >/dev/null || true diff --git a/CLAUDE.md b/CLAUDE.md index df74b7e..47eb9d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ The entrypoint script handles: | `SIMPLERISK_DB_USERNAME/PASSWORD/DATABASE` | DB credentials | | `SIMPLERISK_CRON_SETUP` | Enable/disable PHP cron (default: enabled) | | `SIMPLERISK_CSRF_SECRET` | Override auto-generated CSRF secret | +| `APACHE_MAX_REQUEST_WORKERS` | Optional cap (1-2000) on simultaneous Apache workers, written by the entrypoint's `set_apache_workers`; unset keeps Apache's default of 150. Each request holds a database connection, so size it against the database's `max_connections`, the replica count, the cron job and the session connection (`SIMPLERISK_DB_FOR_SESSIONS`), and the container's memory. Invalid values are ignored with a warning; a failed write logs an error and keeps the default | | `ADMIN_USERNAME` | Username for initial admin user (only during `DB_SETUP`; idempotent) | | `ADMIN_PASSWORD` | Password for initial admin user | | `ADMIN_EMAIL` | Email for initial admin user | diff --git a/simplerisk-minimal/.testing-version b/simplerisk-minimal/.testing-version index f16c2d0..fccfcd7 100644 --- a/simplerisk-minimal/.testing-version +++ b/simplerisk-minimal/.testing-version @@ -1 +1 @@ -20261020-001 +20261105-001 diff --git a/simplerisk-minimal/Dockerfile b/simplerisk-minimal/Dockerfile index 2edab50..cfa5ad9 100644 --- a/simplerisk-minimal/Dockerfile +++ b/simplerisk-minimal/Dockerfile @@ -16,13 +16,13 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # updates feed, then extract -- fail-closed unless PREGA_BUNDLE_FALLBACK allows the # pre-GA path. See common/download_and_verify_bundle.sh. COPY common/download_and_verify_bundle.sh /download_and_verify_bundle.sh -RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20261020-001 +RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20261105-001 FROM php:${php_version}-apache LABEL maintainer="SimpleRisk " -ENV version=20261020-001 +ENV version=20261105-001 WORKDIR /var/www diff --git a/simplerisk-minimal/README.md b/simplerisk-minimal/README.md index e8c6931..518c3d0 100644 --- a/simplerisk-minimal/README.md +++ b/simplerisk-minimal/README.md @@ -65,21 +65,51 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_ | `DB_SETUP_PASS` | `root` (the bundled `stack.yml` ships `simplerisk_setup`) | Used when `DB_SETUP=automatic\|automatic-only\|delete`. Password of the privileged MySQL user used **only** to install the SimpleRisk schema and create the app DB user. In `stack.yml` it is also the bundled MySQL root password; since that MySQL is not exposed outside the stack network, a documented default is used for the zero-config trial. Override it (and `MYSQL_ROOT_PASSWORD` in `stack.yml`) for any non-trial deployment. | | `DB_SETUP_WAIT` | 20 | Used when `DB_SETUP=automatic\|automatic-only`. Time, in seconds, the application is going to wait to set up the database. Useful if you are deploying the database and SimpleRisk at the same time | | `DB_UPGRADE` | `null` (Accepts any value) | Run SimpleRisk's release-by-release database **schema upgrade** against an already-installed database (as opposed to `DB_SETUP`, which installs a fresh one), then continue running (`automatic`) or stop the container (`automatic-only`, for a one-shot upgrade job). Uses the `SIMPLERISK_DB_*` application credentials — no privileged user. The structured per-release JSON result is written to the container log, and the container exits non-zero if the upgrade fails. Idempotent — a no-op when the schema is already current | -| `DB_SSL_ENABLED` | `false` (off) | Opt-in, used when `DB_SETUP=automatic\|automatic-only\|delete`. Set to exactly `true` to require TLS on the privileged setup/delete MySQL client connection (adds `--ssl-mode=REQUIRED --enable-cleartext-plugin`). Any other value, or unset, leaves the connection unchanged (plaintext-capable) | +| `DB_SSL_ENABLED` | `false` (off) | Opt-in, used when `DB_SETUP=automatic\|automatic-only\|delete`. Set to exactly `true` to require TLS on the privileged setup/delete MySQL client connection. When the RDS CA bundle is present (`/etc/ssl/rds/global-bundle.pem`, shipped in the image) the server certificate is also **verified**, chain and host name: `--ssl-mode=VERIFY_IDENTITY --ssl-ca= --enable-cleartext-plugin` with Oracle's client, `--ssl-ca= --ssl-verify-server-cert` with MariaDB's (the arm64 image: Debian has no MySQL client and Oracle's apt repository has no arm64 packages). Without the bundle, or with `DB_SSL_VERIFY=false`, it encrypts without verifying (`--ssl-mode=REQUIRED --enable-cleartext-plugin`, or `--ssl --skip-ssl-verify-server-cert`). Any other value, or unset, leaves the connection unchanged (plaintext-capable) | +| `DB_SSL_VERIFY` | `true` | Only with `DB_SSL_ENABLED=true`. Set to exactly `false` to stop verifying the server certificate and only encrypt, without rebuilding the image (a rollback switch, not a setting to leave on) | +| `RDS_CA_BUNDLE` | `/etc/ssl/rds/global-bundle.pem` | CA file the setup/delete client verifies the server against. The image ships AWS's RDS global bundle there (114 certificates for every region, pinned by `rds-global-bundle.sha256`; see `.github/workflows/rds-bundle-drift.yml`) | | `SIMPLERISK_DB_HOSTNAME` | `localhost` | Hostname of the database server | | `SIMPLERISK_DB_PORT` | 3306 | Port to contact the database | | `SIMPLERISK_DB_USERNAME` |`simplerisk` | User name to be used to access the SimpleRisk database | | `SIMPLERISK_DB_PASSWORD` | `simplerisk` | Password to be used to access the SimpleRisk database. If not provided while setting up the database, a random password will be generated and shown on the container logs | | `SIMPLERISK_DB_DATABASE` | `simplerisk` | Database name where all SimpleRisk objects are stored | | `SIMPLERISK_DB_FOR_SESSIONS` | `true` | Indicator that the application will store all sessions on the configured database | -| `SIMPLERISK_DB_SSL_CERT_PATH` | Empty string (`''`) | Path where SSL certificates, to contact the database, are located | +| `SIMPLERISK_DB_SSL_CERT_PATH` | Empty string (`''`) | Path to the CA file the app uses to contact the database. **Empty means the app connects without TLS** (PDO is given no TLS option). Set it, for example to `/etc/ssl/rds/global-bundle.pem` for RDS, and PDO uses TLS and verifies the chain and the host name; calls the entrypoint makes with the app's own credentials (`MAIL_*` settings) use the same CA | | `SIMPLERISK_CRON_SETUP` | `enabled` | Install the cron job to run in this Docker container | | `SIMPLERISK_CSRF_SECRET` | Auto-generated | Override the auto-generated CSRF secret | +| `APACHE_MAX_REQUEST_WORKERS` | Apache default (150) | Maximum number of requests Apache serves at once (a whole number from 1 to 2000). Each request holds a database connection, so set this so that `replicas x APACHE_MAX_REQUEST_WORKERS` stays under the database's `max_connections`, leaving room for the cron jobs and for the second connection used when `SIMPLERISK_DB_FOR_SESSIONS` is `true`. Each worker is a full PHP process, so also keep it within the container's memory limit. It applies to the Apache prefork MPM the image ships with. An invalid value is ignored with a warning in the container log | | `ADMIN_USERNAME` | — | Username for an initial admin user created once during `DB_SETUP`. Requires `ADMIN_PASSWORD` and `ADMIN_EMAIL`. Skipped if the username already exists | | `ADMIN_PASSWORD` | — | Password for the initial admin user | | `ADMIN_EMAIL` | — | Email address for the initial admin user | | `ADMIN_NAME` | `Administrator` | Display name for the initial admin user | +## Secrets (`_FILE` variables) + +To keep credentials out of the container environment (and out of compose files), the following variables can instead be supplied as a path to a file, by appending `_FILE` to the name. This works with Docker secrets (`/run/secrets/`), Kubernetes secret mounts, or any mounted file. The entrypoint reads the file, trailing newlines are stripped, and the `_FILE` variable is removed before Apache starts. + +`DB_SETUP_USER`, `DB_SETUP_PASS`, `SIMPLERISK_DB_HOSTNAME`, `SIMPLERISK_DB_PORT`, `SIMPLERISK_DB_USERNAME`, `SIMPLERISK_DB_PASSWORD`, `SIMPLERISK_DB_DATABASE`, `SIMPLERISK_CSRF_SECRET`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `ADMIN_USERNAME`, `ADMIN_PASSWORD` + +Setting both `VAR` and `VAR_FILE` is an error, as is a `_FILE` path that is not a readable file; the container exits with a message. + +```yaml +services: + simplerisk: + image: simplerisk/simplerisk-minimal + environment: + - DB_SETUP=automatic + - DB_SETUP_PASS_FILE=/run/secrets/dbms_root_pass + - SIMPLERISK_DB_PASSWORD_FILE=/run/secrets/db_pass + secrets: + - dbms_root_pass + - db_pass + +secrets: + dbms_root_pass: + file: ./dbms_root_pass.txt + db_pass: + file: ./db_pass.txt +``` + ## Mail settings The following environment variables configure the outbound mail settings stored in the SimpleRisk database (`settings` table). They are applied on every container start, so changing a value and restarting the container is all that is needed to update the configuration. Invalid values are silently skipped with a warning in the container logs. diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh index cf43d40..ed5c0c6 100644 --- a/simplerisk-minimal/common/entrypoint.sh +++ b/simplerisk-minimal/common/entrypoint.sh @@ -20,11 +20,59 @@ generate_random_password() { } # Escape a value for use as the replacement in a sed s|...|VALUE|... command. -# Escapes \, &, and | so they aren't interpreted as sed metacharacters. -# Required for any env-var-derived value that may contain / (which would -# otherwise terminate the / delimiter the sed substitution used to use). +# Escapes \, &, | and newlines so they aren't interpreted as sed metacharacters +# or terminate the s command. Required for any env-var-derived value that may +# contain / (which would otherwise terminate the / delimiter the sed +# substitution used to use). Done with parameter expansion (not a sed pipe) so +# embedded newlines survive. sed_escape() { - printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' + local v="$1" + v="${v//\\/\\\\}" + v="${v//&/\\&}" + v="${v//|/\\|}" + v="${v//$'\n'/\\$'\n'}" + printf '%s' "$v" +} + +# Escape a value for a PHP single-quoted string literal ('...'): only \ and ' +# are special there, so a value can neither close the string nor inject code. +php_sq_escape() { + local v="$1" + v="${v//\\/\\\\}" + v="${v//\'/\\\'}" + printf '%s' "$v" +} + +# Escape a value for a MySQL single-quoted string literal ('...'). +sql_str_escape() { + local v="$1" + v="${v//\\/\\\\}" + v="${v//\'/\\\'}" + printf '%s' "$v" +} + +# Escape a value for a MySQL ANSI_QUOTES identifier ("..."). +sql_ident_escape() { + local v="$1" + printf '%s' "${v//\"/\"\"}" +} + +# Rewrite define('NAME', '...'); in $CONFIG_PATH. The value is PHP-escaped first +# (so it cannot break out of the string in config.php) and then sed-escaped. +config_define() { + local name="$1" value="$2" + sed -i "s|\('${name}', '\).*\(');\)|\1$(sed_escape "$(php_sq_escape "$value")")\2|g" "$CONFIG_PATH" +} + +# Run the mysql client as the privileged setup user, reading SQL from stdin. +# Arguments are passed as an argv array (never through a shell string), and the +# password via MYSQL_PWD, so no value is subject to shell interpretation. +mysql_setup() { + local -a flags=() + # DB_SSL_FLAGS is an internal value from set_db_ssl_flags (client-specific TLS flags) + [ -n "${DB_SSL_FLAGS:-}" ] && read -ra flags <<< "$DB_SSL_FLAGS" + MYSQL_PWD="$DB_SETUP_PASS" mysql ${flags[@]+"${flags[@]}"} \ + -u "$DB_SETUP_USER" -h "$SIMPLERISK_DB_HOSTNAME" -P "$SIMPLERISK_DB_PORT" } fatal_error(){ @@ -32,6 +80,41 @@ fatal_error(){ exit 1 } +# Docker/Kubernetes secrets support: if VAR_FILE is set, read VAR from the file it +# points to (typically /run/secrets/), so credentials need not live in the +# container's environment. Setting both VAR and VAR_FILE is an error. The _FILE +# variable is unset afterwards so it is not inherited by child processes. +file_env() { + local var="$1" + local file_var="${var}_FILE" + local var_val="${!var:-}" + local file_val="${!file_var:-}" + + if [ -n "$var_val" ] && [ -n "$file_val" ]; then + fatal_error "Both $var and $file_var are set (but are exclusive)." + fi + + if [ -n "$file_val" ]; then + [ -r "$file_val" ] || fatal_error "$file_var points to '$file_val', which is not a readable file." + # $(< file) strips trailing newlines, which secret files commonly end with + export "$var=$(< "$file_val")" + fi + unset "$file_var" +} + +load_file_envs() { + local var + for var in \ + DB_SETUP_USER DB_SETUP_PASS \ + SIMPLERISK_DB_HOSTNAME SIMPLERISK_DB_PORT SIMPLERISK_DB_USERNAME \ + SIMPLERISK_DB_PASSWORD SIMPLERISK_DB_DATABASE \ + SIMPLERISK_CSRF_SECRET \ + MAIL_USERNAME MAIL_PASSWORD \ + ADMIN_USERNAME ADMIN_PASSWORD; do + file_env "$var" + done +} + set_db_password(){ if [[ "${DB_SETUP:-}" = automatic* ]]; then # shellcheck disable=SC2015 @@ -39,7 +122,7 @@ set_db_password(){ else SIMPLERISK_DB_PASSWORD=${SIMPLERISK_DB_PASSWORD:-simplerisk} fi - sed -i "s|\('DB_PASSWORD', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_PASSWORD")\2|g" "$CONFIG_PATH" + config_define DB_PASSWORD "$SIMPLERISK_DB_PASSWORD" } validate_db_setup(){ @@ -93,28 +176,28 @@ set_config(){ # Replacing config variables. Values are run through sed_escape so paths # or other inputs containing \, &, or | don't break the substitution. SIMPLERISK_DB_HOSTNAME=${SIMPLERISK_DB_HOSTNAME:-localhost} - sed -i "s|\('DB_HOSTNAME', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_HOSTNAME")\2|g" "$CONFIG_PATH" + config_define DB_HOSTNAME "$SIMPLERISK_DB_HOSTNAME" SIMPLERISK_DB_PORT=${SIMPLERISK_DB_PORT:-3306} - sed -i "s|\('DB_PORT', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_PORT")\2|g" "$CONFIG_PATH" + config_define DB_PORT "$SIMPLERISK_DB_PORT" SIMPLERISK_DB_USERNAME=${SIMPLERISK_DB_USERNAME:-simplerisk} - sed -i "s|\('DB_USERNAME', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_USERNAME")\2|g" "$CONFIG_PATH" + config_define DB_USERNAME "$SIMPLERISK_DB_USERNAME" set_db_password SIMPLERISK_DB_DATABASE=${SIMPLERISK_DB_DATABASE:-simplerisk} - sed -i "s|\('DB_DATABASE', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_DATABASE")\2|g" "$CONFIG_PATH" + config_define DB_DATABASE "$SIMPLERISK_DB_DATABASE" SIMPLERISK_DB_FOR_SESSIONS=${SIMPLERISK_DB_FOR_SESSIONS:-true} - sed -i "s|\('USE_DATABASE_FOR_SESSIONS', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_FOR_SESSIONS")\2|g" "$CONFIG_PATH" + config_define USE_DATABASE_FOR_SESSIONS "$SIMPLERISK_DB_FOR_SESSIONS" if [ -n "${SIMPLERISK_DB_SSL_CERT_PATH:-}" ]; then # The sample ships DB_SSL_CERTIFICATE_PATH as a commented-out line so # that operators who don't set the env var don't end up requesting SSL # against a non-existent cert path. When a path is supplied, rewrite # the whole line (commented or not) as an active define. - escaped_ssl_path=$(sed_escape "$SIMPLERISK_DB_SSL_CERT_PATH") + escaped_ssl_path=$(sed_escape "$(php_sq_escape "$SIMPLERISK_DB_SSL_CERT_PATH")") sed -i "s|^[[:space:]]*\(//[[:space:]]*\)\{0,1\}define('DB_SSL_CERTIFICATE_PATH', '[^']*');|define('DB_SSL_CERTIFICATE_PATH', '${escaped_ssl_path}');|" "$CONFIG_PATH" fi @@ -199,7 +282,9 @@ set_csrf_secret(){ CSRF_SECRET_PATH='/var/www/simplerisk/vendor/simplerisk/csrf-magic/csrf-secret.php' # If a SIMPLERISK_CSRF_SECRET value was specified create the csrf-secret.php file with that value - [ -n "${SIMPLERISK_CSRF_SECRET:-}" ] && echo "" > "$CSRF_SECRET_PATH"; + # (single-quoted PHP string, escaped, so the value cannot inject code or be interpolated) + # shellcheck disable=SC2015 + [ -n "${SIMPLERISK_CSRF_SECRET:-}" ] && printf "\n" "$(php_sq_escape "$SIMPLERISK_CSRF_SECRET")" > "$CSRF_SECRET_PATH" || true } set_ssl_certificate(){ @@ -229,6 +314,56 @@ set_ssl_certificate(){ exec_cmd "chmod 600 $key" "Failed to set permissions on TLS private key. Exiting." } +set_apache_workers(){ + # Optional cap on how many requests Apache serves at once. Every request + # holds a database connection, so the default of 150 workers can exceed a + # small database's max_connections (a db.t3.micro allows about 60), and + # several containers sharing one database multiply it. The right number + # depends on the database and the replica count, which the image cannot + # know, so it is left to the operator. Unset keeps Apache's default. + local workers="${APACHE_MAX_REQUEST_WORKERS:-}" + local conf="${APACHE_MPM_CONF_PATH:-/etc/apache2/conf-enabled/simplerisk-mpm.conf}" + + # Remove what a previous start wrote, so unsetting the variable on a + # restarted container goes back to Apache's default + rm -f "$conf" + + if [ -z "$workers" ]; then + return 0 + fi + + # Strip leading zeros and bound the length before any arithmetic: bash + # arithmetic wraps at 64 bits, so a very long digit string could otherwise + # wrap into the valid range + local digits="${workers#"${workers%%[!0]*}"}" + + if ! [[ "$workers" =~ ^[0-9]+$ ]] || [ -z "$digits" ] || [ "${#digits}" -gt 4 ] || [ "$digits" -gt 2000 ]; then + print_log "apache_setup:warn" "APACHE_MAX_REQUEST_WORKERS must be a whole number between 1 and 2000 -- leaving Apache's default." + return 0 + fi + + workers="$digits" + + local content="# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS"$'\n'""$'\n' + # ServerLimit defaults to 256 and silently caps MaxRequestWorkers + if [ "$workers" -gt 256 ]; then + content+=$'\t'"ServerLimit ${workers}"$'\n' + fi + content+=$'\t'"MaxRequestWorkers ${workers}"$'\n'"" + + # Do not claim a cap that was not applied: if the file cannot be written + # (for example a read-only root filesystem), say so and keep the default. + # A single printf is used because a failed redirect on a { } group does not + # reliably report failure. + if ! printf '%s\n' "$content" > "$conf"; then + print_log "apache_setup:error" "Could not write ${conf} -- leaving Apache's default." + rm -f "$conf" || true + return 0 + fi + + print_log "apache_setup:info" "Apache will serve at most ${workers} requests at once." +} + set_cron(){ # If SIMPLERISK_CRON_SETUP was passed and it is set to disabled if [[ -n "${SIMPLERISK_CRON_SETUP:-}" && "${SIMPLERISK_CRON_SETUP:-}" = disabled* ]]; then @@ -247,14 +382,17 @@ set_cron(){ apply_mail_setting(){ local db_key="$1" value="$2" - # Escape backslashes then single quotes for a MySQL single-quoted string literal local escaped - escaped=$(printf '%s' "$value" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g") - mysql -u "$SIMPLERISK_DB_USERNAME" \ - -p"$SIMPLERISK_DB_PASSWORD" \ + escaped=$(sql_str_escape "$value") + # Never force a non-TLS connection: it made MySQL 8's caching_sha2_password refuse the first login + # ("Authentication requires secure connection"), silently skipping every mail setting that needs the app + # user's password. TLS flags come from app_db_tls_flags. + local -a tls=() + read -ra tls <<< "$(app_db_tls_flags)" + MYSQL_PWD="$SIMPLERISK_DB_PASSWORD" mysql -u "$SIMPLERISK_DB_USERNAME" \ -h "$SIMPLERISK_DB_HOSTNAME" \ -P "$SIMPLERISK_DB_PORT" \ - --ssl-mode=DISABLED \ + ${tls[@]+"${tls[@]}"} \ "$SIMPLERISK_DB_DATABASE" \ -e "UPDATE settings SET value='${escaped}' WHERE name='${db_key}';" \ || print_log "mail_settings:warn" "Failed to update ${db_key}" @@ -337,14 +475,74 @@ set_mail_settings(){ [ -n "${MAIL_PASSWORD:-}" ] && apply_mail_setting phpmailer_password "$MAIL_PASSWORD" || true } +# True when the mysql client understands --ssl-mode. Oracle's client does (amd64 image). MariaDB's +# does not, and it is the only client packaged for arm64 (MySQL's apt repo has no arm64 builds), so +# there `mysql` rejects it with "unknown variable 'ssl-mode=REQUIRED'". If the client cannot be asked, +# assume Oracle's, which is what these flags were written for. +mysql_client_supports_ssl_mode() { + local help + help=$(mysql --help 2>/dev/null) || return 0 + [ -n "$help" ] || return 0 + grep -q -- '--ssl-mode' <<< "$help" +} + +# AWS's RDS CA bundle (all regions), vendored in this image from +# https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem and pinned by rds-global-bundle.sha256. +# It is what lets the mysql client and the app verify the database server's certificate. +RDS_CA_BUNDLE="${RDS_CA_BUNDLE:-/etc/ssl/rds/global-bundle.pem}" + +# TLS flags that make the mysql client verify the server certificate (chain and host name) against the CA +# file $1, spelled for whichever client is installed. +mysql_verify_flags() { + if mysql_client_supports_ssl_mode; then + printf '%s' "--ssl-mode=VERIFY_IDENTITY --ssl-ca=$1" + else + # MariaDB's client: giving it a CA makes it verify the chain, --ssl-verify-server-cert adds the host name. + printf '%s' "--ssl-ca=$1 --ssl-verify-server-cert" + fi +} + +# TLS flags for the mysql calls made with the app's own credentials (apply_mail_setting). They follow what the +# app's PHP connection does: with SIMPLERISK_DB_SSL_CERT_PATH set the app verifies against that CA, so this does +# too (and, like the app, does not fall back to plaintext if the file is missing: the call fails loudly). +app_db_tls_flags() { + if [ -n "${SIMPLERISK_DB_SSL_CERT_PATH:-}" ]; then + mysql_verify_flags "$SIMPLERISK_DB_SSL_CERT_PATH" + elif ! mysql_client_supports_ssl_mode; then + # MariaDB's client verifies by default and cannot chain RDS without a CA; Oracle's default does not + # verify. Keep both the same: encrypt if the server offers it, do not verify. + printf '%s' "--skip-ssl-verify-server-cert" + fi +} + set_db_ssl_flags(){ # Some databases require the privileged setup/delete mysql client to send # its credential via the cleartext auth plugin, which the server only # accepts over TLS (e.g. when DB_SETUP_PASS is a short-lived auth token # rather than a static password). Set DB_SSL_ENABLED=true to opt in. + # + # With the RDS CA bundle present the connection is also VERIFIED (chain and host name). Set + # DB_SSL_VERIFY=false to go back to encrypt-without-verify without rebuilding the image; if the bundle is + # missing it falls back the same way, and says so. DB_SSL_FLAGS="" if [ "${DB_SSL_ENABLED:-}" = "true" ]; then - DB_SSL_FLAGS="--ssl-mode=REQUIRED --enable-cleartext-plugin" + if [ "${DB_SSL_VERIFY:-true}" != "false" ] && [ -r "$RDS_CA_BUNDLE" ]; then + DB_SSL_FLAGS="$(mysql_verify_flags "$RDS_CA_BUNDLE")" + # The cleartext plugin is built into MariaDB's client; the option is obsolete there. + if mysql_client_supports_ssl_mode; then DB_SSL_FLAGS="$DB_SSL_FLAGS --enable-cleartext-plugin"; fi + return 0 + fi + if [ "${DB_SSL_VERIFY:-true}" != "false" ]; then + print_log "db_tls:warn" "RDS CA bundle not readable at $RDS_CA_BUNDLE; connecting with TLS but NOT verifying the server certificate" + fi + if mysql_client_supports_ssl_mode; then + DB_SSL_FLAGS="--ssl-mode=REQUIRED --enable-cleartext-plugin" + else + # MariaDB client: --ssl makes TLS mandatory and --skip-ssl-verify-server-cert matches + # REQUIRED (encrypt, do not verify the certificate). The cleartext plugin is built in; + # --enable-cleartext-plugin is obsolete there and only prints a warning. + DB_SSL_FLAGS="--ssl --skip-ssl-verify-server-cert" + fi fi } @@ -352,16 +550,13 @@ delete_db(){ print_log "db_deletion: prepare" "Performing database deletion" # Pass password via env var to avoid shell interpretation of special characters in the value - export MYSQL_PWD="$DB_SETUP_PASS" - # Needed to separate the GRANT statement from the rest because it was providing a syntax error - exec_cmd "mysql ${DB_SSL_FLAGS:-} -u $DB_SETUP_USER -h$SIMPLERISK_DB_HOSTNAME -P$SIMPLERISK_DB_PORT <= 4. +set -uo pipefail +cd "$(dirname "$(readlink -f "$0")")" || exit 1 + +fail=0 +ok() { echo "ok: $1"; } +bad() { echo "FAIL: $1"; echo " expected: $2"; echo " actual: $3"; fail=1; } +check() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; } + +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +mkdir -p "$work/mysql-oracle" "$work/mariadb" "$work/none" + +cat > "$work/mysql-oracle/mysql" <<'FAKE' +#!/bin/sh +cat <<'HELP' +mysql Ver 8.4.8 for Linux on x86_64 (MySQL Community Server - GPL) + --ssl-mode=name SSL connection mode. + --ssl-ca=name CA file in PEM format. + --enable-cleartext-plugin + Enable/disable the clear text authentication plugin. +HELP +FAKE +cat > "$work/mariadb/mysql" <<'FAKE' +#!/bin/sh +cat <<'HELP' +mysql from 11.8.6-MariaDB, client 15.2 for debian-linux-gnu (aarch64) + --ssl Enable SSL for connection (automatically enabled with + --ssl-ca=name CA file in PEM format (check OpenSSL docs, implies + --ssl-verify-server-cert + Verify server's "Common Name" in its cert against +HELP +FAKE +chmod +x "$work/mysql-oracle/mysql" "$work/mariadb/mysql" + +print_log() { :; } # the entrypoint's logger; not under test here +# The entrypoint defines RDS_CA_BUNDLE when it is sourced; here it is set by hand. It starts out pointing at a +# file that does not exist, so the historical checks below keep covering the no-bundle path even when this test +# is run inside the image, where the real /etc/ssl/rds/global-bundle.pem is present. +RDS_CA_BUNDLE="$work/no-such-bundle.pem" + +# Pull in only the functions under test. +eval "$(sed -n '/^mysql_client_supports_ssl_mode() {/,/^}/p;/^mysql_verify_flags() {/,/^}/p;/^app_db_tls_flags() {/,/^}/p;/^set_db_ssl_flags(){/,/^}/p' common/entrypoint.sh)" + +# flags_with +flags_with() { + ( PATH="$work/$1:/usr/bin:/bin" + [ "$1" = none ] && PATH="$work/none:/nonexistent" + if [ "$2" = unset ]; then unset DB_SSL_ENABLED; else DB_SSL_ENABLED="$2"; fi + set_db_ssl_flags; printf '%s' "$DB_SSL_FLAGS" ) +} + +# Oracle client: behaviour must be exactly what it was (amd64 images depend on it). +check "oracle client + DB_SSL_ENABLED=true" "--ssl-mode=REQUIRED --enable-cleartext-plugin" "$(flags_with mysql-oracle true)" +# MariaDB client: no flag it rejects. +got=$(flags_with mariadb true) +check "mariadb client + DB_SSL_ENABLED=true" "--ssl --skip-ssl-verify-server-cert" "$got" +case "$got" in *--ssl-mode*) bad "mariadb flags must not contain --ssl-mode" "(none)" "$got";; *) ok "mariadb flags contain no --ssl-mode";; esac +case "$got" in *--enable-cleartext-plugin*) bad "mariadb flags must not contain the obsolete --enable-cleartext-plugin" "(none)" "$got";; *) ok "mariadb flags contain no --enable-cleartext-plugin";; esac +# Opt-in only: anything but exactly "true" leaves the connection unchanged, for either client. +for c in mysql-oracle mariadb; do + for v in unset false TRUE 1 ""; do + check "$c + DB_SSL_ENABLED='$v' -> no flags" "" "$(flags_with $c "$v")" + done +done +# If the client cannot be asked (not on PATH), keep the historical flags rather than guess. +check "no mysql on PATH + DB_SSL_ENABLED=true keeps the historical flags" "--ssl-mode=REQUIRED --enable-cleartext-plugin" "$(flags_with none true)" + +# ---- With the RDS CA bundle present the setup client VERIFIES the server certificate ----------------- +# (the default bundle path does not exist on a CI host, so everything above still runs the historical path) +BUNDLE="$work/bundle.pem"; echo "-----BEGIN CERTIFICATE-----" > "$BUNDLE" +HIST_ORACLE="--ssl-mode=REQUIRED --enable-cleartext-plugin"; HIST_MARIA="--ssl --skip-ssl-verify-server-cert" + +# vflags +vflags() { + ( PATH="$work/$1:/usr/bin:/bin"; [ "$1" = none ] && PATH="$work/none:/nonexistent" + if [ "$2" = unset ]; then unset DB_SSL_ENABLED; else DB_SSL_ENABLED="$2"; fi + if [ "$3" = unset ]; then unset DB_SSL_VERIFY; else DB_SSL_VERIFY="$3"; fi + RDS_CA_BUNDLE="$4"; set_db_ssl_flags; printf '%s' "$DB_SSL_FLAGS" ) +} +check "oracle + bundle: verify host name and chain" "--ssl-mode=VERIFY_IDENTITY --ssl-ca=$BUNDLE --enable-cleartext-plugin" "$(vflags mysql-oracle true unset "$BUNDLE")" +check "mariadb + bundle: verify (no --ssl-mode, no obsolete cleartext flag)" "--ssl-ca=$BUNDLE --ssl-verify-server-cert" "$(vflags mariadb true unset "$BUNDLE")" +check "no mysql on PATH + bundle: assumes Oracle's client" "--ssl-mode=VERIFY_IDENTITY --ssl-ca=$BUNDLE --enable-cleartext-plugin" "$(vflags none true unset "$BUNDLE")" +# the kill switch: back to encrypt-without-verify, no rebuild needed +check "oracle + bundle + DB_SSL_VERIFY=false: historical flags" "$HIST_ORACLE" "$(vflags mysql-oracle true false "$BUNDLE")" +check "mariadb + bundle + DB_SSL_VERIFY=false: historical flags" "$HIST_MARIA" "$(vflags mariadb true false "$BUNDLE")" +# a missing bundle must not break setup: fall back (the entrypoint logs it) +check "oracle + bundle missing: historical flags" "$HIST_ORACLE" "$(vflags mysql-oracle true unset "$work/nope.pem")" +check "mariadb + bundle missing: historical flags" "$HIST_MARIA" "$(vflags mariadb true unset "$work/nope.pem")" +# opt-in rule is unchanged: a bundle alone never turns TLS on +for c in mysql-oracle mariadb; do for v in unset false TRUE 1 ""; do + check "$c + bundle present + DB_SSL_ENABLED='$v': still no flags" "" "$(vflags $c "$v" unset "$BUNDLE")" +done; done + +# ---- Calls made with the app's own credentials (apply_mail_setting) follow the app's PHP connection -------- +# aflags +aflags() { + ( PATH="$work/$1:/usr/bin:/bin" + if [ "$2" = unset ]; then unset SIMPLERISK_DB_SSL_CERT_PATH; else SIMPLERISK_DB_SSL_CERT_PATH="$2"; fi + app_db_tls_flags ) +} +check "app flags: oracle + cert path set" "--ssl-mode=VERIFY_IDENTITY --ssl-ca=$BUNDLE" "$(aflags mysql-oracle "$BUNDLE")" +check "app flags: mariadb + cert path set" "--ssl-ca=$BUNDLE --ssl-verify-server-cert" "$(aflags mariadb "$BUNDLE")" +# the app does not silently fall back to plaintext when its CA file is missing; neither does this +check "app flags: cert path set but file missing: still verifies (fails loudly)" "--ssl-mode=VERIFY_IDENTITY --ssl-ca=$work/nope.pem" "$(aflags mysql-oracle "$work/nope.pem")" +check "app flags: oracle + no cert path: client default (unchanged)" "" "$(aflags mysql-oracle unset)" +check "app flags: empty cert path is the same as unset" "" "$(aflags mysql-oracle "")" +# MariaDB verifies by default and cannot chain RDS without a CA, so without one it must not verify (as Oracle's does not) +check "app flags: mariadb + no cert path: no verification (as Oracle's default)" "--skip-ssl-verify-server-cert" "$(aflags mariadb unset)" + +exit $fail diff --git a/simplerisk-minimal/test_escaping.sh b/simplerisk-minimal/test_escaping.sh new file mode 100755 index 0000000..67c7040 --- /dev/null +++ b/simplerisk-minimal/test_escaping.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Regression checks that credential values containing shell/PHP/SQL/sed +# metacharacters are neutralised by the escaping helpers in common/entrypoint.sh. +# Requires bash >= 5 and GNU sed (as in the image and ubuntu CI runners); macOS +# /bin/bash 3.2 and BSD sed give false failures, so run it inside the image there. +set -uo pipefail +cd "$(dirname "$(readlink -f "$0")")" || exit 1 + +fail=0 +ok() { echo "ok: $1"; } +bad() { echo "FAIL: $1"; fail=1; } + +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT + +# Pull in only the helpers under test. +helpers=$(sed -n '/^sed_escape() {/,/^}/p;/^php_sq_escape() {/,/^}/p;/^sql_str_escape() {/,/^}/p;/^sql_ident_escape() {/,/^}/p;/^config_define() {/,/^}/p;/^set_csrf_secret(){/,/^}/p' common/entrypoint.sh \ + | sed "s|/var/www/simplerisk/vendor/simplerisk/csrf-magic/csrf-secret.php|$work/csrf-secret.php|") +eval "$helpers" + +# A value hitting every metacharacter class: " ' \ $ ` & | newline and $(cmd). +nasty=$'a"b\'c\\d$(touch '"$work"$'/PWNED)`touch '"$work"$'/PWNED2`&e|f\ng' + +# --- sed_escape: round-trips through a real sed substitution, newline included +printf 'X\n' > "$work/in" +sed -i "s|X|$(sed_escape "$nasty")|" "$work/in" +[ "$(cat "$work/in")" = "$nasty" ] && ok "sed_escape round-trips nasty value" || bad "sed_escape round-trip" + +# --- SQL escapers +[ "$(sql_str_escape "a'b\\c")" = "a\\'b\\\\c" ] && ok "sql_str_escape" || bad "sql_str_escape" +[ "$(sql_ident_escape 'a"b')" = 'a""b' ] && ok "sql_ident_escape" || bad "sql_ident_escape" + +# --- PHP: config.php define and csrf-secret.php must evaluate back to the exact value +if command -v php >/dev/null 2>&1; then + CONFIG_PATH="$work/config.php" + printf " "$CONFIG_PATH" + config_define DB_PASSWORD "$nasty" + got=$(php -r "include '$CONFIG_PATH'; echo DB_PASSWORD;") + [ "$got" = "$nasty" ] && ok "config_define yields exact value in PHP" || bad "config_define (got: $got)" + + SIMPLERISK_CSRF_SECRET="$nasty" set_csrf_secret + got=$(php -r "include '$work/csrf-secret.php'; echo \$secret;") + [ "$got" = "$nasty" ] && ok "set_csrf_secret yields exact value in PHP" || bad "set_csrf_secret (got: $got)" +else + echo "skip: php not installed (PHP round-trip checks)" +fi + +# --- apply_mail_setting must not force a non-TLS connection (breaks caching_sha2_password) +if sed -n '/^apply_mail_setting(){/,/^}/p' common/entrypoint.sh | grep -q -- '--ssl-mode=DISABLED'; then + bad "apply_mail_setting forces --ssl-mode=DISABLED" +else + ok "apply_mail_setting does not force --ssl-mode=DISABLED" +fi + +# --- nothing in the nasty value may have been executed +[ ! -e "$work/PWNED" ] && [ ! -e "$work/PWNED2" ] && ok "no command substitution executed" || bad "injected command executed" + +exit $fail diff --git a/simplerisk-minimal/test_file_env.sh b/simplerisk-minimal/test_file_env.sh new file mode 100755 index 0000000..d4fa6ba --- /dev/null +++ b/simplerisk-minimal/test_file_env.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression checks for the *_FILE secrets support in common/entrypoint.sh. +set -uo pipefail +cd "$(dirname "$(readlink -f "$0")")" || exit 1 + +fail=0 +ok() { echo "ok: $1"; } +bad() { echo "FAIL: $1"; fail=1; } + +# Load only the helpers under test; fatal_error is stubbed to exit the subshell. +helpers=$(sed -n '/^file_env() {/,/^}/p;/^load_file_envs() {/,/^}/p' common/entrypoint.sh) +run() { bash -c 'set -eo pipefail; fatal_error(){ echo "FATAL: $1"; exit 1; }; '"$helpers"'; '"$1"; } + +dir=$(mktemp -d); trap 'rm -rf "$dir"' EXIT +printf 'p@ss w/&|\n' > "$dir/secret" + +out=$(DB_SETUP_PASS_FILE="$dir/secret" run 'load_file_envs; [ "$DB_SETUP_PASS" = "p@ss w/&|" ] && [ -z "${DB_SETUP_PASS_FILE:-}" ] && echo pass') +[ "$out" = pass ] && ok "reads file, strips newline, unsets _FILE" || bad "reads file (got: $out)" + +SIMPLERISK_DB_PASSWORD=x SIMPLERISK_DB_PASSWORD_FILE="$dir/secret" run 'load_file_envs' >/dev/null \ + && bad "VAR and VAR_FILE together should be rejected" || ok "VAR and VAR_FILE together rejected" + +MAIL_PASSWORD_FILE="$dir/missing" run 'load_file_envs' >/dev/null \ + && bad "unreadable _FILE should be rejected" || ok "unreadable _FILE rejected" + +SIMPLERISK_DB_PASSWORD=keep run 'load_file_envs; [ "$SIMPLERISK_DB_PASSWORD" = keep ]' \ + && ok "plain VAR untouched when no _FILE" || bad "plain VAR changed" + +exit $fail diff --git a/simplerisk-minimal/test_rds_bundle.sh b/simplerisk-minimal/test_rds_bundle.sh new file mode 100755 index 0000000..287c08d --- /dev/null +++ b/simplerisk-minimal/test_rds_bundle.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Integrity of the vendored AWS RDS CA bundle that the image ships at /etc/ssl/rds/global-bundle.pem and that +# the setup client, the health probe and (through SIMPLERISK_DB_SSL_CERT_PATH) the app verify the database +# server against. AWS publishes no checksum for it, so we pin our own in rds-global-bundle.sha256: a changed +# bundle must be a deliberate, reviewed change (see .github/workflows/rds-bundle-drift.yml). +# Needs openssl and a sha256 tool; runs on the CI runner and on macOS. +set -uo pipefail +cd "$(dirname "$(readlink -f "$0")")" || exit 1 + +fail=0 +ok() { echo "ok: $1"; } +bad() { echo "FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; fail=1; } + +readonly BUNDLE=common/etc/ssl/rds/global-bundle.pem PIN=rds-global-bundle.sha256 +sha() { if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } + +[ -s "$BUNDLE" ] && ok "bundle exists" || { bad "bundle exists ($BUNDLE)"; exit 1; } +[ -s "$PIN" ] && ok "pin file exists" || { bad "pin file exists ($PIN)"; exit 1; } + +# 1. the pinned hash +want=$(awk '{print $1; exit}' "$PIN"); got=$(sha "$BUNDLE") +[ "$want" = "$got" ] && ok "sha256 matches the pin" || bad "sha256 matches the pin" "pinned $want, file is $got (refresh both together, deliberately)" +[ "$(awk '{print $2; exit}' "$PIN")" = "$BUNDLE" ] && ok "pin names the shipped path" || bad "pin names the shipped path" "$(cat "$PIN")" + +# 2. every certificate parses; nothing but certificates is in the file +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +awk -v d="$work" '/BEGIN CERTIFICATE/{n++} {print > (d "/c" sprintf("%03d", n) ".pem")}' "$BUNDLE" +total=0; unparsable=0; : > "$work/subjects"; : > "$work/enddates" +for f in "$work"/c*.pem; do + total=$((total+1)) + if out=$(openssl x509 -in "$f" -noout -subject -enddate 2>/dev/null); then + echo "$out" | sed -n 's/^subject=//p' >> "$work/subjects"; echo "$out" | sed -n 's/^notAfter=//p' >> "$work/enddates" + else unparsable=$((unparsable+1)); fi +done +[ "$unparsable" = 0 ] && ok "all $total certificates parse" || bad "all certificates parse" "$unparsable of $total do not" +[ "$total" -ge 100 ] && ok "looks like the global bundle ($total certificates)" || bad "looks like the global bundle" "only $total certificates" +grep -q "PRIVATE KEY" "$BUNDLE" && bad "no private key material" "found a PRIVATE KEY block" || ok "no private key material" + +# 3. the roots for every region we run, in the key type the CDK pins (rds-ca-rsa2048-g1) +for r in us-east-1 eu-central-1 ca-central-1; do + grep -q "Amazon RDS $r Root CA RSA2048 G1" "$work/subjects" && ok "contains the $r RSA2048 G1 root" || bad "contains the $r RSA2048 G1 root" +done + +# 4. nothing in it is about to expire (a stale bundle fails closed, so catch it early) +now=$(date +%s); soon=$((now + 3*365*24*3600)); early="" +while IFS= read -r d; do + t=$(date -d "$d" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$d" +%s 2>/dev/null || echo 0) + [ "$t" -gt 0 ] && [ "$t" -lt "$soon" ] && early="$early|$d" +done < "$work/enddates" +[ -z "$early" ] && ok "no certificate expires within 3 years" || bad "no certificate expires within 3 years" "$early" + +# 5. the image really ships it where the entrypoint looks: `COPY common/ /` maps common/etc/ssl/rds -> /etc/ssl/rds +grep -q '^COPY common/ /$' Dockerfile && ok "Dockerfile copies common/ to /" || bad "Dockerfile copies common/ to /" +grep -q 'RDS_CA_BUNDLE:-/etc/ssl/rds/global-bundle.pem' common/entrypoint.sh && ok "entrypoint default path is /etc/ssl/rds/global-bundle.pem" || bad "entrypoint default path is /etc/ssl/rds/global-bundle.pem" + +exit $fail diff --git a/simplerisk-minimal/test_set_apache_workers.sh b/simplerisk-minimal/test_set_apache_workers.sh new file mode 100755 index 0000000..7efe100 --- /dev/null +++ b/simplerisk-minimal/test_set_apache_workers.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Exercises set_apache_workers() from common/entrypoint.sh without starting +# Apache: the function is extracted and run against a temporary conf path. +# Usage: ./test_set_apache_workers.sh + +set -eo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# print_log is what the entrypoint defines; capture its output so the tests +# can assert on what an operator would see +print_log(){ echo "[$1] $2"; } + +# Pull just the function out of the entrypoint (from its header to the first +# closing brace at column 0). Fails loudly if the function moves or is renamed. +func="$(awk '/^set_apache_workers\(\) *\{/{p=1} p{print} p&&/^}/{exit}' "$here/common/entrypoint.sh")" +[ -n "$func" ] || { echo "FAIL: set_apache_workers not found in entrypoint.sh"; exit 1; } +eval "$func" + +export APACHE_MPM_CONF_PATH="$tmp/simplerisk-mpm.conf" +failures=0 +log="" + +check(){ + local name="$1"; shift + if "$@"; then echo "ok $name"; else echo "FAIL $name"; failures=$((failures+1)); fi +} + +# run : run the function in a subshell with the variable set to +# (or unset for an empty argument) and keep what it logged in $log +run(){ log="$( unset APACHE_MAX_REQUEST_WORKERS; [ -z "${1:-}" ] || export APACHE_MAX_REQUEST_WORKERS="$1"; set_apache_workers 2>&1 )"; } + +has(){ grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; } +hasnt(){ ! grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; } +absent(){ [ ! -e "$APACHE_MPM_CONF_PATH" ]; } +logged(){ [[ "$log" == *"$1"* ]]; } + +expected_300=$'# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS\n\n\tServerLimit 300\n\tMaxRequestWorkers 300\n' + +run "" ; check "unset writes nothing" absent +run 40 ; check "40 sets MaxRequestWorkers" has "MaxRequestWorkers 40" +run 40 ; check "40 leaves ServerLimit alone" hasnt ServerLimit +run 40 ; check "40 logs the cap" logged "at most 40" +run 1 ; check "lower bound 1 is accepted" has "MaxRequestWorkers 1$" +run 256 ; check "256 does not raise ServerLimit" hasnt ServerLimit +run 257 ; check "257 raises ServerLimit" has "ServerLimit 257" +run 2000 ; check "upper bound 2000 is accepted" has "MaxRequestWorkers 2000" +run 300 ; check "300 writes the exact expected file" test "$(cat "$APACHE_MPM_CONF_PATH")" = "$expected_300" +run 0040 ; check "leading zeros are normalised" has "MaxRequestWorkers 40$" +run 000000000000000000000040 ; check "long zero padding is normalised" has "MaxRequestWorkers 40$" +run "" ; check "unsetting removes the earlier file" absent + +# Rejected values: no file, a warning, and never a claim that a cap was applied +for bad in abc 0 00 2001 -5 1.5 " 40" "40 " "4 0" 18446744073709553616 99999999999999999999 "40; rm -rf /" '$(touch x)' '`id`' $'40\n41' '٣'; do + run "$bad" + check "rejects '${bad//$'\n'/\\n}'" bash -c '[ ! -e "$1" ]' _ "$APACHE_MPM_CONF_PATH" + check " and warns instead of claiming a cap" bash -c '[[ "$1" == *"[apache_setup:warn]"* && "$1" != *"at most"* ]]' _ "$log" +done + +# A conf path that cannot be written: say so, write nothing, and do not claim the cap +saved="$APACHE_MPM_CONF_PATH" +export APACHE_MPM_CONF_PATH="$tmp/no-such-dir/simplerisk-mpm.conf" +run 40 +check "unwritable path logs an error" logged "[apache_setup:error]" +check "unwritable path does not claim a cap" bash -c '[[ "$1" != *"at most"* ]]' _ "$log" +check "unwritable path leaves no file" absent +export APACHE_MPM_CONF_PATH="$saved" + +# The entrypoint must actually call the function, before it hands off to Apache +call_line="$(grep -n '^[[:space:]]*set_apache_workers$' "$here/common/entrypoint.sh" | head -1 | cut -d: -f1)" +exec_line="$(grep -n '^[[:space:]]*exec "\$@"' "$here/common/entrypoint.sh" | tail -1 | cut -d: -f1)" +check "entrypoint calls set_apache_workers before exec" bash -c '[ -n "$1" ] && [ -n "$2" ] && [ "$1" -lt "$2" ]' _ "$call_line" "$exec_line" + +[ "$failures" -eq 0 ] || { echo "$failures check(s) failed"; exit 1; } +echo "all checks passed" diff --git a/simplerisk/Dockerfile b/simplerisk/Dockerfile index 33ebbc2..65430d3 100644 --- a/simplerisk/Dockerfile +++ b/simplerisk/Dockerfile @@ -14,13 +14,13 @@ SHELL [ "/bin/ash", "-eo", "pipefail", "-c" ] # -fsSL on the SQL fetch too: without --fail, curl writes the 404 body into # /simplerisk.sql and the image ships an HTML error page as its schema. COPY common/download_and_verify_bundle.sh /download_and_verify_bundle.sh -RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20261020-001 && \ - curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20261020-001.sql" > /simplerisk.sql +RUN PREGA_BUNDLE_FALLBACK="$PREGA_BUNDLE_FALLBACK" sh /download_and_verify_bundle.sh 20261105-001 && \ + curl -fsSL "https://github.com/simplerisk/database/raw/master/simplerisk-$DB_LANG-20261105-001.sql" > /simplerisk.sql # Using Ubuntu image FROM ubuntu:${ubuntu_version_code} -ENV version=20261020-001 +ENV version=20261105-001 # Maintained by SimpleRisk LABEL maintainer="Simplerisk "