Files
Gnuboard7/tests/scenarios/installer-secrets-lifecycle.yaml
T
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00

154 lines
9.6 KiB
YAML

# audit:allow test-scenario-coverage reason: 인스톨러 비밀 수명주기의 시나리오 매트릭스 SSoT. 회귀 가드는 test_files 의 통과 테스트 + Manual 시나리오(실제 설치 2회 + storage grep) 로 커버.
feature: 인스톨러 비밀 수명주기 (state.json 평문 제거 → runtime.php 일원화)
description: |
설치 과정에서 입력받는 비밀(DB 비밀번호 2종, 관리자 비밀번호 2종) 이
storage/installer-state.json 에 평문으로 기록되어, 설치 실패/중단/finalize 미호출
환경에서 무기한 잔존하던 결함을 수정한다 (이슈 #465).
설계:
- state.json(0664, 실패 시 무기한 잔존) 에는 비밀을 아예 기록하지 않는다.
- 비밀 채널은 storage/installer/runtime.php(0600, finalize 시 삭제) 로 일원화.
SSE 워커는 세션 접근이 불가하므로 파일 경유가 불가피하며, 세션을 보유한 유일한
공통 시작점인 install-process.php 가 이송 지점이다 (SSE/폴링 공통, 재시도 포함).
- 관리자 비밀번호는 db_seed 가 소비한 즉시 runtime 에서 제거 + putenv 해제
(해제하지 않으면 이후 모든 exec() 자식이 평문을 ENV 상속).
- 레거시/실패 경로 방어: 완료·중단·초기화·finalize 시점 redact 폴백.
- 이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리.
구조적 제약:
state.config 에서 DB 비밀번호가 사라지므로 그 값을 읽던 소비처(db_cleanup /
롤백 seed truncate) 는 hydrateDbSecretsFromRuntime() 으로 자격증명을 복원한다.
두 소비처 모두 env_update 이후에만 실행되어 runtime.php 가 항상 존재한다.
axes:
installation_mode: [sse, polling]
session_state: [alive, lost_after_step3]
retry_kind: [none, resume_after_failure, drop_tables_reconsent]
runtime_state: [absent, present_with_db_only, present_with_db_and_admin]
seed_outcome: [not_reached, success, failure]
completion_path: [normal_finalize, finalize_unlink_failed, finalize_not_called, aborted, reset]
read_db: [disabled, enabled_separate_host]
legacy_state: [clean, has_plaintext_secrets_from_7_0_3_or_earlier]
upgrade_trigger: [not_applicable, state_present_installed, state_present_not_installed, state_absent, state_corrupted]
exclusions:
- { seed_outcome: not_reached, runtime_state: absent, reason: "env_update 이전 실패 — runtime 미생성이 정상" }
- { session_state: lost_after_step3, runtime_state: absent, retry_kind: resume_after_failure, reason: "세션+runtime 동시 유실은 재개 불가 400 → Step 3 재입력 (의도된 안전 실패)" }
- { upgrade_trigger: state_absent, legacy_state: has_plaintext_secrets_from_7_0_3_or_earlier, reason: "state 파일이 없으면 잔존 평문도 없음" }
- { completion_path: reset, seed_outcome: success, reason: "reset 은 완료 전 상태 초기화 — 시딩 성공 후 경로 아님" }
effects:
# 소스 차단 — state.json 에 비밀 미기록
- step3_post_writes_state_config_without_db_write_password
- step3_post_writes_state_config_without_db_read_password
- step3_post_writes_state_config_without_admin_password
- step3_post_writes_state_config_without_admin_password_confirm
- step3_post_keeps_admin_password_in_session_for_runtime_transfer
- step3_post_drops_admin_password_confirm_from_session_no_consumer_exists
- sanitize_config_for_state_preserves_non_secret_fields_like_app_name_and_db_host
- sanitize_config_for_state_drops_read_connection_fields_when_use_read_db_false
- install_process_writes_admin_password_into_runtime_php_before_state_save
- install_process_writes_state_config_through_sanitize_helper
- install_process_fails_installation_start_when_runtime_write_fails
- step3_form_prefill_never_renders_admin_password_value_even_from_legacy_state
# runtime.php 스키마 + 보존 폴백
- build_runtime_adds_admin_section_only_when_caller_supplies_password
- build_runtime_preserves_existing_admin_section_across_retries
- build_runtime_preserves_existing_db_write_password_when_state_config_lacks_it
- build_runtime_read_password_falls_back_to_existing_runtime_before_write_password
- build_runtime_prefers_caller_supplied_password_over_existing_runtime_value
- build_runtime_preserves_existing_app_key_unchanged_regression_guard
# DB 비밀번호 소비처 hydrate 폴백
- hydrate_db_secrets_fills_empty_write_password_from_runtime
- hydrate_db_secrets_fills_empty_read_password_from_runtime
- hydrate_db_secrets_does_not_override_password_already_present_in_config
- hydrate_db_secrets_returns_config_unchanged_when_runtime_absent
- db_cleanup_drop_tables_connects_successfully_with_hydrated_credentials
- rollback_seed_truncate_connects_successfully_with_hydrated_credentials
- generate_env_content_needs_no_hydration_because_merge_runtime_into_env_overwrites_db_lines
# 소비 즉시 삭제 (redact-on-consume)
- db_seed_reads_admin_password_from_runtime_php_not_state_config
- db_seed_falls_back_to_state_config_admin_password_for_legacy_installs_in_progress
- db_seed_removes_admin_section_from_runtime_php_immediately_after_success
- db_seed_redacts_legacy_state_secrets_immediately_after_success
- db_seed_redact_reloads_state_so_completed_task_markers_are_not_lost
- db_seed_unsets_installer_admin_password_env_after_success
- db_seed_unsets_installer_admin_name_email_language_env_after_success
- subsequent_exec_children_do_not_inherit_plaintext_admin_password_env
- db_seed_leaves_admin_password_intact_in_runtime_when_seeding_fails_so_retry_works
# .env 유출 차단 불변식
- merge_runtime_into_env_never_writes_admin_section_whitelist_invariant
- merge_runtime_into_env_still_writes_db_credentials_and_app_key
- installer_runtime_service_provider_ignores_unknown_admin_section
# 완료/실패 시점 폴백
- complete_flag_task_redacts_state_secrets_before_saving_completed_status
- finalize_deletes_state_json_on_success_when_DELETE_INSTALLER_AFTER_COMPLETE_set
- finalize_redacts_state_json_when_unlink_fails
- finalize_redacts_state_json_when_DELETE_INSTALLER_AFTER_COMPLETE_false
- reset_state_redacts_secrets_in_preserved_config
- abort_installation_redacts_secrets_in_preserved_config
- completed_installer_page_load_clears_install_config_from_session
- completed_installer_page_load_clears_db_test_flags_from_session
# 재시도 / 재개 안전성
- resume_after_failure_with_live_session_reinjects_admin_password_into_runtime
- resume_after_failure_with_lost_session_reuses_admin_password_from_runtime
- resume_after_db_seed_completion_passes_validation_via_completed_task_marker
- resume_with_no_session_and_no_runtime_and_no_seed_marker_returns_400_missing_admin_password
- drop_tables_reconsent_reruns_db_seed_with_password_reinjected_by_install_process
# 업그레이드 스텝 (7.0.4) — 기존 설치 사이트 정리
- upgrade_step_no_op_when_state_json_absent
- upgrade_step_deletes_state_json_when_env_installer_completed_is_true
- upgrade_step_deletes_state_json_when_g7_installed_flag_present
- upgrade_step_does_not_abort_upgrade_when_state_json_cannot_be_deleted
- upgrade_step_preserves_state_json_and_redacts_only_when_installation_not_complete
- upgrade_step_preserves_non_secret_config_fields_so_in_progress_install_can_continue
- upgrade_step_preserves_corrupted_state_json_without_destroying_it
- upgrade_step_is_idempotent_across_repeated_runs
- upgrade_step_handles_state_without_config_section
- upgrade_step_does_not_chmod_or_chown_preserving_existing_file_ownership
test_files:
- tests/Unit/Installer/InstallerStateSecretsTest.php
- tests/Unit/Installer/InstallerRuntimeHelperTest.php
- tests/Feature/Upgrade/RedactResidualInstallerStateSecretsTest.php
manual_verification:
- scenario: A_sse_full_install
description: |
SSE 모드로 실제 설치 1회 완료 → 완료 후 storage/ 하위에서 입력한 관리자
비밀번호 문자열 grep 무일치 (installer-state.json / installer/runtime.php /
logs/installation.log 전부) + 그 비밀번호로 관리자 로그인 성공.
- scenario: B_polling_full_install
description: |
폴링 모드로 동일 검증. SSE 와 동일하게 install-process.php 를 경유하므로
비밀 이송 경로가 같아야 한다.
- scenario: C_abort_midway
description: |
Step 5 진행 중 설치 중단 → 잔존 installer-state.json 에 admin_password /
db_write_password 부재 확인 (redact 폴백). 이후 재개 시 정상 진행.
- scenario: D_drop_tables_retry
description: |
기존 DB 가 있는 상태에서 drop_tables 재동의로 재설치 → db_seed 가 다시 실행되고
관리자 계정이 정상 생성됨 (install-process.php 가 비밀번호를 runtime 에 재주입).
- scenario: E_upgrade_existing_site
description: |
7.0.3 이하에서 설치되어 평문 비밀이 남은 installer-state.json 이 있는 사이트를
7.0.4 로 업그레이드 → 파일 삭제(완료 사이트) 또는 비밀만 redact 확인.
rules_layer_coverage:
- rule: upgrade-step-class-caching
coverage: Upgrade_7_0_4 는 빈 위임자, 로직은 data/7.0.4/migrations/ 의 버전 namespace 클래스에 격리
- rule: installer-pattern-no-copy-to-upgrade-step
coverage: 업그레이드 스텝은 인스톨러 파일을 require 하지 않고 비밀 키 목록/파싱/저장을 로컬 인라인 중복 구현. CLI 컨텍스트이므로 chmod/chown 미시도 (기존 inode 권한 보존)
- rule: service-provider-db-safety
coverage: InstallerRuntimeServiceProvider 무수정 — runtime['db'] / runtime['app']['key'] 만 참조하므로 신규 admin 섹션은 무시됨