security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>신고 등록 | (주)테크니컬코리아</title>
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/app-init.js?v=2026031401" defer></script>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
background: #f3f4f6;
margin: 0;
padding: 0;
padding-bottom: env(safe-area-inset-bottom);
-webkit-font-smoothing: antialiased;
}
/* Step indicator */
.step-indicator {
display: flex;
justify-content: center;
align-items: center;
padding: 0.75rem 0.5rem;
gap: 0;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.step {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.625rem;
color: #9ca3af;
}
.step .step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
background: #e5e7eb;
color: #9ca3af;
flex-shrink: 0;
}
.step.active .step-dot { background: #ef4444; color: white; }
.step.active { color: #ef4444; font-weight: 600; }
.step.completed .step-dot { background: #10b981; color: white; }
.step.completed { color: #10b981; }
.step-line {
width: 16px;
height: 2px;
background: #e5e7eb;
margin: 0 0.125rem;
flex-shrink: 0;
}
/* Sections */
.report-section {
margin: 0.75rem;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.section-title {
font-size: 0.9375rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title .sn {
width: 22px;
height: 22px;
border-radius: 50%;
background: #ef4444;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
/* Type buttons */
.type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.625rem;
}
.type-btn {
padding: 1.125rem 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
background: white;
cursor: pointer;
text-align: center;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.type-btn:active { transform: scale(0.97); }
.type-btn .type-icon { font-size: 1.75rem; margin-bottom: 0.5rem; }
.type-btn .type-label { font-size: 0.8125rem; font-weight: 600; color: #374151; }
.type-btn.selected { border-color: #ef4444; background: #fef2f2; }
.type-btn.selected .type-label { color: #dc2626; }
/* Map */
.factory-select {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
margin-bottom: 0.75rem;
background: white;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.map-container {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
}
.map-container canvas { width: 100%; display: block; }
.location-info {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #166534;
margin-bottom: 0.75rem;
line-height: 1.5;
}
.location-info.empty {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
}
.custom-location-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.custom-location-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #ef4444;
}
#customLocationInput {
display: none;
margin-top: 0.5rem;
}
#customLocationInput.visible { display: block; }
#customLocationInput input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
#customLocationInput input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
/* Project/Work selection - Accordion */
.project-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-group {
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
overflow: hidden;
}
.project-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
background: #f9fafb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.project-group-header:active { background: #f3f4f6; }
.project-group-header .group-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.project-group-header .group-icon {
font-size: 1.125rem;
flex-shrink: 0;
}
.project-group-header .group-title {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.project-group-header .group-count {
font-size: 0.6875rem;
background: #e5e7eb;
color: #6b7280;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-weight: 600;
}
.project-group-header .group-arrow {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s;
}
.project-group.open .group-arrow { transform: rotate(180deg); }
.project-group.tbm-group { border-color: #93c5fd; }
.project-group.tbm-group .project-group-header { background: #eff6ff; }
.project-group.tbm-group .group-count { background: #dbeafe; color: #1d4ed8; }
.project-group-body {
display: none;
border-top: 1px solid #e5e7eb;
}
.project-group.open .project-group-body { display: block; }
.project-card {
padding: 0.875rem 1rem;
cursor: pointer;
transition: background 0.12s;
-webkit-tap-highlight-color: transparent;
border-bottom: 1px solid #f3f4f6;
}
.project-card:last-child { border-bottom: none; }
.project-card:active { background: #f9fafb; }
.project-card.selected { background: #f5f3ff; }
.project-card-title {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.125rem;
}
.project-card-desc {
font-size: 0.75rem;
color: #6b7280;
}
.project-card .tbm-info {
font-size: 0.6875rem;
color: #2563eb;
margin-top: 0.25rem;
}
/* 프로젝트 모름 */
.project-skip {
padding: 0.875rem 1rem;
border: 1.5px dashed #d1d5db;
border-radius: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.project-skip:active { background: #f9fafb; }
.project-skip.selected {
border-color: #8b5cf6;
border-style: solid;
background: #f5f3ff;
color: #7c3aed;
font-weight: 600;
}
.project-empty {
text-align: center;
padding: 1.5rem 1rem;
color: #9ca3af;
font-size: 0.8125rem;
}
/* 선택된 항목 표시 */
.project-group-header .group-selected {
font-size: 0.6875rem;
color: #7c3aed;
font-weight: 600;
margin-left: 0.25rem;
}
/* Category & Item */
#categoryContainer { display: none; }
.subsection-title {
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.5rem;
}
.category-grid, #itemGrid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.category-btn, .item-btn {
padding: 0.5rem 0.875rem;
border: 1.5px solid #d1d5db;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.12s;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
}
.category-btn:active, .item-btn:active { transform: scale(0.97); }
.category-btn.selected {
border-color: #ef4444;
background: #fef2f2;
color: #dc2626;
font-weight: 600;
}
.item-btn.selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.item-btn.custom-input-btn {
border-style: dashed;
color: #6b7280;
}
.item-section {
margin-top: 1rem;
padding-top: 0.875rem;
border-top: 1px solid #f3f4f6;
}
#customItemInput {
display: none;
margin-top: 0.75rem;
gap: 0.5rem;
align-items: center;
}
#customItemInput input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
#customItemInput input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
#customItemInput button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.custom-confirm { background: #2563eb; color: white; }
.custom-cancel { background: #f3f4f6; color: #374151; }
/* Photo & Details */
.photo-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.photo-slot {
aspect-ratio: 1;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
overflow: hidden;
background: #f9fafb;
-webkit-tap-highlight-color: transparent;
}
.photo-slot .add-icon { font-size: 1.25rem; color: #9ca3af; }
.photo-slot.has-photo { border-style: solid; border-color: #10b981; }
.photo-slot.has-photo .add-icon { display: none; }
.photo-slot img { width: 100%; height: 100%; object-fit: cover; }
.photo-slot .remove-btn {
display: none;
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0,0,0,0.6);
color: white;
border: none;
font-size: 0.625rem;
cursor: pointer;
align-items: center;
justify-content: center;
z-index: 2;
line-height: 1;
}
.photo-slot.has-photo .remove-btn { display: flex; }
.photo-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-bottom: 0.75rem;
}
.description-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.description-textarea:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.1);
}
/* Submit */
.submit-section {
padding: 0.75rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
#submitBtn {
width: 100%;
padding: 0.9375rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
-webkit-tap-highlight-color: transparent;
}
#submitBtn:disabled { background: #d1d5db; cursor: not-allowed; }
#submitBtn:not(:disabled):active { background: #dc2626; }
/* 가로모드 전체화면 지도 오버레이 */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #fff;
overflow: hidden;
}
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
flex-shrink: 0;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #f1f5f9;
padding: 0.5rem;
}
.landscape-canvas-wrap canvas {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.landscape-trigger-btn {
display: none;
}
@media (max-width: 768px) {
.landscape-trigger-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
border: none;
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.landscape-trigger-btn:active {
transform: scale(0.97);
opacity: 0.85;
}
}
/* 성공 모달 */
.success-modal-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 10001;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
padding: 1rem;
}
.success-modal {
background: white;
border-radius: 1rem;
padding: 2rem 1.5rem;
text-align: center;
max-width: 320px;
width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.success-modal .success-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: #d1fae5;
color: #059669;
font-size: 2rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.success-modal h3 {
font-size: 1.125rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 0.5rem;
}
.success-modal p {
font-size: 0.875rem;
color: #6b7280;
margin: 0 0 1.5rem;
}
.success-modal .modal-buttons {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.success-modal .btn-primary {
padding: 0.75rem;
background: #ef4444;
color: white;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.success-modal .btn-primary:active { background: #dc2626; }
.success-modal .btn-secondary {
padding: 0.75rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.success-modal .btn-secondary:active { background: #e5e7eb; }
/* Responsive */
@media (min-width: 480px) {
body { max-width: 480px; margin: 0 auto; min-height: 100vh; }
}
@media (max-width: 480px) {
.photo-grid { grid-template-columns: repeat(4, 1fr); }
.type-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 360px) {
.type-grid { gap: 0.5rem; }
.type-btn { padding: 0.875rem 0.375rem; }
.type-btn .type-icon { font-size: 1.5rem; }
.photo-grid { gap: 0.375rem; }
}
</style>
</head>
<body>
<!-- AI 챗봇 신고 배너 -->
<a href="/pages/safety/chat-report.html" style="display:flex;align-items:center;gap:0.625rem;margin:0.75rem;padding:0.875rem 1rem;background:linear-gradient(135deg,#0ea5e9,#0284c7);color:white;border-radius:0.75rem;text-decoration:none;box-shadow:0 2px 8px rgba(14,165,233,0.3);-webkit-tap-highlight-color:transparent;">
<span style="font-size:1.5rem;">🤖</span>
<span style="flex:1;"><strong style="font-size:0.875rem;">AI 신고 도우미</strong><br><span style="font-size:0.75rem;opacity:0.9;">사진+설명만으로 간편하게 신고하기</span></span>
<span style="font-size:1.25rem;opacity:0.8;"></span>
</a>
<!-- Step Indicator (5 steps) -->
<div class="step-indicator">
<div class="step active"><span class="step-dot">1</span><span>유형</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">2</span><span>위치</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">3</span><span>작업</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">4</span><span>항목</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">5</span><span>사진</span></div>
</div>
<!-- Step 1: Type Selection -->
<div class="report-section">
<div class="section-title"><span class="sn">1</span>신고 유형</div>
<div class="type-grid">
<button type="button" class="type-btn" data-type="nonconformity">
<div class="type-icon">&#128270;</div>
<div class="type-label">부적합</div>
</button>
<button type="button" class="type-btn" data-type="facility">
<div class="type-icon">&#128295;</div>
<div class="type-label">시설설비</div>
</button>
<button type="button" class="type-btn" data-type="safety">
<div class="type-icon">&#9888;&#65039;</div>
<div class="type-label">안전</div>
</button>
</div>
</div>
<!-- Step 2: Location -->
<div class="report-section">
<div class="section-title"><span class="sn">2</span>위치 선택</div>
<select id="factorySelect" class="factory-select">
<option value="">공장 선택</option>
</select>
<div class="map-container">
<canvas id="issueMapCanvas"></canvas>
</div>
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">
&#128250; 전체화면 지도로 선택
</button>
<div id="selectedLocationInfo" class="location-info empty">
지도에서 작업장을 클릭하여 위치를 선택하세요
</div>
<label class="custom-location-toggle">
<input type="checkbox" id="useCustomLocation">
기타 위치 직접 입력
</label>
<div id="customLocationInput">
<input type="text" id="customLocation" placeholder="위치를 입력하세요">
</div>
</div>
<!-- Step 3: Project/Work Selection -->
<div class="report-section" id="projectContainer">
<div class="section-title"><span class="sn">3</span>프로젝트/작업 선택</div>
<div id="projectList" class="project-list">
<div class="project-empty">위치를 먼저 선택하세요</div>
</div>
</div>
<!-- Step 4: Category & Item -->
<div class="report-section" id="categoryContainer">
<div class="section-title"><span class="sn">4</span>세부 항목</div>
<div class="subsection-title">카테고리</div>
<div class="category-grid" id="categoryGrid"></div>
<div class="item-section">
<div class="subsection-title">항목</div>
<div id="itemGrid"></div>
<div id="customItemInput">
<input type="text" id="customItemName" placeholder="항목명을 입력하세요">
<button type="button" class="custom-confirm" onclick="confirmCustomItem()">확인</button>
<button type="button" class="custom-cancel" onclick="cancelCustomItem()">취소</button>
</div>
</div>
</div>
<!-- Step 5: Photos + Description -->
<div class="report-section">
<div class="section-title"><span class="sn">5</span>사진 및 상세</div>
<div class="photo-grid">
<div class="photo-slot" data-index="0"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="1"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="2"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="3"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
<div class="photo-slot" data-index="4"><span class="add-icon">+</span><button class="remove-btn">&times;</button></div>
</div>
<div class="photo-hint">* 사진 1장 이상 필수 (최대 5장, 카메라 촬영 또는 앨범에서 선택)</div>
<textarea id="additionalDescription" class="description-textarea" placeholder="추가 설명을 입력하세요 (선택사항)"></textarea>
</div>
<!-- Submit -->
<div class="submit-section">
<button type="button" id="submitBtn" disabled onclick="submitReport()">신고 제출</button>
</div>
<!-- 가로모드 전체화면 지도 오버레이 -->
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
<div id="landscapeInner" class="landscape-inner">
<div class="landscape-header">
<h3>&#127981; 작업장 선택</h3>
<button type="button" class="landscape-close-btn" onclick="closeLandscapeMap()">×</button>
</div>
<div class="landscape-canvas-wrap">
<canvas id="landscapeCanvas"></canvas>
</div>
</div>
</div>
<!-- 성공 모달 -->
<div id="successModal" class="success-modal-overlay">
<div class="success-modal">
<div class="success-icon">&#10003;</div>
<h3>신고가 성공적으로 등록되었습니다!</h3>
<p>등록된 신고는 담당자가 확인 후 처리합니다.</p>
<div class="modal-buttons">
<button class="btn-primary" onclick="window.location.href='/pages/safety/report-status.html'">신고 현황 보기</button>
<button class="btn-secondary" onclick="window.location.href='/pages/safety/issue-report.html'">새 신고하기</button>
</div>
</div>
</div>
<!-- Hidden file input for camera/gallery -->
<input type="file" id="photoInput" accept="image/*" style="display:none">
<script src="/js/cross-nav.js?v=2026031401"></script>
<script src="/js/issue-report.js?v=2026040901"></script>
</body>
</html>