ltcai 0.3.0 → 0.3.2

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