Merge pull request 'fix: vision(#623): disinto-chat sandbox hardening (#706)' (#724) from fix/issue-706 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
cf4e9983c2
4 changed files with 140 additions and 4 deletions
|
|
@ -15,8 +15,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 \
|
python3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Non-root user
|
# Non-root user — fixed UID 10001 for sandbox hardening (#706)
|
||||||
RUN useradd -m -u 1000 -s /bin/bash chat
|
RUN useradd -m -u 10001 -s /bin/bash chat
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY server.py /usr/local/bin/server.py
|
COPY server.py /usr/local/bin/server.py
|
||||||
|
|
@ -28,4 +28,8 @@ RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py
|
||||||
USER chat
|
USER chat
|
||||||
WORKDIR /var/chat
|
WORKDIR /var/chat
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint-chat.sh"]
|
ENTRYPOINT ["/entrypoint-chat.sh"]
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,22 @@ set -euo pipefail
|
||||||
# Exec-replace pattern: this script is the container entrypoint and runs
|
# Exec-replace pattern: this script is the container entrypoint and runs
|
||||||
# the server directly (no wrapper needed). Logs to stdout for docker logs.
|
# the server directly (no wrapper needed). Logs to stdout for docker logs.
|
||||||
|
|
||||||
LOGFILE="/var/chat/chat.log"
|
LOGFILE="/tmp/chat.log"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE"
|
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Sandbox sanity checks (#706) — fail fast if isolation is broken
|
||||||
|
if [ -e /var/run/docker.sock ]; then
|
||||||
|
log "FATAL: /var/run/docker.sock is accessible — sandbox violation"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
log "FATAL: running as root (uid 0) — sandbox violation"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Verify Claude CLI is available (expected via volume mount from host).
|
# Verify Claude CLI is available (expected via volume mount from host).
|
||||||
if ! command -v claude &>/dev/null; then
|
if ! command -v claude &>/dev/null; then
|
||||||
log "FATAL: claude CLI not found in PATH"
|
log "FATAL: claude CLI not found in PATH"
|
||||||
|
|
|
||||||
|
|
@ -475,14 +475,23 @@ services:
|
||||||
|
|
||||||
# Chat container — Claude chat UI backend (#705)
|
# Chat container — Claude chat UI backend (#705)
|
||||||
# Internal service only; edge proxy routes to chat:8080
|
# Internal service only; edge proxy routes to chat:8080
|
||||||
|
# Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps
|
||||||
chat:
|
chat:
|
||||||
build:
|
build:
|
||||||
context: ./docker/chat
|
context: ./docker/chat
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: disinto-chat
|
container_name: disinto-chat
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64m
|
||||||
security_opt:
|
security_opt:
|
||||||
- apparmor=unconfined
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
pids_limit: 128
|
||||||
|
mem_limit: 512m
|
||||||
|
memswap_limit: 512m
|
||||||
volumes:
|
volumes:
|
||||||
# Mount claude binary from host (same as agents)
|
# Mount claude binary from host (same as agents)
|
||||||
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
|
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
|
||||||
|
|
|
||||||
113
tools/edge-control/verify-chat-sandbox.sh
Executable file
113
tools/edge-control/verify-chat-sandbox.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# verify-chat-sandbox.sh — One-shot sandbox verification for disinto-chat (#706)
|
||||||
|
#
|
||||||
|
# Runs against a live compose project and asserts hardening constraints.
|
||||||
|
# Exit 0 if all pass, non-zero otherwise.
|
||||||
|
|
||||||
|
CONTAINER="disinto-chat"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); }
|
||||||
|
fail() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
echo "=== disinto-chat sandbox verification ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- docker inspect checks ---
|
||||||
|
|
||||||
|
inspect_json=$(docker inspect "$CONTAINER" 2>/dev/null) || {
|
||||||
|
echo "ERROR: container '$CONTAINER' not found or not running"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ReadonlyRootfs
|
||||||
|
readonly_rootfs=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['ReadonlyRootfs'])")
|
||||||
|
if [ "$readonly_rootfs" = "True" ]; then
|
||||||
|
pass "ReadonlyRootfs=true"
|
||||||
|
else
|
||||||
|
fail "ReadonlyRootfs expected true, got $readonly_rootfs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CapAdd — should be null or empty
|
||||||
|
cap_add=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['CapAdd'])")
|
||||||
|
if [ "$cap_add" = "None" ] || [ "$cap_add" = "[]" ]; then
|
||||||
|
pass "CapAdd=null (no extra capabilities)"
|
||||||
|
else
|
||||||
|
fail "CapAdd expected null, got $cap_add"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CapDrop — should contain ALL
|
||||||
|
cap_drop=$(echo "$inspect_json" | python3 -c "import sys,json; caps=json.load(sys.stdin)[0]['HostConfig']['CapDrop'] or []; print(' '.join(caps))")
|
||||||
|
if echo "$cap_drop" | grep -q "ALL"; then
|
||||||
|
pass "CapDrop contains ALL"
|
||||||
|
else
|
||||||
|
fail "CapDrop expected ALL, got: $cap_drop"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PidsLimit
|
||||||
|
pids_limit=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['PidsLimit'])")
|
||||||
|
if [ "$pids_limit" = "128" ]; then
|
||||||
|
pass "PidsLimit=128"
|
||||||
|
else
|
||||||
|
fail "PidsLimit expected 128, got $pids_limit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Memory limit (512MB = 536870912 bytes)
|
||||||
|
mem_limit=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['Memory'])")
|
||||||
|
if [ "$mem_limit" = "536870912" ]; then
|
||||||
|
pass "Memory=512m"
|
||||||
|
else
|
||||||
|
fail "Memory expected 536870912, got $mem_limit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SecurityOpt — must contain no-new-privileges
|
||||||
|
sec_opt=$(echo "$inspect_json" | python3 -c "import sys,json; opts=json.load(sys.stdin)[0]['HostConfig']['SecurityOpt'] or []; print(' '.join(opts))")
|
||||||
|
if echo "$sec_opt" | grep -q "no-new-privileges"; then
|
||||||
|
pass "SecurityOpt contains no-new-privileges"
|
||||||
|
else
|
||||||
|
fail "SecurityOpt missing no-new-privileges (got: $sec_opt)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No docker.sock bind mount
|
||||||
|
binds=$(echo "$inspect_json" | python3 -c "import sys,json; binds=json.load(sys.stdin)[0]['HostConfig']['Binds'] or []; print(' '.join(binds))")
|
||||||
|
if echo "$binds" | grep -q "docker.sock"; then
|
||||||
|
fail "docker.sock is bind-mounted"
|
||||||
|
else
|
||||||
|
pass "No docker.sock mount"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- runtime exec checks ---
|
||||||
|
|
||||||
|
# touch /root/x should fail (read-only rootfs + unprivileged user)
|
||||||
|
if docker exec "$CONTAINER" touch /root/x 2>/dev/null; then
|
||||||
|
fail "touch /root/x succeeded (should fail)"
|
||||||
|
else
|
||||||
|
pass "touch /root/x correctly denied"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# /var/run/docker.sock must not exist
|
||||||
|
if docker exec "$CONTAINER" ls /var/run/docker.sock 2>/dev/null; then
|
||||||
|
fail "/var/run/docker.sock is accessible"
|
||||||
|
else
|
||||||
|
pass "/var/run/docker.sock not accessible"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# /etc/shadow should not be readable
|
||||||
|
if docker exec "$CONTAINER" cat /etc/shadow 2>/dev/null; then
|
||||||
|
fail "cat /etc/shadow succeeded (should fail)"
|
||||||
|
else
|
||||||
|
pass "cat /etc/shadow correctly denied"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
|
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue