Skip to content

[BUG] Certificates in /app/certs are never imported for the default non-root container user #285

Closed

Description

@LarsLaskowski

Summary

The container entrypoint imports custom CA certificates from /app/certs only when it runs as root or when the system trust store is writable. The image runs as UID 64000 / GID 0 and leaves the Alpine trust store owned by root and not group-writable, so for the default user the import is always skipped with a message. Operators who mount an internal CA (for a private registry, a Trivy server, Portainer, a TLS Docker endpoint or an OTLP collector) get TLS validation failures unless they run the container as root, which defeats the non-root hardening.

Area

  • docs/areas/hosting.md (added in [Docs] Describe the current behavior by area #263) — Container image, Open question 3
  • Security area (.squad/project.md): Container image and runtime user (src/DockerUpdateGuard/Dockerfile, non-root UID 64000, GID 0); also affects which certificates outbound TLS trusts (TLS and transport relaxations).

Current behavior

  • src/DockerUpdateGuard/Dockerfile:51 installs ca-certificates as root; :68-73 changes ownership only of /app and then switches to USER 64000:0. Nothing changes the ownership or mode of /usr/local/share/ca-certificates or /etc/ssl/certs, which stay root:root and not group-writable (the comment at :66-67 already says the import "degrades gracefully when the trust store is not writable").
  • src/DockerUpdateGuard/entrypoint.sh:104-129:
# Only attempt to import when running as root or when trust store is writable
if [ "$(id -u)" = "0" ] || [ -w /usr/local/share/ca-certificates ] ; then
  ...
  update-ca-certificates || echo "Warning: update-ca-certificates failed"
else
  echo "No permission to write system trust store; skipping certificate import"
fi
  • Even if /usr/local/share/ca-certificates were writable, update-ca-certificates must also write /etc/ssl/certs/ca-certificates.crt (and links in /etc/ssl/certs), which are root-owned too.

Reproduction:

  1. docker run the published image with -v ./my-ca:/app/certs:ro (containing my-root-ca.crt) and the default user.
  2. The log shows Found certificate files in /app/certs; attempting import into system trust store... followed by No permission to write system trust store; skipping certificate import.
  3. Any outbound HTTPS call to a host signed by that CA (e.g. a private registry, TrivyBaseUrl, a Portainer BaseUrl, a TLS OtlpEndpoint) fails certificate validation.

(Verified by reading the Dockerfile and entrypoint; a container runtime was not available to run the image during analysis — the acceptance criteria require verifying it in the built image.)

Expected behavior

  • With the default user (UID 64000, GID 0) — and with any other UID in group 0 — every .crt and .pem file in /app/certs is trusted for outbound TLS by the application (.NET HttpClient) and by the bundled Trivy CLI, in addition to the image's system CAs.
  • The container still never runs as UID 0; the import does not require root.
  • The import still never stops the container: missing directory, no files, unreadable or invalid files produce a message and the container starts.
  • The system CAs remain trusted; mounted certificates add to them and never replace them.
  • When the container runs as root, behavior is equivalent (certificates trusted).

Acceptance criteria

  • Built image, default user, /app/certs with one .crt and one .pem: the entrypoint reports the certificates as imported (no "skipping" message) and an HTTPS request from the app to a server signed by that CA succeeds (manual verification documented in the PR, e.g. against a local test server).
  • Same with --user 12345:0 (arbitrary UID in group 0).
  • Same with the image started as root.
  • No /app/certs / empty /app/certs / a non-certificate file: container starts, message printed, system CAs unaffected.
  • The Trivy CLI (/usr/local/bin/trivy, client mode against a server signed by the mounted CA) trusts the mounted CA.
  • The image still runs as USER 64000:0; no new write permission for group 0 outside what the chosen option below needs.
  • If any C# is touched, unit tests per docs/UNIT_TESTS.md; the shell change itself is verified manually (there is no shell test harness in this repository).
  • INSTALL.md/README document mounting /app/certs for the default user.
  • The area document docs/areas/hosting.md describes the new behavior and its Known gaps entry for this issue is removed; docs/ARCHITECTURE.md (Security posture) is updated if it mentions the import.

Implementation notes

Two options:

  1. Make the trust store group-writable in the Dockerfile (chgrp -R 0 /usr/local/share/ca-certificates /etc/ssl/certs && chmod -R g=u …) and keep update-ca-certificates in the entrypoint. Simple, but widens write access to the system trust store for group 0 inside the container and does not work with a read-only root filesystem.
  2. Build a combined bundle in a writable location and point the runtime at it (recommended). In entrypoint.sh, when /app/certs contains .crt/.pem files: create a directory under /tmp (already used for the Trivy cache, writable for any UID), write /etc/ssl/certs/ca-certificates.crt plus the mounted certificates into one bundle there, and export SSL_CERT_FILE=<bundle> before exec dotnet …. .NET on Linux (OpenSSL) and Go programs such as Trivy honor SSL_CERT_FILE. No root, no change to the system trust store, works with a read-only root filesystem when /tmp is a tmpfs. Keep the root path identical (no special case needed).
  • Validate each file minimally before adding it (e.g. it contains a -----BEGIN CERTIFICATE----- block); skip others with a message, as today for non-certificate files.
  • Do not let a pre-set SSL_CERT_FILE from the operator be silently overwritten: if it is already set, append to/derive from that file or print a message and leave it.
  • Keep the readability check for appsettings*.json (entrypoint.sh:85-94) unchanged.
  • Update the Dockerfile comment at :62-67.
  • Must not change: non-root UID 64000, GID 0, /app ownership model, exit code 1 only for unreadable settings files.
  • No configuration keys, no migration.

Related


Generated by Claude Code

Activity

  1. LarsLaskowski commented on Oct 26, 2026

    @LarsLaskowski
    OwnerAuthor

    Squad working record

    Tier: security. Plan: https://github.com/LarsLaskowski/DockerUpdateGuard/blob/3f6ebb537c4a930868f109acbdacb516ac67c64b/specs/issue-285/plan.md (revision 1; decision record 0011 Mounted CA certificates are trusted through a private SSL_CERT_DIR). Pull request: #335. Squad lesson: #336.

    Summary: The entrypoint writes every accepted PEM certificate block from /app/certs into its own file in a private 0700 directory under /tmp and appends that directory to SSL_CERT_DIR (/etc/ssl/certs or the operator's value first). This differs from the issue's option 2 (combined SSL_CERT_FILE bundle): Security measured that .NET 10 stops reading a bundle at the first undecodable block and then ignores every later CA, while a directory isolates a bad file. No root, no trust-store write, read-only root filesystem works with a tmpfs /tmp. Plan challenge: 4 minor objections, all taken in; Security plan review: 2 blocking findings, resolved in one revision and confirmed. Diff review: Reviewer 0 blocking (1 non-blocking doc fix applied), Security diff review APPROVED.

    log.md
    Date Step Member Result
    2026-10-26 05:49 1 Intake Orchestrator Issue 285 read: bug (label bug), open, no comments; branch claude/squad-issue-285-1de1hl
    2026-10-26 05:57 2 Plan squad-lead DONE: tier security; plan.md + record 0011; plan-check passes (opus/medium · 120,038 tokens · 29 tool uses · 454 s)
    2026-10-26 06:05 3 Plan security review squad-security CHANGES_REQUIRED: 2 blocking (one non-DER block drops later CAs in .NET; BEGIN/END matched by prefix breaks AC6), 2 non-blocking (opus/medium · 54,996 tokens · 12 tool uses · 480 s)
    2026-10-26 06:05 2 Plan challenge squad-devils-advocate OBJECTIONS 0 major, 4 minor (newline before append, rm -rf cleanup on shared /tmp, AC5 Trivy test target, settings check line 12 uses [ ]) (opus/medium · 56,479 tokens · 8 tool uses · 104 s)
    2026-10-26 06:09 2 Plan revise squad-lead DONE: revision 1; switched to per-block SSL_CERT_DIR dir; all Security/DA findings accepted; plan-check passes (opus/medium · 32,777 tokens · 11 tool uses · 179 s · continued)
    2026-10-26 06:10 4-5 Skeleton, tests first Orchestrator skipped: plan declares no production/test C# code (Verification without tests)
    2026-10-26 06:10 3 Plan security review squad-security APPROVED on revision 58587f5 (delta confirmation, all 4 findings resolved) (opus/medium · 12,623 tokens · 3 tool uses · 45 s · continued)
    2026-10-26 06:17 6 Implement squad-dev DONE: entrypoint.sh rewrite, Dockerfile comments, docs; AC1-10,12 verified in real Docker (build used scratch Dockerfile without apk step: proxy blocks dl-cdn); AC5 used --scanners secret; real ca-certificates package and real app start not verified (sonnet/medium · 126,541 tokens · 39 tool uses · 406 s)
    2026-10-26 06:20 7 Code check squad-code-officer DL3018 hadolint finding on pre-existing line cleared by ignore comment; analyzer gate passes (haiku/medium · 65,354 tokens · 5 tool uses · 52 s)
    2026-10-26 06:23 7 Code check Orchestrator Format check, analyzer gate, tests (722 passed), scope-check, decision-check pass; coverage gate n/a (no C#)
    2026-10-26 06:25 7 Sonar gate Orchestrator NOT CHECKED: scan uploaded, results API HTTP 403; left to step 11 (sonar-check.py pr)
    2026-10-26 06:28 8 Review squad-reviewer Round 1 on 4ae9d9e: BLOCKING 0, NON-BLOCKING 1 (DOCKER.md:124 says PEM or CRT; DER not accepted) (opus/medium · 63,685 tokens · 10 tool uses · 118 s)
    2026-10-26 06:28 8 Review squad-security diff review on 4ae9d9e: APPROVED (no key material reaches cert dir, 0700 temp dir, SSL_CERT_DIR handling, fail-safe) (opus/medium · 39,072 tokens · 6 tool uses · 58 s)
    2026-10-26 06:28 8 Review Orchestrator verbatim: DOCKER.md:124 PEM-only wording applied as proposed; format, scope, decision checks pass
    2026-10-26 06:30 9 PR approval squad-lead APPROVED: deviations accepted as manual checks (unmodified-Dockerfile build, real app start, Sonar S7688 read); record 0011 set to Accepted (opus/medium · 53,069 tokens · 4 tool uses · 61 s)
    2026-10-26 06:30 10 Pull request Orchestrator working record posted to the issue; specs removed; draft PR opened
    2026-10-26 06:31 12 Wrap-up Orchestrator squad lesson filed: #336 (hadolint DL3018 on unchanged line fails the analyzer gate)

    Squad run metrics

    Member Model/effort Launches Tokens Tool uses Seconds
    squad-code-officer haiku/medium 1 65,354 5 52
    squad-dev sonnet/medium 1 126,541 39 406
    squad-devils-advocate opus/medium 1 56,479 8 104
    squad-lead opus/medium 3 (1 continued) 205,884 44 694
    squad-reviewer opus/medium 1 63,685 10 118
    squad-security opus/medium 3 (1 continued) 106,691 21 583
    Total 10 (2 continued) 624,634 127 1957
    Member Wall time (s) Not covered by launches (s) Gate runs
    Orchestrator 2,520 725 2

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions