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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
56 changes: 55 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,73 @@ 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
`generate-ssh-allowed-signers.sh`.
- `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/<runner_user>` 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"]`.
71 changes: 64 additions & 7 deletions DOCKERFILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile.template
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user>` 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}
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\"]"
Expand Down
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
15 changes: 15 additions & 0 deletions check-gpg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading