@sudowealth/schwab-api
Version:
TypeScript client for Charles Schwab API with OAuth support, market data, trading functionality, and complete type safety
325 lines (324 loc) • 10.7 kB
JavaScript
/**
* Secure logger for Schwab API client
* Focuses on protecting authentication tokens and credentials
*/
// Patterns that indicate authentication/credential data
const SENSITIVE_PATTERNS = [
/authorization/i,
/bearer\s+[\w-]{1,1000}/gi, // Bounded to prevent ReDoS
/refresh_token/i,
/access_token/i,
/client_secret/i,
/client_id/i,
/password/i,
/token/i,
/secret/i,
/api_key/i,
];
// Field names that contain sensitive data
const SENSITIVE_FIELDS = new Set([
// Auth headers
'authorization',
'Authorization',
// OAuth tokens
'access_token',
'accessToken',
'refresh_token',
'refreshToken',
// Client credentials
'client_secret',
'clientSecret',
'client_id',
'clientId',
// Generic sensitive fields
'password',
'apiKey',
'api_key',
'token',
'secret',
'credential',
'credentials',
// Schwab-specific fields
'schwabUserId',
'accountNumber',
'hashValue',
'schwabClientCorrelId',
]);
// Minimal set of dangerous keys for prototype pollution protection
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
export class SecureLogger {
config;
constructor(config = {}) {
this.config = {
enabled: process.env.NODE_ENV !== 'production',
level: 'info',
...config,
};
}
/**
* Check if a string looks like a token
*/
isLikelyToken(value) {
// JWT format: xxx.yyy.zzz
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
return true;
}
// Base64-like token (min 20 chars)
if (value.length > 20 && /^[A-Za-z0-9+/=_-]{20,}$/.test(value)) {
return true;
}
return false;
}
/**
* Sanitize a value to remove sensitive information
*/
sanitizeValue(value) {
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string') {
// Check for token-like strings
if (this.isLikelyToken(value)) {
return `[REDACTED ${value.length} chars]`;
}
// Replace sensitive patterns
let sanitized = value;
for (const pattern of SENSITIVE_PATTERNS) {
if (pattern.test(sanitized)) {
if (/authorization:\s*bearer/i.test(sanitized)) {
sanitized = sanitized.replace(/bearer\s+[\w-]+/gi, 'Bearer [REDACTED]');
}
else {
// For other patterns, check if it's a significant match
const match = sanitized.match(pattern);
if (match && match[0].length > 10) {
sanitized = sanitized.replace(pattern, '[REDACTED]');
}
}
}
}
return sanitized;
}
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeValue(item));
}
if (value && typeof value === 'object') {
// Special handling for errors
if (value instanceof Error) {
return this.sanitizeError(value);
}
return this.sanitizeObject(value);
}
return value;
}
/**
* Sanitize error objects
*/
sanitizeError(error) {
// In production, minimize error information
if (process.env.NODE_ENV === 'production') {
return `${error.name}: [Error details hidden in production]`;
}
// In development, show sanitized stack
const sanitizedMessage = this.sanitizeValue(error.message);
return `${error.name}: ${sanitizedMessage}\n${error.stack || '[No stack]'}`;
}
/**
* Sanitize objects by checking field names
*/
sanitizeObject(obj) {
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
// Skip dangerous keys
if (DANGEROUS_KEYS.has(key)) {
continue;
}
// Check if field name indicates sensitive data
const lowerKey = key.toLowerCase();
if (SENSITIVE_FIELDS.has(key) ||
lowerKey.includes('token') ||
lowerKey.includes('secret') ||
lowerKey.includes('password') ||
lowerKey.includes('credential') ||
lowerKey.includes('key')) {
sanitized[key] = '[REDACTED]';
}
else {
// Recursively sanitize nested values
sanitized[key] = this.sanitizeValue(value);
}
}
return sanitized;
}
/**
* Format log arguments for output
*/
formatArgs(...args) {
return args
.map((arg) => {
if (typeof arg === 'string') {
return this.sanitizeValue(arg);
}
if (arg instanceof Error) {
return this.sanitizeError(arg);
}
if (typeof arg === 'object') {
try {
const sanitized = this.sanitizeValue(arg);
return JSON.stringify(sanitized, null, 2);
}
catch {
return '[Circular Reference]';
}
}
return String(arg);
})
.join(' ');
}
/**
* Check if logging is allowed for the given level
*/
shouldLog(level) {
if (!this.config.enabled)
return false;
const levels = ['debug', 'info', 'warn', 'error'];
const currentLevelIndex = levels.indexOf(this.config.level);
const requestedLevelIndex = levels.indexOf(level);
return requestedLevelIndex >= currentLevelIndex;
}
debug(...args) {
if (this.shouldLog('debug')) {
console.debug('[DEBUG]', this.formatArgs(...args));
}
}
info(...args) {
if (this.shouldLog('info')) {
console.info('[INFO]', this.formatArgs(...args));
}
}
warn(...args) {
if (this.shouldLog('warn')) {
console.warn('[WARN]', this.formatArgs(...args));
}
}
error(...args) {
if (this.shouldLog('error')) {
console.error('[ERROR]', this.formatArgs(...args));
}
}
/**
* Log an error with additional context
* This method provides structured error logging
*/
logError(message, error, context) {
const errorInfo = sanitizeError(error);
const sanitizedContext = context ? this.sanitizeObject(context) : undefined;
if (this.shouldLog('error')) {
console.error('[ERROR]', message, {
error: errorInfo,
...(sanitizedContext && { context: sanitizedContext }),
});
}
}
}
/**
* Create a logger instance for a specific module
*/
export function createLogger(moduleName) {
const logger = new SecureLogger({
enabled: process.env.SCHWAB_DEBUG === 'true',
level: process.env.SCHWAB_LOG_LEVEL || 'info',
});
// Add module name prefix
const originalDebug = logger.debug.bind(logger);
const originalInfo = logger.info.bind(logger);
const originalWarn = logger.warn.bind(logger);
const originalError = logger.error.bind(logger);
logger.debug = (...args) => originalDebug(`[${moduleName}]`, ...args);
logger.info = (...args) => originalInfo(`[${moduleName}]`, ...args);
logger.warn = (...args) => originalWarn(`[${moduleName}]`, ...args);
logger.error = (...args) => originalError(`[${moduleName}]`, ...args);
return logger;
}
// Default logger instance
export const logger = createLogger('SchwabAPI');
/**
* Sanitize a key for logging
* Shows only the beginning and end of the key
*
* @param key The key to sanitize
* @param options Sanitization options
* @returns Sanitized key safe for logging
*/
export function sanitizeKeyForLog(key, options = {}) {
const maxLength = options.maxLength || 15;
if (!key || key.length <= maxLength) {
return key;
}
const prefixLength = Math.floor(maxLength * 0.6);
const suffixLength = Math.floor(maxLength * 0.3);
return `${key.substring(0, prefixLength)}...${key.substring(key.length - suffixLength)}`;
}
/**
* Sanitize an error object for safe logging
* Removes sensitive data while preserving useful debugging information
*
* @param error The error to sanitize
* @returns Sanitized error information
*/
export function sanitizeError(error) {
if (!error || typeof error !== 'object') {
return { message: String(error) };
}
const err = error;
const sanitized = {};
// Safe properties to include
const safeProps = ['name', 'code', 'statusCode', 'status', 'type'];
for (const prop of safeProps) {
if (prop in err) {
sanitized[prop] = err[prop];
}
}
// Sanitize message - remove potential sensitive data patterns
if ('message' in err) {
let message = String(err.message);
// Remove any token-like strings from the message
message = message.replace(/[A-Za-z0-9+/=_-]{20,}/g, '[REDACTED]');
// Remove any patterns that look like account numbers
message = message.replace(/\b\d{8,}\b/g, '[ACCOUNT]');
sanitized.message = message;
}
// Handle stack traces - only include in development
if ('stack' in err && process.env.NODE_ENV !== 'production') {
// Remove file paths that might reveal system structure
const stack = String(err.stack)
.split('\n')
.slice(0, 5) // Limit stack trace depth
.map((line) => line.replace(/\(.*\)/, '(...)')) // Remove file paths
.join('\n');
sanitized.stack = stack;
}
// Include any additional safe metadata
if ('requestId' in err) {
sanitized.requestId = err.requestId;
}
return sanitized;
}
/**
* Sanitize a token for logging
* Shows only the beginning of the token and optionally its length
*
* @param token The token to sanitize
* @param options Sanitization options
* @returns Sanitized token safe for logging
*/
export function sanitizeTokenForLog(token, options = {}) {
if (!token) {
return '[NO TOKEN]';
}
const preview = token.length > 8 ? `${token.substring(0, 8)}...` : '[SHORT TOKEN]';
if (options.showLength) {
return `${preview} (${token.length} chars)`;
}
return preview;
}