Skip to content

fix(hardening): remove the shell ubi-micro ships + detect shells at real paths - #2

Merged
NWarila merged 1 commit into
mainfrom
fix/no-shell-hardening
Jun 2, 2026
Merged

fix(hardening): remove the shell ubi-micro ships + detect shells at real paths#2
NWarila merged 1 commit into
mainfrom
fix/no-shell-hardening

Conversation

@NWarila

@NWarila NWarila commented Jun 2, 2026

Copy link
Copy Markdown
Owner

The gap (caught by local + adversarial build review)

The "distroless / no shell" guarantee was false: ubi9/ubi-micro itself ships /usr/bin/bash (+ /usr/bin/sh), and COPY --from=builder /rootfs/ / overlays onto it without deleting it. The hardening test missed it because it only checked /bin/bash — and on UBI /bin is a symlink to /usr/bin, so the real file lives at usr/bin/bash and the literal /bin/bash tar entry never exists.

Root-cause fix

  • Builder (rpm-rootfs): the dependency closure of any RPM drags bash into the empty installroot → remove it with rpm -e --nodeps --noscripts bash (rpmdb stays consistent → scanners still enumerate packages), fail closed if a shell survives. (No # comments inside the RUN — a comment line silently breaks the \ continuation; that was a second bug found en route.)
  • Runtime (FROM ubi-micro): delete the shell ubi-micro itself carries (COPY can't remove a base file), fail closed if any survives.

Detection fix (real-path aware)

  • runtime-hardening.sh: forbid /usr/bin/{sh,bash,dash} + a generic scan for any shell under any bin/sbin path.
  • verify.py check_runtime_script + check_image_manifest.py FORBIDDEN_BASELINE + example manifest forbidden_executables: add the real /usr/bin shell paths.

Validation (empirical, local)

--no-cache build → zero shells in the image (docker export | tar -tf | grep finds none), rpmdb preserved (scanners enumerate 36 pkgs), CA bundle populated, runtime-hardening.sh passes, verify.py ci green. The CI image build + hardening job re-proves it on a fresh runner.

🤖 Generated with Claude Code

…al paths

A local + adversarial build review caught that the "no shell" claim was false:
ubi9/ubi-micro itself ships /usr/bin/bash (+ /usr/bin/sh), and `COPY --from=builder
/rootfs/ /` overlays onto it without deleting it, so every derived image shipped a
working shell. The hardening test missed it because it only checked /bin/bash and
on UBI /bin is a symlink to /usr/bin (the export tar stores the real file under
usr/bin), so the literal /bin/bash entry never exists.

Root-cause fix:
- containers/Dockerfile builder (rpm-rootfs): the dependency closure of any RPM
  drags bash into the empty installroot; remove it with `rpm -e --nodeps
  --noscripts bash` (rpmdb stays consistent so scanners still enumerate packages)
  and rm any other shell, fail closed if one survives. (NOTE: no '#' comments
  inside the RUN -- a comment line silently breaks the '\' continuation.)
- containers/Dockerfile runtime (FROM ubi-micro): delete the shell ubi-micro
  itself carries (COPY cannot remove a base file), fail closed if any survives.

Detection fix (catch a shell at its real path, not just /bin/*):
- tests/runtime-hardening.sh: forbid /usr/bin/{sh,bash,dash} in addition to
  /bin/*, plus a generic scan for any shell binary under any bin/sbin path.
- tools/verify.py check_runtime_script: require the /usr/bin shell asserts + the
  generic-scan marker.
- tools/check_image_manifest.py FORBIDDEN_BASELINE + examples manifest
  forbidden_executables: add /usr/bin/{sh,bash}.

Validated empirically: --no-cache build -> zero shells in the image (export+grep),
rpmdb preserved, CA bundle populated, runtime-hardening.sh passes, verify.py ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NWarila
NWarila merged commit bb08a2f into main Jun 2, 2026
19 checks passed
@NWarila
NWarila deleted the fix/no-shell-hardening branch June 2, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant