Project Files
src / securityEnhanced.ts
import * as child_process from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
// --- Command sanitisation ---
export function sanitizeCommand(command: string): { valid: boolean; sanitized?: string; error?: string } {
if (!command || typeof command !== "string") {
return { valid: false, error: "Command must be a non-empty string" };
}
const trimmed = command.trim();
if (trimmed.length === 0) {
return { valid: false, error: "Command cannot be empty or whitespace-only" };
}
// Dangerous patterns — these block the entire command
const dangerousPatterns = [
/\$\((.*?)\)/, // Bash command substitution $(...)
/`([^`]+)`/, // Backtick command substitution
/;\s*(rm|del|format)\s+/i,
/\|\s*(rm|del|format)\s+/i,
/&&\s*(rm|del|format)\s+/i,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(trimmed)) {
return { valid: false, error: "Command contains potentially dangerous patterns that are blocked for security" };
}
}
// Suspicious patterns — these also block
const suspiciousPatterns = [
/eval\s+/i,
/exec\s+[^`]/i,
/source\s+/i,
/\.\/\.\.\//,
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(trimmed)) {
return { valid: false, error: "Command contains potentially risky patterns" };
}
}
return { valid: true, sanitized: trimmed };
}
// --- Path safety ---
const SAFE_BASE_DIRS = [os.homedir(), process.cwd(), os.tmpdir()];
function isPathSafe(absolutePath: string): boolean {
const normalizedPath = path.resolve(absolutePath).toLowerCase();
for (const safeDir of SAFE_BASE_DIRS) {
const normalizedSafeDir = path.resolve(safeDir).toLowerCase() + path.sep;
if (normalizedPath === path.resolve(safeDir).toLowerCase() || normalizedPath.startsWith(normalizedSafeDir)) {
return true;
}
}
return false;
}
// --- Execution parameter validation ---
export interface ExecutionParams {
timeoutMs?: number;
maxOutputBytes?: number;
cwd?: string;
env?: Record<string, string>;
stdin?: string;
}
export function validateExecutionParams(params: ExecutionParams): { valid: boolean; validated?: ExecutionParams; error?: string } {
if (params.timeoutMs !== undefined) {
const num = Number(params.timeoutMs);
if (!Number.isInteger(num) || num < 100 || num > 300000) {
return { valid: false, error: "timeout_ms must be an integer between 100 and 300000 (ms)" };
}
}
if (params.maxOutputBytes !== undefined) {
const num = Number(params.maxOutputBytes);
if (!Number.isInteger(num) || num < 1024 || num > 104857600) {
return { valid: false, error: "max_output_bytes must be an integer between 1024 (1KB) and 104857600 (100MB)" };
}
}
if (params.cwd !== undefined && typeof params.cwd === "string") {
try {
params.cwd.replace(/^~(?=[/\\]|$)/, os.homedir());
} catch {
return { valid: false, error: "Invalid cwd path" };
}
}
if (params.stdin !== undefined) {
if (typeof params.stdin !== "string") {
return { valid: false, error: "stdin must be a string" };
}
if (params.stdin.length > 1_048_576) {
return { valid: false, error: `stdin exceeds maximum size of 1048576 bytes` };
}
}
if (params.env !== undefined) {
for (const [key, value] of Object.entries(params.env)) {
if (typeof key !== "string" || typeof value !== "string") {
return { valid: false, error: "env values must be string-to-string mappings" };
}
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
return { valid: false, error: `Invalid environment variable name: ${key}` };
}
}
}
return { valid: true, validated: params };
}
// --- File path sanitisation ---
export function sanitizeFilePath(filePath: string): { valid: boolean; sanitized?: string; error?: string } {
if (!filePath || typeof filePath !== "string") {
return { valid: false, error: "File path must be a non-empty string" };
}
const expanded = expandPath(filePath);
let resolvedPath: string;
try {
resolvedPath = path.resolve(expanded);
} catch {
return { valid: false, error: "Invalid file path - unable to resolve" };
}
const normalized = resolvedPath.replace(/\\/g, "/").toLowerCase();
if (!isPathSafe(resolvedPath)) {
return { valid: false, error: "File access outside of allowed directories is not permitted" };
}
if (process.platform === "win32") {
const dangerousPrefixes = [
"c:/windows",
"c:/program files",
"c:/program files (x86)",
"c:/programdata",
"c:/recycler",
"c:/$recycle.bin",
];
for (const prefix of dangerousPrefixes) {
if (normalized.startsWith(prefix)) {
return { valid: false, error: "Writing to system directories is not allowed" };
}
}
}
const sensitiveFiles = ["/etc/passwd", "/etc/shadow", "/.ssh/id_rsa", "/.ssh/id_ed25519"];
if (sensitiveFiles.some((f) => normalized.includes(f.toLowerCase()))) {
return { valid: false, error: "Access to sensitive files is not allowed" };
}
return { valid: true, sanitized: resolvedPath };
}
// --- Path utilities ---
export function expandPath(p: string): string {
let expanded = p.trim();
if (expanded.startsWith("~")) {
expanded = expanded.replace(/^~(?=[/\\]|$)/, os.homedir());
}
expanded = expanded.replace(/%([^%]+)%/g, (_, key) => process.env[key] ?? `%${key}%`);
expanded = expanded.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] ?? `${key}`);
return expanded;
}
export function normalizePath(p: string): string {
let normalized = p.replace(/\\/g, "/");
if (normalized.length > 1 && normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
try {
return path.resolve(normalized);
} catch {
return normalized;
}
}
export function isSafeToDelete(pathStr: string): boolean {
try {
const expanded = expandPath(pathStr);
const normalized = normalizePath(expanded).toLowerCase();
const homeDir = normalizePath(os.homedir());
const currentDir = normalizePath(process.cwd());
const tempDir = normalizePath(os.tmpdir());
const safeDirs = [homeDir, currentDir, tempDir, normalizePath("~/Documents"), normalizePath("~/Downloads"), normalizePath("~/Desktop")];
return safeDirs.some((dir) => normalized === dir || normalized.startsWith(dir + "/") || normalized.startsWith(dir + "\\"));
} catch {
return false;
}
}
// --- Rate limiter ---
export interface RateLimiter {
execute<T>(fn: () => Promise<T>): Promise<T>;
getStats(): { callsInWindow: number; windowStart: number };
}
export class SimpleRateLimiter implements RateLimiter {
private windowMs: number;
private maxCallsPerWindow: number;
private calls: number[] = [];
constructor(windowMs = 60_000, maxCallsPerWindow = 100) {
this.windowMs = windowMs;
this.maxCallsPerWindow = maxCallsPerWindow;
}
execute<T>(fn: () => Promise<T>): Promise<T> {
this.cleanupOldCalls();
if (this.calls.length >= this.maxCallsPerWindow) {
return Promise.reject(new Error("Rate limit exceeded. Too many commands executed recently."));
}
this.calls.push(Date.now());
return fn();
}
private cleanupOldCalls(): void {
const cutoff = Date.now() - this.windowMs;
this.calls = this.calls.filter((t) => t > cutoff);
}
getStats(): { callsInWindow: number; windowStart: number } {
this.cleanupOldCalls();
return { callsInWindow: this.calls.length, windowStart: Date.now() - this.windowMs };
}
}
// --- Audit logger ---
export interface AuditLogEntry {
timestamp: string;
command: string;
user: string;
result?: "success" | "failure";
exitCode?: number;
durationMs?: number;
}
export class AuditLogger {
private entries: AuditLogEntry[] = [];
private maxSize: number;
constructor(maxSize = 1000) {
this.maxSize = maxSize;
}
log(entry: Omit<AuditLogEntry, "timestamp">): void {
this.entries.push({ ...entry, timestamp: new Date().toISOString() });
if (this.entries.length > this.maxSize) this.entries = this.entries.slice(-this.maxSize);
}
getRecentEntries(count = 10): AuditLogEntry[] {
return this.entries.slice(-count).reverse();
}
clear(): void {
this.entries = [];
}
get stats() {
const successCount = this.entries.filter((e) => e.result === "success").length;
return {
total: this.entries.length,
success: successCount,
failures: this.entries.length - successCount,
recentEntries: this.getRecentEntries(5),
};
}
}
// --- Shell argument escaping ---
export function shellEscape(arg: string): string {
const isWindows = process.platform === "win32";
if (/^["'].*["']$/.test(arg)) return arg;
if (isWindows) {
const escaped = arg.replace(/"/g, '""');
return `"${escaped}"`;
} else {
if (/^[a-zA-Z0-9_.\/-]+$/.test(arg)) return arg;
const escaped = arg.replace(/'/g, "'\\\\''");
return `'${escaped}'`;
}
}
// --- Global instances ---
export const auditLogger = new AuditLogger();
export const rateLimiter = new SimpleRateLimiter();