You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
188 lines
6.5 KiB
188 lines
6.5 KiB
/** |
|
* Security utilities for safe logging and data handling |
|
*/ |
|
|
|
/** |
|
* Truncate a pubkey/npub for safe logging |
|
* Shows first 8 and last 4 characters: abc12345...xyz9 |
|
*/ |
|
export function truncatePubkey(pubkey: string | null | undefined): string { |
|
if (!pubkey) return 'unknown'; |
|
if (pubkey.length <= 16) return pubkey; // Already short, return as-is |
|
|
|
// For hex pubkeys (64 chars) or npubs (longer), truncate |
|
if (pubkey.length > 16) { |
|
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
|
} |
|
|
|
return pubkey; |
|
} |
|
|
|
/** |
|
* Truncate an npub for display |
|
* Shows first 12 characters: npub1abc123... |
|
*/ |
|
export function truncateNpub(npub: string | null | undefined): string { |
|
if (!npub) return 'unknown'; |
|
if (npub.length <= 16) return npub; |
|
return `${npub.slice(0, 12)}...`; |
|
} |
|
|
|
/** |
|
* Sanitize error messages to prevent leaking sensitive data |
|
*/ |
|
export function sanitizeError(error: unknown): string { |
|
if (error instanceof Error) { |
|
let message = error.message; |
|
|
|
// Remove potential private key patterns |
|
message = message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]'); |
|
message = message.replace(/[0-9a-f]{64}/g, '[REDACTED]'); // 64-char hex keys |
|
|
|
// Remove password patterns |
|
message = message.replace(/password[=:]\s*\S+/gi, 'password=[REDACTED]'); |
|
message = message.replace(/pwd[=:]\s*\S+/gi, 'pwd=[REDACTED]'); |
|
|
|
// Truncate long pubkeys in error messages |
|
message = message.replace(/(npub[a-z0-9]{50,})/gi, (match) => truncateNpub(match)); |
|
message = message.replace(/([0-9a-f]{50,})/g, (match) => truncatePubkey(match)); |
|
|
|
return message; |
|
} |
|
return String(error); |
|
} |
|
|
|
/** |
|
* Validate branch name to prevent injection attacks |
|
*/ |
|
export function isValidBranchName(name: string): boolean { |
|
if (!name || typeof name !== 'string') return false; |
|
if (name.length === 0 || name.length > 255) return false; |
|
if (name.startsWith('.') || name.startsWith('-')) return false; |
|
if (name.includes('..') || name.includes('//')) return false; |
|
// Allow alphanumeric, dots, hyphens, underscores, and forward slashes |
|
// But not at the start or end |
|
if (!/^[a-zA-Z0-9._/-]+$/.test(name)) return false; |
|
if (name.endsWith('.') || name.endsWith('-') || name.endsWith('/')) return false; |
|
// Git reserved names |
|
const reserved = ['HEAD', 'MERGE_HEAD', 'FETCH_HEAD', 'ORIG_HEAD']; |
|
if (reserved.includes(name.toUpperCase())) return false; |
|
return true; |
|
} |
|
|
|
/** |
|
* Check if a string might contain a private key |
|
*/ |
|
export function mightContainPrivateKey(str: string): boolean { |
|
// Check for nsec pattern |
|
if (/^nsec[0-9a-z]+$/i.test(str)) return true; |
|
|
|
// Check for 64-char hex (potential private key) |
|
if (/^[0-9a-f]{64}$/i.test(str)) return true; |
|
|
|
return false; |
|
} |
|
|
|
/** |
|
* Redact sensitive data from objects before logging |
|
*/ |
|
export function redactSensitiveData(obj: Record<string, any>): Record<string, any> { |
|
const redacted = { ...obj }; |
|
const sensitiveKeys = ['nsec', 'nsecKey', 'secret', 'privateKey', 'key', 'password', 'token', 'auth']; |
|
|
|
for (const key of Object.keys(redacted)) { |
|
const lowerKey = key.toLowerCase(); |
|
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { |
|
redacted[key] = '[REDACTED]'; |
|
} else if (typeof redacted[key] === 'string' && mightContainPrivateKey(redacted[key])) { |
|
redacted[key] = '[REDACTED]'; |
|
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) { |
|
redacted[key] = redactSensitiveData(redacted[key]); |
|
} |
|
} |
|
|
|
return redacted; |
|
} |
|
|
|
/** |
|
* Get path resolve function (server-side only) |
|
* Uses a function factory to avoid bundling path module in browser builds |
|
*/ |
|
function getPathResolve(): ((path: string) => string) | null { |
|
// Browser check - path module not available |
|
if (typeof window !== 'undefined') { |
|
return null; |
|
} |
|
|
|
// Server-side: dynamically access path module |
|
// This pattern prevents Vite from trying to bundle path for the browser |
|
try { |
|
// Access path through a function to avoid static analysis |
|
const path = (globalThis as any).require?.('path') || |
|
(typeof process !== 'undefined' && process.versions?.node |
|
? (() => { |
|
// This will only work in Node.js environment |
|
// Vite will externalize this for browser builds |
|
try { |
|
// @ts-ignore - path is a Node.js built-in |
|
return require('path'); |
|
} catch { |
|
return null; |
|
} |
|
})() |
|
: null); |
|
|
|
return path?.resolve || null; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Validate repository path to prevent path traversal attacks |
|
* Ensures the resolved path is within the repository root directory |
|
* |
|
* NOTE: This function is server-only and uses Node.js path module |
|
* In browser environments, it performs basic validation only |
|
* |
|
* @param repoPath - The repository path to validate |
|
* @param repoRoot - The root directory for repositories |
|
* @returns Object with validation result and error message if invalid |
|
*/ |
|
export function validateRepoPath(repoPath: string, repoRoot: string): { valid: boolean; error?: string; resolvedPath?: string } { |
|
if (!repoPath || typeof repoPath !== 'string') { |
|
return { valid: false, error: 'Repository path is required' }; |
|
} |
|
|
|
if (!repoRoot || typeof repoRoot !== 'string') { |
|
return { valid: false, error: 'Repository root is required' }; |
|
} |
|
|
|
// Try to get path.resolve function (only available on server) |
|
const pathResolve = getPathResolve(); |
|
|
|
if (!pathResolve) { |
|
// Browser environment - use simple string validation |
|
// This is a fallback, but server-side code should always be used for path validation |
|
if (repoPath.includes('..') || repoPath.includes('//')) { |
|
return { valid: false, error: 'Invalid repository path: path traversal detected' }; |
|
} |
|
return { valid: true, resolvedPath: repoPath }; |
|
} |
|
|
|
// Server-side: use Node.js path module |
|
try { |
|
// Normalize paths to handle Windows/Unix differences |
|
const resolvedPath = pathResolve(repoPath).replace(/\\/g, '/'); |
|
const resolvedRoot = pathResolve(repoRoot).replace(/\\/g, '/'); |
|
|
|
// Must be a subdirectory of repoRoot, not equal to it |
|
if (!resolvedPath.startsWith(resolvedRoot + '/')) { |
|
return { valid: false, error: 'Invalid repository path: path traversal detected' }; |
|
} |
|
|
|
return { valid: true, resolvedPath }; |
|
} catch (err) { |
|
return { valid: false, error: `Failed to validate path: ${err instanceof Error ? err.message : String(err)}` }; |
|
} |
|
}
|
|
|