Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .azure-pipelines/generation-templates/generate_adapter-1es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}

28 changes: 28 additions & 0 deletions .azure-pipelines/generation-templates/generate_adapter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}

161 changes: 161 additions & 0 deletions build/Remove-PublicPSGallery.ps1
Original file line number Diff line number Diff line change
@@ -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))"
}
152 changes: 152 additions & 0 deletions build/Report-GalleryNetwork.ps1
Original file line number Diff line number Diff line change
@@ -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 = @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">*[System[(EventID=5156 or EventID=5157 or EventID=5152)]] and (*[EventData[$portPredicate]])</Select>
</Query>
</QueryList>
"@
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 ====='