move_detect/tests/test_cafe24_package.py
유창욱 7daee0b372 fix(audit): 재감사 확정 12건 전량 해소 (CRITICAL 3 · HIGH 3 · MEDIUM 4 · LOW 2)
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.

CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
  edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
  끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
  검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
  허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
  (tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
  프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
  지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
  한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
  식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
  (/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
  전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
  경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.

HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
  그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
  공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
  04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
  Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
  DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
  이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.

MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
  크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
  claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
  정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
  발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
  공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.

LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
  읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
  PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.

수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.

부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.

검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
2026-08-18 10:33:02 +09:00

234 lines
12 KiB
Python

"""Guard: the Cafe24 upload package must be complete and safe the moment it is built.
The package is what a non-developer drags into Cafe24's FTP root, so a mistake here is
not caught by anything downstream — there is no CI on the hosting account. Every check
below encodes a failure that is silent in production:
* a missing vendor/ autoloader fatals every request (public/index.php's `require` of
../vendor/autoload.php is unconditional);
* a *dev* autoloader eagerly requires tests/Support/InMemoryRepositories.php, which is
not in the package, so it also fatals every request;
* a missing storage/snapshots/ silently drops every snapshot while still returning
201 to the edge device (IngestController's `continue` on a failed write).
"""
import os
import re
import subprocess
import pytest
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# server/deploy/, not deploy/: deploy/ belongs to the Raspberry Pi — its own README opens with
# "Edge deployment (Raspberry Pi 5, air-gapped)" and everything in it is run as root ON the
# Pi. Cafe24 runs nothing; the package is only ever uploaded. Deploying server/ belongs to
# server/, which already owns server/docs/server-deploy.md.
BUILD = os.path.join(ROOT, "server", "deploy", "build_cafe24_package.sh")
HTACCESS = os.path.join(ROOT, "server", "deploy", "htaccess.www")
CONFIG_TMPL = os.path.join(ROOT, "server", "deploy", "config.template.php")
CONFIG_EXAMPLE = os.path.join(ROOT, "server", "config.example.php")
SERVER_DEPLOY_DOC = os.path.join(ROOT, "server", "docs", "server-deploy.md")
CONFIG_PHP = os.path.join(ROOT, "server", "src", "Config.php")
def _read(path):
with open(path, encoding="utf-8") as f:
return f.read()
# Paths are passed to bash/shellcheck relative to ROOT: Git Bash's bash is an MSYS program,
# so it resolves neither C:\a\b (backslashes vanish) nor C:/a/b (not a POSIX path) — it
# wants /c/a/b. A relative path plus cwd=ROOT sidesteps the conversion on every platform.
BUILD_REL = os.path.join("server", "deploy", "build_cafe24_package.sh").replace("\\", "/")
def _config_keys_read_by_app():
"""Every config.php key App\\Config actually reads (the sole consumer: its ctor is
private and fromArray() is called once, from public/index.php)."""
return set(re.findall(r"\$this->a\['([a-z0-9_]+)'\]", _read(CONFIG_PHP)))
def test_build_script_exists_and_is_executable_bash():
assert os.path.isfile(BUILD), "server/deploy/build_cafe24_package.sh is missing"
assert _read(BUILD).startswith("#!"), "build script needs a shebang"
def test_build_script_generates_a_no_dev_autoloader():
sh = _read(BUILD)
# server/public/index.php:3 hard-requires ../vendor/autoload.php, and
# server/src/Auth/Session.php declares three symbols under one filename (hence
# composer.json's classmap entry) — so a hand-rolled PSR-4 loader cannot resolve
# App\Auth\PhpSessionStore. Composer's classmap dump is the only correct artifact.
assert "composer dump-autoload" in sh
assert "--no-dev" in sh
assert "--classmap-authoritative" in sh
def test_build_script_excludes_dev_only_trees():
sh = _read(BUILD)
# Shipping the working tree's vendor/ would drag in PHPUnit *and* a dev
# autoload_files.php that requires tests/Support/InMemoryRepositories.php on every
# request. Shipping tests/ or phpunit.xml is dead weight on a production host.
for forbidden in ("phpunit", "tests"):
assert forbidden in sh, f"build script must explicitly deal with {forbidden}"
def test_build_script_mounts_the_app_in_a_subdirectory_of_www():
sh = _read(BUILD)
# This account's www/ already serves another site, so the app is mounted at
# www/move_detect/ and everything secret stays at the FTP root — one level ABOVE www/,
# two above the app. Emitting the app straight into www/ would put config.php (DB
# password) one level up, i.e. inside a docroot the other site also serves.
assert 'APP_DIR="${APP_DIR-move_detect}"' in sh
assert 'WEB="$STAGE/www${APP_DIR:+/$APP_DIR}"' in sh
# Everything private goes in ONE container beside www/, not sprayed across the FTP root:
# that root also holds the co-hosted site's files, and `vendor/`/`config.php` are ordinary
# names in any PHP project — a flat layout risks overwriting them.
assert 'PRIVATE_DIR="${PRIVATE_DIR:-move_detect_app}"' in sh
assert 'PRIV="$STAGE/$PRIVATE_DIR"' in sh
for private in ('"$PRIV/config.php"', '"$PRIV/src/"', '"$PRIV/storage/snapshots"',
'"$PRIV/vendor/autoload.php"', '"$PRIV/schema.sql"'):
assert private in sh, f"{private} must live inside the private container"
# ...and nothing private may be staged under the web directory.
for leaked in ('"$WEB/config.php"', '"$WEB/src/"', '"$WEB/vendor', '"$WEB/storage'):
assert leaked not in sh, f"{leaked} would be web-reachable"
def test_app_finds_its_root_by_walking_up_not_by_a_hardcoded_depth():
index = _read(os.path.join(ROOT, "server", "public", "index.php"))
# A hardcoded '/..' resolves to www/ under a subdirectory mount — exactly where
# config.php must never live. The walk-up makes one package work at either depth.
assert "APP_ROOT" in index
assert "require __DIR__ . '/../vendor/autoload.php'" not in index
assert "require APP_ROOT . '/vendor/autoload.php'" in index
assert "require APP_ROOT . '/config.php'" in index
# The private container is probed at every level, and BEFORE the bare directory: the
# co-hosted site may itself have config.php + vendor/ at the FTP root, and binding to
# those would hand this app another site's database credentials.
assert "APP_PRIVATE_DIR = 'move_detect_app'" in index
assert "[$dir . '/' . APP_PRIVATE_DIR, $dir]" in index, \
"the container must be probed before the bare directory"
# All three markers must match. config.php + vendor/ alone are ordinary names in any PHP
# project sharing the account; src/Config.php is specific to this app.
for marker in ("'/config.php'", "'/vendor/autoload.php'", "'/src/Config.php'"):
assert marker in index, f"{marker} must be part of the app-root check"
def test_build_script_creates_the_snapshot_dir():
sh = _read(BUILD)
# Nothing in server/src ever creates this directory, and a failed write is swallowed
# by IngestController's `continue` — the API still answers 201 and the evidence is
# gone for good.
assert "storage/snapshots" in sh
def test_htaccess_forces_https_and_keeps_the_front_controller():
ht = _read(HTACCESS)
# src/Auth/Session.php hard-codes cookie_secure => true, so over plain HTTP the
# browser never returns the session cookie and login silently fails. HTTPS is a hard
# requirement, not hardening.
assert "RewriteCond %{HTTPS} !=on" in ht
assert "https://%{HTTP_HOST}%{REQUEST_URI}" in ht
# ...without breaking the existing rewrite-to-index front controller.
assert "RewriteRule ^ index.php [QSA,L]" in ht
def test_htaccess_denies_dotfiles_and_plaintext_servable_files():
ht = _read(HTACCESS)
assert "Options -Indexes" in ht
assert re.search(r'<FilesMatch\s+"\^\\\."', ht), "dotfiles (.git, .env) must be denied"
# schema.sql is the sharp one: Apache has no handler for .sql, so it serves the full
# DDL as plaintext. Unlike .php, PHP will not swallow it.
assert "sql" in ht
def test_config_template_lists_every_key_the_app_reads():
tmpl = _read(CONFIG_TMPL)
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in tmpl}
assert not missing, f"config template omits keys App\\Config reads: {sorted(missing)}"
def test_config_example_lists_every_key_the_app_reads():
example = _read(CONFIG_EXAMPLE)
# Silent-default bug: nonce_ttl and ingest_ip_rate_limit_per_min have `?? default`
# fallbacks, so an operator who never sees them cannot know the knobs exist.
missing = {k for k in _config_keys_read_by_app() if f"'{k}'" not in example}
assert not missing, f"config.example.php omits keys App\\Config reads: {sorted(missing)}"
def test_server_deploy_doc_does_not_tell_operators_to_drop_vendor():
doc = _read(SERVER_DEPLOY_DOC)
# public/index.php:3 is an unconditional `require` of ../vendor/autoload.php.
# "Do not upload vendor/" bricks every request, including the doc's own smoke check.
assert "Do not upload `vendor/`" not in doc
def test_build_script_passes_shellcheck_if_available():
# Probe and invoke through the SAME interpreter. Probing with `bash -c command -v` and
# then invoking shellcheck from the Windows PATH asks two different machines, so the
# probe can pass while the call ENOENTs. And skip visibly rather than `return`, which
# reports a green test that never ran.
r = subprocess.run(["bash", "-c", f"command -v shellcheck >/dev/null || exit 99; "
f"shellcheck -S warning {BUILD_REL}"],
cwd=ROOT, capture_output=True, text=True)
if r.returncode == 99:
pytest.skip("shellcheck not installed; bash -n still guards syntax")
assert r.returncode == 0, f"shellcheck:\n{r.stdout}\n{r.stderr}"
def test_build_script_is_syntactically_valid_bash():
r = subprocess.run(["bash", "-n", BUILD_REL], cwd=ROOT, capture_output=True, text=True)
assert r.returncode == 0, f"bash -n failed:\n{r.stderr}"
# ---------------------------------------------------------------------------
# 2026-08-18 재감사(A11): CLI 도구가 웹에서 닿으면 안 된다.
# tools/dispatch_alerts.php 는 메일을 보내고, backup.php 는 DB 전체를 읽는다.
# 이들이 www/ 아래에 실리면 인증 없이 호출 가능한 표면이 된다.
# ---------------------------------------------------------------------------
TOOLS_DIR = os.path.join(ROOT, "server", "tools")
DIST_PRIV = os.path.join(ROOT, "dist", "1_cafe24_upload", "move_detect_app")
DIST_WEB = os.path.join(ROOT, "dist", "1_cafe24_upload", "www", "move_detect")
needs_package = pytest.mark.skipif(
not os.path.isdir(DIST_PRIV), reason="cafe24 package not built in this checkout"
)
def test_every_cli_tool_refuses_to_run_over_http():
# bootstrap.php 의 SAPI 검사가 유일한 코드 레벨 방어선이다. 도구가 그것을 require
# 하지 않으면 그 방어선이 없는 것과 같다.
for name in sorted(os.listdir(TOOLS_DIR)):
if not name.endswith(".php") or name == "bootstrap.php":
continue
src = _read(os.path.join(TOOLS_DIR, name))
assert "require __DIR__ . '/bootstrap.php'" in src, (
f"tools/{name} 가 bootstrap.php 를 require 하지 않습니다 — CLI 전용 보장이 없습니다."
)
guard = _read(os.path.join(TOOLS_DIR, "bootstrap.php"))
assert "PHP_SAPI !== 'cli'" in guard, "bootstrap.php 의 CLI 전용 가드가 사라졌습니다"
@needs_package
def test_tools_ship_outside_the_web_root():
assert os.path.isdir(os.path.join(DIST_PRIV, "tools")), (
"패키지에 tools/ 가 없습니다 — 통보 디스패처와 보존기간 정리가 동봉되지 않았습니다."
)
assert not os.path.exists(os.path.join(DIST_WEB, "tools")), (
"tools/ 가 웹 공개 경로에 실렸습니다. dispatch_alerts.php 는 메일을 보내고 "
"backup.php 는 DB 전체를 읽습니다."
)
@needs_package
def test_packaged_web_root_holds_no_php_beyond_the_front_controller():
# 웹 루트의 PHP 는 index.php 와 (배포 후 삭제하는) _preflight.php 뿐이어야 한다.
allowed = {"index.php", "_preflight.php"}
found = {
os.path.relpath(os.path.join(dirpath, f), DIST_WEB)
for dirpath, _, files in os.walk(DIST_WEB)
for f in files
if f.endswith(".php")
}
unexpected = found - allowed
assert not unexpected, f"웹 루트에 예상치 못한 PHP 파일: {sorted(unexpected)}"