From 21a9e69400213e4a84640d0068b2cc5a596e2911 Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Mon, 20 Jul 2026 15:31:39 +0300 Subject: [PATCH 1/6] Remove public PSGallery in isolated builds to fix CFSClean2 violations Under network isolation, PowerShellGet still probed the default PSGallery source while resolving package sources and Publish-Module RequiredModules dependencies, even though every install/publish targeted the CFS feed. These probes were the 3 residual CFSClean2 violations to www.powershellgallery.com. Add build/Remove-PublicPSGallery.ps1 and run it (guarded by CfsFeedUrl) right after NuGetAuthenticate in both generation templates. It unregisters PSGallery from PowerShellGet and PSResourceGet so the feed and local gallery are the only sources for the rest of the job. Local development is unaffected: it is a no-op unless DEPENDENCY_PS_REPO points at a private feed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generate_adapter-1es.yml | 12 ++++ .../generation-templates/generate_adapter.yml | 12 ++++ build/Remove-PublicPSGallery.ps1 | 58 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 build/Remove-PublicPSGallery.ps1 diff --git a/.azure-pipelines/generation-templates/generate_adapter-1es.yml b/.azure-pipelines/generation-templates/generate_adapter-1es.yml index ffdb0d362..ab09b7320 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: diff --git a/.azure-pipelines/generation-templates/generate_adapter.yml b/.azure-pipelines/generation-templates/generate_adapter.yml index 7df24f234..21c432957 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: diff --git a/build/Remove-PublicPSGallery.ps1 b/build/Remove-PublicPSGallery.ps1 new file mode 100644 index 000000000..d4123de37 --- /dev/null +++ b/build/Remove-PublicPSGallery.ps1 @@ -0,0 +1,58 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +# ------------------------------------------------------------------------------ + +<# +.SYNOPSIS + Removes the public PowerShell Gallery from the build agent 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. Even though every + Install-Module / Publish-Module in the build targets the CFS-backed feed + (or the build's local gallery) via -Repository, PowerShellGet still probes the + default 'PSGallery' source while resolving package sources and module + dependencies (e.g. the Microsoft.Graph.* RequiredModules check during + Publish-Module). Those probes are the residual CFSClean2 violations. + + Unregistering PSGallery from both PowerShellGet and PSResourceGet leaves the + feed and the local gallery as the only sources, so no PowerShell step can reach + the public gallery. The change persists on the agent for the rest of the job. + + 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 +} + +# 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.' +} + +# 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.' + } +} From 5794df3cc4d4587cb40c18c9794b4479d123db81 Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Tue, 21 Jul 2026 16:11:33 +0300 Subject: [PATCH 2/6] Add package-source diagnostics and PackageManagement cleanup to PSGallery removal Unregistering PSGallery from PowerShellGet/PSResourceGet did not reduce the 3 residual CFSClean2 violations, so this instruments the isolation-prep step to dump the full package-source inventory (PowerShellGet, PackageManagement, PSResourceGet, and NuGet.config) before and after cleanup, and also unregisters any powershellgallery-backed PackageManagement source. This pinpoints which subsystem still reaches www.powershellgallery.com on the isolated agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/Remove-PublicPSGallery.ps1 | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/build/Remove-PublicPSGallery.ps1 b/build/Remove-PublicPSGallery.ps1 index d4123de37..1980a7980 100644 --- a/build/Remove-PublicPSGallery.ps1 +++ b/build/Remove-PublicPSGallery.ps1 @@ -37,6 +37,57 @@ if (-not $Repository -or $Repository -eq 'PSGallery') { 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.' @@ -46,6 +97,22 @@ 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) { @@ -56,3 +123,5 @@ if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) Write-Host 'PSGallery is not registered with PSResourceGet; nothing to remove.' } } + +Write-SourceInventory -Label 'after' From 745c6f55e4e5c056daabb63e9ade62c60645629a Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Tue, 21 Jul 2026 18:31:56 +0300 Subject: [PATCH 3/6] Null-route public PowerShell Gallery host to clear residual CFSClean2 violations An instrumented run proved that after unregistering PSGallery from PowerShellGet, PackageManagement and PSResourceGet, pwsh still opened 3 connections to www.powershellgallery.com. Those originate from the hard-coded default gallery endpoint in the PowerShellGet/PSResourceGet stack and cannot be removed by repository management. Since every dependency installs from the private CFS feed, this null-routes www.powershellgallery.com in the agent hosts file so the host resolves locally to 0.0.0.0, eliminating the CFSClean2 violations across PR, CI and release pipelines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/Remove-PublicPSGallery.ps1 | 73 +++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/build/Remove-PublicPSGallery.ps1 b/build/Remove-PublicPSGallery.ps1 index 1980a7980..1048101e7 100644 --- a/build/Remove-PublicPSGallery.ps1 +++ b/build/Remove-PublicPSGallery.ps1 @@ -4,20 +4,27 @@ <# .SYNOPSIS - Removes the public PowerShell Gallery from the build agent under network isolation. + 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. Even though every - Install-Module / Publish-Module in the build targets the CFS-backed feed - (or the build's local gallery) via -Repository, PowerShellGet still probes the - default 'PSGallery' source while resolving package sources and module - dependencies (e.g. the Microsoft.Graph.* RequiredModules check during - Publish-Module). Those probes are the residual CFSClean2 violations. - - Unregistering PSGallery from both PowerShellGet and PSResourceGet leaves the - feed and the local gallery as the only sources, so no PowerShell step can reach - the public gallery. The change persists on the agent for the rest of the job. + 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 the hard-coded default gallery endpoint baked into the + PowerShellGet / PSResourceGet module stack (used opportunistically while + resolving module metadata), which cannot be removed by repository management. + + To make the build genuinely never reach the public gallery, this script also + null-routes www.powershellgallery.com in the agent hosts file for the duration + of the job. Because every dependency is installed from the private feed, these + probes are non-essential, so resolving the host locally to 0.0.0.0 is safe and + eliminates the CFSClean2 violations for the PR, CI and release pipelines. Local development is unaffected: this only acts when the pipeline configures a private dependency repository (DEPENDENCY_PS_REPO set to something other than @@ -125,3 +132,47 @@ if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) } Write-SourceInventory -Label 'after' + +# Null-route the public gallery host. Repository management alone does not stop the +# hard-coded default gallery endpoint in the PowerShellGet / PSResourceGet stack, so +# resolve www.powershellgallery.com to a dead address locally. The DNS lookup is then +# satisfied by the hosts file (no query leaves the agent) and any stray connection +# goes to 0.0.0.0, so the CFSClean2 policy records no connection to the domain. +$galleryHosts = @('www.powershellgallery.com', 'psg-prod-eastus.azureedge.net') +$hostsPath = if ($IsWindows -or $env:OS -eq 'Windows_NT') { + Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' +} +else { + '/etc/hosts' +} + +Write-Host "Null-routing public gallery hosts via '$hostsPath'." +try { + if (-not (Test-Path $hostsPath)) { + New-Item -Path $hostsPath -ItemType File -Force | Out-Null + } + + $existing = Get-Content -Path $hostsPath -ErrorAction SilentlyContinue + $newLines = New-Object System.Collections.Generic.List[string] + foreach ($galleryHost in $galleryHosts) { + if ($existing -match ("(?i)\s" + [regex]::Escape($galleryHost) + "\s*$")) { + Write-Host " $galleryHost already present in hosts file." + } + else { + $newLines.Add("0.0.0.0`t$galleryHost") + } + } + + if ($newLines.Count -gt 0) { + Add-Content -Path $hostsPath -Value $newLines -ErrorAction Stop + $newLines | ForEach-Object { Write-Host " Added: $_" } + } + + # Best-effort: clear any cached DNS entry so the hosts file takes effect immediately. + if (Get-Command -Name 'Clear-DnsClientCache' -ErrorAction SilentlyContinue) { + Clear-DnsClientCache -ErrorAction SilentlyContinue + } +} +catch { + Write-Host " (Unable to update hosts file: $($_.Exception.Message))" +} From c14fa4f53ce875c79b268316dc509b3657863256 Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Tue, 21 Jul 2026 19:21:58 +0300 Subject: [PATCH 4/6] Instrument DNS query logging to attribute residual CFSClean2 gallery connections The hosts null-route did not help because 1ES netiso manages the hosts file (HostsFileStabilizer) and records the DNS query name regardless of local resolution. Replace it with DNS-Client Operational logging: Remove-PublicPSGallery.ps1 enables/clears the log right after isolation starts, and a new Report-GalleryDns.ps1 step at the end of the PR/CI templates prints each www.powershellgallery.com query (name, UTC/local time, PID) so it can be correlated against the pipeline timeline to pinpoint the offending step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generate_adapter-1es.yml | 15 ++++ .../generation-templates/generate_adapter.yml | 15 ++++ build/Remove-PublicPSGallery.ps1 | 75 ++++++----------- build/Report-GalleryDns.ps1 | 82 +++++++++++++++++++ 4 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 build/Report-GalleryDns.ps1 diff --git a/.azure-pipelines/generation-templates/generate_adapter-1es.yml b/.azure-pipelines/generation-templates/generate_adapter-1es.yml index ab09b7320..dedd1ce3b 100644 --- a/.azure-pipelines/generation-templates/generate_adapter-1es.yml +++ b/.azure-pipelines/generation-templates/generate_adapter-1es.yml @@ -272,3 +272,18 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true +# Diagnostic: under network isolation, dump any DNS queries to the public PowerShell +# Gallery captured since 'Remove public PowerShell Gallery' enabled DNS logging, 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 DNS queries' + condition: always() + inputs: + targetType: inline + script: ./build/Report-GalleryDns.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 21c432957..e560c18f7 100644 --- a/.azure-pipelines/generation-templates/generate_adapter.yml +++ b/.azure-pipelines/generation-templates/generate_adapter.yml @@ -280,3 +280,18 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true +# Diagnostic: under network isolation, dump any DNS queries to the public PowerShell +# Gallery captured since 'Remove public PowerShell Gallery' enabled DNS logging, 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 DNS queries' + condition: always() + inputs: + targetType: inline + script: ./build/Report-GalleryDns.ps1 + pwsh: true + env: + DEPENDENCY_PS_REPO: ${{ parameters.DependencyRepository }} + diff --git a/build/Remove-PublicPSGallery.ps1 b/build/Remove-PublicPSGallery.ps1 index 1048101e7..9a8eb2086 100644 --- a/build/Remove-PublicPSGallery.ps1 +++ b/build/Remove-PublicPSGallery.ps1 @@ -16,15 +16,17 @@ 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 the hard-coded default gallery endpoint baked into the - PowerShellGet / PSResourceGet module stack (used opportunistically while - resolving module metadata), which cannot be removed by repository management. - - To make the build genuinely never reach the public gallery, this script also - null-routes www.powershellgallery.com in the agent hosts file for the duration - of the job. Because every dependency is installed from the private feed, these - probes are non-essential, so resolving the host locally to 0.0.0.0 is safe and - eliminates the CFSClean2 violations for the PR, CI and release pipelines. + 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 queries to a specific build step, this script also + enables and clears the Windows DNS-Client Operational event log immediately after + network isolation starts. Report-GalleryDns.ps1 then dumps every matching query + (name, timestamp, PID) at the end of the job, so the query timestamps can be + correlated against the pipeline timeline to pinpoint the offending step. Local development is unaffected: this only acts when the pipeline configures a private dependency repository (DEPENDENCY_PS_REPO set to something other than @@ -133,46 +135,23 @@ if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) Write-SourceInventory -Label 'after' -# Null-route the public gallery host. Repository management alone does not stop the -# hard-coded default gallery endpoint in the PowerShellGet / PSResourceGet stack, so -# resolve www.powershellgallery.com to a dead address locally. The DNS lookup is then -# satisfied by the hosts file (no query leaves the agent) and any stray connection -# goes to 0.0.0.0, so the CFSClean2 policy records no connection to the domain. -$galleryHosts = @('www.powershellgallery.com', 'psg-prod-eastus.azureedge.net') -$hostsPath = if ($IsWindows -or $env:OS -eq 'Windows_NT') { - Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' -} -else { - '/etc/hosts' -} - -Write-Host "Null-routing public gallery hosts via '$hostsPath'." +# The residual CFSClean2 violations are NOT caused by a registered PSGallery source +# (proven: they persist after every source above is removed) and cannot be hidden +# with a hosts-file null-route (the 1ES netiso HostsFileStabilizer manages the hosts +# file and captures the DNS query name regardless of how it resolves). To attribute +# the 3 www.powershellgallery.com queries to a specific build step, enable the +# Windows DNS-Client Operational log here (right after network isolation starts); +# Report-GalleryDns.ps1 dumps the matching queries at the end of the job so their +# timestamps can be correlated against the pipeline timeline. +$dnsLog = 'Microsoft-Windows-DNS-Client/Operational' +Write-Host "Enabling DNS query logging via '$dnsLog' for gallery-connection attribution." try { - if (-not (Test-Path $hostsPath)) { - New-Item -Path $hostsPath -ItemType File -Force | Out-Null - } - - $existing = Get-Content -Path $hostsPath -ErrorAction SilentlyContinue - $newLines = New-Object System.Collections.Generic.List[string] - foreach ($galleryHost in $galleryHosts) { - if ($existing -match ("(?i)\s" + [regex]::Escape($galleryHost) + "\s*$")) { - Write-Host " $galleryHost already present in hosts file." - } - else { - $newLines.Add("0.0.0.0`t$galleryHost") - } - } - - if ($newLines.Count -gt 0) { - Add-Content -Path $hostsPath -Value $newLines -ErrorAction Stop - $newLines | ForEach-Object { Write-Host " Added: $_" } - } - - # Best-effort: clear any cached DNS entry so the hosts file takes effect immediately. - if (Get-Command -Name 'Clear-DnsClientCache' -ErrorAction SilentlyContinue) { - Clear-DnsClientCache -ErrorAction SilentlyContinue - } + & wevtutil.exe set-log $dnsLog /enabled:false 2>$null + & wevtutil.exe set-log $dnsLog /maxsize:33554432 2>$null + & wevtutil.exe clear-log $dnsLog 2>$null + & wevtutil.exe set-log $dnsLog /enabled:true 2>$null + Write-Host " DNS-Client Operational log enabled and cleared." } catch { - Write-Host " (Unable to update hosts file: $($_.Exception.Message))" + Write-Host " (Unable to enable DNS query logging: $($_.Exception.Message))" } diff --git a/build/Report-GalleryDns.ps1 b/build/Report-GalleryDns.ps1 new file mode 100644 index 000000000..e494c460e --- /dev/null +++ b/build/Report-GalleryDns.ps1 @@ -0,0 +1,82 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +# ------------------------------------------------------------------------------ + +<# +.SYNOPSIS + Reports DNS queries to the public PowerShell Gallery for CFSClean2 attribution. + +.DESCRIPTION + The 1ES network-isolation CFSClean2 policy flags connections to + www.powershellgallery.com, but its report gives no timestamp, command line or + step attribution. Remove-PublicPSGallery.ps1 enables the Windows DNS-Client + Operational event log at the start of the isolated job; this script reads that + log at the end of the job and prints every query whose name matches the public + gallery, including the event time (local and UTC) and the initiating process id. + + Correlate the UTC timestamps below against the pipeline timeline's per-step + start/finish times (each build step records startTime/finishTime) to identify + which step issued the query, then eliminate that query 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, + + [string[]] $Pattern = @('powershellgallery', 'psg-prod', 'psg-'), + + [string] $LogName = 'Microsoft-Windows-DNS-Client/Operational' +) + +$ErrorActionPreference = 'Stop' + +if (-not $Repository -or $Repository -eq 'PSGallery') { + Write-Host 'No private dependency repository configured (DEPENDENCY_PS_REPO); skipping DNS attribution.' + return +} + +Write-Host "===== Public gallery DNS query attribution =====" +Write-Host "Reading '$LogName' for queries matching: $($Pattern -join ', ')" + +$events = $null +try { + $events = Get-WinEvent -LogName $LogName -ErrorAction Stop | + Where-Object { + $message = $_.Message + $message -and ($Pattern | Where-Object { $message -match [regex]::Escape($_) }) + } +} +catch { + Write-Host " (Unable to read '$LogName': $($_.Exception.Message))" + Write-Host "===== End DNS query attribution =====" + return +} + +if (-not $events) { + Write-Host ' No public-gallery DNS queries were recorded. No CFSClean2 source detected in this job.' + Write-Host "===== End DNS query attribution =====" + return +} + +# DNS-Client event 3006 = "DNS query is called"; the message embeds the query name. +$rows = foreach ($e in ($events | Sort-Object TimeCreated)) { + $queryName = '' + if ($e.Message -match '(?im)(?:for the name|Query name[:=]?|name[:=])\s*([^\s,;]+)') { + $queryName = $Matches[1].Trim() + } + [PSCustomObject]@{ + UtcTime = $e.TimeCreated.ToUniversalTime().ToString('o') + LocalTime = $e.TimeCreated.ToString('o') + EventId = $e.Id + Pid = $e.ProcessId + QueryName = $queryName + } +} + +$rows | Format-Table UtcTime, LocalTime, EventId, Pid, QueryName -AutoSize | Out-String -Width 4096 | Write-Host + +Write-Host "Total matching DNS queries: $($rows.Count)" +Write-Host 'Correlate UtcTime values against the pipeline timeline (step startTime/finishTime) to attribute each query.' +Write-Host "===== End DNS query attribution =====" From cadd9a2136d1426540c2138a8c71cafa61d2d263 Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Tue, 21 Jul 2026 23:24:35 +0300 Subject: [PATCH 5/6] Attribute residual CFSClean2 gallery hits via WFP connection auditing DNS-Client Operational logging captured nothing because the 1ES netiso HostsFileStabilizer pre-populates www.powershellgallery.com in the hosts file: the name resolves with no DNS query while the blocked TCP connection still trips CFSClean2. Switch attribution to Windows Filtering Platform connection auditing (Security event 5157/5152), which records the process, PID, destination address/port and timestamp of the blocked egress so it can be correlated against the pipeline timeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generate_adapter-1es.yml | 13 +- .../generation-templates/generate_adapter.yml | 13 +- build/Remove-PublicPSGallery.ps1 | 44 ++++--- build/Report-GalleryDns.ps1 | 82 ------------ build/Report-GalleryNetwork.ps1 | 120 ++++++++++++++++++ 5 files changed, 158 insertions(+), 114 deletions(-) delete mode 100644 build/Report-GalleryDns.ps1 create mode 100644 build/Report-GalleryNetwork.ps1 diff --git a/.azure-pipelines/generation-templates/generate_adapter-1es.yml b/.azure-pipelines/generation-templates/generate_adapter-1es.yml index dedd1ce3b..e1c023e69 100644 --- a/.azure-pipelines/generation-templates/generate_adapter-1es.yml +++ b/.azure-pipelines/generation-templates/generate_adapter-1es.yml @@ -272,17 +272,18 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true -# Diagnostic: under network isolation, dump any DNS queries to the public PowerShell -# Gallery captured since 'Remove public PowerShell Gallery' enabled DNS logging, so -# the residual CFSClean2 connections can be attributed to a specific step via their -# timestamps. No-op outside the CFS-backed pipelines. +# 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 DNS queries' + displayName: 'Report public gallery connections' condition: always() inputs: targetType: inline - script: ./build/Report-GalleryDns.ps1 + 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 e560c18f7..dfd63cb65 100644 --- a/.azure-pipelines/generation-templates/generate_adapter.yml +++ b/.azure-pipelines/generation-templates/generate_adapter.yml @@ -280,17 +280,18 @@ steps: Settings: required IgnorePattern: .gdn Recurse: true -# Diagnostic: under network isolation, dump any DNS queries to the public PowerShell -# Gallery captured since 'Remove public PowerShell Gallery' enabled DNS logging, so -# the residual CFSClean2 connections can be attributed to a specific step via their -# timestamps. No-op outside the CFS-backed pipelines. +# 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 DNS queries' + displayName: 'Report public gallery connections' condition: always() inputs: targetType: inline - script: ./build/Report-GalleryDns.ps1 + 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 index 9a8eb2086..e3c4d35d1 100644 --- a/build/Remove-PublicPSGallery.ps1 +++ b/build/Remove-PublicPSGallery.ps1 @@ -22,11 +22,15 @@ netiso HostsFileStabilizer manages the hosts file and records the DNS query name regardless of how it resolves. - To attribute the residual queries to a specific build step, this script also - enables and clears the Windows DNS-Client Operational event log immediately after - network isolation starts. Report-GalleryDns.ps1 then dumps every matching query - (name, timestamp, PID) at the end of the job, so the query timestamps can be - correlated against the pipeline timeline to pinpoint the offending step. + 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 @@ -136,22 +140,22 @@ if (Get-Command -Name 'Get-PSResourceRepository' -ErrorAction SilentlyContinue) 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) and cannot be hidden -# with a hosts-file null-route (the 1ES netiso HostsFileStabilizer manages the hosts -# file and captures the DNS query name regardless of how it resolves). To attribute -# the 3 www.powershellgallery.com queries to a specific build step, enable the -# Windows DNS-Client Operational log here (right after network isolation starts); -# Report-GalleryDns.ps1 dumps the matching queries at the end of the job so their -# timestamps can be correlated against the pipeline timeline. -$dnsLog = 'Microsoft-Windows-DNS-Client/Operational' -Write-Host "Enabling DNS query logging via '$dnsLog' for gallery-connection attribution." +# (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 { - & wevtutil.exe set-log $dnsLog /enabled:false 2>$null - & wevtutil.exe set-log $dnsLog /maxsize:33554432 2>$null - & wevtutil.exe clear-log $dnsLog 2>$null - & wevtutil.exe set-log $dnsLog /enabled:true 2>$null - Write-Host " DNS-Client Operational log enabled and cleared." + & 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 DNS query logging: $($_.Exception.Message))" + Write-Host " (Unable to enable WFP auditing: $($_.Exception.Message))" } diff --git a/build/Report-GalleryDns.ps1 b/build/Report-GalleryDns.ps1 deleted file mode 100644 index e494c460e..000000000 --- a/build/Report-GalleryDns.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -# ------------------------------------------------------------------------------ -# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. -# ------------------------------------------------------------------------------ - -<# -.SYNOPSIS - Reports DNS queries to the public PowerShell Gallery for CFSClean2 attribution. - -.DESCRIPTION - The 1ES network-isolation CFSClean2 policy flags connections to - www.powershellgallery.com, but its report gives no timestamp, command line or - step attribution. Remove-PublicPSGallery.ps1 enables the Windows DNS-Client - Operational event log at the start of the isolated job; this script reads that - log at the end of the job and prints every query whose name matches the public - gallery, including the event time (local and UTC) and the initiating process id. - - Correlate the UTC timestamps below against the pipeline timeline's per-step - start/finish times (each build step records startTime/finishTime) to identify - which step issued the query, then eliminate that query 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, - - [string[]] $Pattern = @('powershellgallery', 'psg-prod', 'psg-'), - - [string] $LogName = 'Microsoft-Windows-DNS-Client/Operational' -) - -$ErrorActionPreference = 'Stop' - -if (-not $Repository -or $Repository -eq 'PSGallery') { - Write-Host 'No private dependency repository configured (DEPENDENCY_PS_REPO); skipping DNS attribution.' - return -} - -Write-Host "===== Public gallery DNS query attribution =====" -Write-Host "Reading '$LogName' for queries matching: $($Pattern -join ', ')" - -$events = $null -try { - $events = Get-WinEvent -LogName $LogName -ErrorAction Stop | - Where-Object { - $message = $_.Message - $message -and ($Pattern | Where-Object { $message -match [regex]::Escape($_) }) - } -} -catch { - Write-Host " (Unable to read '$LogName': $($_.Exception.Message))" - Write-Host "===== End DNS query attribution =====" - return -} - -if (-not $events) { - Write-Host ' No public-gallery DNS queries were recorded. No CFSClean2 source detected in this job.' - Write-Host "===== End DNS query attribution =====" - return -} - -# DNS-Client event 3006 = "DNS query is called"; the message embeds the query name. -$rows = foreach ($e in ($events | Sort-Object TimeCreated)) { - $queryName = '' - if ($e.Message -match '(?im)(?:for the name|Query name[:=]?|name[:=])\s*([^\s,;]+)') { - $queryName = $Matches[1].Trim() - } - [PSCustomObject]@{ - UtcTime = $e.TimeCreated.ToUniversalTime().ToString('o') - LocalTime = $e.TimeCreated.ToString('o') - EventId = $e.Id - Pid = $e.ProcessId - QueryName = $queryName - } -} - -$rows | Format-Table UtcTime, LocalTime, EventId, Pid, QueryName -AutoSize | Out-String -Width 4096 | Write-Host - -Write-Host "Total matching DNS queries: $($rows.Count)" -Write-Host 'Correlate UtcTime values against the pipeline timeline (step startTime/finishTime) to attribute each query.' -Write-Host "===== End DNS query attribution =====" diff --git a/build/Report-GalleryNetwork.ps1 b/build/Report-GalleryNetwork.ps1 new file mode 100644 index 000000000..e4dd0ec99 --- /dev/null +++ b/build/Report-GalleryNetwork.ps1 @@ -0,0 +1,120 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. See License in the project root for license information. +# ------------------------------------------------------------------------------ + +<# +.SYNOPSIS + Attributes blocked 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 also fails, because the netiso + 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. + + Remove-PublicPSGallery.ps1 enables Windows Filtering Platform (WFP) connection + auditing at the start of the isolated job. A blocked egress produces Security + event 5157 (and packet drops produce 5152) containing the process image name, + PID, destination address/port and timestamp. This script reads the Security log + at the end of the job and prints every blocked outbound connection to port 443 + from pwsh.exe (the process the CFSClean2 report attributes the violation to), + including the event time (local and UTC). + + Correlate the UTC timestamps below against the pipeline timeline's per-step + start/finish times (each build step runs in its own pwsh process with a distinct + startTime/finishTime) 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), + + # Process image names of interest; the CFSClean2 report attributes the + # violation to pwsh.exe. + [string[]] $Process = @('pwsh.exe', 'powershell.exe', 'dotnet.exe', 'nuget.exe') +) + +$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 =====' +Write-Host "Reading Security log for blocked WFP connections (event 5157/5152) on ports: $($Ports -join ', ')" + +# WFP audit event 5157 = "The Windows Filtering Platform has blocked a connection". +# Event 5152 = "blocked a packet". Both carry Application, ProcessID, and the +# Source/Destination address + port fields. +$events = $null +try { + $events = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = @(5157, 5152) } -ErrorAction Stop +} +catch { + Write-Host " (Unable to read Security log for WFP events: $($_.Exception.Message))" + Write-Host ' Ensure the build agent is elevated and auditpol enabled the subcategory.' + Write-Host '===== End network-connection attribution =====' + return +} + +if (-not $events) { + Write-Host ' No blocked WFP connection events were recorded.' + Write-Host '===== End network-connection attribution =====' + return +} + +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 | Sort-Object TimeCreated)) { + $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 } + if ($Process -and $imageName -and ($Process -notcontains $imageName)) { continue } + + [PSCustomObject]@{ + UtcTime = $e.TimeCreated.ToUniversalTime().ToString('o') + LocalTime = $e.TimeCreated.ToString('o') + EventId = $e.Id + Pid = $procId + Image = $imageName + DestAddr = $destAddr + DestPort = $destPort + Direction = $direction + } +} + +if (-not $rows) { + Write-Host ' No blocked outbound connections on the configured ports/processes were recorded.' + Write-Host ' (If CFSClean2 still reports violations, widen -Ports/-Process or inspect all 5157 events.)' + Write-Host '===== End network-connection attribution =====' + return +} + +$rows | Format-Table UtcTime, LocalTime, EventId, Pid, Image, DestAddr, DestPort, Direction -AutoSize | + Out-String -Width 4096 | Write-Host + +Write-Host "Total matching blocked connections: $($rows.Count)" +Write-Host 'Correlate UtcTime values against the pipeline timeline (step startTime/finishTime) to attribute each connection.' +Write-Host '===== End network-connection attribution =====' From 685cde24c94f899b168251f204b126542060a42a Mon Sep 17 00:00:00 2001 From: Givinalis Omachar Date: Wed, 22 Jul 2026 11:29:36 +0300 Subject: [PATCH 6/6] Capture WFP audit-mode permits (5156) and group gallery destinations The first WFP run found no 5157/5152 events: netiso runs in audit mode, so the gallery connection is permitted (event 5156) but still reported. Read 5156 too, filter destination port at the XPath layer to avoid materialising every permit event, print the effective audit setting and a grouped destination summary that surfaces the public-gallery front-door IP with per-connection timestamps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/Report-GalleryNetwork.ps1 | 110 +++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 39 deletions(-) diff --git a/build/Report-GalleryNetwork.ps1 b/build/Report-GalleryNetwork.ps1 index e4dd0ec99..21a34f6a9 100644 --- a/build/Report-GalleryNetwork.ps1 +++ b/build/Report-GalleryNetwork.ps1 @@ -4,27 +4,29 @@ <# .SYNOPSIS - Attributes blocked public-gallery network connections to a build step for - CFSClean2 investigation. + 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 also fails, because the netiso + 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 blocked TCP connection still fires. + 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. A blocked egress produces Security - event 5157 (and packet drops produce 5152) containing the process image name, - PID, destination address/port and timestamp. This script reads the Security log - at the end of the job and prints every blocked outbound connection to port 443 - from pwsh.exe (the process the CFSClean2 report attributes the violation to), - including the event time (local and UTC). - - Correlate the UTC timestamps below against the pipeline timeline's per-step - start/finish times (each build step runs in its own pwsh process with a distinct - startTime/finishTime) to identify which step issued the connection, then + 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 @@ -37,9 +39,9 @@ param( # Destination ports of interest (gallery is HTTPS/443). [int[]] $Ports = @(443, 80), - # Process image names of interest; the CFSClean2 report attributes the - # violation to pwsh.exe. - [string[]] $Process = @('pwsh.exe', 'powershell.exe', 'dotnet.exe', 'nuget.exe') + # 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' @@ -50,28 +52,39 @@ if (-not $Repository -or $Repository -eq 'PSGallery') { } Write-Host '===== Public gallery network-connection attribution =====' -Write-Host "Reading Security log for blocked WFP connections (event 5157/5152) on ports: $($Ports -join ', ')" -# WFP audit event 5157 = "The Windows Filtering Platform has blocked a connection". -# Event 5152 = "blocked a packet". Both carry Application, ProcessID, and the -# Source/Destination address + port fields. -$events = $null +# Show the effective audit policy so we can tell whether auditing was actually on. try { - $events = Get-WinEvent -FilterHashtable @{ LogName = 'Security'; Id = @(5157, 5152) } -ErrorAction Stop + Write-Host '--- Effective WFP audit policy ---' + & auditpol.exe /get /subcategory:'Filtering Platform Connection' 2>&1 | ForEach-Object { Write-Host " $_" } } catch { - Write-Host " (Unable to read Security log for WFP events: $($_.Exception.Message))" - Write-Host ' Ensure the build agent is elevated and auditpol enabled the subcategory.' - Write-Host '===== End network-connection attribution =====' - return + Write-Host " (auditpol /get failed: $($_.Exception.Message))" } -if (-not $events) { - Write-Host ' No blocked WFP connection events were recorded.' +$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 } @@ -79,7 +92,7 @@ function Get-EventField { return '' } -$rows = foreach ($e in ($events | Sort-Object TimeCreated)) { +$rows = foreach ($e in $events) { $xml = [xml]$e.ToXml() $app = Get-EventField $xml 'Application' $destPort = Get-EventField $xml 'DestPort' @@ -89,13 +102,11 @@ $rows = foreach ($e in ($events | Sort-Object TimeCreated)) { $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 } - if ($Process -and $imageName -and ($Process -notcontains $imageName)) { continue } [PSCustomObject]@{ + Time = $e.TimeCreated UtcTime = $e.TimeCreated.ToUniversalTime().ToString('o') - LocalTime = $e.TimeCreated.ToString('o') EventId = $e.Id Pid = $procId Image = $imageName @@ -106,15 +117,36 @@ $rows = foreach ($e in ($events | Sort-Object TimeCreated)) { } if (-not $rows) { - Write-Host ' No blocked outbound connections on the configured ports/processes were recorded.' - Write-Host ' (If CFSClean2 still reports violations, widen -Ports/-Process or inspect all 5157 events.)' + Write-Host " No outbound connections on ports $($Ports -join ',') were audited." Write-Host '===== End network-connection attribution =====' return } -$rows | Format-Table UtcTime, LocalTime, EventId, Pid, Image, DestAddr, DestPort, Direction -AutoSize | - Out-String -Width 4096 | Write-Host +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 "Total matching blocked connections: $($rows.Count)" -Write-Host 'Correlate UtcTime values against the pipeline timeline (step startTime/finishTime) to attribute each connection.' Write-Host '===== End network-connection attribution ====='