This commit is contained in:
pucketm2016 committed 2026-08-09 17:33:11 -05:00
1 parent d27811254d
commit 582d0e635d
35 files changed
+2944 -1203

No files matched your search

+10
View File
@@ -40,6 +40,16 @@ export const config = {
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
myrtille: {
enabled: (process.env.MYRTILLE_ENABLED || 'false').toLowerCase() === 'true',
gatewayUrl: process.env.MYRTILLE_GATEWAY_URL || '',
usePasswordHash: (process.env.MYRTILLE_USE_PASSWORD_HASH || 'true').toLowerCase() === 'true',
allowPlainPassword: (process.env.MYRTILLE_ALLOW_PLAIN_PASSWORD || 'false').toLowerCase() === 'true',
hashEndpoint: process.env.MYRTILLE_HASH_ENDPOINT || 'GetHash.aspx',
defaultWidth: Number(process.env.MYRTILLE_DEFAULT_WIDTH || 1280),
defaultHeight: Number(process.env.MYRTILLE_DEFAULT_HEIGHT || 800),
requestTimeoutMs: Number(process.env.MYRTILLE_REQUEST_TIMEOUT_MS || 15000)
},
vcenter: {
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
baseUrl: process.env.VCENTER_BASE_URL || '',
+2 -2
View File
@@ -33,9 +33,9 @@ export function destroy(req, res) {
res.status(204).end();
}
export function createRdpSession(req, res) {
export async function createRdpSession(req, res) {
try {
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
res.status(201).json(await createRdpLaunchSession(req.params.id, req.user.id));
} catch (error) {
res.status(error.statusCode || 400).json({ error: error.message });
}
+31
View File
@@ -1,5 +1,8 @@
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
import { buildDetectionRule } from '../services/detectionRuleService.js';
import { buildRecipeArtifacts, getRecipe, searchRecipes } from '../services/recipeService.js';
import { createPsadtProfile, createIntuneDeployment } from '../models/psadtModel.js';
import { createApplication } from '../models/applicationModel.js';
export function installerTypes(req, res) {
res.json(listInstallerTechnologies());
@@ -18,3 +21,31 @@ export function detection(req, res) {
res.status(400).json({ error: error.message });
}
}
export function recipes(req, res) {
res.json(searchRecipes(req.query?.q || ''));
}
export function applyRecipe(req, res) {
const recipe = getRecipe(req.params.id);
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
try {
const artifacts = buildRecipeArtifacts(recipe, {
visibility: req.body?.visibility || 'personal',
groupId: req.body?.groupId || null
});
const userId = req.user.id;
const profile = createPsadtProfile(artifacts.profile, userId);
const application = artifacts.application ? createApplication(artifacts.application, userId) : null;
const deployment = createIntuneDeployment({
...artifacts.deployment,
profileId: profile.id,
applicationId: application?.id || null
}, userId);
res.status(201).json({ recipeId: recipe.id, profile, deployment, application });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
+106 -2
View File
@@ -26,10 +26,13 @@ import { computeDrift } from '../services/intuneDriftService.js';
import { canPublish } from '../services/graphRbac.js';
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
import { parseIntunewin } from '../services/intunewinParser.js';
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
import { createAssetFromUpload, getAssetFile, listLinkedAssets } from '../models/assetModel.js';
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
import { planPsadtMigration } from '../services/psadtMigrationService.js';
import { basenameAny, path } from '../utils/pathUtils.js';
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
import { renderDatasheet } from '../services/packageDocService.js';
import { loadApplication } from '../models/applicationModel.js';
import { basenameAny, path, sanitizeFileName } from '../utils/pathUtils.js';
export function catalog(req, res) {
res.json(getPsadtCatalog());
@@ -434,6 +437,24 @@ export function intuneGraphAudit(req, res) {
res.json(listGraphAudit({ deploymentId: deployment.id }));
}
// Auto-generated package datasheet: render a deployment (plus its linked PSADT
// profile and catalog application) to Markdown or HTML. Pure render lives in
// packageDocService. ?format=md|html (default md); ?download=1 forces a file.
export function intuneDatasheet(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const application = deployment.applicationId ? loadApplication(deployment.applicationId, req.user.id) : null;
const doc = renderDatasheet({ deployment, profile, application }, { format: req.query?.format });
const fileName = `${sanitizeFileName(doc.title || 'package', 'package').replace(/\.[^.]+$/, '')}-datasheet.${doc.extension}`;
res.type(doc.contentType);
if (req.query?.download === '1' || req.query?.download === 'true') {
res.set('Content-Disposition', `attachment; filename="${fileName}"`);
}
res.send(doc.content);
}
export function index(req, res) {
res.json(listPsadtProfiles(req.user.id));
}
@@ -467,6 +488,89 @@ export function render(req, res) {
res.json(rendered);
}
// Assemble a complete, portable PSADT v4 package from a profile: render the
// Invoke-AppDeployToolkit.ps1 entry script, lay out Files/SupportFiles/Assets
// from the profile's linked Asset Library items, zip it, and ingest the zip into
// the Asset Library. With { build: true } also hand the assembled tree to the
// .intunewin builder. Pure layout + zip logic lives in packageBuilder.
export async function packageProfile(req, res) {
const rendered = renderPsadtProfile(req.params.id, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
const links = listLinkedAssets('psadt_profile', req.params.id, req.user.id);
const layout = planPackageLayout(links);
const warnings = [...layout.warnings];
const zipEntries = [
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(rendered.script, 'utf8') },
...layout.directories.map((dir) => ({ path: dir, isDirectory: true }))
];
for (const asset of layout.assets) {
const fileRef = getAssetFile(asset.assetId, req.user.id);
if (!fileRef) {
warnings.push(`Asset "${asset.name || asset.assetId}" is no longer accessible and was skipped.`);
continue;
}
zipEntries.push({ path: asset.packagePath, data: fs.readFileSync(fileRef.filePath) });
}
const includedFiles = zipEntries.filter((entry) => !entry.isDirectory && entry.path !== ENTRY_SCRIPT_NAME);
const stageDir = path.join(config.toolsDir, '_package', rendered.profile.id);
fs.rmSync(stageDir, { recursive: true, force: true });
fs.mkdirSync(stageDir, { recursive: true });
const baseName = sanitizeFileName(`${rendered.profile.name || 'psadt'}-package`, 'psadt-package').replace(/\.[^.]+$/, '');
const zipPath = path.join(stageDir, `${baseName}.zip`);
const zipBuffer = buildZip(zipEntries);
fs.writeFileSync(zipPath, zipBuffer);
const asset = createAssetFromUpload(
{ path: zipPath, originalname: `${baseName}.zip`, mimetype: 'application/zip', size: zipBuffer.length },
{ name: `${rendered.profile.name} package`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: `${baseName}.zip` },
req.user.id
);
const manifest = {
script: ENTRY_SCRIPT_NAME,
directories: layout.directories,
files: layout.assets.map((item) => ({ packagePath: item.packagePath, role: item.role, name: item.name })),
fileCount: includedFiles.length
};
let intunewin = null;
if (req.body?.build) {
const setupFile = resolveSetupFile(layout, req.body?.setupFile);
const availability = builderAvailability();
if (!setupFile) {
warnings.push('Skipped .intunewin build: no installer in the package. Link an installer asset or pass setupFile.');
} else if (!availability.available) {
warnings.push(`Skipped .intunewin build: ${availability.reason}`);
} else {
const sourceFolder = path.join(stageDir, 'source');
for (const entry of zipEntries) {
const dest = path.join(sourceFolder, entry.path);
if (entry.isDirectory) { fs.mkdirSync(dest, { recursive: true }); continue; }
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, entry.data);
}
try {
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir: path.join(stageDir, '_out') });
const stats = fs.statSync(outputFile);
const outName = basenameAny(outputFile, 'package.intunewin');
intunewin = createAssetFromUpload(
{ path: outputFile, originalname: outName, mimetype: 'application/octet-stream', size: stats.size },
{ name: `${rendered.profile.name} intunewin`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: outName },
req.user.id
);
manifest.setupFile = setupFile;
} catch (error) {
warnings.push(`.intunewin build failed: ${error.message}`);
}
}
}
res.status(201).json({ asset, manifest, warnings, intunewin, builder: builderAvailability() });
}
export function rules(req, res) {
res.json(validationRuleCatalog());
}
+11 -2
View File
@@ -64,7 +64,8 @@ const operationsArticles = [
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon asks the API for a Myrtille launch URL and opens that gateway in a new tab.',
'Configure myrtille_enabled and myrtille_gateway_url in Configuration / Integrations or pin them with MYRTILLE_ENABLED and MYRTILLE_GATEWAY_URL. Password-hash mode is enabled by default so the browser receives a Myrtille passwordHash instead of the plaintext vault secret.',
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
]),
section('Host Groups and VMware sources', [
@@ -96,6 +97,11 @@ const operationsArticles = [
'Users & Groups is a dedicated administration menu item. Add users and groups through modal create flows.',
'Scripts, RunPlans, variables, PSADT profiles, and Intune deployment plans can use personal, shared, or group visibility.'
]),
section('Profile and themes', [
'Open the avatar menu in the top bar to edit profile details, avatar URL, password settings, and theme preference.',
'Available themes include Dashtreme Pulse, Green Screen, Aurora Glass, Greyscale Pro, and macOS Desktop. The macOS mode uses desktop-style frosted chrome; Greyscale Pro uses neutral high-contrast surfaces.',
'Dense data tables use icon-only row actions with accessible labels and hover tooltips, while page-level create/save commands keep text labels.'
]),
section('Configuration', [
'Config is grouped into collapsible sections for Deployment, Authentication, Execution, Runtime, and Other settings, with Microsoft Graph Intune environments managed in the Config / Intune panel.',
'Settings can be controlled through the UI and environment variables for Docker deployment. Trusted origins, server FQDN, Entra fields, PowerShell binary, and script execution switch are key operational settings.'
@@ -108,6 +114,7 @@ const apiArticles = [
apiExample('Post', '/api/auth/login', 'Authenticate with local credentials and receive a JWT token.', '@{ email = "admin@posh.local"; password = "change-me-now" }'),
apiExample('Get', '/api/auth/me', 'Return the current authenticated user.'),
apiExample('Put', '/api/auth/profile', 'Update display name, profile fields, and avatar URL.', '@{ displayName = "POSH Admin"; email = "admin@posh.local"; jobTitle = "Platform Administrator"; phone = ""; timezone = "America/Chicago"; avatarUrl = "" }'),
apiExample('Put', '/api/auth/theme', 'Update the current user theme. Supported values include dashtreme, terminal, aurora, greyscale, and macos.', '@{ theme = "macos" }'),
apiExample('Get', '/api/bootstrap', 'Return dashboard summary, settings payload, and Entra status.'),
section('Notes', [
'Use the Bearer token returned by /api/auth/login for every protected request.',
@@ -135,7 +142,7 @@ const apiArticles = [
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process $session.url'),
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
@@ -163,6 +170,8 @@ const apiArticles = [
apiExample('Post', '/api/psadt/validate', 'Validate raw PSADT script content.', '@{ platform = "intune"; content = "Invoke-AppDeployToolkit.exe -DeploymentType Install" }'),
apiExample('Post', '/api/psadt/migration/plan', 'Plan a legacy script migration to PSADT 4.2.0.', `@{ targetVersion = "4.2.0"; content = "Deploy-Application.ps1\`nExecute-Process -Path setup.exe\`n$appName = 'Legacy App'" }`),
apiExample('Post', '/api/psadt/profiles', 'Create a reusable PSADT deployment profile.', '@{ name = "Edge Enterprise"; appVendor = "Microsoft"; appName = "Edge"; appVersion = "126.0"; appArch = "x64"; appLang = "EN"; appRevision = "01"; templateVersion = "v4"; deploymentType = "Install"; deployMode = "Auto"; requireAdmin = $true; zeroConfig = $false; suppressReboot = $false; closeProcesses = @(); installTasks = @(@{ type = "exe"; phase = "Install"; filePath = "setup.exe"; arguments = "/S"; secureArguments = $false }); uiPlan = @{ welcome = $true }; configPlan = @{}; admxPlan = @{}; visibility = "shared" }'),
apiExample('Post', '/api/psadt/profiles/psadt_123/package', 'Assemble a portable PSADT v4 package: render Invoke-AppDeployToolkit.ps1, lay out Files/SupportFiles/Assets from the profile\'s linked assets, zip it, and ingest the zip into the Asset Library. Pass build = $true to also produce a .intunewin from the assembled tree (Windows + IntuneWinAppUtil or INTUNEWIN_BUILD_COMMAND). Download the returned asset via /api/assets/{id}/download.', '@{ build = $true; setupFile = "Files/setup.exe" }'),
apiExample('Get', '/api/psadt/intune/deployments/intune_123/datasheet?format=html', 'Auto-generate a package datasheet (Markdown or self-contained HTML) for a deployment: metadata, install/uninstall commands, detection, return codes, assignments, requirements, and source/version. Use format=md or format=html; add download=1 to return it as a file attachment.'),
apiExample('Post', '/api/psadt/intune/deployments', 'Create an Intune Publishing Wizard plan.', '@{ name = "Edge Pilot"; sourceFolder = "C:\\Packages\\Edge\\PSADT"; intunewinFile = "Edge.intunewin"; commandStyle = "v4"; installBehavior = "system"; restartBehavior = "return-code"; uiMode = "native-v4"; requirements = @{ architecture = "x64"; minOs = "Windows 10 22H2"; diskSpaceMb = 512; runAs32Bit = $false }; installCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Install"; uninstallCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Uninstall"; detectionType = "custom-script"; detectionRule = "Write-Output detected"; returnCodes = @(@{ code = 0; type = "success"; meaning = "OK" }, @{ code = 1602; type = "retry"; meaning = "Deferred" }, @{ code = 3010; type = "softReboot"; meaning = "Reboot" }); assignments = @(@{ ring = "Pilot"; intent = "available"; groupName = "IT Pilot"; notes = "Validate" }); status = "ready"; visibility = "shared" }')
], ['api', 'psadt', 'intune', 'migration'])
,
+300
View File
@@ -0,0 +1,300 @@
// Curated application recipe library. Each entry holds the installer
// technology, an example installer file name, silent install args, a detection
// footprint, and (when one exists) the winget id for update tracking.
//
// `installerType` must match an INSTALLER_TECHNOLOGIES id.
// `detection` is passed verbatim to detectionRuleService.buildDetectionRule.
// Args with <angle-bracket> placeholders are intentional refine-me prompts.
export const RECIPES = [
// --- Browsers --------------------------------------------------------------
{
id: 'google-chrome',
vendor: 'Google', name: 'Chrome', category: 'Browser',
installerType: 'msi', fileName: 'googlechromestandaloneenterprise64.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'chrome', description: 'Google Chrome' }],
detection: { type: 'file', path: '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe' },
wingetId: 'Google.Chrome', homepage: 'https://chromeenterprise.google/browser/download/'
},
{
id: 'mozilla-firefox',
vendor: 'Mozilla', name: 'Firefox', category: 'Browser',
installerType: 'nsis', fileName: 'Firefox Setup.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'firefox', description: 'Mozilla Firefox' }],
detection: { type: 'file', path: '%ProgramFiles%\\Mozilla Firefox\\firefox.exe' },
wingetId: 'Mozilla.Firefox', homepage: 'https://www.mozilla.org/firefox/all/'
},
{
id: 'microsoft-edge',
vendor: 'Microsoft', name: 'Edge', category: 'Browser',
installerType: 'msi', fileName: 'MicrosoftEdgeEnterpriseX64.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'msedge', description: 'Microsoft Edge' }],
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe' },
wingetId: 'Microsoft.Edge', homepage: 'https://www.microsoft.com/edge/business/download'
},
{
id: 'brave',
vendor: 'Brave', name: 'Brave Browser', category: 'Browser',
installerType: 'nsis', fileName: 'BraveBrowserStandaloneSetup.exe', arch: 'x64',
installArgs: '/silent /install',
detection: { type: 'file', path: '%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
wingetId: 'Brave.Brave', homepage: 'https://brave.com/download/'
},
// --- Productivity & Office -------------------------------------------------
{
id: 'adobe-reader',
vendor: 'Adobe', name: 'Acrobat Reader', category: 'Productivity',
installerType: 'msi', fileName: 'AcroRead.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'Acrobat', description: 'Adobe Acrobat' }, { name: 'AcroRd32', description: 'Adobe Reader' }],
detection: { type: 'file', path: '%ProgramFiles%\\Adobe\\Acrobat DC\\Acrobat\\Acrobat.exe' },
wingetId: 'Adobe.Acrobat.Reader.64-bit', homepage: 'https://get.adobe.com/reader/enterprise/'
},
{
id: 'microsoft-365-apps',
vendor: 'Microsoft', name: 'Microsoft 365 Apps (Word/Excel/PowerPoint)', category: 'Productivity',
installerType: 'exe', fileName: 'setup.exe', arch: 'x64',
// Office is deployed with the Office Deployment Tool + a configuration.xml.
installArgs: '/configure configuration.xml',
closeProcesses: [{ name: 'winword', description: 'Word' }, { name: 'excel', description: 'Excel' }, { name: 'powerpnt', description: 'PowerPoint' }, { name: 'outlook', description: 'Outlook' }],
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE' },
wingetId: 'Microsoft.Office', homepage: 'https://www.microsoft.com/microsoft-365/enterprise'
},
{
id: 'libreoffice',
vendor: 'The Document Foundation', name: 'LibreOffice', category: 'Productivity',
installerType: 'msi', fileName: 'LibreOffice.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\LibreOffice\\program\\soffice.exe' },
wingetId: 'TheDocumentFoundation.LibreOffice', homepage: 'https://www.libreoffice.org/download/download/'
},
// --- Communication ---------------------------------------------------------
{
id: 'zoom',
vendor: 'Zoom', name: 'Zoom Workplace', category: 'Communication',
installerType: 'msi', fileName: 'ZoomInstallerFull.msi', arch: 'x64',
installArgs: '/qn /norestart ZoomAutoUpdate=true',
closeProcesses: [{ name: 'Zoom', description: 'Zoom' }],
detection: { type: 'file', path: '%ProgramFiles%\\Zoom\\bin\\Zoom.exe' },
wingetId: 'Zoom.Zoom', homepage: 'https://zoom.us/download'
},
{
id: 'microsoft-teams',
vendor: 'Microsoft', name: 'Teams (new)', category: 'Communication',
installerType: 'msix', fileName: 'MSTeams-x64.msix', arch: 'x64',
installArgs: '',
detection: { type: 'file', path: '%ProgramFiles%\\WindowsApps\\MSTeams_*\\ms-teams.exe' },
wingetId: 'Microsoft.Teams', homepage: 'https://www.microsoft.com/microsoft-teams/download-app'
},
{
id: 'slack',
vendor: 'Slack', name: 'Slack', category: 'Communication',
installerType: 'msi', fileName: 'slack-standalone.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'slack', description: 'Slack' }],
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{Slack}', valueName: 'DisplayName' },
wingetId: 'SlackTechnologies.Slack', homepage: 'https://slack.com/downloads/windows'
},
{
id: 'cisco-webex',
vendor: 'Cisco', name: 'Webex App', category: 'Communication',
installerType: 'msi', fileName: 'Webex.msi', arch: 'x64',
installArgs: '/qn /norestart ALLUSERS=1 AUTOUPGRADEENABLED=1',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Cisco Spark\\CiscoCollabHost.exe' },
wingetId: 'Cisco.Webex', homepage: 'https://www.webex.com/downloads.html'
},
// --- Media -----------------------------------------------------------------
{
id: 'vlc',
vendor: 'VideoLAN', name: 'VLC media player', category: 'Media',
installerType: 'nsis', fileName: 'vlc-win64.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'vlc', description: 'VLC media player' }],
detection: { type: 'file', path: '%ProgramFiles%\\VideoLAN\\VLC\\vlc.exe' },
wingetId: 'VideoLAN.VLC', homepage: 'https://www.videolan.org/vlc/'
},
// --- Utilities -------------------------------------------------------------
{
id: '7zip',
vendor: '7-Zip', name: '7-Zip', category: 'Utility',
installerType: 'msi', fileName: '7z-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\7-Zip\\7z.exe' },
wingetId: '7zip.7zip', homepage: 'https://www.7-zip.org/download.html'
},
{
id: 'winrar',
vendor: 'RARLAB', name: 'WinRAR', category: 'Utility',
installerType: 'exe', fileName: 'winrar-x64.exe', arch: 'x64',
installArgs: '/S',
detection: { type: 'file', path: '%ProgramFiles%\\WinRAR\\WinRAR.exe' },
wingetId: 'RARLab.WinRAR', homepage: 'https://www.win-rar.com/download.html'
},
{
id: 'notepad-plus-plus',
vendor: 'Notepad++', name: 'Notepad++', category: 'Utility',
installerType: 'nsis', fileName: 'npp.Installer.x64.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'notepad++', description: 'Notepad++' }],
detection: { type: 'file', path: '%ProgramFiles%\\Notepad++\\notepad++.exe' },
wingetId: 'Notepad++.Notepad++', homepage: 'https://notepad-plus-plus.org/downloads/'
},
{
id: 'greenshot',
vendor: 'Greenshot', name: 'Greenshot', category: 'Utility',
installerType: 'inno', fileName: 'Greenshot-INSTALLER.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
detection: { type: 'file', path: '%ProgramFiles%\\Greenshot\\Greenshot.exe' },
wingetId: 'Greenshot.Greenshot', homepage: 'https://getgreenshot.org/downloads/'
},
{
id: 'putty',
vendor: 'Simon Tatham', name: 'PuTTY', category: 'Utility',
installerType: 'msi', fileName: 'putty-64bit-installer.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\PuTTY\\putty.exe' },
wingetId: 'PuTTY.PuTTY', homepage: 'https://www.putty.org/'
},
{
id: 'winscp',
vendor: 'WinSCP', name: 'WinSCP', category: 'Utility',
installerType: 'inno', fileName: 'WinSCP-Setup.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\WinSCP\\WinSCP.exe' },
wingetId: 'WinSCP.WinSCP', homepage: 'https://winscp.net/eng/download.php'
},
// --- Developer -------------------------------------------------------------
{
id: 'git',
vendor: 'Git', name: 'Git for Windows', category: 'Developer',
installerType: 'inno', fileName: 'Git-64-bit.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
detection: { type: 'file', path: '%ProgramFiles%\\Git\\bin\\git.exe' },
wingetId: 'Git.Git', homepage: 'https://git-scm.com/download/win'
},
{
id: 'vscode',
vendor: 'Microsoft', name: 'Visual Studio Code', category: 'Developer',
installerType: 'inno', fileName: 'VSCodeSetup-x64.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!runcode',
closeProcesses: [{ name: 'Code', description: 'Visual Studio Code' }],
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft VS Code\\Code.exe' },
wingetId: 'Microsoft.VisualStudioCode', homepage: 'https://code.visualstudio.com/download'
},
{
id: 'powershell-7',
vendor: 'Microsoft', name: 'PowerShell 7', category: 'Developer',
installerType: 'msi', fileName: 'PowerShell-7-win-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\PowerShell\\7\\pwsh.exe' },
wingetId: 'Microsoft.PowerShell', homepage: 'https://github.com/PowerShell/PowerShell/releases'
},
{
id: 'nodejs',
vendor: 'OpenJS Foundation', name: 'Node.js LTS', category: 'Developer',
installerType: 'msi', fileName: 'node-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\nodejs\\node.exe' },
wingetId: 'OpenJS.NodeJS.LTS', homepage: 'https://nodejs.org/en/download'
},
{
id: 'python-3',
vendor: 'Python Software Foundation', name: 'Python 3', category: 'Developer',
installerType: 'exe', fileName: 'python-installer.exe', arch: 'x64',
installArgs: '/quiet InstallAllUsers=1 PrependPath=1 Include_test=0',
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Python\\PythonCore' },
wingetId: 'Python.Python.3.12', homepage: 'https://www.python.org/downloads/windows/'
},
{
id: 'temurin-jdk',
vendor: 'Eclipse Adoptium', name: 'Temurin JDK 21', category: 'Developer',
installerType: 'msi', fileName: 'OpenJDK21-jdk_x64_windows.msi', arch: 'x64',
installArgs: '/qn /norestart ADDLOCAL=FeatureMain,FeatureEnvironment',
detection: { type: 'file', path: '%ProgramFiles%\\Eclipse Adoptium\\jdk-21\\bin\\java.exe' },
wingetId: 'EclipseAdoptium.Temurin.21.JDK', homepage: 'https://adoptium.net/temurin/releases/'
},
// --- Remote access & virtualization ---------------------------------------
{
id: 'vmware-workstation',
vendor: 'Broadcom (VMware)', name: 'Workstation Pro', category: 'Virtualization',
installerType: 'installshield', fileName: 'VMware-workstation.exe', arch: 'x64',
installArgs: '/s /v"/qn EULAS_AGREED=1 AUTOSOFTWAREUPDATE=0 REBOOT=ReallySuppress"',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\VMware\\VMware Workstation\\vmware.exe' },
wingetId: 'VMware.WorkstationPro', homepage: 'https://www.vmware.com/products/workstation-pro.html'
},
{
id: 'realvnc-server',
vendor: 'RealVNC', name: 'VNC Server', category: 'Remote access',
installerType: 'msi', fileName: 'VNC-Server.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\RealVNC\\VNC Server\\vncserver.exe' },
wingetId: 'RealVNC.VNCServer', homepage: 'https://www.realvnc.com/en/connect/download/vnc/'
},
{
id: 'teamviewer-host',
vendor: 'TeamViewer', name: 'TeamViewer Host', category: 'Remote access',
installerType: 'msi', fileName: 'TeamViewer_Host.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\TeamViewer\\TeamViewer.exe' },
wingetId: 'TeamViewer.TeamViewer.Host', homepage: 'https://www.teamviewer.com/download/windows/'
},
{
id: 'anydesk',
vendor: 'AnyDesk', name: 'AnyDesk', category: 'Remote access',
installerType: 'exe', fileName: 'AnyDesk.exe', arch: 'x64',
installArgs: '--install "%ProgramFiles(x86)%\\AnyDesk" --start-with-win --silent',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\AnyDesk\\AnyDesk.exe' },
wingetId: 'AnyDeskSoftwareGmbH.AnyDesk', homepage: 'https://anydesk.com/en/downloads/windows'
},
// --- Security & enterprise agents (no winget; vendor-managed updates) ------
{
id: 'crowdstrike-falcon',
vendor: 'CrowdStrike', name: 'Falcon Sensor', category: 'Security agent',
installerType: 'msi', fileName: 'WindowsSensor.exe', arch: 'x64',
installArgs: '/install /quiet /norestart CID=<your-falcon-cid>',
detection: { type: 'file', path: '%ProgramFiles%\\CrowdStrike\\CSFalconService.exe' },
wingetId: '', homepage: 'https://www.crowdstrike.com/'
},
{
id: 'cisco-umbrella',
vendor: 'Cisco', name: 'Umbrella Roaming Client', category: 'Security agent',
installerType: 'msi', fileName: 'Setup.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\OpenDNS\\Umbrella Roaming Client\\ERAgent.exe' },
wingetId: '', homepage: 'https://umbrella.cisco.com/'
},
{
id: 'splunk-uf',
vendor: 'Splunk', name: 'Universal Forwarder', category: 'Monitoring agent',
installerType: 'msi', fileName: 'splunkforwarder-x64.msi', arch: 'x64',
installArgs: '/qn AGREETOLICENSE=Yes RECEIVING_INDEXER="<indexer-host>:9997" SPLUNKUSERNAME=admin',
detection: { type: 'file', path: '%ProgramFiles%\\SplunkUniversalForwarder\\bin\\splunk.exe' },
wingetId: '', homepage: 'https://www.splunk.com/en_us/download/universal-forwarder.html'
},
{
id: 'nessus-agent',
vendor: 'Tenable', name: 'Nessus Agent', category: 'Security agent',
installerType: 'msi', fileName: 'NessusAgent-x64.msi', arch: 'x64',
installArgs: '/qn NESSUS_GROUPS="<group>" NESSUS_SERVER="<manager>:8834" NESSUS_KEY="<linking-key>"',
detection: { type: 'file', path: '%ProgramFiles%\\Tenable\\Nessus Agent\\nessuscli.exe' },
wingetId: '', homepage: 'https://www.tenable.com/downloads/nessus-agents'
},
{
id: 'sap-gui',
vendor: 'SAP', name: 'SAP GUI for Windows', category: 'Enterprise',
installerType: 'exe', fileName: 'NwSapSetup.exe', arch: 'x64',
installArgs: '/silent /product="SAPGUI"',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\SAP\\FrontEnd\\SAPGUI\\saplogon.exe' },
wingetId: '', homepage: 'https://support.sap.com/'
}
];
+1 -4
View File
@@ -11,7 +11,6 @@ import { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js';
import { startHostGroupWorker } from './services/hostGroupWorker.js';
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
// Fail fast in production rather than ship with well-known default secrets.
// These defaults are fine for local development but are publicly known, so a
@@ -56,16 +55,14 @@ app.use(cors({
app.use(express.json({ limit: config.jsonLimit }));
app.use(requestLogger);
app.use('/api', apiRoutes);
mountRdpGateway(app);
app.use((error, req, res, next) => {
logger.error('unhandled error', { error });
res.status(500).json({ error: 'Unexpected server error' });
});
const httpServer = app.listen(config.port, () => {
app.listen(config.port, () => {
logger.info(`POSHManager API listening on ${config.port}`);
startRdpGateway(httpServer);
startCatalogWorker();
startPromotionWorker();
startHostGroupWorker();
+19
View File
@@ -221,6 +221,25 @@ export function deleteAsset(assetId, userId) {
return true;
}
// Reverse of listAssetLinks: every asset linked to a target (e.g. a PSADT
// profile) that the user can see, with the per-link package path/role used by
// the package builder to place it in the deployment tree.
export function listLinkedAssets(targetType, targetId, userId) {
return db.prepare(`
SELECT a.*, l.link_role AS link_role, l.package_path AS link_package_path
FROM asset_links l
INNER JOIN assets a ON a.id = l.asset_id
WHERE l.target_type = ? AND l.target_id = ? AND ${visibleClause('a')}
ORDER BY l.created_at
`).all(targetType, targetId, userId, userId).map((row) => ({
assetId: row.id,
originalName: row.original_name,
kind: row.kind,
linkRole: row.link_role,
packagePath: row.link_package_path || row.package_path || row.original_name
}));
}
export function listAssetLinks(assetId, userId) {
const asset = selectAssetById(assetId, userId);
if (!asset) return null;
+3 -3
View File
@@ -1,11 +1,11 @@
import { Router } from 'express';
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
import { analyze, applyRecipe, detection, installerTypes, recipes } from '../controllers/packagingController.js';
import { requireAuth } from '../middleware/auth.js';
export const packagingRoutes = Router();
// Phase 1 installer intelligence: detect technology, recommend silent commands,
// and generate detection rules.
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
packagingRoutes.post('/analyze', requireAuth, analyze);
packagingRoutes.post('/detection', requireAuth, detection);
packagingRoutes.get('/recipes', requireAuth, recipes);
packagingRoutes.post('/recipes/:id/apply', requireAuth, applyRecipe);
+4
View File
@@ -5,6 +5,7 @@ import {
destroy,
index,
intuneCreate,
intuneDatasheet,
intuneDestroy,
intuneGraphAssign,
intuneGraphAudit,
@@ -25,6 +26,7 @@ import {
intuneUpdate,
migrationApply,
migrationPlan,
packageProfile,
render,
rules,
show,
@@ -60,9 +62,11 @@ psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
psadtRoutes.get('/intune/deployments/:id/datasheet', requireAuth, intuneDatasheet);
psadtRoutes.get('/profiles', requireAuth, index);
psadtRoutes.post('/profiles', requireAuth, create);
psadtRoutes.get('/profiles/:id', requireAuth, show);
psadtRoutes.put('/profiles/:id', requireAuth, update);
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
psadtRoutes.post('/profiles/:id/package', requireAuth, packageProfile);
+123
View File
@@ -0,0 +1,123 @@
// Minimal reader for the OLE2 / Compound File Binary (CFB) container format —
// the on-disk shape of an MSI database (and legacy Office docs). Dependency-free
// and read-only: it walks the FAT / mini-FAT and directory to expose each
// stream as a Buffer keyed by its (decoded) directory name. Just enough of
// [MS-CFB] to pull MSI tables; it does not write or modify compound files.
const SIG = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
const ENDOFCHAIN = 0xfffffffe;
const FREESECT = 0xffffffff;
const MAX_CHAIN = 1 << 24; // chain-length guard against malformed/looping files
export function isCompoundFile(buffer) {
return Buffer.isBuffer(buffer) && buffer.length >= 512 && buffer.subarray(0, 8).equals(SIG);
}
function decodeName(buffer, nameLen) {
// nameLen includes the trailing UTF-16 null terminator.
if (!nameLen || nameLen < 2) return '';
return buffer.toString('utf16le', 0, nameLen - 2);
}
function followChain(start, fat, limit) {
const chain = [];
let sector = start;
let guard = 0;
while (sector !== ENDOFCHAIN && sector !== FREESECT && sector < limit) {
chain.push(sector);
if (++guard > MAX_CHAIN) throw new Error('CFB sector chain too long (malformed file)');
sector = fat[sector];
if (sector == null) break;
}
return chain;
}
export function readCompoundFile(buffer) {
if (!isCompoundFile(buffer)) throw new Error('Not a compound file (bad OLE2 signature)');
const sectorShift = buffer.readUInt16LE(30);
const miniSectorShift = buffer.readUInt16LE(32);
const sectorSize = 1 << sectorShift;
const miniSectorSize = 1 << miniSectorShift;
const numFatSectors = buffer.readUInt32LE(44);
const firstDirSector = buffer.readUInt32LE(48);
const miniCutoff = buffer.readUInt32LE(56);
const firstMiniFatSector = buffer.readUInt32LE(60);
const firstDifatSector = buffer.readUInt32LE(68);
const numDifatSectors = buffer.readUInt32LE(72);
const sectorOffset = (sector) => (sector + 1) * sectorSize;
const totalSectors = Math.floor(buffer.length / sectorSize);
// 1) Assemble the DIFAT (locations of every FAT sector): 109 from the header,
// then the DIFAT sector chain.
const fatSectorLocations = [];
for (let i = 0; i < 109 && fatSectorLocations.length < numFatSectors; i += 1) {
const loc = buffer.readUInt32LE(76 + i * 4);
if (loc !== FREESECT) fatSectorLocations.push(loc);
}
let difatSector = firstDifatSector;
const entriesPerDifat = sectorSize / 4 - 1;
for (let s = 0; s < numDifatSectors && difatSector !== ENDOFCHAIN && difatSector !== FREESECT; s += 1) {
const base = sectorOffset(difatSector);
for (let i = 0; i < entriesPerDifat; i += 1) {
const loc = buffer.readUInt32LE(base + i * 4);
if (loc !== FREESECT && fatSectorLocations.length < numFatSectors) fatSectorLocations.push(loc);
}
difatSector = buffer.readUInt32LE(base + entriesPerDifat * 4);
}
// 2) Build the FAT: a flat next-sector table.
const entriesPerSector = sectorSize / 4;
const fat = new Uint32Array(fatSectorLocations.length * entriesPerSector);
let fi = 0;
for (const loc of fatSectorLocations) {
const base = sectorOffset(loc);
for (let i = 0; i < entriesPerSector; i += 1) fat[fi++] = buffer.readUInt32LE(base + i * 4);
}
const readChain = (start) => {
const chain = followChain(start, fat, totalSectors);
const out = Buffer.allocUnsafe(chain.length * sectorSize);
chain.forEach((sector, idx) => buffer.copy(out, idx * sectorSize, sectorOffset(sector), sectorOffset(sector) + sectorSize));
return out;
};
// 3) Directory entries.
const dirBytes = readChain(firstDirSector);
const entries = [];
for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
const type = dirBytes.readUInt8(off + 66);
if (type === 0) continue; // unused slot
entries.push({
name: decodeName(dirBytes.subarray(off, off + 64), dirBytes.readUInt16LE(off + 64)),
type,
startSector: dirBytes.readUInt32LE(off + 116),
size: Number(dirBytes.readBigUInt64LE(off + 120))
});
}
// 4) Mini stream lives in the root entry; small streams are read from it.
const root = entries.find((e) => e.type === 5);
const miniStream = root && root.size > 0 ? readChain(root.startSector).subarray(0, root.size) : Buffer.alloc(0);
const miniFatBytes = firstMiniFatSector === ENDOFCHAIN ? Buffer.alloc(0) : readChain(firstMiniFatSector);
const miniFat = new Uint32Array(miniFatBytes.length / 4);
for (let i = 0; i < miniFat.length; i += 1) miniFat[i] = miniFatBytes.readUInt32LE(i * 4);
const readMiniChain = (start, size) => {
const chain = followChain(start, miniFat, miniStream.length / miniSectorSize + 1);
const out = Buffer.allocUnsafe(chain.length * miniSectorSize);
chain.forEach((sector, idx) => miniStream.copy(out, idx * miniSectorSize, sector * miniSectorSize, sector * miniSectorSize + miniSectorSize));
return out.subarray(0, size);
};
const streams = new Map();
for (const entry of entries) {
if (entry.type !== 2) continue; // streams only
const data = entry.size < miniCutoff
? readMiniChain(entry.startSector, entry.size)
: readChain(entry.startSector).subarray(0, entry.size);
streams.set(entry.name, data);
}
return { streams };
}
+165
View File
@@ -0,0 +1,165 @@
// Read identity metadata from a Windows Installer (.msi) database. An MSI is a
// compound file (see cfbReader) whose tables are stored as streams. We pull the
// `Property` table (ProductCode/Version/Name, UpgradeCode, Manufacturer) and the
// SummaryInformation property set (subject/author/template) — enough to auto-fill
// a deployment and a product-code detection rule with zero typing. Dependency-
// free and read-only; the table-decoding helpers are pure and unit-tested.
import { isCompoundFile, readCompoundFile } from './cfbReader.js';
const SUMMARY_STREAM = 'SummaryInformation';
// MSI mangles table/stream names into a Unicode private range. Encode a plain
// table name ("Property") the same way the directory stores it, so we can look
// it up in the decoded streams map. [A-Za-z0-9._] map to 6-bit codes packed in
// pairs (0x3800 base) with a trailing odd char at 0x4800.
function encChar(code) {
if (code >= 0x30 && code <= 0x39) return code - 0x30; // 0-9
if (code >= 0x41 && code <= 0x5a) return code - 0x41 + 10; // A-Z
if (code >= 0x61 && code <= 0x7a) return code - 0x61 + 36; // a-z
if (code === 0x2e) return 62; // .
if (code === 0x5f) return 63; // _
return -1;
}
export function encodeStreamName(name) {
let out = '';
let i = 0;
while (i < name.length) {
const c1 = encChar(name.charCodeAt(i));
if (c1 < 0) { out += name[i]; i += 1; continue; }
const c2 = i + 1 < name.length ? encChar(name.charCodeAt(i + 1)) : -1;
if (c2 >= 0) { out += String.fromCharCode(0x3800 + c1 + c2 * 0x40); i += 2; }
else { out += String.fromCharCode(0x4800 + c1); i += 1; }
}
return out;
}
function decoderFor(codepage) {
if (codepage === 65001) return (b) => b.toString('utf8');
if (codepage === 1200) return (b) => b.toString('utf16le');
return (b) => b.toString('latin1'); // 1252/0/ASCII — fine for property keys + GUIDs
}
// _StringPool is an array of (size, refcount) uint16 pairs; slot 0 carries the
// codepage. _StringData is the concatenated bytes. Strings are 1-indexed; a zero
// size with a nonzero refcount marks a >64KB string whose length spans two slots.
export function decodeStringPool(poolBuf, dataBuf) {
const codepage = poolBuf.length >= 2 ? poolBuf.readUInt16LE(0) : 0;
const decode = decoderFor(codepage);
const strings = ['']; // index 0 is always the empty string
let dataOff = 0;
let i = 1;
while (i * 4 + 4 <= poolBuf.length) {
let len = poolBuf.readUInt16LE(i * 4);
const refcount = poolBuf.readUInt16LE(i * 4 + 2);
i += 1;
if (len === 0 && refcount === 0) { strings.push(''); continue; }
if (len === 0 && i * 4 + 4 <= poolBuf.length) {
len = (refcount << 16) | poolBuf.readUInt16LE(i * 4);
i += 1;
}
strings.push(decode(dataBuf.subarray(dataOff, dataOff + len)));
dataOff += len;
}
return { codepage, strings };
}
// The Property table has two string columns (Property, Value), stored column-
// major. Cells are string-pool indices: 2 bytes, or 3 when the pool is huge.
export function parsePropertyTable(tableBuf, strings) {
const idxWidth = strings.length > 0xffff ? 3 : 2;
const rowBytes = idxWidth * 2;
const rows = Math.floor(tableBuf.length / rowBytes);
const readIdx = (off) => (idxWidth === 2 ? tableBuf.readUInt16LE(off) : tableBuf.readUIntLE(off, 3));
const props = {};
for (let r = 0; r < rows; r += 1) {
const key = strings[readIdx(r * idxWidth)];
if (!key) continue;
props[key] = strings[readIdx(rows * idxWidth + r * idxWidth)] || '';
}
return props;
}
function readPropValue(buf, off, type, decode) {
switch (type) {
case 2: return buf.readInt16LE(off); // VT_I2
case 3: return buf.readInt32LE(off); // VT_I4
case 30: { // VT_LPSTR
const len = buf.readUInt32LE(off);
return decode(buf.subarray(off + 4, off + 4 + len)).replace(/\0+$/, '');
}
case 31: { // VT_LPWSTR
const len = buf.readUInt32LE(off);
return buf.toString('utf16le', off + 4, off + 4 + len * 2).replace(/\0+$/, '');
}
default: return null;
}
}
// SummaryInformation is a standard OLE property set ([MS-OLEPS]). We read the
// first section and pull the PIDs MSI populates (subject, author, template…).
export function parseSummaryInformation(buf) {
if (!buf || buf.length < 48) return {};
const sectionOffset = buf.readUInt32LE(44); // FMTID(16) at 28, its offset at 44
if (sectionOffset + 8 > buf.length) return {};
const count = buf.readUInt32LE(sectionOffset + 4);
const idOffsets = [];
for (let i = 0; i < count && sectionOffset + 8 + i * 8 + 8 <= buf.length; i += 1) {
idOffsets.push([
buf.readUInt32LE(sectionOffset + 8 + i * 8),
sectionOffset + buf.readUInt32LE(sectionOffset + 8 + i * 8 + 4)
]);
}
let codepage = 0;
for (const [pid, off] of idOffsets) {
if (pid === 1 && off + 6 <= buf.length && buf.readUInt32LE(off) === 2) {
const cp = buf.readInt16LE(off + 4);
codepage = cp < 0 ? cp + 65536 : cp;
}
}
const decode = decoderFor(codepage);
const PID = { 2: 'title', 3: 'subject', 4: 'author', 6: 'comments', 7: 'template', 9: 'revNumber', 18: 'creatingApp' };
const out = {};
for (const [pid, off] of idOffsets) {
if (!PID[pid] || off + 4 > buf.length) continue;
const value = readPropValue(buf, off + 4, buf.readUInt32LE(off), decode);
if (value != null) out[PID[pid]] = value;
}
return out;
}
// Pure: turn a decoded streams map (from cfbReader) into normalized metadata.
export function extractMsiMetadata(streams) {
const props = {};
const pool = streams.get('_StringPool');
const data = streams.get('_StringData');
if (pool && data) {
const { strings } = decodeStringPool(pool, data);
const table = streams.get(encodeStreamName('Property'));
if (table) Object.assign(props, parsePropertyTable(table, strings));
}
const summary = parseSummaryInformation(streams.get(SUMMARY_STREAM));
const [platform = '', language = ''] = String(summary.template || '').split(';');
return {
source: 'msi',
productName: props.ProductName || summary.subject || summary.title || '',
productVersion: props.ProductVersion || '',
productCode: props.ProductCode || '',
upgradeCode: props.UpgradeCode || '',
manufacturer: props.Manufacturer || summary.author || '',
language: props.ProductLanguage || language.trim(),
platform: platform.trim(),
packageCode: summary.revNumber || '',
properties: props
};
}
export function isMsi(buffer) {
return isCompoundFile(buffer);
}
export function readMsiMetadata(buffer) {
if (!isCompoundFile(buffer)) throw new Error('Not an MSI (missing OLE2 compound-file signature)');
return extractMsiMetadata(readCompoundFile(buffer).streams);
}
+149
View File
@@ -0,0 +1,149 @@
import zlib from 'node:zlib';
import { basenameAny, normalizePackagePath } from '../utils/pathUtils.js';
// Assembles a PSADT v4 package tree from a rendered profile script plus its linked
// Asset Library files, and zips it without an archiver dependency. The layout
// planning and the ZIP writer are pure and unit-tested; disk reads stay in the
// controller.
export const ENTRY_SCRIPT_NAME = 'Invoke-AppDeployToolkit.ps1';
// Link role -> default PSADT v4 subfolder when the link carries no explicit
// package path. Installers/payload go under Files, supporting content under
// SupportFiles, icons under Assets.
const ROLE_FOLDER = {
installer: 'Files',
'package-file': 'Files',
'support-file': 'SupportFiles',
'detection-script': 'SupportFiles',
icon: 'Assets',
reference: 'SupportFiles'
};
// Pure: decide where the rendered script and each linked asset land in the
// package tree. An explicit link packagePath wins; otherwise the role picks a
// standard folder and the asset's own file name. Collisions are dropped with a
// warning so a package never silently overwrites a file.
export function planPackageLayout(links = []) {
const warnings = [];
const used = new Map();
used.set(ENTRY_SCRIPT_NAME.toLowerCase(), ENTRY_SCRIPT_NAME);
const assets = [];
for (const link of links) {
const folder = ROLE_FOLDER[link.linkRole] || 'SupportFiles';
const name = basenameAny(link.packagePath || link.originalName || link.assetId);
let target = normalizePackagePath(link.packagePath, `${folder}/${name}`) || `${folder}/${name}`;
if (!target.includes('/')) target = `${folder}/${target}`;
const key = target.toLowerCase();
if (used.has(key)) {
warnings.push(`Skipped "${link.originalName || link.assetId}": another file already maps to ${target}.`);
continue;
}
used.set(key, link.originalName || link.assetId);
assets.push({ assetId: link.assetId, packagePath: target, role: link.linkRole, name: link.originalName });
}
const directories = ['Files', 'SupportFiles'];
if (assets.some((asset) => asset.packagePath.startsWith('Assets/'))) directories.push('Assets');
return { scriptPath: ENTRY_SCRIPT_NAME, assets, directories, warnings };
}
// Pure: pick the installer the .intunewin build should treat as its entry setup
// file (relative to the package root). An explicit choice wins; otherwise prefer
// an installer-role asset, then the first file under Files/.
export function resolveSetupFile(layout, explicit = '') {
const explicitClean = normalizePackagePath(explicit, '');
if (explicitClean) return explicitClean;
const installer = layout.assets.find((asset) => asset.role === 'installer');
if (installer) return installer.packagePath;
const firstFile = layout.assets.find((asset) => asset.packagePath.startsWith('Files/'));
return firstFile ? firstFile.packagePath : '';
}
let CRC_TABLE;
function crc32(buf) {
if (!CRC_TABLE) {
CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
CRC_TABLE[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i += 1) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
return (crc ^ -1) >>> 0;
}
// Pure: build a standard PKZIP buffer from entries. Files are deflate-compressed
// (method 8), directories and empty files stored (method 0). A fixed 1980 DOS
// timestamp keeps output reproducible. Round-trips through zlib.inflateRawSync.
export function buildZip(entries = []) {
const local = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const isDir = Boolean(entry.isDirectory);
let name = String(entry.path).replace(/\\/g, '/');
if (isDir && !name.endsWith('/')) name += '/';
const nameBytes = Buffer.from(name, 'utf8');
const raw = isDir ? Buffer.alloc(0) : (Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8'));
const deflated = raw.length ? zlib.deflateRawSync(raw) : Buffer.alloc(0);
const useDeflate = raw.length > 0 && deflated.length < raw.length;
const method = useDeflate ? 8 : 0;
const stored = useDeflate ? deflated : raw;
const crc = crc32(raw);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(method, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0x21, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(stored.length, 18);
localHeader.writeUInt32LE(raw.length, 22);
localHeader.writeUInt16LE(nameBytes.length, 26);
localHeader.writeUInt16LE(0, 28);
local.push(localHeader, nameBytes, stored);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0, 8);
centralHeader.writeUInt16LE(method, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(0x21, 14);
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(stored.length, 20);
centralHeader.writeUInt32LE(raw.length, 24);
centralHeader.writeUInt16LE(nameBytes.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE(isDir ? 0x10 : 0, 38);
centralHeader.writeUInt32LE(offset, 42);
central.push(centralHeader, nameBytes);
offset += localHeader.length + nameBytes.length + stored.length;
}
const centralBuffer = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuffer.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...local, centralBuffer, end]);
}
+244
View File
@@ -0,0 +1,244 @@
// Package documentation — turn an Intune deployment (+ optional linked PSADT
// profile and catalog application) into an auto-generated datasheet. Pure: it
// renders a section model to Markdown or self-contained HTML with no IO. The
// controller loads the records and picks the format.
const RETURN_CODE_LABELS = {
success: 'Success',
softReboot: 'Soft reboot',
hardReboot: 'Hard reboot',
retry: 'Retry',
failed: 'Failed'
};
const INTENT_LABELS = {
available: 'Available',
required: 'Required',
uninstall: 'Uninstall',
exclude: 'Excluded'
};
function dash(value) {
const str = value == null ? '' : String(value).trim();
return str === '' ? '—' : str;
}
// Build a format-neutral section model from the loaded records. Each section is
// one of: { rows } definition list, { table } grid, { code } block, or { text }.
export function buildDatasheetModel({ deployment, profile = null, application = null } = {}) {
if (!deployment) throw new Error('deployment is required');
const title = deployment.name || profile?.name || 'PSADT package';
const vendor = profile?.appVendor || application?.vendor || '';
const appName = profile?.appName || deployment.name || '';
const version = profile?.appVersion || application?.version || '';
const arch = profile?.appArch || deployment.requirements?.architecture || '';
const subtitle = [vendor, appName, version].map((part) => String(part || '').trim()).filter(Boolean).join(' ');
const sections = [];
sections.push({
heading: 'Overview',
rows: [
['Package', title],
['Vendor', vendor],
['Application', appName],
['Version', version],
['Architecture', arch],
['App type', deployment.appType],
['Command style', deployment.commandStyle],
['Install behavior', deployment.installBehavior],
['Restart behavior', deployment.restartBehavior],
['Status', deployment.status]
]
});
sections.push({
heading: 'Install & uninstall',
rows: [
['Install command', { code: deployment.installCommand }],
['Uninstall command', { code: deployment.uninstallCommand }]
]
});
sections.push({
heading: 'Detection',
rows: [['Detection type', deployment.detectionType || 'manual']],
code: deployment.detectionRule ? deployment.detectionRule : null
});
const returnCodes = Array.isArray(deployment.returnCodes) ? deployment.returnCodes : [];
sections.push({
heading: 'Return codes',
table: {
columns: ['Code', 'Type', 'Meaning'],
rows: returnCodes.length
? returnCodes.map((rc) => [String(rc.code), RETURN_CODE_LABELS[rc.type] || rc.type || '', rc.meaning || ''])
: []
}
});
const assignments = Array.isArray(deployment.assignments) ? deployment.assignments : [];
sections.push({
heading: 'Assignments',
table: {
columns: ['Ring', 'Intent', 'Group', 'Notes'],
rows: assignments.length
? assignments.map((a) => [a.ring || '', INTENT_LABELS[a.intent] || a.intent || '', a.groupName || '', a.notes || ''])
: []
}
});
const req = deployment.requirements || {};
sections.push({
heading: 'Requirements',
rows: [
['Architecture', req.architecture],
['Minimum OS', req.minOs],
['Disk space (MB)', req.diskSpaceMb ? String(req.diskSpaceMb) : ''],
['Run as 32-bit', req.runAs32Bit ? 'Yes' : 'No']
]
});
sections.push({
heading: 'Source & version',
rows: [
['PSADT profile', profile?.name || deployment.profileName || ''],
['Source folder', deployment.sourceFolder],
['Package file', deployment.intunewinFile],
['Catalog application', application?.name || deployment.applicationName || ''],
['Version source', application ? `${application.versionSource || ''} ${application.versionSourceRef || ''}`.trim() : '']
]
});
if (deployment.notes) {
sections.push({ heading: 'Notes', text: deployment.notes });
}
return { title, subtitle, generatedAt: new Date().toISOString(), sections };
}
function rowValueToMd(value) {
if (value && typeof value === 'object' && 'code' in value) return `\`${dash(value.code).replace(/`/g, '​`')}\``;
return dash(value);
}
function renderMarkdown(model) {
const lines = [`# ${model.title}`, ''];
if (model.subtitle) lines.push(`*${model.subtitle}*`, '');
lines.push(`Generated ${model.generatedAt}`, '');
for (const section of model.sections) {
lines.push(`## ${section.heading}`, '');
if (section.rows) {
lines.push('| Field | Value |', '| --- | --- |');
for (const [label, value] of section.rows) {
lines.push(`| ${label} | ${rowValueToMd(value).replace(/\n/g, ' ')} |`);
}
lines.push('');
}
if (section.table) {
const { columns, rows } = section.table;
lines.push(`| ${columns.join(' | ')} |`, `| ${columns.map(() => '---').join(' | ')} |`);
if (rows.length) {
for (const row of rows) lines.push(`| ${row.map((cell) => dash(cell).replace(/\n/g, ' ').replace(/\|/g, '\\|')).join(' | ')} |`);
} else {
lines.push(`| ${columns.map((_, i) => (i === 0 ? '_None_' : '')).join(' | ')} |`);
}
lines.push('');
}
if (section.code) {
lines.push('```', section.code, '```', '');
}
if (section.text) {
lines.push(section.text, '');
}
}
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
}
function esc(value) {
return dash(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function rowValueToHtml(value) {
if (value && typeof value === 'object' && 'code' in value) return `<code>${esc(value.code)}</code>`;
return esc(value);
}
function renderHtml(model) {
const parts = [];
for (const section of model.sections) {
parts.push(`<section><h2>${esc(section.heading)}</h2>`);
if (section.rows) {
parts.push('<table class="kv"><tbody>');
for (const [label, value] of section.rows) {
parts.push(`<tr><th>${esc(label)}</th><td>${rowValueToHtml(value)}</td></tr>`);
}
parts.push('</tbody></table>');
}
if (section.table) {
const { columns, rows } = section.table;
parts.push('<table class="grid"><thead><tr>');
parts.push(columns.map((col) => `<th>${esc(col)}</th>`).join(''));
parts.push('</tr></thead><tbody>');
if (rows.length) {
for (const row of rows) {
parts.push('<tr>' + row.map((cell) => `<td>${esc(cell)}</td>`).join('') + '</tr>');
}
} else {
parts.push(`<tr><td class="muted" colspan="${columns.length}">None configured</td></tr>`);
}
parts.push('</tbody></table>');
}
if (section.code) {
parts.push(`<pre><code>${esc(section.code)}</code></pre>`);
}
if (section.text) {
parts.push(`<p>${esc(section.text)}</p>`);
}
parts.push('</section>');
}
return [
'<!doctype html>',
'<html lang="en"><head><meta charset="utf-8">',
`<title>${esc(model.title)} — package datasheet</title>`,
'<style>',
':root{color-scheme:light dark}',
'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}',
'h1{margin-bottom:.25rem}.subtitle{color:#6b7280;margin-top:0}.meta{color:#6b7280;font-size:.85rem}',
'section{margin-top:1.75rem}h2{border-bottom:1px solid #d1d5db;padding-bottom:.25rem}',
'table{border-collapse:collapse;width:100%;margin-top:.5rem}',
'th,td{text-align:left;padding:.4rem .6rem;border:1px solid #d1d5db;vertical-align:top}',
'table.kv th{width:14rem;white-space:nowrap}',
'code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}',
'pre{background:#f3f4f6;padding:.75rem;border-radius:.4rem;overflow:auto}pre code{font-size:.85em}',
'.muted{color:#6b7280}',
'</style></head><body>',
`<h1>${esc(model.title)}</h1>`,
model.subtitle ? `<p class="subtitle">${esc(model.subtitle)}</p>` : '',
`<p class="meta">Generated ${esc(model.generatedAt)}</p>`,
parts.join('\n'),
'</body></html>',
''
].filter((line) => line !== '').join('\n');
}
// Render a datasheet for a deployment. format: 'md' (default) | 'html'.
export function renderDatasheet(records, { format = 'md' } = {}) {
const model = buildDatasheetModel(records);
const normalized = format === 'html' ? 'html' : 'md';
const content = normalized === 'html' ? renderHtml(model) : renderMarkdown(model);
return {
format: normalized,
contentType: normalized === 'html' ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
extension: normalized === 'html' ? 'html' : 'md',
title: model.title,
content
};
}
+171 -247
View File
@@ -1,34 +1,10 @@
import { createRequire } from 'node:module';
import { randomBytes } from 'node:crypto';
import { path } from '../utils/pathUtils.js';
import express from 'express';
import { db, visibleClause } from '../db.js';
import { config } from '../config.js';
import { decryptSecret } from './cryptoStore.js';
import { logger } from './logger.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
const require = createRequire(import.meta.url);
const rdp = require('node-rdpjs');
const socketIo = require('socket.io');
const mstscPackagePath = require.resolve('mstsc.js/package.json');
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
const SESSION_TTL_MS = 5 * 60 * 1000;
const sessions = new Map();
let socketServer = null;
function token() {
return randomBytes(24).toString('base64url');
}
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function splitDomainUsername(username = '') {
const value = String(username || '');
const match = value.match(/^([^\\]+)\\(.+)$/);
@@ -36,11 +12,47 @@ function splitDomainUsername(username = '') {
return { domain: match[1], username: match[2] };
}
function cleanupExpiredSessions() {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sessionToken, session] of sessions.entries()) {
if (session.createdAt < cutoff) sessions.delete(sessionToken);
function numberSetting(key, fallback, min, max) {
const value = Number(getStringSetting(key, String(fallback)));
if (!Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(value, max));
}
function makeHttpError(message, statusCode = 400, details = undefined) {
const error = new Error(message);
error.statusCode = statusCode;
if (details) error.details = details;
return error;
}
export function normalizeMyrtilleGatewayUrl(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
const url = new URL(normalized);
if (!['http:', 'https:'].includes(url.protocol)) {
throw makeHttpError('Myrtille gateway URL must use http or https.', 400);
}
return url.toString();
}
function normalizeHashEndpoint(value) {
const trimmed = String(value || '').trim();
return trimmed || 'GetHash.aspx';
}
function myrtilleSettings() {
const gatewayUrl = normalizeMyrtilleGatewayUrl(getStringSetting('myrtille_gateway_url', config.myrtille.gatewayUrl));
return {
enabled: getBooleanSetting('myrtille_enabled', config.myrtille.enabled),
gatewayUrl,
usePasswordHash: getBooleanSetting('myrtille_use_password_hash', config.myrtille.usePasswordHash),
allowPlainPassword: getBooleanSetting('myrtille_allow_plain_password', config.myrtille.allowPlainPassword),
hashEndpoint: normalizeHashEndpoint(getStringSetting('myrtille_hash_endpoint', config.myrtille.hashEndpoint)),
width: numberSetting('myrtille_default_width', config.myrtille.defaultWidth, 640, 7680),
height: numberSetting('myrtille_default_height', config.myrtille.defaultHeight, 480, 4320),
requestTimeoutMs: numberSetting('myrtille_request_timeout_ms', config.myrtille.requestTimeoutMs, 1000, 120000)
};
}
function loadRdpTarget(hostId, userId) {
@@ -55,246 +67,158 @@ function loadRdpTarget(hostId, userId) {
`).get(hostId, userId, userId, userId, userId);
}
export function createRdpLaunchSession(hostId, userId) {
cleanupExpiredSessions();
async function fetchMyrtillePasswordHash(settings, password) {
const url = new URL(settings.hashEndpoint, settings.gatewayUrl);
url.searchParams.set('password', password);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'text/plain, text/html, */*' },
signal: controller.signal
});
const body = (await response.text()).trim();
if (!response.ok) {
throw makeHttpError(
`Myrtille password hash endpoint returned HTTP ${response.status}.`,
502,
{ status: response.status, body: body.slice(0, 500) }
);
}
const hash = extractHashFromMyrtilleResponse(body);
if (!hash) {
throw makeHttpError('Myrtille password hash endpoint did not return a usable hash.', 502, { body: body.slice(0, 500) });
}
return hash;
} catch (error) {
if (error?.name === 'AbortError') {
throw makeHttpError(`Myrtille password hash request timed out after ${settings.requestTimeoutMs}ms.`, 502);
}
if (error?.statusCode) throw error;
throw makeHttpError(`Unable to request a Myrtille password hash: ${error.message}`, 502);
} finally {
clearTimeout(timeout);
}
}
export function extractHashFromMyrtilleResponse(body = '') {
const text = String(body || '').trim();
if (!text) return '';
const inputMatch = text.match(/value=["']([^"']+)["']/i);
if (inputMatch?.[1]) return inputMatch[1].trim();
const markerMatch = text.match(/passwordHash["'\s:=>-]+([A-Za-z0-9+/=_:-]+)/i);
if (markerMatch?.[1]) return markerMatch[1].trim();
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).find((part) => part.length >= 8) || '';
}
export function buildMyrtilleLaunchUrl({
gatewayUrl,
target,
domain = '',
username,
passwordHash = '',
password = '',
width = 1280,
height = 800
}) {
const url = new URL(gatewayUrl);
url.searchParams.set('__EVENTTARGET', '');
url.searchParams.set('__EVENTARGUMENT', '');
url.searchParams.set('server', target);
if (domain) url.searchParams.set('domain', domain);
url.searchParams.set('user', username);
if (passwordHash) url.searchParams.set('passwordHash', passwordHash);
else if (password) url.searchParams.set('password', password);
url.searchParams.set('width', String(width));
url.searchParams.set('height', String(height));
url.searchParams.set('connect', 'Connect!');
return url.toString();
}
export async function createRdpLaunchSession(hostId, userId) {
const target = loadRdpTarget(hostId, userId);
if (!target) {
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
error.statusCode = 404;
throw error;
throw makeHttpError('Windows host with an assigned visible credential is required for RDP launch.', 404);
}
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
const error = new Error('RDP launch is only available for Windows hosts.');
error.statusCode = 400;
throw error;
throw makeHttpError('RDP launch is only available for Windows hosts.', 400);
}
if (target.credential_kind !== 'username_password') {
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
error.statusCode = 400;
throw error;
throw makeHttpError('RDP launch requires a username/password credential assigned to the host.', 400);
}
const settings = myrtilleSettings();
if (!settings.enabled || !settings.gatewayUrl) {
throw makeHttpError('Myrtille RDP gateway is not configured. Enable myrtille_enabled and set myrtille_gateway_url in Configuration or environment variables.', 400);
}
const password = decryptSecret(target);
if (!password) {
const error = new Error('Assigned host credential does not have a decryptable password.');
error.statusCode = 400;
throw error;
throw makeHttpError('Assigned host credential does not have a decryptable password.', 400);
}
const sessionToken = token();
const address = target.fqdn || target.address;
const parsedUsername = splitDomainUsername(target.credential_username);
const session = {
token: sessionToken,
userId,
hostId: target.id,
hostName: target.name,
address: target.fqdn || target.address,
port: 3389,
let passwordHash = '';
let plainPassword = '';
if (settings.usePasswordHash) {
try {
passwordHash = await fetchMyrtillePasswordHash(settings, password);
} catch (error) {
if (!settings.allowPlainPassword) {
throw makeHttpError(
`${error.message} Myrtille password-hash mode is enabled and plaintext URL fallback is disabled.`,
error.statusCode || 502,
error.details
);
}
logger.warn('falling back to plaintext Myrtille password launch because hash generation failed and fallback is enabled', {
hostId: target.id,
hostName: target.name,
userId,
error: error.message
});
plainPassword = password;
}
} else {
if (!settings.allowPlainPassword) {
throw makeHttpError('Myrtille plaintext password launch is disabled. Enable password-hash mode or explicitly allow plaintext fallback.', 400);
}
plainPassword = password;
}
const url = buildMyrtilleLaunchUrl({
gatewayUrl: settings.gatewayUrl,
target: address,
domain: parsedUsername.domain,
username: parsedUsername.username,
password,
credentialName: target.credential_name,
createdAt: Date.now()
};
sessions.set(sessionToken, session);
logger.info('rdp session created', {
passwordHash,
password: plainPassword,
width: settings.width,
height: settings.height
});
logger.info('myrtille rdp launch created', {
hostId: target.id,
hostName: target.name,
userId,
credentialId: target.credential_id
credentialId: target.credential_id,
gatewayUrl: settings.gatewayUrl,
credentialMode: passwordHash ? 'passwordHash' : 'password'
});
return {
token: sessionToken,
url: `/rdp/${sessionToken}`,
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
provider: 'myrtille',
url,
gatewayUrl: settings.gatewayUrl,
expiresInSeconds: 0,
credentialMode: passwordHash ? 'passwordHash' : 'password',
host: {
id: target.id,
name: target.name,
address: session.address
address
}
};
}
function renderSessionPage(session) {
const safeToken = escapeHtml(session.token);
const safeHost = escapeHtml(session.hostName);
const safeAddress = escapeHtml(session.address);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>RDP - ${safeHost}</title>
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
<script src="/rdp/socket.io/socket.io.js"></script>
<script src="/rdp/assets/js/mstsc.js"></script>
<script src="/rdp/assets/js/keyboard.js"></script>
<script src="/rdp/assets/js/rle.js"></script>
<script src="/rdp/assets/js/client.js"></script>
<script src="/rdp/assets/js/canvas.js"></script>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #eff7ff;
background:
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
}
.launch {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
}
.panel {
width: min(560px, 100%);
border: 1px solid rgba(156, 199, 232, .24);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
padding: 28px;
backdrop-filter: blur(24px) saturate(140%);
}
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
</style>
</head>
<body>
<main id="main" class="launch">
<section class="panel">
<span class="eyebrow">POSHManager RDP</span>
<h1>${safeHost}</h1>
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
</section>
</main>
<canvas id="rdpCanvas"></canvas>
<script>
(function () {
try { window.opener = null; } catch (err) {}
var token = "${safeToken}";
var status = document.getElementById('status');
var canvas = document.getElementById('rdpCanvas');
var main = document.getElementById('main');
var client = Mstsc.client.create(canvas);
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
status.textContent = 'Connecting...';
canvas.style.display = 'block';
main.style.display = 'none';
client.connect('poshmanager-session', '', '', token, function (err) {
canvas.style.display = 'none';
main.style.display = 'grid';
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
});
}());
</script>
</body>
</html>`;
}
export function mountRdpGateway(app) {
app.use('/rdp/assets', express.static(mstscClientDir, {
fallthrough: false,
immutable: true,
maxAge: '1h'
}));
app.get('/rdp/sessions/:token', (req, res) => {
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
});
app.get('/rdp/:token', (req, res) => {
cleanupExpiredSessions();
const session = sessions.get(req.params.token);
if (!session) return res.status(404).send('RDP session expired or not found.');
res.type('html').send(renderSessionPage(session));
});
}
export function startRdpGateway(httpServer) {
if (socketServer) return socketServer;
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
socketServer.on('connection', (client) => {
let rdpClient = null;
client.on('infos', (infos = {}) => {
cleanupExpiredSessions();
const sessionToken = infos.token || infos.sessionToken || infos.password;
const session = sessions.get(sessionToken);
if (!session) {
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
client.disconnect();
return;
}
if (rdpClient) rdpClient.close();
const screen = {
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
};
logger.info('rdp connection starting', {
hostId: session.hostId,
hostName: session.hostName,
address: session.address,
userId: session.userId
});
rdpClient = rdp.createClient({
domain: session.domain,
userName: session.username,
password: session.password,
enablePerf: true,
autoLogin: true,
screen,
locale: infos.locale,
logLevel: 'ERROR'
}).on('connect', () => {
client.emit('rdp-connect');
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('bitmap', (bitmap) => {
client.emit('rdp-bitmap', bitmap);
}).on('close', () => {
client.emit('rdp-close');
sessions.delete(sessionToken);
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('error', (err) => {
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
}).connect(session.address, session.port);
});
client.on('mouse', (x, y, button, isPressed) => {
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
});
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
});
client.on('scancode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
});
client.on('unicode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
});
client.on('disconnect', () => {
if (rdpClient) rdpClient.close();
});
});
logger.info('POSHManager RDP gateway mounted at /rdp');
return socketServer;
}
+95
View File
@@ -0,0 +1,95 @@
import { RECIPES } from '../data/recipeCatalog.js';
import { analyzeInstaller } from './installerIntelService.js';
import { buildDetectionRule } from './detectionRuleService.js';
// Lists/searches the curated catalog and turns a recipe into the request bodies
// the models accept (PSADT profile + Intune deployment + winget-tracked app).
// installTask type enum is exe|msi|msp|script; everything EXE-based maps to exe.
function taskTypeFor(installerType = '') {
if (installerType === 'msi') return 'msi';
if (installerType === 'msp') return 'msp';
return 'exe';
}
function summarize(recipe) {
return {
id: recipe.id,
vendor: recipe.vendor,
name: recipe.name,
category: recipe.category || 'Other',
installerType: recipe.installerType,
fileName: recipe.fileName,
arch: recipe.arch || 'x64',
installArgs: recipe.installArgs || '',
detectionKind: recipe.detection?.type || '',
wingetId: recipe.wingetId || '',
homepage: recipe.homepage || ''
};
}
export function listRecipes() {
return RECIPES.map(summarize);
}
export function searchRecipes(query = '') {
const q = String(query).trim().toLowerCase();
if (!q) return listRecipes();
return RECIPES.filter((r) =>
[r.vendor, r.name, r.category, r.wingetId, r.id]
.some((field) => String(field || '').toLowerCase().includes(q))
).map(summarize);
}
export function getRecipe(id) {
return RECIPES.find((r) => r.id === id) || null;
}
// recipe → { profile, deployment, application }; application is null with no winget id.
export function buildRecipeArtifacts(recipe, { visibility = 'personal', groupId = null } = {}) {
if (!recipe) throw new Error('recipe is required');
const scope = { visibility, groupId: visibility === 'group' ? groupId || null : null };
const displayName = `${recipe.vendor} ${recipe.name}`.trim();
const analysis = analyzeInstaller({ fileName: recipe.fileName, productCode: recipe.productCode || '' });
const detection = buildDetectionRule(recipe.detection || {});
const profile = {
name: displayName,
appVendor: recipe.vendor,
appName: recipe.name,
appVersion: recipe.version || '',
appArch: recipe.arch === 'x86' ? 'x86' : 'x64',
closeProcesses: recipe.closeProcesses || [],
installTasks: [{
type: taskTypeFor(recipe.installerType),
phase: 'Install',
filePath: recipe.fileName,
arguments: recipe.installArgs || ''
}],
...scope
};
const deployment = {
name: displayName,
appType: 'Windows app (Win32)',
commandStyle: 'v4',
requirements: { architecture: recipe.arch === 'x86' ? 'x86' : 'x64' },
detectionType: detection.detectionType,
detectionRule: detection.detectionRule,
status: 'draft',
notes: `Created from the "${recipe.name}" recipe. Source: ${recipe.homepage || 'n/a'}`,
...scope
};
const application = recipe.wingetId ? {
name: displayName,
vendor: recipe.vendor,
versionSource: 'winget',
versionSourceRef: recipe.wingetId,
autoCheck: true,
notes: `Auto-tracking ${recipe.wingetId} via winget (from the "${recipe.name}" recipe).`,
...scope
} : null;
return { recipeId: recipe.id, displayName, profile, deployment, application, analysis, detection };
}
+8
View File
@@ -35,6 +35,14 @@ export function settingDefinitions() {
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
{ key: 'myrtille_enabled', value: String(config.myrtille.enabled), pinned: envProvided('MYRTILLE_ENABLED') },
{ key: 'myrtille_gateway_url', value: config.myrtille.gatewayUrl, pinned: envProvided('MYRTILLE_GATEWAY_URL') },
{ key: 'myrtille_use_password_hash', value: String(config.myrtille.usePasswordHash), pinned: envProvided('MYRTILLE_USE_PASSWORD_HASH') },
{ key: 'myrtille_allow_plain_password', value: String(config.myrtille.allowPlainPassword), pinned: envProvided('MYRTILLE_ALLOW_PLAIN_PASSWORD') },
{ key: 'myrtille_hash_endpoint', value: config.myrtille.hashEndpoint, pinned: envProvided('MYRTILLE_HASH_ENDPOINT') },
{ key: 'myrtille_default_width', value: String(config.myrtille.defaultWidth), pinned: envProvided('MYRTILLE_DEFAULT_WIDTH') },
{ key: 'myrtille_default_height', value: String(config.myrtille.defaultHeight), pinned: envProvided('MYRTILLE_DEFAULT_HEIGHT') },
{ key: 'myrtille_request_timeout_ms', value: String(config.myrtille.requestTimeoutMs), pinned: envProvided('MYRTILLE_REQUEST_TIMEOUT_MS') },
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },
+50 -8
View File
@@ -6,7 +6,8 @@ const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } =
const { createCredential } = await load('../models/credentialModel.js');
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
const { createRdpLaunchSession, buildMyrtilleLaunchUrl } = await load('../services/rdpGatewayService.js');
const { updateSettings } = await load('../models/settingsModel.js');
const { db } = await load('../db.js');
const owner = adminUserId();
@@ -54,7 +55,15 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
assert.deepEqual(visibleToOther, [shared.id]);
});
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
test('RDP launch creates a Myrtille browser URL for Windows hosts with visible credentials', async () => {
updateSettings({
myrtille_enabled: 'true',
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
myrtille_use_password_hash: 'false',
myrtille_allow_plain_password: 'true',
myrtille_default_width: '1440',
myrtille_default_height: '900'
});
const credential = createCredential({
name: 'RDP Admin',
kind: 'username_password',
@@ -72,14 +81,28 @@ test('RDP launch creates a short-lived browser session for Windows hosts with vi
tags: [],
visibility: 'shared'
}, owner);
const session = createRdpLaunchSession(host.id, other);
assert.match(session.url, /^\/rdp\/[^/]+$/);
const session = await createRdpLaunchSession(host.id, other);
const url = new URL(session.url);
assert.equal(session.provider, 'myrtille');
assert.equal(url.origin + url.pathname, 'https://rdp.contoso.local/Myrtille/');
assert.equal(url.searchParams.get('server'), 'rdp-win-01.contoso.local');
assert.equal(url.searchParams.get('domain'), 'CONTOSO');
assert.equal(url.searchParams.get('user'), 'rdpadmin');
assert.equal(url.searchParams.get('password'), 'rdp-secret');
assert.equal(url.searchParams.get('width'), '1440');
assert.equal(url.searchParams.get('height'), '900');
assert.equal(url.searchParams.get('connect'), 'Connect!');
assert.equal(session.host.name, 'RDP-WIN-01');
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
assert.ok(!session.url.includes('rdp-secret'));
});
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
test('RDP launch rejects non-Windows hosts, hosts without credentials, or disabled Myrtille config', async () => {
updateSettings({
myrtille_enabled: 'true',
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
myrtille_use_password_hash: 'false',
myrtille_allow_plain_password: 'true'
});
const credential = createCredential({
name: 'RDP Linux Credential',
kind: 'username_password',
@@ -88,10 +111,29 @@ test('RDP launch rejects non-Windows hosts or hosts without credentials', () =>
visibility: 'shared'
}, owner);
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
await assert.rejects(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
await assert.rejects(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
const windows = createHost({ name: 'RDP-DISABLED-01', address: 'rdp-disabled.local', osFamily: 'windows', transport: 'winrm', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
updateSettings({ myrtille_enabled: 'false' });
await assert.rejects(() => createRdpLaunchSession(windows.id, owner), /Myrtille RDP gateway is not configured/i);
});
test('Myrtille launch URL can carry passwordHash without exposing plaintext', () => {
const url = new URL(buildMyrtilleLaunchUrl({
gatewayUrl: 'https://rdp.contoso.local/Myrtille/',
target: 'win-01.contoso.local',
domain: 'CONTOSO',
username: 'admin',
passwordHash: 'hash-value',
width: 1280,
height: 800
}));
assert.equal(url.searchParams.get('passwordHash'), 'hash-value');
assert.equal(url.searchParams.has('password'), false);
assert.equal(url.searchParams.get('connect'), 'Connect!');
});
test('manual host groups keep explicit members', () => {
+67
View File
@@ -0,0 +1,67 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { adminUserId, load } from './setup.mjs';
const owner = adminUserId();
const { createPsadtProfile, renderPsadtProfile } = await load('../models/psadtModel.js');
const { createAssetFromUpload, createAssetLink, listLinkedAssets } = await load('../models/assetModel.js');
const { buildZip, planPackageLayout } = await load('../services/packageBuilder.js');
function seedAsset(name, contents) {
const tmp = path.join(os.tmpdir(), `pkg-src-${Math.random().toString(16).slice(2)}`);
fs.writeFileSync(tmp, contents);
return createAssetFromUpload(
{ path: tmp, originalname: name, mimetype: 'application/octet-stream', size: contents.length },
{ name, visibility: 'shared' },
owner
);
}
test('listLinkedAssets returns a PSADT profile\'s linked assets with their package path and role', () => {
const profile = createPsadtProfile({ name: 'Linked Pkg App', appVendor: 'Acme', appName: 'Tool', visibility: 'shared' }, owner);
const installer = seedAsset('setup.exe', Buffer.from('MZ binary'));
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer', packagePath: 'Files/setup.exe' }, owner);
const linked = listLinkedAssets('psadt_profile', profile.id, owner);
assert.equal(linked.length, 1);
assert.equal(linked[0].assetId, installer.id);
assert.equal(linked[0].linkRole, 'installer');
assert.equal(linked[0].packagePath, 'Files/setup.exe');
});
test('a profile + linked assets assemble into a zip carrying the rendered script and payload', () => {
const profile = createPsadtProfile({ name: 'Assemble App', appVendor: 'Acme', appName: 'Assemble', visibility: 'shared' }, owner);
const installer = seedAsset('install.exe', Buffer.from('payload-bytes'));
const config = seedAsset('settings.xml', Buffer.from('<config/>'));
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer' }, owner);
createAssetLink(config.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'support-file' }, owner);
const rendered = renderPsadtProfile(profile.id, owner);
const layout = planPackageLayout(listLinkedAssets('psadt_profile', profile.id, owner));
assert.deepEqual(layout.assets.map((a) => a.packagePath).sort(), ['Files/install.exe', 'SupportFiles/settings.xml']);
const zip = buildZip([
{ path: 'Invoke-AppDeployToolkit.ps1', data: Buffer.from(rendered.script, 'utf8') },
{ path: 'Files/install.exe', data: Buffer.from('payload-bytes') }
]);
const names = listZipNames(zip);
assert.ok(names.includes('Invoke-AppDeployToolkit.ps1'));
assert.ok(names.includes('Files/install.exe'));
});
function listZipNames(buf) {
const names = [];
let pos = 0;
while (buf.readUInt32LE(pos) === 0x04034b50) {
const compSize = buf.readUInt32LE(pos + 18);
const nameLen = buf.readUInt16LE(pos + 26);
const extraLen = buf.readUInt16LE(pos + 28);
names.push(buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8'));
pos = pos + 30 + nameLen + extraLen + compSize;
}
return names;
}
+104
View File
@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import zlib from 'node:zlib';
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
// --- layout planning (pure) --------------------------------------------------
test('planPackageLayout places linked assets by role when no package path is set', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' },
{ assetId: 'a2', originalName: 'config.xml', linkRole: 'support-file' },
{ assetId: 'a3', originalName: 'icon.png', linkRole: 'icon' }
]);
assert.equal(layout.scriptPath, ENTRY_SCRIPT_NAME);
assert.deepEqual(layout.assets.map((a) => a.packagePath), ['Files/setup.exe', 'SupportFiles/config.xml', 'Assets/icon.png']);
assert.ok(layout.directories.includes('Assets'), 'Assets dir added because an icon is present');
});
test('planPackageLayout honors an explicit package path verbatim', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/x64/setup.exe' }
]);
assert.equal(layout.assets[0].packagePath, 'Files/x64/setup.exe');
});
test('planPackageLayout has no Assets dir when nothing maps there', () => {
const layout = planPackageLayout([{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' }]);
assert.deepEqual(layout.directories, ['Files', 'SupportFiles']);
});
test('planPackageLayout drops colliding targets with a warning', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/setup.exe' },
{ assetId: 'a2', originalName: 'other.exe', linkRole: 'installer', packagePath: 'files/SETUP.exe' }
]);
assert.equal(layout.assets.length, 1, 'second asset on the same path is skipped');
assert.equal(layout.warnings.length, 1);
});
test('planPackageLayout relocates a bare entry-script name under its role folder, never overwriting root', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'Invoke-AppDeployToolkit.ps1', linkRole: 'reference', packagePath: 'Invoke-AppDeployToolkit.ps1' }
]);
assert.equal(layout.assets[0].packagePath, 'SupportFiles/Invoke-AppDeployToolkit.ps1');
assert.notEqual(layout.assets[0].packagePath, ENTRY_SCRIPT_NAME);
});
// --- setup file resolution (pure) --------------------------------------------
test('resolveSetupFile prefers an explicit choice, then installer role, then first Files entry', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' },
{ assetId: 'a2', originalName: 'setup.exe', linkRole: 'installer' }
]);
assert.equal(resolveSetupFile(layout, 'Files/manual.exe'), 'Files/manual.exe');
assert.equal(resolveSetupFile(layout), 'Files/setup.exe');
const noInstaller = planPackageLayout([{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' }]);
assert.equal(resolveSetupFile(noInstaller), 'Files/helper.exe');
const empty = planPackageLayout([]);
assert.equal(resolveSetupFile(empty), '');
});
// --- zip writer (pure, round-trips through zlib) -----------------------------
test('buildZip produces a valid archive whose entries inflate back to their input', () => {
const script = 'Write-Output "hello"\n'.repeat(50);
const binary = Buffer.from([0, 1, 2, 3, 255, 254, 253]);
const zip = buildZip([
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(script, 'utf8') },
{ path: 'Files', isDirectory: true },
{ path: 'Files/payload.bin', data: binary }
]);
assert.equal(zip.readUInt32LE(0), 0x04034b50, 'starts with a local file header');
const recovered = readZip(zip);
assert.equal(recovered.get(ENTRY_SCRIPT_NAME).toString('utf8'), script);
assert.ok(recovered.has('Files/'), 'directory entry is present');
assert.deepEqual([...recovered.get('Files/payload.bin')], [...binary]);
});
test('buildZip stores incompressible/empty data without inflating it', () => {
const zip = buildZip([{ path: 'a.txt', data: Buffer.alloc(0) }]);
assert.equal(readZip(zip).get('a.txt').length, 0);
});
// Minimal central-directory reader for the test: walk local headers and inflate.
function readZip(buf) {
const out = new Map();
let pos = 0;
while (buf.readUInt32LE(pos) === 0x04034b50) {
const method = buf.readUInt16LE(pos + 8);
const compSize = buf.readUInt32LE(pos + 18);
const nameLen = buf.readUInt16LE(pos + 26);
const extraLen = buf.readUInt16LE(pos + 28);
const name = buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8');
const dataStart = pos + 30 + nameLen + extraLen;
const stored = buf.slice(dataStart, dataStart + compSize);
out.set(name, method === 8 ? zlib.inflateRawSync(stored) : stored);
pos = dataStart + compSize;
}
return out;
}
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDatasheetModel, renderDatasheet } from '../services/packageDocService.js';
function sampleDeployment(overrides = {}) {
return {
id: 'intune_1',
name: 'Acme Reader 24.1',
profileId: 'psadt_1',
profileName: 'Acme Reader',
applicationId: null,
appType: 'Windows app (Win32)',
commandStyle: 'v4',
installBehavior: 'system',
restartBehavior: 'return-code',
sourceFolder: 'C:\\packages\\reader',
intunewinFile: 'reader.intunewin',
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
detectionType: 'msi-product-code',
detectionRule: '{12345678-ABCD-1234-ABCD-1234567890AB}',
requirements: { architecture: 'x64', minOs: 'Windows 10 22H2', diskSpaceMb: 250, runAs32Bit: false },
returnCodes: [
{ code: 0, type: 'success', meaning: 'Install completed.' },
{ code: 3010, type: 'softReboot', meaning: 'Reboot required.' }
],
assignments: [
{ ring: 'Pilot', intent: 'available', groupName: 'IT-Pilot', notes: 'Validation ring.' }
],
status: 'draft',
notes: 'Created from the recipe.',
...overrides
};
}
const sampleProfile = {
name: 'Acme Reader',
appVendor: 'Acme',
appName: 'Reader',
appVersion: '24.1',
appArch: 'x64'
};
test('buildDatasheetModel pulls metadata from the profile and lays out every section', () => {
const model = buildDatasheetModel({ deployment: sampleDeployment(), profile: sampleProfile });
assert.equal(model.title, 'Acme Reader 24.1');
assert.equal(model.subtitle, 'Acme Reader 24.1');
const headings = model.sections.map((s) => s.heading);
assert.deepEqual(headings, ['Overview', 'Install & uninstall', 'Detection', 'Return codes', 'Assignments', 'Requirements', 'Source & version', 'Notes']);
});
test('buildDatasheetModel requires a deployment', () => {
assert.throws(() => buildDatasheetModel({}), /deployment is required/);
});
test('buildDatasheetModel omits the Notes section when there are no notes', () => {
const model = buildDatasheetModel({ deployment: sampleDeployment({ notes: '' }), profile: sampleProfile });
assert.ok(!model.sections.some((s) => s.heading === 'Notes'));
});
test('renderDatasheet markdown carries commands, detection, return codes and assignments', () => {
const doc = renderDatasheet({ deployment: sampleDeployment(), profile: sampleProfile }, { format: 'md' });
assert.equal(doc.format, 'md');
assert.equal(doc.extension, 'md');
assert.match(doc.contentType, /text\/markdown/);
assert.match(doc.content, /^# Acme Reader 24\.1/);
assert.match(doc.content, /Invoke-AppDeployToolkit\.exe -DeploymentType Install/);
assert.match(doc.content, /\{12345678-ABCD-1234-ABCD-1234567890AB\}/);
assert.match(doc.content, /\| 3010 \| Soft reboot \| Reboot required\. \|/);
assert.match(doc.content, /\| Pilot \| Available \| IT-Pilot \| Validation ring\. \|/);
});
test('renderDatasheet html is self-contained and escapes user content', () => {
const doc = renderDatasheet(
{ deployment: sampleDeployment({ notes: 'Watch <script> & "quotes"' }), profile: sampleProfile },
{ format: 'html' }
);
assert.equal(doc.format, 'html');
assert.equal(doc.extension, 'html');
assert.match(doc.contentType, /text\/html/);
assert.match(doc.content, /^<!doctype html>/);
assert.match(doc.content, /Watch &lt;script&gt; &amp; &quot;quotes&quot;/);
assert.ok(!doc.content.includes('<script>'), 'no unescaped script tag leaks through');
});
test('renderDatasheet shows empty tables gracefully and defaults to markdown', () => {
const doc = renderDatasheet({ deployment: sampleDeployment({ assignments: [], returnCodes: [] }) });
assert.equal(doc.format, 'md');
assert.match(doc.content, /_None_/);
});
test('renderDatasheet falls back to the deployment name without a profile', () => {
const doc = renderDatasheet({ deployment: sampleDeployment({ profileId: null }) }, { format: 'md' });
assert.match(doc.content, /# Acme Reader 24\.1/);
});
+92
View File
@@ -0,0 +1,92 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { listRecipes, searchRecipes, getRecipe, buildRecipeArtifacts } =
await load('../services/recipeService.js');
// --- catalog -----------------------------------------------------------------
test('listRecipes exposes a sanitized catalog with the fields the UI needs', () => {
const list = listRecipes();
assert.ok(list.length >= 10, 'catalog has a useful number of recipes');
const chrome = list.find((r) => r.id === 'google-chrome');
assert.ok(chrome);
assert.equal(chrome.wingetId, 'Google.Chrome');
assert.ok(chrome.installArgs.includes('/qn'));
assert.ok(chrome.homepage.startsWith('https://'));
});
test('every recipe references a known installer technology and a valid detection footprint', async () => {
const { listInstallerTechnologies } = await load('../services/installerIntelService.js');
const techIds = new Set(listInstallerTechnologies().map((t) => t.id));
for (const r of listRecipes()) {
assert.ok(techIds.has(r.installerType), `${r.id} -> ${r.installerType} is a known technology`);
// Detection must not throw when applied (file/registry/msi all valid here).
assert.doesNotThrow(() => buildRecipeArtifacts(getRecipe(r.id)), `${r.id} detection builds`);
}
});
test('enterprise agents without a winget id still build, but skip the catalog app', () => {
const falcon = getRecipe('crowdstrike-falcon');
assert.equal(falcon.wingetId, '');
const { application, deployment } = buildRecipeArtifacts(falcon);
assert.equal(application, null, 'no winget id -> no auto-tracked app');
assert.ok(deployment.detectionRule.includes('CSFalconService.exe'));
});
test('searchRecipes matches vendor, name, and winget id case-insensitively', () => {
assert.ok(searchRecipes('mozilla').some((r) => r.id === 'mozilla-firefox'));
assert.ok(searchRecipes('VLC').some((r) => r.id === 'vlc'));
assert.ok(searchRecipes('Microsoft.PowerShell').some((r) => r.id === 'powershell-7'));
assert.equal(searchRecipes('nonexistent-app-xyz').length, 0);
assert.equal(searchRecipes('').length, listRecipes().length);
});
// --- apply transform (pure) --------------------------------------------------
test('buildRecipeArtifacts produces profile + deployment + tracked app for an MSI recipe', () => {
const { profile, deployment, application } = buildRecipeArtifacts(getRecipe('google-chrome'));
// Profile carries the raw silent install as a PSADT install task.
assert.equal(profile.name, 'Google Chrome');
assert.equal(profile.appVendor, 'Google');
assert.equal(profile.installTasks.length, 1);
assert.equal(profile.installTasks[0].type, 'msi');
assert.equal(profile.installTasks[0].filePath, 'googlechromestandaloneenterprise64.msi');
assert.ok(profile.installTasks[0].arguments.includes('/qn'));
// Deployment wraps it in PSADT v4 with a file-detection rule.
assert.equal(deployment.commandStyle, 'v4');
assert.equal(deployment.detectionType, 'custom-script');
assert.ok(deployment.detectionRule.includes('chrome.exe'));
assert.equal(deployment.status, 'draft');
// Catalog app is wired to the winget version watcher.
assert.equal(application.versionSource, 'winget');
assert.equal(application.versionSourceRef, 'Google.Chrome');
assert.equal(application.autoCheck, true);
});
test('EXE-based recipes become exe install tasks with the silent switch', () => {
const { profile } = buildRecipeArtifacts(getRecipe('git'));
assert.equal(profile.installTasks[0].type, 'exe');
assert.ok(profile.installTasks[0].arguments.includes('/VERYSILENT'));
});
test('applying with group visibility scopes the group id', () => {
const { profile, deployment } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'group', groupId: 'grp_1' });
assert.equal(profile.visibility, 'group');
assert.equal(profile.groupId, 'grp_1');
assert.equal(deployment.groupId, 'grp_1');
});
test('group id is dropped when visibility is not group', () => {
const { profile } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'personal', groupId: 'grp_1' });
assert.equal(profile.groupId, null);
});
test('buildRecipeArtifacts throws on a missing recipe', () => {
assert.throws(() => buildRecipeArtifacts(null));
});