Private/Reports.ps1
|
function ConvertTo-ReportRow { <# .SYNOPSIS Flattens a finding into the shape used by every report format (no AST objects, relative paths). #> [CmdletBinding()] param( [Parameter(Mandatory)] [psobject]$Finding, [string]$BasePath ) [pscustomobject][ordered]@{ RuleId = [string]$Finding.RuleId Severity = [string]$Finding.Severity Status = [string]$Finding.Status Area = [string]$Finding.Area Kind = [string]$Finding.Kind File = Get-RelativeReportPath -Path $Finding.Path -BasePath $BasePath Line = [int]$Finding.Line Column = [int]$Finding.Column EndLine = [int]$Finding.EndLine EndColumn = [int]$Finding.EndColumn Match = [string]$Finding.Match Title = [string]$Finding.Title Replacement = [string]$Finding.Replacement ReplacementModule = [string]$Finding.ReplacementModule RetirementDate = [string]$Finding.RetirementDate Notes = [string]$Finding.Notes Message = [string]$Finding.Message LineText = [string]$Finding.LineText Sources = @($Finding.Sources | ForEach-Object { [string]$_ }) } } function Get-RelativeReportPath { [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [string]$Path, [AllowNull()] [string]$BasePath ) if ([string]::IsNullOrEmpty($Path)) { return '<script>' } $result = $Path if ($BasePath) { $base = $BasePath.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar if ($Path.StartsWith($base, [System.StringComparison]::OrdinalIgnoreCase)) { $result = $Path.Substring($base.Length) } } return $result.Replace('\', '/') } function ConvertTo-CsvReport { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rows ) if ($Rows.Count -eq 0) { return '"RuleId","Severity","Status","Area","Kind","File","Line","Column","EndLine","EndColumn","Match","Title","Replacement","ReplacementModule","RetirementDate","Notes","Message","LineText","Sources"' } $flat = foreach ($row in $Rows) { $copy = $row.PSObject.Copy() $copy.Sources = $row.Sources -join ' ' # Spreadsheet apps run cells starting with = + - @ as formulas; prefix them with a quote. foreach ($property in $copy.PSObject.Properties) { if ($property.Value -is [string] -and $property.Value -match '^[=+\-@\t\r]') { $property.Value = "'" + $property.Value } } $copy } return ($flat | ConvertTo-Csv -NoTypeInformation) -join [Environment]::NewLine } function Get-ReportSummary { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rows ) [pscustomobject]@{ Total = $Rows.Count Files = @($Rows | Select-Object -ExpandProperty File -Unique).Count Error = @($Rows | Where-Object Severity -EQ 'Error').Count Warning = @($Rows | Where-Object Severity -EQ 'Warning').Count Information = @($Rows | Where-Object Severity -EQ 'Information').Count Areas = @($Rows | Group-Object -Property Area | Sort-Object -Property Count -Descending) } } function ConvertTo-HtmlEncoded { [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [AllowEmptyString()] [string]$Text ) if ($null -eq $Text) { return '' } return [System.Net.WebUtility]::HtmlEncode($Text) } function ConvertTo-HtmlReport { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rows, [Parameter(Mandatory)] [string]$Title ) $summary = Get-ReportSummary -Rows $Rows $generated = (Get-Date).ToString('yyyy-MM-dd HH:mm') $sortedRows = @($Rows | Sort-Object -Property @{ Expression = { @{ Error = 0; Warning = 1; Information = 2 }[$_.Severity] } }, File, Line) $tableRows = New-Object System.Text.StringBuilder foreach ($row in $sortedRows) { $sourceLinks = @($row.Sources | ForEach-Object { $uri = $null if ($_ -match '^https://' -and [uri]::TryCreate($_, [System.UriKind]::Absolute, [ref]$uri)) { '<a href="{0}" target="_blank" rel="noopener noreferrer">{1}</a>' -f (ConvertTo-HtmlEncoded $uri.AbsoluteUri), (ConvertTo-HtmlEncoded $uri.Host) } }) -join '<br>' $notes = if ($row.Notes) { '<div class="notes">{0}</div>' -f (ConvertTo-HtmlEncoded $row.Notes) } else { '' } $retirement = if ($row.RetirementDate) { '<div class="muted">Retirement: {0}</div>' -f (ConvertTo-HtmlEncoded $row.RetirementDate) } else { '' } $replacementModule = if ($row.ReplacementModule) { '<div class="muted">Module: {0}</div>' -f (ConvertTo-HtmlEncoded $row.ReplacementModule) } else { '' } [void]$tableRows.AppendLine(('<tr data-severity="{0}">' -f (ConvertTo-HtmlEncoded $row.Severity))) [void]$tableRows.AppendLine((' <td><span class="badge {0}">{1}</span></td>' -f (ConvertTo-HtmlEncoded $row.Severity.ToLowerInvariant()), (ConvertTo-HtmlEncoded $row.Status))) [void]$tableRows.AppendLine((' <td class="file"><code>{0}:{1}</code><div class="code">{2}</div></td>' -f (ConvertTo-HtmlEncoded $row.File), $row.Line, (ConvertTo-HtmlEncoded $row.LineText))) [void]$tableRows.AppendLine((' <td><code>{0}</code><div class="muted">{1} · {2}</div></td>' -f (ConvertTo-HtmlEncoded $row.Match), (ConvertTo-HtmlEncoded $row.RuleId), (ConvertTo-HtmlEncoded $row.Area))) [void]$tableRows.AppendLine((' <td>{0}{1}{2}</td>' -f (ConvertTo-HtmlEncoded $row.Title), $retirement, $notes)) [void]$tableRows.AppendLine((' <td><code>{0}</code>{1}</td>' -f (ConvertTo-HtmlEncoded $row.Replacement), $replacementModule)) [void]$tableRows.AppendLine((' <td class="sources">{0}</td>' -f $sourceLinks)) [void]$tableRows.AppendLine('</tr>') } $areaChips = ($summary.Areas | ForEach-Object { '<span class="chip">{0} <b>{1}</b></span>' -f (ConvertTo-HtmlEncoded $_.Name), $_.Count }) -join ' ' $body = if ($Rows.Count -eq 0) { '<div class="empty">✔ No deprecated commands found. Nice work!</div>' } else { @" <div class="toolbar"> <input id="search" type="search" placeholder="Filter by file, command, rule ..." aria-label="Filter findings"> <label><input type="checkbox" class="sev" value="Error" checked> Retired</label> <label><input type="checkbox" class="sev" value="Warning" checked> Deprecated</label> <label><input type="checkbox" class="sev" value="Information" checked> Announced</label> </div> <div class="areas">$areaChips</div> <div class="table-wrap"> <table> <thead><tr><th>Status</th><th>Where</th><th>Found</th><th>Problem</th><th>Use instead</th><th>Source</th></tr></thead> <tbody> $($tableRows.ToString())</tbody> </table> </div> "@ } # Inline script for the search box. It is allowed by hash in the Content-Security-Policy, so the # browser runs nothing else, even if a scanned value somehow got past HTML encoding. $filterScript = @' (function () { var search = document.getElementById('search'); if (!search) { return; } var boxes = Array.prototype.slice.call(document.querySelectorAll('input.sev')); var rows = Array.prototype.slice.call(document.querySelectorAll('tbody tr')); function apply() { var q = search.value.toLowerCase(); var allowed = boxes.filter(function (b) { return b.checked; }).map(function (b) { return b.value; }); rows.forEach(function (r) { var show = allowed.indexOf(r.getAttribute('data-severity')) !== -1 && r.textContent.toLowerCase().indexOf(q) !== -1; r.style.display = show ? '' : 'none'; }); } search.addEventListener('input', apply); boxes.forEach(function (b) { b.addEventListener('change', apply); }); })(); '@ $sha256 = [System.Security.Cryptography.SHA256]::Create() $scriptHash = [Convert]::ToBase64String($sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($filterScript))) $sha256.Dispose() return @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'sha256-$scriptHash'"> <title>$(ConvertTo-HtmlEncoded $Title)</title> <style> :root { --bg:#f7f7f8; --card:#fff; --text:#1d1d1f; --muted:#6b6b76; --border:#e3e3e8; --error:#c62828; --warning:#b26a00; --info:#1565c0; --code:#f0f0f3; } @media (prefers-color-scheme: dark) { :root { --bg:#16161a; --card:#1f1f24; --text:#ececf1; --muted:#9a9aa6; --border:#33333b; --error:#ef5350; --warning:#ffb74d; --info:#64b5f6; --code:#2a2a31; } } * { box-sizing:border-box; } body { margin:0; padding:24px 16px; background:var(--bg); color:var(--text); font:14px/1.5 -apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; } main { max-width:1280px; margin:0 auto; } h1 { font-size:22px; margin:0 0 4px; } .muted { color:var(--muted); font-size:12px; } .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin:20px 0; } .card { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:14px; } .card .num { font-size:26px; font-weight:600; } .card.error .num { color:var(--error); } .card.warning .num { color:var(--warning); } .card.info .num { color:var(--info); } .toolbar { display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin-bottom:10px; } .toolbar input[type=search] { flex:1 1 260px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:var(--card); color:var(--text); } .areas { margin-bottom:12px; } .chip { display:inline-block; background:var(--card); border:1px solid var(--border); border-radius:999px; padding:2px 10px; margin:0 6px 6px 0; font-size:12px; } .table-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--border); border-radius:10px; } table { border-collapse:collapse; width:100%; min-width:980px; } td.file { min-width:200px; } td.file code { word-break:normal; overflow-wrap:anywhere; } td:nth-child(3), td:nth-child(5) { min-width:170px; } td.sources { min-width:150px; } th, td { text-align:left; vertical-align:top; padding:10px 12px; border-bottom:1px solid var(--border); } th { font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); } code { background:var(--code); padding:1px 5px; border-radius:4px; font-family:Consolas,"Cascadia Code",Menlo,monospace; font-size:12.5px; word-break:break-word; } .code { font-family:Consolas,"Cascadia Code",Menlo,monospace; font-size:12px; color:var(--muted); margin-top:4px; white-space:pre-wrap; word-break:break-word; } .notes { margin-top:6px; font-size:12.5px; } .badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; color:#fff; } .badge.error { background:var(--error); } .badge.warning { background:var(--warning); } .badge.information { background:var(--info); } .sources a { color:var(--info); font-size:12px; } .empty { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:32px; text-align:center; font-size:16px; } footer { margin-top:16px; } </style> </head> <body> <main> <h1>$(ConvertTo-HtmlEncoded $Title)</h1> <div class="muted">Generated $generated by M365DeprecationScanner</div> <div class="cards"> <div class="card"><div class="num">$($summary.Total)</div><div class="muted">Findings</div></div> <div class="card"><div class="num">$($summary.Files)</div><div class="muted">Affected files</div></div> <div class="card error"><div class="num">$($summary.Error)</div><div class="muted">Retired – broken today</div></div> <div class="card warning"><div class="num">$($summary.Warning)</div><div class="muted">Deprecated – fix soon</div></div> <div class="card info"><div class="num">$($summary.Information)</div><div class="muted">Announced – plan ahead</div></div> </div> $body <footer class="muted">Rules are based on public Microsoft announcements. Always check the linked source before changing production scripts.</footer> </main> <script>$filterScript</script> </body> </html> "@ } function ConvertTo-MarkdownEscaped { [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [AllowEmptyString()] [string]$Text ) if ([string]::IsNullOrEmpty($Text)) { return '' } # Backslash-escape every Markdown punctuation character so scanned content cannot create links, # images, emphasis or HTML in a job summary. '<' and '>' become entities. $singleLine = $Text -replace '[\r\n\u0085\u2028\u2029]+', ' ' $escaped = [regex]::Replace($singleLine, '[\\`*_{}\[\]()#+\-.!|~&]', '\$0') return ($escaped -replace '<', '<' -replace '>', '>') } function ConvertTo-MarkdownCode { [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [AllowEmptyString()] [string]$Text ) if ([string]::IsNullOrEmpty($Text)) { return '' } # Code spans cannot contain backticks safely, and pipes would split the table; fall back to escaped text. if ($Text -match '[`|\r\n\u0085\u2028\u2029]') { return ConvertTo-MarkdownEscaped $Text } return '`' + $Text + '`' } function ConvertTo-MarkdownReport { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rows, [Parameter(Mandatory)] [string]$Title, [int]$MaxRows = 500 ) $summary = Get-ReportSummary -Rows $Rows $builder = New-Object System.Text.StringBuilder [void]$builder.AppendLine("## $(ConvertTo-MarkdownEscaped $Title)") [void]$builder.AppendLine() if ($Rows.Count -eq 0) { [void]$builder.AppendLine(':white_check_mark: No deprecated commands found.') return $builder.ToString() } [void]$builder.AppendLine(('**{0} finding(s)** in **{1} file(s)**: :red_circle: {2} retired · :orange_circle: {3} deprecated · :large_blue_circle: {4} announced' -f $summary.Total, $summary.Files, $summary.Error, $summary.Warning, $summary.Information)) [void]$builder.AppendLine() [void]$builder.AppendLine('| | File | Line | Found | Use instead | Rule |') [void]$builder.AppendLine('|---|---|---:|---|---|---|') $icons = @{ Error = ':red_circle:'; Warning = ':orange_circle:'; Information = ':large_blue_circle:' } $sortedRows = @($Rows | Sort-Object -Property @{ Expression = { @{ Error = 0; Warning = 1; Information = 2 }[$_.Severity] } }, File, Line) foreach ($row in ($sortedRows | Select-Object -First $MaxRows)) { $rule = ConvertTo-MarkdownEscaped $row.RuleId $firstSource = @($row.Sources | Where-Object { $_ -match '^https://' }) | Select-Object -First 1 if ($firstSource) { $rule = '[{0}]({1})' -f $rule, ($firstSource -replace '\)', '%29' -replace ' ', '%20') } [void]$builder.AppendLine(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $icons[$row.Severity], (ConvertTo-MarkdownCode $row.File), $row.Line, (ConvertTo-MarkdownCode $row.Match), (ConvertTo-MarkdownCode $row.Replacement), $rule)) } if ($sortedRows.Count -gt $MaxRows) { [void]$builder.AppendLine() [void]$builder.AppendLine(('_Showing the first {0} of {1} findings. Use the HTML, CSV or SARIF report for the full list._' -f $MaxRows, $sortedRows.Count)) } return $builder.ToString() } function ConvertTo-SarifReport { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Rows ) $levels = @{ Error = 'error'; Warning = 'warning'; Information = 'note' } $version = '0.0.0' if ($ExecutionContext.SessionState.Module) { $version = $ExecutionContext.SessionState.Module.Version.ToString() } $ruleIndex = @{} $sarifRules = New-Object System.Collections.Generic.List[object] foreach ($row in $Rows) { if ($ruleIndex.ContainsKey($row.RuleId)) { continue } $ruleIndex[$row.RuleId] = $sarifRules.Count $helpText = 'Use {0} instead.' -f $row.Replacement if ($row.Notes) { $helpText += ' ' + $row.Notes } $rule = [ordered]@{ id = $row.RuleId name = $row.RuleId shortDescription = @{ text = $row.Title } fullDescription = @{ text = $row.Message } help = @{ text = $helpText } defaultConfiguration = @{ level = $levels[$row.Severity] } properties = @{ tags = @('deprecation', $row.Area, $row.Status) } } $firstSource = @($row.Sources | Where-Object { $_ -match '^https://' }) | Select-Object -First 1 if ($firstSource) { $rule.helpUri = $firstSource } $sarifRules.Add($rule) } $results = foreach ($row in $Rows) { $location = [ordered]@{ physicalLocation = [ordered]@{ artifactLocation = [ordered]@{ uri = $row.File } region = [ordered]@{ startLine = [Math]::Max(1, $row.Line) startColumn = [Math]::Max(1, $row.Column) endLine = [Math]::Max(1, $row.EndLine) endColumn = [Math]::Max(1, $row.EndColumn) } } } if ($row.File -eq '<script>') { $location.physicalLocation.artifactLocation.uri = 'script.ps1' } elseif ([System.IO.Path]::IsPathRooted($row.File)) { $location.physicalLocation.artifactLocation.uri = ([uri]$row.File).AbsoluteUri } else { $location.physicalLocation.artifactLocation.uri = (($row.File -split '/') | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/' $location.physicalLocation.artifactLocation.uriBaseId = '%SRCROOT%' } [ordered]@{ ruleId = $row.RuleId ruleIndex = $ruleIndex[$row.RuleId] level = $levels[$row.Severity] message = @{ text = $row.Message } locations = @($location) } } $sarif = [ordered]@{ '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' version = '2.1.0' runs = @( [ordered]@{ tool = @{ driver = [ordered]@{ name = 'M365DeprecationScanner' informationUri = 'https://github.com/matejpaunovski/M365DeprecationScanner' version = $version rules = $sarifRules.ToArray() } } results = [object[]]@($results) } ) } return ConvertTo-Json -InputObject $sarif -Depth 20 } |