ltcai 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,14 +38,22 @@ def create_knowledge_graph_router(
38
38
  """Serve the interactive knowledge graph canvas UI."""
39
39
  graph()
40
40
  require_user(request)
41
- return FileResponse(static_dir / "graph.html")
41
+ response = FileResponse(static_dir / "graph.html")
42
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
43
+ response.headers["Pragma"] = "no-cache"
44
+ response.headers["Expires"] = "0"
45
+ return response
42
46
 
43
47
  @router.get("/knowledge-graph")
44
48
  async def knowledge_graph_legacy_page(request: Request):
45
49
  """Backward-compatible route for the graph page."""
46
50
  graph()
47
51
  require_user(request)
48
- return FileResponse(static_dir / "graph.html")
52
+ response = FileResponse(static_dir / "graph.html")
53
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
54
+ response.headers["Pragma"] = "no-cache"
55
+ response.headers["Expires"] = "0"
56
+ return response
49
57
 
50
58
  @router.get("/knowledge-graph/stats")
51
59
  async def knowledge_graph_stats(request: Request):
@@ -0,0 +1,580 @@
1
+ """Lattice AI Admin Security & Audit Command Center.
2
+
3
+ 피드백 #5 (lattice_ai_admin_security_dashboard_review.txt) 반영.
4
+
5
+ 추가 엔드포인트:
6
+ GET /admin/security/overview
7
+ GET /admin/security/users
8
+ GET /admin/security/events
9
+ GET /admin/security/events/{event_id}
10
+ GET /admin/security/conversations/{conversation_id}
11
+ GET /admin/security/conversations/{conversation_id}/raw
12
+ GET /admin/security/files
13
+ GET /admin/security/files/{file_id}
14
+ GET /admin/security/files/{file_id}/content
15
+ GET /admin/security/raw
16
+ POST /admin/security/export
17
+
18
+ 핵심 원칙:
19
+ - Secret/API key/token/password/private key는 관리자도 원문을 보면 안 됨.
20
+ - 원문 조회 자체를 admin_view_sensitive_raw 감사로그로 남김.
21
+ - 모든 응답은 마스킹된 preview를 기본으로, raw는 별도 권한 필요.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ import json
28
+ import logging
29
+ import re
30
+ import time
31
+ from collections import defaultdict
32
+ from datetime import datetime
33
+ from typing import Any, Callable, Dict, List, Optional, Tuple
34
+
35
+ from fastapi import APIRouter, HTTPException, Query, Request
36
+ from fastapi.responses import Response, StreamingResponse
37
+ from pydantic import BaseModel
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ # ── Hard secret patterns ──────────────────────────────────────────────────────
43
+ # 이 값들은 관리자도 절대 원문으로 보면 안 된다.
44
+
45
+ HARD_SECRET_PATTERNS = [
46
+ re.compile(r"(?i)(api[_-]?key|secret|access[_-]?token|password|passwd|bearer)\s*[:=]\s*\S+"),
47
+ re.compile(r"sk-[A-Za-z0-9]{20,}"),
48
+ re.compile(r"ghp_[A-Za-z0-9]{30,}"),
49
+ re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
50
+ re.compile(r"AKIA[0-9A-Z]{16}"),
51
+ re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----"),
52
+ ]
53
+
54
+
55
+ def redact_hard_secrets(text: str) -> str:
56
+ if not text:
57
+ return text or ""
58
+ out = text
59
+ for pat in HARD_SECRET_PATTERNS:
60
+ out = pat.sub("[REDACTED_SECRET]", out)
61
+ return out
62
+
63
+
64
+ def soft_mask(text: str, *, keep: int = 4) -> str:
65
+ if not text:
66
+ return ""
67
+ raw = redact_hard_secrets(text)
68
+ if len(raw) <= keep * 2:
69
+ return "*" * len(raw)
70
+ return raw[:keep] + "*" * min(len(raw) - keep * 2, 30) + raw[-keep:]
71
+
72
+
73
+ # ── Export models ─────────────────────────────────────────────────────────────
74
+
75
+
76
+ class ExportRequest(BaseModel):
77
+ scope: str = "events" # events | users | files | conversations | overview
78
+ format: str = "json" # json | csv | excel | pdf | txt
79
+ filters: Optional[Dict[str, Any]] = None
80
+
81
+
82
+ # ── Helpers ───────────────────────────────────────────────────────────────────
83
+
84
+
85
+ def _user_label(users: Dict[str, Dict[str, Any]], email: Optional[str]) -> str:
86
+ if not email:
87
+ return "Unknown"
88
+ u = users.get(email) or {}
89
+ return u.get("nickname") or u.get("name") or email
90
+
91
+
92
+ def _summarize_user_risk(
93
+ history: List[Dict[str, Any]],
94
+ file_events: List[Dict[str, Any]],
95
+ classify_sensitive_message: Callable[[Dict[str, Any], int], Dict[str, Any]],
96
+ ) -> List[Dict[str, Any]]:
97
+ """사용자별 준수 채팅/위험 채팅/준수 파일/위험 파일 카운트."""
98
+ buckets: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
99
+ "user": "Unknown",
100
+ "total_chats": 0,
101
+ "compliant_chats": 0,
102
+ "risky_chats": 0,
103
+ "uploaded_files": 0,
104
+ "compliant_files": 0,
105
+ "risky_files": 0,
106
+ "high_risk_events": 0,
107
+ "last_activity_at": None,
108
+ })
109
+
110
+ for idx, item in enumerate(history):
111
+ role = item.get("role")
112
+ if role != "user":
113
+ continue
114
+ email = item.get("user_email") or item.get("user_nickname") or "Unknown"
115
+ nickname = item.get("user_nickname") or email
116
+ bucket = buckets[email]
117
+ bucket["user"] = nickname
118
+ bucket["total_chats"] += 1
119
+ try:
120
+ cls = classify_sensitive_message(item, idx)
121
+ except Exception:
122
+ cls = {"sensitivity": "none"}
123
+ if (cls.get("sensitivity") or "none") != "none":
124
+ bucket["risky_chats"] += 1
125
+ if cls.get("sensitivity") == "high":
126
+ bucket["high_risk_events"] += 1
127
+ else:
128
+ bucket["compliant_chats"] += 1
129
+ ts = item.get("timestamp")
130
+ if ts and (not bucket["last_activity_at"] or ts > bucket["last_activity_at"]):
131
+ bucket["last_activity_at"] = ts
132
+
133
+ for fe in file_events:
134
+ email = fe.get("user_email") or "Unknown"
135
+ bucket = buckets[email]
136
+ bucket["uploaded_files"] += 1
137
+ if (fe.get("sensitivity") or "none") != "none" or fe.get("sensitive_labels"):
138
+ bucket["risky_files"] += 1
139
+ if fe.get("sensitivity") == "high":
140
+ bucket["high_risk_events"] += 1
141
+ else:
142
+ bucket["compliant_files"] += 1
143
+
144
+ out: List[Dict[str, Any]] = []
145
+ for email, b in buckets.items():
146
+ total = b["total_chats"] + b["uploaded_files"]
147
+ risk = b["risky_chats"] + b["risky_files"]
148
+ b["email"] = email
149
+ b["risk_rate"] = round((risk / total) * 100, 1) if total else 0.0
150
+ out.append(b)
151
+ out.sort(key=lambda x: (x["high_risk_events"], x["risky_chats"] + x["risky_files"]), reverse=True)
152
+ return out
153
+
154
+
155
+ def _csv_dump(rows: List[Dict[str, Any]]) -> bytes:
156
+ import csv
157
+
158
+ buf = io.StringIO()
159
+ if not rows:
160
+ return b""
161
+ keys: List[str] = []
162
+ seen: set = set()
163
+ for r in rows:
164
+ for k in r.keys():
165
+ if k not in seen:
166
+ seen.add(k)
167
+ keys.append(k)
168
+ writer = csv.DictWriter(buf, fieldnames=keys, extrasaction="ignore")
169
+ writer.writeheader()
170
+ for r in rows:
171
+ sanitized = {k: redact_hard_secrets(str(v)) if isinstance(v, str) else v for k, v in r.items()}
172
+ writer.writerow(sanitized)
173
+ return buf.getvalue().encode("utf-8")
174
+
175
+
176
+ def _excel_dump(rows: List[Dict[str, Any]]) -> bytes:
177
+ try:
178
+ from openpyxl import Workbook
179
+ except Exception: # pragma: no cover
180
+ return _csv_dump(rows)
181
+ wb = Workbook()
182
+ ws = wb.active
183
+ ws.title = "security_export"
184
+ headers: List[str] = []
185
+ if rows:
186
+ seen: set = set()
187
+ for r in rows:
188
+ for k in r.keys():
189
+ if k not in seen:
190
+ seen.add(k)
191
+ headers.append(k)
192
+ ws.append(headers)
193
+ for r in rows:
194
+ ws.append([
195
+ redact_hard_secrets(str(r.get(h))) if isinstance(r.get(h), str) else r.get(h)
196
+ for h in headers
197
+ ])
198
+ else:
199
+ ws.append(["empty"])
200
+ buf = io.BytesIO()
201
+ wb.save(buf)
202
+ return buf.getvalue()
203
+
204
+
205
+ def _pdf_report(title: str, rows: List[Dict[str, Any]], overview: Dict[str, Any]) -> bytes:
206
+ try:
207
+ from reportlab.lib.pagesizes import A4
208
+ from reportlab.lib.styles import getSampleStyleSheet
209
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table
210
+ except Exception: # pragma: no cover
211
+ return ("PDF library not available\n" + json.dumps(overview, ensure_ascii=False, indent=2)).encode("utf-8")
212
+
213
+ buf = io.BytesIO()
214
+ doc = SimpleDocTemplate(buf, pagesize=A4, title=title)
215
+ styles = getSampleStyleSheet()
216
+ story: List[Any] = []
217
+ story.append(Paragraph(f"<b>{title}</b>", styles["Title"]))
218
+ story.append(Spacer(1, 12))
219
+ story.append(Paragraph(f"Generated: {datetime.utcnow().isoformat()}Z", styles["Normal"]))
220
+ story.append(Spacer(1, 12))
221
+ story.append(Paragraph("Overview", styles["Heading2"]))
222
+ for k, v in overview.items():
223
+ story.append(Paragraph(f"{k}: {redact_hard_secrets(str(v))}", styles["Normal"]))
224
+ story.append(Spacer(1, 12))
225
+ if rows:
226
+ story.append(Paragraph("Top entries", styles["Heading2"]))
227
+ headers = list(rows[0].keys())
228
+ table_data = [headers] + [
229
+ [redact_hard_secrets(str(r.get(h, ""))) for h in headers] for r in rows[:30]
230
+ ]
231
+ story.append(Table(table_data))
232
+ doc.build(story)
233
+ return buf.getvalue()
234
+
235
+
236
+ # ── Router factory ────────────────────────────────────────────────────────────
237
+
238
+
239
+ def create_security_router(
240
+ *,
241
+ require_admin: Callable,
242
+ get_history: Callable,
243
+ get_audit_events: Callable,
244
+ classify_sensitive_message: Callable,
245
+ build_sensitivity_report: Callable,
246
+ list_uploaded_files: Optional[Callable] = None,
247
+ get_conversation: Optional[Callable] = None,
248
+ append_audit_event: Optional[Callable] = None,
249
+ ) -> APIRouter:
250
+ """관리자 보안/감사 Command Center API.
251
+
252
+ - require_admin(request) -> (admin_email, users)
253
+ - get_history() -> List[Dict]
254
+ - get_audit_events() -> List[Dict]
255
+ - classify_sensitive_message(item, index) -> Dict
256
+ - build_sensitivity_report(history) -> Dict
257
+ - list_uploaded_files() -> Optional[List[Dict]] (없으면 audit_events에서 추론)
258
+ - get_conversation(conversation_id) -> Optional[Dict]
259
+ - append_audit_event(event_type, **payload) -> None
260
+ """
261
+
262
+ router = APIRouter()
263
+
264
+ # ── Common loaders ────────────────────────────────────────────────────
265
+
266
+ def _events() -> List[Dict[str, Any]]:
267
+ try:
268
+ return list(get_audit_events() or [])
269
+ except Exception as e:
270
+ logger.warning("get_audit_events failed: %s", e)
271
+ return []
272
+
273
+ def _file_events() -> List[Dict[str, Any]]:
274
+ if list_uploaded_files:
275
+ try:
276
+ items = list(list_uploaded_files() or [])
277
+ if items:
278
+ return items
279
+ except Exception:
280
+ logger.debug("list_uploaded_files failed", exc_info=True)
281
+ return [e for e in _events() if e.get("event_type") == "document_upload"]
282
+
283
+ def _log_view(admin_email: str, target_type: str, target_id: str, reason: str = "security_review") -> None:
284
+ if not append_audit_event:
285
+ return
286
+ try:
287
+ append_audit_event(
288
+ "admin_view_sensitive_raw",
289
+ admin_email=admin_email,
290
+ target_type=target_type,
291
+ target_id=target_id,
292
+ reason=reason,
293
+ )
294
+ except Exception:
295
+ logger.debug("append_audit_event for admin_view_sensitive_raw failed", exc_info=True)
296
+
297
+ # ── 1. Security Overview ──────────────────────────────────────────────
298
+
299
+ @router.get("/admin/security/overview")
300
+ async def security_overview(request: Request):
301
+ require_admin(request)
302
+ history = get_history() or []
303
+ events = _events()
304
+ report = build_sensitivity_report(history) or {}
305
+ summary = report.get("summary", {})
306
+ sev = summary.get("severity_counts", {}) or {}
307
+ today = datetime.utcnow().date().isoformat()
308
+ today_events = [e for e in events if str(e.get("timestamp", ""))[:10] == today]
309
+
310
+ return {
311
+ "generated_at": datetime.utcnow().isoformat() + "Z",
312
+ "cards": {
313
+ "events_today": len(today_events),
314
+ "high_risk_events": int(sev.get("high", 0)),
315
+ "risky_chats": int(summary.get("risky_messages", 0)),
316
+ "risky_files": sum(
317
+ 1 for fe in _file_events()
318
+ if (fe.get("sensitivity") or "none") != "none" or fe.get("sensitive_labels")
319
+ ),
320
+ "secret_blocks": sum(
321
+ 1 for e in events
322
+ if (e.get("event_type") in {"secret_block", "external_send_block"})
323
+ or "secret" in (e.get("sensitive_labels") or [])
324
+ ),
325
+ "external_blocks": sum(
326
+ 1 for e in events
327
+ if e.get("event_type") == "external_send_block"
328
+ ),
329
+ "admin_raw_views": sum(
330
+ 1 for e in events
331
+ if e.get("event_type") == "admin_view_sensitive_raw"
332
+ ),
333
+ "review_required": int(sev.get("high", 0)) + int(sev.get("medium", 0)),
334
+ },
335
+ "field_counts": summary.get("field_counts", {}),
336
+ "severity_counts": sev,
337
+ "risk_rate": summary.get("risk_rate", 0),
338
+ }
339
+
340
+ # ── 2. User Risk Matrix ───────────────────────────────────────────────
341
+
342
+ @router.get("/admin/security/users")
343
+ async def security_users(request: Request):
344
+ _, users = require_admin(request)
345
+ history = get_history() or []
346
+ per_user = _summarize_user_risk(history, _file_events(), classify_sensitive_message)
347
+ # 사용자 메타데이터 join
348
+ for row in per_user:
349
+ email = row.get("email")
350
+ meta = users.get(email or "") or {}
351
+ row["role"] = meta.get("role") or "user"
352
+ row["disabled"] = bool(meta.get("disabled"))
353
+ row["user"] = _user_label(users, email)
354
+ return {"users": per_user, "total": len(per_user)}
355
+
356
+ # ── 3. Events listing (with filters) ──────────────────────────────────
357
+
358
+ @router.get("/admin/security/events")
359
+ async def security_events(
360
+ request: Request,
361
+ user: Optional[str] = Query(None),
362
+ type: Optional[str] = Query(None),
363
+ severity: Optional[str] = Query(None),
364
+ date_from: Optional[str] = Query(None, alias="from"),
365
+ date_to: Optional[str] = Query(None, alias="to"),
366
+ limit: int = Query(200, ge=1, le=2000),
367
+ ):
368
+ require_admin(request)
369
+ events = _events()
370
+ out: List[Dict[str, Any]] = []
371
+ for idx, e in enumerate(events):
372
+ ts = str(e.get("timestamp") or "")
373
+ if user and (e.get("user_email") != user and e.get("user_nickname") != user):
374
+ continue
375
+ if type and e.get("event_type") != type:
376
+ continue
377
+ if severity and (e.get("sensitivity") or "none") != severity:
378
+ continue
379
+ if date_from and ts < date_from:
380
+ continue
381
+ if date_to and ts > date_to:
382
+ continue
383
+ mc = dict(e)
384
+ mc["event_id"] = str(e.get("event_id") or idx)
385
+ if "content_preview" in mc:
386
+ mc["content_preview"] = redact_hard_secrets(str(mc.get("content_preview") or ""))
387
+ out.append(mc)
388
+ out.sort(key=lambda x: str(x.get("timestamp") or ""), reverse=True)
389
+ return {"events": out[:limit], "total": len(out)}
390
+
391
+ @router.get("/admin/security/events/{event_id}")
392
+ async def security_event_detail(event_id: str, request: Request):
393
+ admin_email, _ = require_admin(request)
394
+ events = _events()
395
+ idx_to_find: Optional[int] = None
396
+ try:
397
+ idx_to_find = int(event_id)
398
+ except Exception:
399
+ idx_to_find = None
400
+
401
+ target: Optional[Dict[str, Any]] = None
402
+ if idx_to_find is not None and 0 <= idx_to_find < len(events):
403
+ target = events[idx_to_find]
404
+ else:
405
+ for e in events:
406
+ if str(e.get("event_id") or "") == event_id:
407
+ target = e
408
+ break
409
+ if not target:
410
+ raise HTTPException(status_code=404, detail="이벤트를 찾을 수 없습니다.")
411
+ _log_view(admin_email, "event", str(event_id))
412
+ masked = dict(target)
413
+ if "content_preview" in masked:
414
+ masked["content_preview"] = redact_hard_secrets(str(masked.get("content_preview") or ""))
415
+ return {"event": masked, "raw_available": True}
416
+
417
+ # ── 4. Conversation drill-down ────────────────────────────────────────
418
+
419
+ @router.get("/admin/security/conversations/{conversation_id}")
420
+ async def security_conversation_summary(conversation_id: str, request: Request):
421
+ require_admin(request)
422
+ history = [h for h in (get_history() or []) if h.get("conversation_id") == conversation_id]
423
+ items = [classify_sensitive_message(h, i) for i, h in enumerate(history)]
424
+ return {
425
+ "conversation_id": conversation_id,
426
+ "messages_total": len(items),
427
+ "risky_messages": sum(1 for it in items if it.get("sensitivity") != "none"),
428
+ "items": items,
429
+ }
430
+
431
+ @router.get("/admin/security/conversations/{conversation_id}/raw")
432
+ async def security_conversation_raw(conversation_id: str, request: Request):
433
+ admin_email, _ = require_admin(request)
434
+ history = [h for h in (get_history() or []) if h.get("conversation_id") == conversation_id]
435
+ if not history:
436
+ raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
437
+ _log_view(admin_email, "conversation", conversation_id)
438
+ # 원문 조회 — 단, hard secret은 항상 redact
439
+ masked = []
440
+ for h in history:
441
+ cleaned = dict(h)
442
+ if "content" in cleaned:
443
+ cleaned["content"] = redact_hard_secrets(str(cleaned.get("content") or ""))
444
+ masked.append(cleaned)
445
+ return {"conversation_id": conversation_id, "messages": masked}
446
+
447
+ # ── 5. File monitor ───────────────────────────────────────────────────
448
+
449
+ @router.get("/admin/security/files")
450
+ async def security_files(request: Request):
451
+ require_admin(request)
452
+ files = _file_events()
453
+ for f in files:
454
+ if "content_preview" in f:
455
+ f["content_preview"] = redact_hard_secrets(str(f.get("content_preview") or ""))
456
+ return {"files": files, "total": len(files)}
457
+
458
+ @router.get("/admin/security/files/{file_id}")
459
+ async def security_file_detail(file_id: str, request: Request):
460
+ admin_email, _ = require_admin(request)
461
+ files = _file_events()
462
+ target = next(
463
+ (f for f in files if str(f.get("file_id") or f.get("filename") or "") == file_id),
464
+ None,
465
+ )
466
+ if not target:
467
+ raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
468
+ _log_view(admin_email, "file", file_id)
469
+ cleaned = dict(target)
470
+ if "content_preview" in cleaned:
471
+ cleaned["content_preview"] = redact_hard_secrets(str(cleaned.get("content_preview") or ""))
472
+ return {"file": cleaned}
473
+
474
+ @router.get("/admin/security/files/{file_id}/content")
475
+ async def security_file_content(file_id: str, request: Request):
476
+ admin_email, _ = require_admin(request)
477
+ files = _file_events()
478
+ target = next(
479
+ (f for f in files if str(f.get("file_id") or f.get("filename") or "") == file_id),
480
+ None,
481
+ )
482
+ if not target:
483
+ raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
484
+ _log_view(admin_email, "file_content", file_id, reason="raw_content_review")
485
+ # raw text가 있더라도 hard secret은 redact
486
+ text = target.get("extracted_text") or target.get("content_preview") or ""
487
+ return {"file_id": file_id, "text": redact_hard_secrets(str(text))}
488
+
489
+ # ── 6. Raw data explorer ──────────────────────────────────────────────
490
+
491
+ @router.get("/admin/security/raw")
492
+ async def security_raw(request: Request, scope: str = Query("audit")):
493
+ admin_email, _ = require_admin(request)
494
+ _log_view(admin_email, "raw", scope, reason="raw_explorer")
495
+ if scope == "audit":
496
+ payload = _events()
497
+ elif scope == "history":
498
+ payload = get_history() or []
499
+ elif scope == "files":
500
+ payload = _file_events()
501
+ else:
502
+ raise HTTPException(status_code=400, detail="지원하지 않는 scope입니다.")
503
+ # JSON 전체에서 hard secret redact
504
+ text = json.dumps(payload, ensure_ascii=False)
505
+ text = redact_hard_secrets(text)
506
+ return Response(content=text, media_type="application/json; charset=utf-8")
507
+
508
+ # ── 7. Export ─────────────────────────────────────────────────────────
509
+
510
+ @router.post("/admin/security/export")
511
+ async def security_export(req: ExportRequest, request: Request):
512
+ admin_email, users = require_admin(request)
513
+ scope = (req.scope or "events").lower()
514
+ fmt = (req.format or "json").lower()
515
+ if scope == "events":
516
+ rows = _events()
517
+ elif scope == "users":
518
+ rows = _summarize_user_risk(get_history() or [], _file_events(), classify_sensitive_message)
519
+ elif scope == "files":
520
+ rows = _file_events()
521
+ elif scope == "overview":
522
+ report = build_sensitivity_report(get_history() or []) or {}
523
+ rows = [report.get("summary", {})]
524
+ else:
525
+ raise HTTPException(status_code=400, detail="지원하지 않는 scope입니다.")
526
+
527
+ if not isinstance(rows, list):
528
+ rows = []
529
+
530
+ _log_view(admin_email, "export", f"{scope}:{fmt}", reason="export")
531
+
532
+ if fmt == "json":
533
+ body = json.dumps(rows, ensure_ascii=False, indent=2)
534
+ body = redact_hard_secrets(body)
535
+ filename = f"security_{scope}.json"
536
+ return Response(
537
+ content=body,
538
+ media_type="application/json; charset=utf-8",
539
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
540
+ )
541
+ if fmt == "csv":
542
+ body = _csv_dump(rows)
543
+ filename = f"security_{scope}.csv"
544
+ return Response(
545
+ content=body,
546
+ media_type="text/csv; charset=utf-8",
547
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
548
+ )
549
+ if fmt in {"excel", "xlsx"}:
550
+ body = _excel_dump(rows)
551
+ filename = f"security_{scope}.xlsx"
552
+ return Response(
553
+ content=body,
554
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
555
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
556
+ )
557
+ if fmt == "pdf":
558
+ overview = build_sensitivity_report(get_history() or []).get("summary", {}) or {}
559
+ body = _pdf_report("Lattice AI Security Report", rows, overview)
560
+ filename = f"security_{scope}.pdf"
561
+ return Response(
562
+ content=body,
563
+ media_type="application/pdf",
564
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
565
+ )
566
+ if fmt == "txt":
567
+ body = "\n".join(
568
+ redact_hard_secrets(json.dumps(r, ensure_ascii=False)) for r in rows
569
+ )
570
+ return Response(
571
+ content=body,
572
+ media_type="text/plain; charset=utf-8",
573
+ headers={"Content-Disposition": f"attachment; filename=security_{scope}.txt"},
574
+ )
575
+ raise HTTPException(status_code=400, detail="지원하지 않는 포맷입니다.")
576
+
577
+ return router
578
+
579
+
580
+ __all__ = ["create_security_router", "redact_hard_secrets", "soft_mask"]
@@ -1 +1 @@
1
- """Core utilities: security, sessions, audit."""
1
+ """Core utilities: security, sessions, audit, context_builder, document_generator."""