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:
2026-07-30 11:20:02 +09:00
parent 99943d9871
commit 70824c2afb
185 changed files with 2062 additions and 34521 deletions
@@ -1,6 +1,5 @@
using System.Reflection;
using QuantEngine.Infrastructure.External;
using QuantEngine.Infrastructure.Data;
using QuantEngine.Infrastructure.Services;
namespace QuantEngine.Core.Tests;
@@ -12,9 +11,7 @@ public class SecurityTests
[InlineData("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100")]
public void AssertReadOnly_AllowsReadOnlyQuotationPaths(string path, string trId)
{
var client = CreateClient();
var ex = Record.Exception(() => InvokeAssertReadOnly(client, path, trId));
var ex = Record.Exception(() => InvokeAssertReadOnly(path, trId));
Assert.Null(ex);
}
@@ -25,45 +22,34 @@ public class SecurityTests
[InlineData("/uapi/domestic-stock/v1/trading/order-cash", "FHKST01010100")]
public void AssertReadOnly_BlocksTradingPathsOrIds(string path, string trId)
{
var client = CreateClient();
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, path, trId));
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(path, trId));
Assert.IsType<InvalidOperationException>(ex.InnerException);
Assert.Contains("BLOCKED", ex.InnerException!.Message);
}
[Fact]
public void AssertReadOnly_BlocksKnownTradingTrIdPrefixes()
[Theory]
[InlineData("VTTC8434R00")]
[InlineData("TTTC9912U")]
[InlineData("VTTC5555X")]
public void AssertReadOnly_BlocksEntireTradingTrIdFamily_NotJustHardcodedCodes(string trId)
{
var client = CreateClient();
var ex = Assert.Throws<TargetInvocationException>(() => InvokeAssertReadOnly(client, "/uapi/domestic-stock/v1/quotations/inquire-price", "VTTC8434R00"));
// These TR_IDs are not among the previously hardcoded exact-match list — they only get
// blocked once the guard checks the true TTTC*/VTTC* prefix family instead of a fixed
// set of known order codes.
var ex = Assert.Throws<TargetInvocationException>(() =>
InvokeAssertReadOnly("/uapi/domestic-stock/v1/quotations/inquire-price", trId));
Assert.IsType<InvalidOperationException>(ex.InnerException);
Assert.Contains("TR_ID", ex.InnerException!.Message);
}
private static KisApiClient CreateClient()
private static void InvokeAssertReadOnly(string path, string trId)
{
Environment.SetEnvironmentVariable("KIS_APP_Key_TEST", "mock-key");
Environment.SetEnvironmentVariable("KIS_APP_Secret_TEST", "mock-secret");
return new KisApiClient(new HttpClient(new DummyHandler()), new NoopConnectionFactory());
}
private static void InvokeAssertReadOnly(KisApiClient client, string path, string trId)
{
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
// QuantEngine.Infrastructure.Services.KisApiClient is the class actually DI-registered
// in Program.cs and running in production — a second, unused KisApiClient used to live
// under Infrastructure.External with its own independent AssertReadOnly implementation
// that this test suite exercised instead. That class has been removed.
var method = typeof(KisApiClient).GetMethod("AssertReadOnly", BindingFlags.Static | BindingFlags.NonPublic)
?? throw new InvalidOperationException("AssertReadOnly method not found.");
method.Invoke(client, new object[] { path, trId });
}
private sealed class DummyHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
private sealed class NoopConnectionFactory : IDbConnectionFactory
{
public System.Data.IDbConnection CreateConnection() => throw new NotSupportedException("Not needed for read-only guard tests.");
method.Invoke(null, new object[] { path, trId });
}
}
@@ -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;
@@ -75,18 +75,32 @@ namespace QuantEngine.Web.Pages.Admin.Database
using var conn = _connectionFactory.CreateConnection();
if (conn.State != ConnectionState.Open) conn.Open();
// Column/PK names come from the request, so they must be checked against the
// table's real columns before going anywhere near a SQL string — otherwise an
// attacker-controlled form field name reaches the query unescaped.
var columnWhitelist = await LoadColumnWhitelistAsync(conn, tableName);
if (string.IsNullOrEmpty(pkColumn) || !columnWhitelist.Contains(pkColumn))
{
ErrorMessage = "허용되지 않은 기본 키 컬럼입니다.";
return Page();
}
// Load target columns to update
var columns = new List<string>();
var parameters = new List<NpgsqlParameter>();
foreach (var key in Request.Form.Keys)
{
if (key == "tableName" || key == "pkColumn" || key == "pkValue" || key == "__RequestVerificationToken")
continue;
if (!columnWhitelist.Contains(key))
continue; // 테이블에 실재하지 않는 컬럼명은 무시 (SQL 인젝션 방지)
var val = Request.Form[key].ToString();
columns.Add($"\"{key}\" = @{key}");
var param = new NpgsqlParameter($"@{key}", NpgsqlTypes.NpgsqlDbType.Text);
param.Value = (object?)val ?? DBNull.Value;
parameters.Add(param);
@@ -133,6 +147,8 @@ namespace QuantEngine.Web.Pages.Admin.Database
using var conn = _connectionFactory.CreateConnection();
if (conn.State != ConnectionState.Open) conn.Open();
var columnWhitelist = await LoadColumnWhitelistAsync(conn, tableName);
var colNames = new List<string>();
var paramNames = new List<string>();
var parameters = new List<NpgsqlParameter>();
@@ -142,6 +158,9 @@ namespace QuantEngine.Web.Pages.Admin.Database
if (key == "tableName" || key == "__RequestVerificationToken")
continue;
if (!columnWhitelist.Contains(key))
continue; // 테이블에 실재하지 않는 컬럼명은 무시 (SQL 인젝션 방지)
var val = Request.Form[key].ToString();
colNames.Add($"\"{key}\"");
paramNames.Add($"@{key}");
@@ -171,6 +190,35 @@ namespace QuantEngine.Web.Pages.Admin.Database
return RedirectToPage(new { tableName });
}
/// <summary>
/// DB-verified column names for a table, used to validate any identifier (PK column,
/// form field names) before it is interpolated into a SQL string. Table names arriving
/// here must already be whitelist-checked against TableList by the caller.
/// </summary>
private async Task<HashSet<string>> LoadColumnWhitelistAsync(IDbConnection conn, string tableName)
{
var parts = tableName.Split('.');
var schema = parts[0];
var tableOnly = parts[1];
var sql = @"
SELECT column_name
FROM information_schema.columns
WHERE table_schema = @schema AND table_name = @table_only;";
using var cmd = new NpgsqlCommand(sql, (NpgsqlConnection)conn);
cmd.Parameters.AddWithValue("@schema", schema);
cmd.Parameters.AddWithValue("@table_only", tableOnly);
var columns = new HashSet<string>(StringComparer.Ordinal);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
columns.Add(reader.GetString(0));
}
return columns;
}
private async Task LoadTableListAsync()
{
TableList.Clear();