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

+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 };
}