Files
poshmanager/server/services/intunewinParser.js
2026-06-25 12:05:53 -05:00

105 lines
4.4 KiB
JavaScript

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