POSA_LEAKSMS/scripts/generate_security_plan_images.py
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

167 lines
6.7 KiB
Python

# -*- coding: utf-8 -*-
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs" / "assets" / "security_plan" / "generated"
OUT.mkdir(parents=True, exist_ok=True)
FONT_CANDIDATES = [
Path("C:/Windows/Fonts/NotoSansKR-VF.ttf"),
Path("C:/Windows/Fonts/malgun.ttf"),
]
BOLD_CANDIDATES = [
Path("C:/Windows/Fonts/NotoSansKR-VF.ttf"),
Path("C:/Windows/Fonts/malgunbd.ttf"),
]
def first_existing(paths):
for path in paths:
if path.exists():
return path
raise FileNotFoundError("No Korean font found")
FONT_PATH = first_existing(FONT_CANDIDATES)
BOLD_PATH = first_existing(BOLD_CANDIDATES)
FONT = ImageFont.truetype(str(FONT_PATH), 22)
SMALL = ImageFont.truetype(str(FONT_PATH), 18)
TINY = ImageFont.truetype(str(FONT_PATH), 15)
BOLD = ImageFont.truetype(str(BOLD_PATH), 25)
TITLE_FONT = ImageFont.truetype(str(BOLD_PATH), 32)
def wrap(draw, text, font, width):
words = text.split(" ")
lines = []
current = ""
for word in words:
test = f"{current} {word}".strip()
if draw.textbbox((0, 0), test, font=font)[2] <= width or not current:
current = test
else:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
def draw_title(draw, text):
draw.text((40, 28), text, font=TITLE_FONT, fill="#0f172a")
draw.line((40, 72, 1160, 72), fill="#cbd5e1", width=2)
def draw_box(draw, xy, title, lines, fill, outline="#334155"):
x1, y1, x2, y2 = xy
draw.rounded_rectangle(xy, radius=14, fill=fill, outline=outline, width=3)
draw.text((x1 + 18, y1 + 14), title, font=BOLD, fill="#0f172a")
y = y1 + 52
for line in lines:
for wrapped in wrap(draw, line, SMALL, x2 - x1 - 36):
draw.text((x1 + 18, y), wrapped, font=SMALL, fill="#1f2937")
y += 25
def draw_arrow(draw, p1, p2, color="#0f766e", width=5):
draw.line([p1, p2], fill=color, width=width)
x1, y1 = p1
x2, y2 = p2
if abs(x2 - x1) >= abs(y2 - y1):
points = [(x2, y2), (x2 - 16, y2 - 9), (x2 - 16, y2 + 9)] if x2 >= x1 else [
(x2, y2), (x2 + 16, y2 - 9), (x2 + 16, y2 + 9)
]
else:
points = [(x2, y2), (x2 - 9, y2 - 16), (x2 + 9, y2 - 16)] if y2 >= y1 else [
(x2, y2), (x2 - 9, y2 + 16), (x2 + 9, y2 + 16)
]
draw.polygon(points, fill=color)
def save_target_architecture():
img = Image.new("RGB", (1200, 720), "#f8fafc")
draw = ImageDraw.Draw(img)
draw_title(draw, "보안대책서 보완용 목표 구성도")
draw_box(draw, (55, 130, 275, 300), "전산실 센서", ["SHT30 온습도 센서", "STM32 보드", "측정 주기 5분"], "#e0f2fe")
draw_box(draw, (360, 115, 610, 315), "STM32 단말 보안", ["기본 계정 변경", "환경파일 API 키", "로컬 로그", "불필요 서비스 차단"], "#dcfce7")
draw_box(draw, (705, 95, 950, 330), "Cafe24 서버", ["PHP API", "MySQL DB", "임계 판정", "대시보드/보고서"], "#fef3c7")
draw_box(draw, (990, 150, 1160, 280), "담당자", ["SMS 수신", "대시보드 확인", "현장 점검"], "#ede9fe")
draw_box(draw, (405, 410, 905, 610), "보안 경계 및 통제", ["기관 내부 업무망과 분리", "HTTPS/TLS 전송", "기관 방화벽 목적지/포트 제한", "서버 관리자 페이지 인증/세션 보호"], "#ffe4e6")
draw_arrow(draw, (275, 215), (360, 215))
draw_arrow(draw, (610, 215), (705, 215))
draw_arrow(draw, (950, 210), (990, 210))
draw_arrow(draw, (610, 340), (705, 450))
draw_arrow(draw, (405, 450), (250, 300))
draw_arrow(draw, (905, 450), (1065, 285))
draw.text((292, 184), "I2C/측정", font=TINY, fill="#334155")
draw.text((626, 184), "HTTPS API", font=TINY, fill="#334155")
draw.text((958, 180), "SMS/웹", font=TINY, fill="#334155")
img.save(OUT / "security_plan_target_architecture.png")
def save_data_flow():
img = Image.new("RGB", (1200, 720), "#ffffff")
draw = ImageDraw.Draw(img)
draw_title(draw, "데이터 흐름 및 보호조치 순서도")
items = [
("1. 온습도 측정", ["SHT30 측정", "온도/습도 5분 주기"]),
("2. 단말 보고", ["측정값 수집", "periodic/startup", "정상 보고"]),
("3. API 전송", ["HTTPS/TLS", "HMAC/서명 검증", "API 키 일치"]),
("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "sensor_metric", "sensor_log", "sms_log"]),
("5. 알림/대응", ["임계 SMS 발송", "정상복귀 SMS", "대시보드 확인"]),
]
xs = [45, 275, 505, 735, 965]
colors = ["#e0f2fe", "#dcfce7", "#fef3c7", "#ede9fe", "#ffe4e6"]
for index, (head, body) in enumerate(items):
draw_box(draw, (xs[index], 150, xs[index] + 190, 380), head, body, colors[index])
if index < 4:
draw_arrow(draw, (xs[index] + 190, 265), (xs[index + 1], 265), "#2563eb")
draw_box(
draw,
(160, 470, 1040, 620),
"문서에 추가할 보호대책 명시",
["전송 중 암호화, API 서명, 비밀값 분리, 최소 수집, 보관기간, 접근권한, 로그 위변조 방지, 백업/복구 점검을 표로 명시"],
"#f1f5f9",
)
for x in [140, 370, 600, 830, 1060]:
draw_arrow(draw, (x, 380), (x, 470), "#64748b", 3)
img.save(OUT / "security_plan_data_flow.png")
def save_document_update_map():
img = Image.new("RGB", (1200, 760), "#ffffff")
draw = ImageDraw.Draw(img)
draw_title(draw, "보안대책서 수정·추가 반영 위치")
draw_box(draw, (60, 125, 340, 300), "1. 사업개요 수정", ["서버실 온습도(SHT30) 모니터링 범위", "임계 SMS/대시보드 운영 기능 반영"], "#e0f2fe")
draw_box(draw, (460, 125, 740, 300), "2. 구성도 교체/보강", ["SHT30, STM32, Cafe24, MySQL, SMS", "업무망 분리와 HTTPS 경계 표시"], "#dcfce7")
draw_box(draw, (860, 125, 1140, 300), "3. 기술대책 구체화", ["API 서명, 키 관리, 임계 판정", "로그/백업/보관기간"], "#fef3c7")
draw_box(draw, (260, 430, 540, 605), "4. 운영 절차 추가", ["설치 점검 화면", "월간 보고서", "임계 경보/오프라인 대응 절차"], "#ede9fe")
draw_box(draw, (660, 430, 940, 605), "5. 증적 목록 추가", ["점검 스크린샷", "SMS 테스트 결과", "DB 마이그레이션 적용 확인"], "#ffe4e6")
draw_arrow(draw, (340, 215), (460, 215))
draw_arrow(draw, (740, 215), (860, 215))
draw_arrow(draw, (600, 300), (400, 430))
draw_arrow(draw, (600, 300), (800, 430))
img.save(OUT / "security_plan_document_update_map.png")
def main():
save_target_architecture()
save_data_flow()
save_document_update_map()
print(f"Generated images in {OUT}")
if __name__ == "__main__":
main()