stop on BITS download errors

reject 0 byte dsa-collector.exe before launching
This commit is contained in:
2026-09-30 09:46:37 -04:00
parent 35c1904d0d
commit 4e6c35a8f4
6 changed files with 119 additions and 5 deletions
+3
View File
@@ -60,6 +60,9 @@ If ( [string]::IsNullOrWhiteSpace([string]$DsaDownloadedFile) -or
!(Test-Path -LiteralPath $DsaExecutablePath -PathType Leaf) ) {
Throw "Failed to download dsa-collect.exe to ${DsaExecutablePath}"
}
If ( (Get-Item -LiteralPath $DsaExecutablePath -ErrorAction Stop).Length -eq 0 ) {
Throw "The downloaded dsa-collect.exe is 0 bytes: ${DsaExecutablePath}. The collector was not started. Check the download response and client TLS/BITS settings."
}
$DsaArguments = @('--source', [string]$DSAManifestSource, '--output', [string]$DSAManifestOutput)
If ( $DsaZipEnabled ) {
+10 -1
View File
@@ -1,13 +1,21 @@
# Management Scripts
Most scripts here require the Tools.ps1 script. You can dot source that script like this:
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
```powershell
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
. $([Scriptblock]::Create((New-Object Net.WebClient).DownloadString('https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1')))
```
On older Windows PowerShell/.NET configurations, select TLS 1.2 **before** the first HTTPS download. The TLS setting inside `Tools.ps1` cannot help download `Tools.ps1` itself. See [Microsoft's PowerShell TLS guidance](https://learn.microsoft.com/en-us/security/engineering/solving-tls1-problem#update-windows-powershell-scripts-or-related-registry-settings).
You can then run any of the scripts using the Run-Script function:
Run-Script -LivePSScript Script-Name
If you're using an RMM tool, the scripts in the /rmm directory are all that's needed to hook the main script in this directory.
For DSA collection, paste [rmm/Get-DSAManifest.ps1](rmm/Get-DSAManifest.ps1) into the RMM caller, preserving its configured platform variables. This caller selects TLS 1.2 before downloading, stops on bootstrap failure, and dot-sources the downloaded tools in the caller's scope. Updating the repository alone will not change an existing RMM caller.
## Uploading files
After dot-sourcing `Tools.ps1`, scripts can upload a completed file to the Emberkom FileDrop:
@@ -31,4 +39,5 @@ Offline tests (no uploads or notifications):
```powershell
powershell.exe -NoProfile -File .\tests\Upload-File.Tests.ps1
powershell.exe -NoProfile -File .\tests\Get-DSAManifest.Tests.ps1
powershell.exe -NoProfile -File .\tests\Download-File.Tests.ps1
```
+3 -3
View File
@@ -203,9 +203,9 @@ Function Download-File {
$URI = Get-AbsoluteURI -URL $URL
If ( $null -eq $URI ) { Return }
If ( [string]::IsNullOrEmpty($File) ) { $File = '{0}{1}' -f (Get-TempPath), (Split-Path $URI -Leaf) }
#Try { (New-Object System.Net.WebClient).DownloadFile($URI,$File) }
Try { Start-BitsTransfer -Source $URI -Destination $File }
Catch { Write-Error $_.Exception.Message ; Return }
# BITS errors must stop the caller instead of returning a failed download's path.
Try { Start-BitsTransfer -Source $URI -Destination $File -ErrorAction Stop }
Catch { Throw "BITS download failed from ${URI} to ${File}: $($_.Exception.Message)" }
Return $File
}
+23
View File
@@ -0,0 +1,23 @@
#Requires -Version 5.0
# Syncro supplies DSAManifestSource, Zip, Upload, and DeleteAfterUpload.
# Preserve those caller variables for the repository script.
# This must run before downloading Tools.ps1, including on Server 2016.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$RmmToolsUrl = 'https://dev.emberkom.com/emberkom/management-scripts/raw/branch/master/Tools.ps1'
$RmmToolsClient = New-Object Net.WebClient
Try {
$RmmToolsText = $RmmToolsClient.DownloadString($RmmToolsUrl)
$RmmToolsScript = [Scriptblock]::Create($RmmToolsText)
}
Catch {
Throw "Unable to download or parse Tools.ps1 from ${RmmToolsUrl}: $($_.Exception.Message)"
}
Finally {
$RmmToolsClient.Dispose()
}
# Dot-source in this scope so the RMM input variables remain accessible.
. $RmmToolsScript
Run-Script -LivePSScript Get-DSAManifest
+64
View File
@@ -0,0 +1,64 @@
#Requires -Version 5.0
# Offline regression tests. BITS and URL resolution are mocked.
$ErrorActionPreference = 'Stop'
$DownloadToolsPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Tools.ps1'
$DownloadTokens = $null
$DownloadParseErrors = $null
$DownloadAst = [Management.Automation.Language.Parser]::ParseFile($DownloadToolsPath, [ref]$DownloadTokens, [ref]$DownloadParseErrors)
If ($DownloadParseErrors.Count) { Throw ($DownloadParseErrors | Out-String) }
$DownloadDefinition = $DownloadAst.Find({
param($Node)
$Node -is [Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq 'Download-File'
}, $true)
. ([scriptblock]::Create($DownloadDefinition.Extent.Text))
Function Get-AbsoluteURI { param($URL) Return $URL }
Function Start-BitsTransfer {
[CmdletBinding()]
param($Source, $Destination)
If ($script:DownloadTestMode -eq 'success') {
[IO.File]::WriteAllText($Destination, 'Synthetic download.')
Return
}
[IO.File]::WriteAllBytes($Destination, [byte[]]@())
If ($script:DownloadTestMode -eq 'terminating-error') { Throw 'Synthetic BITS connection failure.' }
Microsoft.PowerShell.Utility\Write-Error 'Synthetic BITS TLS failure.'
}
$DownloadTestRoot = Join-Path ([IO.Path]::GetTempPath()) ('download-file-tests-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -Path $DownloadTestRoot -ItemType Directory
Try {
$DownloadTestFile = Join-Path $DownloadTestRoot 'collector $test.exe'
$script:DownloadTestMode = 'success'
$DownloadResult = Download-File -URL 'https://example.invalid/collector.exe' -File $DownloadTestFile
If ($DownloadResult -cne $DownloadTestFile -or [IO.File]::ReadAllText($DownloadResult) -cne 'Synthetic download.') {
Throw 'Successful download did not return the completed file.'
}
Write-Host 'PASS: successful download returns its destination.'
Foreach ($script:DownloadTestMode in @('nonterminating-error','terminating-error')) {
# Match an RMM that uses the normal Continue preference and does not pass -ErrorAction.
$ErrorActionPreference = 'Continue'
$DownloadCaught = $null
$DownloadReturnedPaths = @()
Try {
Download-File -URL 'https://example.invalid/collector.exe' -File $DownloadTestFile |
ForEach-Object { $DownloadReturnedPaths += $_ }
}
Catch { $DownloadCaught = $_ }
Finally { $ErrorActionPreference = 'Stop' }
If ($null -eq $DownloadCaught -or $DownloadCaught.Exception.Message -notlike '*Synthetic BITS*failure*') {
Throw 'Failed transfer must throw and preserve the BITS error.'
}
If ($DownloadReturnedPaths.Count -ne 0) { Throw 'Failed transfer returned a success path.' }
Write-Host "PASS: $script:DownloadTestMode stops the caller and preserves the transfer error."
}
Write-Host "All Download-File tests passed on PowerShell $($PSVersionTable.PSVersion), $([IntPtr]::Size * 8)-bit."
}
Finally {
$DownloadResolvedRoot = (Resolve-Path -LiteralPath $DownloadTestRoot).Path
$DownloadTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
If (!$DownloadResolvedRoot.StartsWith($DownloadTempRoot, [StringComparison]::OrdinalIgnoreCase) -or
(Split-Path $DownloadResolvedRoot -Leaf) -notlike 'download-file-tests-*') { Throw 'Unexpected cleanup directory.' }
Remove-Item -LiteralPath $DownloadResolvedRoot -Recurse -Force
}
+16 -1
View File
@@ -13,12 +13,18 @@ Function Download-File {
[CmdletBinding()]
param($URL, $File)
$script:DsaDownloadCalls++
If ($script:DsaTestMode -eq 'empty-download') {
[IO.File]::WriteAllBytes($File, [byte[]]@())
Return $File
}
If ($script:DsaTestMode -eq 'download-failure') { Throw 'Simulated BITS failure.' }
[IO.File]::WriteAllText($File, 'Mock executable; never executed.')
Return $File
}
Function Start-Process {
[CmdletBinding()]
param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru, $WindowStyle)
$script:DsaProcessCalls++
$DsaMatch = [regex]::Match($ArgumentList, '"--output"\s+"([^"]+)"')
If (!$DsaMatch.Success) { Throw 'Collector output argument missing.' }
$script:DsaCsvPath = $DsaMatch.Groups[1].Value
@@ -64,6 +70,7 @@ Function Invoke-DsaCase {
$script:DsaTestMode = $Mode
$script:DsaRecordedUploads = @()
$script:DsaDownloadCalls = 0
$script:DsaProcessCalls = 0
$script:DsaGeneratedPath = $null
$DsaCaught = $null
Try { . $DsaScriptUnderTest | Out-Null } Catch { $DsaCaught = $_ }
@@ -73,6 +80,14 @@ Function Invoke-DsaCase {
Assert-Dsa ($script:DsaDownloadCalls -eq 0) 'Invalid flag must fail before downloading.'
Return
}
If ($Mode -in @('empty-download','download-failure')) {
Assert-Dsa ($null -ne $DsaCaught) 'Download failure must stop collection.'
Assert-Dsa ($script:DsaProcessCalls -eq 0 -and $script:DsaRecordedUploads.Count -eq 0) 'Download failure must prevent execution and upload.'
If ($Mode -eq 'empty-download') {
Assert-Dsa ($DsaCaught.Exception.Message -like '*0 bytes*') 'Empty download error must explain the failure.'
}
Return
}
If ($Mode -in @('collector-failure','missing-output')) {
Assert-Dsa ($null -ne $DsaCaught -and $script:DsaRecordedUploads.Count -eq 0) 'Collection failure must prevent upload.'
Return
@@ -107,7 +122,7 @@ Try {
Write-Host "PASS: ZIP=$ZipExpected Upload=$UploadExpected DeleteAfterUpload=$DeleteExpected"
}
}
Foreach ($Mode in @('upload-failure','no-receipt','incomplete-receipt','collector-failure','missing-output')) {
Foreach ($Mode in @('upload-failure','no-receipt','incomplete-receipt','collector-failure','missing-output','empty-download','download-failure')) {
Invoke-DsaCase $ZipExpected $true $true $Mode
Write-Host "PASS: ZIP=$ZipExpected $Mode prevents deletion."
}