From 8402c400ed63387853579b0851dc0fe083617ce2 Mon Sep 17 00:00:00 2001 From: Chris LaPlante Date: Mon, 15 Jun 2026 15:26:48 -0400 Subject: [PATCH] Fix summary for Folder Scan output. Bug with "Policy-relevant binaries" numbers --- WDAC-Policy-Wizard/app/src/BuildPage.cs | 2 +- WDAC-Policy-Wizard/app/src/MainForm.cs | 271 +++++++++++++++++++++++- 2 files changed, 267 insertions(+), 6 deletions(-) diff --git a/WDAC-Policy-Wizard/app/src/BuildPage.cs b/WDAC-Policy-Wizard/app/src/BuildPage.cs index 0b41d04..9ee4147 100644 --- a/WDAC-Policy-Wizard/app/src/BuildPage.cs +++ b/WDAC-Policy-Wizard/app/src/BuildPage.cs @@ -100,7 +100,7 @@ public void SetScanSummary(long totalFilesOnDisk, int policyRelevantFiles, int h $" Signer rules (PCA/Pub) {signerRules,8:N0}\r\n" + $" Hash rules in XML {hashRules,8:N0}\r\n" + $" Unique hashes {uniqueHashes,8:N0}\r\n" + - $" Duplicate hashes removed {duplicateHashes,8:N0}\r\n" + + $" Duplicate hashes (collisions) {duplicateHashes,5:N0}\r\n" + $"\r\n" + $" Elapsed time {elapsedText,8}"; diff --git a/WDAC-Policy-Wizard/app/src/MainForm.cs b/WDAC-Policy-Wizard/app/src/MainForm.cs index 6fe5073..f77e41e 100644 --- a/WDAC-Policy-Wizard/app/src/MainForm.cs +++ b/WDAC-Policy-Wizard/app/src/MainForm.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.ComponentModel; using System.Drawing; +using System.Linq; +using System.Text.RegularExpressions; using System.Windows.Forms; using System.IO; using WDAC_Wizard.src; @@ -53,6 +55,224 @@ private class FolderScanStats public TimeSpan Elapsed; } + // Matches the trailing hash-flavor descriptor that New-CIPolicy -Level Hash appends to a + // rule's FriendlyName. Designed to be forward-compatible with new SHA bit lengths and + // any future "Hash Sha" combinations the cmdlet may emit. + // + // Observed today (Windows 10/11): + // "Hash Sha1", "Hash Sha256", + // "Hash Page Sha1", "Hash Page Sha256", + // "Hash Authenticode SIP Sha256" + // + // Pattern breakdown: + // \s+ literal whitespace before "Hash" + // (? capture the descriptor for downstream classification + // Hash literal "Hash" + // (?:\s+(?:Page|Authenticode\s+SIP))? optional qualifier + // \s+Sha\d+ "Sha" followed by 1+ digits (Sha1, Sha256, Sha384, ...) + // ) + // \s*$ optional trailing whitespace + private static readonly Regex HashRuleFriendlyNameSuffixRegex = new Regex( + @"\s+(?Hash(?:\s+(?:Page|Authenticode\s+SIP))?\s+Sha\d+)\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Extracts the source binary path and hash-flavor descriptor from the FriendlyName of a + /// hash rule produced by New-CIPolicy. New-CIPolicy emits 1, 2, or 4 hash rules per + /// scanned file depending on file type, all sharing a FriendlyName of + /// "<path> <flavor>" where the flavor matches + /// . + /// + /// The Allow/Deny rule's FriendlyName attribute. + /// + /// The matched flavor descriptor (e.g. "Hash Sha256", "Hash Page Sha1", + /// "Hash Authenticode SIP Sha256"), or null when the regex didn't match. + /// + /// The trimmed source path, or the trimmed full FriendlyName when no match. + private static string ExtractScannedBinaryPath(string friendlyName, out string flavor) + { + flavor = null; + if (string.IsNullOrEmpty(friendlyName)) + { + return null; + } + + Match match = HashRuleFriendlyNameSuffixRegex.Match(friendlyName); + if (match.Success) + { + flavor = NormalizeFlavor(match.Groups["flavor"].Value); + return friendlyName.Substring(0, match.Index).Trim(); + } + + // FriendlyName didn't match the expected New-CIPolicy pattern; fall back to using + // the whole string so the caller still gets a stable per-binary key. + return friendlyName.Trim(); + } + + /// + /// Collapses any internal whitespace runs in a captured flavor descriptor to a single + /// space and normalizes casing so flavors group identically regardless of how the + /// upstream cmdlet formatted them. + /// + private static string NormalizeFlavor(string flavor) + { + if (string.IsNullOrEmpty(flavor)) + { + return flavor; + } + + return Regex.Replace(flavor.Trim(), @"\s+", " "); + } + + /// + /// Writes a detailed per-class breakdown of the folder scan results to the log file. Each + /// scanned binary is bucketed by the unique set of hash flavors emitted for it (e.g. + /// "Sha1+Sha256+Page Sha1+Page Sha256") and binaries the cmdlet emitted multiple rules + /// for the same flavor are split into a separate "(emitted multiple times)" bucket. The + /// classification is data-driven, so any new flavor combination produced by future + /// New-CIPolicy versions appears automatically. + /// + private static void LogFolderScanBreakdown( + string scanPath, + FolderScanStats stats, + Dictionary> binaryFlavors) + { + if (binaryFlavors == null || stats == null) + { + return; + } + + // Bucket each binary by its (flavor-set, has-multiples) signature. + // Key = pipe-joined sorted flavors; "*" appended when any flavor count > 1. + var classes = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in binaryFlavors) + { + Dictionary flavorCounts = kvp.Value; + bool hasMultiples = false; + int totalRules = 0; + foreach (int n in flavorCounts.Values) + { + totalRules += n; + if (n > 1) hasMultiples = true; + } + + var sortedFlavors = new List(flavorCounts.Keys); + sortedFlavors.Sort(StringComparer.OrdinalIgnoreCase); + string flavorSetKey = string.Join("|", sortedFlavors) + (hasMultiples ? "|*" : ""); + + if (!classes.TryGetValue(flavorSetKey, out var bucket)) + { + bucket = new ClassBucket + { + Label = BuildClassLabel(sortedFlavors, hasMultiples), + RulesPerBinaryDisplay = BuildRulesPerBinaryDisplay(flavorCounts, hasMultiples), + }; + classes[flavorSetKey] = bucket; + } + + bucket.Binaries++; + bucket.TotalRules += totalRules; + } + + // Emit the breakdown as a fixed-width table in the log. + try + { + Logger.Log.AddNewSeparationLine("Folder Scan Breakdown"); + Logger.Log.AddInfoMsg($"Scan path: {scanPath}"); + Logger.Log.AddInfoMsg($"Elapsed: {stats.Elapsed}"); + Logger.Log.AddInfoMsg(""); + Logger.Log.AddInfoMsg( + string.Format("{0,-60} {1,10} {2,-40} {3,12}", + "File class", "Binaries", "Rules each", "Total rules")); + Logger.Log.AddInfoMsg(new string('-', 124)); + + // Most common classes first for readability. + foreach (var c in classes.Values + .OrderByDescending(b => b.Binaries) + .ThenBy(b => b.Label, StringComparer.OrdinalIgnoreCase)) + { + Logger.Log.AddInfoMsg( + string.Format("{0,-60} {1,10:N0} {2,-40} {3,12:N0}", + Truncate(c.Label, 60), + c.Binaries, + Truncate(c.RulesPerBinaryDisplay, 40), + c.TotalRules)); + } + + Logger.Log.AddInfoMsg(new string('-', 124)); + Logger.Log.AddInfoMsg( + string.Format("{0,-60} {1,10:N0} {2,-40} {3,12:N0}", + "TOTAL", + stats.PolicyRelevantFiles, + "-", + stats.HashRules)); + Logger.Log.AddInfoMsg(""); + Logger.Log.AddInfoMsg($"Total files on disk: {stats.TotalFilesOnDisk:N0}"); + Logger.Log.AddInfoMsg($"Policy-relevant binaries: {stats.PolicyRelevantFiles:N0}"); + Logger.Log.AddInfoMsg($"Signer rules (PCA/Pub): {stats.SignerRules:N0}"); + Logger.Log.AddInfoMsg($"Hash rules in XML: {stats.HashRules:N0}"); + Logger.Log.AddInfoMsg($"Unique hashes: {stats.UniqueHashes:N0}"); + Logger.Log.AddInfoMsg($"Duplicate hashes (collisions): {stats.DuplicateHashes:N0}"); + } + catch (Exception ex) + { + // Logging is best-effort; never let a logging failure break the build flow. + Logger.Log.AddWarningMsg($"LogFolderScanBreakdown failed: {ex.Message}"); + } + } + + private sealed class ClassBucket + { + public string Label; + public string RulesPerBinaryDisplay; + public int Binaries; + public int TotalRules; + } + + /// + /// Builds a human-friendly class label from a sorted list of flavor descriptors + /// (e.g. "Hash Sha1, Hash Sha256, Hash Page Sha1, Hash Page Sha256"). Marks the bucket + /// as "(emitted multiple times)" when one or more flavors were emitted more than once + /// for the same binary. + /// + private static string BuildClassLabel(List sortedFlavors, bool hasMultiples) + { + string joined = sortedFlavors.Count > 0 + ? string.Join(", ", sortedFlavors) + : "Unknown"; + return hasMultiples + ? joined + " (emitted multiple times)" + : joined; + } + + /// + /// Builds the "Rules each" cell, e.g. "4" or "8 (2x of 4 flavors)". + /// + private static string BuildRulesPerBinaryDisplay(Dictionary flavorCounts, bool hasMultiples) + { + int total = 0; + foreach (int n in flavorCounts.Values) total += n; + + if (!hasMultiples) + { + return total.ToString(); + } + + // Find the most common multiplier (typically 2) for an informative label. + int maxMultiplier = 1; + foreach (int n in flavorCounts.Values) + { + if (n > maxMultiplier) maxMultiplier = n; + } + return $"{total} ({maxMultiplier}x of {flavorCounts.Count} flavor{(flavorCounts.Count == 1 ? "" : "s")})"; + } + + private static string Truncate(string s, int maxLen) + { + if (string.IsNullOrEmpty(s) || s.Length <= maxLen) return s ?? string.Empty; + return s.Substring(0, maxLen - 1) + "…"; + } + public enum EditWorkflowType { Edit = 0, @@ -1434,13 +1654,27 @@ public SiPolicy ProcessSignerRules(BackgroundWorker worker, SiPolicy siPolicy) scanTask.Wait(); sw.Stop(); - // Compute scan summary stats from the generated policy + // Compute scan summary stats from the generated policy. + // + // New-CIPolicy -Level Hash emits 1, 2, or 4 hash rules per scanned file: + // - PE binaries with page hashes: 4 rules (flat Sha1/Sha256, page Sha1/Sha256) + // - PE binaries without page hashes: 2 rules (flat Sha1, Sha256) + // - Non-PE scripts/data (.js, .ENU, etc.): 1 rule (Authenticode SIP Sha256) + // It can also emit the *same* rule twice for some files (a known cmdlet bug + // we suppress the warning for in CreateScannedPolicy.ps1), which is what + // produces the "duplicate hashes" we report. + // + // FileRules.Length is therefore the rule count, NOT the binary count. We + // derive the binary count by parsing each rule's FriendlyName. if (signerSiPolicy != null) { int hashRuleCount = 0; int signerRuleCount = signerSiPolicy.Signers?.Length ?? 0; - int totalFileRules = signerSiPolicy.FileRules?.Length ?? 0; var uniqueHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Per-binary flavor frequencies, e.g. { "Hash Sha1" -> 2, "Hash Sha256" -> 2 }. + // Used to classify each binary into a flavor-set bucket for the breakdown log. + var binaryFlavors = new Dictionary>(StringComparer.OrdinalIgnoreCase); int duplicateHashCount = 0; if (signerSiPolicy.FileRules != null) @@ -1448,8 +1682,17 @@ public SiPolicy ProcessSignerRules(BackgroundWorker worker, SiPolicy siPolicy) foreach (var rule in signerSiPolicy.FileRules) { byte[] hash = null; - if (rule is Allow allow) hash = allow.Hash; - else if (rule is Deny deny) hash = deny.Hash; + string friendlyName = null; + if (rule is Allow allow) + { + hash = allow.Hash; + friendlyName = allow.FriendlyName; + } + else if (rule is Deny deny) + { + hash = deny.Hash; + friendlyName = deny.FriendlyName; + } if (hash != null && hash.Length > 0) { @@ -1457,6 +1700,20 @@ public SiPolicy ProcessSignerRules(BackgroundWorker worker, SiPolicy siPolicy) string hex = BitConverter.ToString(hash); if (!uniqueHashes.Add(hex)) duplicateHashCount++; + + string sourcePath = ExtractScannedBinaryPath(friendlyName, out string flavor); + if (!string.IsNullOrEmpty(sourcePath)) + { + if (!binaryFlavors.TryGetValue(sourcePath, out var flavorCounts)) + { + flavorCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + binaryFlavors[sourcePath] = flavorCounts; + } + + string flavorKey = string.IsNullOrEmpty(flavor) ? "Unknown" : flavor; + flavorCounts.TryGetValue(flavorKey, out int n); + flavorCounts[flavorKey] = n + 1; + } } } } @@ -1465,7 +1722,7 @@ public SiPolicy ProcessSignerRules(BackgroundWorker worker, SiPolicy siPolicy) _folderScanStats = new FolderScanStats { TotalFilesOnDisk = totalFiles >= 0 ? totalFiles : 0, - PolicyRelevantFiles = totalFileRules, + PolicyRelevantFiles = binaryFlavors.Count, HashRules = hashRuleCount, SignerRules = signerRuleCount, UniqueHashes = uniqueHashes.Count, @@ -1473,6 +1730,10 @@ public SiPolicy ProcessSignerRules(BackgroundWorker worker, SiPolicy siPolicy) Elapsed = sw.Elapsed }; + // Write a detailed per-class breakdown to the log only - keeps the UI + // summary compact while preserving the diagnostic data for support cases. + LogFolderScanBreakdown(scanPathDisplay, _folderScanStats, binaryFlavors); + siPolicy = PolicyHelper.MergePolicies(signerSiPolicy, siPolicy); } }