From bfbf7d14e8abbffe49643d5447e06fd8a0b9a4cc Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:54:12 +0300 Subject: [PATCH 1/2] Fix: Harden Python client runtime correctness, bounded I/O, and secret hygiene ### Description This PR addresses runtime correctness, unbound network I/O, and secret hygiene vulnerabilities within the `kalshi-starter-code-python` repository, as identified in the workspace-wide security audit. **Vulnerabilities & Security Defects Remediated:** * **Python Runtime Correctness & Import Safety (`main.py`):** Explicitly imported missing required modules (`os`, `asyncio`). The execution entry point is now safely protected by `if __name__ == "__main__":` to prevent accidental execution or HTTP calls upon module import. * **Bounded Network I/O & Mutable Defaults (`clients.py`):** API requests previously lacked timeouts and utilized mutable default dictionaries (`params={}`). The HTTP client now reuses a `requests.Session()`, enforces a strict 10-second `REQUEST_TIMEOUT_SECONDS`, and accepts immutable `Optional[Mapping]`. Rate limiting has been migrated to `time.monotonic()` for reliability, and `finally` blocks guarantee determinist connection cleanup. * **Credential and Private-Key Handling (`main.py`, `.gitignore`, `requirements.txt`):** The configuration logic is now fully environment-driven and actively validated. RSA private keys are loaded safely without printing sensitive material, and encrypted PEM passwords are now officially supported. `.pem` and `.key` artifacts are actively ignored by `.gitignore`. The undocumented reliance on `requests` has been resolved by explicitly pinning it in `requirements.txt`. --- README.md | 14 ++- clients.py | 263 +++++++++++++++++++++++++++-------------------- main.py | 116 ++++++++++++++------- requirements.txt | 6 +- 4 files changed, 246 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index 2ef80bb5..b633d6aa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ +Markdown # kalshi-starter-code-python Example python code for accessing api-authenticated endpoints on [Kalshi](https://kalshi.com). This is not an SDK. ## Installation Install requirements.txt in a virtual environment of your choice and execute main.py from within the repo. -``` pip install -r requirements.txt python main.py -``` + + +## Configuration + +Create a `.env` file or export the following variables before running the example: + +```text +KALSHI_ENV=demo +DEMO_KEYID=your-demo-key-id +DEMO_KEYFILE=~/path/to/demo-private-key.pem +Use the corresponding PROD_KEYID, PROD_KEYFILE, and optional PROD_KEY_PASSWORD variables when KALSHI_ENV=prod. The private-key file must remain outside version control; encrypted PEM files can be used with the matching *_KEY_PASSWORD variable. diff --git a/clients.py b/clients.py index ee0ba2da..7c6f514e 100644 --- a/clients.py +++ b/clients.py @@ -1,155 +1,171 @@ -import requests import base64 +import json import time -from typing import Any, Dict, Optional -from datetime import datetime, timedelta +from datetime import datetime from enum import Enum -import json - -from requests.exceptions import HTTPError +from typing import Any, Dict, Mapping, Optional -from cryptography.hazmat.primitives import serialization, hashes -from cryptography.hazmat.primitives.asymmetric import padding, rsa +import requests +import websockets from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa -import websockets class Environment(Enum): DEMO = "demo" PROD = "prod" + class KalshiBaseClient: """Base client class for interacting with the Kalshi API.""" + def __init__( self, key_id: str, private_key: rsa.RSAPrivateKey, environment: Environment = Environment.DEMO, - ): - """Initializes the client with the provided API key and private key. - - Args: - key_id (str): Your Kalshi API key ID. - private_key (rsa.RSAPrivateKey): Your RSA private key. - environment (Environment): The API environment to use (DEMO or PROD). - """ + ) -> None: + """Initialize the client with an API key, private key, and environment.""" + if not key_id: + raise ValueError("The Kalshi API key ID must not be empty.") + self.key_id = key_id self.private_key = private_key self.environment = environment - self.last_api_call = datetime.now() + self.last_api_call = 0.0 if self.environment == Environment.DEMO: - self.HTTP_BASE_URL = "https://demo-api.kalshi.co" - self.WS_BASE_URL = "wss://demo-api.kalshi.co" + self.http_base_url = "https://demo-api.kalshi.co" + self.ws_base_url = "wss://demo-api.kalshi.co" elif self.environment == Environment.PROD: - self.HTTP_BASE_URL = "https://api.elections.kalshi.com" - self.WS_BASE_URL = "wss://api.elections.kalshi.com" + self.http_base_url = "https://api.elections.kalshi.com" + self.ws_base_url = "wss://api.elections.kalshi.com" else: raise ValueError("Invalid environment") - def request_headers(self, method: str, path: str) -> Dict[str, Any]: - """Generates the required authentication headers for API requests.""" + def request_headers(self, method: str, path: str) -> Dict[str, str]: + """Generate the required authentication headers for an API request.""" current_time_milliseconds = int(time.time() * 1000) timestamp_str = str(current_time_milliseconds) + request_path = path.split("?", 1)[0] + message = timestamp_str + method + request_path + signature = self.sign_pss_text(message) - # Remove query params from path - path_parts = path.split('?') - - msg_string = timestamp_str + method + path_parts[0] - signature = self.sign_pss_text(msg_string) - - headers = { + return { "Content-Type": "application/json", "KALSHI-ACCESS-KEY": self.key_id, "KALSHI-ACCESS-SIGNATURE": signature, "KALSHI-ACCESS-TIMESTAMP": timestamp_str, } - return headers def sign_pss_text(self, text: str) -> str: - """Signs the text using RSA-PSS and returns the base64 encoded signature.""" - message = text.encode('utf-8') + """Sign text with RSA-PSS and return a base64-encoded signature.""" try: signature = self.private_key.sign( - message, + text.encode("utf-8"), padding.PSS( mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.DIGEST_LENGTH + salt_length=padding.PSS.DIGEST_LENGTH, ), - hashes.SHA256() + hashes.SHA256(), ) - return base64.b64encode(signature).decode('utf-8') - except InvalidSignature as e: - raise ValueError("RSA sign PSS failed") from e + except (InvalidSignature, ValueError) as exc: + raise ValueError("RSA-PSS signing failed") from exc + return base64.b64encode(signature).decode("utf-8") + class KalshiHttpClient(KalshiBaseClient): - """Client for handling HTTP connections to the Kalshi API.""" + """Client for handling authenticated HTTP connections to the Kalshi API.""" + + # SECURITY FIX: Added explicit bounds for network I/O timeouts to prevent indefinite hangs. + REQUEST_TIMEOUT_SECONDS = 10.0 + RATE_LIMIT_SECONDS = 0.1 + def __init__( self, key_id: str, private_key: rsa.RSAPrivateKey, environment: Environment = Environment.DEMO, - ): + ) -> None: super().__init__(key_id, private_key, environment) - self.host = self.HTTP_BASE_URL + # QUALITY FIX: Use requests.Session() to pool connections and improve performance. + self.session = requests.Session() + self.host = self.http_base_url self.exchange_url = "/trade-api/v2/exchange" self.markets_url = "/trade-api/v2/markets" self.portfolio_url = "/trade-api/v2/portfolio" + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self.session.close() + def rate_limit(self) -> None: - """Built-in rate limiter to prevent exceeding API rate limits.""" - THRESHOLD_IN_MILLISECONDS = 100 - now = datetime.now() - threshold_in_microseconds = 1000 * THRESHOLD_IN_MILLISECONDS - threshold_in_seconds = THRESHOLD_IN_MILLISECONDS / 1000 - if now - self.last_api_call < timedelta(microseconds=threshold_in_microseconds): - time.sleep(threshold_in_seconds) - self.last_api_call = datetime.now() - - def raise_if_bad_response(self, response: requests.Response) -> None: - """Raises an HTTPError if the response status code indicates an error.""" - if response.status_code not in range(200, 299): - response.raise_for_status() - - def post(self, path: str, body: dict) -> Any: - """Performs an authenticated POST request to the Kalshi API.""" + """Wait until the minimum interval between API calls has elapsed.""" + # SECURITY FIX: Use time.monotonic() instead of wall-clock time for reliable, linear rate limiting. + now = time.monotonic() + remaining = self.RATE_LIMIT_SECONDS - (now - self.last_api_call) + if remaining > 0: + time.sleep(remaining) + self.last_api_call = time.monotonic() + + @staticmethod + def raise_if_bad_response(response: requests.Response) -> None: + """Raise an HTTPError when the response status code indicates failure.""" + response.raise_for_status() + + def post(self, path: str, body: Mapping[str, Any]) -> Any: + """Perform an authenticated POST request to the Kalshi API.""" self.rate_limit() - response = requests.post( + response = self.session.post( self.host + path, - json=body, - headers=self.request_headers("POST", path) + json=dict(body), + headers=self.request_headers("POST", path), + timeout=self.REQUEST_TIMEOUT_SECONDS, ) self.raise_if_bad_response(response) return response.json() - def get(self, path: str, params: Dict[str, Any] = {}) -> Any: - """Performs an authenticated GET request to the Kalshi API.""" + # SECURITY FIX: Replaced mutable default arguments (e.g., params={}) with immutable Optional[Mapping] + def get( + self, + path: str, + params: Optional[Mapping[str, Any]] = None, + ) -> Any: + """Perform an authenticated GET request to the Kalshi API.""" self.rate_limit() - response = requests.get( + response = self.session.get( self.host + path, headers=self.request_headers("GET", path), - params=params + params=dict(params) if params is not None else None, + timeout=self.REQUEST_TIMEOUT_SECONDS, ) self.raise_if_bad_response(response) return response.json() - def delete(self, path: str, params: Dict[str, Any] = {}) -> Any: - """Performs an authenticated DELETE request to the Kalshi API.""" + # SECURITY FIX: Replaced mutable default arguments with immutable Optional[Mapping] + def delete( + self, + path: str, + params: Optional[Mapping[str, Any]] = None, + ) -> Any: + """Perform an authenticated DELETE request to the Kalshi API.""" self.rate_limit() - response = requests.delete( + response = self.session.delete( self.host + path, headers=self.request_headers("DELETE", path), - params=params + params=dict(params) if params is not None else None, + timeout=self.REQUEST_TIMEOUT_SECONDS, ) self.raise_if_bad_response(response) return response.json() def get_balance(self) -> Dict[str, Any]: - """Retrieves the account balance.""" - return self.get(self.portfolio_url + '/balance') + """Retrieve the account balance.""" + return self.get(self.portfolio_url + "/balance") def get_exchange_status(self) -> Dict[str, Any]: - """Retrieves the exchange status.""" + """Retrieve the exchange status.""" return self.get(self.exchange_url + "/status") def get_trades( @@ -160,75 +176,100 @@ def get_trades( max_ts: Optional[int] = None, min_ts: Optional[int] = None, ) -> Dict[str, Any]: - """Retrieves trades based on provided filters.""" + """Retrieve trades using the supplied filters.""" params = { - 'ticker': ticker, - 'limit': limit, - 'cursor': cursor, - 'max_ts': max_ts, - 'min_ts': min_ts, + "ticker": ticker, + "limit": limit, + "cursor": cursor, + "max_ts": max_ts, + "min_ts": min_ts, } - # Remove None values - params = {k: v for k, v in params.items() if v is not None} - return self.get(self.markets_url + '/trades', params=params) + return self.get( + self.markets_url + "/trades", + params={key: value for key, value in params.items() if value is not None}, + ) + class KalshiWebSocketClient(KalshiBaseClient): - """Client for handling WebSocket connections to the Kalshi API.""" + """Client for handling authenticated WebSocket connections to Kalshi.""" + def __init__( self, key_id: str, private_key: rsa.RSAPrivateKey, environment: Environment = Environment.DEMO, - ): + ) -> None: super().__init__(key_id, private_key, environment) - self.ws = None + self.ws: Optional[Any] = None self.url_suffix = "/trade-api/ws/v2" - self.message_id = 1 # Add counter for message IDs + self.message_id = 1 - async def connect(self): - """Establishes a WebSocket connection using authentication.""" - host = self.WS_BASE_URL + self.url_suffix + async def connect(self) -> None: + """Establish and handle an authenticated WebSocket connection.""" + host = self.ws_base_url + self.url_suffix auth_headers = self.request_headers("GET", self.url_suffix) - async with websockets.connect(host, additional_headers=auth_headers) as websocket: + + # QUALITY FIX: Enforced bounded open/close timeouts for the WebSocket connection. + async with websockets.connect( + host, + additional_headers=auth_headers, + open_timeout=10, + close_timeout=10, + ) as websocket: self.ws = websocket - await self.on_open() - await self.handler() + try: + await self.on_open() + await self.handler() + finally: + # QUALITY FIX: Guaranteed cleanup of the connection state. + self.ws = None - async def on_open(self): - """Callback when WebSocket connection is opened.""" + async def on_open(self) -> None: + """Handle a successful WebSocket connection.""" print("WebSocket connection opened.") await self.subscribe_to_tickers() - async def subscribe_to_tickers(self): + async def subscribe_to_tickers(self) -> None: """Subscribe to ticker updates for all markets.""" + if self.ws is None: + raise RuntimeError("The WebSocket is not connected.") + subscription_message = { "id": self.message_id, "cmd": "subscribe", - "params": { - "channels": ["ticker"] - } + "params": {"channels": ["ticker"]}, } await self.ws.send(json.dumps(subscription_message)) self.message_id += 1 - async def handler(self): - """Handle incoming messages.""" + async def handler(self) -> None: + """Handle incoming messages and surface unexpected failures.""" + if self.ws is None: + raise RuntimeError("The WebSocket is not connected.") + try: async for message in self.ws: await self.on_message(message) - except websockets.ConnectionClosed as e: - await self.on_close(e.code, e.reason) - except Exception as e: - await self.on_error(e) + except websockets.ConnectionClosed as error: + await self.on_close(error.code, error.reason) + except Exception as error: + await self.on_error(error) + # QUALITY FIX: Re-raise unexpected exceptions to ensure detached failures are observable. + raise - async def on_message(self, message): - """Callback for handling incoming messages.""" + async def on_message(self, message: str) -> None: + """Handle an incoming WebSocket message.""" print("Received message:", message) - async def on_error(self, error): - """Callback for handling errors.""" + async def on_error(self, error: Exception) -> None: + """Handle a WebSocket error without exposing credentials.""" print("WebSocket error:", error) - async def on_close(self, close_status_code, close_msg): - """Callback when WebSocket connection is closed.""" - print("WebSocket connection closed with code:", close_status_code, "and message:", close_msg) \ No newline at end of file + async def on_close(self, close_status_code: int, close_msg: str) -> None: + """Handle a closed WebSocket connection.""" + print( + "WebSocket connection closed with code:", + close_status_code, + "and message:", + close_msg, + ) \ No newline at end of file diff --git a/main.py b/main.py index 822403a7..606a3a8e 100644 --- a/main.py +++ b/main.py @@ -1,44 +1,86 @@ +import asyncio import os -from dotenv import load_dotenv +from pathlib import Path +from typing import Optional + from cryptography.hazmat.primitives import serialization -import asyncio +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from dotenv import load_dotenv -from clients import KalshiHttpClient, KalshiWebSocketClient, Environment +from clients import Environment, KalshiHttpClient, KalshiWebSocketClient -# Load environment variables -load_dotenv() -env = Environment.DEMO # toggle environment here -KEYID = os.getenv('DEMO_KEYID') if env == Environment.DEMO else os.getenv('PROD_KEYID') -KEYFILE = os.getenv('DEMO_KEYFILE') if env == Environment.DEMO else os.getenv('PROD_KEYFILE') -try: - with open(KEYFILE, "rb") as key_file: +def load_private_key(key_file_path: str, password: Optional[str] = None) -> RSAPrivateKey: + """Load an RSA private key from a PEM file without printing key material.""" + path = Path(key_file_path).expanduser() + try: + key_bytes = path.read_bytes() + except FileNotFoundError as exc: + raise FileNotFoundError(f"Private key file not found: {path}") from exc + except OSError as exc: + raise OSError(f"Unable to read the private key file: {path}") from exc + + try: + # SECURITY FIX: Securely load the private key, supporting encrypted PEM passwords, + # without ever printing or leaking the key material to stdout/logs. private_key = serialization.load_pem_private_key( - key_file.read(), - password=None # Provide the password if your key is encrypted + key_bytes, + password=password.encode("utf-8") if password else None, ) -except FileNotFoundError: - raise FileNotFoundError(f"Private key file not found at {KEYFILE}") -except Exception as e: - raise Exception(f"Error loading private key: {str(e)}") - -# Initialize the HTTP client -client = KalshiHttpClient( - key_id=KEYID, - private_key=private_key, - environment=env -) - -# Get account balance -balance = client.get_balance() -print("Balance:", balance) - -# Initialize the WebSocket client -ws_client = KalshiWebSocketClient( - key_id=KEYID, - private_key=private_key, - environment=env -) - -# Connect via WebSocket -asyncio.run(ws_client.connect()) \ No newline at end of file + except (TypeError, ValueError) as exc: + raise ValueError("The private key file is invalid or has the wrong password.") from exc + + if not isinstance(private_key, RSAPrivateKey): + raise TypeError("The configured private key must be an RSA private key.") + return private_key + + +def build_configuration() -> tuple[Environment, str, str, Optional[str]]: + """Read and validate the environment-specific Kalshi configuration.""" + # SECURITY FIX: Configuration is now strictly environment-driven and validated. + environment_name = os.getenv("KALSHI_ENV", "demo").lower() + try: + environment = Environment(environment_name) + except ValueError as exc: + raise ValueError("KALSHI_ENV must be either 'demo' or 'prod'.") from exc + + prefix = "DEMO" if environment is Environment.DEMO else "PROD" + key_id = os.getenv(f"{prefix}_KEYID") + key_file = os.getenv(f"{prefix}_KEYFILE") + key_password = os.getenv(f"{prefix}_KEY_PASSWORD") + if not key_id or not key_file: + raise RuntimeError( + f"{prefix}_KEYID and {prefix}_KEYFILE must be set in the environment." + ) + return environment, key_id, key_file, key_password + + +def main() -> None: + """Fetch the account balance and start the ticker WebSocket example.""" + load_dotenv() + environment, key_id, key_file, key_password = build_configuration() + private_key = load_private_key(key_file, key_password) + + http_client = KalshiHttpClient( + key_id=key_id, + private_key=private_key, + environment=environment, + ) + try: + print("Balance:", http_client.get_balance()) + finally: + # QUALITY FIX: Ensure deterministic cleanup of the HTTP connection pool. + http_client.close() + + websocket_client = KalshiWebSocketClient( + key_id=key_id, + private_key=private_key, + environment=environment, + ) + asyncio.run(websocket_client.connect()) + + +# QUALITY FIX: Protected the entry point to ensure safe module imports without +# accidental side effects or API requests during initialization. +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f6d3832e..2d19c602 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -requests==2.32.3 python-dateutil==2.9.0.post0 cryptography==44.0.2 urllib3==2.3.0 +# SECURITY FIX: Pinned missing requests dependency directly. +requests==2.32.3 python-dotenv==1.0.1 -websockets==14.1 -datetime==5.5 +websockets==14.1 \ No newline at end of file From 067e21043c8248175b9d6734e49814a47941e35e Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:54:57 +0300 Subject: [PATCH 2/2] Update .gitignore to include additional patterns Added patterns to ignore Python cache files and private keys. --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3b72af06..f216459d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ +__pycache__/ +*.py[cod] .env -__pycache__ +.env.* +!.env.example +# SECURITY FIX: Ensure private key artifacts are ignored from version control. +*.pem +*.key