fix: security, data-integrity, and doc-drift findings from repo audit
Consolidates duplicate KIS API client implementations (governance tests were exercising an unused class instead of the one actually running in production), closes a SQL injection path in the DB admin page, fixes a migration that used MySQL-only syntax and had never actually applied (confirmed against production), resyncs docs/db/quantengine.dbml with all migrations, and removes a duplicate OMS·WMS·ERP frontend tree in favor of src/frontend/. Also corrects several unverifiable/inflated claims in the OMS planning docs and realigns CI/CD and architecture documentation with what's actually in the repo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,250 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using QuantEngine.Core.Interfaces;
|
||||
using QuantEngine.Infrastructure.Data;
|
||||
|
||||
namespace QuantEngine.Infrastructure.External
|
||||
{
|
||||
public class KisCredentials
|
||||
{
|
||||
public string AppKey { get; }
|
||||
public string AppSecret { get; }
|
||||
public string Account { get; } // "real" | "mock"
|
||||
public string Domain { get; }
|
||||
|
||||
public KisCredentials(string appKey, string appSecret, string account)
|
||||
{
|
||||
AppKey = appKey;
|
||||
AppSecret = appSecret;
|
||||
Account = account;
|
||||
Domain = account == "real"
|
||||
? "https://openapi.koreainvestment.com:9443"
|
||||
: "https://openapivts.koreainvestment.com:29443";
|
||||
}
|
||||
|
||||
public static KisCredentials Load(string account = "mock")
|
||||
{
|
||||
string keyVar = account == "real" ? "KIS_APP_Key" : "KIS_APP_Key_TEST";
|
||||
string secretVar = account == "real" ? "KIS_APP_Secret" : "KIS_APP_Secret_TEST";
|
||||
|
||||
string? appKey = Environment.GetEnvironmentVariable(keyVar);
|
||||
string? appSecret = Environment.GetEnvironmentVariable(secretVar);
|
||||
|
||||
if (string.IsNullOrEmpty(appKey) || string.IsNullOrEmpty(appSecret))
|
||||
{
|
||||
// Fallback registry checks are not cross-platform and environment variables should be defined.
|
||||
// In production/Linux it is env-only.
|
||||
throw new InvalidOperationException(
|
||||
$"KIS Credentials Environment Variables missing: {keyVar} or {secretVar}."
|
||||
);
|
||||
}
|
||||
|
||||
return new KisCredentials(appKey, appSecret, account);
|
||||
}
|
||||
}
|
||||
|
||||
public class KisApiClient : IKisApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IDbConnectionFactory _dbConnectionFactory;
|
||||
private readonly KisCredentials _creds;
|
||||
|
||||
private static readonly string[] ForbiddenPathSubstrings = { "/trading/" };
|
||||
private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC08", "VTTC08", "TTTC01", "VTTC01", "TTTC8434R", "VTTC8434R" };
|
||||
|
||||
public KisApiClient(HttpClient httpClient, IDbConnectionFactory dbConnectionFactory, string account = "mock")
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_dbConnectionFactory = dbConnectionFactory;
|
||||
_creds = KisCredentials.Load(account);
|
||||
}
|
||||
|
||||
private void AssertReadOnly(string path, string trId)
|
||||
{
|
||||
foreach (var forbidden in ForbiddenPathSubstrings)
|
||||
{
|
||||
if (path.Contains(forbidden, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path}");
|
||||
}
|
||||
}
|
||||
foreach (var prefix in ForbiddenTrIdPrefixes)
|
||||
{
|
||||
if (trId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={trId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> IssueOrReuseTokenAsync()
|
||||
{
|
||||
using var conn = _dbConnectionFactory.CreateConnection();
|
||||
conn.Open();
|
||||
|
||||
// 1. Try to load cached token
|
||||
var cached = await conn.QueryFirstOrDefaultAsync<(string access_token, string expires_at)>(
|
||||
"SELECT access_token, expires_at FROM quantengine.kis_tokens WHERE account = @Account",
|
||||
new { Account = _creds.Account }
|
||||
);
|
||||
|
||||
if (cached.access_token != null)
|
||||
{
|
||||
if (DateTime.TryParse(cached.expires_at, out var expiresAtUtc))
|
||||
{
|
||||
// Reuse token if it has more than 10 minutes left before expiration
|
||||
if (DateTime.UtcNow < expiresAtUtc.AddMinutes(-10))
|
||||
{
|
||||
return cached.access_token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Request new token from KIS API
|
||||
var requestUrl = $"{_creds.Domain}/oauth2/tokenP";
|
||||
var requestBody = new
|
||||
{
|
||||
grant_type = "client_credentials",
|
||||
appkey = _creds.AppKey,
|
||||
appsecret = _creds.AppSecret
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(requestUrl, requestBody);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var resData = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var accessToken = resData.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("Failed to parse access_token from response.");
|
||||
var expiresInSec = resData.GetProperty("expires_in").GetInt32();
|
||||
var expiresAt = DateTime.UtcNow.AddSeconds(expiresInSec);
|
||||
|
||||
// 3. Upsert token cache into PG database
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO quantengine.kis_tokens (account, access_token, expires_at, updated_at)
|
||||
VALUES (@Account, @AccessToken, @ExpiresAt, @UpdatedAt)
|
||||
ON CONFLICT (account) DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
new
|
||||
{
|
||||
Account = _creds.Account,
|
||||
AccessToken = accessToken,
|
||||
ExpiresAt = expiresAt.ToString("o"),
|
||||
UpdatedAt = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
private async Task<string> SendRequestAsync(string path, string trId, Dictionary<string, string> queryParams)
|
||||
{
|
||||
AssertReadOnly(path, trId);
|
||||
var token = await IssueOrReuseTokenAsync();
|
||||
|
||||
var queryBuilder = new List<string>();
|
||||
foreach (var kvp in queryParams)
|
||||
{
|
||||
queryBuilder.Add($"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}");
|
||||
}
|
||||
var fullUrl = $"{_creds.Domain}{path}?{string.Join("&", queryBuilder)}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, fullUrl);
|
||||
request.Headers.Accept.Clear();
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Add("authorization", $"Bearer {token}");
|
||||
request.Headers.Add("appkey", _creds.AppKey);
|
||||
request.Headers.Add("appsecret", _creds.AppSecret);
|
||||
request.Headers.Add("tr_id", trId);
|
||||
request.Headers.Add("custtype", "P");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetCurrentPriceAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||
"FHKST01010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetAskingPrice10LevelAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||
"FHKST01010200",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyShortSaleAsync(string code, string startDate, string endDate, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/daily-short-sale",
|
||||
"FHPST04830000",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetDailyItemChartPriceAsync(string code, string startDate, string endDate, string period = "D", string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice",
|
||||
"FHKST03010100",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code },
|
||||
{ "FID_INPUT_DATE_1", startDate },
|
||||
{ "FID_INPUT_DATE_2", endDate },
|
||||
{ "FID_PERIOD_DIV_CODE", period },
|
||||
{ "FID_ORG_ADJ_PRC", "0" }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> GetInvestorTrendAsync(string code, string account = "mock")
|
||||
{
|
||||
var json = await SendRequestAsync(
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-investor",
|
||||
"FHKST01010900",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "FID_COND_MRKT_DIV_CODE", "J" },
|
||||
{ "FID_INPUT_ISCD", code }
|
||||
}
|
||||
);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(json) ?? new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,15 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_runs_audit (
|
||||
-- Foreign key constraint (optional - don't enforce if kis_collection_runs might be deleted)
|
||||
-- CONSTRAINT fk_kis_collection_runs_audit FOREIGN KEY (run_id)
|
||||
-- REFERENCES quantengine.kis_collection_runs(id) ON DELETE CASCADE
|
||||
|
||||
INDEX idx_kis_collection_runs_audit_run_id (run_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_runs_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_runs_audit_timestamp (changed_at DESC)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_run_id
|
||||
ON quantengine.kis_collection_runs_audit (run_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_changed_by
|
||||
ON quantengine.kis_collection_runs_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_runs_audit_timestamp
|
||||
ON quantengine.kis_collection_runs_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- kis_collection_snapshots_audit: Audit trail for snapshots
|
||||
-- ============================================================================
|
||||
@@ -55,12 +58,15 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_snapshots_audit (
|
||||
-- Foreign key constraint (optional)
|
||||
-- CONSTRAINT fk_kis_collection_snapshots_audit FOREIGN KEY (snapshot_id)
|
||||
-- REFERENCES quantengine.kis_collection_snapshots(id) ON DELETE CASCADE
|
||||
|
||||
INDEX idx_kis_collection_snapshots_audit_snapshot_id (snapshot_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_snapshots_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_snapshots_audit_timestamp (changed_at DESC)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_snapshot_id
|
||||
ON quantengine.kis_collection_snapshots_audit (snapshot_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_changed_by
|
||||
ON quantengine.kis_collection_snapshots_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_snapshots_audit_timestamp
|
||||
ON quantengine.kis_collection_snapshots_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- kis_collection_errors_audit: Audit trail for error records
|
||||
-- ============================================================================
|
||||
@@ -79,13 +85,16 @@ CREATE TABLE IF NOT EXISTS quantengine.kis_collection_errors_audit (
|
||||
new_values JSONB,
|
||||
|
||||
-- Audit trail indexing
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_kis_collection_errors_audit_error_id (error_id, changed_at DESC),
|
||||
INDEX idx_kis_collection_errors_audit_changed_by (changed_by, changed_at DESC),
|
||||
INDEX idx_kis_collection_errors_audit_timestamp (changed_at DESC)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_error_id
|
||||
ON quantengine.kis_collection_errors_audit (error_id, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_changed_by
|
||||
ON quantengine.kis_collection_errors_audit (changed_by, changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kis_collection_errors_audit_timestamp
|
||||
ON quantengine.kis_collection_errors_audit (changed_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Trigger Functions: Auto-log changes to kis_collection_runs
|
||||
-- ============================================================================
|
||||
|
||||
@@ -22,11 +22,11 @@ public class KisApiClient : IKisApiClient
|
||||
private const int TokenRefreshSkewMinutes = 10;
|
||||
|
||||
private static readonly string[] ForbiddenPathSubstrings = { "/trading/" };
|
||||
private static readonly string[] ForbiddenTrIdPrefixes =
|
||||
{
|
||||
"TTTC08", "VTTC08", "TTTC01", "VTTC01",
|
||||
"TTTC8434R", "VTTC8434R"
|
||||
};
|
||||
|
||||
// 실제 매수/매도 주문 TR_ID는 전부 TTTC/VTTC로 시작한다 (governance/rules/06_no_direct_api_trading.yaml).
|
||||
// 개별 주문 코드를 나열하면 목록에 없는 신규 주문 TR_ID가 새어나갈 수 있으므로 접두사 전체를 차단한다.
|
||||
// 이 클라이언트가 실제로 호출하는 조회용 TR_ID는 전부 FH로 시작해 이 규칙과 절대 겹치지 않는다.
|
||||
private static readonly string[] ForbiddenTrIdPrefixes = { "TTTC", "VTTC" };
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ITokenCache _tokenCache;
|
||||
|
||||
Reference in New Issue
Block a user