[E00-S02-T06] App root filesystem read-only except mounts/tmpfs #173

Closed
opened 2026-08-27 00:07:14 +00:00 by kpcto · 11 comments
Owner

Parent story: [E00-S02] Docker Compose baseline (#59)

Intent

Ensure the application root filesystem is read-only except declared mounts and tmpfs.

Acceptance criteria

  • app root filesystem is read-only
  • writable paths are limited to declared mounts and tmpfs

Explicitly out of scope

  • non-root execution (E00-S02-T05)
  • multi-arch build targets (E00-S02-T07)
  • secrets not embedded (E00-S02-T08)

Test plan

  • attempt a write to the root filesystem and confirm it is denied

Rollback note

  • revert the read-only filesystem/mount configuration

Owning stream

platform

Risk quadrant

agent-full

> Parent story: [E00-S02] Docker Compose baseline (#59) ## Intent Ensure the application root filesystem is read-only except declared mounts and tmpfs. ## Acceptance criteria - app root filesystem is read-only - writable paths are limited to declared mounts and tmpfs ## Explicitly out of scope - non-root execution (E00-S02-T05) - multi-arch build targets (E00-S02-T07) - secrets not embedded (E00-S02-T08) ## Test plan - attempt a write to the root filesystem and confirm it is denied ## Rollback note - revert the read-only filesystem/mount configuration ### Owning stream platform ### Risk quadrant agent-full
kpcto added this to the Sprint 0 milestone 2026-08-27 00:07:14 +00:00
kpcto added the
status
ready
kind
task
labels 2026-08-27 00:07:14 +00:00
bot-dispatcher added
status
proposed
and removed
status
ready
kind
task
labels 2026-08-27 00:07:16 +00:00
Member

Auto-reverted by dispatcher: DoR lint: required section "Intent" is empty; required section "Acceptance criteria" is empty; required section "Explicitly out of scope" is empty; required section "Test plan" is empty; required section "Rollback note" is empty; acceptance criteria: no bullet assertions found

status/ready may only be applied by a human maintainer.

> Auto-reverted by dispatcher: DoR lint: required section "Intent" is empty; required section "Acceptance criteria" is empty; required section "Explicitly out of scope" is empty; required section "Test plan" is empty; required section "Rollback note" is empty; acceptance criteria: no bullet assertions found `status/ready` may only be applied by a human maintainer.
kpcto added the
kind
task
label 2026-08-27 21:24:35 +00:00
kpcto added
status
ready
and removed
status
proposed
labels 2026-08-29 00:48:36 +00:00
bot-dispatcher added
loop
1
status
in-progress
and removed
status
ready
labels 2026-08-29 00:48:46 +00:00
Member
agent: dispatcher
phase: complete
plan_summary: Dispatch #173: [E00-S02-T06] App root filesystem read-only except mounts/tmpfs (platform, security-gated)
```yaml agent: dispatcher phase: complete plan_summary: Dispatch #173: [E00-S02-T06] App root filesystem read-only except mounts/tmpfs (platform, security-gated) ```
bot-dispatcher added the needs/security-reviewneeds/security-review labels 2026-08-29 00:48:46 +00:00
Member
agent: implementer
phase: start
issue: 173
task: "[E00-S02-T06] App root filesystem read-only except mounts/tmpfs"
plan_summary: |
  Implement the read-only app root filesystem on top of the E00-S02-T01..T05
  Compose baseline:
  1. compose.yaml: add read_only: true + tmpfs /tmp to the app service; update
     header doc (T06 in scope, T07/T08 remain later tasks).
  2. apps/server/Dockerfile: header doc only (T06 is a Compose-level concern,
     the image is unchanged).
  3. tests/readonly-rootfs.test.mjs (new): static assertions (read_only: true,
     declared /tmp tmpfs, no writable mounts), non-vacuous mutation probes, and
     a Docker-gated real-stack probe (root-fs write denied, tmpfs write
     allowed, ReadonlyRootfs=true, health endpoint regression guard).
  4. Open PR with criterion -> test table; CI (frozen install) green.
out_of_scope:
  - non-root execution (E00-S02-T05)   # already landed
  - multi-arch build targets (E00-S02-T07)
  - secrets not embedded (E00-S02-T08)
```yaml agent: implementer phase: start issue: 173 task: "[E00-S02-T06] App root filesystem read-only except mounts/tmpfs" plan_summary: | Implement the read-only app root filesystem on top of the E00-S02-T01..T05 Compose baseline: 1. compose.yaml: add read_only: true + tmpfs /tmp to the app service; update header doc (T06 in scope, T07/T08 remain later tasks). 2. apps/server/Dockerfile: header doc only (T06 is a Compose-level concern, the image is unchanged). 3. tests/readonly-rootfs.test.mjs (new): static assertions (read_only: true, declared /tmp tmpfs, no writable mounts), non-vacuous mutation probes, and a Docker-gated real-stack probe (root-fs write denied, tmpfs write allowed, ReadonlyRootfs=true, health endpoint regression guard). 4. Open PR with criterion -> test table; CI (frozen install) green. out_of_scope: - non-root execution (E00-S02-T05) # already landed - multi-arch build targets (E00-S02-T07) - secrets not embedded (E00-S02-T08) ```
Member
agent: implementer
phase: end
issue: 173
task: "[E00-S02-T06] App root filesystem read-only except mounts/tmpfs"
outcome: done
pr: 387
pr_url: https://gitea:3000/Fabrika/PersonalBlog/pulls/387
summary: |
  App root filesystem read-only except mounts/tmpfs implemented and merged-ready:
  - compose.yaml: app service declares read_only: true (root fs read-only) and
    tmpfs: /tmp as the only writable path (no writable volume/bind mounts).
  - apps/server/Dockerfile: header doc only (T06 is Compose-level; image unchanged).
  - tests/readonly-rootfs.test.mjs (new, 10 tests): static assertions for both
    acceptance criteria, non-vacuous mutation probes (remove/flip read_only,
    remove tmpfs, add writable mount, read_only on db only), and a Docker-gated
    real-stack probe (ReadonlyRootfs=true via docker inspect, root-fs write
    denied, /tmp tmpfs write allowed, health endpoint 200 regression guard).
ci:
  - "CI / Frozen lockfile install (pull_request)": success (56s)
tests:
  new_file: "9/9 pass + 1 Docker-gated skip (no Docker daemon in sandbox)"
  full_suite: "77 pass / 12 fail / 5 skip; identical 12 environmental failures on clean main (Node 22, no node_modules) — zero new failures"
acceptance_criteria:
  - "app root filesystem is read-only": covered (static + real-stack probe)
  - "writable paths are limited to declared mounts and tmpfs": covered (static + real-stack probe)
out_of_scope_respected:
  - non-root execution (E00-S02-T05)   # untouched
  - multi-arch build targets (E00-S02-T07)   # untouched
  - secrets not embedded (E00-S02-T08)   # untouched
rollback_note: drop `read_only`/`tmpfs` from the app service in compose.yaml
```yaml agent: implementer phase: end issue: 173 task: "[E00-S02-T06] App root filesystem read-only except mounts/tmpfs" outcome: done pr: 387 pr_url: https://gitea:3000/Fabrika/PersonalBlog/pulls/387 summary: | App root filesystem read-only except mounts/tmpfs implemented and merged-ready: - compose.yaml: app service declares read_only: true (root fs read-only) and tmpfs: /tmp as the only writable path (no writable volume/bind mounts). - apps/server/Dockerfile: header doc only (T06 is Compose-level; image unchanged). - tests/readonly-rootfs.test.mjs (new, 10 tests): static assertions for both acceptance criteria, non-vacuous mutation probes (remove/flip read_only, remove tmpfs, add writable mount, read_only on db only), and a Docker-gated real-stack probe (ReadonlyRootfs=true via docker inspect, root-fs write denied, /tmp tmpfs write allowed, health endpoint 200 regression guard). ci: - "CI / Frozen lockfile install (pull_request)": success (56s) tests: new_file: "9/9 pass + 1 Docker-gated skip (no Docker daemon in sandbox)" full_suite: "77 pass / 12 fail / 5 skip; identical 12 environmental failures on clean main (Node 22, no node_modules) — zero new failures" acceptance_criteria: - "app root filesystem is read-only": covered (static + real-stack probe) - "writable paths are limited to declared mounts and tmpfs": covered (static + real-stack probe) out_of_scope_respected: - non-root execution (E00-S02-T05) # untouched - multi-arch build targets (E00-S02-T07) # untouched - secrets not embedded (E00-S02-T08) # untouched rollback_note: drop `read_only`/`tmpfs` from the app service in compose.yaml ```
Member
agent: security
phase: start
issue: 173
pr: 387
plan_summary: |
  Security review of PR #387 (feature/173): app root filesystem read-only
  except mounts/tmpfs.
  1. Deterministic scans on the PR head tree (41428b0): gitleaks (secrets),
     osv-scanner (dependencies) where available.
  2. Authz/input-boundary trace over the diff: no new routes or data-access
     paths expected (compose hardening + tests only) — verify.
  3. Hardening judgement: read_only rootfs + tmpfs correctness, writable-path
     scope, tmpfs sizing/exhaustion, test hygiene (docker exec probes).
  4. Post verdict: approve or request-changes with severity + findings.
```yaml agent: security phase: start issue: 173 pr: 387 plan_summary: | Security review of PR #387 (feature/173): app root filesystem read-only except mounts/tmpfs. 1. Deterministic scans on the PR head tree (41428b0): gitleaks (secrets), osv-scanner (dependencies) where available. 2. Authz/input-boundary trace over the diff: no new routes or data-access paths expected (compose hardening + tests only) — verify. 3. Hardening judgement: read_only rootfs + tmpfs correctness, writable-path scope, tmpfs sizing/exhaustion, test hygiene (docker exec probes). 4. Post verdict: approve or request-changes with severity + findings. ```
bot-dispatcher removed the needs/security-reviewneeds/security-review labels 2026-08-29 00:55:34 +00:00
Member
agent: reviewer
verdict: approve
```yaml agent: reviewer verdict: approve ```
bot-dispatcher added
status
review
and removed
status
in-progress
loop
1
kind
task
labels 2026-08-29 00:56:20 +00:00
Member
agent: security
phase: complete
issue: 173
pr: 387
head: 41428b083eddd4bea50178fe2aa994b7372bc13d
verdict: approve
scan_summary:
  secrets_gitleaks: clean            # `gitleaks detect --source . --no-git --redact` → "no leaks found" (exit 0)
  dependencies_osv_scanner: clean    # `osv-scanner --recursive .` → "No issues found" (exit 0; 3 locked packages)
  sast_semgrep: skipped              # not installed in the security image; gap covered by the manual authz/input trace
authz_trace: no_new_routes           # diff adds no routes/handlers/data access; GET /health (pre-existing T03) unchanged
scope_check: pass                    # 3 files (+362/−7): compose.yaml, apps/server/Dockerfile (comment-only), tests/readonly-rootfs.test.mjs (new); no CI/agents/review-process files touched — pipeline tripwire clear
test_verification:
  readonly_rootfs: "9 pass / 1 docker-gated skip / 0 fail"
  compose_config_plus_non_root: "22 pass / 4 skip / 0 fail (no T01..T05 regressions)"
  full_suite: "77 pass / 12 fail / 5 skip — the identical 12 failures exist on clean origin/main (68/12/4): +9 pass, +1 skip, 0 new failures (env lacks pnpm install + Node 24)"
findings:
  - severity: nit
    file: compose.yaml
    line: 84
    what: >
      The app tmpfs is declared without a size cap (`tmpfs: - /tmp`), so
      Docker's default (~50% of host RAM) applies; tmpfs pages are unswappable
      host memory.
    exploit_path: >
      An attacker who already has code execution in the (non-root, read-only)
      app container can fill /tmp and pressure host memory until the OOM killer
      disturbs the host or co-located containers.
    fix: >
      Cap it, e.g. `tmpfs: - /tmp:size=64m` (or long-syntax `tmpfs_size`) —
      follow-up hardening, not required by the T06 acceptance criteria.
  - severity: nit
    file: compose.yaml
    line: 70
    what: >
      Informational, pre-existing (T01..T05) and unchanged by this PR: dev
      default DATABASE_URL postgres://eppp:eppp@db:5432/eppp. Secrets hygiene
      is explicitly task T08 — not a finding against #387.
blockers: none

Security review of PR #387 (evidence)

Scanner output (verbatim):

  • gitleaks detect --source . --no-git --redact → INF no leaks found (exit 0)
  • osv-scanner --recursive . → No issues found (exit 0)
  • semgrep: command -v semgrep fails in this image → skipped per the security-audit playbook, with the gap covered by the manual trace below.

Authz trace (scanners can't do this): the diff adds no route, handler, endpoint, or data-access path — compose.yaml + Dockerfile comments + a new test file. The only HTTP surface (GET /health on port 3000) and port bindings (3000/5432) are pre-existing and unchanged. No default-allow path and no check reordering is possible in this diff.

Input boundaries / injection: no new input parsing, deserialization, or outbound-request construction. In the new test file every spawnSync uses fixed argv arrays with constant strings; the two sh -c '…' probe strings are constants; the compose text is only ever read and regex-matched (data, never executed). The health probe fetches a hardcoded http://127.0.0.1:3000/health inside the container — no SSRF surface.

Hardening judgement: read_only: true + tmpfs: /tmp on app satisfies both acceptance criteria; the app service declares no writable volume/bind mounts (the db-data volume belongs to db, out of scope). This is a genuine containment improvement: persistence/defacement via writes to /usr, /etc, or the app dirs is now denied even after code execution.

Test honesty: mutation probes are non-vacuous (each asserts the mutation changed the text before asserting failure), assertions are behavioral, no existing tests were deleted or weakened (existing skip count unchanged; the +1 skip is the new Docker-gated probe). Full-suite parity against a fresh origin/main worktree confirmed: 77/12/5 vs 68/12/4 → 0 new failures.

Pipeline tripwire: agents/, CI config, .gitea/workflows/, and the review process are untouched by the diff.

Both findings are nit-level (no blocker), so verdict: approve per the checklist (approve requires criteria covered + no blockers, both true here). The tmpfs size cap is worth folding into a follow-up hardening task.

```yaml agent: security phase: complete issue: 173 pr: 387 head: 41428b083eddd4bea50178fe2aa994b7372bc13d verdict: approve scan_summary: secrets_gitleaks: clean # `gitleaks detect --source . --no-git --redact` → "no leaks found" (exit 0) dependencies_osv_scanner: clean # `osv-scanner --recursive .` → "No issues found" (exit 0; 3 locked packages) sast_semgrep: skipped # not installed in the security image; gap covered by the manual authz/input trace authz_trace: no_new_routes # diff adds no routes/handlers/data access; GET /health (pre-existing T03) unchanged scope_check: pass # 3 files (+362/−7): compose.yaml, apps/server/Dockerfile (comment-only), tests/readonly-rootfs.test.mjs (new); no CI/agents/review-process files touched — pipeline tripwire clear test_verification: readonly_rootfs: "9 pass / 1 docker-gated skip / 0 fail" compose_config_plus_non_root: "22 pass / 4 skip / 0 fail (no T01..T05 regressions)" full_suite: "77 pass / 12 fail / 5 skip — the identical 12 failures exist on clean origin/main (68/12/4): +9 pass, +1 skip, 0 new failures (env lacks pnpm install + Node 24)" findings: - severity: nit file: compose.yaml line: 84 what: > The app tmpfs is declared without a size cap (`tmpfs: - /tmp`), so Docker's default (~50% of host RAM) applies; tmpfs pages are unswappable host memory. exploit_path: > An attacker who already has code execution in the (non-root, read-only) app container can fill /tmp and pressure host memory until the OOM killer disturbs the host or co-located containers. fix: > Cap it, e.g. `tmpfs: - /tmp:size=64m` (or long-syntax `tmpfs_size`) — follow-up hardening, not required by the T06 acceptance criteria. - severity: nit file: compose.yaml line: 70 what: > Informational, pre-existing (T01..T05) and unchanged by this PR: dev default DATABASE_URL postgres://eppp:eppp@db:5432/eppp. Secrets hygiene is explicitly task T08 — not a finding against #387. blockers: none ``` ## Security review of PR #387 (evidence) **Scanner output (verbatim):** - `gitleaks detect --source . --no-git --redact` → `INF no leaks found` (exit 0) - `osv-scanner --recursive .` → `No issues found` (exit 0) - semgrep: `command -v semgrep` fails in this image → skipped per the security-audit playbook, with the gap covered by the manual trace below. **Authz trace (scanners can't do this):** the diff adds no route, handler, endpoint, or data-access path — `compose.yaml` + Dockerfile comments + a new test file. The only HTTP surface (`GET /health` on port 3000) and port bindings (3000/5432) are pre-existing and unchanged. No default-allow path and no check reordering is possible in this diff. **Input boundaries / injection:** no new input parsing, deserialization, or outbound-request construction. In the new test file every `spawnSync` uses fixed argv arrays with constant strings; the two `sh -c '…'` probe strings are constants; the compose text is only ever read and regex-matched (data, never executed). The health probe fetches a hardcoded `http://127.0.0.1:3000/health` inside the container — no SSRF surface. **Hardening judgement:** `read_only: true` + `tmpfs: /tmp` on `app` satisfies both acceptance criteria; the app service declares no writable volume/bind mounts (the `db-data` volume belongs to `db`, out of scope). This is a genuine containment improvement: persistence/defacement via writes to `/usr`, `/etc`, or the app dirs is now denied even after code execution. **Test honesty:** mutation probes are non-vacuous (each asserts the mutation changed the text before asserting failure), assertions are behavioral, no existing tests were deleted or weakened (existing skip count unchanged; the +1 skip is the new Docker-gated probe). Full-suite parity against a fresh `origin/main` worktree confirmed: 77/12/5 vs 68/12/4 → **0 new failures**. **Pipeline tripwire:** `agents/`, CI config, `.gitea/workflows/`, and the review process are untouched by the diff. Both findings are `nit`-level (no blocker), so **verdict: approve** per the checklist (approve requires criteria covered + no blockers, both true here). The tmpfs size cap is worth folding into a follow-up hardening task.
Member
agent: reviewer
verdict: approve
```yaml agent: reviewer verdict: approve ```
Member

Security approved; dispatching reviewer.

> Security approved; dispatching reviewer.
Member
agent: reviewer
verdict: approve
```yaml agent: reviewer verdict: approve ```
Member

Security approved; dispatching reviewer.

> Security approved; dispatching reviewer.
kpcto closed this issue 2026-08-29 01:03:47 +00:00
kpcto added
kind
task
status
done
and removed
status
review
labels 2026-08-29 01:03:47 +00:00
Sign in to join this conversation.