Skip to content

Commit ebdfbc6

Browse files
committed
phase1 개발 완료
1 parent a3f2e94 commit ebdfbc6

12 files changed

Lines changed: 1366 additions & 73 deletions

File tree

README.md

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,69 @@ Phase 1 입력 소스/스키마/질의 초안은 `docs/PHASE1_INPUT_SOURCES_AND_
3030
Phase 1 논리 테이블 설계 초안은 `docs/PHASE1_LOGICAL_SCHEMA.md`에 정리합니다.
3131
Postgres 기준 초기 DDL 초안은 `schema/001_phase1_initial.sql`에 정리합니다.
3232

33+
---
34+
35+
## Security Data Query Platform — 단계별 진행 현황
36+
37+
### ✅ Phase 1: 데이터 수집/정규화 코어 — **완료**
38+
39+
`src/mori_soc/` 아래에 Python 라이브러리로 구현되었습니다.
40+
현재는 **인메모리 레이어**로 동작하며, 실제 DB/API 연결 없이 로직 검증이 가능합니다.
41+
42+
| 구성 요소 | 모듈 | 내용 |
43+
|---|---|---|
44+
| **데이터 모델** | `models/entities.py` | Host, HostAlias, Alert, Vulnerability, HostObservation, QueryResult |
45+
| **Fleet 수집기** | `collectors/fleet_logs.py` | osquery status/result 로그 파싱, NormalizedEnvelope 생성 |
46+
| **Wazuh 수집기** | `collectors/wazuh_alerts.py` | Wazuh 4.x alert JSON 파싱, level → severity 변환 |
47+
| **Zabbix 수집기** | `collectors/zabbix_events.py` | trigger 이벤트 → alert, item 값 → host_observation |
48+
| **정규화/엔터티 매핑** | `services/normalization.py` | EnvelopeEntityMapper — host 자동 생성, HostAlias 등록 |
49+
| **수집 인제스천** | `services/ingestion.py` | CollectorIngestionService — 수집기 → 저장소 흐름 |
50+
| **Risk Score 계산기** | `services/risk_score.py` | alert/vuln 심각도 가중치 기반 점수 산출 |
51+
| **질의 카탈로그** | `services/query_catalog.py` | 12개 질의 인텐트 정의 |
52+
| **질의 서비스** | `services/query_service.py` | 12개 인텐트 핸들러, 근거(evidence) 기반 응답 |
53+
| **논리 뷰 집계** | `services/views.py` | latest_host_status_view, host_risk_summary_view, host_timeline_view |
54+
| **인메모리 저장소** | `repositories/memory.py` | InMemoryRepository, InMemoryQueryStore |
55+
| **API 계약** | `api/contracts.py` | QueryRequest, QueryResponse, EvidenceRef, QueryScope |
56+
| **단위 테스트** | `tests/test_query_service.py` | 20+ 테스트 케이스 (질의 1~12 + 뷰 3개) |
57+
58+
**12개 질의 인텐트:**
59+
60+
| # | intent | 설명 |
61+
|---|---|---|
62+
| 1 | `alert_summary` | 지난 N시간 high/critical 경보 요약 |
63+
| 2 | `offline_hosts` | 현재 오프라인/unknown 호스트 |
64+
| 3 | `fleet_checkin_gap` | Fleet 체크인 누락 호스트 |
65+
| 4 | `top_vulnerable_hosts` | 취약점 상위 호스트 Top N |
66+
| 5 | `host_timeline` | 특정 호스트 타임라인 (alert+query+obs 병합) |
67+
| 6 | `host_wazuh_alerts` | 특정 호스트 Wazuh 경보만 조회 |
68+
| 7 | `host_fleet_queries` | 특정 호스트 Fleet 쿼리 결과 조회 |
69+
| 8 | `new_high_vulns` | 최근 신규 high+ 취약점 |
70+
| 9 | `risky_hosts` | 경보 多 + offline/unknown 호스트 |
71+
| 10 | `unmapped_assets` | Fleet/Wazuh/Zabbix 미매핑 자산 |
72+
| 11 | `login_failure_spike` | 로그인 실패 급증 호스트 |
73+
| 12 | `collection_errors` | 수집 오류 반복 호스트 |
74+
75+
### 🔲 Phase 2: 관제 질의 엔진 — **미착수**
76+
77+
다음에 구현해야 할 내용입니다.
78+
79+
| 항목 | 내용 |
80+
|---|---|
81+
| **FastAPI HTTP 서버** | `POST /query` 엔드포인트, QueryRequest → QueryResponse |
82+
| **PostgresRepository** | InMemoryRepository를 Postgres로 교체, `schema/001_phase1_initial.sql` 적용 |
83+
| **자연어 → Intent 변환** | 입력 문장을 intent + filter로 파싱하는 경량 변환기 |
84+
| **Dockerfile** | `mori-soc` Python 서비스용 컨테이너 이미지 |
85+
| **docker-compose.yml 추가** | `mori-api` 서비스 + 전용 `soc-postgres` DB 항목 |
86+
| **실시간 수집 연동** | Wazuh/Fleet/Zabbix API 폴링 또는 webhook 수신 |
87+
88+
### 🔲 Phase 3: 제한형 조사 에이전트 — **미착수**
89+
90+
- host/user/ip 기준 다단계 pivot
91+
- 여러 소스 cross-check
92+
- 조사형 질문 지원 + 다음 확인 포인트 추천
93+
94+
---
95+
3396
## 참고 문서
3497

3598
- `docs/FUNCTIONAL_SPEC.md`: 기능 정의서 원문
@@ -99,8 +162,19 @@ Postgres 기준 초기 DDL 초안은 `schema/001_phase1_initial.sql`에 정리
99162
- `docs/PHASE1_INPUT_SOURCES_AND_SCHEMA.md`: Phase 1 입력 소스/스키마/질의 명세 문서
100163
- `docs/PHASE1_LOGICAL_SCHEMA.md`: Phase 1 논리 테이블/관계 설계 문서
101164
- `schema/001_phase1_initial.sql`: Phase 1 초기 SQL 스키마 파일
102-
- `src/mori_soc/*`: Phase 1 Python 구현 골격(모델, collector 계약, API 계약, 질의 카탈로그)
103-
- `tests/*`: Phase 1 collector / query service / ingestion 단위 테스트
165+
- `src/mori_soc/models/entities.py`: Host, HostAlias, Alert, Vulnerability, HostObservation, QueryResult 엔터티
166+
- `src/mori_soc/collectors/fleet_logs.py`: Fleet osquery log 수집기
167+
- `src/mori_soc/collectors/wazuh_alerts.py`: Wazuh 4.x alert 수집기
168+
- `src/mori_soc/collectors/zabbix_events.py`: Zabbix trigger/item 수집기
169+
- `src/mori_soc/services/normalization.py`: 수집 엔벨로프 → 엔터티 매핑 (EnvelopeEntityMapper)
170+
- `src/mori_soc/services/ingestion.py`: 수집기 인제스천 서비스
171+
- `src/mori_soc/services/risk_score.py`: Risk Score 계산기 (alert/vuln 가중치 기반)
172+
- `src/mori_soc/services/query_catalog.py`: Phase 1 질의 카탈로그 (12개 인텐트)
173+
- `src/mori_soc/services/query_service.py`: 인텐트 핸들러 + 근거(evidence) 기반 응답
174+
- `src/mori_soc/services/views.py`: 논리 뷰 집계 (latest_host_status, host_risk_summary, host_timeline)
175+
- `src/mori_soc/repositories/memory.py`: InMemoryRepository / InMemoryQueryStore
176+
- `src/mori_soc/api/contracts.py`: QueryRequest, QueryResponse, EvidenceRef, QueryScope
177+
- `tests/test_query_service.py`: 20+ 단위 테스트 (질의 1~12 + 뷰 3개)
104178
- `docs/ZABBIX_AGENT_ACTIVE_SETUP.md`: Zabbix Agent 온보딩 문서
105179
- `docs/TRIVY_USAGE.md`: Trivy 활용 가이드
106180
- `docs/FLEET_MACBOOK_ENROLLMENT_AND_TEST.md`: Fleet macOS 등록/검증/대시보드 확인 문서
@@ -261,35 +335,41 @@ Grafana Explore에서 Loki로 아래 쿼리를 확인합니다.
261335
- Fleet status 로그: `{job="fleetdm", log_type="status"}`
262336
- Fleet result 로그: `{job="fleetdm", log_type="result"}`
263337

264-
## 12. 집에서 이어서 작업할 때 사용할 프롬프트
338+
## 12. 이어서 작업할 때 사용할 프롬프트
265339

266340
다른 장소에서 이 저장소 작업을 다시 이어갈 때는,
267341
현재 목표와 읽어야 할 파일, 그리고 바로 다음 작업 범위를 한 번에 적어주면 가장 빠르게 이어집니다.
268342

269-
### 짧은 버전
343+
### 현재 상태 (Phase 1 완료 / Phase 2 미착수)
270344

271-
- `이 저장소는 MORI SOC-lite이고 지금은 Security Data Query Platform Phase 1 구현 중이야.`
272-
- `README, docs/SECURITY_DATA_QUERY_PLATFORM.md, docs/PHASE1_INPUT_SOURCES_AND_SCHEMA.md, docs/PHASE1_LOGICAL_SCHEMA.md를 먼저 읽고 현재 상태를 요약해줘.`
273-
- `그 다음 src/mori_soc와 tests를 보고 마지막 구현 다음 단계부터 코드와 테스트까지 이어서 진행해줘.`
345+
- Phase 1 (데이터 수집/정규화 코어) — **완료**
346+
- `src/mori_soc/` : 수집기, 정규화, 질의 서비스, 뷰 집계, 인메모리 저장소
347+
- `tests/test_query_service.py` : 20+ 단위 테스트
348+
- Phase 2 (관제 질의 엔진) — **미착수**
349+
- FastAPI HTTP 서버, PostgresRepository, Dockerfile, docker-compose 항목 추가 필요
274350

275-
### 추천 시작 프롬프트
351+
### Phase 2 시작 프롬프트 (추천)
276352

277-
- `이 저장소는 MORI SOC-lite이며, 현재 목표는 FleetDM/Wazuh/Zabbix/host log를 수집·정규화해서 자연어로 조회할 수 있는 Security Data Query Platform을 만드는 것이다. 지금은 Phase 1(Data Collection/Normalization Core) 구현 중이다. 먼저 README, docs/SECURITY_DATA_QUERY_PLATFORM.md, docs/PHASE1_INPUT_SOURCES_AND_SCHEMA.md, docs/PHASE1_LOGICAL_SCHEMA.md, schema/001_phase1_initial.sql, src/mori_soc, tests를 읽고 현재 구현 상태를 요약해줘. 그 다음 아직 구현되지 않은 다음 단계 하나를 제안하고 바로 구현과 테스트까지 진행해줘.`
353+
```
354+
이 저장소는 MORI SOC-lite이며, Security Data Query Platform을 단계별로 구현 중이다.
355+
README의 "Security Data Query Platform — 단계별 진행 현황" 섹션과
356+
docs/SECURITY_DATA_QUERY_PLATFORM.md, docs/PHASE1_LOGICAL_SCHEMA.md,
357+
schema/001_phase1_initial.sql, src/mori_soc/를 읽고 현재 상태를 확인해줘.
358+
Phase 1(데이터 수집/정규화 코어)은 완료되어 있다.
359+
Phase 2(관제 질의 엔진)를 시작해줘: FastAPI HTTP 서버 → PostgresRepository → Dockerfile → docker-compose 항목 순서로.
360+
```
278361

279-
### 같이 적으면 좋은 추가 정보
280-
281-
- 이번에 하고 싶은 범위
282-
- 예: `Fleet collector 고도화`, `repository 추가`, `Wazuh collector 시작`
283-
- 이번 턴 목표
284-
- 예: `코드 작성 + unit test 통과까지`
285-
- 실행 허용 범위
286-
- 예: `safe한 unit test는 바로 실행해도 됨`
362+
### 짧은 버전
287363

288-
### 예시
364+
```
365+
이 저장소 MORI SOC-lite에서 Phase 2 시작해줘.
366+
README 상단 현황 섹션, src/mori_soc, schema/001_phase1_initial.sql 읽고 바로 이어서.
367+
```
289368

290-
- `Phase 1 계속하자. 이번에는 src/mori_soc 기준으로 Wazuh alert collector stub와 테스트를 추가해줘. 변경 후 unit test까지 실행해줘.`
369+
### 같이 적으면 좋은 추가 정보
291370

292-
이렇게 시작하면 이전 대화가 길더라도 저장소 상태 기준으로 맥락을 빠르게 복원할 수 있습니다.
371+
- 이번에 하고 싶은 범위 (예: `FastAPI 서버만`, `Postgres 연결까지`, `Docker까지`)
372+
- 실행 허용 범위 (예: `unit test는 바로 실행해도 됨`)
293373

294374
Starter dashboard에도 아래 패널이 표시됩니다.
295375

@@ -327,15 +407,24 @@ Starter dashboard에도 아래 패널이 표시됩니다.
327407
- Trivy 실행 가능
328408
- Wazuh/Fleet 내부 포트 접속 가능
329409

330-
## 12. 다음 작업 후보
410+
## 13. 다음 작업 후보
411+
412+
### Security Data Query Platform (Phase 2)
413+
414+
1. **FastAPI HTTP 서버**`src/mori_soc/api/server.py` 생성, `POST /query` 엔드포인트
415+
2. **PostgresRepository**`src/mori_soc/repositories/postgres.py`, `schema/001_phase1_initial.sql` 적용
416+
3. **Dockerfile**`mori-soc` Python 서비스 컨테이너 이미지
417+
4. **docker-compose.yml**`mori-api` + `soc-postgres` 서비스 항목 추가
418+
5. **자연어 → Intent 변환기** — 입력 문장을 intent + filter로 파싱하는 경량 규칙/LLM 기반 변환기
419+
420+
### 인프라 운영
331421

332422
- Grafana 대시보드/프로비저닝 고도화
333-
- Zabbix 템플릿/API 자동화 또는 통합 운영 UI 설계
334423
- HTTPS 리버스 프록시(Nginx/Caddy) 추가
335424
- Wazuh/Fleet 초기 운영 설정 보강
336-
- 실제 서버 기동 후 헬스체크 및 초기 로그인 검증
337425

338426
---
339427

340-
이 저장소는 **초기 배포 스캐폴드와 자동화 기반**까지 정리된 상태이며,
341-
실서비스 운영 전에는 서버 리소스, 인증서, 초기 계정/비밀번호 정책에 맞춘 추가 보완이 필요합니다.
428+
이 저장소는 **초기 배포 스캐폴드 + Phase 1 데이터 처리 로직**까지 정리된 상태입니다.
429+
실서비스 배포를 위해서는 Phase 2(HTTP API + DB 연결 + Docker화)가 필요하며,
430+
그 전에는 서버 리소스, 인증서, 초기 계정/비밀번호 정책에 맞춘 추가 보완도 필요합니다.

src/mori_soc/collectors/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@
22

33
from .base import BaseCollector, CollectorRecord, NormalizedEnvelope
44
from .fleet_logs import FleetLogCollector
5+
from .wazuh_alerts import WazuhAlertCollector
6+
from .zabbix_events import ZabbixEventCollector
57

6-
__all__ = ["BaseCollector", "CollectorRecord", "NormalizedEnvelope", "FleetLogCollector"]
8+
__all__ = [
9+
"BaseCollector",
10+
"CollectorRecord",
11+
"NormalizedEnvelope",
12+
"FleetLogCollector",
13+
"WazuhAlertCollector",
14+
"ZabbixEventCollector",
15+
]
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
from datetime import datetime, timezone
6+
from typing import Iterable
7+
8+
from .base import BaseCollector, CollectorRecord, NormalizedEnvelope
9+
10+
11+
class WazuhAlertCollector(BaseCollector):
12+
"""Collector for Wazuh 4.x alert JSON lines.
13+
14+
각 줄은 Wazuh alert JSON 형식:
15+
{"id": "...", "timestamp": "...", "agent": {"id": "001", "name": "mbp-01", "ip": "..."},
16+
"rule": {"id": "100001", "level": 10, "description": "..."}, "full_log": "..."}
17+
"""
18+
19+
def __init__(self, alert_lines: Iterable[str] = ()) -> None:
20+
self._alert_lines = tuple(alert_lines)
21+
22+
@property
23+
def source_name(self) -> str:
24+
return "wazuh"
25+
26+
def collect(self) -> Iterable[CollectorRecord]:
27+
yield from self.collect_lines(self._alert_lines)
28+
29+
def collect_lines(self, lines: Iterable[str]) -> list[CollectorRecord]:
30+
records: list[CollectorRecord] = []
31+
for index, line in enumerate(lines):
32+
payload = json.loads(line)
33+
observed_at = self._extract_timestamp(payload)
34+
host_aliases = self._extract_host_aliases(payload)
35+
external_id = str(payload.get("id") or f"wazuh-{index}")
36+
records.append(
37+
CollectorRecord(
38+
source=self.source_name,
39+
record_type="alert",
40+
observed_at=observed_at,
41+
external_id=external_id,
42+
host_aliases=host_aliases,
43+
payload=payload,
44+
)
45+
)
46+
return records
47+
48+
def normalize(self, record: CollectorRecord) -> Iterable[NormalizedEnvelope]:
49+
if record.record_type == "alert":
50+
yield self._normalize_alert(record)
51+
return
52+
raise ValueError(f"Unsupported Wazuh record_type: {record.record_type}")
53+
54+
def _normalize_alert(self, record: CollectorRecord) -> NormalizedEnvelope:
55+
payload = record.payload
56+
agent = payload.get("agent") if isinstance(payload.get("agent"), dict) else {}
57+
rule = payload.get("rule") if isinstance(payload.get("rule"), dict) else {}
58+
59+
level = rule.get("level") if isinstance(rule, dict) else None
60+
severity = self._normalize_severity(level)
61+
62+
host_alias = record.host_aliases[0] if record.host_aliases else None
63+
hostname = self._str(agent.get("name") if isinstance(agent, dict) else None) or host_alias
64+
primary_ip = self._str(agent.get("ip") if isinstance(agent, dict) else None)
65+
66+
rule_desc = self._str(rule.get("description") if isinstance(rule, dict) else None)
67+
full_log = payload.get("full_log", "")
68+
message = rule_desc or (str(full_log)[:200] if full_log else None) or "wazuh alert"
69+
rule_id = self._str(str(rule.get("id")) if isinstance(rule, dict) and rule.get("id") is not None else None)
70+
71+
normalized = {
72+
"host_id": host_alias,
73+
"hostname": hostname,
74+
"primary_ip": primary_ip,
75+
"source_event_id": record.external_id,
76+
"severity": severity,
77+
"original_severity": str(level) if level is not None else None,
78+
"rule_name": rule_desc,
79+
"rule_id": rule_id,
80+
"message": message,
81+
}
82+
return NormalizedEnvelope(
83+
entity_type="alert",
84+
entity_id=self._make_id(record),
85+
observed_at=record.observed_at,
86+
source=self.source_name,
87+
raw_ref=f"wazuh:{record.external_id}",
88+
normalized=normalized,
89+
raw_payload=payload,
90+
)
91+
92+
def _extract_host_aliases(self, payload: dict) -> list[str]:
93+
aliases: list[str] = []
94+
agent = payload.get("agent") if isinstance(payload.get("agent"), dict) else {}
95+
for candidate in (
96+
agent.get("name") if isinstance(agent, dict) else None,
97+
agent.get("id") if isinstance(agent, dict) else None,
98+
):
99+
if isinstance(candidate, str) and candidate and candidate not in aliases:
100+
aliases.append(candidate)
101+
return aliases
102+
103+
def _extract_timestamp(self, payload: dict) -> datetime:
104+
ts = payload.get("timestamp")
105+
if isinstance(ts, str):
106+
for fmt in (
107+
lambda v: datetime.fromisoformat(v.replace("Z", "+00:00").replace("+0000", "+00:00")),
108+
):
109+
try:
110+
parsed = fmt(ts)
111+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
112+
except (ValueError, TypeError):
113+
continue
114+
return datetime.now(tz=timezone.utc)
115+
116+
def _normalize_severity(self, level: object) -> str:
117+
try:
118+
lvl = int(level) # type: ignore[arg-type]
119+
except (TypeError, ValueError):
120+
return "info"
121+
if lvl >= 13:
122+
return "critical"
123+
if lvl >= 11:
124+
return "high"
125+
if lvl >= 8:
126+
return "medium"
127+
if lvl >= 4:
128+
return "low"
129+
return "info"
130+
131+
def _make_id(self, record: CollectorRecord) -> str:
132+
digest = hashlib.sha1(
133+
f"wazuh|{record.external_id}|{record.observed_at.isoformat()}".encode("utf-8")
134+
).hexdigest()
135+
return f"wazuh-alert-{digest[:16]}"
136+
137+
def _str(self, value: object) -> str | None:
138+
return value if isinstance(value, str) and value else None
139+

0 commit comments

Comments
 (0)