Files
utools/backend/utils.js
2026-03-05 19:32:01 +01:00

353 lines
14 KiB
JavaScript

// backend/utils.js
const net = require('net'); // Node.js built-in module for IP validation
const { spawn } = require('child_process');
const pino = require('pino'); // Import pino for logging within utils if needed
const Sentry = require("@sentry/node"); // Import Sentry for error reporting
// Logger instance (assuming a logger is initialized elsewhere and passed or created here)
// For simplicity, creating a basic logger here. Ideally, pass the main logger instance.
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
/**
* Validiert eine IP-Adresse (v4 oder v6) mit Node.js' eingebautem net Modul.
* @param {string} ip - Die zu validierende IP-Adresse.
* @returns {boolean} True, wenn gültig (als v4 oder v6), sonst false.
*/
function isValidIp(ip) {
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
return false;
}
const trimmedIp = ip.trim();
const ipVersion = net.isIP(trimmedIp); // Gibt 0, 4 oder 6 zurück
return ipVersion === 4 || ipVersion === 6;
}
/**
* Prüft, ob eine IP-Adresse im privaten, Loopback- oder Link-Local-Bereich liegt.
* @param {string} ip - Die zu prüfende IP-Adresse (bereits validiert).
* @returns {boolean} True, wenn die IP privat/lokal ist, sonst false.
*/
function isPrivateIp(ip) {
if (!ip) return false;
// Normalize IPv6-mapped IPv4 addresses (e.g., ::ffff:192.168.1.1 -> 192.168.1.1)
if (ip.startsWith('::ffff:')) {
ip = ip.substring(7);
}
const ipVersion = net.isIP(ip);
if (ipVersion === 4) {
const parts = ip.split('.').map(Number);
return (
parts[0] === 10 || // 10.0.0.0/8
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
(parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16
parts[0] === 127 || // 127.0.0.0/8 (Loopback)
(parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 (Link-local)
// Block 0.0.0.0 (Commonly "Any" or "Current Network")
(parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0)
);
} else if (ipVersion === 6) {
const lowerCaseIp = ip.toLowerCase();
return (
lowerCaseIp === '::1' || // ::1/128 (Loopback)
lowerCaseIp === '::' || // ::/128 (Unspecified)
lowerCaseIp.startsWith('fc') || lowerCaseIp.startsWith('fd') || // fc00::/7 (Unique Local)
lowerCaseIp.startsWith('fe8') || lowerCaseIp.startsWith('fe9') || // fe80::/10 (Link-local)
lowerCaseIp.startsWith('fea') || lowerCaseIp.startsWith('feb')
);
}
return false;
}
/**
* Validiert einen Domainnamen (sehr einfache Prüfung).
* @param {string} domain - Der zu validierende Domainname.
* @returns {boolean} True, wenn wahrscheinlich gültig, sonst false.
*/
function isValidDomain(domain) {
if (!domain || typeof domain !== 'string' || domain.trim().length < 3) {
return false;
}
// Regex updated to be more robust and handle international characters (IDNs)
const domainRegex = /^(?:[a-z0-9\p{L}](?:[a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}])?\.)+[a-z0-9\p{L}][a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}]$/iu;
return domainRegex.test(domain.trim());
}
/**
* Validiert eine MAC-Adresse.
* @param {string} mac - Die zu validierende MAC-Adresse.
* @returns {boolean} True, wenn das Format gültig ist, sonst false.
*/
function isValidMacAddress(mac) {
if (!mac || typeof mac !== 'string') {
return false;
}
// This regex matches common MAC address formats (e.g., 00:1A:2B:3C:4D:5E, 00-1A-2B-3C-4D-5E, 001A2B3C4D5E)
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/;
return macRegex.test(mac.trim());
}
/**
* Bereinigt eine IP-Adresse (z.B. entfernt ::ffff: Präfix von IPv4-mapped IPv6).
* @param {string} ip - Die IP-Adresse.
* @returns {string} Die bereinigte IP-Adresse.
*/
function getCleanIp(ip) {
if (!ip) return ip;
const trimmedIp = ip.trim();
if (trimmedIp.startsWith('::ffff:')) {
const potentialIp4 = trimmedIp.substring(7);
if (net.isIP(potentialIp4) === 4) {
return potentialIp4;
}
}
// Keep localhost IPs as they are
if (trimmedIp === '::1' || trimmedIp === '127.0.0.1') {
return trimmedIp;
}
// Return trimmed IP for other cases
return trimmedIp;
}
/**
* Führt einen Shell-Befehl sicher aus und gibt stdout zurück. (Nur für Ping verwendet)
* @param {string} command - Der Befehl (z.B. 'ping').
* @param {string[]} args - Die Argumente als Array.
* @returns {Promise<string>} Eine Promise, die mit stdout aufgelöst wird.
*/
function executeCommand(command, args) {
return new Promise((resolve, reject) => {
// Basic argument validation
args.forEach(arg => {
if (typeof arg === 'string' && /[;&|`$()<>]/.test(arg)) {
const error = new Error(`Invalid character detected in command argument.`);
logger.error({ command, arg }, "Potential command injection attempt detected in argument");
Sentry.captureException(error); // Send to Sentry
return reject(error);
}
});
const proc = spawn(command, args);
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.on('error', (err) => {
const error = new Error(`Failed to start command ${command}: ${err.message}`);
logger.error({ command, args, error: err.message }, `Failed to start command`);
Sentry.captureException(error); // Send to Sentry
reject(error);
});
proc.on('close', (code) => {
if (code !== 0) {
const error = new Error(`Command ${command} failed with code ${code}: ${stderr || 'No stderr output'}`);
// Attach stdout/stderr to the error object for better context in rejection
error.stdout = stdout;
error.stderr = stderr;
logger.error({ command, args, exitCode: code, stderr: stderr.trim(), stdout: stdout.trim() }, `Command failed`);
Sentry.captureException(error, { extra: { stdout: stdout.trim(), stderr: stderr.trim() } }); // Send to Sentry
reject(error);
} else {
resolve(stdout);
}
});
});
}
/**
* Parst die Ausgabe des Linux/macOS ping Befehls.
* @param {string} pingOutput - Die rohe stdout Ausgabe von ping.
* @returns {object} Ein Objekt mit geparsten Daten oder Fehlern.
*/
function parsePingOutput(pingOutput) {
const result = {
rawOutput: pingOutput,
stats: null,
error: null,
};
try {
let packetsTransmitted = 0;
let packetsReceived = 0;
let packetLossPercent = 100;
let rtt = { min: null, avg: null, max: null, mdev: null };
const lines = pingOutput.trim().split('\n');
const statsLine = lines.find(line => line.includes('packets transmitted'));
if (statsLine) {
const transmittedMatch = statsLine.match(/(\d+)\s+packets transmitted/);
const receivedMatch = statsLine.match(/(\d+)\s+(?:received|packets received)/);
const lossMatch = statsLine.match(/([\d.]+)%\s+packet loss/);
if (transmittedMatch) packetsTransmitted = parseInt(transmittedMatch[1], 10);
if (receivedMatch) packetsReceived = parseInt(receivedMatch[1], 10);
if (lossMatch) packetLossPercent = parseFloat(lossMatch[1]);
}
// Handle both 'rtt' and 'round-trip' prefixes for broader compatibility
const rttLine = lines.find(line => line.startsWith('rtt min/avg/max/mdev') || line.startsWith('round-trip min/avg/max/stddev'));
if (rttLine) {
const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/);
if (rttMatch) {
rtt = {
min: parseFloat(rttMatch[1]),
avg: parseFloat(rttMatch[2]),
max: parseFloat(rttMatch[3]),
mdev: parseFloat(rttMatch[4]), // Note: mdev/stddev might have different meanings
};
}
}
result.stats = {
packets: { transmitted: packetsTransmitted, received: packetsReceived, lossPercent: packetLossPercent },
rtt: rtt.avg !== null ? rtt : null, // Only include RTT if average is available
};
// Check for common error messages or patterns
if (packetsTransmitted > 0 && packetsReceived === 0) {
result.error = "Request timed out or host unreachable.";
} else if (pingOutput.includes('unknown host') || pingOutput.includes('Name or service not known')) {
result.error = "Unknown host.";
}
} catch (parseError) {
logger.error({ error: parseError.message, output: pingOutput }, "Failed to parse ping output");
Sentry.captureException(parseError, { extra: { pingOutput } }); // Send to Sentry
result.error = "Failed to parse ping output.";
}
return result;
}
/**
* Parst eine einzelne Zeile der Linux/macOS traceroute Ausgabe.
* @param {string} line - Eine Zeile aus stdout.
* @returns {object | null} Ein Objekt mit Hop-Daten oder null bei uninteressanten Zeilen.
*/
function parseTracerouteLine(line) {
line = line.trim();
// Ignore header lines and empty lines
if (!line || line.startsWith('traceroute to') || line.includes('hops max')) return null;
// Regex to capture hop number, hostname (optional), IP address, and RTT times
// Handles cases with or without hostname, and different spacing
const hopMatch = line.match(/^(\s*\d+)\s+(?:([a-zA-Z0-9\.\-]+)\s+\(([\d\.:a-fA-F]+)\)|([\d\.:a-fA-F]+))\s+(.*)$/);
const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); // Match lines with only timeouts
if (timeoutMatch) {
// Handle timeout line
return {
hop: parseInt(timeoutMatch[1].trim(), 10),
hostname: null,
ip: null,
rtt: ['*', '*', '*'], // Represent timeouts as '*'
rawLine: line,
};
} else if (hopMatch) {
// Handle successful hop line
const hop = parseInt(hopMatch[1].trim(), 10);
const hostname = hopMatch[2]; // Hostname if present
const ipInParen = hopMatch[3]; // IP if hostname is present
const ipDirect = hopMatch[4]; // IP if hostname is not present
const restOfLine = hopMatch[5].trim();
const ip = ipInParen || ipDirect; // Determine the correct IP
// Extract RTT times, handling '*' for timeouts and removing ' ms' units
const rttParts = restOfLine.split(/\s+/);
const rtts = rttParts
.map(p => p === '*' ? '*' : p.replace(/\s*ms$/, '')) // Keep '*' or remove ' ms'
.filter(p => p === '*' || !isNaN(parseFloat(p))) // Ensure it's '*' or a number
.slice(0, 3); // Take the first 3 valid RTT values
// Pad with '*' if fewer than 3 RTTs were found (e.g., due to timeouts)
while (rtts.length < 3) rtts.push('*');
return {
hop: hop,
hostname: hostname || null, // Use null if hostname wasn't captured
ip: ip,
rtt: rtts,
rawLine: line,
};
}
// Return null if the line doesn't match expected formats
return null;
}
/**
* Checks if a specific TCP port is open on a given host.
* @param {number} port - The port to check.
* @param {string} host - The target host IP address.
* @param {number} timeout - Connection timeout in milliseconds.
* @returns {Promise<{port: number, status: 'open'|'closed'|'timeout', service: string}>} A promise that resolves with the port status.
*/
function checkPort(port, host, timeout = 2000) {
// A small map of common ports to their services
const commonPorts = {
21: 'FTP', 22: 'SSH', 23: 'Telnet', 25: 'SMTP', 53: 'DNS', 80: 'HTTP',
110: 'POP3', 143: 'IMAP', 443: 'HTTPS', 445: 'SMB', 993: 'IMAPS',
995: 'POP3S', 1433: 'MSSQL', 1521: 'Oracle', 3306: 'MySQL', 3389: 'RDP',
5432: 'PostgreSQL', 5900: 'VNC', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt'
};
const service = commonPorts[port] || 'Unknown';
return new Promise((resolve, reject) => {
// DEFENSE IN DEPTH: Prevent scanning of private IPs at the function level
if (!isValidIp(host) || isPrivateIp(host)) {
const error = new Error(`Scanning restricted: ${host} is not a valid public IP.`);
logger.warn({ host, port }, "Blocked attempt to scan restricted IP in checkPort");
return resolve({
port,
status: 'error',
service,
error: 'Restricted IP',
details: 'Scanning private or invalid IPs is not allowed.'
});
}
const socket = new net.Socket();
socket.setTimeout(timeout);
socket.on('connect', () => {
socket.destroy();
resolve({ port, status: 'open', service });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ port, status: 'timeout', service });
});
socket.on('error', (err) => {
socket.destroy();
// 'ECONNREFUSED' is the key for a closed port. Other errors might be network issues.
const status = err.code === 'ECONNREFUSED' ? 'closed' : 'error';
resolve({ port, status, service, error: err.code });
});
// Explicit inline guard (defence-in-depth; also satisfies CodeQL SSRF dataflow)
if (!isValidIp(host) || isPrivateIp(host)) {
socket.destroy();
return resolve({ port, status: 'error', service, error: 'Restricted IP' });
}
socket.connect(port, host);
});
}
module.exports = {
isValidIp,
isPrivateIp,
isValidDomain,
isValidMacAddress,
getCleanIp,
executeCommand,
parsePingOutput,
parseTracerouteLine,
checkPort,
};