329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
/**
|
|
* Security Module - Comprehensive security hardening for SDK operations
|
|
*
|
|
* Provides:
|
|
* - Input validation and sanitization
|
|
* - Rate limiting
|
|
* - Audit logging
|
|
* - Secret detection
|
|
* - Path traversal protection
|
|
* - Command injection prevention
|
|
*/
|
|
import { logger } from "../utils/logger.js";
|
|
import { existsSync, appendFileSync, mkdirSync } from "fs";
|
|
import { join, resolve, normalize } from "path";
|
|
import { homedir } from "os";
|
|
import { createHash, randomBytes } from "crypto";
|
|
// =============================================================================
|
|
// Input Validation
|
|
// =============================================================================
|
|
/**
|
|
* Validate and sanitize file paths
|
|
*/
|
|
export function sanitizePath(inputPath, allowedBases = [process.cwd()]) {
|
|
if (!inputPath || typeof inputPath !== 'string') {
|
|
return null;
|
|
}
|
|
// Normalize the path
|
|
const normalizedPath = normalize(resolve(inputPath));
|
|
// Check for null bytes (path traversal attack)
|
|
if (inputPath.includes('\0')) {
|
|
logger.warn('Null byte detected in path', { path: inputPath });
|
|
return null;
|
|
}
|
|
// Check for path traversal attempts
|
|
if (inputPath.includes('..')) {
|
|
const resolved = resolve(inputPath);
|
|
const isWithinAllowed = allowedBases.some(base => resolved.startsWith(resolve(base)));
|
|
if (!isWithinAllowed) {
|
|
logger.warn('Path traversal attempt blocked', { path: inputPath });
|
|
return null;
|
|
}
|
|
}
|
|
// Verify the path is within allowed bases
|
|
const isAllowed = allowedBases.some(base => normalizedPath.startsWith(resolve(base)));
|
|
if (!isAllowed) {
|
|
logger.warn('Path outside allowed directories', { path: normalizedPath, allowed: allowedBases });
|
|
return null;
|
|
}
|
|
return normalizedPath;
|
|
}
|
|
/**
|
|
* Validate command for injection attacks
|
|
*/
|
|
export function validateCommand(command) {
|
|
if (!command || typeof command !== 'string') {
|
|
return { valid: false, reason: 'Command must be a non-empty string' };
|
|
}
|
|
// Check for command chaining/injection patterns
|
|
const dangerousPatterns = [
|
|
/;\s*rm\s/i, // Command chaining with rm
|
|
/\|\s*sh\b/i, // Piping to shell
|
|
/\|\s*bash\b/i, // Piping to bash
|
|
/`[^`]+`/, // Backtick command substitution
|
|
/\$\([^)]*\beval\b/, // Eval in command substitution
|
|
/>\s*\/dev\/sd[a-z]/i, // Writing to block devices
|
|
/&&\s*rm\s+-rf/i, // Chained destructive rm
|
|
/\|\|\s*rm\s+-rf/i, // OR chained destructive rm
|
|
/\bnc\s+-[elp]/i, // Netcat reverse shell flags
|
|
/\bcurl\b.*\|\s*\w+sh/i, // Curl pipe to shell
|
|
/\bwget\b.*\|\s*\w+sh/i, // Wget pipe to shell
|
|
/>\s*~\/.bashrc/i, // Writing to shell config
|
|
/>\s*~\/.profile/i, // Writing to profile
|
|
/>\s*\/etc\//i, // Writing to /etc
|
|
/\bsudo\s/i, // Sudo escalation
|
|
/\bchmod\s+[0-7]*7[0-7]*/i, // World-writable permissions
|
|
];
|
|
for (const pattern of dangerousPatterns) {
|
|
if (pattern.test(command)) {
|
|
return { valid: false, reason: `Dangerous pattern detected: ${pattern.source}` };
|
|
}
|
|
}
|
|
// Check command length
|
|
if (command.length > 10000) {
|
|
return { valid: false, reason: 'Command too long (max 10000 chars)' };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
/**
|
|
* Sanitize user input for logging
|
|
*/
|
|
export function sanitizeForLog(input, maxLength = 1000) {
|
|
if (input === null || input === undefined) {
|
|
return 'null';
|
|
}
|
|
let str = typeof input === 'string' ? input : JSON.stringify(input);
|
|
// Truncate
|
|
if (str.length > maxLength) {
|
|
str = str.substring(0, maxLength) + '...[truncated]';
|
|
}
|
|
// Remove potential secrets
|
|
str = redactSecrets(str);
|
|
return str;
|
|
}
|
|
// =============================================================================
|
|
// Secret Detection
|
|
// =============================================================================
|
|
/**
|
|
* Patterns that indicate potential secrets
|
|
*/
|
|
const SECRET_PATTERNS = [
|
|
// API Keys
|
|
{ pattern: /sk-[a-zA-Z0-9]{20,}/g, replacement: 'sk-***REDACTED***' },
|
|
{ pattern: /api[_-]?key['":\s]*[a-zA-Z0-9]{20,}/gi, replacement: 'api_key=***REDACTED***' },
|
|
{ pattern: /bearer\s+[a-zA-Z0-9._-]{20,}/gi, replacement: 'bearer ***REDACTED***' },
|
|
// Anthropic/OpenAI
|
|
{ pattern: /sk-ant-[a-zA-Z0-9-]{20,}/g, replacement: 'sk-ant-***REDACTED***' },
|
|
{ pattern: /sk-proj-[a-zA-Z0-9-]{20,}/g, replacement: 'sk-proj-***REDACTED***' },
|
|
// AWS
|
|
{ pattern: /AKIA[0-9A-Z]{16}/g, replacement: 'AKIA***REDACTED***' },
|
|
{ pattern: /aws[_-]?secret['":\s]*[a-zA-Z0-9/+=]{40}/gi, replacement: 'aws_secret=***REDACTED***' },
|
|
// GitHub
|
|
{ pattern: /ghp_[a-zA-Z0-9]{36}/g, replacement: 'ghp_***REDACTED***' },
|
|
{ pattern: /github_pat_[a-zA-Z0-9_]{22,}/g, replacement: 'github_pat_***REDACTED***' },
|
|
// Generic tokens/passwords
|
|
{ pattern: /password['":\s]*[^\s'"]{8,}/gi, replacement: 'password=***REDACTED***' },
|
|
{ pattern: /token['":\s]*[a-zA-Z0-9._-]{20,}/gi, replacement: 'token=***REDACTED***' },
|
|
{ pattern: /secret['":\s]*[a-zA-Z0-9._-]{20,}/gi, replacement: 'secret=***REDACTED***' },
|
|
// Private keys
|
|
{ pattern: /-----BEGIN [A-Z]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z]+ PRIVATE KEY-----/g, replacement: '***PRIVATE_KEY_REDACTED***' },
|
|
// Database URLs
|
|
{ pattern: /postgres:\/\/[^@]+@[^\s]+/gi, replacement: 'postgres://***REDACTED***' },
|
|
{ pattern: /mysql:\/\/[^@]+@[^\s]+/gi, replacement: 'mysql://***REDACTED***' },
|
|
{ pattern: /mongodb(\+srv)?:\/\/[^@]+@[^\s]+/gi, replacement: 'mongodb://***REDACTED***' },
|
|
];
|
|
/**
|
|
* Redact secrets from a string
|
|
*/
|
|
export function redactSecrets(text) {
|
|
let result = text;
|
|
for (const { pattern, replacement } of SECRET_PATTERNS) {
|
|
result = result.replace(pattern, replacement);
|
|
}
|
|
return result;
|
|
}
|
|
/**
|
|
* Check if text contains potential secrets
|
|
*/
|
|
export function containsSecrets(text) {
|
|
for (const { pattern } of SECRET_PATTERNS) {
|
|
if (pattern.test(text)) {
|
|
// Reset lastIndex for global patterns
|
|
pattern.lastIndex = 0;
|
|
return true;
|
|
}
|
|
pattern.lastIndex = 0;
|
|
}
|
|
return false;
|
|
}
|
|
const rateLimitStore = new Map();
|
|
/**
|
|
* Check rate limit
|
|
*/
|
|
export function checkRateLimit(key, config) {
|
|
const now = Date.now();
|
|
const entry = rateLimitStore.get(key);
|
|
if (!entry || now > entry.resetTime) {
|
|
// New window
|
|
rateLimitStore.set(key, {
|
|
count: 1,
|
|
resetTime: now + config.windowMs
|
|
});
|
|
return { allowed: true, remaining: config.maxRequests - 1, resetIn: config.windowMs };
|
|
}
|
|
if (entry.count >= config.maxRequests) {
|
|
return { allowed: false, remaining: 0, resetIn: entry.resetTime - now };
|
|
}
|
|
entry.count++;
|
|
return { allowed: true, remaining: config.maxRequests - entry.count, resetIn: entry.resetTime - now };
|
|
}
|
|
/**
|
|
* Create a rate limiter function
|
|
*/
|
|
export function createRateLimiter(config) {
|
|
return (key) => checkRateLimit(key, config).allowed;
|
|
}
|
|
// Audit log path
|
|
const AUDIT_LOG_DIR = join(homedir(), '.agentic-flow', 'audit');
|
|
const AUDIT_LOG_FILE = join(AUDIT_LOG_DIR, 'security.log');
|
|
/**
|
|
* Write audit log entry
|
|
*/
|
|
export function auditLog(entry) {
|
|
const fullEntry = {
|
|
...entry,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
// Log to application logger
|
|
logger.info('Security audit', fullEntry);
|
|
// Write to audit file
|
|
try {
|
|
if (!existsSync(AUDIT_LOG_DIR)) {
|
|
mkdirSync(AUDIT_LOG_DIR, { recursive: true });
|
|
}
|
|
appendFileSync(AUDIT_LOG_FILE, JSON.stringify(fullEntry) + '\n');
|
|
}
|
|
catch (error) {
|
|
logger.warn('Failed to write audit log', { error: error.message });
|
|
}
|
|
}
|
|
/**
|
|
* Audit tool usage
|
|
*/
|
|
export function auditToolUsage(toolName, input, outcome, sessionId) {
|
|
auditLog({
|
|
event: 'tool_usage',
|
|
actor: 'agent',
|
|
resource: toolName,
|
|
action: 'execute',
|
|
outcome,
|
|
details: { input: sanitizeForLog(input, 500) },
|
|
sessionId
|
|
});
|
|
}
|
|
/**
|
|
* Audit permission decision
|
|
*/
|
|
export function auditPermissionDecision(toolName, decision, reason, sessionId) {
|
|
auditLog({
|
|
event: 'permission_decision',
|
|
actor: 'permission_handler',
|
|
resource: toolName,
|
|
action: decision,
|
|
outcome: decision === 'allow' ? 'success' : 'blocked',
|
|
details: { reason },
|
|
sessionId
|
|
});
|
|
}
|
|
/**
|
|
* Default security context
|
|
*/
|
|
export function getDefaultSecurityContext() {
|
|
return {
|
|
sessionId: randomBytes(16).toString('hex'),
|
|
allowedPaths: [
|
|
process.cwd(),
|
|
'/tmp',
|
|
join(homedir(), '.agentic-flow')
|
|
],
|
|
allowedCommands: [
|
|
'ls', 'cat', 'head', 'tail', 'grep', 'find', 'wc',
|
|
'git', 'npm', 'node', 'python', 'python3',
|
|
'echo', 'pwd', 'date', 'which', 'env'
|
|
],
|
|
blockedPatterns: [
|
|
/rm\s+-rf\s+[\/~]/,
|
|
/chmod\s+777/,
|
|
/curl.*\|\s*bash/,
|
|
/wget.*\|\s*sh/,
|
|
/>\s*\/etc\//
|
|
],
|
|
rateLimit: {
|
|
maxRequests: 100,
|
|
windowMs: 60000 // 1 minute
|
|
},
|
|
auditEnabled: true
|
|
};
|
|
}
|
|
/**
|
|
* Validate operation against security context
|
|
*/
|
|
export function validateOperation(operation, target, context) {
|
|
// Check rate limit
|
|
const rateCheck = checkRateLimit(`${context.sessionId}:${operation}`, context.rateLimit);
|
|
if (!rateCheck.allowed) {
|
|
return { allowed: false, reason: `Rate limit exceeded. Reset in ${Math.ceil(rateCheck.resetIn / 1000)}s` };
|
|
}
|
|
// Check path for read/write
|
|
if (operation === 'read' || operation === 'write') {
|
|
const sanitized = sanitizePath(target, context.allowedPaths);
|
|
if (!sanitized) {
|
|
return { allowed: false, reason: 'Path not allowed or invalid' };
|
|
}
|
|
}
|
|
// Check command for execute
|
|
if (operation === 'execute') {
|
|
const validation = validateCommand(target);
|
|
if (!validation.valid) {
|
|
return { allowed: false, reason: validation.reason };
|
|
}
|
|
// Check against blocked patterns
|
|
for (const pattern of context.blockedPatterns) {
|
|
if (pattern.test(target)) {
|
|
return { allowed: false, reason: `Blocked pattern: ${pattern.source}` };
|
|
}
|
|
}
|
|
}
|
|
// Audit if enabled
|
|
if (context.auditEnabled) {
|
|
auditLog({
|
|
event: 'operation_validated',
|
|
actor: 'security_context',
|
|
resource: target,
|
|
action: operation,
|
|
outcome: 'success',
|
|
sessionId: context.sessionId
|
|
});
|
|
}
|
|
return { allowed: true };
|
|
}
|
|
// =============================================================================
|
|
// Secure Hash
|
|
// =============================================================================
|
|
/**
|
|
* Create secure hash of content
|
|
*/
|
|
export function secureHash(content, algorithm = 'sha256') {
|
|
return createHash(algorithm).update(content).digest('hex');
|
|
}
|
|
/**
|
|
* Generate secure random token
|
|
*/
|
|
export function generateSecureToken(length = 32) {
|
|
return randomBytes(length).toString('hex');
|
|
}
|
|
// =============================================================================
|
|
// Exports
|
|
// =============================================================================
|
|
export { SECRET_PATTERNS, AUDIT_LOG_DIR, AUDIT_LOG_FILE };
|
|
//# sourceMappingURL=security.js.map
|