fix(security): Pi 노드 하드닝 6종 medium 갭 보완 + 회귀 수정

적대적 보안 감사에서 확정된 medium 6건 보완 (critical/high 0):
- systemd 유닛(양 노드): NoNewPrivileges + ProtectSystem=strict/
  ProtectHome=read-only/ProtectKernel*/ProtectControlGroups/RestrictSUIDSGID/
  LockPersonality/RestrictRealtime/RestrictAddressFamilies(+AF_NETLINK) 샌드박싱.
- harden_pi.sh: SSH 차단을 마지막 단계([6/6])로 재정렬 — 앞 단계 실패 시 set -e 로
  SSH 가 살아있는 상태에서 중단(원격 복구 가능). nftables·unattended-upgrades 를
  오프라인(debs/ --no-download)으로도 설치 지원(폐쇄망 harden). 미발견 secret 경고.
- install.sh(양 노드): config_local.py 설치 즉시 chmod 600 (harden 전 노출창 제거).
- vendor.sh(양 노드): .deb 를 파일명이 아닌 dpkg-deb Architecture 필드로 실검사
  (arm64/all 만 허용, 그 외 hard-fail). nftables·unattended-upgrades 를 번들에 포함.

검증(3-리뷰어 워크플로)이 잡은 회귀 동반 수정:
- [critical] ProtectHome=read-only 가 lgpio 의 작업디렉터리(.lgd-nfy 파이프) 쓰기를
  막아 add_event_detect(누수 인터럽트 등록) 실패 → 데몬 크래시루프 → 감지 불능.
  Environment=LG_WD=/tmp 로 PrivateTmp 쓰기가능 /tmp 이동(누수 노드 한정, SHT30 무관).
- [medium] 오프라인 설치에 --no-install-recommends 추가(vendor --no-recommends 정합,
  네트워크 폴백 방지) — install.sh x2 + harden_pi.sh.
- SupplementaryGroups=video gpio 명시 · nft|head 에 ||true(SIGPIPE 중단 방지) ·
  online harden 분기에 apt-get update.

※ 온디바이스 런타임 테스트 1회 필요(빌드머신 Windows 에선 systemd 실행 불가):
  실 Pi 에서 systemctl start 후 add_event_detect 콜백·카메라 촬영·업로드 정상 확인.
This commit is contained in:
cw2007 2026-07-03 09:28:44 +09:00
parent 96c5205f61
commit de51d1c3d4
7 changed files with 189 additions and 29 deletions

View file

@ -50,7 +50,8 @@ if [ "${OFFLINE}" = "1" ]; then
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update
@ -91,6 +92,15 @@ if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/leak_sensor.log; then
fi
sudo chmod 640 /var/log/leak_sensor.log
# 3.5) config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
fi
# 4) config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then
echo ""

View file

@ -22,7 +22,8 @@ Wants=network-online.target
Type=simple
User=pi
Group=pi
SupplementaryGroups=video
# rpi-lgpio 는 /dev/gpiochip0(root:gpio 660) 접근에 gpio 그룹, 카메라는 video 그룹 필요.
SupplementaryGroups=video gpio
WorkingDirectory=/home/pi/raspberry_pi4
ExecStart=/home/pi/raspberry_pi4/venv/bin/python leak_sensor.py
Restart=always
@ -33,10 +34,38 @@ SyslogIdentifier=leak-sensor
# 환경 변수
Environment=PYTHONUNBUFFERED=1
# lgpio(rpi-lgpio 백엔드)는 add_event_detect 시 작업디렉터리에 .lgd-nfy 알림 파이프를
# 만든다. 아래 ProtectHome=read-only 로 /home 이 읽기전용이 되므로, PrivateTmp 의
# 쓰기가능 /tmp 로 lgpio 작업디렉터리를 옮긴다. (없으면 누수 인터럽트 등록이 실패해
# 데몬이 크래시루프 → 누수 감지 불능.) LG_WD 는 lg 라이브러리가 읽는 환경변수.
# ※ 이 하드닝은 chardev 백엔드(python3-rpi-lgpio) 를 가정한다. sysfs 기반
# python3-rpi.gpio 로 되돌리면 ProtectKernelTunables/ProtectHome 와 충돌하므로
# 백엔드를 바꾸지 말 것(vendor.sh/install.sh 의 python3-rpi-lgpio 핀 유지).
Environment=LG_WD=/tmp
# 누수 촬영 임시파일을 프로세스 전용 /tmp 로 격리 (공용 /tmp 노출 방지)
PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. 카메라/GPIO 는 /dev char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/gpiochip*, 카메라 노드가 숨겨져 동작 불능.
# 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5
StartLimitInterval=600s

View file

@ -38,7 +38,8 @@ CAM="${CAM:-}"
if [ -z "${CAM}" ]; then
if apt-cache show rpicam-apps >/dev/null 2>&1; then CAM=rpicam-apps; else CAM=libcamera-apps; fi
fi
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv ${CAM}"
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
BASE_PKGS="python3-rpi-lgpio python3-requests python3-venv nftables unattended-upgrades ${CAM}"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
@ -55,4 +56,23 @@ done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

View file

@ -52,7 +52,8 @@ if [ "${OFFLINE}" = "1" ]; then
exit 1
fi
echo " 오프라인 .deb 설치 (apt-get --no-download — 의존성 순서·해결 자동, 네트워크 미사용)"
sudo apt-get install -y --no-download ./debs/*.deb
# --no-install-recommends: vendor.sh 가 Recommends 를 안 담으므로(--no-recommends) 맞춘다.
sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치 (인터넷 필요)"
sudo apt-get update
@ -120,6 +121,15 @@ if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/sht30_sensor.log; the
fi
sudo chmod 640 /var/log/sht30_sensor.log
# config_local.py 즉시 잠금 (API_KEY 포함 — harden 전까지 world-readable 방지)
# 하드닝(harden_pi.sh)이 나중에 다시 조이지만, 그 사이 창(window)에도 노출되지
# 않도록 설치 시점에 바로 600 으로 잠근다. harden 여부와 무관하게 보호된다.
if [ -f config_local.py ]; then
chmod 600 config_local.py
sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true
echo " config_local.py 권한 600 (소유자 ${SERVICE_USER}) 설정"
fi
# config_local 안내 (fail closed — 없으면 실행 시 즉시 중단됨)
if [ ! -f config_local.py ]; then
echo ""

View file

@ -38,6 +38,26 @@ Environment=PYTHONUNBUFFERED=1
# 프로세스 전용 /tmp 격리
PrivateTmp=true
# ── 샌드박싱(하드닝) — 데몬 침해 시 root 승격·파일시스템 오염을 차단 ──────────
# User=pi 는 NOPASSWD sudo 계정이므로 NoNewPrivileges 로 setuid(sudo) 승격 경로를 막는다.
NoNewPrivileges=true
# 루트파일시스템 읽기전용 + /var/log 만 쓰기 허용(로그 로테이션 포함). /tmp 는 PrivateTmp.
ProtectSystem=strict
ReadWritePaths=/var/log
# /home 는 읽기전용(자기 코드/config_local.py 읽기만; 쓰기·타 계정 홈 접근 차단).
ProtectHome=read-only
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
# HTTPS(POST)+로컬소켓(DNS/journal)만. I2C 는 /dev/i2c-1 char (소켓 아님)라 무관.
# AF_NETLINK 필수 — glibc getaddrinfo/NSS 가 로컬주소 열거에 netlink 를 쓴다
# (빼면 DNS 해석이 간헐 실패 → 서버 POST 불가). AF_INET/6=HTTPS, AF_UNIX=DNS·journal.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
# ※ PrivateDevices 는 쓰지 않는다 — /dev/i2c-1 이 숨겨져 센서 접근 불능.
# 재시작 상한 (10분 내 5번 실패 시 중단)
StartLimitBurst=5
StartLimitInterval=600s

View file

@ -35,7 +35,8 @@ echo " wheelhouse 파일 수: ${WHEEL_COUNT}"
echo "[vendor] (2/2) apt .deb → debs/"
mkdir -p debs
BASE_PKGS="python3-requests python3-venv i2c-tools"
# nftables·unattended-upgrades 도 포함 → harden_pi.sh 를 폐쇄망에서 오프라인 실행 가능.
BASE_PKGS="python3-requests python3-venv i2c-tools nftables unattended-upgrades"
DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \
| awk '/^\w/{print $1}' | sort -u)"
@ -52,4 +53,23 @@ done
[ "${MISS}" = 0 ] || { echo "[ERROR] 필수 .deb 누락 — 반드시 타깃 arch(aarch64 Bookworm)에서 vendor.sh 를 실행하세요."; exit 1; }
echo " debs 파일 수: $(ls debs/*.deb 2>/dev/null | wc -l) (필수 패키지 모두 확인됨)"
# 아키텍처 실검사 — 파일명이 아니라 .deb 내부 Architecture 필드로 타깃(arm64/all) 검증.
# (호스트가 amd64면 apt-get download 가 amd64 .deb 를 받아, 타깃 Pi 에서 dpkg 가
# "wrong architecture" 로 깨진다. 파일명만 보는 검사는 이를 못 잡는다.)
BAD_ARCH=0
for deb in debs/*.deb; do
[ -e "$deb" ] || continue
arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')"
case "$arch" in
arm64|all) : ;;
*) echo "[ERROR] arch 불일치: $(basename "$deb") = ${arch} (arm64/all 아님)"; BAD_ARCH=1 ;;
esac
done
if [ "${BAD_ARCH}" != 0 ]; then
echo "[ERROR] 타깃(aarch64/arm64) 이 아닌 .deb 가 있습니다. 호스트 arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2
echo " 반드시 arm64 Pi(또는 arm64 chroot/qemu)에서 vendor.sh 를 실행하세요." >&2
exit 1
fi
echo " arch 검사 통과 (모든 .deb = arm64/all)"
echo "[vendor] 완료. wheelhouse/ 와 debs/ 를 타깃 Pi 4 로 전송 후 install.sh 실행."

View file

@ -9,28 +9,60 @@
# - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST).
# - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지).
#
# 실행 순서(중요): SSH 차단은 "맨 마지막" 단계다. 그 앞의 실패하기 쉬운 단계
# (패키지 설치·방화벽·권한)에서 문제가 나면 set -e 로 SSH 가 살아있는 상태에서
# 중단되어 원격 디버깅이 가능하다. 모든 앞 단계가 성공해야만 SSH 를 끊는다.
#
# 폐쇄망(air-gapped): 같은 폴더에 vendor.sh 산출물 debs/ 가 있으면 nftables·
# unattended-upgrades 도 오프라인(--no-download)으로 설치한다. 없으면 온라인 apt.
# (vendor.sh 가 이 두 패키지도 미리 받아 debs/ 에 포함한다.)
#
# 실행: 모듈 설치(install.sh)와 검증(test_connection.py)이 전부 끝난 "마지막"에.
# sudo bash harden_pi.sh
# ⚠️ SSH 로 접속해 실행 중이면 완료 즉시 세션이 끊기고 재접속 불가 — 마지막 단계!
# ⚠️ SSH 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가.
# =============================================================================
set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; }
# debs/ 를 스크립트 위치 기준으로 찾도록 cwd 를 스크립트 폴더로 이동(절대경로 로직엔 무관).
cd "$(dirname "$0")" 2>/dev/null || true
echo "[1/6] SSH 완전 비활성..."
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다.
# 오프라인 번들(debs/) 유무 판정 — install.sh 와 동일한 규칙.
OFFLINE_DEBS=0
if compgen -G "./debs/*.deb" >/dev/null 2>&1; then
OFFLINE_DEBS=1
echo "[INFO] 오프라인 번들(debs/) 감지 → 패키지 설치는 네트워크 미사용(--no-download)"
fi
echo "[2/6] 불필요 리스닝/주변 서비스 비활성..."
for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \
triggerhappy.service triggerhappy.socket cups.service cups.socket \
cups-browsed.service ModemManager.service; do
systemctl disable --now "$svc" 2>/dev/null || true
done
# 필요 패키지 확보: 이미 설치돼 있으면 skip, 아니면 오프라인 debs/ → 온라인 apt 순.
# 설치 후 재검증하여 실패 시 즉시 중단(SSH 아직 살아있음).
ensure_pkgs() {
local missing=() p
for p in "$@"; do dpkg -s "$p" >/dev/null 2>&1 || missing+=("$p"); done
if [ "${#missing[@]}" -eq 0 ]; then
echo " (${*} 이미 설치됨 — skip)"
elif [ "${OFFLINE_DEBS}" = 1 ]; then
echo " 오프라인 debs/ 에서 설치 (네트워크 미사용): ${missing[*]}"
# --no-install-recommends: vendor.sh 가 Recommends 를 번들에 안 담으므로(--no-recommends)
# 설치 때도 Recommends 를 요구하지 않게 맞춘다(폐쇄망 결정성 — 네트워크 폴백 방지).
apt-get install -y --no-install-recommends --no-download ./debs/*.deb
else
echo " 온라인 apt 설치: ${missing[*]}"
# 단독 온라인 실행 대비(로컬 apt 목록이 비어있을 수 있음). install.sh 뒤면 no-op.
apt-get update -q || true
apt-get install -y -q "${missing[@]}"
fi
for p in "$@"; do
dpkg -s "$p" >/dev/null 2>&1 || {
echo "[ERROR] '$p' 설치 실패. 폐쇄망이면 vendor.sh(debs/)에 포함해 재배포하거나," >&2
echo " 온라인이면 네트워크를 확인하세요. (SSH 는 아직 차단되지 않음 — 복구 가능)" >&2
exit 1
}
done
}
echo "[3/6] 방화벽(nftables) — 인바운드 전부 차단..."
apt-get install -y -q nftables >/dev/null
echo "[1/6] 방화벽(nftables) — 인바운드 전부 차단..."
ensure_pkgs nftables
cat > /etc/nftables.conf <<'NFT'
#!/usr/sbin/nft -f
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만.
@ -51,8 +83,8 @@ NFT
systemctl enable --now nftables
nft -f /etc/nftables.conf
echo "[4/6] 자동 보안 패치(unattended-upgrades)..."
apt-get install -y -q unattended-upgrades >/dev/null
echo "[2/6] 자동 보안 패치(unattended-upgrades)..."
ensure_pkgs unattended-upgrades
# Raspberry Pi OS 는 기본 Origins 패턴(Debian 전용)에 안 걸린다 — Raspbian/RPF origin 추가.
cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT'
Unattended-Upgrade::Origins-Pattern {
@ -69,23 +101,42 @@ APT::Periodic::Unattended-Upgrade "1";
APT
systemctl enable --now unattended-upgrades 2>/dev/null || true
echo "[5/6] 로컬 권한 조이기..."
# 시크릿 파일 소유자 전용 (API_KEY 포함)
echo "[3/6] 로컬 권한 조이기..."
# 시크릿 파일 소유자 전용 (API_KEY 포함). 못 찾으면 조용히 넘기지 말고 경고.
SECRET_FOUND=0
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do
[ -f "$f" ] && { chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; }
if [ -f "$f" ]; then chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; SECRET_FOUND=1; fi
done
[ "${SECRET_FOUND}" = 1 ] || echo "[WARN] config_local.py 를 표준 경로에서 못 찾음 — 배치 경로가 다르면" \
"수동으로 잠그세요: chmod 600 <경로>/config_local.py (API_KEY 노출 방지)"
# 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구)
if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_boot_behaviour B1 || true
fi
echo "[6/6] 검증 (증적 캡처 대상 — SOURCE_SECURITY_EVIDENCE.md §4)..."
echo "-- ssh 상태 (disabled 여야 함):"
systemctl is-enabled ssh.service 2>&1 || true
echo "[4/6] 불필요 리스닝/주변 서비스 비활성..."
for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \
triggerhappy.service triggerhappy.socket cups.service cups.socket \
cups-browsed.service ModemManager.service; do
systemctl disable --now "$svc" 2>/dev/null || true
done
echo "[5/6] 사전 검증 캡처 (SSH 차단 전 — 방화벽/리스닝 상태)..."
echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):"
ss -lntup || true
echo "-- nftables 룰 (input policy drop 확인):"
nft list chain inet filter input | head -12
echo "-- nftables input chain (policy drop 확인):"
# || true: pipefail 하에서 head 가 파이프를 먼저 닫아 nft 가 SIGPIPE(141) 로 죽어도
# 이 검증 단계가 중단되지 않게 한다(SSH 차단 직전 단계라 오탐 중단 방지).
nft list chain inet filter input | head -12 || true
echo "[6/6] SSH 완전 비활성 (마지막 — 되돌리기 어려운 단계)..."
# 여기까지 왔다면 앞의 모든 단계가 성공한 것. 이제 SSH 를 끊는다.
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다.
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
echo "-- ssh 상태 (enabled 가 아니어야 함):"
systemctl is-enabled ssh.service 2>&1 || true
echo ""
echo "완료. 위 출력을 캡처해 보안 증적으로 보관할 것."
echo "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것."
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"