diff --git a/.azure-pipelines/generation-templates/generate_adapter-1es.yml b/.azure-pipelines/generation-templates/generate_adapter-1es.yml index ffdb0d362..e1c023e69 100644 --- a/.azure-pipelines/generation-templates/generate_adapter-1es.yml +++ b/.azure-pipelines/generation-templates/generate_adapter-1es.yml @@ -28,6 +28,18 @@ steps: - ${{ if ne(parameters.CfsFeedUrl, '') }}: - task: NuGetAuthenticate@1 displayName: 'Authenticate to CFS feed' + # Under network isolation the public PowerShell Gallery must never be contacted + # (CFSClean2). Even with every install/publish targeting the feed, PowerShellGet + # still probes the default PSGallery source while resolving dependencies. Removing + # it leaves the feed and local gallery as the only sources for the rest of the job. + - task: powershell@2 + displayName: 'Remove public PowerShell Gallery' + inputs: + targetType: inline + script: ./build/Remove-PublicPSGallery.ps1 + pwsh: true + env: + DEPENDENCY_PS_REPO: ${{ parameters.DependencyRepository }} - task: powershell@2 displayName: 'Show current PowerShell version information' inputs: @@ -260,3 +272,19 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true +# Diagnostic: under network isolation, dump any blocked outbound connections to the +# public PowerShell Gallery captured via the WFP auditing that 'Remove public +# PowerShell Gallery' enabled, so the residual CFSClean2 connections can be +# attributed to a specific step via their timestamps. No-op outside the CFS-backed +# pipelines. +- ${{ if ne(parameters.CfsFeedUrl, '') }}: + - task: powershell@2 + displayName: 'Report public gallery connections' + condition: always() + inputs: + targetType: inline + script: ./build/Report-GalleryNetwork.ps1 + pwsh: true + env: + DEPENDENCY_PS_REPO: ${{ parameters.DependencyRepository }} + diff --git a/.azure-pipelines/generation-templates/generate_adapter.yml b/.azure-pipelines/generation-templates/generate_adapter.yml index 7df24f234..dfd63cb65 100644 --- a/.azure-pipelines/generation-templates/generate_adapter.yml +++ b/.azure-pipelines/generation-templates/generate_adapter.yml @@ -28,6 +28,18 @@ steps: - ${{ if ne(parameters.CfsFeedUrl, '') }}: - task: NuGetAuthenticate@1 displayName: 'Authenticate to CFS feed' + # Under network isolation the public PowerShell Gallery must never be contacted + # (CFSClean2). Even with every install/publish targeting the feed, PowerShellGet + # still probes the default PSGallery source while resolving dependencies. Removing + # it leaves the feed and local gallery as the only sources for the rest of the job. + - task: powershell@2 + displayName: 'Remove public PowerShell Gallery' + inputs: + targetType: inline + script: ./build/Remove-PublicPSGallery.ps1 + pwsh: true + env: + DEPENDENCY_PS_REPO: ${{ parameters.DependencyRepository }} - task: powershell@2 displayName: 'Show current PowerShell version information' inputs: @@ -268,3 +280,19 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true +# Diagnostic: under network isolation, dump any blocked outbound connections to the +# public PowerShell Gallery captured via the WFP auditing that 'Remove public +# PowerShell Gallery' enabled, so the residual CFSClean2 connections can be +# attributed to a specific step via their timestamps. No-op outside the CFS-backed +# pipelines. +- ${{ if ne(parameters.CfsFeedUrl, '') }}: + - task: powershell@2 + displayName: 'Report public gallery connections' + condition: always() + inputs: + targetType: inline + script: ./build/Report-GalleryNetwork.ps1 + pwsh: true + env: + DEPENDENCY_PS_REPO: ${{ parameters.DependencyRepository }} + diff --git a/build/Remove-PublicPSGallery.ps1 b/build/Remove-PublicPSGallery.ps1 new file mode 100644 index 000000000..e3c4d35d1 --- /dev/null +++ b/build/Remove-PublicPSGallery.ps1 @@ -0,0 +1,161 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +# ------------------------------------------------------------------------------ + +<# +.SYNOPSIS + Hardens the build agent against the public PowerShell Gallery under network isolation. + +.DESCRIPTION + The 1ES CI/PR pipelines run under network isolation, where the CFSClean2 SFI + policy flags any connection to www.powershellgallery.com. Every Install-Module / + Publish-Module in the build already targets the CFS-backed feed (or the build's + local gallery) via -Repository, and this script also unregisters the public + PSGallery from PowerShellGet, PackageManagement and PSResourceGet. + + However, the residual CFSClean2 violations are NOT caused by a registered + PSGallery source: an instrumented run proved that after unregistering PSGallery + from every subsystem, pwsh still opened 3 connections to www.powershellgallery.com. + Those come from a hard-coded default gallery endpoint baked into the + PowerShellGet / PSResourceGet module stack, which cannot be removed by repository + management, and cannot be hidden with a hosts-file null-route because the 1ES + netiso HostsFileStabilizer manages the hosts file and records the DNS query name + regardless of how it resolves. + + To attribute the residual connections to a specific build step, this script also + enables Windows Filtering Platform (WFP) connection auditing immediately after + network isolation starts. Report-GalleryNetwork.ps1 then dumps every blocked + outbound connection (process, PID, destination address/port, timestamp) at the + end of the job, so the connection timestamps can be correlated against the + pipeline timeline to pinpoint the offending step. DNS-layer logging cannot be + used because netiso's HostsFileStabilizer pre-populates the gallery hostname in + the hosts file, so the name resolves with no DNS query while the blocked TCP + connection still fires. + + Local development is unaffected: this only acts when the pipeline configures a + private dependency repository (DEPENDENCY_PS_REPO set to something other than + 'PSGallery'), so running it on a developer machine is a no-op. + + See https://aka.ms/1es/netiso/pipelinetemplates. +#> +[CmdletBinding()] +param( + [string] $Repository = $env:DEPENDENCY_PS_REPO +) + +$ErrorActionPreference = 'Stop' + +if (-not $Repository -or $Repository -eq 'PSGallery') { + Write-Host 'No private dependency repository configured (DEPENDENCY_PS_REPO); leaving PSGallery registered.' + return +} + +function Write-SourceInventory { + param([string] $Label) + + Write-Host "===== Package source inventory ($Label) =====" + + Write-Host '--- PowerShellGet PSRepositories ---' + try { Get-PSRepository -ErrorAction Stop | Format-Table Name, SourceLocation, InstallationPolicy -AutoSize | Out-String | Write-Host } + catch { Write-Host " (Get-PSRepository failed: $($_.Exception.Message))" } + + Write-Host '--- PackageManagement sources ---' + try { Get-PackageSource -ErrorAction Stop | Format-Table Name, ProviderName, Location, IsTrusted -AutoSize | Out-String | Write-Host } + catch { Write-Host " (Get-PackageSource failed: $($_.Exception.Message))" } + + if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) { + Write-Host '--- PSResourceGet repositories ---' + try { Get-PSResourceRepository -ErrorAction Stop | Format-Table Name, Uri, Trusted -AutoSize | Out-String | Write-Host } + catch { Write-Host " (Get-PSResourceRepository failed: $($_.Exception.Message))" } + } + + # NuGet.config files that the NuGet client / credential provider read. A + # powershellgallery source configured here would be probed by pwsh outside of + # the PowerShellGet repository store. + Write-Host '--- NuGet.config files referencing powershellgallery ---' + $configPaths = @( + (Join-Path $env:APPDATA 'NuGet\NuGet.Config'), + (Join-Path $env:ProgramData 'NuGet\NuGet.Config'), + (Join-Path ${env:ProgramFiles(x86)} 'NuGet\Config') + ) | Where-Object { $_ -and (Test-Path $_) } + $found = $false + foreach ($p in $configPaths) { + $files = @() + if (Test-Path $p -PathType Container) { + $files = Get-ChildItem -Path $p -Filter '*.config' -File -Recurse -ErrorAction SilentlyContinue + } + elseif (Test-Path $p -PathType Leaf) { + $files = Get-Item -Path $p -ErrorAction SilentlyContinue + } + foreach ($f in $files) { + if (Select-String -Path $f.FullName -Pattern 'powershellgallery' -SimpleMatch -ErrorAction SilentlyContinue) { + Write-Host " $($f.FullName) references powershellgallery" + $found = $true + } + } + } + if (-not $found) { Write-Host ' (none)' } + + Write-Host "===== End inventory ($Label) =====" +} + +Write-SourceInventory -Label 'before' + +# PowerShellGet v2 +if (Get-PSRepository -Name 'PSGallery' -ErrorAction SilentlyContinue) { + Write-Host 'Unregistering public PSGallery from PowerShellGet.' + Unregister-PSRepository -Name 'PSGallery' +} +else { + Write-Host 'PSGallery is not registered with PowerShellGet; nothing to remove.' +} + +# PackageManagement / OneGet: Install-Module ultimately resolves through the +# PackageManagement source list, which can retain a powershellgallery-backed +# source even after the PowerShellGet PSRepository is unregistered. +if (Get-Command -Name 'Get-PackageSource' -ErrorAction SilentlyContinue) { + $publicSources = Get-PackageSource -ErrorAction SilentlyContinue | + Where-Object { $_.Location -and $_.Location -match 'powershellgallery\.com' } + foreach ($source in $publicSources) { + Write-Host "Unregistering PackageManagement source '$($source.Name)' ($($source.Location))." + try { Unregister-PackageSource -Name $source.Name -Force -ErrorAction Stop } + catch { Write-Host " (Unregister-PackageSource failed: $($_.Exception.Message))" } + } + if (-not $publicSources) { + Write-Host 'No powershellgallery-backed PackageManagement sources registered; nothing to remove.' + } +} + +# PSResourceGet (v3) +if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) { + if (Get-PSResourceRepository -Name 'PSGallery' -ErrorAction SilentlyContinue) { + Write-Host 'Unregistering public PSGallery from PSResourceGet.' + Unregister-PSResourceRepository -Name 'PSGallery' + } + else { + Write-Host 'PSGallery is not registered with PSResourceGet; nothing to remove.' + } +} + +Write-SourceInventory -Label 'after' + +# The residual CFSClean2 violations are NOT caused by a registered PSGallery source +# (proven: they persist after every source above is removed). DNS-layer attribution +# also fails, because the 1ES netiso HostsFileStabilizer pre-populates +# www.powershellgallery.com in the hosts file: the name resolves from hosts with NO +# DNS query (nothing in the DNS-Client Operational log), yet the outbound TCP +# connection to the gallery IP still fires and trips the WFP violation. The reliable +# attribution source is Windows Filtering Platform connection auditing: a blocked +# egress produces Security event 5157 with the process image, PID, destination +# address/port and timestamp. Enable it here (right after isolation starts); +# Report-GalleryDns.ps1 dumps the matching blocked connections at the end of the job +# so their timestamps can be correlated against the pipeline timeline. +Write-Host 'Enabling Windows Filtering Platform connection auditing for gallery-connection attribution.' +try { + & auditpol.exe /set /subcategory:'Filtering Platform Connection' /success:enable /failure:enable | Out-Null + & auditpol.exe /set /subcategory:'Filtering Platform Packet Drop' /success:enable /failure:enable | Out-Null + Write-Host ' WFP connection/packet-drop auditing enabled (Security events 5157/5152).' +} +catch { + Write-Host " (Unable to enable WFP auditing: $($_.Exception.Message))" +} diff --git a/build/Report-GalleryNetwork.ps1 b/build/Report-GalleryNetwork.ps1 new file mode 100644 index 000000000..21a34f6a9 --- /dev/null +++ b/build/Report-GalleryNetwork.ps1 @@ -0,0 +1,152 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +# ------------------------------------------------------------------------------ + +<# +.SYNOPSIS + Attributes public-gallery network connections to a build step for CFSClean2 + investigation. + +.DESCRIPTION + The 1ES network-isolation CFSClean2 policy flags connections to + www.powershellgallery.com, but its report gives no timestamp, command line or + step attribution. DNS-layer attribution fails, because the netiso + HostsFileStabilizer pre-populates the gallery hostname in the hosts file, so the + name resolves with NO DNS query while the connection still fires. + + Remove-PublicPSGallery.ps1 enables Windows Filtering Platform (WFP) connection + auditing at the start of the isolated job. Each connection that WFP inspects + produces a Security event: 5156 (permitted) or 5157 (blocked); 5152 covers + dropped packets. Every event carries the process image, PID, destination + address/port and timestamp. netiso frequently runs in *audit* mode, where the + connection is permitted (5156) but still reported as a violation, so this script + reads 5156, 5157 and 5152. + + It prints the effective audit setting, total event counts, and a grouped + summary of outbound port-443/80 destinations (with process, count and first/last + timestamp) so the public-gallery IP surfaces even when the initiating process is + not pwsh.exe. Correlate the UTC timestamps against the pipeline timeline's + per-step start/finish times to identify which step issued the connection, then + eliminate it at its source. + + Runs only when a private dependency repository is configured (DEPENDENCY_PS_REPO + set to something other than 'PSGallery'); a no-op on developer machines. +#> +[CmdletBinding()] +param( + [string] $Repository = $env:DEPENDENCY_PS_REPO, + + # Destination ports of interest (gallery is HTTPS/443). + [int[]] $Ports = @(443, 80), + + # Known public-gallery front-door IP(s) from the CFSClean2 report, highlighted + # in the output when seen. + [string[]] $GalleryIp = @('150.171.109.114', '150.171.108.114') +) + +$ErrorActionPreference = 'Stop' + +if (-not $Repository -or $Repository -eq 'PSGallery') { + Write-Host 'No private dependency repository configured (DEPENDENCY_PS_REPO); skipping network attribution.' + return +} + +Write-Host '===== Public gallery network-connection attribution =====' + +# Show the effective audit policy so we can tell whether auditing was actually on. +try { + Write-Host '--- Effective WFP audit policy ---' + & auditpol.exe /get /subcategory:'Filtering Platform Connection' 2>&1 | ForEach-Object { Write-Host " $_" } +} +catch { + Write-Host " (auditpol /get failed: $($_.Exception.Message))" +} + +$events = $null +# Filter on the destination port inside the query (XPath) so we do not have to +# materialise every 5156 permit event the agent generates. +$portPredicate = ($Ports | ForEach-Object { "Data[@Name='DestPort']='$_'" }) -join ' or ' +$filterXml = @" + + + + + +"@ +try { + $events = Get-WinEvent -FilterXml ([xml]$filterXml) -ErrorAction Stop +} +catch { + Write-Host " (No matching Security events, or unable to read the Security log: $($_.Exception.Message))" + Write-Host ' This means WFP connection auditing produced no events on this agent.' + Write-Host '===== End network-connection attribution =====' + return +} + +Write-Host "Total WFP connection/drop events on ports $($Ports -join ','): $($events.Count)" + +function Get-EventField { + param($EventXml, [string] $Name) + $node = $EventXml.Event.EventData.Data | Where-Object { $_.Name -eq $Name } + if ($node) { return [string]$node.'#text' } + return '' +} + +$rows = foreach ($e in $events) { + $xml = [xml]$e.ToXml() + $app = Get-EventField $xml 'Application' + $destPort = Get-EventField $xml 'DestPort' + $destAddr = Get-EventField $xml 'DestAddress' + $procId = Get-EventField $xml 'ProcessID' + $direction = Get-EventField $xml 'Direction' + + $imageName = if ($app) { [System.IO.Path]::GetFileName(($app -replace '\\device\\harddiskvolume\d+', '')) } else { '' } + $portInt = 0; [void][int]::TryParse($destPort, [ref]$portInt) + if ($Ports -notcontains $portInt) { continue } + + [PSCustomObject]@{ + Time = $e.TimeCreated + UtcTime = $e.TimeCreated.ToUniversalTime().ToString('o') + EventId = $e.Id + Pid = $procId + Image = $imageName + DestAddr = $destAddr + DestPort = $destPort + Direction = $direction + } +} + +if (-not $rows) { + Write-Host " No outbound connections on ports $($Ports -join ',') were audited." + Write-Host '===== End network-connection attribution =====' + return +} + +Write-Host '' +Write-Host '--- Outbound destinations grouped by image + address + port ---' +$rows | Group-Object Image, DestAddr, DestPort | + Sort-Object Count -Descending | + ForEach-Object { + $first = ($_.Group | Sort-Object Time | Select-Object -First 1) + $last = ($_.Group | Sort-Object Time | Select-Object -Last 1) + $flag = if ($GalleryIp -contains $first.DestAddr) { ' <== PUBLIC GALLERY' } else { '' } + '{0,4}x {1,-16} {2,-16}:{3,-5} first={4} last={5}{6}' -f ` + $_.Count, $first.Image, $first.DestAddr, $first.DestPort, ` + $first.UtcTime, $last.UtcTime, $flag | Write-Host + } + +Write-Host '' +Write-Host '--- Individual connections to the known public-gallery IP(s) ---' +$galleryRows = $rows | Where-Object { $GalleryIp -contains $_.DestAddr } | Sort-Object Time +if ($galleryRows) { + $galleryRows | Format-Table UtcTime, EventId, Pid, Image, DestAddr, DestPort, Direction -AutoSize | + Out-String -Width 4096 | Write-Host + Write-Host "Total connections to public-gallery IP(s): $($galleryRows.Count)" + Write-Host 'Correlate the UtcTime values above against the pipeline timeline (step startTime/finishTime) to attribute each connection.' +} +else { + Write-Host ' No connections to the known public-gallery IP were audited on ports of interest.' + Write-Host ' Review the grouped destination list above for the gallery front-door address.' +} + +Write-Host '===== End network-connection attribution ====='