Public/New-DllSuiteReport.ps1

function New-DllSuiteReport {
    <#
    .SYNOPSIS
        Render a self-contained HTML report from an Invoke-DllSuiteAnalysis
        result. The .html embeds CSS, JS and the analysis data as JSON, so
        it opens anywhere with no dependencies.
 
    .DESCRIPTION
        Takes the structured analysis object produced by
        Invoke-DllSuiteAnalysis (or its serialized JSON form) and writes a
        single HTML file. The page renders client-side from the embedded
        JSON, so filtering and table sorting work offline. Sections:
 
          - Summary (counters and scanned paths).
          - Duplicate groups (byte-identical DLLs).
          - GUID conflicts (drift highlighted).
          - Registration status of conflicted CoClasses.
          - Full DLL inventory.
 
        Designed to be the artifact you mail to the dev teams.
 
    .PARAMETER Analysis
        Analysis object as returned by Invoke-DllSuiteAnalysis. Pipeline-
        friendly. Mutually exclusive with -JsonPath.
 
    .PARAMETER JsonPath
        Path to a previously saved report.json (schema 'dllsuite/1').
        Useful in CI: scan once on the server, render the HTML on a
        different machine. Mutually exclusive with -Analysis.
 
    .PARAMETER OutputPath
        Destination .html file. Created or overwritten.
 
    .EXAMPLE
        Invoke-DllSuiteAnalysis -Path .\bin -Recurse |
            New-DllSuiteReport -OutputPath .\suite-report.html
 
    .EXAMPLE
        New-DllSuiteReport -JsonPath .\artifacts\report.json `
                           -OutputPath .\artifacts\report.html
    #>

    [CmdletBinding(DefaultParameterSetName = 'FromObject')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'FromObject', ValueFromPipeline)]
        [psobject]$Analysis,

        [Parameter(Mandatory, ParameterSetName = 'FromJson')]
        [string]$JsonPath,

        [Parameter(Mandatory)]
        [string]$OutputPath
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'FromJson') {
            $resolved = (Resolve-Path -LiteralPath $JsonPath -ErrorAction Stop).ProviderPath
            $jsonText = Get-Content -LiteralPath $resolved -Raw -Encoding UTF8
            $Analysis = $jsonText | ConvertFrom-Json
        } else {
            $jsonText = $Analysis | ConvertTo-Json -Depth 12
        }

        if (-not $Analysis -or -not $Analysis.Schema) {
            throw "Input does not look like a dllsuite/1 analysis object."
        }

        # Escape </script> in case any string field contained it (paranoia).
        $safeJson = $jsonText -replace '</script', '<\/script'

        $title = "DLL Suite Analysis - {0}" -f $Analysis.HostName

        # ---- CSS (single-quoted here-string -> no PS interpolation) ----
        $css = @'
*,*::before,*::after { box-sizing: border-box; }
html { font-size: 14px; }
body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    color: #1f2328;
    background: #f6f8fa;
    line-height: 1.5;
}
header {
    background: #24292f;
    color: #fff;
    padding: 1.5rem 2rem;
    border-bottom: 4px solid #0969da;
}
header h1 { margin: 0 0 .5rem; font-size: 1.5rem; font-weight: 600; }
header .meta { display: flex; gap: 1.5rem; flex-wrap: wrap; font-size: .85rem; opacity: .85; }
header .meta span { white-space: nowrap; }
main { max-width: 1400px; margin: 0 auto; padding: 1.5rem 2rem 4rem; }
section { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; margin-bottom: 1.5rem; overflow: hidden; }
section > header { display: none; }
section h2 {
    margin: 0;
    padding: .9rem 1.25rem;
    font-size: 1.05rem;
    font-weight: 600;
    background: #f6f8fa;
    border-bottom: 1px solid #d0d7de;
    display: flex;
    align-items: center;
    gap: .6rem;
}
section h2 .count {
    background: #0969da;
    color: #fff;
    padding: .1rem .55rem;
    border-radius: 999px;
    font-size: .75rem;
    font-weight: 600;
}
section h2 .count.zero { background: #6a737d; }
section h2 .count.warn { background: #bf8700; }
section h2 .count.bad { background: #cf222e; }
section .desc { padding: .75rem 1.25rem; font-size: .85rem; color: #57606a; border-bottom: 1px solid #d0d7de; }
.summary-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
    gap: 1px;
    background: #d0d7de;
}
.metric {
    background: #fff;
    padding: 1rem 1.25rem;
    display: flex;
    flex-direction: column;
    gap: .25rem;
}
.metric .v { font-size: 1.6rem; font-weight: 600; line-height: 1; }
.metric .l { font-size: .75rem; color: #57606a; text-transform: uppercase; letter-spacing: .04em; }
.metric.warn .v { color: #bf8700; }
.metric.bad .v { color: #cf222e; }
.paths { padding: .75rem 1.25rem; font-size: .85rem; color: #57606a; border-top: 1px solid #d0d7de; }
.paths code { background: #f6f8fa; padding: .1rem .35rem; border-radius: 3px; margin-right: .35rem; }
.controls { padding: .75rem 1.25rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; border-bottom: 1px solid #d0d7de; }
.controls input[type="search"] {
    flex: 1 1 200px;
    padding: .35rem .6rem;
    border: 1px solid #d0d7de;
    border-radius: 6px;
    font-size: .85rem;
    font-family: inherit;
}
.controls label { font-size: .85rem; cursor: pointer; user-select: none; display: flex; align-items: center; gap: .35rem; }
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
thead th { background: #f6f8fa; text-align: left; padding: .6rem 1.25rem; font-weight: 600; border-bottom: 1px solid #d0d7de; white-space: nowrap; }
tbody td { padding: .55rem 1.25rem; border-bottom: 1px solid #eaeef2; vertical-align: top; }
tbody tr:hover { background: #f6f8fa; }
tbody tr.hidden { display: none; }
code, .mono { font-family: ui-monospace, SFMono-Regular, 'Cascadia Code', Consolas, monospace; font-size: .82rem; }
.guid { color: #6f42c1; }
.path { color: #57606a; word-break: break-all; }
.badge { display: inline-block; padding: .12rem .5rem; border-radius: 3px; font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; line-height: 1.5; }
.badge.ok { background: #dafbe1; color: #1a7f37; }
.badge.warn { background: #fff8c5; color: #9a6700; }
.badge.bad { background: #ffebe9; color: #cf222e; }
.badge.muted { background: #eaeef2; color: #57606a; }
.badge.info { background: #ddf4ff; color: #0969da; }
.kind-coclass { color: #1a7f37; }
.kind-interface, .kind-dispatch { color: #0969da; }
.kind-enum, .kind-record, .kind-union, .kind-alias, .kind-module { color: #6e7781; }
ul.compact { margin: .25rem 0; padding-left: 1.25rem; }
ul.compact li { font-size: .82rem; color: #57606a; }
details summary { cursor: pointer; font-size: .82rem; color: #0969da; }
details[open] summary { margin-bottom: .35rem; }
.empty { padding: 2rem 1.25rem; color: #57606a; font-style: italic; text-align: center; }
footer { text-align: center; padding: 1.5rem; color: #57606a; font-size: .8rem; }
@media (max-width: 720px) {
    main { padding: 1rem; }
    header { padding: 1rem; }
    thead th, tbody td { padding: .5rem; }
}
'@


        # ---- JS (single-quoted here-string) ----------------------------
        $js = @'
(function () {
    var data = window.__SUITE_ANALYSIS__;
    if (!data) { document.body.innerHTML = '<p style="padding:2rem">No analysis data found.</p>'; return; }
 
    function el(tag, attrs, children) {
        var n = document.createElement(tag);
        if (attrs) for (var k in attrs) {
            if (k === 'class') n.className = attrs[k];
            else if (k === 'html') n.innerHTML = attrs[k];
            else if (k === 'text') n.textContent = attrs[k];
            else n.setAttribute(k, attrs[k]);
        }
        if (children) for (var i = 0; i < children.length; i++) {
            var c = children[i];
            if (c == null) continue;
            n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
        }
        return n;
    }
    function fmtBytes(n) {
        if (n == null) return '';
        if (n < 1024) return n + ' B';
        if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
        return (n/(1024*1024)).toFixed(2) + ' MB';
    }
    function shortHash(h) { return h ? h.substring(0, 12) : ''; }
    function basename(p) { return p ? p.replace(/^.*[\\\/]/, '') : ''; }
 
    // Build the page
    document.title = data.HostName + ' - DLL Suite Analysis';
 
    var root = document.getElementById('root');
    var s = data.Summary;
 
    // ----- Summary cards -----
    var cards = [
        { v: s.FilesScanned, l: 'Files scanned' },
        { v: s.UniqueByHash, l: 'Unique by hash' },
        { v: s.ComServers, l: 'COM servers' },
        { v: s.DuplicateGroups, l: 'Duplicate groups', cls: s.DuplicateGroups > 0 ? 'warn' : '' },
        { v: s.GuidConflicts, l: 'GUID conflicts', cls: s.GuidConflicts > 0 ? 'warn' : '' },
        { v: s.DriftIssues, l: 'Drift issues', cls: s.DriftIssues > 0 ? 'bad' : '' },
        { v: s.ParseErrors, l: 'Parse errors', cls: s.ParseErrors > 0 ? 'bad' : '' }
    ];
    var grid = el('div', { class: 'summary-grid' });
    cards.forEach(function (c) {
        grid.appendChild(el('div', { class: 'metric' + (c.cls ? ' ' + c.cls : '') }, [
            el('span', { class: 'v', text: String(c.v) }),
            el('span', { class: 'l', text: c.l })
        ]));
    });
    var pathsList = (data.ScannedPaths || []).map(function (p) {
        return el('code', { text: p });
    });
    var pathsDiv = el('div', { class: 'paths' });
    pathsDiv.appendChild(document.createTextNode('Paths: '));
    pathsList.forEach(function (c) { pathsDiv.appendChild(c); });
    var summary = section('Summary', null, null);
    summary.appendChild(grid);
    summary.appendChild(pathsDiv);
    root.appendChild(summary);
 
    // ----- Duplicate groups -----
    var dupSec = section('Duplicate groups', data.DuplicateGroups.length, 'Byte-identical DLLs grouped by SHA-256. Pick a canonical copy and remove the rest.');
    if (data.DuplicateGroups.length === 0) {
        dupSec.appendChild(el('div', { class: 'empty', text: 'No duplicates.' }));
    } else {
        var dupTbl = el('table', null, [
            el('thead', null, [el('tr', null, ['SHA-256', 'Size', 'Count', 'Paths'].map(function (h) { return el('th', { text: h }); }))]),
            el('tbody', null, data.DuplicateGroups.map(function (g) {
                return el('tr', null, [
                    el('td', null, [el('code', { class: 'mono', text: shortHash(g.Sha256) })]),
                    el('td', { text: fmtBytes(g.Size) }),
                    el('td', { text: String(g.Count) }),
                    el('td', null, [el('ul', { class: 'compact' }, g.Paths.map(function (p) { return el('li', null, [el('span', { class: 'mono path', text: p })]); }))])
                ]);
            }))
        ]);
        dupSec.appendChild(dupTbl);
    }
    root.appendChild(dupSec);
 
    // ----- GUID conflicts -----
    var conflicts = data.GuidConflicts || [];
    var driftCount = conflicts.filter(function (c) { return c.HasDrift; }).length;
    var conflictHeaderClass = driftCount > 0 ? 'bad' : (conflicts.length > 0 ? 'warn' : 'zero');
    var conSec = section('GUID conflicts', conflicts.length, 'Same GUID in DLLs with different SHA-256. DRIFT means the interface or method set also differs across versions.', conflictHeaderClass);
 
    if (conflicts.length === 0) {
        conSec.appendChild(el('div', { class: 'empty', text: 'No GUID conflicts.' }));
    } else {
        var ctrls = el('div', { class: 'controls' });
        var search = el('input', { type: 'search', placeholder: 'Filter by GUID or name...' });
        var driftOnly = el('input', { type: 'checkbox', id: 'drift-only' });
        var driftLabel = el('label', null, [driftOnly, document.createTextNode(' Drift only')]);
        var coOnly = el('input', { type: 'checkbox', id: 'coclass-only' });
        var coLabel = el('label', null, [coOnly, document.createTextNode(' CoClasses only')]);
        ctrls.appendChild(search);
        ctrls.appendChild(driftLabel);
        ctrls.appendChild(coLabel);
        conSec.appendChild(ctrls);
 
        var conTbl = el('table');
        var thead = el('thead', null, [el('tr', null, ['', 'Kind', 'Name', 'GUID', 'Versions', 'Occurrences'].map(function (h) { return el('th', { text: h }); }))]);
        conTbl.appendChild(thead);
        var tbody = el('tbody');
        conflicts.forEach(function (c) {
            var badge = c.HasDrift ? el('span', { class: 'badge bad', text: 'drift' }) : el('span', { class: 'badge warn', text: 'dup-guid' });
            var occList = el('ul', { class: 'compact' }, c.Occurrences.map(function (o) {
                var sig = c.Kind === 'coclass' ? ('ifaces=' + o.IfaceCount) : ('methods=' + o.MethodCount);
                return el('li', null, [el('span', { class: 'mono path', text: o.Path + ' [' + shortHash(o.Sha256) + ' ' + sig + ']' })]);
            }));
            var tr = el('tr', null, [
                el('td', null, [badge]),
                el('td', null, [el('span', { class: 'kind-' + c.Kind, text: c.Kind })]),
                el('td', { text: c.Name || '' }),
                el('td', null, [el('code', { class: 'mono guid', text: c.Guid })]),
                el('td', { text: String(c.DistinctVersions) }),
                el('td', null, [occList])
            ]);
            tr.dataset.guid = (c.Guid || '').toLowerCase();
            tr.dataset.name = (c.Name || '').toLowerCase();
            tr.dataset.kind = c.Kind || '';
            tr.dataset.drift = c.HasDrift ? '1' : '0';
            tbody.appendChild(tr);
        });
        conTbl.appendChild(tbody);
        conSec.appendChild(conTbl);
 
        function applyFilter() {
            var q = search.value.toLowerCase();
            var d = driftOnly.checked;
            var co = coOnly.checked;
            for (var i = 0; i < tbody.rows.length; i++) {
                var r = tbody.rows[i];
                var match = true;
                if (q && r.dataset.guid.indexOf(q) === -1 && r.dataset.name.indexOf(q) === -1) match = false;
                if (d && r.dataset.drift !== '1') match = false;
                if (co && r.dataset.kind !== 'coclass') match = false;
                r.classList.toggle('hidden', !match);
            }
        }
        search.addEventListener('input', applyFilter);
        driftOnly.addEventListener('change', applyFilter);
        coOnly.addEventListener('change', applyFilter);
    }
    root.appendChild(conSec);
 
    // ----- Registration status -----
    var regStatus = data.RegistrationStatus || [];
    var regSec = section('Registration status', regStatus.length, 'For each conflicted CoClass GUID, where the OS-level registration currently points.');
    if (regStatus.length === 0) {
        regSec.appendChild(el('div', { class: 'empty', text: 'No conflicted CoClasses to report.' }));
    } else {
        var regTbl = el('table', null, [
            el('thead', null, [el('tr', null, ['Status', 'CLSID', 'Name', 'Registered to', 'Hive', 'View'].map(function (h) { return el('th', { text: h }); }))]),
            el('tbody', null, regStatus.map(function (r) {
                var cls = r.State === 'NotRegistered' ? 'warn'
                        : r.State === 'RegisteredOutsideScan' ? 'info'
                        : r.State === 'RegisteredToScanned' ? 'ok' : 'muted';
                return el('tr', null, [
                    el('td', null, [el('span', { class: 'badge ' + cls, text: r.State })]),
                    el('td', null, [el('code', { class: 'mono guid', text: r.Clsid })]),
                    el('td', { text: r.Name || '' }),
                    el('td', null, [el('span', { class: 'mono path', text: r.RegisteredPath || '-' })]),
                    el('td', { text: r.Hive || '' }),
                    el('td', { text: r.View || '' })
                ]);
            }))
        ]);
        regSec.appendChild(regTbl);
    }
    root.appendChild(regSec);
 
    // ----- All DLLs -----
    var dlls = data.Dlls || [];
    var dllSec = section('All DLLs', dlls.length, 'Full inventory of every PE that was parsed.');
    if (dlls.length === 0) {
        dllSec.appendChild(el('div', { class: 'empty', text: 'No DLLs parsed.' }));
    } else {
        var dllCtrls = el('div', { class: 'controls' });
        var dllSearch = el('input', { type: 'search', placeholder: 'Filter by path or library name...' });
        dllCtrls.appendChild(dllSearch);
        dllSec.appendChild(dllCtrls);
 
        var dllTbl = el('table');
        dllTbl.appendChild(el('thead', null, [el('tr', null, ['Path', 'Size', 'SHA-256', 'COM', 'TypeLib', 'Entries'].map(function (h) { return el('th', { text: h }); }))]));
        var dbody = el('tbody');
        dlls.forEach(function (d) {
            var comBadge = d.IsComServer ? el('span', { class: 'badge ok', text: 'COM' }) : el('span', { class: 'badge muted', text: '-' });
            var tlb = d.HasTypeLib ? (d.LibName + ' v' + d.LibVersion) : '-';
            var ec = (d.Entries || []).length;
            var tr = el('tr', null, [
                el('td', null, [el('span', { class: 'mono path', text: d.Path })]),
                el('td', { text: fmtBytes(d.Size) }),
                el('td', null, [el('code', { class: 'mono', text: shortHash(d.Sha256) })]),
                el('td', null, [comBadge]),
                el('td', { text: tlb }),
                el('td', { text: String(ec) })
            ]);
            tr.dataset.path = (d.Path || '').toLowerCase();
            tr.dataset.lib = (d.LibName || '').toLowerCase();
            dbody.appendChild(tr);
        });
        dllTbl.appendChild(dbody);
        dllSec.appendChild(dllTbl);
 
        dllSearch.addEventListener('input', function () {
            var q = dllSearch.value.toLowerCase();
            for (var i = 0; i < dbody.rows.length; i++) {
                var r = dbody.rows[i];
                var match = !q || r.dataset.path.indexOf(q) !== -1 || r.dataset.lib.indexOf(q) !== -1;
                r.classList.toggle('hidden', !match);
            }
        });
    }
    root.appendChild(dllSec);
 
    function section(title, count, desc, countClass) {
        var sec = el('section');
        var h = el('h2', null, [document.createTextNode(title)]);
        if (count != null) {
            var cls = countClass || (count === 0 ? 'zero' : '');
            h.appendChild(el('span', { class: 'count' + (cls ? ' ' + cls : ''), text: String(count) }));
        }
        sec.appendChild(h);
        if (desc) sec.appendChild(el('div', { class: 'desc', text: desc }));
        return sec;
    }
})();
'@


        # ---- Final HTML ------------------------------------------------
        # Inline HTML escape to avoid taking a dependency on System.Web.
        $enc = {
            param([string]$s)
            if (-not $s) { return '' }
            $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;').Replace('"','&quot;')
        }

        $hostEnc   = & $enc $Analysis.HostName
        $scanEnc   = & $enc ([string]$Analysis.ScannedAt)
        $schemaEnc = & $enc ([string]$Analysis.Schema)
        $titleEnc  = & $enc $title

        $html = "<!DOCTYPE html>`r`n<html lang=`"en`">`r`n<head>`r`n" +
                "<meta charset=`"utf-8`">`r`n" +
                "<meta name=`"viewport`" content=`"width=device-width,initial-scale=1`">`r`n" +
                "<title>$titleEnc</title>`r`n" +
                "<style>`r`n$css`r`n</style>`r`n</head>`r`n<body>`r`n" +
                "<header>`r`n" +
                " <h1>DLL Suite Analysis</h1>`r`n" +
                " <div class=`"meta`">`r`n" +
                " <span>Host: $hostEnc</span>`r`n" +
                " <span>Scanned: $scanEnc</span>`r`n" +
                " <span>Schema: $schemaEnc</span>`r`n" +
                " </div>`r`n" +
                "</header>`r`n" +
                "<main id=`"root`"></main>`r`n" +
                "<footer>Generated by SysUtils <code>Invoke-DllSuiteAnalysis</code> + <code>New-DllSuiteReport</code></footer>`r`n" +
                "<script id=`"data`" type=`"application/json`">`r`n$safeJson`r`n</script>`r`n" +
                "<script>window.__SUITE_ANALYSIS__ = JSON.parse(document.getElementById('data').textContent);</script>`r`n" +
                "<script>`r`n$js`r`n</script>`r`n" +
                "</body>`r`n</html>`r`n"

        Set-Content -LiteralPath $OutputPath -Value $html -Encoding UTF8
        Write-Verbose "HTML report written to $OutputPath"
    }
}