diff --git a/backend/scenario/scenario_id.py b/backend/scenario/scenario_id.py new file mode 100644 index 0000000..4ff39ca --- /dev/null +++ b/backend/scenario/scenario_id.py @@ -0,0 +1,102 @@ +import os + +import httpx +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import JSONResponse + +from backend.auth.security import require_admin + +router = APIRouter(prefix="/api/admin/scenario", tags=["scenario-admin"]) + +SUPPRESSOR_BASE = ( + f"http://{os.getenv('SUPPRESSOR_PRIVATE_IP', 'localhost')}:{os.getenv('SUPPRESSOR_PORT', '8001')}" +) + + +def _suppressor_url(path: str) -> str: + return f"{SUPPRESSOR_BASE}/api/scenario/{path}" + + +async def _get(path: str) -> dict: + async with httpx.AsyncClient(timeout=30) as client: + try: + res = await client.get(_suppressor_url(path)) + res.raise_for_status() + return res.json() + except httpx.ConnectError: + raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.") + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + + +async def _delete(path: str) -> dict: + async with httpx.AsyncClient(timeout=30) as client: + try: + res = await client.delete(_suppressor_url(path)) + res.raise_for_status() + return res.json() + except httpx.ConnectError: + raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.") + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + + +# ── 목록 조회 ────────────────────────────────────────────────────────────────── +@router.get("/list", dependencies=[Depends(require_admin)]) +async def list_scenarios(): + return await _get("list") + + +# ── 단일 조회 ────────────────────────────────────────────────────────────────── +@router.get("/detail/{scenario_id}", dependencies=[Depends(require_admin)]) +async def get_scenario(scenario_id: str): + return await _get(f"detail/{scenario_id}") + + +# ── 업로드 ──────────────────────────────────────────────────────────────────── +@router.post("/upload", dependencies=[Depends(require_admin)]) +async def upload_scenario( + json_file: UploadFile = File(...), + md_file: UploadFile = File(None), +): + async with httpx.AsyncClient(timeout=60) as client: + try: + files: dict = { + "json_file": (json_file.filename, await json_file.read(), "application/json"), + } + if md_file and md_file.filename: + files["md_file"] = (md_file.filename, await md_file.read(), "text/markdown") + + res = await client.post(_suppressor_url("upload"), files=files) + res.raise_for_status() + return JSONResponse(status_code=res.status_code, content=res.json()) + except httpx.ConnectError: + raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.") + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + + +# ── 삭제 ────────────────────────────────────────────────────────────────────── +@router.delete("/delete/{scenario_id}", dependencies=[Depends(require_admin)]) +async def delete_scenario(scenario_id: str): + return await _delete(f"delete/{scenario_id}") + + +# ── vectorDB 재적재 ──────────────────────────────────────────────────────────── +@router.post("/reload", dependencies=[Depends(require_admin)]) +async def reload_scenarios(): + async with httpx.AsyncClient(timeout=300) as client: + try: + res = await client.post(_suppressor_url("reload")) + res.raise_for_status() + return res.json() + except httpx.ConnectError: + raise HTTPException(status_code=503, detail="Suppressor 서버에 연결할 수 없습니다.") + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail=e.response.text) + + +# ── vectorDB 상태 ────────────────────────────────────────────────────────────── +@router.get("/db-status", dependencies=[Depends(require_admin)]) +async def db_status(): + return await _get("db-status") \ No newline at end of file diff --git a/frontend/static/js/admin/scenario_id.js b/frontend/static/js/admin/scenario_id.js new file mode 100644 index 0000000..5e62940 --- /dev/null +++ b/frontend/static/js/admin/scenario_id.js @@ -0,0 +1,338 @@ +const API = '/api/admin/scenario' + +// ── 상태 ─────────────────────────────────────────────────────────────────────── +let allScenarios = [] + +// ── 초기화 ───────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + fetchDbStatus() + fetchScenarios() + + document + .getElementById('reload-btn') + .addEventListener('click', reloadVectorDB) + document + .getElementById('upload-form') + .addEventListener('submit', handleUpload) + document + .getElementById('json-file-input') + .addEventListener('change', onJsonFileChange) + document.getElementById('search-input').addEventListener('input', (e) => { + renderTable(filterScenarios(e.target.value)) + }) +}) + +// ── vectorDB 상태 조회 ───────────────────────────────────────────────────────── +async function fetchDbStatus() { + const el = document.getElementById('db-status') + try { + const res = await fetch(`${API}/db-status`) + const data = await res.json() + if (data.status === 'ok') { + el.textContent = `vectorDB: ${data.vector_count}개 벡터 적재됨` + el.className = + 'text-sm font-medium text-green-600 bg-green-50 px-3 py-1 rounded-full' + } else { + el.textContent = `vectorDB 연결 오류` + el.className = + 'text-sm font-medium text-red-600 bg-red-50 px-3 py-1 rounded-full' + } + } catch { + el.textContent = 'Suppressor 연결 불가' + el.className = + 'text-sm font-medium text-red-600 bg-red-50 px-3 py-1 rounded-full' + } +} + +// ── 시나리오 목록 조회 ───────────────────────────────────────────────────────── +async function fetchScenarios() { + const tbody = document.getElementById('scenario-tbody') + tbody.innerHTML = ` + + + 불러오는 중... + + ` + + try { + const res = await fetch(`${API}/list`) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + const data = await res.json() + allScenarios = data.scenarios || [] + document.getElementById('total-count').textContent = + `총 ${allScenarios.length}개` + renderTable(allScenarios) + } catch (err) { + tbody.innerHTML = ` + + + ${err.message} + + ` + } +} + +// ── 테이블 렌더링 ────────────────────────────────────────────────────────────── +function renderTable(scenarios) { + const tbody = document.getElementById('scenario-tbody') + + if (scenarios.length === 0) { + tbody.innerHTML = ` + + + 시나리오가 없습니다. + + ` + return + } + + tbody.innerHTML = scenarios + .map( + (s) => ` + + + ${s.id} + + +
+ ${(s.behavior_tags || []) + .slice(0, 3) + .map( + (tag) => + `${tag}`, + ) + .join('')} + ${ + (s.behavior_tags || []).length > 3 + ? `+${s.behavior_tags.length - 3}` + : '' + } +
+ + + ${ + s.has_doc + ? ` + check_circle 있음 + ` + : ` + warning 없음 + ` + } + + + + + + + `, + ) + .join('') +} + +// ── 검색 필터 ────────────────────────────────────────────────────────────────── +function filterScenarios(keyword) { + const q = keyword.trim().toLowerCase() + if (!q) return allScenarios + return allScenarios.filter( + (s) => + s.id.toLowerCase().includes(q) || + (s.behavior_tags || []).some((t) => t.toLowerCase().includes(q)), + ) +} + +// ── 파일 입력 변경 시 파일명 표시 ───────────────────────────────────────────── +function onJsonFileChange(e) { + const file = e.target.files[0] + const label = document.getElementById('json-file-label') + label.textContent = file ? file.name : 'JSON 파일 선택' +} + +// ── 시나리오 업로드 ──────────────────────────────────────────────────────────── +async function handleUpload(e) { + e.preventDefault() + + const jsonInput = document.getElementById('json-file-input') + const mdInput = document.getElementById('md-file-input') + const btn = document.getElementById('upload-btn') + + if (!jsonInput.files[0]) { + showToast('JSON 파일을 선택해주세요.', 'error') + return + } + + const formData = new FormData() + formData.append('json_file', jsonInput.files[0]) + if (mdInput.files[0]) { + formData.append('md_file', mdInput.files[0]) + } + + btn.disabled = true + btn.textContent = '업로드 중...' + + try { + const res = await fetch(`${API}/upload`, { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + throw new Error(data.detail || '업로드 실패') + } + + showToast(data.message, 'success') + if (data.reload_required) { + showToast('변경사항을 반영하려면 vectorDB 재적재가 필요합니다.', 'warn') + } + jsonInput.value = '' + mdInput.value = '' + document.getElementById('json-file-label').textContent = 'JSON 파일 선택' + fetchScenarios() + fetchDbStatus() + } catch (err) { + showToast(err.message, 'error') + } finally { + btn.disabled = false + btn.textContent = '업로드' + } +} + +// ── vectorDB 재적재 ──────────────────────────────────────────────────────────── +async function reloadVectorDB() { + const btn = document.getElementById('reload-btn') + const confirmed = await showConfirm( + 'vectorDB 재적재', + '기존 벡터를 모두 삭제하고 현재 시나리오 파일 전체를 다시 임베딩합니다.\n시간이 걸릴 수 있습니다. 진행할까요?', + ) + if (!confirmed) return + + btn.disabled = true + btn.innerHTML = `refresh 재적재 중...` + + try { + const res = await fetch(`${API}/reload`, { method: 'POST' }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || '재적재 실패') + showToast(`재적재 완료 — ${data.vector_count}개 벡터`, 'success') + fetchDbStatus() + } catch (err) { + showToast(err.message, 'error') + } finally { + btn.disabled = false + btn.innerHTML = `refresh vectorDB 재적재` + } +} + +// ── 삭제 확인 ────────────────────────────────────────────────────────────────── +async function confirmDelete(scenarioId) { + const confirmed = await showConfirm( + '시나리오 삭제', + `'${scenarioId}' 시나리오를 삭제합니다.\nJSON 파일과 MD 문서가 함께 삭제되며, vectorDB 재적재가 필요합니다.`, + ) + if (!confirmed) return + + try { + const res = await fetch(`${API}/delete/${scenarioId}`, { method: 'DELETE' }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || '삭제 실패') + showToast(data.message, 'success') + if (data.reload_required) { + showToast('변경사항을 반영하려면 vectorDB 재적재가 필요합니다.', 'warn') + } + fetchScenarios() + fetchDbStatus() + } catch (err) { + showToast(err.message, 'error') + } +} + +// ── 상세 모달 ────────────────────────────────────────────────────────────────── +async function openDetailModal(scenarioId) { + const modal = document.getElementById('detail-modal') + const title = document.getElementById('modal-scenario-title') + const content = document.getElementById('modal-content-area') + + title.textContent = scenarioId + content.textContent = '불러오는 중...' + modal.classList.remove('hidden') + + try { + const res = await fetch(`${API}/detail/${scenarioId}`) + const data = await res.json() + if (!res.ok) throw new Error(data.detail) + + const json = JSON.stringify(data.scenario, null, 2) + content.textContent = json + } catch (err) { + content.textContent = `오류: ${err.message}` + } +} + +function closeDetailModal() { + document.getElementById('detail-modal').classList.add('hidden') +} + +// ── 공통 확인 다이얼로그 ─────────────────────────────────────────────────────── +function showConfirm(title, message) { + return new Promise((resolve) => { + document.getElementById('confirm-title').textContent = title + document.getElementById('confirm-message').textContent = message + const modal = document.getElementById('confirm-modal') + modal.classList.remove('hidden') + + const confirmBtn = document.getElementById('confirm-ok') + const cancelBtn = document.getElementById('confirm-cancel') + + function cleanup(result) { + modal.classList.add('hidden') + confirmBtn.removeEventListener('click', onOk) + cancelBtn.removeEventListener('click', onCancel) + resolve(result) + } + function onOk() { + cleanup(true) + } + function onCancel() { + cleanup(false) + } + confirmBtn.addEventListener('click', onOk) + cancelBtn.addEventListener('click', onCancel) + }) +} + +// ── 토스트 알림 ──────────────────────────────────────────────────────────────── +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container') + const colors = { + success: 'bg-green-50 border-green-200 text-green-800', + error: 'bg-red-50 border-red-200 text-red-800', + warn: 'bg-amber-50 border-amber-200 text-amber-800', + info: 'bg-blue-50 border-blue-200 text-blue-800', + } + const icons = { + success: 'check_circle', + error: 'error', + warn: 'warning', + info: 'info', + } + + const el = document.createElement('div') + el.className = `flex items-start gap-3 px-4 py-3 rounded-xl border shadow-md text-sm font-medium transition-all ${colors[type] || colors.info}` + el.innerHTML = ` + ${icons[type] || 'info'} + ${message} + ` + container.appendChild(el) + setTimeout(() => el.remove(), 4000) +} diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index d070a7e..9739c5d 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -82,6 +82,12 @@ window.exts3SessionPromise = window.exts3SessionPromise || (async () => { } }); + document.querySelectorAll('a[href="/scenario"]').forEach(link => { + if (!isAdmin) { + link.style.display = 'none'; + } + }); + const protectedLinks = ['/search', '/library', '/build', '/user_set']; document.querySelectorAll('a').forEach(link => { const href = link.getAttribute('href') || '#'; diff --git a/frontend/templates/admin/admin_dash.html b/frontend/templates/admin/admin_dash.html index 0a6b278..78b1d52 100644 --- a/frontend/templates/admin/admin_dash.html +++ b/frontend/templates/admin/admin_dash.html @@ -96,6 +96,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리
@@ -155,6 +156,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/admin/log.html b/frontend/templates/admin/log.html index 1dc10e1..c3152e1 100644 --- a/frontend/templates/admin/log.html +++ b/frontend/templates/admin/log.html @@ -105,6 +105,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -164,6 +165,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/admin/version_diff.html b/frontend/templates/admin/version_diff.html index 604466f..e2f1d37 100644 --- a/frontend/templates/admin/version_diff.html +++ b/frontend/templates/admin/version_diff.html @@ -77,6 +77,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -88,6 +89,7 @@ home explore앱 탐색 inventory_2라이브러리 +dataset시나리오 관리 dashboard_customize관리자 대시보드 diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 9588bb4..647b3e1 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -96,6 +96,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리
@@ -156,6 +157,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/library/library.html b/frontend/templates/library/library.html index 1affd01..67d2c58 100644 --- a/frontend/templates/library/library.html +++ b/frontend/templates/library/library.html @@ -114,6 +114,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -173,6 +174,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/scenario/scenario_id.html b/frontend/templates/scenario/scenario_id.html new file mode 100644 index 0000000..402b44d --- /dev/null +++ b/frontend/templates/scenario/scenario_id.html @@ -0,0 +1,447 @@ + + + + + + 시나리오 ID 관리 - Suppressor + + + + + + + + + +
+ +
+ + + + + +
+ +
+
+

+ 시나리오 ID 관리 +

+

+ vectorDB에 적재되는 탐지 시나리오를 관리합니다. +

+
+
+ + 상태 확인 중... + + +
+
+ +
+ +
+
+
+

+ 등록된 시나리오 +

+

+ 불러오는 중... +

+
+
+ search + +
+
+ +
+ + + + + + + + + + + + + + + + +
시나리오 IDBehavior Tags문서Actions
+
+
+ + +
+
+

+ 시나리오 추가 +

+

+ JSON 파일명이 시나리오 ID가 됩니다.
+ MD 문서는 선택 사항이지만 dynamic 분석에 필요합니다. +

+ +
+ +
+ + +

+ pattern_name, vector_fingerprint 포함 필수 +

+
+ + +
+ + +

+ JSON 파일명과 동일한 이름이어야 합니다 +

+
+ + +
+
+ + +
+
+ info + 업로드 후 작업 순서 +
+
    +
  1. JSON (+ MD 선택) 파일 업로드
  2. +
  3. 업로드 완료 확인
  4. +
  5. vectorDB 재적재 버튼 클릭
  6. +
  7. 적재된 벡터 수 확인
  8. +
+

+ 삭제 후에도 재적재가 필요합니다. +

+
+
+
+
+ + + + + + + + +
+ + + + diff --git a/frontend/templates/search/detail/detail.html b/frontend/templates/search/detail/detail.html index ac098e3..aa77714 100644 --- a/frontend/templates/search/detail/detail.html +++ b/frontend/templates/search/detail/detail.html @@ -97,6 +97,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -156,6 +157,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/search/list.html b/frontend/templates/search/list.html index cccd08b..9063c0f 100644 --- a/frontend/templates/search/list.html +++ b/frontend/templates/search/list.html @@ -103,6 +103,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -143,6 +144,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/search/no_result.html b/frontend/templates/search/no_result.html index a4f1567..e454b49 100644 --- a/frontend/templates/search/no_result.html +++ b/frontend/templates/search/no_result.html @@ -95,6 +95,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -153,6 +154,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/search/search.html b/frontend/templates/search/search.html index c2ac6ec..d0c991a 100644 --- a/frontend/templates/search/search.html +++ b/frontend/templates/search/search.html @@ -111,6 +111,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -169,6 +170,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/setting/user_setting.html b/frontend/templates/setting/user_setting.html index 4809852..f68512c 100644 --- a/frontend/templates/setting/user_setting.html +++ b/frontend/templates/setting/user_setting.html @@ -94,6 +94,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -165,6 +166,10 @@

Nexu inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/frontend/templates/upload/build.html b/frontend/templates/upload/build.html index 20e7777..b673ad1 100644 --- a/frontend/templates/upload/build.html +++ b/frontend/templates/upload/build.html @@ -101,6 +101,7 @@ 대시보드 앱 탐색 라이브러리 +시나리오 관리

@@ -159,6 +160,10 @@

Supp inventory_2 라이브러리 + +dataset +시나리오 관리 + dashboard_customize 관리자 대시보드 diff --git a/main.py b/main.py index acbc207..f3fc81e 100644 --- a/main.py +++ b/main.py @@ -240,6 +240,12 @@ async def admin_policy_catalog(request: Request): if blocked is not None: return blocked return templates.TemplateResponse(request, "admin/policy_catalog.html", {"request": request}) +@app.get("/scenario", response_class=HTMLResponse) +async def admin_scenario_id(request: Request): + blocked = require_admin_page(request) + if blocked: + return blocked + return templates.TemplateResponse(request, "scenario/scenario_id.html", {"request": request}) # 라이브러리 페이지 @@ -286,6 +292,8 @@ async def build(request: Request): app.include_router(policy_catalog_router) from backend.admin.permissions import router as admin_permissions_router app.include_router(admin_permissions_router) +from backend.scenario.scenario_id import router as scenario_id_router +app.include_router(scenario_id_router) # 업로드 파일 스캔 서버로 전송----------------------- from backend.security_scan.file_save import router as file_save_router