6 Commits

Author SHA1 Message Date
dc9a49586e docs(env): align PACK_HOST_DIR with CLAUDE.md (F5) 2026-05-17 14:12:04 +09:00
5da7a0040b fix(stock,docs): portfolio total_buy 수량 곱산 + insta-trends spec 변경 이력 (F4 + F6)
[F4] /api/portfolio 응답의 summary.total_buy가 종목별 단가 × 수량의 합이
되도록 fix. 기존 인라인 코드가 purchase_price를 수량 미곱산으로 단순
누적해 명세(qty 100 · avg 72000 → 7,200,000)와 어긋났음. API_SPEC.md에
purchase_price 필드 의미 + total_buy 계산식 명시. test 3건 (단가 곱산,
avg_price 폴백, 다종목 합산).

[F6] insta-trends spec/plan 상단에 "google_trends → youtube_trending"
변경 이력 추가. Google Trends endpoint 폐기로 source 교체된 이력이
본문 검색 시 혼란 주는 문제 차단. 사유 cross-ref:
feedback_external_data_sources.md
2026-05-17 14:06:19 +09:00
faffca0967 Merge pull request 'feat/security-hardening' (#5) from feat/security-hardening into main
Reviewed-on: #5
2026-05-17 14:00:03 +09:00
49c5c57be5 docs(env): add ALLOW_UNAUTHENTICATED_ADMIN guidance for F2 2026-05-17 13:58:24 +09:00
6053e69afc fix(stock): admin API auth hardening — ADMIN_API_KEY 빈 값 시 503 거부 (CODE_REVIEW F2)
운영 .env에 ADMIN_API_KEY가 누락되면 verify_admin이 무조건 통과해서
/api/trade/balance, /api/trade/order 인증이 무력화되던 문제 차단.

- ADMIN_API_KEY 설정 + 올바른 키 → 통과 (기존 동작)
- ADMIN_API_KEY 설정 + 잘못된 키 → 401 (기존 동작)
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (dev mode)
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (신규, 운영 보호)

.env.example에 신규 ALLOW_UNAUTHENTICATED_ADMIN=false 안내 추가.
stock/pytest.ini 신규 (pythonpath=. 설정으로 tests 모듈 import 가능).
test_admin_auth.py 4 케이스 (RED → GREEN 검증, regression 포함).
2026-05-17 13:53:50 +09:00
1e5e1bcdff fix(packs-lab): sign-link path traversal — startswith → relative_to (CODE_REVIEW F1)
str(abs_path).startswith(str(PACK_HOST_DIR))는 trailing slash가 없어
sibling 경로(/foo/packs ↔ /foo/packs_evil)를 통과시켜 DSM API에 잘못된
호스트 경로를 전달할 수 있었음. Path.relative_to 기반으로 컴포넌트 단위
엄격 검증으로 교체. test_sign_link_rejects_sibling_path 회귀 테스트
추가 (RED → GREEN 검증).
2026-05-17 13:50:22 +09:00
10 changed files with 195 additions and 8 deletions

View File

@@ -51,9 +51,14 @@ PGID=1000
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP) # Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000 WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000
# Admin API Key (trade/order 등 민감 엔드포인트 보호, 미설정 시 인증 비활성화) # Admin API Key — /api/trade/* 등 민감 엔드포인트 보호.
# 운영 .env에는 반드시 값을 채워야 함. 빈 값이면 503 응답으로 거부됨 (CODE_REVIEW F2).
ADMIN_API_KEY= ADMIN_API_KEY=
# 개발 모드: 위 ADMIN_API_KEY 비워둔 채로 trade/admin 엔드포인트 호출 허용.
# 운영 환경에서는 절대 true로 두지 말 것. 기본 false (보호 활성).
ALLOW_UNAUTHENTICATED_ADMIN=false
# Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider) # Anthropic API Key (AI Coach 프록시 + 뉴스 요약 Claude provider)
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5-20251001 ANTHROPIC_MODEL=claude-haiku-4-5-20251001
@@ -120,4 +125,6 @@ PACK_BASE_DIR=/app/data/packs
# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴). # DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴).
# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용). # 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용).
# DSM API는 일반 사용자 권한에서 /volume1/... 절대경로를 거부(408).
# shared folder 시점(/docker/...)이 운영 표준 (CLAUDE.md와 일치).
PACK_HOST_DIR=/volume1/docker/webpage/media/packs PACK_HOST_DIR=/volume1/docker/webpage/media/packs

View File

@@ -2,6 +2,10 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
## ⚠️ 변경 이력
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 task와 코드 블록은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint(RSS + dailytrends JSON 양쪽) 모두 404 폐기 확인. YouTube Data API v3 mostPopular로 source 대체 + pytrends 의존성 제거. 운영 코드는 현재 `youtube_trending` 사용 중. 이 plan을 다시 실행할 일이 있으면 본문의 `google_trends` 단어를 `youtube_trending`으로 읽어달라. 자세한 사유와 교체 체크리스트는 `feedback_external_data_sources.md`.
**Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline. **Goal:** Add a "Trends" tab to the Insta page that pulls external trends from NAVER popular + Google Trends, lets the user set category weights, and feeds those weights back into the daily keyword extraction pipeline.
**Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact). **Architecture:** New `trend_collector` module in insta-lab (NAVER `news.json` 인기순 + `pytrends` Google Trends + Claude Haiku 카테고리 분류 + in-memory 캐시). Existing `trending_keywords` table gets a `source` column; new `account_preferences` table stores category weights. `keyword_extractor` gains a `extract_with_weights()` variant. InstaAgent runs a new 09:00 cron for trend collection and applies weights at 09:30 extraction. web-ui splits InstaCards into Cards/Trends tabs and adds 3 panels (AccountFocus / ExternalTrends / PreferenceImpact).

View File

@@ -4,6 +4,10 @@
상태: 사용자 승인 대기 → writing-plans 진입 예정 상태: 사용자 승인 대기 → writing-plans 진입 예정
연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계) 연관 문서: `2026-05-15-insta-agent-design.md` (insta-lab 기본 설계)
## ⚠️ 변경 이력
- **2026-05-17**: 본문에 `google_trends` source로 기재된 모든 항목은 **실제 구현에서 `youtube_trending`으로 교체됨**. Google Trends 비공식 endpoint 두 가지(`trendingsearches/daily/rss?geo=KR`, `/trends/api/dailytrends?...`)가 모두 404로 폐기되어 운영 호출이 빈 결과로 끝나는 문제 확인 → YouTube Data API v3 `videos.list?chart=mostPopular&regionCode=KR`로 source 대체. 이후 spec 본문을 읽을 때는 `google_trends``youtube_trending`, "Google Trends" → "YouTube 인기"로 치환 해석. 사유와 source 교체 시 동시 갱신 체크리스트: `feedback_external_data_sources.md`.
--- ---
## 1. 목적·배경 ## 1. 목적·배경

View File

@@ -133,8 +133,12 @@ async def sign_link(
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인. # 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨. # file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
# str.startswith는 '/foo/packs' 와 '/foo/packs_evil' 같은 sibling 경로를 통과시키므로
# Path.relative_to로 엄격하게 컴포넌트 단위 검증한다 (CODE_REVIEW F1).
abs_path = Path(payload.file_path).resolve() abs_path = Path(payload.file_path).resolve()
if not str(abs_path).startswith(str(PACK_HOST_DIR)): try:
abs_path.relative_to(PACK_HOST_DIR.resolve())
except ValueError:
raise HTTPException(status_code=400, detail="허용된 경로 외부") raise HTTPException(status_code=400, detail="허용된 경로 외부")
try: try:

View File

@@ -60,6 +60,29 @@ def test_sign_link_path_outside_base():
assert r.status_code == 400 assert r.status_code == 400
def test_sign_link_rejects_sibling_path():
"""PACK_HOST_DIR='/foo/packs' 일 때 '/foo/packs_evil/x.mp4' 같이 prefix만
통과하는 sibling 경로는 거부해야 한다 (CODE_REVIEW F1, path traversal 변형).
기존 str.startswith 방식은 trailing slash가 없어 sibling 경로를 통과시킴.
relative_to 기반 검증으로 교체되어야 통과한다.
"""
import json as _json
from pathlib import Path
base_resolved = Path("/foo/packs").resolve()
# base의 자식이 아닌 sibling 경로 (예: /foo/packs_evil/...)
sibling_posix = (base_resolved.parent / f"{base_resolved.name}_evil" / "x.mp4").as_posix()
with patch("app.routes.PACK_HOST_DIR", base_resolved):
body = _json.dumps(
{"file_path": sibling_posix, "expires_in_seconds": 14400}
).encode()
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
assert r.status_code == 400, (
f"sibling 경로 '{sibling_posix}'가 허용됨 (status={r.status_code}) "
f"— path traversal 가능성"
)
def test_upload_invalid_token(): def test_upload_invalid_token():
r = client.post( r = client.post(
"/api/packs/upload", "/api/packs/upload",

View File

@@ -142,6 +142,7 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
"name": "삼성전자", "name": "삼성전자",
"quantity": 100, "quantity": 100,
"avg_price": 72000, "avg_price": 72000,
"purchase_price": 72000,
"current_price": 74500, "current_price": 74500,
"price_session": "NXT_AFTER", "price_session": "NXT_AFTER",
"price_as_of": "2026-05-11T19:21:40+09:00", "price_as_of": "2026-05-11T19:21:40+09:00",
@@ -159,6 +160,10 @@ KB증권·삼성증권 등 Open API 미제공 증권사용.
} }
``` ```
> **`purchase_price` 필드**: 종목별 매입 단가(1주당). 사용자가 수동 등록한 매입가가
> 평균단가(`avg_price`)와 다를 때 표시용으로 분리한다. 미설정 시 `avg_price`로 폴백.
> `summary.total_buy = SUM(purchase_price × quantity)` (CODE_REVIEW F4에서 명세 정합화).
> **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다. > **주의**: 현재가 조회에 실패한 종목은 `current_price`, `eval_amount`, `profit_amount`, `profit_rate` 가 `null`로 반환됩니다.
> 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요. > 프론트에서 `null` 체크 후 `"조회 실패"` 등으로 표시해 주세요.

View File

@@ -47,13 +47,30 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
# Windows AI Server URL (NAS .env에서 설정) # Windows AI Server URL (NAS .env에서 설정)
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000") WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
# Admin API Key 인증 # Admin API Key 인증 — /api/trade/* 보호 (CODE_REVIEW F2)
# 빈 키 + 명시적 dev flag 없으면 503으로 거부. 운영 .env에 ADMIN_API_KEY 누락 시
# 무인증 통과되던 버그 차단.
ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "") ADMIN_API_KEY = os.getenv("ADMIN_API_KEY", "")
def verify_admin(x_admin_key: str = Header(None)): def verify_admin(x_admin_key: str = Header(None)):
"""admin/trade 엔드포인트 보호용 API 키 검증""" """admin/trade 엔드포인트 보호용 API 키 검증.
- ADMIN_API_KEY 설정됨 + 키 일치 → 통과
- ADMIN_API_KEY 설정됨 + 키 불일치 → 401 Unauthorized
- ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)
- ADMIN_API_KEY 미설정 + dev flag 없음 → 503 (보호 강화, 운영 .env 누락 차단)
"""
if not ADMIN_API_KEY: if not ADMIN_API_KEY:
return # 키 미설정 시 인증 비활성화 (개발 환경) if os.getenv("ALLOW_UNAUTHENTICATED_ADMIN", "false").lower() == "true":
return # 개발 환경 명시적 허용
raise HTTPException(
status_code=503,
detail=(
"admin endpoint protected — ADMIN_API_KEY not configured. "
"Set ADMIN_API_KEY in .env, or set ALLOW_UNAUTHENTICATED_ADMIN=true "
"for development only."
),
)
if x_admin_key != ADMIN_API_KEY: if x_admin_key != ADMIN_API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
@@ -337,11 +354,11 @@ def get_portfolio():
price_session = detail["session"] if detail else None price_session = detail["session"] if detail else None
price_as_of = detail["as_of"] if detail else None price_as_of = detail["as_of"] if detail else None
# avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준 # avg_price: 평균단가 — 손익(평가금액 - 매입원가) 계산 기준
# purchase_price: 매입가 — 총 매입 금액 표시 기준 (없으면 avg_price로 폴백) # purchase_price: 매입 단가(1주당) — 없으면 avg_price로 폴백 (CODE_REVIEW F4)
purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"] purchase_price = item.get("purchase_price") if item.get("purchase_price") is not None else item["avg_price"]
cost_basis = item["avg_price"] * item["quantity"] cost_basis = item["avg_price"] * item["quantity"]
# 총 매입 금액 표시는 종목별 매입가의 단순 합계 (수량 미곱산) # 총 매입 금액 = 단가 × 보유 수량. API_SPEC.md 예시(qty 100·avg 72000 → 7,200,000)와 일치
buy_amount = purchase_price buy_amount = purchase_price * item["quantity"]
eval_amount = current_price * item["quantity"] if current_price is not None else None eval_amount = current_price * item["quantity"] if current_price is not None else None
profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None profit_amount = (eval_amount - cost_basis) if eval_amount is not None else None
profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None profit_rate = round((profit_amount / cost_basis) * 100, 2) if (profit_amount is not None and cost_basis) else None

3
stock/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
asyncio_mode = auto

View File

@@ -0,0 +1,43 @@
"""verify_admin 보안 강화 회귀 테스트 (CODE_REVIEW F2).
운영 .env에서 ADMIN_API_KEY가 누락되면 /api/trade/balance, /api/trade/order
인증이 무력화되는 버그를 막기 위한 가드.
"""
import os
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from app import main as stock_main
def test_verify_admin_rejects_when_key_missing_and_no_dev_flag(monkeypatch):
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN 미설정 → 503."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
monkeypatch.delenv("ALLOW_UNAUTHENTICATED_ADMIN", raising=False)
with pytest.raises(HTTPException) as exc_info:
stock_main.verify_admin(x_admin_key=None)
assert exc_info.value.status_code == 503
assert "ADMIN_API_KEY" in exc_info.value.detail
def test_verify_admin_allows_when_key_missing_with_dev_flag(monkeypatch):
"""ADMIN_API_KEY 미설정 + ALLOW_UNAUTHENTICATED_ADMIN=true → 통과 (개발 모드)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "")
monkeypatch.setenv("ALLOW_UNAUTHENTICATED_ADMIN", "true")
stock_main.verify_admin(x_admin_key=None) # 예외 없으면 통과
def test_verify_admin_rejects_wrong_key(monkeypatch):
"""ADMIN_API_KEY 설정 + 잘못된 키 → 401 (regression)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
with pytest.raises(HTTPException) as exc_info:
stock_main.verify_admin(x_admin_key="wrong")
assert exc_info.value.status_code == 401
def test_verify_admin_allows_correct_key(monkeypatch):
"""ADMIN_API_KEY 설정 + 올바른 키 → 통과 (regression)."""
monkeypatch.setattr(stock_main, "ADMIN_API_KEY", "secret123")
stock_main.verify_admin(x_admin_key="secret123") # 예외 없으면 통과

View File

@@ -0,0 +1,77 @@
"""포트폴리오 /api/portfolio 응답의 total_buy 계산 회귀 테스트 (CODE_REVIEW F4).
purchase_price는 종목별 단가(1주당) 의미. total_buy = SUM(purchase_price × quantity).
purchase_price가 없으면 avg_price로 폴백 후 동일하게 수량 곱산.
"""
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.main import app
def _fake_db_setup(monkeypatch, items, cash=None):
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_all_portfolio", lambda: items)
monkeypatch.setattr(stock_main, "get_all_broker_cash", lambda: cash or [])
def test_portfolio_total_buy_uses_purchase_price_times_quantity(monkeypatch):
"""purchase_price 설정 시: total_buy = purchase_price × quantity 의 합."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 72000, "purchase_price": 70000},
]
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
assert resp.status_code == 200
data = resp.json()
# purchase_price=70000 × quantity=100 = 7,000,000
assert data["summary"]["total_buy"] == 7_000_000
def test_portfolio_total_buy_falls_back_to_avg_price_with_quantity(monkeypatch):
"""purchase_price 미설정 시: avg_price 폴백 + 수량 곱산. API_SPEC 예시와 일치."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 72000, "purchase_price": None},
]
fake_prices = {"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"}}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
assert resp.status_code == 200
data = resp.json()
# avg_price=72000 × quantity=100 = 7,200,000 (API_SPEC.md 예시와 일치)
assert data["summary"]["total_buy"] == 7_200_000
def test_portfolio_total_buy_sums_multiple_holdings(monkeypatch):
"""여러 종목 합산도 단가 × 수량 합."""
items = [
{"id": 1, "broker": "KB", "ticker": "005930", "name": "삼성전자",
"quantity": 100, "avg_price": 70000, "purchase_price": 70000},
{"id": 2, "broker": "NH", "ticker": "000660", "name": "SK하이닉스",
"quantity": 50, "avg_price": 130000, "purchase_price": 130000},
]
fake_prices = {
"005930": {"price": 74500, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
"000660": {"price": 140000, "session": "REGULAR", "as_of": "2026-05-17T10:00:00+09:00"},
}
_fake_db_setup(monkeypatch, items)
from app import main as stock_main
monkeypatch.setattr(stock_main, "get_current_prices_detail", lambda t: fake_prices)
client = TestClient(app)
resp = client.get("/api/portfolio")
data = resp.json()
# 70000*100 + 130000*50 = 7,000,000 + 6,500,000 = 13,500,000
assert data["summary"]["total_buy"] == 13_500_000