From e933f825fb5f5fe883c143aed52c97602b27cea4 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Wed, 6 May 2026 15:23:42 +0200 Subject: [PATCH] refactor: replace config summary with sanitised version in health checks - Updated the HealthCheck implementation in both futures and perps applications to use the new sanitiseConfig function, ensuring sensitive information is redacted in the health output. - Removed the summariseConfig function as it is no longer needed. - Enhanced the sanitiseConfig function to redact private keys and mask RPC URLs for security. --- market-maker/src/apps/futures/main.ts | 19 +--- market-maker/src/apps/perps/main.ts | 19 +--- market-maker/src/core/config/base.ts | 52 ++++++++++ market-maker/src/core/healthcheck.ts | 134 +++++++++++++++----------- 4 files changed, 135 insertions(+), 89 deletions(-) diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index e3aa2ea..d204d37 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -14,6 +14,7 @@ import { HealthCheck } from "../../core/healthcheck.ts"; import { runMakerLoop } from "../../core/runner.ts"; import { serializeError } from "../../core/errSerializer.ts"; import { createFuturesVenue } from "../../adapters/futures/index.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; import { loadFuturesConfig } from "./config.ts"; async function main(): Promise { @@ -128,7 +129,7 @@ async function main(): Promise { const health = new HealthCheck({ port: config.health.port, appName: "futures-mm", - configSummary: summariseConfig(config), + configSummary: sanitiseConfig(config), oracle, inventory, collateral, @@ -155,20 +156,4 @@ async function main(): Promise { }); } -function summariseConfig(c: ReturnType): Record { - return { - nodeEnv: c.nodeEnv, - commitHash: c.commitHash, - logLevel: c.logLevel, - dryRun: c.dryRun, - network: c.network.name, - venue: { kind: c.venue.kind, address: c.venue.address }, - pricing: c.pricing, - sizing: c.sizing, - risk: c.risk, - gas: c.gas, - timing: c.timing, - }; -} - main(); diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 441f061..e78acd1 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -14,6 +14,7 @@ import { HealthCheck } from "../../core/healthcheck.ts"; import { runMakerLoop } from "../../core/runner.ts"; import { serializeError } from "../../core/errSerializer.ts"; import { createPerpsVenue } from "../../adapters/perps/index.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; import { loadPerpsConfig } from "./config.ts"; async function main(): Promise { @@ -121,7 +122,7 @@ async function main(): Promise { const health = new HealthCheck({ port: config.health.port, appName: "perps-mm", - configSummary: summariseConfig(config), + configSummary: sanitiseConfig(config), oracle, inventory, collateral, @@ -148,20 +149,4 @@ async function main(): Promise { }); } -function summariseConfig(c: ReturnType): Record { - return { - nodeEnv: c.nodeEnv, - commitHash: c.commitHash, - logLevel: c.logLevel, - dryRun: c.dryRun, - network: c.network.name, - venue: { kind: c.venue.kind, address: c.venue.address }, - pricing: c.pricing, - sizing: c.sizing, - risk: c.risk, - gas: c.gas, - timing: c.timing, - }; -} - main(); diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index e26087b..6628262 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -4,6 +4,8 @@ import yaml from "js-yaml"; import { type StringOptions, type TUnsafe, type TSchema, Type } from "@sinclair/typebox"; import Ajv from "ajv"; import addFormats from "ajv-formats"; +import type { Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; import { ConfigError } from "../errors.ts"; import { parseUsd, secondsToMs } from "./units.ts"; @@ -402,3 +404,53 @@ export function configBigint(value: string, field: string): bigint { throw new ConfigError(`Invalid bigint value for ${field}: "${value}"`); } } + +/** + * Returns a deep clone of the full parsed config with secrets redacted, safe + * to expose on the /health endpoint. Specifically: + * + * - Each `wallets[name].privateKey` is replaced with "[REDACTED]" and a + * derived `address` is added so operators can still verify which signer + * is configured. + * - `network.rpcUrl` is masked to origin only — paths/query strings on + * managed RPC providers (Alchemy, Infura, …) usually carry API keys. + * + * Bigints in the parsed config (e.g. risk caps, sizing.baseQuantity) are + * preserved as-is; the caller is expected to JSON.stringify with a replacer + * that handles bigints. + */ +export function sanitiseConfig< + T extends { + wallets: Record; + network: { rpcUrl: string }; + }, +>(config: T): Record { + const clone = structuredClone(config) as Record; + + const wallets = clone.wallets as Record; + for (const [name, wallet] of Object.entries(wallets)) { + let address: string; + try { + address = privateKeyToAccount(wallet.privateKey as Hex).address; + } catch { + address = "[invalid]"; + } + wallets[name] = { privateKey: "[REDACTED]", address }; + } + + const network = clone.network as { rpcUrl: string }; + network.rpcUrl = maskRpcUrl(network.rpcUrl); + + return clone; +} + +function maskRpcUrl(raw: string): string { + try { + const url = new URL(raw); + const hasPath = url.pathname && url.pathname !== "/"; + const hasQuery = url.search.length > 0; + return hasPath || hasQuery ? `${url.protocol}//${url.host}/[redacted]` : `${url.protocol}//${url.host}`; + } catch { + return "[invalid url]"; + } +} diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index 6b35fa1..c652f53 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -19,7 +19,12 @@ export interface ExecutorStats { export interface HealthCheckOptions { port: number; appName: string; - /** Sanitised, JSON-safe config slice for /health output. */ + /** + * Full parsed config with secrets redacted (private keys, RPC API keys), + * surfaced verbatim under `config` in /health output. Build via + * `sanitiseConfig` from `core/config/base.ts`. Bigints are serialised to + * strings by the /health JSON.stringify replacer. + */ configSummary: Record; oracle: OracleTracker; inventory: InventoryManager; @@ -97,63 +102,72 @@ export class HealthCheck { private handleHealth(res: ServerResponse): void { const { oracle, inventory, collateral, book, gas, risk } = this.opts; - const body = JSON.stringify({ - app: this.opts.appName, - status: this.status, - walletAddress: this.walletAddress, - lastError: this.lastError, - uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), - config: this.opts.configSummary, - market: { - oraclePrice: oracle.currentPrice.toString(), - volatility: fractionToNumber(oracle.volatility), - bestBid: book.bestBid.toString(), - bestAsk: book.bestAsk.toString(), - ownOrders: book.ownOrders.size, + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + config: this.opts.configSummary, + market: { + oraclePrice: oracle.currentPrice.toString(), + volatility: fractionToNumber(oracle.volatility), + bestBid: book.bestBid.toString(), + bestAsk: book.bestAsk.toString(), + ownOrders: book.ownOrders.size, + }, + inventory: { + netPosition: inventory.netQuantity.toString(), + inventorySkew: fractionToNumber(inventory.inventorySkew), + }, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + venueOrderMargin: collateral.venueOrderMargin.toString(), + venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), + walletTokenBalance: collateral.walletTokenBalance.toString(), + nativeBalance: collateral.nativeBalance.toString(), + utilizationPct: collateral.utilizationPct, + }, + gas: { + gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: gas.isGasSpiking, + gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), + }, + stats: { + tickCount: this.tickCount, + lastTickAt: this.lastTickAt, + ordersPlaced: this.executorStats?.ordersPlaced ?? 0, + ordersCancelled: this.executorStats?.ordersCancelled ?? 0, + reconcileCount: this.executorStats?.reconcileCount ?? 0, + }, }, - inventory: { - netPosition: inventory.netQuantity.toString(), - inventorySkew: fractionToNumber(inventory.inventorySkew), - }, - collateral: { - vaultBalance: collateral.vaultBalance.toString(), - portfolioIM: collateral.portfolioIM.toString(), - portfolioMM: collateral.portfolioMM.toString(), - venueOrderMargin: collateral.venueOrderMargin.toString(), - venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), - walletTokenBalance: collateral.walletTokenBalance.toString(), - nativeBalance: collateral.nativeBalance.toString(), - utilizationPct: collateral.utilizationPct, - }, - gas: { - gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), - gasSpiking: gas.isGasSpiking, - gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), - }, - risk: { - throttled: risk.throttled, - throttleReason: risk.throttleReason, - cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), - }, - stats: { - tickCount: this.tickCount, - lastTickAt: this.lastTickAt, - ordersPlaced: this.executorStats?.ordersPlaced ?? 0, - ordersCancelled: this.executorStats?.ordersCancelled ?? 0, - reconcileCount: this.executorStats?.reconcileCount ?? 0, - }, - }); + bigIntReplacer, + ); res.writeHead(200, { "Content-Type": "application/json" }); res.end(body); } private handleStop(res: ServerResponse): void { - if (this.paused) return this.respondOk(res); + if (this.paused) { + this.respondOk(res); + return; + } this.paused = true; this.status = "stopped"; this.lastError = null; - if (!this.onStop) return this.respondOk(res); + if (!this.onStop) { + this.respondOk(res); + return; + } this.onStop() .then(() => this.respondOk(res)) .catch((err) => { @@ -163,13 +177,24 @@ export class HealthCheck { }); } + private respondOk(res: ServerResponse): void { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, status: this.status })); + } + private handleStart(res: ServerResponse): void { - if (!this.paused) return this.respondOk(res); + if (!this.paused) { + this.respondOk(res); + return; + } this.paused = false; this.status = "running"; this.lastError = null; - if (!this.onStart) return this.respondOk(res); + if (!this.onStart) { + this.respondOk(res); + return; + } this.onStart() .then(() => this.respondOk(res)) .catch((err) => { @@ -178,14 +203,13 @@ export class HealthCheck { res.end(JSON.stringify({ ok: false, error: "start callback failed" })); }); } - - private respondOk(res: ServerResponse): void { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true, status: this.status })); - } } function fractionToNumber(value: Fraction): number { // diagnostic only — never used in trading math return (Number(value.s) * Number(value.n)) / Number(value.d); } + +function bigIntReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +}