적대적 보안 감사에서 확정된 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 콜백·카메라 촬영·업로드 정상 확인.
142 lines
7.4 KiB
Bash
142 lines
7.4 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통)
|
|
#
|
|
# 정책 (2026-07 확정):
|
|
# - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST).
|
|
# 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의
|
|
# boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨).
|
|
# - 인바운드 전부 차단(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 로 접속해 실행 중이면 마지막 단계에서 세션이 끊기고 재접속 불가.
|
|
# =============================================================================
|
|
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
|
|
|
|
# 오프라인 번들(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
|
|
|
|
# 필요 패키지 확보: 이미 설치돼 있으면 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 "[1/6] 방화벽(nftables) — 인바운드 전부 차단..."
|
|
ensure_pkgs nftables
|
|
cat > /etc/nftables.conf <<'NFT'
|
|
#!/usr/sbin/nft -f
|
|
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만.
|
|
flush ruleset
|
|
table inet filter {
|
|
chain input {
|
|
type filter hook input priority 0; policy drop;
|
|
iif "lo" accept
|
|
ct state established,related accept
|
|
# 현장 진단용 ping 허용 (원치 않으면 아래 2줄 삭제)
|
|
ip protocol icmp icmp type echo-request accept
|
|
ip6 nexthdr ipv6-icmp accept
|
|
}
|
|
chain forward { type filter hook forward priority 0; policy drop; }
|
|
chain output { type filter hook output priority 0; policy accept; }
|
|
}
|
|
NFT
|
|
systemctl enable --now nftables
|
|
nft -f /etc/nftables.conf
|
|
|
|
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 {
|
|
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
|
|
"origin=Raspbian,codename=${distro_codename},label=Raspbian";
|
|
"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation";
|
|
};
|
|
// 자동 리부팅 금지 — 커널 패치 후에도 센서 공백을 만들지 않는다(수동 리부팅).
|
|
Unattended-Upgrade::Automatic-Reboot "false";
|
|
APT
|
|
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
|
|
APT::Periodic::Update-Package-Lists "1";
|
|
APT::Periodic::Unattended-Upgrade "1";
|
|
APT
|
|
systemctl enable --now unattended-upgrades 2>/dev/null || true
|
|
|
|
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
|
|
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 "[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 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 "완료. 위 [5/6]·[6/6] 출력을 캡처해 보안 증적으로 보관할 것."
|
|
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"
|