- tools/make_deploy_bundles.py: PMS7003·Zero2W 디스플레이 노드 번들 생성 반영(폐쇄망 동봉) - tools/harden_pi.sh: 하드닝 스크립트 갱신 - docs/superpowers: 노드 센서 통합·스모크 파일럿·대시보드 다크 설계/계획 문서 - docs/claude-config: IoT 납품·검증 스킬/룰 참조본 - review/: 프로젝트 리뷰·UI v2 목업
22 KiB
leak_sms_v3 프로젝트 리뷰
1. 리뷰 개요
| 항목 | 내용 |
|---|---|
| 리뷰 일자 | 2026-07-11 |
| 기준 브랜치 | feat/pi-c-sensor3-leak-node |
| 기준 커밋 | a72ea18 |
| 대상 | PHP/MySQL 서버, 누수·SHT30 Raspberry Pi 클라이언트, 배포 도구, SQL 스키마, 테스트와 운영 문서 |
| 제외 | 실제 Raspberry Pi/GPIO/I2C 장비 시험, 실 MySQL 통합 시험, 운영 SMS 게이트웨이, systemd 실기동 |
| 워킹 트리 | 수정 및 미추적 파일이 있는 상태. 리뷰 중 추가된 대시보드 분리 파일도 포함했으며 기존 변경은 수정하지 않음 |
이 보고서는 현재 워킹 트리의 정적 분석과 로컬에서 가능한 회귀 검증을 결합한 결과다. 단순한 코드 스타일보다 누수 감지의 정확성, 알림 전달 보장, 장애 복구, 보안 경계, 배포 재현성을 우선 평가했다.
2. 결론
현장 무인 배포를 보류한다.
정상 경로에는 HMAC 인증, nonce 재사용 방지, PDO 준비문, TOTP 로그인, CSRF 방어, 사진 형식·크기 검사 등 유효한 보호 장치가 있다. 그러나 누수 상태를 부팅 시 서버와 동기화하지 않는 문제, 독립 스레드 재시도로 인한 이벤트 역전, 하드웨어 오류를 정상 시뮬레이션 값으로 대체하는 동작 때문에 실제 누수·복구 상태와 서버 상태가 영구적으로 달라질 수 있다. 알림 실패를 재처리하는 내구성 있는 큐도 없다.
| 등급 | 건수 | 의미 |
|---|---|---|
| P0 | 0 | 즉시 악용 또는 전면 장애가 확인된 항목 |
| P1 | 7 | 현장 배포 전에 해결해야 할 정확성·안전·보안·준법 결함 |
| P2 | 7 | 다음 안정화 단계에서 해결해야 할 신뢰성·운영·테스트 결함 |
3. P1 발견 사항
P1-01. 부팅 시 누수 상태가 동기화되지 않고 알림 재시도 경로도 없다
근거
- 누수 클라이언트는 시작 시 GPIO 값을 읽지만 서버에는 항상
startup이벤트를 보낸다:raspberry_pi4/leak_sensor.py,raspberry_pi4/leak_sensor.py - 주기 보고도 항상
periodic이벤트다:raspberry_pi4/leak_sensor.py - 서버는
leak_alert와recovery만 상태 전환 이벤트로 인정하며,startup/periodic에서는 기존is_leak값을 보존한다:php/api/sensor_data.php,php/api/sensor_data.php - 서버의 누수 SMS 처리는 상태 전환 이벤트 안에서만 실행된다:
php/api/sensor_data.php - 사진 촬영·업로드는 GPIO callback에서 상태가 누수로 바뀐 경우에만 시작된다. 부팅 시 현재 상태 보고에는 이 경로가 없다:
raspberry_pi4/leak_sensor.py,raspberry_pi4/leak_sensor.py - SMS 실패 여부와 무관하게 API는 HTTP 200 응답을 반환한다:
php/api/sensor_data.php
영향
- 서버가 정상으로 알고 있을 때 센서가 젖은 상태로 재부팅하면 누수 상태와 SMS가 누락된다.
- 서버가 누수로 알고 있을 때 센서가 마른 상태로 재부팅하면 열린 사고가 종료되지 않는다.
- 최초 등록 센서가 젖은 상태로 시작하면 상태 행은 누수로 생성될 수 있지만 SMS·사진·사고 이력은 생성되지 않는다.
- 최초 SMS 전송이 실패해도 이후
periodic보고는 재전송을 유발하지 않는다.
권장 조치
startup을 현재 물리 상태의 권위 있는 동기화 이벤트로 정의하고 서버가 불일치를 전환으로 처리한다.- 상태 전환과 SMS 요청을 DB 트랜잭션으로 기록하고, 별도 durable outbox 작업자가 성공할 때까지 재시도한다.
- 젖은 상태 부팅, 마른 상태 부팅, SMS 일시 실패 후 복구를 통합 테스트로 고정한다.
P1-02. 누수와 복구 이벤트의 도착 순서가 역전될 수 있다
근거
- 누수와 복구 전송이 각각 독립 daemon thread에서 시작된다:
raspberry_pi4/leak_sensor.py - 각 요청은 독립적으로 재시도한다:
raspberry_pi4/leak_sensor.py - 서버는 센서별 시퀀스나 이벤트 발생 시각의 단조 증가를 검증하지 않고 도착 순서대로 상태와 사고 이력을 갱신한다:
php/api/sensor_data.php,php/api/sensor_data.php
영향
leak_alert 전송이 지연되는 동안 recovery가 먼저 성공하면, 뒤늦은 누수 이벤트가 최종 상태를 다시 누수로 만든다. 실제 센서는 마른 상태인데 대시보드는 누수, 사고는 열린 상태, SMS는 늦게 발송되는 결과가 가능하다.
권장 조치
센서별 단일 FIFO 전송 큐를 디스크에 유지하고 각 이벤트에 영속적인 단조 증가 sequence를 부여한다. 서버는 마지막 처리 시퀀스보다 오래된 상태 전환을 중복 성공으로 응답하되 상태에는 적용하지 않아야 한다.
P1-03. 운영 의존성 누락이 정상 시뮬레이션으로 위장되고 배포 시험은 실제 하드웨어를 확인하지 않는다
근거
- 누수 클라이언트는 GPIO import 실패 시 자동으로
SimGPIO를 사용한다:raspberry_pi4/leak_sensor.py,raspberry_pi4/leak_sensor.py - SHT30 드라이버는 SMBus를 사용할 수 없으면 자동 시뮬레이션으로 전환하고 현실적인 온·습도 값을 생성한다:
raspberry_pi4_sht30/sht30_driver.py,raspberry_pi4_sht30/sht30_driver.py - SHT30 연결 테스트는 실제 센서 측정값이 아니라 고정된
24.0°C,48.0%API 요청으로 서버 연결을 판정한다:raspberry_pi4_sht30/test_connection.py - 별도 센서 테스트도 실제 측정 예외를 출력만 하고 실패 상태에 반영하지 않는다:
raspberry_pi4_sht30/test_sensor.py - 생성된 설치 절차는 서비스 시작과 더미 연결 테스트 후 원격 접근을 강화한다:
tools/make_deploy_bundles.py
영향
GPIO 또는 SMBus 라이브러리 누락은 정상처럼 보이는 시뮬레이션 값으로 대체된다. I2C bus open 실패와 실행 중 통신·CRC 실패는 시뮬레이션으로 전환되지 않지만, 배포 검증이 실제 센서와 실행 중인 서비스의 측정값을 확인하지 않으므로 이런 실패도 놓친 채 성공으로 판정할 수 있다. 더미 측정값이 최근 정상 데이터로 저장되고 현장 접근을 제한한 뒤에야 실제 센서 미동작을 발견할 가능성이 있다.
권장 조치
시뮬레이션은 명시적인 개발 전용 플래그가 있을 때만 허용한다. 운영 기본값은 fail-closed로 하고, 배포 검증은 실제 GPIO 상태 변화 또는 실제 I2C 측정, 서비스 안정 실행, 재부팅 후 재연결까지 확인해야 한다.
P1-04. 불완전한 SHT30 값이 거짓 복구와 0.0 복구 SMS를 만든다
근거
- 서버는 범위를 벗어난 값을
null로 만들고 온도·습도 중 하나만 유효해도 요청을 수락한다:php/api/sensor_data.php - 임계값 판정은 누락된 차원을 복구된 것으로 취급한다:
php/sms_send.php - 복구 SMS 작성 시 누락된 값은
0.0으로 대체된다:php/api/sensor_data.php
영향
고온 경보 뒤 temperature=130, 정상 습도가 들어오면 온도는 무효화되지만 복구로 판정되어 0.0°C 복구 SMS가 발송될 수 있다. 두 값이 모두 무효인 경우에도 heartbeat가 갱신되면 센서 장애가 온라인 상태로 가려질 수 있다.
권장 조치
SHT30 측정은 온도와 습도가 모두 존재하고 범위 안일 때만 정상 측정으로 처리한다. 무효 입력은 sensor_error/out_of_range 상태와 원인을 별도로 기록하고, 두 값이 모두 유효할 때만 복구 판정을 수행한다.
P1-05. heartbeat 작업이 수동 cron에 의존하며 실행 중단을 탐지하지 못한다
근거
- offline 감지와 일일 상태 SMS는
php/cron_heartbeat.php에 있으며 API 요청과 일부 웹 요청에서 보조적으로 호출된다:php/api/sensor_data.php - 생성된 설치 문서는 cron 등록을 수동 단계로 안내한다:
tools/make_deploy_bundles.py - 운영 점검은 cron의 마지막 성공 시각을 검사하지 않는다:
php/ops_checks.php
영향
cron이 누락되거나 멈추고 모든 Pi가 오프라인이면 API 유입도 없어 보조 실행이 일어나지 않는다. 이때 offline SMS와 일일 상태 보고가 모두 조용히 중단된다.
권장 조치
스케줄 설치를 자동화하고 마지막 시작·성공·실패 시각을 DB 또는 상태 파일에 기록한다. ops_checks.php가 지연을 실패로 판정하게 하고, 서버 자체 장애까지 감지할 외부 dead-man 점검을 둔다.
P1-06. 단일 공용 API 키가 센서 ID와 결합되지 않는다
근거
- 모든 장치가 전역
API_KEY로 HMAC을 생성한다:php/config.php,php/config.php - 수집 API는
sensor_id범위만 검사하며 키가 특정device_id나 센서 집합에 속하는지 확인하지 않는다:php/api/sensor_data.php - 상태 테이블의 주 식별자는
sensor_id다:sql/schema_sht30.sql
영향
장치 한 대의 키가 유출되면 공격자는 다른 센서의 누수·복구·온습도 이벤트를 위조할 수 있다. 장치별 폐기와 회전도 불가능하여 침해 범위가 전체 설비로 확대된다.
권장 조치
장치 레지스트리에 장치별 키 또는 공개키, 허용 센서 ID, 활성 상태, 회전 버전을 둔다. 인증 후 payload의 device_id와 sensor_id 권한을 함께 검증하고 장치 단위 폐기·회전을 지원한다.
P1-07. 누수 사진 보존 정책과 보안 문서의 데이터 범위가 실제 구현과 다르다
근거
- 사진 업로드 API는 누수 사진 파일과 메타데이터를 저장한다:
php/api/photo_upload.php - 보존 정리 작업은 측정·로그 테이블만 삭제하고 사진 파일·사진 메타데이터·사고 이력을 처리하지 않는다:
php/retention_cleanup.php - 보안 문서는 시스템이 SHT30 온습도만 처리하며 누수·카메라·사진을 수집하지 않는다고 기술한다:
docs/SECURITY_PLAN_PASS_READINESS.md,docs/SECURITY_PLAN_PASS_READINESS.md,docs/SECURITY_PLAN_HWP_REVIEW.md
영향
사진과 사고 데이터가 무기한 남고, 데이터 목록·접근 통제·백업·파기 설명이 실제 처리와 불일치한다. 현장 사진에 사람, 설비 정보, 위치 단서가 포함될 경우 개인정보·보안 심사 근거가 부정확해진다.
권장 조치
사진과 사고 데이터의 목적, 보존 기간, 접근 주체, 백업 범위를 확정한다. 정리 작업이 DB 메타데이터와 파일을 일관되게 삭제하도록 만들고, 보안 문서의 데이터 흐름과 위협 모델을 현재 구현에 맞춘다.
4. P2 발견 사항
P2-01. 수집 처리와 외부 알림에 원자성이 없다
nonce, 수집 로그, 상태, 측정값, 사고 이력이 각각 autocommit으로 기록된다: php/api/sensor_data.php. 중간 실패 시 일부 변경만 남고 catch 경로가 nonce를 해제하면 재시도가 중복 또는 상충 상태를 만들 수 있다: php/api/sensor_data.php. 센서 요청에 대한 DB 변경을 하나의 트랜잭션으로 묶고, SMS·사진 요청은 같은 트랜잭션에서 outbox에 기록해야 한다.
P2-02. 사진 업로드가 재시작과 중복 요청에 안전하지 않다
중복 센서 요청 응답은 원래 log_id를 돌려주지 않으며, 클라이언트는 log_id가 없으면 사진을 건너뛴다: php/api/sensor_data.php, raspberry_pi4/leak_sensor.py. 사진 업로드는 한 번만 시도하고 임시 파일을 삭제한다: raspberry_pi4/leak_sensor.py. 서버는 파일 이동 뒤 DB insert가 실패하면 이동된 파일을 제거하지 않는다: php/api/photo_upload.php. 요청 ID별 결과 매핑, 로컬 spool, 재시도 가능한 업로드 ID, orphan 정리가 필요하다.
P2-03. 잘못된 event_type이 오류가 아니라 periodic으로 조용히 바뀐다
알 수 없는 이벤트 문자열을 periodic으로 강제한다: php/api/sensor_data.php. 오타나 버전 불일치가 HTTP 200으로 숨겨져 누수 전환이 누락될 수 있다. 허용 enum 외 값에는 400을 반환하고 구조화된 거부 로그를 남겨야 한다.
P2-04. php/var의 독립 접근 차단 파일이 저장소와 배포 번들에 포함되지 않는다
.gitignore가 php/var/*를 제외하고 .gitkeep만 다시 포함한다: .gitignore. 현재 저장소에도 .gitkeep만 추적되며 로컬의 var/.htaccess와 var/photos/.htaccess는 배포 자산이 아니다. 번들 생성기는 var를 다시 만들면서 index.html만 둔다: tools/make_deploy_bundles.py. 상위 php/.htaccess의 rewrite가 동작하면 차단되지만, 감사·로그인·거부 로그와 사진을 위한 독립 방어선이 재현되지 않는다. 차단 파일을 명시적으로 추적·번들링하고 clean archive 검사를 추가해야 한다.
P2-05. 테스트 실행기가 필수 도구와 테스트 부재를 성공으로 처리하며 현재 회귀 테스트도 2건 실패한다
run_tests.sh는 런타임이 없으면 테스트를 건너뛰고, 실행된 테스트가 0건이어도 성공 종료할 수 있다: run_tests.sh. CI 설정도 발견되지 않았다. 현재 PHP 회귀 테스트에서는 다음 두 실패가 확인됐다.
php/config.local.example.php가 삭제된 워킹 트리라 보안 강화 테스트가 fatal error로 중단된다. 배포 문서 여러 곳은 여전히 이 파일 복사를 지시한다.- 이슬점 기능이 로컬 설정으로 비활성화된 경우에도 threshold 회귀 테스트는 활성 동작을 기대해 실패한다. 테스트가 로컬 비밀·운영 설정에 종속되어 있다.
고정 fixture 설정을 사용하고 필수 런타임·테스트 누락을 실패로 처리해야 한다. PHP/Python/C/MySQL 통합과 이벤트 순서, 재시도, 장애 주입을 CI 필수 게이트로 구성해야 한다.
P2-06. 월간 보고서가 이름·위치 변경 시 행을 덮어쓰며 현재 통합 범위를 반영하지 않는다
보고서는 sensor_id와 이름·위치로 그룹화한 여러 행을 sensor_id 하나로 다시 keying한다: php/monthly_report.php, php/monthly_report.php. 월중 이름이나 위치가 바뀌면 일부 집계가 덮어써진다. 또한 보고 범위가 SHT30 중심이라 누수·응축 등 현재 기능을 완전히 반영하지 않는다: php/monthly_report.php. ID 기준으로 집계하고 표시명은 별도 최신 레지스트리에서 가져오며 통합 이벤트 범위를 명시해야 한다.
P2-07. 일일 상태 SMS 실패가 그날 전체 재시도를 차단한다
일일 중복 조회는 성공 상태를 조건으로 하지 않고, 전송 결과를 확인하지 않은 채 전송 로그를 남긴다: php/sms_send.php. 첫 시도가 일시 실패해도 실패 행이 당일 중복으로 취급되어 다음 시도를 막을 수 있다. 성공 기록만 dedup 대상으로 삼고 실패에는 제한된 backoff 재시도를 적용해야 한다.
5. 추가 관찰
- 새로 분리된
php/dashboard_data.php와php/tabs/*.php는 현재php/.htaccess의 직접 접근 차단 목록에 포함되지 않는다. tab 파일에는APP_VERSIONguard가 있고 helper는 직접 요청 시 데이터 출력을 하지 않아 즉시 정보 노출로 보지는 않았지만, 내부 include 전용 PHP 파일은 웹 서버에서 일괄 차단하는 편이 안전하다. - 대시보드는 helper를 session 인증 전에 include하지만, 리뷰 시점 helper는 함수와 상수만 정의하고 쿼리나 출력을 실행하지 않았다. 향후 module 초기화 코드가 추가되면 인증 경계가 쉽게 무너질 수 있으므로 인증 이후 include하거나 bootstrap과 데이터 조회를 분리해야 한다.
- systemd service 파일은
StartLimitInterval과StartLimitBurst를[Service]에 둔 legacy 문법을 사용한다. Debian systemd parser 소스는 이 조합을 호환 매핑하므로, 대상 Raspberry Pi에서 확인하지 않은 채 기능 결함으로 분류하지 않았다. 다만[Unit]의StartLimitIntervalSec=/StartLimitBurst=로 정규화하고systemd-analyze verify와 실제 crash-loop 시험을 배포 게이트에 추가해야 한다. - STM32 경로와 Raspberry Pi 경로가 함께 존재하므로 현재 생산 대상과 폐기·실험 코드를 문서와 빌드에서 명확히 구분할 필요가 있다.
6. 확인된 강점
- 원문 body 기반 HMAC, timestamp freshness, body size 제한, nonce 재사용 방지가 구현되어 있다.
- 주요 DB 접근은 PDO 준비문을 사용하고 센서 ID와 수치 범위를 검증한다.
- 관리자 인증에 bcrypt, TOTP, session cookie 강화, CSRF 검사가 적용되어 있다.
- 사진 업로드에 MIME·크기 검사, 무작위 파일명, 경로 검증이 있다.
- 중복 알림을 줄이기 위한 named lock과 cooldown 설계가 있다.
- 운영 비밀 파일은 저장소 제외 대상으로 두고 배포 도구도 별도 비밀 입력을 사용한다.
이 장치들은 유효하지만, P1의 상태 정확성·전달 보장 문제를 상쇄하지는 않는다.
7. 로컬 검증 결과
| 검증 | 결과 | 비고 |
|---|---|---|
| PHP syntax lint | 통과 | 64개 파일, 실패 0건 |
| Python bytecode compile | 통과 | 37개 파일, 실패 0건 |
| PHP 회귀 테스트 | 5 통과 / 2 실패 | example config 삭제, 로컬 설정 종속 threshold 테스트 |
| Python↔PHP parity | PHP/Python 통과 | C compiler 부재로 C parity는 skip |
| SHT30 sensor test | 시뮬레이션으로 통과 | PYTHONUTF8=1 필요; 실제 I2C 검증 아님 |
| MySQL 통합 | 미실행 | 로컬 DB fixture 없음 |
| 실제 GPIO/I2C | 미실행 | 대상 하드웨어 없음 |
| systemd 검증 | 미실행 | Windows 리뷰 환경 |
기본 Windows CP949 콘솔에서 SHT30 테스트의 Unicode 출력이 UnicodeEncodeError를 일으켰다. UTF-8 모드에서는 통과했지만 자동 시뮬레이션을 사용했으므로 하드웨어 건전성의 증거로 사용할 수 없다.
8. 권장 수정 순서와 완료 기준
단계 A. 상태 정확성과 전달 보장
- 센서별 persistent FIFO,
sequence, 서버 stale-event 거부를 구현한다. - 부팅 상태 동기화를 정의하고 누수·복구 불일치를 정상 전환으로 처리한다.
- DB 트랜잭션과 SMS/photo outbox를 도입한다.
- 젖은 부팅, 마른 부팅, 순서 역전, 네트워크 단절, SMS 실패·복구 테스트를 통과한다.
단계 B. 하드웨어와 입력 신뢰성
- 운영 시뮬레이션을 기본 금지하고 센서 오류 상태를 노출한다.
- SHT30 완전성 검증과 오류/복구 상태기를 수정한다.
- 실제 Pi에서 GPIO/I2C, 재부팅, 장시간 서비스 실행을 검증한다.
단계 C. 보안·운영·준법
- 장치별 자격 증명과 센서 권한 매핑을 도입한다.
- cron dead-man, 일일 SMS 재시도, systemd 검증을 배포 게이트에 넣는다.
- 사진 보존·파기 구현과 보안 문서를 일치시킨다.
- clean checkout에서 접근 차단 파일과 배포 번들을 검증한다.
단계 D. 자동 회귀 게이트
- 테스트를 로컬 설정과 분리하고 필수 skip을 실패로 바꾼다.
- PHP/Python/C/MySQL 조합을 CI에서 실행한다.
- clean checkout 기준 배포 번들 생성, PHP lint,
systemd-analyze verify, 문서 참조 검사를 필수화한다.
배포 재개 기준: P1 7건의 수정과 해당 장애 시나리오 자동화, 실제 Raspberry Pi 재부팅 시험, SMS 실패 후 재전송 증거, 사진 보존 정책 승인이 모두 완료되어야 한다.
9. 리뷰 한계
이 리뷰는 코드와 로컬 실행 결과에 기반한다. 운영 네트워크, 실제 SMS 사업자 응답, DB 부하·락 경합, Raspberry Pi 전원 장애, GPIO 노이즈, I2C 버스 장애는 재현하지 못했다. 따라서 P1 해결 뒤에도 현장 전 staging에서 장애 주입과 장시간 soak test가 필요하다.