import { normalizeOsFamily } from '../utils/osFamily.js'; import { db } from '../db.js'; const WINDOWS_EXECUTABLES = [ 'cmd.exe', 'powershell.exe', 'wmic.exe', 'msiexec.exe', 'reg.exe', 'sc.exe', 'net.exe', 'netsh.exe', 'schtasks.exe', 'whoami.exe', 'gpupdate.exe' ]; const ALIASES_TO_AVOID = { '%': 'ForEach-Object', '?': 'Where-Object', cat: 'Get-Content', cd: 'Set-Location', cls: 'Clear-Host', copy: 'Copy-Item', cp: 'Copy-Item', del: 'Remove-Item', dir: 'Get-ChildItem', echo: 'Write-Output', erase: 'Remove-Item', h: 'Get-History', history: 'Get-History', kill: 'Stop-Process', ls: 'Get-ChildItem', md: 'mkdir', move: 'Move-Item', mv: 'Move-Item', popd: 'Pop-Location', ps: 'Get-Process', pushd: 'Push-Location', pwd: 'Get-Location', r: 'Invoke-History', rd: 'Remove-Item', ren: 'Rename-Item', rm: 'Remove-Item', rmdir: 'Remove-Item', sl: 'Set-Location', sort: 'Sort-Object', type: 'Get-Content', where: 'Where-Object' }; const RULES = [ { id: 'windows-registry-provider', level: 'warning', title: 'Windows registry provider usage', pattern: /\b(?:HKLM:|HKCU:|HKCR:|HKU:|Registry::|SOFTWARE\\|SYSTEM\\CurrentControlSet\\)/i, message: 'The script references Windows registry providers or registry paths, which are not available on Linux targets.', remediation: 'Guard registry logic with $IsWindows or split the script by OS target.' }, { id: 'windows-drive-or-unc-path', level: 'warning', title: 'Windows filesystem path usage', pattern: /(?:\b[A-Za-z]:\\|\\\\[A-Za-z0-9_.-]+\\)/, message: 'The script contains Windows drive or UNC paths. Linux PowerShell uses Linux paths and case-sensitive filesystems.', remediation: 'Use Join-Path and OS-aware path roots, or branch path logic with $IsWindows/$IsLinux.' }, { id: 'windows-environment-variable', level: 'warning', title: 'Windows environment variable usage', pattern: /\$env:(?:ProgramFiles|ProgramFiles\(x86\)|SystemRoot|WINDIR|COMPUTERNAME|USERPROFILE|APPDATA|LOCALAPPDATA)\b/i, message: 'The script references Windows-specific environment variables that are usually absent on Linux.', remediation: 'Use cross-platform variables such as $HOME where possible, or provide Linux-specific fallbacks.' }, { id: 'wmi-or-win32-class', level: 'warning', title: 'WMI or Win32 class usage', pattern: /\b(?:Get-WmiObject|gwmi|Win32_[A-Za-z0-9_]+|root\\cimv2)\b/i, message: 'The script uses WMI/Win32 inventory patterns that are Windows-specific and commonly fail on Linux.', remediation: 'Use cross-platform PowerShell/.NET APIs, SSH-native commands, or OS-specific branches.' }, { id: 'windows-service-or-eventlog-cmdlet', level: 'warning', title: 'Windows service or event log cmdlet usage', pattern: /\b(?:Get-EventLog|New-EventLog|Write-EventLog|Get-WinEvent|Get-Service|Set-Service|Restart-Service)\b/i, message: 'This cmdlet often targets Windows service or event-log subsystems and may not behave the same on Linux hosts.', remediation: 'Validate Linux behavior explicitly or wrap the command in an OS-specific branch.' }, { id: 'windows-executable', level: 'warning', title: 'Windows executable usage', pattern: new RegExp(`\\b(?:${WINDOWS_EXECUTABLES.map(escapeRegExp).join('|')})\\b`, 'i'), message: 'The script launches Windows executables that are not expected to exist on Linux targets.', remediation: 'Replace with PowerShell-native commands, Linux equivalents, or target Windows hosts only.' } ]; export function analyzePowerShellCompatibility(content, targets = []) { const targetOsFamilies = [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family)).filter(Boolean))]; const hasLinuxTargets = targetOsFamilies.includes('linux'); const findings = []; if (!hasLinuxTargets) { return { targetOsFamilies, hasLinuxTargets, findings, summary: 'No Linux targets selected.' }; } const text = String(content || ''); const lines = text.split(/\r?\n/); for (const rule of RULES) { const match = findRuleMatch(lines, rule.pattern); if (match) findings.push(toFinding(rule, match)); } for (const match of findAliasMatches(lines).slice(0, 6)) { findings.push({ id: `alias-${match.alias}`, level: 'info', title: `Alias "${match.alias}" used`, message: `Alias "${match.alias}" maps to ${match.command}. Aliases can be profile or host dependent, so they are risky in scripts that run across Windows and Linux.`, remediation: `Use the full command name ${match.command}.`, line: match.line, excerpt: match.excerpt, source: 'Microsoft PowerShell alias guidance' }); } if (!findings.length) { findings.push({ id: 'linux-ready', level: 'ok', title: 'No obvious Linux blockers detected', message: 'Static analysis did not find common Windows-only patterns. Runtime behavior still depends on installed modules and host configuration.', remediation: 'Run a controlled test against a Linux host before broad execution.', line: null, excerpt: '', source: 'POSHManager compatibility analyzer' }); } return { targetOsFamilies, hasLinuxTargets, findings, summary: `${findings.filter((finding) => finding.level === 'warning').length} warning(s), ${findings.filter((finding) => finding.level === 'info').length} note(s) for Linux targets.` }; } export function analyzeScriptTargetCompatibility(scriptId, payload, userId) { const script = db.prepare(` SELECT * FROM scripts WHERE id = ? AND ( visibility = 'shared' OR owner_user_id = ? OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) ) `).get(scriptId, userId, userId); if (!script) return null; const targets = resolveVisibleCompatibilityTargets(payload, userId); return { scriptId: script.id, scriptName: script.name, targets: summarizeTargets(targets), ...analyzePowerShellCompatibility(script.content, targets) }; } export function analyzeRunPlanCompatibility(runplanId, userId) { const runplan = db.prepare(` SELECT r.*, s.content AS script_content, s.name AS script_name FROM runplans r JOIN scripts s ON s.id = r.script_id WHERE r.id = ? AND ( r.visibility = 'shared' OR r.owner_user_id = ? OR r.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) ) `).get(runplanId, userId, userId); if (!runplan) return null; const targets = resolveRunPlanTargets(runplanId); return { runplanId, scriptId: runplan.script_id, scriptName: runplan.script_name, targets: summarizeTargets(targets), ...analyzePowerShellCompatibility(runplan.script_content, targets) }; } export function compatibilityLogLines(analysis) { if (!analysis?.hasLinuxTargets) return []; return [ `Linux target compatibility preflight: ${analysis.summary}`, ...analysis.findings .filter((finding) => finding.level !== 'ok') .slice(0, 10) .map((finding) => `${finding.level.toUpperCase()}: ${finding.title}${finding.line ? ` on line ${finding.line}` : ''}. ${finding.message} ${finding.remediation}`) ]; } function findRuleMatch(lines, pattern) { for (const [index, line] of lines.entries()) { if (pattern.test(line)) return { line: index + 1, excerpt: line.trim().slice(0, 260) }; } return null; } function toFinding(rule, match) { return { id: rule.id, level: rule.level, title: rule.title, message: rule.message, remediation: rule.remediation, line: match.line, excerpt: match.excerpt, source: 'PowerShell cross-platform compatibility' }; } function findAliasMatches(lines) { const matches = []; const aliases = Object.keys(ALIASES_TO_AVOID).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|'); const commandPattern = new RegExp(`^\\s*(?:&\\s*)?(${aliases})(?:\\s|$|\\|)`, 'i'); const pipelinePattern = new RegExp(`\\|\\s*(${aliases})(?:\\s|$|\\|)`, 'i'); for (const [index, line] of lines.entries()) { const stripped = line.replace(/#.*/, ''); const match = stripped.match(commandPattern) || stripped.match(pipelinePattern); if (!match) continue; const alias = match[1].toLowerCase(); matches.push({ alias, command: ALIASES_TO_AVOID[alias], line: index + 1, excerpt: line.trim().slice(0, 260) }); } return matches; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function resolveVisibleCompatibilityTargets(payload = {}, userId) { const hostIds = [...new Set([payload.hostId, ...(payload.hostIds || [])].filter(Boolean))]; const groupIds = [...new Set([payload.hostGroupId, ...(payload.hostGroupIds || [])].filter(Boolean))]; const targets = []; if (hostIds.length) { targets.push(...db.prepare(` SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport FROM hosts h WHERE h.id IN (${hostIds.map(() => '?').join(',')}) AND ( h.visibility = 'shared' OR h.owner_user_id = ? OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) ) `).all(...hostIds, userId, userId)); } if (groupIds.length) { targets.push(...db.prepare(` SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport FROM host_group_members hgm JOIN host_groups hg ON hg.id = hgm.host_group_id AND ( hg.visibility = 'shared' OR hg.owner_user_id = ? OR hg.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) ) JOIN hosts h ON h.id = hgm.host_id AND ( h.visibility = 'shared' OR h.owner_user_id = ? OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?) ) WHERE hgm.host_group_id IN (${groupIds.map(() => '?').join(',')}) `).all(userId, userId, userId, userId, ...groupIds)); } return dedupeTargets(targets); } function resolveRunPlanTargets(runplanId) { return dedupeTargets(db.prepare(` SELECT h.id, h.name, h.address, h.os_family AS osFamily, h.transport FROM hosts h WHERE h.id IN ( SELECT host_id FROM runplan_hosts WHERE runplan_id = ? UNION SELECT hgm.host_id FROM runplan_host_groups rhg JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id WHERE rhg.runplan_id = ? ) ORDER BY h.name `).all(runplanId, runplanId)); } function dedupeTargets(targets = []) { const seen = new Set(); return targets.filter((target) => { if (!target?.id || seen.has(target.id)) return false; seen.add(target.id); target.osFamily = normalizeOsFamily(target.osFamily || target.os_family, 'other'); return true; }); } function summarizeTargets(targets = []) { return { count: targets.length, osFamilies: [...new Set(targets.map((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other')))], linuxCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'linux').length, windowsCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'windows').length, otherCount: targets.filter((target) => normalizeOsFamily(target.osFamily || target.os_family, 'other') === 'other').length, sample: targets.slice(0, 8).map((target) => ({ id: target.id, name: target.name, address: target.address, osFamily: normalizeOsFamily(target.osFamily || target.os_family, 'other'), transport: target.transport })) }; }