validation.ts•13 kB
/**
* Security Validation System
*
* Provides input validation, path traversal protection, and
* injection attack prevention for the Watchtower debugging system.
*/
import { basename, join, normalize, resolve } from 'path';
import { homedir } from 'os';
/**
* Path validation result
*/
export interface ValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
normalizedPath?: string;
}
/**
* Validation rules
*/
export const ValidationRules = {
// Path validation
MAX_PATH_LENGTH: 4096,
ALLOWED_EXTENSIONS: ['.js', '.ts', '.py', '.cs', '.dart', '.json', '.txt', '.md'],
DISALLOWED_PATTERNS: [
/\.\./, // Parent directory traversal
/\/\.\./, // Parent directory traversal
/\\\\\.\./, // Windows parent directory traversal
/<|>|:|"|'|`|;|&|\|/, // Command injection characters
/\x00/, // Null byte
],
// File name validation
MAX_FILENAME_LENGTH: 255,
DISALLOWED_FILENAMES: [
'con',
'prn',
'aux',
'nul',
'com1',
'com2',
'com3',
'com4',
'com5',
'com6',
'com7',
'com8',
'com9',
'lpt1',
'lpt2',
'lpt3',
'lpt4',
'lpt5',
'lpt6',
'lpt7',
'lpt8',
'lpt9',
],
// Command validation
ALLOWED_COMMANDS: ['node', 'python', 'dotnet', 'dart', 'vsdbg', 'netcoredbg', 'debugpy'],
DISALLOWED_ARGS: [
'--eval',
'-e', // Command execution
'--exec', // Command execution
'&&',
'||',
';', // Command chaining
'$(',
'${', // Command substitution
'|',
'>',
'<',
'>>', // Redirection
],
};
/**
* Security Validator class
*/
export class SecurityValidator {
private allowedBasePaths: Set<string>;
private allowedCommands: Set<string>;
constructor() {
this.allowedBasePaths = new Set([
process.cwd(),
homedir(),
join(homedir(), 'projects'),
join(homedir(), 'documents'),
]);
this.allowedCommands = new Set(ValidationRules.ALLOWED_COMMANDS);
}
/**
* Validate and normalize a file path
*/
validatePath(path: string, basePath?: string): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
if (!path || typeof path !== 'string') {
result.isValid = false;
result.errors.push('Path cannot be empty');
return result;
}
// Check path length
if (path.length > ValidationRules.MAX_PATH_LENGTH) {
result.isValid = false;
result.errors.push(
`Path exceeds maximum length of ${ValidationRules.MAX_PATH_LENGTH} characters`
);
}
// Check for path traversal
if (this.hasPathTraversal(path)) {
result.isValid = false;
result.errors.push('Path traversal detected (../)');
}
// Check for injection characters
if (this.hasInjectionCharacters(path)) {
result.isValid = false;
result.errors.push('Invalid characters in path');
}
// Check for reserved Windows filenames
const filename = basename(path);
if (ValidationRules.DISALLOWED_FILENAMES.includes(filename.toLowerCase())) {
result.isValid = false;
result.errors.push(`Filename "${filename}" is reserved on Windows`);
}
// Check filename length
if (filename.length > ValidationRules.MAX_FILENAME_LENGTH) {
result.isValid = false;
result.errors.push(
`Filename exceeds maximum length of ${ValidationRules.MAX_FILENAME_LENGTH} characters`
);
}
// Normalize path
try {
const normalized = normalize(path);
result.normalizedPath = normalized;
// Check if path is within allowed base directories
if (basePath) {
const resolvedPath = resolve(normalized);
const resolvedBase = resolve(basePath);
if (!resolvedPath.startsWith(resolvedBase)) {
result.isValid = false;
result.errors.push('Path is outside allowed base directory');
}
}
// Check if path exists if it's a file
if (this.isFile(path)) {
const extension = '.' + path.split('.').pop()?.toLowerCase();
if (!ValidationRules.ALLOWED_EXTENSIONS.includes(extension || '')) {
result.warnings.push(`File extension "${extension}" is not explicitly allowed`);
}
}
} catch (error) {
result.isValid = false;
result.errors.push(`Invalid path: ${(error as Error).message}`);
}
return result;
}
/**
* Validate a command and its arguments
*/
validateCommand(command: string, args: string[] = []): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
// Check command
if (!command || typeof command !== 'string') {
result.isValid = false;
result.errors.push('Command cannot be empty');
return result;
}
const commandName = this.extractCommandName(command);
// Check if command is allowed
if (!this.allowedCommands.has(commandName)) {
result.isValid = false;
result.errors.push(`Command "${commandName}" is not allowed`);
}
// Check arguments
for (const arg of args) {
if (!this.validateArgument(arg).isValid) {
result.isValid = false;
result.errors.push(`Invalid argument: ${arg}`);
}
}
return result;
}
/**
* Validate a single argument
*/
validateArgument(arg: string): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
if (!arg || typeof arg !== 'string') {
result.isValid = false;
result.errors.push('Argument cannot be empty');
return result;
}
// Check for argument injection
if (this.hasInjectionCharacters(arg)) {
result.isValid = false;
result.errors.push('Invalid characters in argument');
}
// Check for disallowed patterns
for (const pattern of ValidationRules.DISALLOWED_ARGS) {
if (arg.includes(pattern)) {
result.isValid = false;
result.errors.push(`Disallowed argument pattern: ${pattern}`);
}
}
return result;
}
/**
* Validate environment variables
*/
validateEnvironment(env: Record<string, string>): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
const sensitiveKeys = ['password', 'secret', 'token', 'key', 'auth'];
const redacted = new Set<string>();
for (const [key, value] of Object.entries(env)) {
// Check for sensitive keys that should not be logged
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some(sensitiveKey => lowerKey.includes(sensitiveKey))) {
redacted.add(key);
}
// Validate key format
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result.warnings.push(`Environment variable key "${key}" has non-standard characters`);
}
// Validate value
if (this.hasInjectionCharacters(value)) {
result.isValid = false;
result.errors.push(`Invalid characters in environment variable "${key}"`);
}
}
if (redacted.size > 0) {
result.warnings.push(`Found ${redacted.size} potentially sensitive environment variables`);
}
return result;
}
/**
* Validate debug configuration
*/
validateDebugConfig(config: any): ValidationResult {
const result: ValidationResult = {
isValid: true,
errors: [],
warnings: [],
};
// Check program path
if (config.program) {
const programValidation = this.validatePath(config.program);
if (!programValidation.isValid) {
result.isValid = false;
result.errors.push(...programValidation.errors);
}
result.warnings.push(...programValidation.warnings);
}
// Check working directory
if (config.cwd) {
const cwdValidation = this.validatePath(config.cwd);
if (!cwdValidation.isValid) {
result.isValid = false;
result.errors.push(...cwdValidation.errors);
}
result.warnings.push(...cwdValidation.warnings);
}
// Check arguments
if (config.args && Array.isArray(config.args)) {
for (const arg of config.args) {
const argValidation = this.validateArgument(String(arg));
if (!argValidation.isValid) {
result.isValid = false;
result.errors.push(...argValidation.errors);
}
}
}
// Check environment variables
if (config.env && typeof config.env === 'object') {
const envValidation = this.validateEnvironment(config.env);
if (!envValidation.isValid) {
result.isValid = false;
result.errors.push(...envValidation.errors);
}
result.warnings.push(...envValidation.warnings);
}
return result;
}
/**
* Check for path traversal
*/
private hasPathTraversal(path: string): boolean {
// Normalize and check for parent directory references
const normalized = normalize(path);
return normalized !== path && /(?:^|[\\\/])\.\.([\\\/]|$)/.test(normalized);
}
/**
* Check for injection characters
*/
private hasInjectionCharacters(input: string): boolean {
return ValidationRules.DISALLOWED_PATTERNS.some(pattern => pattern.test(input));
}
/**
* Extract command name from full path
*/
private extractCommandName(command: string): string {
const base = basename(command);
// Remove file extension for commands like node.exe, python.exe
return base.split('.')[0]?.toLowerCase() || '';
}
/**
* Check if path is a file
*/
private isFile(path: string): boolean {
// This is a simplified check - in a real implementation,
// you would use fs.statSync to check if it's a file
try {
const fs = require('fs');
return fs.statSync(path).isFile();
} catch {
return false;
}
}
/**
* Add allowed base path
*/
addAllowedBasePath(path: string): void {
this.allowedBasePaths.add(resolve(path));
}
/**
* Add allowed command
*/
addAllowedCommand(command: string): void {
this.allowedCommands.add(command.toLowerCase());
}
/**
* Remove allowed base path
*/
removeAllowedBasePath(path: string): void {
this.allowedBasePaths.delete(resolve(path));
}
/**
* Remove allowed command
*/
removeAllowedCommand(command: string): void {
this.allowedCommands.delete(command.toLowerCase());
}
/**
* Get validation summary
*/
getSummary(): {
allowedBasePaths: string[];
allowedCommands: string[];
} {
return {
allowedBasePaths: Array.from(this.allowedBasePaths),
allowedCommands: Array.from(this.allowedCommands),
};
}
}
/**
* Global security validator instance
*/
let globalValidator: SecurityValidator | null = null;
/**
* Get or create global security validator
*/
export function getGlobalValidator(): SecurityValidator {
if (!globalValidator) {
globalValidator = new SecurityValidator();
}
return globalValidator;
}
/**
* Convenience functions for validation
*/
export function validatePath(path: string, basePath?: string): ValidationResult {
return getGlobalValidator().validatePath(path, basePath);
}
export function validateCommand(command: string, args?: string[]): ValidationResult {
return getGlobalValidator().validateCommand(command, args);
}
export function validateArgument(arg: string): ValidationResult {
return getGlobalValidator().validateArgument(arg);
}
export function validateEnvironment(env: Record<string, string>): ValidationResult {
return getGlobalValidator().validateEnvironment(env);
}
export function validateDebugConfig(config: any): ValidationResult {
return getGlobalValidator().validateDebugConfig(config);
}
/**
* Create security validator with custom configuration
*/
export function createSecurityValidator(): SecurityValidator {
return new SecurityValidator();
}
/**
* Sanitize user input
*/
export function sanitizeInput(input: any): string {
if (typeof input !== 'string') {
return String(input);
}
// Remove or replace potentially dangerous characters
return input
.replace(/[<>"'`;&|]/g, '') // Remove shell injection characters
.replace(/\x00/g, '') // Remove null bytes
.trim();
}
/**
* Check if input is safe for logging
*/
export function isSafeForLogging(input: any): boolean {
if (typeof input !== 'string') {
return true;
}
// Check for sensitive patterns
const sensitivePatterns = [
/password\s*[:=]\s*\S+/i,
/token\s*[:=]\s*\S+/i,
/secret\s*[:=]\s*\S+/i,
/api[_-]?key\s*[:=]\s*\S+/i,
/eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*/, // JWT
];
return !sensitivePatterns.some(pattern => pattern.test(input));
}
/**
* Escape shell arguments safely
*/
export function escapeShellArg(arg: string): string {
// Simple escaping for Windows
return arg.replace(/["\\]/g, '\\$&');
}