diff --git a/Get-DSAManifest.ps1 b/Get-DSAManifest.ps1 index aee33e4..27d999a 100644 --- a/Get-DSAManifest.ps1 +++ b/Get-DSAManifest.ps1 @@ -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 ) { diff --git a/README.md b/README.md index bceff4f..d574ab7 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/Tools.ps1 b/Tools.ps1 index 3e45948..1886407 100644 --- a/Tools.ps1 +++ b/Tools.ps1 @@ -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 } diff --git a/rmm/Get-DSAManifest.ps1 b/rmm/Get-DSAManifest.ps1 new file mode 100644 index 0000000..58d9a43 --- /dev/null +++ b/rmm/Get-DSAManifest.ps1 @@ -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 diff --git a/tests/Download-File.Tests.ps1 b/tests/Download-File.Tests.ps1 new file mode 100644 index 0000000..8ba9781 --- /dev/null +++ b/tests/Download-File.Tests.ps1 @@ -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 +} diff --git a/tests/Get-DSAManifest.Tests.ps1 b/tests/Get-DSAManifest.Tests.ps1 index b5310f0..891aa2d 100644 --- a/tests/Get-DSAManifest.Tests.ps1 +++ b/tests/Get-DSAManifest.Tests.ps1 @@ -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." }