Private/ConvertTo-FylgyrHtml.ps1
|
function ConvertTo-FylgyrHtml { [CmdletBinding()] [OutputType([string], [void])] param( [Parameter(Mandatory)] [PSCustomObject[]]$Results, [string]$Target = '', [string[]]$ScannedTargets = @(), [string]$OutputPath ) $manifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..' | Join-Path -ChildPath 'Fylgyr.psd1' $version = '' if (Test-Path -Path $manifestPath -PathType Leaf) { try { $manifestData = Import-PowerShellDataFile -Path $manifestPath if ($manifestData -and $manifestData.ModuleVersion) { $version = [string]$manifestData.ModuleVersion } } catch { Write-Verbose "Unable to resolve module version from manifest: $($_.Exception.Message)" } } if ([string]::IsNullOrWhiteSpace($version) -or $version -match '^0(\.0)+$') { $module = Get-Module -Name Fylgyr -ErrorAction SilentlyContinue if ($module -and $module.Version) { $version = $module.Version.ToString() } } if ([string]::IsNullOrWhiteSpace($version)) { $version = '0.0.0' } $templatePath = Join-Path -Path $PSScriptRoot -ChildPath '..' | Join-Path -ChildPath 'Data' | Join-Path -ChildPath 'report-template.html' if (-not (Test-Path -Path $templatePath -PathType Leaf)) { throw "HTML template not found at '$templatePath'." } $coveragePath = Join-Path -Path $PSScriptRoot -ChildPath '..' | Join-Path -ChildPath '..' | Join-Path -ChildPath '..' | Join-Path -ChildPath 'docs' | Join-Path -ChildPath 'COVERAGE.md' $coverageSummaryHtml = '<div class="coverage-card"><h3>Coverage Map</h3><p>Coverage summary unavailable.</p></div>' $owaspTop10BaseUrl = 'https://owasp.org/www-project-top-10-ci-cd-security-risks' $owaspControlUrlMap = @{ 'CICD-SEC-1' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-01-Insufficient-Flow-Control-Mechanisms' 'CICD-SEC-2' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-02-Inadequate-Identity-And-Access-Management' 'CICD-SEC-3' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-03-Dependency-Chain-Abuse' 'CICD-SEC-4' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-04-Poisoned-Pipeline-Execution' 'CICD-SEC-5' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-05-Insufficient-PBAC' 'CICD-SEC-6' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-06-Insufficient-Credential-Hygiene' 'CICD-SEC-7' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-07-Insecure-System-Configuration' 'CICD-SEC-8' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-08-Ungoverned-Usage-of-3rd-Party-Services' 'CICD-SEC-9' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-09-Improper-Artifact-Integrity-Validation' 'CICD-SEC-10' = 'https://owasp.org/www-project-top-10-ci-cd-security-risks/CICD-SEC-10-Insufficient-Logging-And-Visibility' } $buildOwaspControlUrl = { param( [string]$ControlId ) if ($ControlId -match '^CICD-SEC-(\d+)$') { $normalizedControlId = "CICD-SEC-$([int]$Matches[1])" if ($owaspControlUrlMap.ContainsKey($normalizedControlId)) { return [string]$owaspControlUrlMap[$normalizedControlId] } } return "$owaspTop10BaseUrl/" } $openGapIds = [System.Collections.Generic.List[string]]::new() $coveredOwaspRisks = [System.Collections.Generic.List[PSCustomObject]]::new() $missingOwaspRisks = [System.Collections.Generic.List[PSCustomObject]]::new() $owaspRiskNamesById = @{} $owaspTotalRiskCount = 0 if (Test-Path -Path $coveragePath -PathType Leaf) { $coverageText = Get-Content -Path $coveragePath -Raw $coverageLineRaw = [regex]::Match($coverageText, '(?m)^\s*\*{0,2}\s*Coverage:[^\n]+').Value $openGapsLineRaw = [regex]::Match($coverageText, '(?m)^\s*\*{0,2}\s*Open gaps:\s*[^\n]+').Value $owaspIdSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($owaspRow in [regex]::Matches($coverageText, '(?m)^\|\s*(CICD-SEC-\d+)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|')) { $owaspId = [string]$owaspRow.Groups[1].Value.Trim() if ([string]::IsNullOrWhiteSpace($owaspId) -or -not $owaspIdSet.Add($owaspId)) { continue } $owaspName = [string]$owaspRow.Groups[2].Value.Trim() $coveringChecks = [string]$owaspRow.Groups[4].Value.Trim() $owaspTotalRiskCount++ $owaspRiskNamesById[$owaspId] = $owaspName $isEmDashPlaceholder = ($coveringChecks.Length -eq 1 -and [int][char]$coveringChecks[0] -eq 8212) $isMissingCoverage = [string]::IsNullOrWhiteSpace($coveringChecks) -or $coveringChecks -in @('-', '--', '---', '----', '-----', '------') -or $isEmDashPlaceholder if ($isMissingCoverage) { $missingOwaspRisks.Add([PSCustomObject]@{ Id = $owaspId Name = $owaspName }) } else { $coveredOwaspRisks.Add([PSCustomObject]@{ Id = $owaspId Name = $owaspName }) } } $coverageLine = if ($coverageLineRaw) { (($coverageLineRaw -replace '^\s*\*{0,2}\s*', '') -replace '\*{0,2}\s*$', '').Trim() } else { '' } $openGapsLine = if ($openGapsLineRaw) { (($openGapsLineRaw -replace '^\s*\*{0,2}\s*', '') -replace '\*{0,2}\s*$', '').Trim() } else { '' } if ($coverageLine -match 'Open gaps:') { $parts = $coverageLine -split 'Open gaps:', 2 $coverageLine = ($parts[0]).Trim() if ($parts.Count -gt 1 -and [string]::IsNullOrWhiteSpace($openGapsLine)) { $gapsText = ($parts[1]).Trim().TrimEnd('.') if (-not [string]::IsNullOrWhiteSpace($gapsText)) { $openGapsLine = "Open gaps: $gapsText." } } } $coverageLine = $coverageLine.Trim().TrimEnd('.') if (-not [string]::IsNullOrWhiteSpace($coverageLine)) { $coverageLine = "$coverageLine." } $openGapsLine = $openGapsLine.Trim().TrimEnd('.') if (-not [string]::IsNullOrWhiteSpace($openGapsLine) -and $openGapsLine -notmatch '^Open gaps:') { $openGapsLine = "Open gaps: $openGapsLine" } if (-not [string]::IsNullOrWhiteSpace($openGapsLine)) { $openGapsLine = "$openGapsLine." } $owaspText = if ($coverageLine) { [System.Net.WebUtility]::HtmlEncode($coverageLine) } else { 'Coverage summary not found.' } $openGapsText = if ($openGapsLine) { [System.Net.WebUtility]::HtmlEncode($openGapsLine) } else { 'Open gaps summary not found.' } if ($openGapsLine -match '^Open gaps:\s*(.+)\.$') { foreach ($gapId in @($Matches[1] -split ',')) { $normalizedGapId = ([string]$gapId).Trim() if (-not [string]::IsNullOrWhiteSpace($normalizedGapId)) { $openGapIds.Add($normalizedGapId) } } } $coverageSummaryHtml = @" <div class="coverage-card"> <h3>Coverage Dashboard</h3> <p>$owaspText</p> <p>$openGapsText</p> </div> "@ } $summary = [ordered]@{ total = $Results.Count pass = ($Results | Where-Object Status -EQ 'Pass').Count fail = ($Results | Where-Object Status -EQ 'Fail').Count warning = ($Results | Where-Object Status -EQ 'Warning').Count error = ($Results | Where-Object Status -EQ 'Error').Count info = ($Results | Where-Object Status -EQ 'Info').Count suppressed = ($Results | Where-Object Status -EQ 'Suppressed').Count } $scannedRepoSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($scanTarget in @($ScannedTargets)) { if ([string]::IsNullOrWhiteSpace($scanTarget)) { continue } $normalizedTarget = [string]$scanTarget if ($normalizedTarget -match '^org/') { continue } if ($normalizedTarget -match '^[^/]+/[^/]+$') { $scannedRepoSet.Add($normalizedTarget) | Out-Null } } $orgSections = [System.Collections.Generic.List[string]]::new() $repoSections = [System.Collections.Generic.List[string]]::new() $otherSections = [System.Collections.Generic.List[string]]::new() $orgTocItems = [System.Collections.Generic.List[string]]::new() $repoTocItems = [System.Collections.Generic.List[string]]::new() $otherTocItems = [System.Collections.Generic.List[string]]::new() $repoTargetSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $groupIndex = 0 $resultGroups = @($Results | Group-Object -Property Target | Sort-Object -Property Name) foreach ($resultGroup in $resultGroups) { $groupIndex++ $rawGroupName = [string]$resultGroup.Name if ([string]::IsNullOrWhiteSpace($rawGroupName)) { $rawGroupName = 'unknown' } $groupTitle = [System.Net.WebUtility]::HtmlEncode($rawGroupName) $groupId = "target-$groupIndex" $groupCheckCount = @($resultGroup.Group).Count $groupNonPassCount = @($resultGroup.Group | Where-Object { $_.Status -ne 'Pass' }).Count $checkGroups = @($resultGroup.Group | Group-Object -Property CheckName) $checkHtml = [System.Collections.Generic.List[string]]::new() foreach ($checkGroup in $checkGroups) { $checkName = [System.Net.WebUtility]::HtmlEncode([string]$checkGroup.Name) $findingHtml = [System.Collections.Generic.List[string]]::new() foreach ($result in @($checkGroup.Group)) { $status = [string]$result.Status $statusClass = switch ($status) { 'Fail' { 'status-fail' } 'Warning' { 'status-warning' } 'Error' { 'status-error' } 'Info' { 'status-info' } 'Suppressed' { 'status-suppressed' } default { 'status-pass' } } $detail = [System.Net.WebUtility]::HtmlEncode([string]$result.Detail) $resource = [System.Net.WebUtility]::HtmlEncode([string]$result.Resource) $severity = [System.Net.WebUtility]::HtmlEncode([string]$result.Severity) $remediation = [System.Net.WebUtility]::HtmlEncode([string]$result.Remediation) $attacksHtml = '' $attackText = @($result.AttackMapping) -join ', ' if (-not [string]::IsNullOrWhiteSpace($attackText)) { $attacksHtml = "<p><strong>Attacks:</strong> $([System.Net.WebUtility]::HtmlEncode($attackText))</p>" } $evidenceHtml = '' if ($result.PSObject.Properties.Name -contains 'Evidence' -and $result.Evidence) { $evidence = $result.Evidence $yamlSnippet = if ($evidence.YamlSnippet) { [System.Net.WebUtility]::HtmlEncode([string]$evidence.YamlSnippet) } else { '' } $commitSha = if ($evidence.CommitSha) { [System.Net.WebUtility]::HtmlEncode([string]$evidence.CommitSha) } else { '' } $scanTime = if ($evidence.ScanTime) { [System.Net.WebUtility]::HtmlEncode(([datetime]$evidence.ScanTime).ToString('o')) } else { '' } $permalink = if ($evidence.Permalink) { [System.Net.WebUtility]::HtmlEncode([string]$evidence.Permalink) } else { '' } $yamlBlock = if ($yamlSnippet) { "<h5>YamlSnippet</h5><pre>$yamlSnippet</pre>" } else { '' } $commitBlock = if ($commitSha) { "<p><strong>CommitSha:</strong> $commitSha</p>" } else { '' } $timeBlock = if ($scanTime) { "<p><strong>ScanTime:</strong> $scanTime</p>" } else { '' } $linkBlock = if ($permalink) { "<p><strong>Permalink:</strong> <a href='$permalink'>$permalink</a></p>" } else { '' } $evidenceHtml = @" <details class="evidence-block"> <summary>Evidence</summary> $commitBlock $timeBlock $linkBlock $yamlBlock </details> "@ } $findingHtml.Add(@" <div class="finding $statusClass"> <div class="finding-header"> <span class="status">$status</span> <span class="severity">$severity</span> </div> <p class="detail">$detail</p> <p><strong>Resource:</strong> $resource</p> <p><strong>Remediation:</strong> $remediation</p> $attacksHtml $evidenceHtml </div> "@) } $checkHtml.Add(@" <section class="check-group"> <h4>$checkName</h4> $($findingHtml -join "`n") </section> "@) } $groupHtml = @" <section class="repo-group"> <h3 id="$groupId">$groupTitle</h3> <p class="group-meta">$groupNonPassCount non-pass result(s) across $groupCheckCount check result(s).</p> $($checkHtml -join "`n") </section> "@ $tocItem = "<li><a href='#$groupId'>$groupTitle</a><span class='toc-count'>$groupNonPassCount non-pass / $groupCheckCount checks</span></li>" if ($rawGroupName -match '^org/') { $orgSections.Add($groupHtml) $orgTocItems.Add($tocItem) } elseif ($rawGroupName -match '^[^/]+/[^/]+$') { $repoSections.Add($groupHtml) $repoTocItems.Add($tocItem) $repoTargetSet.Add($rawGroupName) | Out-Null } else { $otherSections.Add($groupHtml) $otherTocItems.Add($tocItem) } } $repoScannedCount = if ($scannedRepoSet.Count -gt 0) { $scannedRepoSet.Count } else { $repoTargetSet.Count } $repoMissingCount = 0 if ($scannedRepoSet.Count -gt 0) { foreach ($scannedRepo in $scannedRepoSet) { if (-not $repoTargetSet.Contains($scannedRepo)) { $repoMissingCount++ } } } $repoWithResultsCount = if ($repoScannedCount -gt 0) { [Math]::Max(0, $repoScannedCount - $repoMissingCount) } else { $repoTargetSet.Count } $scanScopeHtml = @( '<h2>Scan Scope</h2>' '<div class="summary-grid">' " <div class=""summary-item""><h3>$repoScannedCount</h3><p>Repositories Scanned</p></div>" " <div class=""summary-item""><h3>$repoWithResultsCount</h3><p>Repositories With Results</p></div>" " <div class=""summary-item""><h3>$repoMissingCount</h3><p>Repositories Without Results</p></div>" " <div class=""summary-item""><h3>$($orgSections.Count)</h3><p>Organization Targets</p></div>" " <div class=""summary-item""><h3>$($repoSections.Count)</h3><p>Repository Targets</p></div>" '</div>' ) -join "`n" $tocBlocks = [System.Collections.Generic.List[string]]::new() if ($orgTocItems.Count -gt 0) { $orgTocBlock = @( '<h3><a href="#scope-org">Organization Scope</a></h3>' '<ul class="toc-list">' " $($orgTocItems -join "`n ")" '</ul>' ) -join "`n" $tocBlocks.Add($orgTocBlock) } if ($repoTocItems.Count -gt 0) { $repoTocBlock = @( '<h3><a href="#scope-repo">Repository Scope</a></h3>' '<ul class="toc-list">' " $($repoTocItems -join "`n ")" '</ul>' ) -join "`n" $tocBlocks.Add($repoTocBlock) } if ($otherTocItems.Count -gt 0) { $otherTocBlock = @( '<h3><a href="#scope-other">Other Scope</a></h3>' '<ul class="toc-list">' " $($otherTocItems -join "`n ")" '</ul>' ) -join "`n" $tocBlocks.Add($otherTocBlock) } $tableOfContentsHtml = if ($tocBlocks.Count -gt 0) { $tocBlocks -join "`n" } else { '<p>No targets were included in this report.</p>' } $riskCandidates = @($Results | Where-Object { $_.Status -in @('Fail', 'Warning', 'Error') }) $severityRank = @{ Info = 0; Low = 1; Medium = 2; High = 3; Critical = 4 } $statusRank = @{ Warning = 1; Fail = 2; Error = 3 } $orderedRiskCandidates = @($riskCandidates | Sort-Object -Property @( @{ Expression = { if ($severityRank.ContainsKey([string]$_.Severity)) { $severityRank[[string]$_.Severity] } else { -1 } }; Descending = $true }, @{ Expression = { if ($statusRank.ContainsKey([string]$_.Status)) { $statusRank[[string]$_.Status] } else { -1 } }; Descending = $true }, @{ Expression = { [string]$_.CheckName }; Descending = $false } )) $prioritizedRiskItems = [System.Collections.Generic.List[string]]::new() $riskLimit = [Math]::Min(10, $orderedRiskCandidates.Count) for ($riskIndex = 0; $riskIndex -lt $riskLimit; $riskIndex++) { $riskResult = $orderedRiskCandidates[$riskIndex] $riskCheckName = [System.Net.WebUtility]::HtmlEncode([string]$riskResult.CheckName) $riskSeverity = [System.Net.WebUtility]::HtmlEncode([string]$riskResult.Severity) $riskStatus = [System.Net.WebUtility]::HtmlEncode([string]$riskResult.Status) $riskTarget = [System.Net.WebUtility]::HtmlEncode([string]$riskResult.Target) $riskResource = [System.Net.WebUtility]::HtmlEncode([string]$riskResult.Resource) $prioritizedRiskItems.Add("<li><strong>$riskCheckName</strong><span class='risk-meta'>$riskSeverity / $riskStatus</span><span class='risk-meta'>target: $riskTarget</span><span class='risk-meta'>resource: $riskResource</span></li>") } $criticalHighCount = @($riskCandidates | Where-Object { $_.Severity -in @('Critical', 'High') }).Count $mediumCount = @($riskCandidates | Where-Object { $_.Severity -eq 'Medium' }).Count $openGapPills = [System.Collections.Generic.List[string]]::new() foreach ($gapId in $openGapIds) { $gapName = '' if ($owaspRiskNamesById.ContainsKey([string]$gapId)) { $gapName = [string]$owaspRiskNamesById[[string]$gapId] } $gapLabel = if ([string]::IsNullOrWhiteSpace($gapName)) { [string]$gapId } else { "$gapId - $gapName" } $gapUrl = & $buildOwaspControlUrl ([string]$gapId) $openGapPills.Add("<a class='pill' href='$([System.Net.WebUtility]::HtmlEncode($gapUrl))' target='_blank' rel='noopener noreferrer'>$([System.Net.WebUtility]::HtmlEncode($gapLabel))</a>") } $coveredOwaspPills = [System.Collections.Generic.List[string]]::new() foreach ($coveredOwaspRisk in $coveredOwaspRisks) { $coveredLabel = if ([string]::IsNullOrWhiteSpace([string]$coveredOwaspRisk.Name)) { [string]$coveredOwaspRisk.Id } else { "$($coveredOwaspRisk.Id) - $($coveredOwaspRisk.Name)" } $coveredUrl = & $buildOwaspControlUrl ([string]$coveredOwaspRisk.Id) $coveredOwaspPills.Add("<a class='pill' href='$([System.Net.WebUtility]::HtmlEncode($coveredUrl))' target='_blank' rel='noopener noreferrer'>$([System.Net.WebUtility]::HtmlEncode($coveredLabel))</a>") } if ($openGapPills.Count -eq 0 -and $missingOwaspRisks.Count -gt 0) { foreach ($missingOwaspRisk in $missingOwaspRisks) { $missingLabel = if ([string]::IsNullOrWhiteSpace([string]$missingOwaspRisk.Name)) { [string]$missingOwaspRisk.Id } else { "$($missingOwaspRisk.Id) - $($missingOwaspRisk.Name)" } $missingUrl = & $buildOwaspControlUrl ([string]$missingOwaspRisk.Id) $openGapPills.Add("<a class='pill' href='$([System.Net.WebUtility]::HtmlEncode($missingUrl))' target='_blank' rel='noopener noreferrer'>$([System.Net.WebUtility]::HtmlEncode($missingLabel))</a>") } } $prioritizedRiskListHtml = if ($prioritizedRiskItems.Count -gt 0) { @" <h3>Prioritized Findings</h3> <ul class="risk-list"> $($prioritizedRiskItems -join "`n ") </ul> "@ } else { '<p>No fail/warning/error findings in this report.</p>' } $missingRiskHtml = if ($openGapPills.Count -gt 0) { @" <h3>OWASP CI/CD Coverage Context</h3> <p><strong>Missing means:</strong> this OWASP CI/CD Top 10 risk currently has no mapped Fylgyr check in coverage metadata.</p> <p><strong>Covered OWASP CI/CD risks ($($coveredOwaspRisks.Count)/$owaspTotalRiskCount):</strong></p> <p>$($coveredOwaspPills -join ' ')</p> <h3>Missing OWASP Coverage</h3> <p>$($openGapPills -join ' ')</p> "@ } elseif ($coveredOwaspPills.Count -gt 0) { @" <h3>OWASP CI/CD Coverage Context</h3> <p><strong>Missing means:</strong> this OWASP CI/CD Top 10 risk currently has no mapped Fylgyr check in coverage metadata.</p> <p><strong>Covered OWASP CI/CD risks ($($coveredOwaspRisks.Count)/$owaspTotalRiskCount):</strong></p> <p>$($coveredOwaspPills -join ' ')</p> <h3>Missing OWASP Coverage</h3><p>No open OWASP gaps listed in coverage metadata.</p> "@ } else { '<h3>Missing OWASP Coverage</h3><p>No open OWASP gaps listed in coverage metadata.</p>' } $riskPrioritizationHtml = @" <div class="summary-grid"> <div class="summary-item"><h3>$criticalHighCount</h3><p>Critical/High Findings</p></div> <div class="summary-item"><h3>$mediumCount</h3><p>Medium Findings</p></div> <div class="summary-item"><h3>$($riskCandidates.Count)</h3><p>Total Prioritized Findings</p></div> </div> $prioritizedRiskListHtml $missingRiskHtml "@ $overallRecommendationItems = [System.Collections.Generic.List[PSCustomObject]]::new() $overallRecommendationKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $addOverallRecommendation = { param( [int]$Priority, [string]$Key, [string]$Text ) if (-not $overallRecommendationKeys.Contains($Key)) { $overallRecommendationKeys.Add($Key) | Out-Null $overallRecommendationItems.Add([PSCustomObject]@{ Priority = $Priority Text = $Text }) | Out-Null } } if (@($riskCandidates | Where-Object { $_.Status -eq 'Error' }).Count -gt 0) { & $addOverallRecommendation 0 'resolve-errors' 'P0 Resolve API and token scope errors first so scan coverage is complete before triage decisions.' } if (@($riskCandidates | Where-Object { $_.CheckName -eq 'OrgMfaPolicy' -and $_.Status -eq 'Fail' }).Count -gt 0) { & $addOverallRecommendation 0 'org-mfa' 'P0 Enforce organization-wide MFA immediately to reduce account takeover risk from stolen credentials.' } if (@($riskCandidates | Where-Object { $_.CheckName -in @('PatPolicy', 'OAuthAppPolicy', 'GitHubAppSecurity') }).Count -gt 0) { & $addOverallRecommendation 0 'token-governance' 'P0 Tighten token and app governance: least privilege, approval gates, and short-lived credentials where possible.' } if (@($riskCandidates | Where-Object { $_.CheckName -eq 'ActionPinning' -and $_.Status -eq 'Fail' }).Count -gt 0) { & $addOverallRecommendation 1 'action-pinning' 'P1 Pin all third-party actions to full commit SHAs to reduce mutable-tag supply chain exposure.' } if (@($riskCandidates | Where-Object { $_.CheckName -eq 'EgressControl' }).Count -gt 0) { & $addOverallRecommendation 1 'egress-control' 'P1 Enforce workflow egress controls in block mode to limit secret exfiltration and attacker callouts.' } if (@($riskCandidates | Where-Object { $_.CheckName -eq 'RunnerHygiene' }).Count -gt 0) { & $addOverallRecommendation 1 'runner-isolation' 'P1 Isolate runners with ephemeral execution and segmented network paths; avoid long-lived shared runners.' } if (@($riskCandidates | Where-Object { $_.CheckName -eq 'BranchProtection' -or $_.CheckName -eq 'Rulesets' }).Count -gt 0) { & $addOverallRecommendation 1 'branch-rulesets' 'P1 Keep strict default branch and tag protection baselines so stolen maintainer sessions cannot silently tamper with release paths.' } $orderedOverallRecommendations = @($overallRecommendationItems | Sort-Object -Property Priority, Text) $overallRecommendationListItems = [System.Collections.Generic.List[string]]::new() foreach ($recommendation in $orderedOverallRecommendations) { $overallRecommendationListItems.Add("<li>$([System.Net.WebUtility]::HtmlEncode([string]$recommendation.Text))</li>") | Out-Null } $overallRecommendationListHtml = if ($overallRecommendationListItems.Count -gt 0) { @" <h3>Prioritized From This Scan</h3> <ul class="recommendation-list"> $($overallRecommendationListItems -join "`n ") </ul> "@ } else { '<h3>Prioritized From This Scan</h3><p>No fail/warning/error findings detected.</p>' } $companionControlHtml = @" <div class="note-box"> <strong>Scope note:</strong> Controls in this section are companion recommendations for endpoint and network hardening. They are not directly validated by this scan unless a corresponding GitHub finding exists. </div> <h3>Recommended Companion Controls</h3> <ul class="recommendation-list"> <li>Extension governance: enforce publisher allowlists via Intune or Group Policy and use staged extension update rings for sensitive developer populations.</li> <li>Endpoint protection: deploy Microsoft Defender XDR (or equivalent EDR), enable tamper protection, and maintain host isolation runbooks.</li> <li>Network telemetry: keep DNS and outbound HTTPS visibility so unusual exfiltration behavior can be investigated quickly.</li> <li>Runner isolation: keep CI runners ephemeral and in segmented network zones; integrate private networking where supported.</li> <li>Identity and token resilience: prefer short-lived and least-privilege credentials, and require app approval workflows.</li> <li>Emergency credential response: include GitHub Credential Revocation API in playbooks to rapidly revoke exposed classic and fine-grained PATs.</li> <li>Dependency hardening on workstations: use package-manager cooldown controls to reduce exposure to freshly compromised package versions.</li> <li>Workstation posture scanners (for example Bagel) can complement Fylgyr by inventorying local credential and configuration risk on developer endpoints.</li> </ul> "@ $overallRecommendationsHtml = @" $overallRecommendationListHtml $companionControlHtml "@ $defenderXdrRulesHtml = @" <div class="note-box"> <strong>Telemetry assumptions:</strong> Custom detections below require Defender for Endpoint data in Microsoft Defender XDR. GitHub identity and OAuth visibility may additionally require Defender for Cloud Apps integration and GitHub connector coverage. </div> <h3>Recommended Custom Detections</h3> <ul class="recommendation-list"> <li>Inventory VS Code extensions across endpoints and alert on known compromised versions.</li> <li>Suspicious extension-driven command execution from developer tools.</li> <li>Outbound connections to high-risk endpoints or unusual GitHub API usage patterns from developer workstations.</li> <li>Persistence artifact creation in known post-compromise paths.</li> <li>Potential credential-harvesting process patterns across shells and package managers.</li> </ul> <p class="code-title">Query 0: VS Code extension inventory in Defender XDR (MDE)</p> <pre>DeviceProcessEvents | where Timestamp > ago(30d) | where InitiatingProcessCommandLine has "\\.vscode\\extensions\\" | where InitiatingProcessCommandLine has "code.exe" | extend ExtensionName = extract(@"extensions\\([^\\]+)", 1, InitiatingProcessCommandLine) | distinct DeviceName, ExtensionName | sort by DeviceName asc</pre> <p class="code-title">Query 1: Suspicious npx GitHub install command</p> <pre>DeviceProcessEvents | where Timestamp > ago(7d) | where ProcessCommandLine has "npx -y github:" | where ProcessCommandLine has "#" | project Timestamp, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine, AccountName</pre> <p class="code-title">Query 1b: Potential PAT material in process command line</p> <pre>DeviceProcessEvents | where Timestamp > ago(7d) | where ProcessCommandLine has_any ("ghp_", "github_pat_") | project Timestamp, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine, AccountName</pre> <p class="code-title">Query 2: Potential exfiltration-related network activity</p> <pre>DeviceNetworkEvents | where Timestamp > ago(7d) | where RemoteUrl has_any ("api.github.com/search/commits", "fulcio.sigstore.dev", "rekor.sigstore.dev") | project Timestamp, DeviceName, InitiatingProcessFileName, RemoteUrl, RemoteIP, RemotePort</pre> <p class="code-title">Query 3: Persistence indicators on developer endpoints</p> <pre>DeviceFileEvents | where Timestamp > ago(7d) | where FolderPath has_any (".local/share/kitty", "Library/LaunchAgents", "/var/tmp") | where FileName has_any ("cat.py", "com.user.kitty-monitor.plist", ".gh_update_state") | project Timestamp, DeviceName, ActionType, FolderPath, FileName, InitiatingProcessFileName</pre> <p class="code-title">Query 4: GitHub cloud activity pivot (when CloudAppEvents is available)</p> <pre>CloudAppEvents | where Timestamp > ago(7d) | where Application == "GitHub" | where ActionType has_any ("OAuth", "Token", "Repository") | project Timestamp, AccountDisplayName, ActionType, ActivityObjects, IPAddress, UserAgent</pre> "@ $scopeSections = [System.Collections.Generic.List[string]]::new() if ($orgSections.Count -gt 0) { $orgScopeSection = @( '<section class="scope-block" id="scope-org">' " <h3>Organization Scope ($($orgSections.Count) target(s))</h3>" " $($orgSections -join "`n")" '</section>' ) -join "`n" $scopeSections.Add($orgScopeSection) } if ($repoSections.Count -gt 0) { $repoScopeSection = @( '<section class="scope-block" id="scope-repo">' " <h3>Repository Scope ($($repoSections.Count) target(s))</h3>" " $($repoSections -join "`n")" '</section>' ) -join "`n" $scopeSections.Add($repoScopeSection) } if ($otherSections.Count -gt 0) { $otherScopeSection = @( '<section class="scope-block" id="scope-other">' " <h3>Other Scope ($($otherSections.Count) target(s))</h3>" " $($otherSections -join "`n")" '</section>' ) -join "`n" $scopeSections.Add($otherScopeSection) } $resultSectionsHtml = if ($scopeSections.Count -gt 0) { $scopeSections -join "`n" } else { '<p>No findings were produced for this run.</p>' } $template = Get-Content -Path $templatePath -Raw $html = $template $html = $html.Replace('{{TITLE}}', 'Fylgyr HTML Report') $html = $html.Replace('{{GENERATED_AT}}', [System.Net.WebUtility]::HtmlEncode((Get-Date -Format 'o'))) $html = $html.Replace('{{TARGET}}', [System.Net.WebUtility]::HtmlEncode($Target)) $html = $html.Replace('{{VERSION}}', [System.Net.WebUtility]::HtmlEncode($version)) $html = $html.Replace('{{SUMMARY_TOTAL}}', [string]$summary.total) $html = $html.Replace('{{SUMMARY_PASS}}', [string]$summary.pass) $html = $html.Replace('{{SUMMARY_FAIL}}', [string]$summary.fail) $html = $html.Replace('{{SUMMARY_WARNING}}', [string]$summary.warning) $html = $html.Replace('{{SUMMARY_ERROR}}', [string]$summary.error) $html = $html.Replace('{{SUMMARY_INFO}}', [string]$summary.info) $html = $html.Replace('{{SUMMARY_SUPPRESSED}}', [string]$summary.suppressed) $html = $html.Replace('{{SCAN_SCOPE}}', $scanScopeHtml) $html = $html.Replace('{{TABLE_OF_CONTENTS}}', $tableOfContentsHtml) $html = $html.Replace('{{RISK_PRIORITIES}}', $riskPrioritizationHtml) $html = $html.Replace('{{OVERALL_RECOMMENDATIONS}}', $overallRecommendationsHtml) $html = $html.Replace('{{DEFENDER_XDR_RULES}}', $defenderXdrRulesHtml) $html = $html.Replace('{{COVERAGE_DASHBOARD}}', $coverageSummaryHtml) $html = $html.Replace('{{RESULT_SECTIONS}}', $resultSectionsHtml) if ($OutputPath) { Set-Content -Path $OutputPath -Value $html -Encoding UTF8 return } return $html } |