335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
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
|
|
}))
|
|
};
|
|
}
|