/**
* Security utilities for PartnerCore Proxy
*
* Provides path sanitization, input validation, and secret masking
* to prevent common security vulnerabilities.
*/
import * as path from 'path';
import * as fs from 'fs';
/**
* Sensitive patterns that should never be logged
* Expanded to catch more credential types
*/
const SENSITIVE_KEY_PATTERNS = [
/api[_-]?key/i,
/password/i,
/secret/i,
/token/i,
/bearer/i,
/authorization/i,
/credential/i,
/access[_-]?key/i, // AWS access keys
/private[_-]?key/i, // Private keys
/connection[_-]?string/i, // Connection strings
/^auth$/i, // Simple 'auth' key
/session[_-]?id/i, // Session identifiers
/refresh[_-]?token/i, // Refresh tokens
/client[_-]?secret/i, // OAuth client secrets
/signing[_-]?key/i, // Signing keys
/encryption[_-]?key/i, // Encryption keys
/cert(?:ificate)?/i, // Certificates
];
/**
* Patterns that indicate a value is likely a secret
* (regardless of key name)
*/
const SECRET_VALUE_PATTERNS = [
/^-----BEGIN.*KEY-----/, // PEM format keys
/^sk_live_/, // Stripe live keys
/^sk_test_/, // Stripe test keys
/^AKIA[A-Z0-9]{16}$/, // AWS access key IDs
/^ghp_[a-zA-Z0-9]{36}$/, // GitHub personal access tokens
/^gho_[a-zA-Z0-9]{36}$/, // GitHub OAuth tokens
/^github_pat_/, // GitHub PATs (new format)
/^xox[bpras]-/, // Slack tokens
/^eyJ[a-zA-Z0-9_-]*\./, // JWT tokens
];
/**
* Mask sensitive values in objects for safe logging
*/
export function maskSensitiveData(obj: unknown, depth = 0): unknown {
if (depth > 10) return '[MAX_DEPTH]';
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
// Check if the value looks like a known secret format
for (const pattern of SECRET_VALUE_PATTERNS) {
if (pattern.test(obj)) {
return '[REDACTED]';
}
}
// Mask strings that look like keys/tokens (long alphanumeric strings)
if (obj.length > 20 && /^[a-zA-Z0-9+/=_-]+$/.test(obj)) {
return `[MASKED:${obj.slice(0, 4)}...${obj.slice(-4)}]`;
}
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => maskSensitiveData(item, depth + 1));
}
if (typeof obj === 'object') {
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// Skip prototype pollution vectors
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some(pattern => pattern.test(key));
if (isSensitiveKey && typeof value === 'string' && value.length > 0) {
masked[key] = '[REDACTED]';
} else {
masked[key] = maskSensitiveData(value, depth + 1);
}
}
return masked;
}
return obj;
}
/**
* Decode URL-encoded strings recursively to catch bypass attempts
*/
function decodeUrlRecursive(input: string, maxIterations = 3): string {
let decoded = input;
let prev = '';
let iterations = 0;
// Keep decoding until no change or max iterations
while (decoded !== prev && iterations < maxIterations) {
prev = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch {
// Invalid encoding, return as-is
break;
}
iterations++;
}
return decoded;
}
/**
* Check for path traversal patterns in a string
*/
function containsTraversalPatterns(input: string): boolean {
const patterns = [
/\.\.[/\\]/, // ../ or ..\
/[/\\]\.\./, // /.. or \..
/^\.\./, // starts with ..
/\.\.$/, // ends with ..
/^\.\.$/, // just ..
];
return patterns.some(p => p.test(input));
}
/**
* Validate and sanitize a file path to prevent directory traversal
*
* @param filePath - The path to validate (can be absolute if within workspace, or relative)
* @param workspaceRoot - The allowed root directory
* @returns The sanitized absolute path
* @throws SecurityError if path is outside workspace or contains suspicious patterns
*/
export function sanitizePath(filePath: string, workspaceRoot: string): string {
// Check for null bytes first (before any other processing)
if (filePath.includes('\0')) {
throw new SecurityError(
'Null byte detected in path',
'NULL_BYTE_INJECTION'
);
}
// Decode URL encoding recursively to catch bypass attempts
const decoded = decodeUrlRecursive(filePath);
// Check for traversal in both original and decoded versions
if (containsTraversalPatterns(filePath) || containsTraversalPatterns(decoded)) {
throw new SecurityError(
'Path traversal pattern detected',
'PATH_TRAVERSAL'
);
}
// Additional suspicious patterns that might indicate attack attempts
const suspiciousPatterns = [
/%2e/i, // Any remaining URL-encoded dots
/%5c/i, // URL-encoded backslash
/%2f/i, // URL-encoded forward slash
/%00/i, // URL-encoded null
/\uff0e/, // Full-width period
/\u2024/, // One dot leader
/\u2025/, // Two dot leader
/\u2026/, // Horizontal ellipsis
/\ufe52/, // Small full stop
/\uff0f/, // Full-width solidus
/\uff3c/, // Full-width reverse solidus
/%c0%ae/i, // Overlong UTF-8 encoding of .
/%c1%1c/i, // Overlong UTF-8 encoding of /
/%c0%af/i, // Overlong UTF-8 encoding of /
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(filePath)) {
throw new SecurityError(
'Suspicious encoding pattern detected',
'SUSPICIOUS_ENCODING'
);
}
}
// Normalize the workspace root
const normalizedRoot = path.resolve(workspaceRoot);
// Handle absolute paths - if the path is absolute and within workspace, allow it
let normalizedPath: string;
if (path.isAbsolute(decoded) || /^[a-zA-Z]:/.test(decoded)) {
// Absolute path provided - resolve it directly
normalizedPath = path.resolve(decoded);
} else {
// Relative path - resolve from workspace root
normalizedPath = path.resolve(workspaceRoot, decoded);
}
// Check for path traversal after resolution - path must be within workspace
if (!normalizedPath.startsWith(normalizedRoot)) {
throw new SecurityError(
'Path resolves outside workspace',
'PATH_TRAVERSAL'
);
}
return normalizedPath;
}
/**
* Validate that a path exists and is within the workspace
*/
export function validatePathExists(
filePath: string,
workspaceRoot: string,
type: 'file' | 'directory' | 'any' = 'any'
): string {
const sanitized = sanitizePath(filePath, workspaceRoot);
if (!fs.existsSync(sanitized)) {
throw new SecurityError(
'Path does not exist',
'PATH_NOT_FOUND'
);
}
const stat = fs.statSync(sanitized);
if (type === 'file' && !stat.isFile()) {
throw new SecurityError(
'Expected file but found directory',
'NOT_A_FILE'
);
}
if (type === 'directory' && !stat.isDirectory()) {
throw new SecurityError(
'Expected directory but found file',
'NOT_A_DIRECTORY'
);
}
return sanitized;
}
/**
* Validate tool arguments against expected schema
*/
export function validateToolArgs(
args: Record<string, unknown>,
required: string[],
types: Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array'>
): void {
// Check required fields
for (const field of required) {
if (!(field in args) || args[field] === undefined || args[field] === null) {
throw new ValidationError(`Missing required argument: ${field}`);
}
}
// Check types
for (const [field, expectedType] of Object.entries(types)) {
if (!(field in args)) continue;
const value = args[field];
let actualType: string;
if (Array.isArray(value)) {
actualType = 'array';
} else {
actualType = typeof value;
}
if (actualType !== expectedType) {
throw new ValidationError(
`Invalid type for ${field}: expected ${expectedType}, got ${actualType}`
);
}
}
}
/**
* Sanitize string input to prevent injection
*/
export function sanitizeString(input: string, maxLength = 10000): string {
if (typeof input !== 'string') {
throw new ValidationError('Expected string input');
}
// Truncate if too long
if (input.length > maxLength) {
input = input.slice(0, maxLength);
}
// Remove null bytes
input = input.replace(/\0/g, '');
return input;
}
/**
* Security error with code for categorization
*/
export class SecurityError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.name = 'SecurityError';
this.code = code;
}
}
/**
* Validation error for invalid input
*/
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Rate limiter for API calls
*/
export class RateLimiter {
private requests: number[] = [];
private readonly maxRequests: number;
private readonly windowMs: number;
constructor(maxRequests: number, windowMs: number) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
/**
* Check if a request is allowed
*/
isAllowed(): boolean {
// Handle edge cases
if (this.maxRequests <= 0) {
return false;
}
const now = Date.now();
// Remove old requests outside the window
this.requests = this.requests.filter(time => now - time < this.windowMs);
if (this.requests.length >= this.maxRequests) {
return false;
}
this.requests.push(now);
return true;
}
/**
* Get remaining requests in current window
*/
remaining(): number {
const now = Date.now();
this.requests = this.requests.filter(time => now - time < this.windowMs);
return Math.max(0, this.maxRequests - this.requests.length);
}
/**
* Get time until window resets (ms)
*/
resetIn(): number {
if (this.requests.length === 0) return 0;
const oldest = Math.min(...this.requests);
return Math.max(0, this.windowMs - (Date.now() - oldest));
}
}