Private/Report.ps1
|
function Get-ICStatusClass { [CmdletBinding()] param( [AllowNull()] [string]$Status ) switch ($Status) { 'Succeeded' { 'ok' } 'Completed' { 'ok' } 'Partial' { 'warn' } 'CompletedWithWarnings' { 'warn' } 'Failed' { 'bad' } 'CompletedWithErrors' { 'bad' } default { 'neutral' } } } function ConvertTo-ICReportHref { [CmdletBinding()] param( [AllowNull()] [string]$RelativePath ) if ([string]::IsNullOrWhiteSpace($RelativePath)) { return $null } $candidate = $RelativePath.Replace('\', '/') if ( $candidate.StartsWith('/', [System.StringComparison]::Ordinal) -or $candidate -match '^[A-Za-z]:' -or $candidate -match '[\x00-\x1f\x7f]' ) { return $null } $encodedSegments = New-Object System.Collections.Generic.List[string] foreach ($segment in @($candidate -split '/')) { if ([string]::IsNullOrEmpty($segment) -or $segment -eq '.' -or $segment -eq '..') { return $null } try { $encodedSegments.Add([System.Uri]::EscapeDataString($segment)) } catch { return $null } } return '../' + ($encodedSegments -join '/') } function ConvertTo-ICMetricRows { [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$CollectorResults ) $rows = New-Object System.Collections.Generic.List[string] foreach ($result in $CollectorResults) { $metrics = Get-ICPropertyValue -InputObject $result -Name 'metrics' if ($null -eq $metrics) { continue } $metricEntries = if ($metrics -is [System.Collections.IDictionary]) { @($metrics.Keys | ForEach-Object { [pscustomobject]@{ Name = [string]$_; Value = $metrics[$_] } }) } else { @($metrics.PSObject.Properties | Where-Object IsGettable | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Value = $_.Value } }) } foreach ($metric in $metricEntries) { $value = $metric.Value if ($null -eq $value -or $value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { continue } $rows.Add(('<tr><td>{0}</td><td>{1}</td><td>{2}</td></tr>' -f (ConvertTo-ICHtml (Get-ICPropertyValue -InputObject $result -Name 'name' -Default 'Unknown')), (ConvertTo-ICHtml $metric.Name), (ConvertTo-ICHtml $value))) } } return $rows -join [Environment]::NewLine } function ConvertTo-ICCoverageRow { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Coverage ) $rows = New-Object System.Collections.Generic.List[string] foreach ($entry in @($Coverage.collectors)) { $statusClass = Get-ICStatusClass -Status $entry.status if ($entry.status -eq 'NotSelected') { $statusClass = 'neutral' } $rows.Add(('<tr><td>{0}</td><td>{1}</td><td><span class="pill {2}">{3}</span></td><td>{4}</td><td>{5}</td></tr>' -f (ConvertTo-ICHtml $entry.name), (ConvertTo-ICHtml $entry.selected), $statusClass, (ConvertTo-ICHtml $entry.status), (ConvertTo-ICHtml $entry.outputFiles), (ConvertTo-ICHtml $entry.issueCount))) } return $rows -join [Environment]::NewLine } function ConvertTo-ICIssueRow { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Coverage ) $issues = @($Coverage.issues) if ($issues.Count -eq 0) { return '<tr><td colspan="4" class="ok-text">No structured collection issue was recorded.</td></tr>' } $rows = New-Object System.Collections.Generic.List[string] foreach ($issue in $issues) { $severityClass = switch ([string]$issue.severity) { 'Error' { 'bad' } 'Warning' { 'warn' } default { 'neutral' } } $rows.Add(('<tr><td><span class="pill {0}">{1}</span></td><td><code>{2}</code></td><td>{3}</td><td>{4}</td></tr>' -f $severityClass, (ConvertTo-ICHtml $issue.severity), (ConvertTo-ICHtml $issue.code), (ConvertTo-ICHtml $issue.component), (ConvertTo-ICHtml $issue.message))) } return $rows -join [Environment]::NewLine } function ConvertTo-ICDictionaryRow { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Value ) $rows = New-Object System.Collections.Generic.List[string] if ($Value -is [System.Collections.IDictionary]) { foreach ($key in $Value.Keys) { $rows.Add(('<tr><th>{0}</th><td>{1}</td></tr>' -f (ConvertTo-ICHtml $key), (ConvertTo-ICHtml $Value[$key]))) } } else { foreach ($property in $Value.PSObject.Properties) { $rows.Add(('<tr><th>{0}</th><td>{1}</td></tr>' -f (ConvertTo-ICHtml $property.Name), (ConvertTo-ICHtml $property.Value))) } } return $rows -join [Environment]::NewLine } function ConvertTo-ICTimelineRow { [CmdletBinding()] param( [AllowNull()] [object]$Timeline ) if ($null -eq $Timeline -or @($Timeline.Entries).Count -eq 0) { return '<tr><td colspan="5" class="muted">No timestamped evidence record was indexed.</td></tr>' } $rows = New-Object System.Collections.Generic.List[string] foreach ($entry in @($Timeline.Entries | Sort-Object timestampUtc -Descending | Select-Object -First 100)) { $source = [string](Get-ICPropertyValue -InputObject $entry -Name 'source') $href = ConvertTo-ICReportHref -RelativePath $source $sourceCell = if ($null -ne $href) { '<a href="{0}">{1}</a>' -f (ConvertTo-ICHtml $href), (ConvertTo-ICHtml $source) } else { '<span class="muted">{0}</span>' -f (ConvertTo-ICHtml $source) } $rows.Add(('<tr><td>{0}</td><td>{1}</td><td>{2}</td><td>{3}</td><td>{4}</td></tr>' -f (ConvertTo-ICHtml (Get-ICPropertyValue -InputObject $entry -Name 'timestampUtc')), (ConvertTo-ICHtml (Get-ICPropertyValue -InputObject $entry -Name 'collector')), (ConvertTo-ICHtml (Get-ICPropertyValue -InputObject $entry -Name 'eventType')), (ConvertTo-ICHtml (Get-ICPropertyValue -InputObject $entry -Name 'title')), $sourceCell)) } return $rows -join [Environment]::NewLine } function New-ICHtmlReport { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Context ) $collectorRows = New-Object System.Collections.Generic.List[string] $warningBlocks = New-Object System.Collections.Generic.List[string] $evidenceBlocks = New-Object System.Collections.Generic.List[string] foreach ($result in @($Context.CollectorResults)) { $statusClass = Get-ICStatusClass -Status $result.status $duration = '{0:N1} s' -f ([double]$result.durationMilliseconds / 1000) $collectorRows.Add(('<tr><td>{0}</td><td><span class="pill {1}">{2}</span></td><td>{3}</td><td>{4}</td></tr>' -f (ConvertTo-ICHtml $result.name), $statusClass, (ConvertTo-ICHtml $result.status), (ConvertTo-ICHtml $duration), @($result.outputFiles).Count)) $messages = @($result.warnings) if (-not [string]::IsNullOrWhiteSpace([string]$result.error)) { $messages += [string]$result.error } foreach ($message in $messages) { $warningBlocks.Add(('<li><strong>{0}</strong> — {1}</li>' -f (ConvertTo-ICHtml $result.name), (ConvertTo-ICHtml $message))) } $links = New-Object System.Collections.Generic.List[string] foreach ($relativePath in @($result.outputFiles)) { $href = ConvertTo-ICReportHref -RelativePath ([string]$relativePath) if ($null -ne $href) { $links.Add(('<li><a href="{0}">{1}</a></li>' -f (ConvertTo-ICHtml $href), (ConvertTo-ICHtml $relativePath))) } else { $links.Add(('<li class="muted">Unsafe output path omitted: {0}</li>' -f (ConvertTo-ICHtml $relativePath))) } } if ($links.Count -eq 0) { $links.Add('<li class="muted">No output file recorded.</li>') } $evidenceBlocks.Add(('<details><summary>{0} <span class="muted">({1} file(s))</span></summary><ul>{2}</ul></details>' -f (ConvertTo-ICHtml $result.name), @($result.outputFiles).Count, ($links -join [Environment]::NewLine))) } if ($warningBlocks.Count -eq 0) { $warningBlocks.Add('<li class="ok-text">No collector warning or fatal error was recorded.</li>') } $coverage = if ($null -ne $Context.PSObject.Properties['Coverage']) { $Context.Coverage } else { New-ICCoverageData -Context $Context } $coverageRows = ConvertTo-ICCoverageRow -Coverage $coverage $issueRows = ConvertTo-ICIssueRow -Coverage $coverage $privacyRows = ConvertTo-ICDictionaryRow -Value $coverage.privacyScope $limitRows = ConvertTo-ICDictionaryRow -Value $coverage.resourceLimits $timeline = if ($null -ne $Context.PSObject.Properties['Timeline']) { $Context.Timeline } else { $null } $timelineRows = ConvertTo-ICTimelineRow -Timeline $timeline $timelineSummary = if ($null -ne $timeline) { '{0} of {1} candidate timestamp(s) indexed; truncated: {2}; unreadable source files: {3}; invalid timestamps: {4}' -f ` $timeline.EntryCount, $timeline.CandidateCount, $timeline.Truncated, (Get-ICPropertyValue -InputObject $timeline -Name 'SourceFilesFailed' -Default 0), (Get-ICPropertyValue -InputObject $timeline -Name 'InvalidTimestampCount' -Default 0) } else { 'Timeline index was not generated.' } $timelineArtifactLinks = if ($null -ne $timeline) { '<a href="../analysis/timeline.json">JSON</a> · <a href="../analysis/timeline.csv">CSV</a>' } else { '' } $coverageArtifactLink = if (Test-Path -LiteralPath (Join-Path $Context.MetadataPath 'coverage.json') -PathType Leaf) { '<a href="../metadata/coverage.json">Machine-readable coverage</a>' } else { 'Machine-readable coverage was not written.' } $completed = if ($null -ne $Context.CompletedAtUtc) { $Context.CompletedAtUtc } else { [datetime]::UtcNow } $duration = [math]::Round(($completed - $Context.StartedAtUtc).TotalSeconds, 2) $overallClass = Get-ICStatusClass -Status $Context.Status $metricRows = ConvertTo-ICMetricRows -CollectorResults @($Context.CollectorResults) if ([string]::IsNullOrWhiteSpace($metricRows)) { $metricRows = '<tr><td colspan="3" class="muted">No scalar metrics were reported.</td></tr>' } $html = @" <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Incident Capsule // $(ConvertTo-ICHtml $Context.CaseId) // $(ConvertTo-ICHtml $Context.HostName)</title> <style> :root{color-scheme:dark;--bg:#071015;--panel:#0d1820;--panel2:#101e27;--line:#20343f;--text:#d7e5e7;--muted:#7f9aa2;--green:#68f0ae;--cyan:#65d9ff;--amber:#ffcc66;--red:#ff6b7a;--neutral:#b9c8cc}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:radial-gradient(circle at 20% 0,#102631 0,transparent 33%),var(--bg);color:var(--text);font:14px/1.55 ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace}.wrap{max-width:1240px;margin:auto;padding:30px}.hero,.panel,.card{border:1px solid var(--line);background:rgba(13,24,32,.95);box-shadow:0 16px 60px rgba(0,0,0,.22)}.hero{padding:26px;border-top:3px solid var(--green);border-radius:8px}.hero h1{margin:0;color:var(--green);font-size:28px;letter-spacing:.1em}.hero p{margin:6px 0 0;color:var(--muted)}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.card{padding:16px;border-radius:8px;min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.value{font-size:18px;margin-top:6px;overflow-wrap:anywhere}.panel{padding:19px;margin-top:14px;border-radius:8px}.panel h2{font-size:15px;color:var(--cyan);letter-spacing:.06em;margin:0 0 13px}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-weight:500}.pill{display:inline-block;border:1px solid currentColor;padding:2px 8px;border-radius:999px;font-size:11px}.ok{color:var(--green)}.warn{color:var(--amber)}.bad{color:var(--red)}.neutral{color:var(--neutral)}.ok-text{color:var(--green)}.muted{color:var(--muted)}ul{padding-left:23px}li{margin:5px 0}a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}details{border-top:1px solid var(--line);padding:10px 0}details:first-child{border-top:0}summary{cursor:pointer;color:var(--text)}code{color:var(--green)}.footer{color:var(--muted);margin:18px 0 2px;font-size:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}@media(max-width:900px){.grid{grid-template-columns:1fr 1fr}.two{grid-template-columns:1fr}}@media(max-width:540px){.wrap{padding:12px}.grid{grid-template-columns:1fr}.hero h1{font-size:22px}table{display:block;overflow-x:auto}} </style> </head> <body> <main class="wrap"> <section class="hero"> <h1>INCIDENT CAPSULE</h1> <p>WINDOWS FIRST-RESPONSE COLLECTION // OFFLINE REPORT // NO EXTERNAL RESOURCES</p> </section> <section class="grid"> <div class="card"><div class="label">Case</div><div class="value">$(ConvertTo-ICHtml $Context.CaseId)</div></div> <div class="card"><div class="label">Host</div><div class="value">$(ConvertTo-ICHtml $Context.HostName)</div></div> <div class="card"><div class="label">Profile</div><div class="value">$(ConvertTo-ICHtml $Context.Profile)</div></div> <div class="card"><div class="label">Status</div><div class="value $overallClass">$(ConvertTo-ICHtml $Context.Status)</div></div> </section> <section class="two"> <div class="panel"> <h2>ACQUISITION</h2> <table><tbody> <tr><th>Capsule ID</th><td>$(ConvertTo-ICHtml $Context.CapsuleId)</td></tr> <tr><th>Operator</th><td>$(ConvertTo-ICHtml $Context.Operator)</td></tr> <tr><th>Elevated</th><td>$(ConvertTo-ICHtml $Context.IsElevated)</td></tr> <tr><th>Started UTC</th><td>$(ConvertTo-ICHtml $Context.StartedAtUtc.ToString('o'))</td></tr> <tr><th>Completed UTC</th><td>$(ConvertTo-ICHtml $completed.ToString('o'))</td></tr> <tr><th>Duration</th><td>$(ConvertTo-ICHtml $duration) s</td></tr> </tbody></table> </div> <div class="panel"> <h2>INTEGRITY</h2> <table><tbody> <tr><th>Algorithm</th><td>SHA-256</td></tr> <tr><th>JSON manifest</th><td><a href="../metadata/manifest.json">metadata/manifest.json</a></td></tr> <tr><th>Checksum list</th><td><a href="../metadata/manifest.sha256">metadata/manifest.sha256</a></td></tr> <tr><th>Capsule metadata</th><td><a href="../metadata/capsule.json">metadata/capsule.json</a></td></tr> <tr><th>Collector log</th><td><a href="../logs/collector.log">logs/collector.log</a></td></tr> <tr><th>Verification</th><td><code>Test-IncidentCapsuleIntegrity</code></td></tr> </tbody></table> </div> </section> <section class="panel"> <h2>COLLECTION STATUS</h2> <table><thead><tr><th>Collector</th><th>Status</th><th>Duration</th><th>Files</th></tr></thead><tbody> $($collectorRows -join [Environment]::NewLine) </tbody></table> </section> <section class="panel"> <h2>COLLECTION COVERAGE</h2> <p class="muted">Coverage describes acquisition quality and scope. It is not a verdict about whether the host is secure or compromised. $coverageArtifactLink</p> <table><thead><tr><th>Collector</th><th>Selected</th><th>Status</th><th>Files</th><th>Issues</th></tr></thead><tbody> $coverageRows </tbody></table> </section> <section class="panel"> <h2>ATTENTION REQUIRED</h2> <table><thead><tr><th>Severity</th><th>Reason code</th><th>Component</th><th>Message</th></tr></thead><tbody> $issueRows </tbody></table> </section> <section class="two"> <div class="panel"> <h2>PRIVACY SCOPE</h2> <table><tbody>$privacyRows</tbody></table> </div> <div class="panel"> <h2>RESOURCE LIMITS</h2> <table><tbody>$limitRows</tbody></table> </div> </section> <section class="panel"> <h2>COLLECTOR METRICS</h2> <table><thead><tr><th>Collector</th><th>Metric</th><th>Value</th></tr></thead><tbody> $metricRows </tbody></table> </section> <section class="panel"> <h2>DERIVED TIMELINE</h2> <p class="muted">$(ConvertTo-ICHtml $timelineSummary) $timelineArtifactLinks</p> <table><thead><tr><th>UTC</th><th>Collector</th><th>Type</th><th>Summary</th><th>Source</th></tr></thead><tbody> $timelineRows </tbody></table> </section> <section class="panel"> <h2>WARNINGS AND ERRORS</h2> <ul>$($warningBlocks -join [Environment]::NewLine)</ul> </section> <section class="panel"> <h2>EVIDENCE INDEX</h2> $($evidenceBlocks -join [Environment]::NewLine) </section> <p class="footer">Generated offline by Incident Capsule $(ConvertTo-ICHtml $script:ICVersion). Preserve the original archive and verify hashes before analysis or transfer.</p> </main> </body> </html> "@ return Write-ICUtf8File -Path $Context.ReportPath -Content $html } |