diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 4b8f1d8..36b31e8 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -19,7 +19,9 @@ jobs: - uses: actions/checkout@v4 - name: shellcheck - uses: ludeeus/action-shellcheck@master + # Pinned to a commit SHA (not @master): a root-of-trust repo should not + # run a third-party action from a moving branch. 00cae50 == tag 2.0.0. + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0 env: SHELLCHECK_OPTS: -e SC1091 with: @@ -65,7 +67,7 @@ jobs: - name: Generate for each template run: | set -euo pipefail - for t in node next nginx; do + for t in node next nginx strapi; do echo "--- template: $t ---" out="$(./dockerfile.sh -t "$t" -f package.json --dry-run)" echo "$out" @@ -74,6 +76,28 @@ jobs: echo "$out" | grep -q '^CMD ' || { echo "::error::$t: missing CMD"; exit 1; } done + - name: Strapi template carries its required runtime contract + run: | + set -euo pipefail + # Strapi MUST default to a glibc runner (sharp/libvips break on musl), + # enable corepack (Yarn 4 berry), run in production, and expose 1337. + out="$(./dockerfile.sh -t strapi -f package.json --dry-run)" + echo "$out" + echo "$out" | grep -q '^FROM orochinetwork/ubuntu:node AS builder' || { echo "::error::strapi: wrong builder image"; exit 1; } + echo "$out" | grep -q '^FROM node:22-trixie-slim AS runner' || { echo "::error::strapi: runner must default to glibc node:22-trixie-slim"; exit 1; } + echo "$out" | grep -q '^ENV NODE_ENV=production' || { echo "::error::strapi: missing NODE_ENV=production"; exit 1; } + echo "$out" | grep -q '^RUN corepack enable' || { echo "::error::strapi: missing corepack enable"; exit 1; } + echo "$out" | grep -q '^EXPOSE 1337' || { echo "::error::strapi: missing EXPOSE 1337"; exit 1; } + echo "$out" | grep -qF 'CMD ["npm", "run", "start"]' || { echo "::error::strapi: wrong CMD"; exit 1; } + # A Strapi build IS a node build: it reuses the shared build-prod-node.sh + # and must NOT reference a strapi-specific build script. + echo "$out" | grep -q 'scripts/build-prod-node\.sh' || { echo "::error::strapi: must reuse build-prod-node.sh"; exit 1; } + echo "$out" | grep -q 'build-prod-strapi\.sh' && { echo "::error::strapi: must not reference build-prod-strapi.sh"; exit 1; } + # An -r override must still win over the glibc default. + ./dockerfile.sh -t strapi -r node:22-bookworm-slim -f package.json --dry-run \ + | grep -q '^FROM node:22-bookworm-slim AS runner' || { echo "::error::strapi: -r override not honored"; exit 1; } + echo "strapi template contract verified" + - name: Reject invalid template and unknown args run: | set -euo pipefail @@ -85,6 +109,23 @@ jobs: ! ./dockerfile.sh -t node -f "README.md;.npmrc" --dry-run 2>/dev/null echo "All invalid inputs correctly rejected" + - name: Reject directive injection, ambiguous paths, and bad BASE_REVISION + run: | + set -euo pipefail + nl="$(printf '\nRUN echo PWNED')" + # Newlines in user values must not be able to inject Dockerfile directives. + ! ./dockerfile.sh -t node -f "build${nl}" -b true --dry-run 2>/dev/null + ! ./dockerfile.sh -t node -f package.json -c "[\"x\"]${nl}" -b true --dry-run 2>/dev/null + ! ./dockerfile.sh -t node -f package.json --run "echo hi${nl}" -b true --dry-run 2>/dev/null + # More than one ';' in -f is ambiguous (guard vs COPY would disagree). + ! ./dockerfile.sh -t node -f 'a;b;c' -b true --dry-run 2>/dev/null + # BASE_REVISION must reject path traversal and shell metacharacters. + ! BASE_REVISION='../../evil/main' ./dockerfile.sh -t node -f package.json -b true --dry-run 2>/dev/null + ! BASE_REVISION='a;rm -rf /' ./dockerfile.sh -t node -f package.json -b true --dry-run 2>/dev/null + # A valid SHA-style revision is still accepted. + BASE_REVISION='db70372fd4ecbc111cb195ebe249809d8f0768a3' ./dockerfile.sh -t node -f package.json -b true --dry-run >/dev/null + echo "Injection / ambiguous-path / bad-revision inputs correctly rejected" + builder-secret-no-leak: name: Build secret does not persist in builder layer runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4753f..0af7109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). from version control with a committed `.env.example`; added `.gitignore` and a `.dockerignore.example`; parameterized RabbitMQ credentials in `docker-compose.yaml`. +- **`BASE_REVISION` is validated before use:** `check-gpg.sh`, `check-ssh.sh`, and + `dockerfile.sh` now reject a `BASE_REVISION` containing shell metacharacters or + a `..` path-traversal sequence (curl normalizes `../`) before it is interpolated + into any fetch URL or any destructive setup runs. Only a commit SHA, tag, or + branch name is accepted. +- **`dockerfile.sh` directive-injection hardening:** user-supplied values + (`-c`, `-f`, `-b`, `-r`, `--run`) are rejected if they contain a newline or + carriage return, so a value like `$'build\nRUN …'` can no longer inject extra + Dockerfile directives. A `-f` value with more than one `;` is also rejected + (the credential guard and the COPY generator parsed it differently). +- **`check-ssh.sh` fails closed on unparseable signers:** if any + `ssh-allowed-signers` line fails to produce a fingerprint it now errors instead + of silently building a narrower allowlist. +- **`generate-yarn-npm.sh` carries an explicit do-not-use-in-`docker build` + warning** (it writes a plaintext token for ephemeral runners only; image builds + must use `dockerfile.sh`'s BuildKit secret mount). +- **CI shellcheck action pinned to a commit SHA** (`ludeeus/action-shellcheck`) + instead of a moving `@master` branch. +- **Empty signer-key guard:** `check-gpg.sh` / `check-ssh.sh` reject an empty key + id / fingerprint before the `grep -Fxq` allowlist check, so an empty value can + never match a blank line in the allowlist (defense-in-depth). +- **Generated `.yarnrc.yml` is written with `printf`, not `echo "…\n…"`:** the + multi-line credential file is now produced shell-agnostically (POSIX `printf` + interprets `\n` in every shell) instead of relying on the builder's `/bin/sh` + being dash. ### Added +- **`strapi` template** (`dockerfile.sh`): builds a Strapi headless CMS. Builder + `orochinetwork/ubuntu:node`; runner defaults to the glibc image + **`node:22-trixie-slim`** (required — Strapi's native `sharp`/`libvips` break on + Alpine/musl), still overridable via `-r`/`RUNNER_IMAGE`. Enables `corepack` + (Yarn 4 berry), sets `NODE_ENV=production`, `EXPOSE 1337`, defaults `CMD` to + `["npm", "run", "start"]`, and copies a sensible runtime set + (`config src database public types dist .strapi tsconfig.json package.json + node_modules favicon.png`) when no `-f` is given. A Strapi build is a node build, + so the template **reuses the shared `scripts/build-prod-node.sh`** (via a + `BUILD_SCRIPT_TEMPLATE` indirection) rather than carrying a duplicate build + script. Exercised by the CI dry-run smoke matrix. +- **Trust roster reconciliation:** `generate-ssh-allowed-signers.sh`'s + `GITHUB_USERS` now mirrors the GPG allowlist (`gpg-list.asc` / `gpg/*.asc`). + Added the active orochi-network/dev-off contributors that already hold a GPG key: + `alothanhh`, `BaoNinh2808`, `brianw3b`, `CaoHoaiTan`, `harris1111`, + `hungnguyen18`, `ngotrongphuc`, `nguyendinhthang3101`, `SangTran-127`, + `ThanhNguyen03`, `wonrax`. No one was removed. (`BaoNinh2808`/`brianw3b` publish + no SSH keys yet, so the generator skips them until they upload one.) - `generate-checksums.sh` — single source of truth for `checksum.sha256`; `security.sh` and `generate-ssh-allowed-signers.sh` delegate to it. - Trust-anchor drift report (GPG keys vs SSH signers) in @@ -40,10 +83,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - `SECURITY.md`, `CODEOWNERS`, this `CHANGELOG.md`, and an "Adding a new template" guide in `DOCKERFILE.md`. - CI: `.github/workflows/lint-and-test.yml` (shellcheck, `bash -n`, - checksum-freshness check, and `dockerfile.sh --dry-run` smoke tests). + checksum-freshness check, and `dockerfile.sh --dry-run` smoke tests). The smoke + matrix now also covers `strapi`, with an extra assertion that the template keeps + its glibc runner / corepack / `NODE_ENV=production` / `EXPOSE 1337` contract. ### Fixed - `build-prod-*.sh` only write `src/version.ts` when a `src/` directory exists (pure static sites no longer fail the build). - `dockerfile.sh` no longer advertises the unimplemented `rust` template as selectable; invalid/unknown templates and arguments now fail fast. +- **nginx template build no longer aborts:** the runner stage pre-creates + `/home/` before `chown`, so the `nginx` template (whose base image + ships no `/home/nginx`) builds successfully. nginx still runs non-root and + listens on `:80`, which requires the host to allow unprivileged low ports + (`net.ipv4.ip_unprivileged_port_start=0`, the Docker Desktop default). +- `docker-compose.yaml`: removed the obsolete top-level `version` key (ignored by + Compose v2). +- `README.md`: documents the `next` template and corrects the default command to + `["npm", "start"]`. diff --git a/DOCKERFILE.md b/DOCKERFILE.md index 702802c..58e6da1 100644 --- a/DOCKERFILE.md +++ b/DOCKERFILE.md @@ -52,6 +52,7 @@ See [GitHub Actions Integration](#github-actions-integration) section below. | `node` | `node:24-alpine` | Node.js backend applications | | `next` | `node:24-alpine` | Next.js applications (SSR/SSG) | | `nginx` | `nginx:stable-alpine` | Static websites (React, Vue, HTML) | +| `strapi` | `node:22-trixie-slim` | Strapi headless CMS (glibc runner for `sharp`/`libvips`) | List all templates with details: @@ -63,7 +64,7 @@ List all templates with details: | Option | Description | Default | |--------|-------------|---------| -| `-t, --template TYPE` | Template type: `node`, `nginx`, `next` | Required | +| `-t, --template TYPE` | Template type: `node`, `nginx`, `next`, `strapi` | Required | | `-f, --file FILE` | File/directory to copy (repeatable) | None | | `-c, --command CMD` | Container CMD command | `["npm", "start"]` | | `-b, --build CMD` | Custom build command | Auto-detected | @@ -158,6 +159,44 @@ COPY --from=builder --chown=nginx:nginx build /usr/share/nginx/html --run "touch /run/nginx.pid && chown nginx:nginx /run/nginx.pid" ``` +### Strapi Headless CMS + +`strapi` defaults to the glibc runner `node:22-trixie-slim` (Strapi's native +`sharp`/`libvips` are built against glibc and break at runtime on Alpine/musl). It +enables `corepack` (Strapi ships Yarn 4 berry; the base image only carries Yarn 1), +sets `ENV NODE_ENV=production`, `EXPOSE 1337`, and defaults `CMD` to +`["npm", "run", "start"]`. + +If you pass no `-f`, it copies a sensible default runtime set: +`config src database public types dist .strapi tsconfig.json package.json +node_modules favicon.png`. `tsconfig.json` is **load-bearing** — Strapi reads its +`outDir` to locate the compiled server in `dist/`. + +```bash +# Use the built-in default copy set +./dockerfile.sh -t strapi + +# Or specify the copy set explicitly (equivalent to the default) +./dockerfile.sh -t strapi \ + -f config \ + -f src \ + -f database \ + -f public \ + -f types \ + -f dist \ + -f .strapi \ + -f tsconfig.json \ + -f package.json \ + -f node_modules \ + -f favicon.png +``` + +If you must override the runner image, keep it glibc-based: + +```bash +./dockerfile.sh -t strapi -r node:22-bookworm-slim +``` + ### Complex Example with All Options ```bash @@ -244,12 +283,12 @@ CMD ["npm", "start"] ### Default Values -| Setting | Builder | Runner (node/next) | Runner (nginx) | -|---------|---------|-------------------|----------------| -| Base Image | `orochinetwork/ubuntu:node` | `node:24-alpine` | `nginx:stable-alpine` | -| User | `ubuntu` | `node` | `nginx` | -| Group | `ubuntu` | `node` | `nginx` | -| Workdir | `/home/ubuntu/app` | `/home/node/app` | `/usr/share/nginx/html` | +| Setting | Builder | Runner (node/next) | Runner (nginx) | Runner (strapi) | +|---------|---------|-------------------|----------------|-----------------| +| Base Image | `orochinetwork/ubuntu:node` | `node:24-alpine` | `nginx:stable-alpine` | `node:22-trixie-slim` | +| User | `ubuntu` | `node` | `nginx` | `node` | +| Group | `ubuntu` | `node` | `nginx` | `node` | +| Workdir | `/home/ubuntu/app` | `/home/node/app` | `/usr/share/nginx/html` | `/home/node/app` | ### Template-Specific Features @@ -266,6 +305,24 @@ CMD ["npm", "start"] - Default CMD: `["nginx", "-g", "daemon off;"]` - Auto-defaults to copying `build/` to `/usr/share/nginx/html` if no files specified +#### Strapi (`-t strapi`) +- Runner defaults to **`node:22-trixie-slim`** (glibc — **required**: Strapi's + native `sharp`/`libvips` are built against glibc and break at runtime on + Alpine/musl). A `-r`/`RUNNER_IMAGE` override is still honored — keep it glibc. +- Enables `corepack` in both the builder and the runner (Strapi uses Yarn 4 berry; + the base image ships Yarn 1). +- Includes `ENV NODE_ENV=production` and `EXPOSE 1337` +- Default CMD: `["npm", "run", "start"]` +- Auto-defaults to copying `config src database public types dist .strapi + tsconfig.json package.json node_modules favicon.png` if no files specified. + `tsconfig.json` is load-bearing (Strapi reads its `outDir` to find `dist/`). +- **Reuses the shared `scripts/build-prod-node.sh`** — a Strapi build is a node + build (frozen-lockfile install + `yarn build`, which runs `strapi build`: + admin panel + server → `dist`). There is intentionally no + `build-prod-strapi.sh`; the Strapi-specific bits (glibc runner, corepack layer, + copy set, `NODE_ENV`/`EXPOSE`/`CMD`) live in the `dockerfile.sh` template, not + in the build script. + ## GitHub Actions Integration ### Using the Composite Action diff --git a/Dockerfile.template b/Dockerfile.template index 8d1a8fb..69132aa 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -29,8 +29,10 @@ FROM {{runner_base_image}} AS runner ARG RUNNER_WORKDIR={{runner_workdir}} {{extra_env}} -# Make app folder for application -RUN mkdir -p ${RUNNER_WORKDIR} && \ +# Make app folder for application. Create the user's home too: some runner base +# images (e.g. nginx:stable-alpine) ship the runtime user without a /home dir, so +# an unconditional `chown -R /home/` would fail and abort the build. +RUN mkdir -p ${RUNNER_WORKDIR} /home/{{runner_user}} && \ chown -R {{runner_user}}:{{runner_group}} ${RUNNER_WORKDIR} && \ chown -R {{runner_user}} /home/{{runner_user}} && \ chmod -R 750 ${RUNNER_WORKDIR} diff --git a/README.md b/README.md index 783fe0e..63b1bce 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ All repo must include `./scripts/build-prod.sh`, this will be used to build for ### Node.js Application -- `-t | --template`: Template to use, now we support `node` and `nginx` +- `-t | --template`: Template to use, now we support `node`, `next`, `nginx` and `strapi` - `node`: Using for Node.js application - - `nginx`: Using for React.js application or startic website + - `next`: Using for Next.js application + - `nginx`: Using for React.js application or static website + - `strapi`: Using for Strapi headless CMS (glibc runner `node:22-trixie-slim`, EXPOSE 1337) - `-f | --file`: Selected file to copy -- `-c | --command`: Command to be executed default to `["yarn", "start"]` +- `-c | --command`: Command to be executed, default to `["npm", "start"]` ```bash curl -sL https://raw.githubusercontent.com/orochi-network/dev-off/main/dockerfile.sh | bash -- -t node -f package.json -f node_modules -f build -c "[\"yarn\", \"start\"]" diff --git a/SECURITY.md b/SECURITY.md index f023950..46cfbc6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,7 +38,10 @@ Treat changes to this repository with the same care as production secrets. 3. **Consumers should pin `BASE_REVISION` to a commit SHA or release tag**, not `main`. Every script accepts `BASE_REVISION`; the `actions` composite actions expose it as the `base_revision` input. Pinning converts "track a moving - branch" into "run a reviewed, tested release." + branch" into "run a reviewed, tested release." The scripts validate the value + before interpolating it into any fetch URL — only a commit SHA, tag, or branch + name is accepted, and `..` / shell metacharacters are rejected (a malicious + `BASE_REVISION` could otherwise repoint `curl` at an arbitrary repo/path). ## Required GitHub settings (must be enabled on `main`) diff --git a/check-gpg.sh b/check-gpg.sh index 3941f05..f87062b 100755 --- a/check-gpg.sh +++ b/check-gpg.sh @@ -5,6 +5,14 @@ set -euo pipefail # If base revision was set, we are going to use given revision # BASE_REVISION="db70372fd4ecbc111cb195ebe249809d8f0768a3" curl -sL https://... BASE_REVISION="${BASE_REVISION:-main}" +# Validate BASE_REVISION before it is interpolated into any fetch URL and before +# any destructive setup runs. Accept only a git commit SHA, tag, or branch name; +# reject shell metacharacters and any '..' sequence (curl normalizes '../' in URL +# paths and could be repointed at an arbitrary repo/path). See SECURITY.md. +if [[ ! "$BASE_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ || "$BASE_REVISION" == *..* ]]; then + echo "Error: invalid BASE_REVISION '${BASE_REVISION}'. Use a commit SHA, tag, or branch name (chars [A-Za-z0-9._/-], no '..')." >&2 + exit 1 +fi BASE_URL="https://raw.githubusercontent.com/orochi-network/dev-off/${BASE_REVISION}" # Log the revision we are trusting (forensics: pin this to a commit SHA in CI). @@ -69,6 +77,13 @@ for COMMIT in $COMMITS; do exit 1 fi + # Defense-in-depth: an empty key id would match a blank line in the allowlist + # via `grep -Fxq ""`. Reject it before the membership check. + if [[ -z "$KEY" ]]; then + echo "Empty signer key id for commit $COMMIT" >&2 + exit 1 + fi + # Key must be in central allowlist if ! grep -Fxq "$KEY" .allowed-keyids.txt; then echo "Signer key $KEY not in allowlist" diff --git a/check-ssh.sh b/check-ssh.sh index bf629c5..cf023c4 100755 --- a/check-ssh.sh +++ b/check-ssh.sh @@ -5,6 +5,14 @@ set -euo pipefail # If base revision was set, we are going to use given revision # BASE_REVISION="db70372fd4ecbc111cb195ebe249809d8f0768a3" curl -sL https://... BASE_REVISION="${BASE_REVISION:-main}" +# Validate BASE_REVISION before it is interpolated into any fetch URL and before +# any destructive setup runs. Accept only a git commit SHA, tag, or branch name; +# reject shell metacharacters and any '..' sequence (curl normalizes '../' in URL +# paths and could be repointed at an arbitrary repo/path). See SECURITY.md. +if [[ ! "$BASE_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ || "$BASE_REVISION" == *..* ]]; then + echo "Error: invalid BASE_REVISION '${BASE_REVISION}'. Use a commit SHA, tag, or branch name (chars [A-Za-z0-9._/-], no '..')." >&2 + exit 1 +fi BASE_URL="https://raw.githubusercontent.com/orochi-network/dev-off/${BASE_REVISION}" # Log the revision we are trusting (forensics: pin this to a commit SHA in CI). @@ -28,6 +36,17 @@ grep -v '^#' ssh-allowed-signers | grep -v '^$' | while read -r line; do echo "$line" | awk '{print $2, $3}' | ssh-keygen -lf /dev/stdin 2>/dev/null done | awk '{print $2}' > .allowed-ssh-fingerprints.txt +# Fail closed if any signer line failed to parse. ssh-keygen errors above are +# swallowed (2>/dev/null), so a malformed entry would be silently dropped and the +# allowlist would be quietly narrower than the committed signer file — a legit +# signer could vanish without warning. Require one fingerprint per signer line. +expected_signers=$(grep -v '^#' ssh-allowed-signers | grep -c '[^[:space:]]' || true) +got_fingerprints=$(grep -c '[^[:space:]]' .allowed-ssh-fingerprints.txt || true) +if [[ "$expected_signers" -eq 0 || "$expected_signers" -ne "$got_fingerprints" ]]; then + echo "ERROR: parsed $got_fingerprints of $expected_signers ssh-allowed-signers entries — refusing to continue with an incomplete fingerprint allowlist." >&2 + exit 1 +fi + # Configure git to use SSH signature verification git config --global gpg.format ssh git config --global gpg.ssh.allowedSignersFile "$(pwd)/ssh-allowed-signers" @@ -67,6 +86,13 @@ for COMMIT in $COMMITS; do exit 1 fi + # Defense-in-depth: an empty fingerprint would match a blank line in the + # allowlist via `grep -Fxq ""`. Reject it before the membership check. + if [[ -z "$KEY" ]]; then + echo "Empty signer fingerprint for commit $COMMIT" >&2 + exit 1 + fi + # Key fingerprint must be in central allowlist if ! grep -Fxq "$KEY" .allowed-ssh-fingerprints.txt; then echo "Signer key $KEY not in allowlist" diff --git a/checksum.sha256 b/checksum.sha256 index 9c4990f..ea6626e 100644 --- a/checksum.sha256 +++ b/checksum.sha256 @@ -1,11 +1,11 @@ -01bd313aa7a50bb9993a462e6c540e803771c56a083b6c7c0545e5f33617b5e5 ./check-ssh.sh +1e0c7cc14593577dcbd2b343f39a87120bc42482f25df215c52a9373029dbd4a ./ssh-allowed-signers 48f2d49fab1d7c52f6930bfb239874f1e61479210ceb260d0e2dd9a54c2877e6 ./gpg-list.asc 5b7f96cd62b02c59adb90159877bd61230c3d95eca3e19a1c4e5dfb4140a986c ./scripts/build-prod-next.sh -73d342c47d7a883c74fab489dda45718940343b95e21afd2649383b05c483415 ./ssh-allowed-signers -90ce4c5d804943369289ed6d3033cb711984d3c113426ccff891b290d30689a2 ./check-gpg.sh -9b4aa9f7d26145ba1af99e4ad21cd495eb272d0f39004f245fa939f08dd5d2ef ./dockerfile.sh +8334d814926b6f9368315606199db972f320d98bc997833eaabd5864fdad3a90 ./check-ssh.sh +89d4d141e77c525d43564e72a477cc4dcacae10624b81b9521ed3050be2c0494 ./dockerfile.sh +9dbf9d726d881c43729954f9a29777c0e17d84755c9c32c2fc72a0df39f335cb ./Dockerfile.template aad9d75f80076441f3164f81fb9fca4f4d55ec3c021129cad29633d89b24170d ./scripts/build-prod-nginx.sh -b71e79b66d6a431fc0510d1f8146afbd847d6953b2d0d5314ffde2e868ea8cdc ./generate-yarn-npm.sh +d1ab36d76bf4b3b4fb1d39a3daa02d679358fba2f884c61f5d64e9c764236edb ./check-gpg.sh ece9b5c612ffff561d0a3b0a5fc7a28ef477ccf13e79da3fdc5460c96eced306 ./scripts/build-prod-node.sh -ed81728a80fb032e177c986b97b762fa7a01f82be71caab9a5f7576dc7bc6755 ./Dockerfile.template f15c4e8e374105e07ab7de3cf94e3ec6aaccd02a76ca9907368782612bc7e50f ./configs/nginx.conf +fcf790d01c0ccde52a74a4fa49df33ba9a0f6210868dfa7895048a58bd613710 ./generate-yarn-npm.sh diff --git a/docker-compose.yaml b/docker-compose.yaml index 00be0a8..c5d3aad 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.9" - services: postgres: image: postgres:18 diff --git a/dockerfile.sh b/dockerfile.sh index 51dcbf3..2613303 100755 --- a/dockerfile.sh +++ b/dockerfile.sh @@ -5,6 +5,14 @@ set -euo pipefail # If base revision was set, we are going to use given revision # BASE_REVISION="db70372fd4ecbc111cb195ebe249809d8f0768a3" curl -sL https://... BASE_REVISION="${BASE_REVISION:-main}" +# Validate BASE_REVISION before it is interpolated into any fetch URL. Accept only +# a git commit SHA, tag, or branch name; reject shell metacharacters and any '..' +# sequence (curl normalizes '../' in URL paths and could be repointed at an +# arbitrary repo/path). See SECURITY.md. +if [[ ! "$BASE_REVISION" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ || "$BASE_REVISION" == *..* ]]; then + echo "Error: invalid BASE_REVISION '${BASE_REVISION}'. Use a commit SHA, tag, or branch name (chars [A-Za-z0-9._/-], no '..')." >&2 + exit 1 +fi BASE_URL="https://raw.githubusercontent.com/orochi-network/dev-off/${BASE_REVISION}" # ============================================================================ @@ -24,6 +32,11 @@ RUNNER_WORKDIR="/home/node/app" # CLI variables # ============================================================================ DOCKER_TEMPLATE="" +# Which remote build script the generated Dockerfile fetches +# (build-prod-${BUILD_SCRIPT_TEMPLATE}.sh). Defaults to the template name; a +# template whose build is identical to another's can point at the shared script +# (e.g. strapi reuses build-prod-node.sh). Set in the per-template case block. +BUILD_SCRIPT_TEMPLATE="" DOCKER_FILE=() DOCKER_COMMAND="[\"npm\", \"start\"]" BUILD_COMMAND="" @@ -38,7 +51,7 @@ UNKNOWN_ARGS=() # Templates that this script knows how to generate. Add new templates here and # extend the "Template-specific overrides" case below. See DOCKERFILE.md. -SUPPORTED_TEMPLATES=("node" "nginx" "next") +SUPPORTED_TEMPLATES=("node" "nginx" "next" "strapi") show_help() { cat <&2 + exit 1 + fi +done + # Refuse to copy credential files into any image layer (defense-in-depth against # leaking npm/registry tokens from the builder into the runner image). Both the # source and the destination name are checked, case-insensitively, so neither @@ -202,6 +238,14 @@ is_credential_name() { esac } for item in "${DOCKER_FILE[@]+"${DOCKER_FILE[@]}"}"; do + # At most one ';' may separate src from dst. More than one is ambiguous: this + # guard reads dst as the last field while the COPY generator reads it as the + # second field, so they would disagree on what is being copied. Reject it. + semicolons="${item//[^;]/}" + if [[ "${#semicolons}" -gt 1 ]]; then + echo "Error: -f value '${item}' contains more than one ';' (use 'src' or 'src;dst')." >&2 + exit 1 + fi src="${item%%;*}" dst="${item##*;}" # equals src when there is no ';' for name in "$src" "$dst"; do @@ -227,6 +271,9 @@ check_file() { # ============================================================================ # Template-specific overrides # ============================================================================ +# Default the remote build script to this template's own; a case branch may +# repoint it at a shared script. +BUILD_SCRIPT_TEMPLATE="$DOCKER_TEMPLATE" case "$DOCKER_TEMPLATE" in node) RUNNER_BASE_IMAGE="${RUNNER_IMAGE:-node:24-alpine}" @@ -239,6 +286,10 @@ nginx) RUNNER_WORKDIR="/usr/share/nginx/html" DOCKER_COMMAND="[\"nginx\", \"-g\", \"daemon off;\"]" EXPOSE_PORT=$'\nEXPOSE 80' + # NOTE: nginx runs as the non-root 'nginx' user (USER nginx). Binding :80 then + # requires the host kernel to allow it (net.ipv4.ip_unprivileged_port_start=0, + # the default on Docker Desktop). On a stock-kernel Linux host (threshold 1024) + # a non-root master cannot bind :80; switch this and configs/nginx.conf to 8080. # Fix permissions for non-root nginx: create cache dirs, relocate pid, disable 'user' directive RUNNER_COMMANDS+=("mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp /var/cache/nginx/scgi_temp && chown -R nginx:nginx /var/cache/nginx && sed -i 's|^user nginx;|# user nginx;|' /etc/nginx/nginx.conf && sed -i 's|pid */run/nginx.pid;|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf") # For nginx, if no files specified, use default build directory @@ -251,6 +302,31 @@ next) EXTRA_ENV=$'\nENV NEXT_TELEMETRY_DISABLED=1' COREPACK_ENABLE="corepack enable" ;; +strapi) + # Runner DEFAULTS to a glibc image (node:22-trixie-slim), NOT alpine/musl: + # Strapi's native sharp/libvips are built against glibc and break at runtime on + # musl. A -r/RUNNER_IMAGE override is still honored (use a glibc image). + RUNNER_BASE_IMAGE="${RUNNER_IMAGE:-node:22-trixie-slim}" + # NODE_ENV=production so Strapi serves the prebuilt admin panel and skips dev + # tooling at runtime. + EXTRA_ENV=$'\nENV NODE_ENV=production' + # The base image ships Yarn 1; Strapi projects use Yarn 4 berry, so corepack + # must be enabled to run the pinned package manager. corepack is enabled by the + # dedicated builder RUN layer (COREPACK_ENABLE) before the build script runs. + COREPACK_ENABLE="corepack enable" + # A Strapi build IS a node build: immutable install + `yarn build` (which runs + # `strapi build`). Reuse the shared build-prod-node.sh instead of a duplicate + # per-template script. The Strapi-specific bits (glibc runner, corepack layer, + # copy set, NODE_ENV/EXPOSE/CMD) all live in this case block. + BUILD_SCRIPT_TEMPLATE="node" + DOCKER_COMMAND="[\"npm\", \"run\", \"start\"]" + EXPOSE_PORT=$'\nEXPOSE 1337' + # Sensible default runtime copy set for a built Strapi app (overridable via -f). + # tsconfig.json is load-bearing: Strapi reads its outDir to locate dist/. + if [[ ${#DOCKER_FILE[@]} -eq 0 ]]; then + DOCKER_FILE=(config src database public types dist .strapi tsconfig.json package.json node_modules favicon.png) + fi + ;; esac # ============================================================================ @@ -305,7 +381,7 @@ generate_build_command() { echo " will fetch and execute the build script from dev-off at build time." >&2 echo " Pin BASE_REVISION to a commit SHA, or commit scripts/build-prod.sh," >&2 echo " to avoid trusting a moving branch. See SECURITY.md." >&2 - local build_script="${BASE_URL}/scripts/build-prod-${DOCKER_TEMPLATE}.sh" + local build_script="${BASE_URL}/scripts/build-prod-${BUILD_SCRIPT_TEMPLATE}.sh" inner="curl -fsSL ${build_script} | bash -eo pipefail" fi @@ -315,19 +391,20 @@ generate_build_command() { # lets the non-root builder user read the mounted secret. If the build fails, # `set -e` aborts the RUN (no layer is committed → still no leak). # Emit the Dockerfile lines with printf '%s\n' so the literal \n / \" escape - # sequences pass through verbatim (the builder's /bin/sh dash echo interprets - # them at image-build time, matching the original template). + # sequences pass through verbatim to the generated Dockerfile. Multi-line + # .yarnrc.yml writes use `printf` at build time (POSIX printf interprets \n in + # every shell), not `echo` (whose \n handling depends on the builder's /bin/sh). local h="/home/${BUILDER_USER}" printf '%s\n' "# Build with npm auth mounted as a secret (token never persists in a layer)" printf '%s\n' "RUN --mount=type=secret,id=npm_access_token,mode=0444 set -eu && \\" printf '%s\n' " NPM_ACCESS_TOKEN=\$(cat /run/secrets/npm_access_token) && \\" printf '%s\n' " echo \"//registry.npmjs.org/:_authToken=\$NPM_ACCESS_TOKEN\" > ${h}/.npmrc && \\" - printf '%s\n' " echo \"enableTelemetry: false\\nnodeLinker: node-modules\\nnpmScopes:\" > ${h}/.yarnrc.yml && \\" + printf '%s\n' " printf 'enableTelemetry: false\\nnodeLinker: node-modules\\nnpmScopes:\\n' > ${h}/.yarnrc.yml && \\" printf '%s\n' " echo \" orochi-network:\" >> ${h}/.yarnrc.yml && \\" - printf '%s\n' " echo \" npmRegistryServer: \\\"https://registry.npmjs.org\\\"\\n npmAlwaysAuth: true\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " printf ' npmRegistryServer: \"https://registry.npmjs.org\"\\n npmAlwaysAuth: true\\n' >> ${h}/.yarnrc.yml && \\" printf '%s\n' " echo \" npmAuthToken: \$NPM_ACCESS_TOKEN\" >> ${h}/.yarnrc.yml && \\" printf '%s\n' " echo \" zkdb:\" >> ${h}/.yarnrc.yml && \\" - printf '%s\n' " echo \" npmRegistryServer: \\\"https://registry.npmjs.org\\\"\\n npmAlwaysAuth: true\" >> ${h}/.yarnrc.yml && \\" + printf '%s\n' " printf ' npmRegistryServer: \"https://registry.npmjs.org\"\\n npmAlwaysAuth: true\\n' >> ${h}/.yarnrc.yml && \\" printf '%s\n' " echo \" npmAuthToken: \$NPM_ACCESS_TOKEN\" >> ${h}/.yarnrc.yml && \\" printf '%s\n' " { ${inner} ; } && \\" printf '%s\n' " rm -f ${h}/.npmrc ${h}/.yarnrc.yml" diff --git a/generate-ssh-allowed-signers.sh b/generate-ssh-allowed-signers.sh index c83be41..aad4cca 100755 --- a/generate-ssh-allowed-signers.sh +++ b/generate-ssh-allowed-signers.sh @@ -16,6 +16,22 @@ GITHUB_USERS=( chiro-hiro bao-ninh-orochi chirojr + # Reconciled with gpg-list.asc / gpg/*.asc: every name below holds a GPG key in + # the trust anchor AND is an active orochi-network/dev-off contributor. Added so + # the SSH signer roster mirrors the GPG allowlist. (BaoNinh2808 and brianw3b + # currently publish no SSH keys on GitHub; the generator skips them with a + # warning until they upload one — kept here so the rosters stay in parity.) + alothanhh + BaoNinh2808 + brianw3b + CaoHoaiTan + harris1111 + hungnguyen18 + ngotrongphuc + nguyendinhthang3101 + SangTran-127 + ThanhNguyen03 + wonrax ) OUTPUT="ssh-allowed-signers" diff --git a/generate-yarn-npm.sh b/generate-yarn-npm.sh index fb8083b..3891805 100755 --- a/generate-yarn-npm.sh +++ b/generate-yarn-npm.sh @@ -1,9 +1,22 @@ #!/usr/bin/env bash set -euo pipefail -# Write credential files with owner-only permissions (0600). This script's whole -# purpose is to leave .npmrc/.yarnrc.yml on disk for a subsequent build step, so -# we deliberately do NOT delete them here — but we do restrict their permissions. +# ============================================================================ +# SECURITY — read before use +# +# This script writes a registry auth token in PLAINTEXT to .npmrc/.yarnrc.yml and +# deliberately leaves them on disk for a subsequent build step. It is intended +# ONLY for ephemeral CI runners (the `orochi-network/actions` `configure-auth` +# step), where the runner — and these files — are destroyed after the job. +# +# Do NOT call this inside a `docker build` / Dockerfile `RUN`: the token would +# persist in an image layer. For image builds use dockerfile.sh, which mounts the +# token as a BuildKit secret and writes+uses+deletes the credentials inside a +# single RUN so it never lands in a layer. See SECURITY.md ("Build-time +# credentials"). +# ============================================================================ + +# Write credential files with owner-only permissions (umask 077 → 0600). umask 077 # Default values diff --git a/ssh-allowed-signers b/ssh-allowed-signers index 3910cc4..25c5145 100644 --- a/ssh-allowed-signers +++ b/ssh-allowed-signers @@ -16,3 +16,38 @@ # chirojr * ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHeuJY1wp6wqkMMBpfzeXbTQQ+LzCfTDd5itZUw7EABE + +# alothanhh +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC7SuM8jK/uIMULlWDl3xdRd7xkHc1N8DDUzMDzzOqTH +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILp15E5c+CD3PmWQlNKN4EokcpoKxubx5rQS13qFqOvm + +# CaoHoaiTan +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDf7Nx1lsg9K7eydYTNp5PuHf7S0SkEqSp2dfuh5nwkQ + +# harris1111 +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFBWpLExw+RIimAb8KsVE49CDfa0DdtDmDwuhI8yVkQI +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIODd5Ac7ZB/XmXlzMsQFRpxuTt62dFlu036sZSfLFTce +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHlScWuUnZr6C02vcPmbfBMnArel7eyJm4C0XbAMf3xC +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIACBanKWafregVazSjtsE4hese5Qs6y5IW448yYMEvar +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFGAGL5tSKmqqrvCr623lfWKiNFjKFPDKq2lrBTdAxOZ +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+s+qV9Wai0hCjWnct41iGe1eP9Thf5hD2F3hdQcykg + +# hungnguyen18 +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII8W78GiWNeB6INbNAtMxNf+Zqj7GhrBac2hF/PWcihi + +# ngotrongphuc +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPD3qCwoTYgMJ8LfMe888K9nEmpjHgRDlDVNZ7Trkr7I + +# nguyendinhthang3101 +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINgPmlF2v641A7NUmWHCNKFfQAyKU6jEVWCltB8tytnR +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDPMyPOSuHNKYG+kReALihYV2tt160Nzn3C0zRccdaQR + +# SangTran-127 +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHXNybB1GHE4ODGcUGmaepsWngCKOJofb0A6S9Cjaa7h +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBaszZ8y5ycn94a2Fo0lgTXE5Vdq8IadrXxl0N8GuuVR + +# ThanhNguyen03 +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBtJ799DON1dmhj7EsP2sh+QqKojTw3th2LyXERVZiP3 + +# wonrax +* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILcVnyW/bNR+hbNQ4utoprtSm8ONNFMER9lgLT9u9rVu