This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
// Application version comparison + upstream version lookup for the Phase 5
// catalog. The comparison and Graph/winget mapping are pure and unit-tested; the
// network lookups (`fetchLatestVersion`) require live sources and are marked
// needs-live-verification.
// Compare two dotted version strings numerically with lexical fallback for
// non-numeric segments. Returns -1 (a<b), 0 (equal), or 1 (a>b).
export function compareVersions(a = '', b = '') {
const norm = (v) => String(v).trim().replace(/^v/i, '').split(/[.\-+]/).filter(Boolean);
const pa = norm(a);
const pb = norm(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const sa = pa[i] ?? '0';
const sb = pb[i] ?? '0';
const na = Number(sa);
const nb = Number(sb);
const bothNumeric = Number.isFinite(na) && Number.isFinite(nb) && /^\d+$/.test(sa) && /^\d+$/.test(sb);
let cmp;
if (bothNumeric) cmp = na === nb ? 0 : (na < nb ? -1 : 1);
else cmp = sa === sb ? 0 : (sa < sb ? -1 : 1);
if (cmp !== 0) return cmp;
}
return 0;
}
// Decide whether a catalog entry has a newer upstream version than what is
// currently published.
export function evaluateUpdateState({ currentVersion, latestKnownVersion } = {}) {
const current = currentVersion || '';
const latest = latestKnownVersion || '';
if (!latest) return { updateAvailable: false, current, latest, reason: 'no upstream version known' };
if (!current) return { updateAvailable: true, current, latest, reason: 'no current version recorded' };
const cmp = compareVersions(latest, current);
return {
updateAvailable: cmp > 0,
current,
latest,
reason: cmp > 0 ? 'newer upstream version available' : 'up to date'
};
}
// Pure mapping from a Graph mobileApp (win32LobApp) to catalog fields, used when
// adopting an existing tenant app into the catalog.
export function mapGraphAppToCatalog(graphApp = {}) {
return {
name: graphApp.displayName || graphApp.name || graphApp.id || 'Imported app',
vendor: graphApp.publisher || '',
publisher: graphApp.publisher || '',
currentVersion: graphApp.displayVersion || graphApp.version || '',
currentGraphAppId: graphApp.id || ''
};
}
// Look up the latest upstream version for a catalog entry. `manual` sources are
// operator-driven; `url` expects JSON with a `version` field; `winget` queries a
// community winget index (configurable ref). Network-dependent — verify live.
export async function fetchLatestVersion({ versionSource = 'manual', versionSourceRef = '' } = {}) {
if (versionSource === 'manual' || !versionSourceRef) {
return { version: '', source: 'manual', message: 'Manual source; set the latest version by hand.' };
}
if (versionSource === 'url') {
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`Version URL returned ${res.status}`);
const data = await res.json().catch(() => ({}));
const version = data.version || data.latestVersion || data.tag_name || '';
return { version: String(version).replace(/^v/i, ''), source: 'url', message: version ? '' : 'No version field found in URL response.' };
}
if (versionSource === 'winget') {
// Community winget index (e.g. https://api.winget.run/v2/packages/<id>).
const res = await fetch(versionSourceRef, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`winget source returned ${res.status}`);
const data = await res.json().catch(() => ({}));
const version = data?.Package?.Latest?.PackageVersion || data?.latestVersion || data?.version || '';
return { version: String(version), source: 'winget', message: version ? '' : 'No version found in winget response.' };
}
throw new Error(`Unknown version source "${versionSource}"`);
}

View File

@@ -0,0 +1,49 @@
import { applyVersionCheck, listAutoCheckApplications } from '../models/applicationModel.js';
import { fetchLatestVersion } from './appVersionService.js';
import { logger } from './logger.js';
// Background upstream-version checker for catalog applications that opted in via
// `autoCheck`. The runner is dependency-injected (fetcher) so it can be tested
// without network access; the scheduler wires in the real fetchLatestVersion.
export async function runCatalogChecks({ fetcher = fetchLatestVersion } = {}) {
const apps = listAutoCheckApplications();
const summary = { checked: 0, updated: 0, errors: 0 };
for (const app of apps) {
summary.checked += 1;
try {
const result = await fetcher(app);
const next = result.version || '';
const changed = next && next !== app.latestKnownVersion;
applyVersionCheck(app.id, { latestKnownVersion: next || app.latestKnownVersion, message: result.message || '' });
if (changed) summary.updated += 1;
} catch (error) {
summary.errors += 1;
applyVersionCheck(app.id, { latestKnownVersion: app.latestKnownVersion, message: error.message });
}
}
return summary;
}
let timer = null;
// Start the recurring worker. Disabled unless CATALOG_CHECK_INTERVAL_MINUTES > 0
// so installs never make surprise outbound calls.
export function startCatalogWorker() {
const minutes = Number(process.env.CATALOG_CHECK_INTERVAL_MINUTES || 0);
if (!Number.isFinite(minutes) || minutes <= 0) return null;
const intervalMs = minutes * 60 * 1000;
timer = setInterval(() => {
runCatalogChecks()
.then((summary) => logger.info('catalog auto-check complete', summary))
.catch((error) => logger.error('catalog auto-check failed', { error: error.message }));
}, intervalMs);
timer.unref?.();
logger.info(`catalog auto-check scheduled every ${minutes} minute(s)`);
return timer;
}
export function stopCatalogWorker() {
if (timer) clearInterval(timer);
timer = null;
}

View File

@@ -0,0 +1,35 @@
import crypto from 'node:crypto';
import { config } from '../config.js';
function keyBuffer() {
// Derive fixed-length AES key material from the configured secret-like environment value.
return crypto.createHash('sha256').update(config.credentialStoreKey).digest();
}
export function encryptSecret(value = '') {
// AES-GCM stores cipher text plus IV/tag so reads can expose metadata without returning secrets.
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', keyBuffer(), iv);
const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
cipher: encrypted.toString('base64'),
iv: iv.toString('base64'),
tag: tag.toString('base64')
};
}
export function decryptSecret(record) {
// Missing cipher fields mean the credential has no retrievable secret rather than throwing.
if (!record?.secret_cipher || !record?.secret_iv || !record?.secret_tag) return '';
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
keyBuffer(),
Buffer.from(record.secret_iv, 'base64')
);
decipher.setAuthTag(Buffer.from(record.secret_tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(record.secret_cipher, 'base64')),
decipher.final()
]).toString('utf8');
}

View File

@@ -0,0 +1,66 @@
// Generate a "software footprint" detection rule from simple inputs, in the
// shape the deployment model already uses (`detectionType` + `detectionRule`).
// MSI maps to a product-code rule; file/registry produce Intune-style custom
// PowerShell detection scripts so they flow straight through publishing without
// extra mapping. Pure + unit-tested.
function fileDetectionScript({ path, version }) {
const lines = [`$path = '${path.replace(/'/g, "''")}'`, 'if (Test-Path -LiteralPath $path) {'];
if (version) {
lines.push(" $found = (Get-Item -LiteralPath $path).VersionInfo.ProductVersion");
lines.push(` if ($found -and [version]$found -ge [version]'${version}') { Write-Output 'Detected'; exit 0 }`);
} else {
lines.push(" Write-Output 'Detected'; exit 0");
}
lines.push('}');
lines.push('exit 1');
return lines.join('\n');
}
function registryDetectionScript({ keyPath, valueName, valueData }) {
const key = keyPath.replace(/'/g, "''");
const lines = [`$key = '${key}'`];
if (valueName) {
lines.push("$value = (Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue).'" + valueName.replace(/'/g, "''") + "'");
if (valueData) {
lines.push(`if ($value -eq '${valueData.replace(/'/g, "''")}') { Write-Output 'Detected'; exit 0 }`);
} else {
lines.push("if ($null -ne $value) { Write-Output 'Detected'; exit 0 }");
}
} else {
lines.push("if (Test-Path -LiteralPath $key) { Write-Output 'Detected'; exit 0 }");
}
lines.push('exit 1');
return lines.join('\n');
}
export function buildDetectionRule(input = {}) {
switch (input.type) {
case 'msi': {
if (!input.productCode) throw new Error('productCode is required for MSI detection');
return {
detectionType: 'msi-product-code',
detectionRule: input.productCode,
description: `MSI product code ${input.productCode}`
};
}
case 'file': {
if (!input.path) throw new Error('path is required for file detection');
return {
detectionType: 'custom-script',
detectionRule: fileDetectionScript(input),
description: input.version ? `File ${input.path} version ≥ ${input.version}` : `File exists: ${input.path}`
};
}
case 'registry': {
if (!input.keyPath) throw new Error('keyPath is required for registry detection');
return {
detectionType: 'custom-script',
detectionRule: registryDetectionScript(input),
description: input.valueName ? `Registry ${input.keyPath}\\${input.valueName}` : `Registry key ${input.keyPath}`
};
}
default:
throw new Error(`Unknown detection type "${input.type}" (expected msi, file, or registry)`);
}
}

View File

@@ -0,0 +1,87 @@
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { getSetting, getBooleanSetting } from '../models/settingsModel.js';
// Microsoft Entra ID (Azure AD) OAuth 2.0 authorization-code login.
//
// Tenant/client/callback values are read from the effective settings so admins
// can adjust them in the Config screen, while the client secret stays in the
// process environment (never persisted to the settings table). The flow uses
// Node's global fetch and the existing jsonwebtoken dependency for signed
// state, so no new packages are required.
const AUTHORITY_HOST = 'https://login.microsoftonline.com';
const GRAPH_ME_URL = 'https://graph.microsoft.com/v1.0/me';
const LOGIN_SCOPE = 'openid profile email https://graph.microsoft.com/User.Read';
export function entraSettings() {
return {
enabled: getBooleanSetting('entra_enabled', config.entra.enabled),
tenantId: getSetting('entra_tenant_id') || config.entra.tenantId,
clientId: getSetting('entra_client_id') || config.entra.clientId,
clientSecret: config.entra.clientSecret,
callbackUrl: getSetting('entra_callback_url') || config.entra.callbackUrl
};
}
// Login is only offered when Entra is enabled AND fully configured. Missing any
// piece (common in dev) means we fall back to local auth without erroring.
export function isEntraLoginConfigured() {
const s = entraSettings();
return Boolean(s.enabled && s.tenantId && s.clientId && s.clientSecret && s.callbackUrl);
}
export function signState(payload = {}) {
return jwt.sign({ ...payload, purpose: 'entra-state' }, config.jwtSecret, { expiresIn: '10m' });
}
export function verifyState(state) {
const decoded = jwt.verify(state, config.jwtSecret);
if (decoded.purpose !== 'entra-state') throw new Error('Invalid state');
return decoded;
}
export function buildAuthorizeUrl(state) {
const s = entraSettings();
const params = new URLSearchParams({
client_id: s.clientId,
response_type: 'code',
redirect_uri: s.callbackUrl,
response_mode: 'query',
scope: LOGIN_SCOPE,
state
});
return `${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/authorize?${params.toString()}`;
}
export async function exchangeCodeForToken(code) {
const s = entraSettings();
const response = await fetch(`${AUTHORITY_HOST}/${encodeURIComponent(s.tenantId)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: s.clientId,
client_secret: s.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: s.callbackUrl,
scope: LOGIN_SCOPE
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token exchange failed: ${response.status}`);
return payload;
}
export async function fetchEntraProfile(accessToken) {
const response = await fetch(GRAPH_ME_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload?.error?.message || `Profile lookup failed: ${response.status}`);
const email = (payload.mail || payload.userPrincipalName || '').toLowerCase();
if (!email) throw new Error('Entra profile did not include an email or user principal name');
return {
email,
displayName: payload.displayName || email,
oid: payload.id || ''
};
}

View File

@@ -0,0 +1,28 @@
// Pure per-connection RBAC checks for Intune tenant write-back. A connection may
// name explicit publishers (who may trigger writes) and approvers (who may
// approve change requests). Admins always pass. Empty lists mean "no extra
// restriction" — the allow_write / require_approval gates still apply.
function asList(value) {
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function canPublish(connection, user) {
if (!user) return false;
if (user.role === 'admin') return true;
const publishers = asList(connection?.publisher_ids ?? connection?.publisherIds);
return publishers.length === 0 || publishers.includes(user.id);
}
export function canApprove(connection, user) {
if (!user) return false;
if (user.role === 'admin') return true;
const approvers = asList(connection?.approver_ids ?? connection?.approverIds);
return approvers.includes(user.id);
}

View File

@@ -0,0 +1,423 @@
import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
function sanitizeBase(url = '') {
return String(url || '').replace(/\/+$/, '');
}
// Defense in depth: even though connection URLs are validated on write, re-check
// before every outbound request so stored/legacy rows can't trigger SSRF.
function assertAllowedEndpoints(connection) {
if (!isAllowedAuthorityHost(connection.authority_host)) {
throw new Error('Graph connection authority host is not an allowed Microsoft endpoint');
}
if (!isAllowedGraphHost(connection.graph_base_url)) {
throw new Error('Graph connection base URL is not an allowed Microsoft endpoint');
}
}
export async function getGraphToken(connection) {
assertAllowedEndpoints(connection);
const authority = sanitizeBase(connection.authority_host);
const graphBase = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${authority}/${encodeURIComponent(connection.tenant_id)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: connection.client_id,
client_secret: connection.clientSecret,
scope: `${graphBase}/.default`,
grant_type: 'client_credentials'
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token request failed: ${response.status}`);
return payload.access_token;
}
export async function graphRequest(connection, path, options = {}) {
assertAllowedEndpoints(connection);
const token = options.token || await getGraphToken(connection);
const base = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${base}${path}`, {
method: options.method || 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = payload?.error?.message || payload?.error_description || `Graph request failed: ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function testGraphConnection(connection) {
const token = await getGraphToken(connection);
const payload = await graphRequest(connection, '/v1.0/deviceAppManagement?$select=id&$top=1', { token });
return { ok: true, message: `Connected. deviceAppManagement readable; sample keys: ${Object.keys(payload).join(', ')}` };
}
// Create or update the win32LobApp metadata object. Content (.intunewin bytes)
// upload is a separate Phase 2 concern; this writes the app definition only.
// mobileApps create/update lives on the beta endpoint for full win32 fidelity.
export async function upsertWin32App(connection, payload, existingAppId = '') {
const token = await getGraphToken(connection);
if (existingAppId) {
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(existingAppId)}`, {
token,
method: 'PATCH',
body: payload
});
return { id: existingAppId, updated: true };
}
const created = await graphRequest(connection, '/beta/deviceAppManagement/mobileApps', {
token,
method: 'POST',
body: payload
});
return { id: created.id, created: true, raw: created };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForFileState(connection, token, appPath, cvId, fileId, target, { attempts = 30, delayMs = 2000 } = {}) {
for (let i = 0; i < attempts; i++) {
const file = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}`, { token });
const state = file.uploadState || '';
if (state === target) return file;
if (state.endsWith('Failed')) throw new Error(`Intune content upload failed in state "${state}"`);
await sleep(delayMs);
}
throw new Error(`Timed out waiting for Intune content state "${target}"`);
}
// Upload encrypted .intunewin content to Azure Storage in blocks, then commit.
// The SAS URI is returned by Graph (trusted); the block PUTs go straight to
// Azure Blob storage, outside the Graph allow-list.
async function azureBlockUpload(sasUri, content) {
const blockSize = 6 * 1024 * 1024;
const blockIds = [];
let index = 0;
for (let start = 0; start < content.length; start += blockSize) {
const chunk = content.subarray(start, start + blockSize);
const blockId = Buffer.from(String(index).padStart(8, '0')).toString('base64');
blockIds.push(blockId);
const res = await fetch(`${sasUri}&comp=block&blockid=${encodeURIComponent(blockId)}`, {
method: 'PUT',
headers: { 'x-ms-blob-type': 'BlockBlob' },
body: chunk
});
if (!res.ok) throw new Error(`Azure block upload failed (${res.status}) on block ${index}`);
index += 1;
}
const listXml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join('')}</BlockList>`;
const commit = await fetch(`${sasUri}&comp=blocklist`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
body: listXml
});
if (!commit.ok) throw new Error(`Azure block-list commit failed (${commit.status})`);
}
// Full Win32 content commit flow (Phase 2). Requires a live tenant + Azure
// Storage; validated end to end only against a real tenant. `parsed` is the
// output of parseIntunewin().
export async function uploadWin32Content(connection, appId, parsed) {
const token = await getGraphToken(connection);
const appPath = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/microsoft.graph.win32LobApp`;
const version = await graphRequest(connection, `${appPath}/contentVersions`, { token, method: 'POST', body: {} });
const cvId = version.id;
const fileMeta = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files`, {
token,
method: 'POST',
body: {
'@odata.type': '#microsoft.graph.mobileAppContentFile',
name: parsed.setupFile || 'IntunePackage.intunewin',
size: parsed.unencryptedContentSize,
sizeEncrypted: parsed.encryptedContentSize,
isDependency: false,
manifest: null
}
});
const fileId = fileMeta.id;
const ready = await waitForFileState(connection, token, appPath, cvId, fileId, 'azureStorageUriRequestSuccess');
await azureBlockUpload(ready.azureStorageUri, parsed.encryptedContent);
await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}/commit`, {
token,
method: 'POST',
body: { fileEncryptionInfo: parsed.fileEncryptionInfo }
});
await waitForFileState(connection, token, appPath, cvId, fileId, 'commitFileSuccess');
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`, {
token,
method: 'PATCH',
body: { '@odata.type': '#microsoft.graph.win32LobApp', committedContentVersion: cvId }
});
return { contentVersionId: cvId, fileId, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize };
}
// Replace the app's assignments in one call. The /assign action is a full
// replacement, so callers send the complete desired assignment set.
export async function assignWin32App(connection, appId, mobileAppAssignments) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/assign`, {
token,
method: 'POST',
body: { mobileAppAssignments }
});
return { ok: true, count: mobileAppAssignments.length };
}
// Replace the app's supersedence/dependency relationships. updateRelationships
// is also a full replacement of the app's outgoing relationship set.
export async function setWin32AppRelationships(connection, appId, relationships) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/updateRelationships`, {
token,
method: 'POST',
body: { relationships }
});
return { ok: true, count: relationships.length };
}
// Live win32LobApp metadata + assignments + relationships, used by drift checks.
export async function getWin32AppState(connection, appId) {
const token = await getGraphToken(connection);
const base = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`;
const [app, assignments, relationships] = await Promise.all([
graphRequest(connection, base, { token }),
graphRequest(connection, `${base}/assignments`, { token }).then((r) => r.value || []).catch(() => []),
graphRequest(connection, `${base}/relationships`, { token }).then((r) => r.value || []).catch(() => [])
]);
return { app, assignments, relationships };
}
export async function getGraphMobileApp(connection, appId) {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
return graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}?$select=id,displayName,publisher,displayVersion`,
{ token }
);
}
export async function listGraphMobileApps(connection, query = '') {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
const filter = query ? `&$filter=contains(displayName,'${String(query).replaceAll("'", "''")}')` : '';
const payload = await graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps?$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime,isAssigned${filter}&$top=50`,
{ token }
);
return (payload.value || []).map((app) => ({
id: app.id,
displayName: app.displayName || app.name || app.id,
publisher: app.publisher || '',
createdDateTime: app.createdDateTime || '',
lastModifiedDateTime: app.lastModifiedDateTime || '',
isAssigned: Boolean(app.isAssigned)
}));
}
export async function syncIntuneDeploymentStatus(connection, graphAppId, mode = 'reports') {
const token = await getGraphToken(connection);
const rows = [];
const sources = [];
let overview = {};
if (mode === 'reports' || mode === 'both') {
const reportResult = await syncReports(connection, token, graphAppId);
rows.push(...reportResult.rows);
overview = reportResult.overview;
sources.push(...reportResult.sources);
}
if ((mode === 'legacy-status' || mode === 'both') && !rows.length) {
const legacyRows = await syncLegacyDeviceStatuses(connection, token, graphAppId);
rows.push(...legacyRows);
sources.push('legacy-deviceStatuses');
}
return {
rows,
overview,
sources,
summary: summarizeRows(rows, overview)
};
}
async function syncReports(connection, token, graphAppId) {
const rows = [];
const sources = [];
let overview = {};
try {
const appOverview = await graphRequest(connection, '/beta/deviceManagement/reports/getAppStatusOverviewReport', {
token,
method: 'POST',
body: { filter: `(ApplicationId eq '${graphAppId}')` }
});
overview = reportRows(appOverview)[0] || {};
sources.push('getAppStatusOverviewReport');
} catch (error) {
overview = { warning: `Overview report unavailable: ${error.message}` };
}
try {
const deviceReport = await graphRequest(connection, '/beta/deviceManagement/reports/getDeviceInstallStatusReport', {
token,
method: 'POST',
body: {
select: [
'DeviceId',
'DeviceName',
'UserPrincipalName',
'Platform',
'Status',
'ErrorCode',
'AppInstallState',
'InstallState',
'InstallStateDetail',
'LastModifiedDateTime',
'ApplicationId'
],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(deviceReport).map((row) => normalizeReportStatus(row, 'device')));
sources.push('getDeviceInstallStatusReport');
} catch (error) {
rows.push(failureRow('device', 'report-unavailable', error.message, { source: 'getDeviceInstallStatusReport' }));
}
try {
const userReport = await graphRequest(connection, '/beta/deviceManagement/reports/getUserInstallStatusReport', {
token,
method: 'POST',
body: {
select: ['UserId', 'UserName', 'UserPrincipalName', 'Status', 'InstalledDeviceCount', 'FailedDeviceCount', 'ApplicationId'],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(userReport).map((row) => normalizeReportStatus(row, 'user')));
sources.push('getUserInstallStatusReport');
} catch (error) {
rows.push(failureRow('user', 'report-unavailable', error.message, { source: 'getUserInstallStatusReport' }));
}
return { rows, overview, sources };
}
async function syncLegacyDeviceStatuses(connection, token, graphAppId) {
const payload = await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${graphAppId}/deviceStatuses`, { token });
return (payload.value || []).map((row) => ({
scope: 'device',
principalId: row.id || '',
principalName: row.deviceName || row.userName || '',
deviceId: row.deviceId || '',
deviceName: row.deviceName || '',
userId: row.userId || '',
userPrincipalName: row.userPrincipalName || row.userName || '',
installState: normalizeState(row.installState || row.mobileAppInstallStatusValue || row.status),
installStateDetail: row.installStateDetail || '',
errorCode: row.errorCode,
errorDescription: row.errorDescription || failureCause(row.errorCode, row.installStateDetail),
lastSyncDateTime: row.lastSyncDateTime || '',
raw: row
}));
}
function reportRows(report) {
const schema = report.Schema || report.schema || [];
const values = report.Values || report.values || report.value || [];
if (!Array.isArray(values)) return [];
if (!schema.length) return values;
return values.map((entry) => {
if (!Array.isArray(entry)) return entry;
return Object.fromEntries(schema.map((column, index) => [column.Column || column.column || column.Name || column.name, entry[index]]));
});
}
function normalizeReportStatus(row, scope) {
const installState = normalizeState(row.Status || row.InstallState || row.AppInstallState || row.State || row.InstallStatus);
const errorCode = row.ErrorCode ?? row.Error ?? row.HexErrorCode ?? '';
return {
scope,
principalId: scope === 'device' ? row.DeviceId || row.ManagedDeviceId || '' : row.UserId || '',
principalName: scope === 'device' ? row.DeviceName || '' : row.UserName || row.UserPrincipalName || '',
deviceId: row.DeviceId || row.ManagedDeviceId || '',
deviceName: row.DeviceName || '',
userId: row.UserId || '',
userPrincipalName: row.UserPrincipalName || row.UserName || '',
installState,
installStateDetail: row.InstallStateDetail || row.StatusDetails || row.StateDetails || '',
errorCode,
errorDescription: row.ErrorDescription || row.ErrorDetails || failureCause(errorCode, row.InstallStateDetail),
lastSyncDateTime: row.LastModifiedDateTime || row.LastSyncDateTime || row.LastReportedDateTime || '',
raw: row
};
}
function normalizeState(value = '') {
const text = String(value || '').toLowerCase();
if (text.includes('fail') || text === 'error') return 'failed';
if (text.includes('install') || text === 'success' || text === 'installed') return 'installed';
if (text.includes('pending') || text.includes('progress')) return 'pending';
if (text.includes('notapplicable') || text.includes('not applicable')) return 'notApplicable';
if (text.includes('notinstalled') || text.includes('not installed')) return 'notInstalled';
return text || 'unknown';
}
function failureRow(scope, code, message, raw) {
return {
scope,
installState: 'failed',
installStateDetail: code,
errorCode: code,
errorDescription: message,
raw
};
}
function failureCause(errorCode, detail = '') {
const code = String(errorCode || '').toLowerCase();
const known = {
'0x87d1041c': 'Intune did not detect the app after installation. Check detection rules, install command, and app install timing.',
'0x87d13b9f': 'App installation failed. Review Intune Management Extension logs and PSADT logs.',
'1602': 'User deferred or cancelled installation. Confirm return code is mapped as retry and DeferRunInterval is configured when needed.',
'3010': 'Soft reboot required. Confirm Intune return code behavior.',
'1703': 'Legacy soft reboot mapping. Confirm this is intentional for the package.'
};
return known[code] || detail || '';
}
function summarizeRows(rows, overview = {}) {
const counts = { total: rows.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0 };
for (const row of rows) {
const state = row.installState || 'unknown';
if (counts[state] == null) counts.unknown += 1;
else counts[state] += 1;
}
return { counts, overview };
}

View File

@@ -0,0 +1,76 @@
import { INSTALLER_TECHNOLOGIES } from '../data/installerCatalog.js';
// Installer intelligence: infer the installer technology from a file name and
// return recommended silent install/uninstall commands plus a detection
// suggestion. Pure + table-driven, so it is fully unit-tested. Binary signature
// sniffing (reading the file) is Phase 5; this works from the name/extension.
function extensionOf(fileName = '') {
const match = String(fileName).toLowerCase().match(/(\.[a-z0-9]+)$/);
return match ? match[1] : '';
}
function render(template = '', { file = '', productCode = '' } = {}) {
return String(template)
.split('{file}').join(file)
.split('{productCode}').join(productCode || '{productCode}');
}
function shape(tech, ctx) {
return {
id: tech.id,
label: tech.label,
install: render(tech.install, ctx),
uninstall: render(tech.uninstall, ctx),
psadtInstall: render(tech.psadtInstall, ctx),
psadtUninstall: tech.psadtUninstall ? render(tech.psadtUninstall, ctx) : '',
detection: tech.detection,
notes: tech.notes
};
}
export function listInstallerTechnologies() {
return INSTALLER_TECHNOLOGIES.map(({ id, label, extensions, detection, notes }) => ({ id, label, extensions, detection, notes }));
}
// Analyze a file name. Returns the best-guess technology, a confidence level,
// and the full set of candidate technologies for that extension so the operator
// can pick when the name alone is ambiguous (common for .exe).
export function analyzeInstaller({ fileName = '', productCode = '' } = {}) {
const ext = extensionOf(fileName);
const lower = String(fileName).toLowerCase();
const ctx = { file: fileName, productCode };
const byExt = INSTALLER_TECHNOLOGIES.filter((tech) => tech.extensions.includes(ext));
if (!byExt.length) {
return {
fileName,
extension: ext,
confidence: 'unknown',
primary: null,
candidates: [],
message: ext ? `No installer technology is known for "${ext}".` : 'Provide an installer file name with an extension.'
};
}
// Unique extension (.msi/.msp/.msix...) → high confidence, single technology.
if (byExt.length === 1) {
return { fileName, extension: ext, confidence: 'high', primary: shape(byExt[0], ctx), candidates: [shape(byExt[0], ctx)] };
}
// Ambiguous (.exe): try a name hint, otherwise fall back to generic EXE.
const hinted = byExt.find((tech) => (tech.hints || []).some((hint) => lower.includes(hint)) && tech.id !== 'exe');
const generic = byExt.find((tech) => tech.id === 'exe') || byExt[0];
const primary = hinted || generic;
return {
fileName,
extension: ext,
confidence: hinted ? 'medium' : 'low',
primary: shape(primary, ctx),
// Offer all candidates (generic last) so the UI can present a picker.
candidates: [...byExt.filter((t) => t.id !== 'exe'), ...byExt.filter((t) => t.id === 'exe')].map((t) => shape(t, ctx)),
message: hinted
? `Matched "${hinted.label}" from the file name.`
: 'Ambiguous EXE — pick the matching technology, or confirm the silent switch with the vendor.'
};
}

View File

@@ -0,0 +1,66 @@
// Pure drift computation: compares the POSHManager plan (expected) against what
// Microsoft Graph currently reports (live), for the win32LobApp metadata, its
// assignments, and its supersedence/dependency relationships. No I/O — the
// controller fetches live state and feeds it here, so this is fully unit-tested.
// Fields on the win32LobApp we treat as authoritative from the plan.
const APP_DRIFT_FIELDS = [
'displayName',
'publisher',
'installCommandLine',
'uninstallCommandLine',
'applicableArchitectures'
];
function fieldDrift(expected = {}, actual = {}, fields = APP_DRIFT_FIELDS) {
const drift = [];
for (const field of fields) {
const want = expected[field] ?? '';
const got = actual[field] ?? '';
if (String(want) !== String(got)) drift.push({ field, expected: want, actual: got });
}
return drift;
}
function assignmentKey(assignment = {}) {
const target = assignment.target || {};
const type = (target['@odata.type'] || '').replace('#microsoft.graph.', '');
return `${assignment.intent || ''}|${type}|${target.groupId || ''}`;
}
function relationshipKey(rel = {}) {
const type = (rel['@odata.type'] || '').replace('#microsoft.graph.', '');
return `${type}|${rel.targetId || ''}`;
}
// Generic set diff by key: returns items present only in expected (missing in
// tenant) and only in actual (extra in tenant).
function setDiff(expected, actual, keyFn) {
const expectedKeys = new Map(expected.map((item) => [keyFn(item), item]));
const actualKeys = new Map(actual.map((item) => [keyFn(item), item]));
const missing = [...expectedKeys.keys()].filter((k) => !actualKeys.has(k));
const extra = [...actualKeys.keys()].filter((k) => !expectedKeys.has(k));
return { missing, extra };
}
export function computeDrift({
expectedApp = {},
liveApp = {},
expectedAssignments = [],
liveAssignments = [],
expectedRelationships = [],
liveRelationships = []
} = {}) {
const appDrift = fieldDrift(expectedApp, liveApp);
const assignmentDrift = setDiff(expectedAssignments, liveAssignments, assignmentKey);
const relationshipDrift = setDiff(expectedRelationships, liveRelationships, relationshipKey);
const inSync =
appDrift.length === 0 &&
assignmentDrift.missing.length === 0 &&
assignmentDrift.extra.length === 0 &&
relationshipDrift.missing.length === 0 &&
relationshipDrift.extra.length === 0;
return { inSync, appDrift, assignmentDrift, relationshipDrift };
}

View File

@@ -0,0 +1,69 @@
// Pure helpers for the application promotion pipeline: producing the next
// version's deployment plan and advancing assignment rings. No I/O, so both are
// unit-tested; the controller persists/pushes the results.
// Bump the trailing numeric component of a version string (1.2.3 -> 1.2.4,
// 126 -> 127). Falls back to appending ".1" when no number is present.
export function bumpVersion(version = '') {
const text = String(version).trim();
if (!text) return '1.0.0';
const match = text.match(/^(.*?)(\d+)(\D*)$/);
if (!match) return `${text}.1`;
const [, head, num, tail] = match;
return `${head}${Number(num) + 1}${tail}`;
}
// Build a fresh deployment payload for the next version, based on an existing
// plan. The new plan is unpublished (no graphAppId), draft status, and keeps the
// application link so the catalog groups both versions.
export function cloneForNextVersion(deployment, { version, applicationId } = {}) {
if (!deployment) throw new Error('A source deployment is required');
const nextVersion = version || bumpVersion(extractVersion(deployment.name));
const baseName = deployment.name.replace(/\s+\d[\d.]*$/, '').trim() || deployment.name;
return {
name: `${baseName} ${nextVersion}`.trim(),
profileId: deployment.profileId || null,
scriptId: deployment.scriptId || null,
applicationId: applicationId || deployment.applicationId || null,
sourceFolder: deployment.sourceFolder || '',
intunewinFile: '',
commandStyle: deployment.commandStyle,
installBehavior: deployment.installBehavior,
restartBehavior: deployment.restartBehavior,
uiMode: deployment.uiMode,
requirements: { ...(deployment.requirements || {}) },
installCommand: deployment.installCommand,
uninstallCommand: deployment.uninstallCommand,
detectionType: deployment.detectionType,
detectionRule: deployment.detectionRule,
returnCodes: deployment.returnCodes || [],
assignments: deployment.assignments || [],
relationships: deployment.relationships || [],
status: 'draft',
graphAppId: '',
graphConnectionId: deployment.graphConnectionId || null,
notes: `Version ${nextVersion} created from ${deployment.name}.`,
visibility: deployment.visibility,
groupId: deployment.groupId || null
};
}
function extractVersion(name = '') {
const match = String(name).match(/(\d[\d.]*)\s*$/);
return match ? match[1] : '';
}
// Advance a named assignment ring to a new intent (e.g. promote "Broad" from
// available to required). Returns a new assignments array; unknown rings are a
// no-op so callers can detect "nothing changed".
export function setRingIntent(assignments = [], ring, intent) {
let changed = false;
const next = assignments.map((assignment) => {
if (assignment.ring?.toLowerCase() === String(ring).toLowerCase() && assignment.intent !== intent) {
changed = true;
return { ...assignment, intent };
}
return assignment;
});
return { assignments: next, changed };
}

View File

@@ -0,0 +1,268 @@
// Pure mapping from a POSHManager Intune deployment plan (+ optional PSADT
// profile) to the Microsoft Graph `win32LobApp` resource shape and its
// assignment/relationship bodies.
//
// These functions perform no I/O so they can be unit-tested without a tenant.
// The live HTTP calls live in graphService; the publish controller composes the
// two. Field shapes follow the Graph beta `win32LobApp` documentation.
const RESTART_BEHAVIOR = {
'return-code': 'basedOnReturnCode',
suppress: 'suppress',
intune: 'force'
};
const RETURN_CODE_TYPES = new Set(['success', 'softReboot', 'hardReboot', 'retry', 'failed']);
// Map a human OS string ("Windows 10 22H2", "Windows 11 23H2") to the Graph
// windowsMinimumOperatingSystem boolean flag. Defaults to a safe broad floor.
function minimumSupportedOperatingSystem(minOs = '') {
const text = String(minOs).toLowerCase();
const flags = {
v10_1607: false, v10_1703: false, v10_1709: false, v10_1803: false,
v10_1809: false, v10_1903: false, v10_1909: false, v10_2004: false,
v10_20H2: false, v10_21H1: false, v10_21H2: false, v10_22H2: false,
v11_21H2: false, v11_22H2: false, v11_23H2: false, v11_24H2: false
};
const map = [
['11 24h2', 'v11_24H2'], ['11 23h2', 'v11_23H2'], ['11 22h2', 'v11_22H2'], ['11 21h2', 'v11_21H2'],
['22h2', 'v10_22H2'], ['21h2', 'v10_21H2'], ['21h1', 'v10_21H1'], ['20h2', 'v10_20H2'],
['2004', 'v10_2004'], ['1909', 'v10_1909'], ['1903', 'v10_1903'], ['1809', 'v10_1809'],
['1803', 'v10_1803'], ['1709', 'v10_1709'], ['1703', 'v10_1703'], ['1607', 'v10_1607']
];
const match = map.find(([needle]) => text.includes(needle));
flags[match ? match[1] : 'v10_1809'] = true;
return flags;
}
function applicableArchitectures(architecture = 'x64') {
if (architecture === 'both') return 'x64,x86';
if (architecture === 'x86') return 'x86';
return 'x64';
}
function mapReturnCodes(returnCodes = []) {
const mapped = (returnCodes || []).map((entry) => ({
returnCode: Number(entry.code),
type: RETURN_CODE_TYPES.has(entry.type) ? entry.type : 'failed'
})).filter((entry) => Number.isFinite(entry.returnCode));
// Intune requires at least the canonical 0/1707/3010/1641 floor; ensure 0=success exists.
if (!mapped.some((entry) => entry.returnCode === 0)) mapped.unshift({ returnCode: 0, type: 'success' });
return mapped;
}
// Detection rule mapping. Our model stores a single `detectionRule` string plus
// a `detectionType`, so structured types (file/registry) that need more fields
// are emitted best-effort and a warning is returned for operator review.
function mapDetectionRules(deployment, warnings) {
const rule = String(deployment.detectionRule || '').trim();
switch (deployment.detectionType) {
case 'msi-product-code':
if (!rule) warnings.push('MSI detection selected but no product code provided.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppProductCodeDetection',
productCode: rule,
productVersionOperator: 'notConfigured',
productVersion: null
}];
case 'custom-script':
if (!rule) warnings.push('Custom-script detection selected but the script body is empty.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
enforceSignatureCheck: false,
runAs32Bit: false
}];
case 'file-version':
warnings.push('File-version detection requires structured path/file/version fields; emitted a best-effort rule for review.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppFileSystemDetection',
path: rule || 'C:\\Program Files',
fileOrFolderName: '',
check32BitOn64System: false,
detectionType: 'exists',
operator: 'notConfigured',
detectionValue: null
}];
case 'registry':
warnings.push('Registry detection requires structured key/value fields; emitted a best-effort rule for review.');
return [{
'@odata.type': '#microsoft.graph.win32LobAppRegistryDetection',
keyPath: rule || 'HKEY_LOCAL_MACHINE\\SOFTWARE',
valueName: '',
check32BitOn64System: false,
detectionType: 'exists',
operator: 'notConfigured',
detectionValue: null
}];
default:
warnings.push(`Unknown detection type "${deployment.detectionType}"; defaulted to script detection.`);
return [{
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
enforceSignatureCheck: false,
runAs32Bit: false
}];
}
}
export function buildWin32LobAppPayload(deployment, profile = null) {
const warnings = [];
if (!deployment) throw new Error('A deployment is required to build a win32LobApp payload');
const requirements = deployment.requirements || {};
const displayName = profile?.appName
? `${profile.appVendor ? `${profile.appVendor} ` : ''}${profile.appName}${profile.appVersion ? ` ${profile.appVersion}` : ''}`.trim()
: deployment.name;
const fileName = deployment.intunewinFile || `${(deployment.name || 'package').replace(/[^A-Za-z0-9._-]+/g, '-')}.intunewin`;
if (!deployment.installCommand) warnings.push('Install command is empty.');
if (!deployment.uninstallCommand) warnings.push('Uninstall command is empty.');
if (!deployment.intunewinFile) warnings.push('No .intunewin file recorded; content must be uploaded before the app is installable (Phase 2).');
const payload = {
'@odata.type': '#microsoft.graph.win32LobApp',
displayName,
description: deployment.notes || displayName,
publisher: profile?.appVendor || 'POSHManager',
fileName,
setupFilePath: fileName,
installCommandLine: deployment.installCommand || '',
uninstallCommandLine: deployment.uninstallCommand || '',
applicableArchitectures: applicableArchitectures(requirements.architecture),
allowAvailableUninstall: true,
minimumFreeDiskSpaceInMB: Number(requirements.diskSpaceMb) > 0 ? Number(requirements.diskSpaceMb) : null,
minimumSupportedOperatingSystem: minimumSupportedOperatingSystem(requirements.minOs),
installExperience: {
'@odata.type': 'microsoft.graph.win32LobAppInstallExperience',
runAsAccount: deployment.installBehavior === 'user' ? 'user' : 'system',
deviceRestartBehavior: RESTART_BEHAVIOR[deployment.restartBehavior] || 'basedOnReturnCode'
},
returnCodes: mapReturnCodes(deployment.returnCodes),
detectionRules: mapDetectionRules(deployment, warnings),
requirementRules: [],
msiInformation: null,
runAs32Bit: Boolean(requirements.runAs32Bit)
};
return { payload, warnings };
}
// ---------------------------------------------------------------------------
// Phase 3 — assignments / filters / rings
// ---------------------------------------------------------------------------
const ASSIGNMENT_INTENTS = new Set(['available', 'required', 'uninstall']);
function assignmentTarget(assignment, warnings) {
const filter = assignment.filterId
? {
deviceAndAppManagementAssignmentFilterId: assignment.filterId,
deviceAndAppManagementAssignmentFilterType: assignment.filterType && assignment.filterType !== 'none' ? assignment.filterType : 'include'
}
: { deviceAndAppManagementAssignmentFilterId: null, deviceAndAppManagementAssignmentFilterType: 'none' };
if (assignment.groupMode === 'allDevices') {
return { '@odata.type': '#microsoft.graph.allDevicesAssignmentTarget', ...filter };
}
if (assignment.groupMode === 'allUsers') {
return { '@odata.type': '#microsoft.graph.allLicensedUsersAssignmentTarget', ...filter };
}
if (!assignment.groupId) {
warnings.push(`Assignment "${assignment.ring}" has no Entra group id and was skipped. Resolve the group to its object id first.`);
return null;
}
const isExclusion = assignment.intent === 'exclude';
return {
'@odata.type': isExclusion
? '#microsoft.graph.exclusionGroupAssignmentTarget'
: '#microsoft.graph.groupAssignmentTarget',
groupId: assignment.groupId,
...filter
};
}
// Build the `mobileAppAssignments` array for the /assign action. Exclusions map
// to exclusionGroupAssignmentTarget (Graph still requires a real intent, so we
// exclude from "required"). Group assignments without a resolved object id are
// skipped with a warning rather than producing an invalid request.
export function buildAssignmentsPayload(deployment) {
const warnings = [];
const assignments = [];
for (const assignment of deployment.assignments || []) {
const target = assignmentTarget(assignment, warnings);
if (!target) continue;
const intent = assignment.intent === 'exclude'
? 'required'
: (ASSIGNMENT_INTENTS.has(assignment.intent) ? assignment.intent : 'required');
assignments.push({
'@odata.type': '#microsoft.graph.mobileAppAssignment',
intent,
target,
settings: {
'@odata.type': '#microsoft.graph.win32LobAppAssignmentSettings',
notifications: 'showAll',
restartSettings: null,
installTimeSettings: null,
deliveryOptimizationPriority: 'notConfigured',
autoUpdateSettings: assignment.autoUpdate
? { '@odata.type': '#microsoft.graph.win32LobAppAutoUpdateSettings', autoUpdateSupersededApps: 'enabled' }
: null
}
});
}
if (!assignments.length) warnings.push('No assignable targets were produced; nothing would be assigned.');
return { assignments, warnings };
}
// ---------------------------------------------------------------------------
// Phase 4 — supersedence & dependency relationships
// ---------------------------------------------------------------------------
// Intune limits a supersedence graph to 11 nodes total (this app + superseded +
// their related apps). We can only see this app's direct edges, so we enforce a
// local ceiling of 10 supersedence targets and flag that the full graph limit is
// tenant-wide. Self-references and duplicates are rejected as cycles.
export const MAX_SUPERSEDENCE_TARGETS = 10;
export function validateRelationships(relationships = [], selfAppId = '') {
const errors = [];
const seen = new Set();
let supersedenceCount = 0;
for (const rel of relationships) {
const target = rel.targetAppId;
if (!target) { errors.push('A relationship is missing targetAppId.'); continue; }
if (selfAppId && target === selfAppId) errors.push(`An app cannot have a ${rel.kind} relationship with itself (${target}).`);
const key = `${rel.kind}:${target}`;
if (seen.has(key)) errors.push(`Duplicate ${rel.kind} relationship to ${target}.`);
seen.add(key);
if (rel.kind === 'supersedence') supersedenceCount += 1;
}
if (supersedenceCount > MAX_SUPERSEDENCE_TARGETS) {
errors.push(`Too many supersedence targets (${supersedenceCount}); Intune allows at most ${MAX_SUPERSEDENCE_TARGETS} direct targets (11-node graph limit).`);
}
return errors;
}
export function buildRelationshipsPayload(relationships = [], selfAppId = '') {
const errors = validateRelationships(relationships, selfAppId);
if (errors.length) {
const error = new Error(errors.join(' '));
error.validation = errors;
throw error;
}
const mapped = relationships.map((rel) => {
if (rel.kind === 'dependency') {
return {
'@odata.type': '#microsoft.graph.mobileAppDependency',
targetId: rel.targetAppId,
dependencyType: rel.mode === 'autoInstall' ? 'autoInstall' : 'detect'
};
}
return {
'@odata.type': '#microsoft.graph.mobileAppSupersedence',
targetId: rel.targetAppId,
supersedenceType: rel.mode === 'replace' ? 'replace' : 'update'
};
});
return { relationships: mapped };
}

View File

@@ -0,0 +1,80 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
// Wraps a PSADT/package source folder into a `.intunewin` using the Microsoft
// Win32 Content Prep Tool (IntuneWinAppUtil.exe) on Windows, or an optional
// cross-platform packager via INTUNEWIN_BUILD_COMMAND. The command construction
// is pure and unit-tested; the spawn wrapper is thin.
export function resolveToolPath() {
return config.intunewinUtilPath || path.join(config.toolsDir, 'IntuneWinAppUtil.exe');
}
// Where to put the tool and whether we can build here.
export function builderAvailability() {
if (config.intunewinBuildCommand) {
return { available: true, mode: 'external-command', toolPath: null };
}
const toolPath = resolveToolPath();
if (!fs.existsSync(toolPath)) {
return { available: false, mode: 'bundled', toolPath, reason: `IntuneWinAppUtil.exe not found at ${toolPath}. Place it in the tools directory or set INTUNEWIN_UTIL_PATH / INTUNEWIN_BUILD_COMMAND.` };
}
if (process.platform !== 'win32') {
return { available: false, mode: 'bundled', toolPath, reason: 'IntuneWinAppUtil.exe requires Windows. Set INTUNEWIN_BUILD_COMMAND to use a cross-platform packager.' };
}
return { available: true, mode: 'bundled', toolPath };
}
// Pure: produce the spawn descriptor for the configured packager.
export function buildPackagerInvocation({ toolPath, sourceFolder, setupFile, outputDir, commandTemplate }) {
if (commandTemplate) {
const command = commandTemplate
.split('{source}').join(sourceFolder)
.split('{setup}').join(setupFile)
.split('{output}').join(outputDir);
return { shell: true, command };
}
return {
shell: false,
command: toolPath,
args: ['-c', sourceFolder, '-s', setupFile, '-o', outputDir, '-q']
};
}
// IntuneWinAppUtil names the output after the setup file's base name.
export function expectedOutputFile(outputDir, setupFile) {
return path.join(outputDir, `${path.parse(setupFile).name}.intunewin`);
}
export function buildIntuneWin({ sourceFolder, setupFile, outputDir }) {
return new Promise((resolve, reject) => {
const availability = builderAvailability();
if (!availability.available) return reject(new Error(availability.reason || 'Packager not available'));
if (!sourceFolder || !fs.existsSync(sourceFolder)) return reject(new Error(`Source folder not found: ${sourceFolder}`));
if (!setupFile) return reject(new Error('A setup file (entry installer) is required'));
fs.mkdirSync(outputDir, { recursive: true });
const invocation = buildPackagerInvocation({
toolPath: availability.toolPath,
sourceFolder,
setupFile,
outputDir,
commandTemplate: config.intunewinBuildCommand
});
const child = invocation.shell
? spawn(invocation.command, { shell: true })
: spawn(invocation.command, invocation.args, { shell: false });
let stderr = '';
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
child.on('error', (error) => reject(new Error(`Packager failed to start: ${error.message}`)));
child.on('close', (code) => {
const outputFile = expectedOutputFile(outputDir, setupFile);
if (code === 0 && fs.existsSync(outputFile)) return resolve({ outputFile });
reject(new Error(`Packager exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
});
});
}

View File

@@ -0,0 +1,104 @@
import zlib from 'node:zlib';
// Minimal reader for the `.intunewin` package produced by the Microsoft Win32
// Content Prep Tool. The package is a ZIP containing:
// IntuneWinPackage/Metadata/Detection.xml (encryption info + sizes)
// IntuneWinPackage/Contents/IntunePackage.intunewin (AES-encrypted payload)
//
// We avoid a third-party zip dependency by parsing the central directory and
// inflating entries with the built-in zlib. Only stored (0) and deflate (8)
// compression methods are supported, which is what the tool emits.
const EOCD_SIG = 0x06054b50;
const CEN_SIG = 0x02014b50;
const LOC_SIG = 0x04034b50;
function findEndOfCentralDirectory(buffer) {
// EOCD is at the end; scan backwards past any (absent) comment.
for (let i = buffer.length - 22; i >= 0; i--) {
if (buffer.readUInt32LE(i) === EOCD_SIG) return i;
}
throw new Error('Not a valid .intunewin (ZIP end-of-central-directory not found)');
}
function readCentralDirectory(buffer) {
const eocd = findEndOfCentralDirectory(buffer);
const count = buffer.readUInt16LE(eocd + 10);
let offset = buffer.readUInt32LE(eocd + 16);
const entries = [];
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(offset) !== CEN_SIG) break;
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLen);
entries.push({ name, method, compressedSize, localHeaderOffset });
offset += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
function readEntryData(buffer, entry) {
const o = entry.localHeaderOffset;
if (buffer.readUInt32LE(o) !== LOC_SIG) throw new Error(`Bad local header for ${entry.name}`);
const nameLen = buffer.readUInt16LE(o + 26);
const extraLen = buffer.readUInt16LE(o + 28);
const dataStart = o + 30 + nameLen + extraLen;
const raw = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.method === 0) return Buffer.from(raw);
if (entry.method === 8) return zlib.inflateRawSync(raw);
throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}`);
}
function findEntry(entries, suffix) {
// Match by path suffix so we tolerate the IntuneWinPackage/ prefix casing.
const lower = suffix.toLowerCase();
return entries.find((e) => e.name.toLowerCase().replace(/\\/g, '/').endsWith(lower));
}
function xmlValue(xml, tag) {
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i'));
return match ? match[1].trim() : '';
}
// Graph's commit step expects camelCase fileEncryptionInfo; map directly from
// the Detection.xml EncryptionInfo element.
function parseEncryptionInfo(xml) {
const block = xml.match(/<EncryptionInfo>([\s\S]*?)<\/EncryptionInfo>/i);
if (!block) throw new Error('Detection.xml is missing EncryptionInfo');
const info = block[1];
return {
encryptionKey: xmlValue(info, 'EncryptionKey'),
macKey: xmlValue(info, 'MacKey'),
initializationVector: xmlValue(info, 'InitializationVector'),
mac: xmlValue(info, 'Mac'),
profileIdentifier: xmlValue(info, 'ProfileIdentifier') || 'ProfileVersion1',
fileDigest: xmlValue(info, 'FileDigest'),
fileDigestAlgorithm: xmlValue(info, 'FileDigestAlgorithm') || 'SHA256'
};
}
export function parseIntunewin(buffer) {
if (!Buffer.isBuffer(buffer)) buffer = Buffer.from(buffer);
const entries = readCentralDirectory(buffer);
const detectionEntry = findEntry(entries, 'metadata/detection.xml');
if (!detectionEntry) throw new Error('Detection.xml not found in .intunewin package');
const contentEntry = findEntry(entries, 'contents/intunepackage.intunewin');
if (!contentEntry) throw new Error('Encrypted content (IntunePackage.intunewin) not found in package');
const xml = readEntryData(buffer, detectionEntry).toString('utf8');
const encryptedContent = readEntryData(buffer, contentEntry);
const unencryptedContentSize = Number(xmlValue(xml, 'UnencryptedContentSize')) || 0;
return {
setupFile: xmlValue(xml, 'SetupFile') || xmlValue(xml, 'FileName') || '',
name: xmlValue(xml, 'Name') || '',
unencryptedContentSize,
encryptedContent,
encryptedContentSize: encryptedContent.length,
fileEncryptionInfo: parseEncryptionInfo(xml)
};
}

44
server/services/logger.js Normal file
View File

@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
import winston from 'winston';
import { config } from '../config.js';
fs.mkdirSync(config.logDir, { recursive: true });
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
export const logger = winston.createLogger({
level: config.isProduction ? 'info' : 'debug',
format: logFormat,
transports: [
new winston.transports.File({ filename: path.join(config.logDir, 'system.log'), maxsize: 5_000_000, maxFiles: 5 }),
...(!config.isProduction
? [new winston.transports.Console({
format: winston.format.combine(winston.format.colorize(), winston.format.simple())
})]
: [])
]
});
export function readSystemLog(limit = 300) {
// System logs are line-delimited JSON; malformed historic lines are returned as plain messages.
const file = path.join(config.logDir, 'system.log');
if (!fs.existsSync(file)) return [];
const content = fs.readFileSync(file, 'utf8');
return content
.trim()
.split('\n')
.filter(Boolean)
.slice(-limit)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return { level: 'info', message: line };
}
});
}

View File

@@ -0,0 +1,317 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { db, id, now } from '../db.js';
import { decryptSecret } from './cryptoStore.js';
import { config } from '../config.js';
import { logger } from './logger.js';
import { listExecutableAssets } from '../models/assetModel.js';
import { listExecutableCustomVariables } from '../models/variableModel.js';
import { getBooleanSetting } from '../models/settingsModel.js';
// The execution kill-switch can be set at boot via ALLOW_SCRIPT_EXECUTION and
// flipped at runtime via the Config screen (allow_script_execution). The DB
// value, when present, takes precedence so the UI toggle is actually effective.
function scriptExecutionAllowed() {
return getBooleanSetting('allow_script_execution', config.allowScriptExecution);
}
export function executeRunPlan(runplanId, userId) {
// Create the durable job record before async execution so the UI can attach to logs immediately.
const runplan = db.prepare('SELECT * FROM runplans WHERE id = ?').get(runplanId);
if (!runplan) throw new Error('RunPlan not found');
const script = db.prepare('SELECT * FROM scripts WHERE id = ?').get(runplan.script_id);
if (!script) throw new Error('Script not found');
const hosts = db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM runplan_hosts rh
JOIN hosts h ON h.id = rh.host_id
LEFT JOIN credentials c ON c.id = h.credential_id
WHERE rh.runplan_id = ?
ORDER BY h.name
`).all(runplanId);
if (hosts.length === 0) throw new Error('RunPlan has no hosts');
const jobId = id('job');
db.prepare(`
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, ?, ?, 'running', ?, ?, ?)
`).run(jobId, runplan.id, script.id, userId, now(), now());
for (const host of hosts) {
db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`).run(id('jhost'), jobId, host.id);
}
const executionContext = {
jobId,
runplanId: runplan.id,
scriptId: script.id,
scriptName: script.name,
triggeredBy: userId,
customVariables: listExecutableCustomVariables(userId),
assets: listExecutableAssets(userId)
};
void runJob(jobId, script, hosts, Boolean(runplan.parallel), executionContext).catch((error) => {
logger.error('job runner crashed', { jobId, error });
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}
export function executeScriptTest(scriptId, payload, userId) {
const script = db.prepare(`
SELECT * FROM scripts
WHERE id = ? AND (
visibility = 'shared'
OR owner_user_id = ?
OR group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(scriptId, userId, userId);
if (!script) throw new Error('Script not found');
const host = db.prepare(`
SELECT h.*, c.name AS credential_name, c.kind AS credential_kind, c.username AS credential_username,
c.secret_cipher, c.secret_iv, c.secret_tag
FROM hosts h
JOIN credentials c ON c.id = ?
AND (
c.visibility = 'shared'
OR c.owner_user_id = ?
OR c.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
WHERE h.id = ? AND (
h.visibility = 'shared'
OR h.owner_user_id = ?
OR h.group_id IN (SELECT group_id FROM user_groups WHERE user_id = ?)
)
`).get(payload.credentialId, userId, userId, payload.hostId, userId, userId);
if (!host) throw new Error('Visible host and credential are required');
const jobId = id('job');
db.prepare(`
INSERT INTO jobs (id, runplan_id, script_id, status, triggered_by, started_at, created_at)
VALUES (?, NULL, ?, 'running', ?, ?, ?)
`).run(jobId, script.id, userId, now(), now());
db.prepare(`
INSERT INTO job_hosts (id, job_id, host_id, status, started_at)
VALUES (?, ?, ?, 'queued', NULL)
`).run(id('jhost'), jobId, host.id);
const executionContext = {
jobId,
runplanId: 'script-test',
scriptId: script.id,
scriptName: script.name,
triggeredBy: userId,
testRun: true,
customVariables: listExecutableCustomVariables(userId),
assets: listExecutableAssets(userId)
};
appendLog(jobId, host.id, 'system', `Test run requested for ${script.name} using credential ${host.credential_name}`);
void runJob(jobId, script, [host], false, executionContext).catch((error) => {
logger.error('script test runner crashed', { jobId, error });
appendLog(jobId, host.id, 'stderr', `Script test runner crashed: ${error.message}`);
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run('failed', now(), jobId);
});
return db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
}
async function runJob(jobId, script, hosts, parallel, executionContext) {
// RunPlans can fan out in parallel or execute serially while preserving identical log semantics.
const runner = async (host) => runHost(jobId, script, host, executionContext);
if (parallel) {
await Promise.all(hosts.map(runner));
} else {
for (const host of hosts) await runner(host);
}
const failed = db.prepare('SELECT COUNT(*) AS count FROM job_hosts WHERE job_id = ? AND status = ?').get(jobId, 'failed').count;
db.prepare('UPDATE jobs SET status = ?, ended_at = ? WHERE id = ?').run(failed ? 'failed' : 'completed', now(), jobId);
}
async function runHost(jobId, script, host, executionContext) {
// Each host owns its own temp wrapper and job_host lifecycle for per-target evidence.
db.prepare('UPDATE job_hosts SET status = ?, started_at = ? WHERE job_id = ? AND host_id = ?').run('running', now(), jobId, host.id);
appendLog(jobId, host.id, 'system', `Starting ${script.name} on ${host.name} (${host.transport})`);
if (!scriptExecutionAllowed()) {
appendLog(jobId, host.id, 'stderr', 'Script execution is disabled by ALLOW_SCRIPT_EXECUTION=false.');
finishHost(jobId, host.id, 'failed', 126);
return;
}
const secret = decryptSecret(host);
const wrapper = buildWrapper(script.content, host, executionContext);
const file = path.join(os.tmpdir(), `poshmanager-${jobId}-${host.id}.ps1`);
try {
await fs.writeFile(file, wrapper, { mode: 0o600 });
await spawnPowerShell(jobId, host, file, secret, executionContext);
} catch (error) {
appendLog(jobId, host.id, 'stderr', error.message);
finishHost(jobId, host.id, 'failed', 1);
} finally {
await fs.rm(file, { force: true }).catch(() => {});
}
}
function buildWrapper(content, host, executionContext = {}) {
// Build PowerShell wrappers without shell interpolation; host values are quoted as PS literals.
const runtimeVariables = runtimeVariableValues(host, executionContext);
const customVariables = executionContext.customVariables || [];
const assetVariables = (executionContext.assets || []).map((asset) => ({
name: `asset:${asset.id}`,
value: asset.localPath,
valueType: 'string'
}));
const rendered = renderTokens(content, [...runtimeVariables, ...customVariables, ...assetVariables]);
const scriptWithPrelude = `${buildVariablePrelude(runtimeVariables, customVariables)}\n${rendered}`;
const escaped = scriptWithPrelude.replace(/'@/g, "'`@");
if (host.transport === 'local') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
'& $scriptBlock'
].join('\n');
}
if (host.transport === 'ssh') {
return [
'$ErrorActionPreference = "Continue"',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -HostName ${psString(host.address)} -UserName $env:POSHM_USERNAME -ScriptBlock $scriptBlock`
].join('\n');
}
return [
'$ErrorActionPreference = "Continue"',
'$securePassword = ConvertTo-SecureString $env:POSHM_SECRET -AsPlainText -Force',
'$credential = [System.Management.Automation.PSCredential]::new($env:POSHM_USERNAME, $securePassword)',
`$scriptText = @'`,
escaped,
"'@",
'$scriptBlock = [ScriptBlock]::Create($scriptText)',
`Invoke-Command -ComputerName ${psString(host.address)} -Credential $credential -ScriptBlock $scriptBlock`
].join('\n');
}
function spawnPowerShell(jobId, host, file, secret, executionContext = {}) {
// Secrets are passed through process environment variables and are never written to job logs.
const runtimeEnv = Object.fromEntries(runtimeVariableValues(host, executionContext).map((variable) => [variable.name, String(variable.value ?? '')]));
const assetManifest = JSON.stringify(executionContext.assets || []);
return new Promise((resolve) => {
const child = spawn(config.powershellBin, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', file], {
env: {
...process.env,
...runtimeEnv,
POSHM_HOST_ID: host.id,
POSHM_HOST_NAME: host.name,
POSHM_HOST_ADDRESS: host.address,
POSHM_HOST_TRANSPORT: host.transport,
POSHM_ASSET_MANIFEST: assetManifest,
POSHM_USERNAME: host.credential_username || '',
POSHM_SECRET: secret || ''
},
shell: false
});
child.stdout.on('data', (chunk) => appendChunk(jobId, host.id, 'stdout', chunk));
child.stderr.on('data', (chunk) => appendChunk(jobId, host.id, 'stderr', chunk));
child.on('error', (error) => {
appendLog(jobId, host.id, 'stderr', `Unable to start PowerShell: ${error.message}`);
finishHost(jobId, host.id, 'failed', 127);
resolve();
});
child.on('close', (code, signal) => {
if (signal) appendLog(jobId, host.id, 'stderr', `PowerShell terminated by signal ${signal}`);
const exitCode = code ?? 1;
finishHost(jobId, host.id, exitCode === 0 ? 'completed' : 'failed', exitCode);
resolve();
});
});
}
function appendChunk(jobId, hostId, stream, chunk) {
String(chunk)
.split(/\r?\n/)
.filter(Boolean)
.forEach((line) => appendLog(jobId, hostId, stream, line));
}
function appendLog(jobId, hostId, stream, line) {
db.prepare(`
INSERT INTO job_logs (id, job_id, host_id, stream, line, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id('log'), jobId, hostId, stream, line, now());
}
function finishHost(jobId, hostId, status, exitCode) {
db.prepare(`
UPDATE job_hosts SET status = ?, exit_code = ?, ended_at = ?
WHERE job_id = ? AND host_id = ?
`).run(status, exitCode, now(), jobId, hostId);
appendLog(jobId, hostId, 'system', `Finished with status ${status} and exit code ${exitCode}`);
}
function runtimeVariableValues(host, executionContext) {
const assetManifest = JSON.stringify(executionContext.assets || []);
return [
{ name: 'POSHM_JOB_ID', value: executionContext.jobId || '', valueType: 'string' },
{ name: 'POSHM_RUNPLAN_ID', value: executionContext.runplanId || '', valueType: 'string' },
{ name: 'POSHM_SCRIPT_ID', value: executionContext.scriptId || '', valueType: 'string' },
{ name: 'POSHM_SCRIPT_NAME', value: executionContext.scriptName || '', valueType: 'string' },
{ name: 'POSHM_HOST_ID', value: host.id, valueType: 'string' },
{ name: 'POSHM_HOST_NAME', value: host.name, valueType: 'string' },
{ name: 'POSHM_HOST_ADDRESS', value: host.address, valueType: 'string' },
{ name: 'POSHM_HOST_TRANSPORT', value: host.transport, valueType: 'string' },
{ name: 'POSHM_ASSET_MANIFEST', value: assetManifest, valueType: 'string' },
{ name: 'POSHM_TRIGGERED_BY', value: executionContext.triggeredBy || '', valueType: 'string' }
];
}
function buildVariablePrelude(runtimeVariables, customVariables) {
const envLines = runtimeVariables.map((variable) => `$env:${variable.name} = ${psLiteral(variable.value, variable.valueType)}`);
const setLines = [...runtimeVariables, ...customVariables]
.filter((variable) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(variable.name))
.map((variable) => `Set-Variable -Name ${psString(variable.name)} -Value ${psLiteral(variable.value, variable.valueType)} -Scope Script`);
return [
'# POSHManager runtime variables',
...envLines,
...setLines
].join('\n');
}
function renderTokens(content, variables) {
let rendered = String(content || '');
for (const variable of variables) {
const token = `{{${variable.name}}}`;
rendered = rendered.split(token).join(String(variable.value ?? ''));
}
return rendered;
}
function psLiteral(value, type = 'string') {
if (type === 'boolean') return String(value).toLowerCase() === 'true' || value === true ? '$true' : '$false';
if (type === 'number' && value !== '' && Number.isFinite(Number(value))) return String(Number(value));
if (type === 'json') return `(ConvertFrom-Json -InputObject ${psString(value)})`;
return psString(value);
}
function psString(value) {
return `'${String(value || '').replace(/'/g, "''")}'`;
}

View File

@@ -0,0 +1,73 @@
import { applyPromotion, listPromotionCandidates } from '../models/psadtModel.js';
import { getGraphConnectionSystem } from '../models/graphModel.js';
import { setRingIntent } from './intunePromotionService.js';
import { buildAssignmentsPayload } from './intunePublishService.js';
import { assignWin32App } from './graphService.js';
import { logger } from './logger.js';
// Scheduled auto-promotion: after a deployment's soak window elapses, advance
// its configured rollout ring's intent. When the linked Graph connection is
// write-enabled and not approval-gated, the new assignments are also pushed to
// the tenant; otherwise the plan is promoted and an operator pushes it.
// Pure eligibility decision so the timing logic is unit-tested.
export function evaluatePromotion(deployment, nowMs = Date.now()) {
const hours = Number(deployment.autoPromoteAfterHours) || 0;
if (hours <= 0) return { due: false, reason: 'auto-promote disabled' };
if (!deployment.autoPromoteRing) return { due: false, reason: 'no target ring' };
if (deployment.promotedAt) return { due: false, reason: 'already promoted' };
if (!deployment.soakStartedAt) return { due: false, reason: 'soak not started' };
const soakStart = Date.parse(deployment.soakStartedAt);
if (Number.isNaN(soakStart)) return { due: false, reason: 'invalid soak timestamp' };
const elapsedHours = (nowMs - soakStart) / 3_600_000;
if (elapsedHours < hours) return { due: false, reason: `soaking (${elapsedHours.toFixed(1)}/${hours}h)` };
return { due: true, reason: 'soak window elapsed' };
}
export async function runAutoPromotions({ nowMs = Date.now(), pusher = assignWin32App } = {}) {
const summary = { evaluated: 0, promoted: 0, pushed: 0, errors: 0 };
for (const deployment of listPromotionCandidates()) {
summary.evaluated += 1;
const decision = evaluatePromotion(deployment, nowMs);
if (!decision.due) continue;
try {
const { assignments, changed } = setRingIntent(deployment.assignments, deployment.autoPromoteRing, deployment.autoPromoteIntent);
applyPromotion(deployment.id, changed ? assignments : deployment.assignments);
summary.promoted += 1;
// Optionally push to the tenant when it is safe to do so automatically.
if (deployment.graphAppId && deployment.graphConnectionId) {
const connection = getGraphConnectionSystem(deployment.graphConnectionId);
if (connection?.allow_write && !connection?.require_approval) {
const built = buildAssignmentsPayload({ ...deployment, assignments });
await pusher(connection, deployment.graphAppId, built.assignments);
summary.pushed += 1;
}
}
} catch (error) {
summary.errors += 1;
logger.error('auto-promotion failed', { deploymentId: deployment.id, error: error.message });
}
}
return summary;
}
let timer = null;
export function startPromotionWorker() {
const minutes = Number(process.env.AUTO_PROMOTE_INTERVAL_MINUTES || 0);
if (!Number.isFinite(minutes) || minutes <= 0) return null;
timer = setInterval(() => {
runAutoPromotions()
.then((summary) => logger.info('auto-promotion run complete', summary))
.catch((error) => logger.error('auto-promotion run failed', { error: error.message }));
}, minutes * 60 * 1000);
timer.unref?.();
logger.info(`auto-promotion scheduled every ${minutes} minute(s)`);
return timer;
}
export function stopPromotionWorker() {
if (timer) clearInterval(timer);
timer = null;
}

View File

@@ -0,0 +1,148 @@
const replacements = [
{ id: 'MIG-FN-EXECUTE-PROCESS', label: 'Execute-Process to Start-ADTProcess', pattern: /\bExecute-Process\b/g, replacement: 'Start-ADTProcess', automatic: true },
{ id: 'MIG-FN-EXECUTE-MSI', label: 'Execute-MSI to Start-ADTMsiProcess', pattern: /\bExecute-MSI\b/g, replacement: 'Start-ADTMsiProcess', automatic: true },
{ id: 'MIG-FN-EXECUTE-MSP', label: 'Execute-MSP to Start-ADTMspProcess', pattern: /\bExecute-MSP\b/g, replacement: 'Start-ADTMspProcess', automatic: true },
{ id: 'MIG-FN-WELCOME', label: 'Show-InstallationWelcome to Show-ADTInstallationWelcome', pattern: /\bShow-InstallationWelcome\b/g, replacement: 'Show-ADTInstallationWelcome', automatic: true },
{ id: 'MIG-FN-PROMPT', label: 'Show-InstallationPrompt to Show-ADTInstallationPrompt', pattern: /\bShow-InstallationPrompt\b/g, replacement: 'Show-ADTInstallationPrompt', automatic: true },
{ id: 'MIG-FN-PROGRESS', label: 'Show-InstallationProgress to Show-ADTInstallationProgress', pattern: /\bShow-InstallationProgress\b/g, replacement: 'Show-ADTInstallationProgress', automatic: true },
{ id: 'MIG-FN-RESTART', label: 'Show-InstallationRestartPrompt to Show-ADTInstallationRestartPrompt', pattern: /\bShow-InstallationRestartPrompt\b/g, replacement: 'Show-ADTInstallationRestartPrompt', automatic: true },
{ id: 'MIG-FN-LOG', label: 'Write-Log to Write-ADTLogEntry', pattern: /\bWrite-Log\b/g, replacement: 'Write-ADTLogEntry', automatic: true },
{ id: 'MIG-FN-REMOVE-MSI', label: 'Remove-MSIApplications to Uninstall-ADTApplication', pattern: /\bRemove-MSIApplications\b/g, replacement: 'Uninstall-ADTApplication', automatic: true },
{ id: 'MIG-FN-HKCU', label: 'Invoke-HKCURegistrySettingsForAllUsers to Invoke-ADTAllUsersRegistryAction', pattern: /\bInvoke-HKCURegistrySettingsForAllUsers\b/g, replacement: 'Invoke-ADTAllUsersRegistryAction', automatic: true },
{ id: 'MIG-NAME-DEPLOY-PS1', label: 'Deploy-Application.ps1 to Invoke-AppDeployToolkit.ps1', pattern: /\bDeploy-Application\.ps1\b/g, replacement: 'Invoke-AppDeployToolkit.ps1', automatic: true },
{ id: 'MIG-NAME-DEPLOY-EXE', label: 'Deploy-Application.exe to Invoke-AppDeployToolkit.exe', pattern: /\bDeploy-Application\.exe\b/g, replacement: 'Invoke-AppDeployToolkit.exe', automatic: true },
{ id: 'MIG-VAR-APPNAME', label: '$appName to $adtSession.AppName', pattern: /\$appName\b/g, replacement: '$adtSession.AppName', automatic: true },
{ id: 'MIG-VAR-APPVERSION', label: '$appVersion to $adtSession.AppVersion', pattern: /\$appVersion\b/g, replacement: '$adtSession.AppVersion', automatic: true },
{ id: 'MIG-VAR-APPVENDOR', label: '$appVendor to $adtSession.AppVendor', pattern: /\$appVendor\b/g, replacement: '$adtSession.AppVendor', automatic: true },
{ id: 'MIG-VAR-APPARCH', label: '$appArch to $adtSession.AppArch', pattern: /\$appArch\b/g, replacement: '$adtSession.AppArch', automatic: true },
{ id: 'MIG-VAR-APPLANG', label: '$appLang to $adtSession.AppLang', pattern: /\$appLang\b/g, replacement: '$adtSession.AppLang', automatic: true },
{ id: 'MIG-VAR-APPREVISION', label: '$appRevision to $adtSession.AppRevision', pattern: /\$appRevision\b/g, replacement: '$adtSession.AppRevision', automatic: true },
{ id: 'MIG-MODULE-420', label: 'Pin PSAppDeployToolkit ModuleVersion to 4.2.0', pattern: /ModuleVersion\s*=\s*['"][0-9.]+['"]/g, replacement: "ModuleVersion = '4.2.0'", automatic: true }
];
const manualChecks = [
{
id: 'MANUAL-SERVICEUI',
severity: 'critical',
pattern: /\b(ServiceUI\.exe|Invoke-ServiceUI\.ps1)\b/i,
title: 'Remove ServiceUI launch wrappers',
guidance: 'PSADT v4.1+ provides native user-session UI for Intune. Remove ServiceUI and test with SYSTEM context launch helpers.'
},
{
id: 'MANUAL-TEMPLATE-SHAPE',
severity: 'warning',
pattern: /function\s+(Install|Uninstall|Repair)-ADTDeployment/i,
title: 'Older function-based deployment phase shape detected',
guidance: 'Review the migrated script against the upstream v4 template that uses New-Variable phase scriptblocks and Open-ADTSession.'
},
{
id: 'MANUAL-RAW-MSIEXEC',
severity: 'warning',
pattern: /\bmsiexec(?:\.exe)?\b/i,
title: 'Raw msiexec call needs installer review',
guidance: 'Prefer Start-ADTMsiProcess or Start-ADTMspProcess so logging, success codes, and reboot handling stay consistent.'
},
{
id: 'MANUAL-INTUNE-DEFER',
severity: 'warning',
pattern: /Show-ADTInstallationWelcome[^\n\r]*-AllowDefer\b(?![^\n\r]*-DeferRunInterval)/i,
title: 'Intune deferral flow should include DeferRunInterval',
guidance: 'Add -DeferRunInterval to avoid repeated Intune retry prompts after a 1602 defer result.'
}
];
export function planPsadtMigration(content = '', options = {}) {
const source = String(content || '');
const steps = [];
let automaticCount = 0;
for (const rule of replacements) {
const matches = [...source.matchAll(rule.pattern)].filter((match) => !isCommentLine(source, match.index || 0));
if (!matches.length) continue;
automaticCount += matches.length;
steps.push({
id: rule.id,
type: 'replace',
automatic: rule.automatic,
status: 'ready',
title: rule.label,
count: matches.length,
lines: matches.slice(0, 8).map((match) => lineNumber(source, match.index || 0)),
replacement: rule.replacement
});
}
for (const rule of manualChecks) {
const match = rule.pattern.exec(source);
if (!match || isCommentLine(source, match.index || 0)) continue;
steps.push({
id: rule.id,
type: 'manual-review',
automatic: false,
status: 'review',
severity: rule.severity,
title: rule.title,
guidance: rule.guidance,
lines: [lineNumber(source, match.index || 0)]
});
}
if (!/Open-ADTSession/i.test(source)) {
steps.push({
id: 'MANUAL-OPEN-ADTSESSION',
type: 'manual-review',
automatic: false,
status: 'review',
severity: 'warning',
title: 'Open-ADTSession not detected',
guidance: 'Latest PSADT scripts should initialize the module and open an ADTSession before invoking phase logic.',
lines: []
});
}
return {
targetVersion: options.targetVersion || '4.2.0',
summary: {
steps: steps.length,
automatic: steps.filter((step) => step.automatic).length,
manual: steps.filter((step) => !step.automatic).length,
replacements: automaticCount
},
steps,
migratedContent: applyPsadtMigration(source)
};
}
export function applyPsadtMigration(content = '') {
let migrated = String(content || '');
for (const rule of replacements) {
migrated = replaceOutsideComments(migrated, rule.pattern, rule.replacement);
}
migrated = migrated.replace(/(Show-ADTInstallationPrompt[^\n\r]*)\s-Icon\s+\S+/gi, '$1');
if (!/POSHManager PSADT migration notes/i.test(migrated)) {
migrated = [
'# POSHManager PSADT migration notes:',
'# - Automatic replacements were applied for known v3/v4 legacy names and variables.',
'# - Review manual migration findings before deploying through Intune or any production channel.',
'',
migrated
].join('\n');
}
return migrated;
}
function replaceOutsideComments(content, pattern, replacement) {
return content.split(/\r?\n/).map((line) => {
if (line.trimStart().startsWith('#')) return line;
return line.replace(pattern, replacement);
}).join('\n');
}
function isCommentLine(content, index) {
const line = content.slice(0, index).split(/\r?\n/).length;
return content.split(/\r?\n/)[line - 1]?.trimStart().startsWith('#');
}
function lineNumber(content, index) {
return content.slice(0, index).split(/\r?\n/).length;
}

View File

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