Initial
This commit is contained in:
247
server/services/psadtValidator.js
Normal file
247
server/services/psadtValidator.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { psadtValidationRules } from '../data/psadtValidationRules.js';
|
||||
|
||||
const ruleById = new Map(psadtValidationRules.map((rule) => [rule.id, rule]));
|
||||
const legacyFunctions = [
|
||||
'Execute-Process',
|
||||
'Execute-MSI',
|
||||
'Execute-MSP',
|
||||
'Show-InstallationWelcome',
|
||||
'Show-InstallationPrompt',
|
||||
'Show-InstallationProgress',
|
||||
'Show-InstallationRestartPrompt',
|
||||
'Write-Log',
|
||||
'Remove-MSIApplications',
|
||||
'Invoke-HKCURegistrySettingsForAllUsers'
|
||||
];
|
||||
|
||||
export function validationRuleCatalog() {
|
||||
return psadtValidationRules;
|
||||
}
|
||||
|
||||
export function validatePsadtScript(content = '', context = {}) {
|
||||
const text = String(content || '');
|
||||
const findings = [
|
||||
...checkPowerShellRequirement(text),
|
||||
...checkRequireAdminConfig(text),
|
||||
...checkRemovedDetectionConfig(text),
|
||||
...checkAppProcessesToClose(text),
|
||||
...checkSessionCleanup(text),
|
||||
...checkModuleVersionDrift(text),
|
||||
...checkDeployModeAuto(text),
|
||||
...checkLegacyFunctions(text),
|
||||
...checkLegacyDeploymentNaming(text),
|
||||
...checkLegacyDeploymentVariables(text),
|
||||
...checkEnvironmentVariables(text),
|
||||
...checkServiceUi(text),
|
||||
...checkDeferRunInterval(text, context),
|
||||
...checkPromptIcon(text),
|
||||
...checkRawMsiExec(text),
|
||||
...checkStartProcessWait(text),
|
||||
...checkExitCodes(text),
|
||||
...checkGlobalModuleImport(text),
|
||||
...checkDeprecatedObjectProperty(text),
|
||||
...checkUserContextRequireAdmin(text, context)
|
||||
].map((finding, index) => ({
|
||||
id: `${finding.ruleId}-${index + 1}`,
|
||||
...ruleById.get(finding.ruleId),
|
||||
...finding
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: findings.every((finding) => !['critical', 'error'].includes(finding.severity)),
|
||||
score: scoreFindings(findings),
|
||||
counts: countFindings(findings),
|
||||
findings,
|
||||
rules: psadtValidationRules,
|
||||
analyzedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function add(ruleId, text, match, extra = {}) {
|
||||
return {
|
||||
ruleId,
|
||||
line: lineNumber(text, match.index ?? 0),
|
||||
excerpt: lineText(text, match.index ?? 0),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function checkPowerShellRequirement(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/#requires\s+-version\s+([0-9.]+)/gi)) {
|
||||
const version = Number.parseFloat(match[1]);
|
||||
if (Number.isFinite(version) && version < 5.1) findings.push(add('REQ-PSVERSION-MINIMUM', text, match, { detected: match[0] }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRequireAdminConfig(text) {
|
||||
const findings = [];
|
||||
const configPattern = /(Toolkit\.)?RequireAdmin\s*=/gi;
|
||||
for (const match of text.matchAll(configPattern)) {
|
||||
const nearby = text.slice(Math.max(0, match.index - 80), match.index + 160);
|
||||
if (!/\$adtSession\s*=|@\{|RequireAdmin\s*=\s*\$(true|false)/i.test(nearby) || /Toolkit\.RequireAdmin/i.test(match[0])) {
|
||||
findings.push(add('UPG-REQUIREADMIN-CONFIG', text, match));
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRemovedDetectionConfig(text) {
|
||||
return [...text.matchAll(/\b(Toolkit\.)?(OOBEDetection|SessionDetection)\b/gi)]
|
||||
.map((match) => add('UPG-REMOVED-CONFIG-DETECTION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkAppProcessesToClose(text) {
|
||||
if (/AppProcessesToClose/i.test(text)) return [];
|
||||
return [...text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-CloseProcesses\s+(?!\$adtSession\.AppProcessesToClose)/gi)]
|
||||
.map((match) => add('UPG-APP-PROCESSES-SESSION', text, match));
|
||||
}
|
||||
|
||||
function checkSessionCleanup(text) {
|
||||
if (!/Open-ADTSession/i.test(text)) return [];
|
||||
if (/Remove-ADTHashtableNullOrEmptyValues/i.test(text)) return [];
|
||||
const match = /Open-ADTSession/i.exec(text);
|
||||
return match ? [add('UPG-MISSING-SESSION-CLEANUP', text, match)] : [];
|
||||
}
|
||||
|
||||
function checkLegacyFunctions(text) {
|
||||
const pattern = new RegExp(`\\b(${legacyFunctions.map(escapeRegExp).join('|')})\\b`, 'gi');
|
||||
return [...text.matchAll(pattern)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('LEGACY-V3-FUNCTION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkLegacyDeploymentNaming(text) {
|
||||
return [...text.matchAll(/\bDeploy-Application\.(ps1|exe)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('LEGACY-DEPLOY-APPLICATION', text, match));
|
||||
}
|
||||
|
||||
function checkLegacyDeploymentVariables(text) {
|
||||
return [...text.matchAll(/\$(appName|appVersion|appVendor|appArch|appLang|appRevision)\b/g)]
|
||||
.map((match) => add('LEGACY-DEPLOYMENT-VARIABLE', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkEnvironmentVariables(text) {
|
||||
if (/Open-ADTSession|Export-ADTEnvironmentTableToSessionState/i.test(text)) return [];
|
||||
return [...text.matchAll(/\$(envProgramFiles|envProgramFilesX86|envSystemRoot|envWinDir|envTemp)\b/g)]
|
||||
.map((match) => add('FAQ-ENV-VAR-BEFORE-SESSION', text, match, { detected: match[0] }));
|
||||
}
|
||||
|
||||
function checkServiceUi(text) {
|
||||
return [...text.matchAll(/\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('INTUNE-SERVICEUI', text, match));
|
||||
}
|
||||
|
||||
function checkDeferRunInterval(text, context) {
|
||||
const findings = [];
|
||||
const intuneHint = context.platform === 'intune' || /Intune|IME|Microsoft Intune/i.test(text);
|
||||
for (const match of text.matchAll(/Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/gi)) {
|
||||
if (intuneHint || /Invoke-AppDeployToolkit\.exe|DeployMode/i.test(text)) findings.push(add('INTUNE-DEFER-RUN-INTERVAL', text, match));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkPromptIcon(text) {
|
||||
return [...text.matchAll(/Show-ADTInstallationPrompt[^\n\r]*\s-Icon\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('UI-PROMPT-ICON-FLUENT', text, match));
|
||||
}
|
||||
|
||||
function checkRawMsiExec(text) {
|
||||
return [...text.matchAll(/\bmsiexec(?:\.exe)?\b[^\n\r]*(\/i|\/x|\/f|\/p)/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('INSTALL-RAW-MSIEXEC', text, match));
|
||||
}
|
||||
|
||||
function checkStartProcessWait(text) {
|
||||
return [...text.matchAll(/Start-ADTProcess[^\n\r]*-FilePath\s+['"][^'"]+\.(exe|cmd|bat)['"][^\n\r]*/gi)]
|
||||
.filter((match) => !/-WaitForChildProcesses\b/i.test(match[0]) && !/-NoWait\b/i.test(match[0]))
|
||||
.map((match) => add('INSTALL-PROCESS-NO-WAIT-CHILD', text, match));
|
||||
}
|
||||
|
||||
function checkExitCodes(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/\b(6[0-9]{4}|7[0-9]{4})\b/g)) {
|
||||
if (isCommentMatch(text, match)) continue;
|
||||
const code = Number(match[1]);
|
||||
if (code >= 60000 && code <= 68999) findings.push(add('EXIT-RESERVED-BUILTIN', text, match, { detected: String(code) }));
|
||||
if (code >= 69000 && code <= 69999) findings.push(add('EXIT-CUSTOM-RANGE', text, match, { detected: String(code) }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkModuleVersionDrift(text) {
|
||||
const findings = [];
|
||||
for (const match of text.matchAll(/ModuleVersion\s*=\s*['"]([0-9.]+)['"]/gi)) {
|
||||
if (isCommentMatch(text, match)) continue;
|
||||
if (compareVersion(match[1], '4.2.0') < 0) findings.push(add('REPO-MODULE-VERSION-DRIFT', text, match, { detected: match[1] }));
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkDeployModeAuto(text) {
|
||||
return [...text.matchAll(/-DeployMode\s+(Interactive|Silent|NonInteractive)\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('REPO-DEPLOYMODE-NO-AUTO', text, match, { detected: match[1] }));
|
||||
}
|
||||
|
||||
function checkDeprecatedObjectProperty(text) {
|
||||
return [...text.matchAll(/\bGet-ADTObjectProperty\b/gi)]
|
||||
.filter((match) => !isCommentMatch(text, match))
|
||||
.map((match) => add('DEPRECATED-GET-ADTOBJECTPROPERTY', text, match));
|
||||
}
|
||||
|
||||
function checkGlobalModuleImport(text) {
|
||||
return [...text.matchAll(/Import-Module\s+['"]?PSAppDeployToolkit['"]?(?![\\/])/gi)].map((match) => add('FAQ-GLOBAL-MODULE-IMPORT', text, match));
|
||||
}
|
||||
|
||||
function checkUserContextRequireAdmin(text, context) {
|
||||
if (!/Start-ADTProcessAsUser|Start-ADTMsiProcessAsUser/i.test(text)) return [];
|
||||
if (/RequireAdmin\s*=\s*\$false/i.test(text) || context.requireAdmin === false) return [];
|
||||
const match = /Start-ADTMsiProcessAsUser|Start-ADTProcessAsUser/i.exec(text);
|
||||
return match ? [add('USERCONTEXT-REQUIREADMIN', text, match)] : [];
|
||||
}
|
||||
|
||||
function scoreFindings(findings) {
|
||||
const penalties = { critical: 35, error: 22, warning: 10, info: 3 };
|
||||
const penalty = findings.reduce((total, finding) => total + (penalties[finding.severity] || 0), 0);
|
||||
return Math.max(0, 100 - penalty);
|
||||
}
|
||||
|
||||
function countFindings(findings) {
|
||||
return findings.reduce((counts, finding) => {
|
||||
counts[finding.severity] = (counts[finding.severity] || 0) + 1;
|
||||
counts.total += 1;
|
||||
return counts;
|
||||
}, { total: 0, critical: 0, error: 0, warning: 0, info: 0 });
|
||||
}
|
||||
|
||||
function lineNumber(text, index) {
|
||||
return text.slice(0, index).split(/\r?\n/).length;
|
||||
}
|
||||
|
||||
function lineText(text, index) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
return lines[lineNumber(text, index) - 1]?.trim().slice(0, 240) || '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isCommentMatch(text, match) {
|
||||
return lineText(text, match.index ?? 0).trimStart().startsWith('#');
|
||||
}
|
||||
|
||||
function compareVersion(left, right) {
|
||||
const a = String(left).split('.').map((part) => Number(part) || 0);
|
||||
const b = String(right).split('.').map((part) => Number(part) || 0);
|
||||
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
||||
if ((a[index] || 0) < (b[index] || 0)) return -1;
|
||||
if ((a[index] || 0) > (b[index] || 0)) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user