From 2b4e8219feee185a756dcd127a0b0c3089b20cf3 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Mon, 22 Jun 2026 19:26:02 -0700 Subject: [PATCH 01/10] Add nightly Attack Surface Analyzer install-diff Runs ASA against the freshly built wsl.msi on a clean agent to verify the installer does not weaken OS security configuration (Continuous SDL). Findings are triaged against a checked-in allowlist and published as SARIF. Non-gating initially. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/asa-stage.yml | 75 ++++++++ .pipelines/wsl-build-nightly-onebranch.yml | 8 +- tools/devops/Run-AsaInstallDiff.ps1 | 205 +++++++++++++++++++++ tools/devops/asa-expected-findings.json | 68 +++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 .pipelines/asa-stage.yml create mode 100644 tools/devops/Run-AsaInstallDiff.ps1 create mode 100644 tools/devops/asa-expected-findings.json diff --git a/.pipelines/asa-stage.yml b/.pipelines/asa-stage.yml new file mode 100644 index 0000000000..bb30bb2869 --- /dev/null +++ b/.pipelines/asa-stage.yml @@ -0,0 +1,75 @@ +# Attack Surface Analyzer (ASA) install-diff stage. +# +# Runs on a clean Windows agent: installs the freshly built wsl.msi and uses ASA +# to verify the installer does not weaken the OS security configuration. Findings +# are triaged against tools/devops/asa-expected-findings.json; the SARIF and the +# net-new report are published as artifacts. +# +# Introduced as NON-GATING (failOnNewFindings: false). Once the allowlist is +# confirmed stable, flip failOnNewFindings to true to make it a hard gate. + +parameters: + - name: pool + type: string + default: '' + + # When false, the stage reports findings but does not fail the build. + - name: failOnNewFindings + type: boolean + default: false + + # WSL-built PE binaries are unsigned on dev/nightly builds (ESRP signing runs on + # release). Allow them so the gate only flags genuinely new surface. + - name: allowUnsignedWslBinaries + type: boolean + default: true + +stages: + - stage: asa + displayName: "Attack Surface Analyzer" + dependsOn: [build_x64] + jobs: + - job: asa_install_diff + displayName: "ASA install-diff (x64)" + timeoutInMinutes: 60 + + variables: + ob_outputDirectory: '$(Build.SourcesDirectory)\out' + ob_artifactBaseName: 'drop_wsl' + ob_artifactSuffix: '_asa' + + ${{ if eq(parameters.pool, '') }}: + pool: {'type': 'windows'} + ${{ else }}: + pool: ${{ parameters.pool }} + + steps: + - task: DownloadPipelineArtifact@2 + displayName: Download x64 build artifact + inputs: + artifact: "drop_wsl_build" + path: $(Pipeline.Workspace)\drop_x64 + + - task: UseDotNet@2 + displayName: Install .NET SDK (for ASA CLI) + inputs: + packageType: sdk + version: '8.x' + + - task: PowerShell@2 + displayName: Run ASA install-diff + inputs: + targetType: filePath + filePath: tools/devops/Run-AsaInstallDiff.ps1 + arguments: >- + -MsiPath (Get-ChildItem '$(Pipeline.Workspace)\drop_x64\bundle\wsl.*.x64.msi' | Select-Object -First 1 -ExpandProperty FullName) + -WorkDir '$(ob_outputDirectory)\asa' + -AllowUnsignedWslBinaries:$${{ parameters.allowUnsignedWslBinaries }} + -FailOnNewFindings:$${{ parameters.failOnNewFindings }} + + - task: PublishPipelineArtifact@1 + displayName: Publish ASA report + condition: always() + inputs: + targetPath: $(ob_outputDirectory)\asa + artifactName: $(ob_artifactBaseName)$(ob_artifactSuffix) diff --git a/.pipelines/wsl-build-nightly-onebranch.yml b/.pipelines/wsl-build-nightly-onebranch.yml index 1434dd233d..4e921361eb 100644 --- a/.pipelines/wsl-build-nightly-onebranch.yml +++ b/.pipelines/wsl-build-nightly-onebranch.yml @@ -53,4 +53,10 @@ extends: - template: nuget-stage.yml@self parameters: - isNightly: true \ No newline at end of file + isNightly: true + + - template: asa-stage.yml@self + parameters: + # Non-gating for now: publishes ASA findings without failing the build. + # Flip to true once the allowlist in tools/devops/asa-expected-findings.json is stable. + failOnNewFindings: false \ No newline at end of file diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 new file mode 100644 index 0000000000..d606d399c0 --- /dev/null +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# +# Run-AsaInstallDiff.ps1 +# +# Attack Surface Analyzer (ASA) install-diff for the WSL MSI, intended to run on a +# clean Windows agent (CI nightly) but also usable locally. It: +# 1. (optionally) uninstalls any pre-existing WSL MSI to get a clean baseline, +# 2. collects a baseline snapshot, +# 3. installs the supplied wsl.msi, +# 4. collects a second snapshot, +# 5. exports the diff as SARIF + JSON, +# 6. filters ASA analyzer findings through tools/devops/asa-expected-findings.json, +# 7. writes a net-new findings report and (optionally) fails if any remain. +# +# This satisfies the Continuous SDL requirement that installers / high-privilege +# programs do not weaken the OS security configuration. + +[CmdletBinding()] +param( + # Path to the wsl.msi to install and analyze. + [Parameter(Mandatory = $true)] + [string] $MsiPath, + + # Working directory for the ASA database and exported reports. + [string] $WorkDir = (Join-Path $env:TEMP 'wsl-asa'), + + # Allowlist of known-benign findings. + [string] $ExpectedFindingsPath = (Join-Path $PSScriptRoot 'asa-expected-findings.json'), + + # Directory the installer deploys to (scopes the file-system collector). + [string] $InstallDir = 'C:\Program Files\WSL', + + # Treat all unsigned WSL PE binaries under $InstallDir as expected. Use for + # unsigned dev/nightly builds where ESRP signing has not run. + [switch] $AllowUnsignedWslBinaries, + + # Exit non-zero when net-new (non-allowlisted) findings remain. Off by default + # so the stage can be introduced as non-gating, then flipped on once clean. + [switch] $FailOnNewFindings +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not (Test-Path $MsiPath)) { throw "MSI not found: $MsiPath" } +$MsiPath = (Resolve-Path $MsiPath).Path +New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + +$db = Join-Path $WorkDir 'asa.sqlite' +$installLog = Join-Path $WorkDir 'wsl-msi-install.log' +$sarifPath = Join-Path $WorkDir 'wsl_clean_vs_wsl_after_summary.Sarif' +$reportPath = Join-Path $WorkDir 'asa-net-new-findings.json' +$collectors = @('-c', '-C', '-d', '-F', '-p', '-r', '-s', '-u', '-f', '--directories', $InstallDir) + +function Test-Admin { + $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-Admin)) { + throw 'Run-AsaInstallDiff.ps1 must run elevated (ASA needs admin to read ACLs and install the MSI).' +} + +# --- Ensure the ASA CLI is available --- +$asa = Get-Command asa -ErrorAction SilentlyContinue +if (-not $asa) { + $toolPath = Join-Path $env:USERPROFILE '.dotnet\tools\asa.exe' + if (-not (Test-Path $toolPath)) { + Write-Host '=== Installing Attack Surface Analyzer CLI ===' -ForegroundColor Cyan + & dotnet tool install --global Microsoft.CST.AttackSurfaceAnalyzer.CLI + if ($LASTEXITCODE -ne 0) { throw "Failed to install ASA CLI ($LASTEXITCODE)" } + } + $asa = $toolPath +} +else { + $asa = $asa.Source +} +Write-Host "Using ASA: $asa" + +function Get-WslMsiProductCodes { + $codes = @() + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + foreach ($r in $roots) { + Get-ItemProperty $r -ErrorAction SilentlyContinue | Where-Object { + $_.DisplayName -match 'Windows Subsystem for Linux' -and $_.WindowsInstaller -eq 1 + } | ForEach-Object { $codes += $_.PSChildName } + } + $codes | Select-Object -Unique +} + +# --- [0] Clean baseline: remove any pre-existing WSL MSI --- +Write-Host '=== [0/5] Ensuring clean baseline ===' -ForegroundColor Cyan +& wsl.exe --shutdown 2>$null +foreach ($code in (Get-WslMsiProductCodes)) { + Write-Host "Uninstalling existing WSL product $code" + $u = Start-Process msiexec.exe -ArgumentList @('/x', $code, '/qn', '/norestart') -Wait -PassThru + Write-Host " msiexec /x exit: $($u.ExitCode)" +} +Start-Sleep -Seconds 5 + +# --- [1] Baseline collect --- +Write-Host '=== [1/5] Baseline collect (wsl_clean) ===' -ForegroundColor Cyan +& $asa collect --runid wsl_clean @collectors --databasefilename $db --overwrite +if ($LASTEXITCODE -ne 0) { throw "Baseline collect failed ($LASTEXITCODE)" } + +# --- [2] Install --- +Write-Host "=== [2/5] Installing $MsiPath ===" -ForegroundColor Cyan +$p = Start-Process msiexec.exe -ArgumentList @('/i', "`"$MsiPath`"", '/qn', '/norestart', '/l*v', "`"$installLog`"") -Wait -PassThru +Write-Host "msiexec /i exit code: $($p.ExitCode)" +if ($p.ExitCode -notin @(0, 3010, 1641)) { + throw "MSI install failed ($($p.ExitCode)); see $installLog" +} + +# --- [3] Second collect --- +Write-Host '=== [3/5] Second collect (wsl_after) ===' -ForegroundColor Cyan +& $asa collect --runid wsl_after @collectors --databasefilename $db --overwrite +if ($LASTEXITCODE -ne 0) { throw "Second collect failed ($LASTEXITCODE)" } + +# --- [4] Export diff --- +Write-Host '=== [4/5] Export diff (SARIF + JSON) ===' -ForegroundColor Cyan +& $asa export-collect --firstrunid wsl_clean --secondrunid wsl_after --databasefilename $db --outputpath $WorkDir +& $asa export-collect --firstrunid wsl_clean --secondrunid wsl_after --databasefilename $db --outputpath $WorkDir --outputsarif +if (-not (Test-Path $sarifPath)) { throw "Expected SARIF not produced at $sarifPath" } + +# --- [5] Filter findings through the allowlist --- +Write-Host '=== [5/5] Triage findings against allowlist ===' -ForegroundColor Cyan +$expected = Get-Content $ExpectedFindingsPath -Raw | ConvertFrom-Json +$sarif = Get-Content $sarifPath -Raw | ConvertFrom-Json -AsHashtable +$results = $sarif.runs[0].results + +function Convert-GlobToRegex([string] $glob) { + $escaped = [Regex]::Escape($glob) -replace '\\\*', '.*' -replace '\\\?', '.' + return '^' + $escaped + '$' +} + +function Get-FindingPath([string] $text) { + # Messages look like "Missing ASLR: C:\path\file (CREATED)". + if ($text -match ':\s*(.+?)\s*\([A-Z]+\)\s*$') { return $Matches[1].Trim() } + $idx = $text.IndexOf(':') + if ($idx -ge 0) { return $text.Substring($idx + 1).Trim() } + return $text.Trim() +} + +function Test-Expected([string] $ruleId, [string] $path) { + if ($expected.ignoreRules -contains $ruleId) { return $true } + $norm = $path.Replace('\', '/') + foreach ($entry in $expected.expected) { + if ($entry.ruleId -ne $ruleId) { continue } + foreach ($g in $entry.pathGlobs) { + $rx = Convert-GlobToRegex ($g.Replace('\', '/')) + if ($norm -match $rx) { return $true } + } + } + if ($AllowUnsignedWslBinaries -and $ruleId -eq 'Unsigned binaries') { + $instNorm = $InstallDir.Replace('\', '/') + if ($norm.StartsWith($instNorm, [StringComparison]::OrdinalIgnoreCase)) { return $true } + } + return $false +} + +$netNew = @() +$allowed = @() +foreach ($r in $results) { + $ruleId = if ($r.ContainsKey('ruleId')) { [string]$r.ruleId } else { 'Default Level' } + $text = if ($r.ContainsKey('message') -and $r.message.ContainsKey('text')) { [string]$r.message.text } else { '' } + $path = Get-FindingPath $text + $record = [ordered]@{ ruleId = $ruleId; path = $path; message = $text } + if (Test-Expected $ruleId $path) { $allowed += $record } else { $netNew += $record } +} + +$netNew = $netNew | Sort-Object { $_.ruleId }, { $_.path } -Unique + +$summary = [ordered]@{ + msi = $MsiPath + totalResults = $results.Count + allowlistedCount = $allowed.Count + netNewCount = $netNew.Count + netNew = $netNew +} +$summary | ConvertTo-Json -Depth 6 | Set-Content $reportPath -Encoding UTF8 + +Write-Host '' +Write-Host "ASA results: $($results.Count) total, $($allowed.Count) allowlisted, $($netNew.Count) net-new." -ForegroundColor Green +Write-Host "SARIF: $sarifPath" +Write-Host "Report: $reportPath" + +if ($netNew.Count -gt 0) { + Write-Host '' + Write-Warning "$($netNew.Count) net-new ASA finding(s) not covered by the allowlist:" + $netNew | Group-Object { $_.ruleId } | ForEach-Object { + Write-Host (" [{0}] x{1}" -f $_.Name, $_.Count) -ForegroundColor Yellow + $_.Group | Select-Object -First 25 | ForEach-Object { Write-Host " $($_.path)" } + } + Write-Host '' + Write-Host 'Review each finding. If benign, add it to tools/devops/asa-expected-findings.json with a rationale; otherwise fix the installer/code.' + if ($FailOnNewFindings) { + throw "ASA install-diff found $($netNew.Count) net-new finding(s)." + } +} +else { + Write-Host 'No net-new attack-surface findings. Install-diff is clean against the allowlist.' -ForegroundColor Green +} diff --git a/tools/devops/asa-expected-findings.json b/tools/devops/asa-expected-findings.json new file mode 100644 index 0000000000..f9d70e9a87 --- /dev/null +++ b/tools/devops/asa-expected-findings.json @@ -0,0 +1,68 @@ +{ + "$comment": "Attack Surface Analyzer (ASA) install-diff allowlist for WSL. Each entry documents a category of ASA finding that has been reviewed and determined to be benign, so the nightly ASA gate only fires on net-new, genuine attack-surface changes. Reviewed 2026-06-22 against a clean-VM install-diff of wsl.msi. See tools/devops/Run-AsaInstallDiff.ps1.", + "ignoreRules": [ + "Default Level", + "Registry Keys Frequently Modified by Windows" + ], + "expected": [ + { + "ruleId": "Missing ASLR", + "pathGlobs": [ + "*/lib/libd3d12.so", + "*/lib/libd3d12core.so", + "*/lib/libdxcore.so", + "*/tools/bsdtar", + "*/tools/init", + "*/tools/kernel" + ], + "rationale": "Linux guest ELF artifacts. ASA evaluates the Windows PE DllCharacteristics ASLR bit, which does not apply to ELF binaries that execute inside the Linux VM; those rely on Linux kernel ASLR." + }, + { + "ruleId": "Missing DEP", + "pathGlobs": [ + "*/lib/libd3d12.so", + "*/lib/libd3d12core.so", + "*/lib/libdxcore.so", + "*/tools/bsdtar", + "*/tools/init", + "*/tools/kernel" + ], + "rationale": "Linux guest ELF artifacts. ASA evaluates the Windows PE NX/DEP bit, which does not apply to ELF binaries that execute inside the Linux VM; those rely on Linux kernel NX." + }, + { + "ruleId": "Unsigned binaries", + "pathGlobs": [ + "*/tools/kernel" + ], + "rationale": "The Linux guest kernel is an ELF image and is not Authenticode-signable. WSL-built PE binaries are signed via the ESRP pipeline on release/nightly builds; for unsigned dev builds pass -AllowUnsignedWslBinaries to the runbook." + }, + { + "ruleId": "Binaries with expired signatures", + "pathGlobs": [ + "*/msrdc.exe", + "*/rdclientax.dll", + "*/rdpnanoTransport.dll", + "*/RdpWinStlHelper.dll", + "*/msal.wsl.proxy.exe", + "*/*.mui" + ], + "rationale": "Third-party bundled redistributables (the Microsoft Remote Desktop client used by WSLg, and the approved MSAL proxy) plus their localization resources. Their Authenticode signatures remain valid via RFC3161 timestamp countersignature despite the signing certificate having passed its validity date (verified Get-AuthenticodeSignature Status=Valid)." + }, + { + "ruleId": "Modified Services", + "pathGlobs": [ + "wmiApSrv", + "NetSetupSvc", + "AppReadiness" + ], + "rationale": "Incidental Windows host services touched by the OS during MSI installation. Not WSL components." + }, + { + "ruleId": "Open Ports", + "pathGlobs": [ + "*5353*" + ], + "rationale": "UDP 5353 (mDNS) is opened by svchost.exe, an incidental host service, not a WSL listener." + } + ] +} From 22f70e4f1f7c0d6abb453dd978abbbd935718c3b Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Mon, 22 Jun 2026 19:30:53 -0700 Subject: [PATCH 02/10] Fix: OneBranch auto-uploads outputs; guard explicit publish step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/asa-stage.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.pipelines/asa-stage.yml b/.pipelines/asa-stage.yml index bb30bb2869..a227cf796b 100644 --- a/.pipelines/asa-stage.yml +++ b/.pipelines/asa-stage.yml @@ -67,9 +67,13 @@ stages: -AllowUnsignedWslBinaries:$${{ parameters.allowUnsignedWslBinaries }} -FailOnNewFindings:$${{ parameters.failOnNewFindings }} - - task: PublishPipelineArtifact@1 - displayName: Publish ASA report - condition: always() - inputs: - targetPath: $(ob_outputDirectory)\asa - artifactName: $(ob_artifactBaseName)$(ob_artifactSuffix) + # On the OneBranch managed pool, binaries in ob_outputDirectory are + # uploaded automatically; an explicit publish step is rejected. Only + # publish manually when running on a custom (non-managed) pool. + - ${{ if ne(parameters.pool, '') }}: + - task: PublishPipelineArtifact@1 + displayName: Publish ASA report + condition: always() + inputs: + targetPath: $(ob_outputDirectory)\asa + artifactName: $(ob_artifactBaseName)$(ob_artifactSuffix) From 90a153b390a6bab33562162d4669631e14e4dc7e Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 09:38:24 -0700 Subject: [PATCH 03/10] Run nightly ASA install-diff on a clean CloudTest VM instead of the build container The OneBranch build container is network-isolated (NuGet 401) and is the wrong host for installing WSL and measuring OS attack surface. Move ASA to the nightly CloudTest harness: generate an ASA TestGroup/TestMap for one clean client image, stage the runbook + allowlist into the build drop, and run the install-diff via CloudTestServerBuildTask. Non-gating to start (ASA_FAIL_ON_NEW). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/asa-stage.yml | 86 +++++++++------------- .pipelines/build-job.yml | 4 + .pipelines/wsl-build-nightly-onebranch.yml | 7 +- cloudtest/CMakeLists.txt | 27 +++++++ cloudtest/TestGroupAsa.xml.in | 16 ++++ cloudtest/TestMapAsa.xml.in | 13 ++++ tools/devops/Run-AsaInstallDiff.ps1 | 47 +++++++++--- 7 files changed, 132 insertions(+), 68 deletions(-) create mode 100644 cloudtest/TestGroupAsa.xml.in create mode 100644 cloudtest/TestMapAsa.xml.in diff --git a/.pipelines/asa-stage.yml b/.pipelines/asa-stage.yml index a227cf796b..d18eba8a11 100644 --- a/.pipelines/asa-stage.yml +++ b/.pipelines/asa-stage.yml @@ -1,28 +1,27 @@ # Attack Surface Analyzer (ASA) install-diff stage. # -# Runs on a clean Windows agent: installs the freshly built wsl.msi and uses ASA -# to verify the installer does not weaken the OS security configuration. Findings -# are triaged against tools/devops/asa-expected-findings.json; the SARIF and the -# net-new report are published as artifacts. +# Runs the ASA install-diff on a clean CloudTest VM (the same harness used by the +# nightly TAEF tests). The VM installs the freshly built wsl.msi and uses ASA to +# verify the installer does not weaken the OS security configuration. Findings are +# triaged against tools/devops/asa-expected-findings.json (staged into the build +# drop under testbin\devops). The ASA SARIF and the net-new report are written to +# the CloudTest logging directory and uploaded with the test results. # -# Introduced as NON-GATING (failOnNewFindings: false). Once the allowlist is -# confirmed stable, flip failOnNewFindings to true to make it a hard gate. +# Introduced as NON-GATING: the runbook is generated with -FailOnNewFindings:$false +# (cloudtest/CMakeLists.txt ASA_FAIL_ON_NEW). Once the allowlist is confirmed +# stable, flip ASA_FAIL_ON_NEW to "$true" in cloudtest/CMakeLists.txt to make it a +# hard gate. parameters: - name: pool type: string default: '' - # When false, the stage reports findings but does not fail the build. - - name: failOnNewFindings - type: boolean - default: false - - # WSL-built PE binaries are unsigned on dev/nightly builds (ESRP signing runs on - # release). Allow them so the gate only flags genuinely new surface. - - name: allowUnsignedWslBinaries - type: boolean - default: true + # Client image whose generated ASA group is run. Must match the image passed to + # add_asa_group() in cloudtest/CMakeLists.txt. + - name: image + type: string + default: 'wsl-test-image-win11-23h2-ent-2024-11-18' stages: - stage: asa @@ -31,49 +30,30 @@ stages: jobs: - job: asa_install_diff displayName: "ASA install-diff (x64)" - timeoutInMinutes: 60 - + dependsOn: [] variables: ob_outputDirectory: '$(Build.SourcesDirectory)\out' ob_artifactBaseName: 'drop_wsl' ob_artifactSuffix: '_asa' - + timeoutInMinutes: 180 + cancelTimeoutInMinutes: 240 ${{ if eq(parameters.pool, '') }}: - pool: {'type': 'windows'} + pool: {'type': 'cloudtestagentless'} ${{ else }}: pool: ${{ parameters.pool }} - steps: - - task: DownloadPipelineArtifact@2 - displayName: Download x64 build artifact + - task: CloudTestServerBuildTask@2 inputs: - artifact: "drop_wsl_build" - path: $(Pipeline.Workspace)\drop_x64 - - - task: UseDotNet@2 - displayName: Install .NET SDK (for ASA CLI) - inputs: - packageType: sdk - version: '8.x' - - - task: PowerShell@2 - displayName: Run ASA install-diff - inputs: - targetType: filePath - filePath: tools/devops/Run-AsaInstallDiff.ps1 - arguments: >- - -MsiPath (Get-ChildItem '$(Pipeline.Workspace)\drop_x64\bundle\wsl.*.x64.msi' | Select-Object -First 1 -ExpandProperty FullName) - -WorkDir '$(ob_outputDirectory)\asa' - -AllowUnsignedWslBinaries:$${{ parameters.allowUnsignedWslBinaries }} - -FailOnNewFindings:$${{ parameters.failOnNewFindings }} - - # On the OneBranch managed pool, binaries in ob_outputDirectory are - # uploaded automatically; an explicit publish step is rejected. Only - # publish manually when running on a custom (non-managed) pool. - - ${{ if ne(parameters.pool, '') }}: - - task: PublishPipelineArtifact@1 - displayName: Publish ASA report - condition: always() - inputs: - targetPath: $(ob_outputDirectory)\asa - artifactName: $(ob_artifactBaseName)$(ob_artifactSuffix) + DisplayName: "ASA install-diff (x64)" + connectedServiceName: "CloudTest-PROD" + cloudTestTenant: "wsl" + testMapLocation: 'testbin\x64\cloudtest\asa-${{ parameters.image }}\TestMap.xml' + pipelineArtifactName: "drop_wsl_build" + pipelineArtifactBuildUrl: '$(System.TaskDefinitionsUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)' + buildDropArtifactName: "" + timeoutInMinutes: 180 + sessionTimeout: 180 + cancelTimeoutInMinutes: 240 + TestTimeout: "0.02:00:00" + parserProperties: "worker:VsTestVersion=V150;session:HoldTrigger=Failure;VstsTestResultAttachmentUploadBehavior=Always" + notificationSubscribers: $(Build.RequestedForEmail) diff --git a/.pipelines/build-job.yml b/.pipelines/build-job.yml index f883f0b4bb..22f409e797 100644 --- a/.pipelines/build-job.yml +++ b/.pipelines/build-job.yml @@ -313,6 +313,10 @@ jobs: Move-Item -Path "bin\x64\cloudtest" -Destination "$(ob_outputDirectory)\testbin\x64\cloudtest" + mkdir $(ob_outputDirectory)\testbin\devops + Copy-Item -Path "tools\devops\Run-AsaInstallDiff.ps1" -Destination "$(ob_outputDirectory)\testbin\devops\Run-AsaInstallDiff.ps1" + Copy-Item -Path "tools\devops\asa-expected-findings.json" -Destination "$(ob_outputDirectory)\testbin\devops\asa-expected-findings.json" + Move-Item -Path "tools\test\test-setup.ps1" -Destination "$(ob_outputDirectory)\testbin\test-setup.ps1" Move-Item -Path "tools\test\CloudTest-Setup.bat" -Destination "$(ob_outputDirectory)\testbin\CloudTest-Setup.bat" Move-Item -Path "diagnostics\wsl.wprp" -Destination "$(ob_outputDirectory)\testbin\wsl.wprp" diff --git a/.pipelines/wsl-build-nightly-onebranch.yml b/.pipelines/wsl-build-nightly-onebranch.yml index 4e921361eb..ba88014715 100644 --- a/.pipelines/wsl-build-nightly-onebranch.yml +++ b/.pipelines/wsl-build-nightly-onebranch.yml @@ -56,7 +56,6 @@ extends: isNightly: true - template: asa-stage.yml@self - parameters: - # Non-gating for now: publishes ASA findings without failing the build. - # Flip to true once the allowlist in tools/devops/asa-expected-findings.json is stable. - failOnNewFindings: false \ No newline at end of file + # Non-gating for now: ASA publishes findings without failing the build + # (controlled by ASA_FAIL_ON_NEW in cloudtest/CMakeLists.txt). Flip that to + # "$true" once the allowlist in tools/devops/asa-expected-findings.json is stable. \ No newline at end of file diff --git a/cloudtest/CMakeLists.txt b/cloudtest/CMakeLists.txt index 643a069d1c..6f0abe79c3 100644 --- a/cloudtest/CMakeLists.txt +++ b/cloudtest/CMakeLists.txt @@ -51,6 +51,27 @@ function(add_test_group image version suffix filter) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/TestGroup.xml.in ${DIR}/TestGroup.xml) endfunction() +# Attack Surface Analyzer install-diff group. Runs on a single clean client image +# and is generated only for nightly/release builds that publish the wsl.msi bundle. +# Map ALLOW_UNSIGNED_PACKAGE (0/1) to a PowerShell switch literal for the runbook. +if (ALLOW_UNSIGNED_PACKAGE STREQUAL "1") + set(ASA_ALLOW_UNSIGNED "$true") +else() + set(ASA_ALLOW_UNSIGNED "$false") +endif() + +# Non-gating to start: report net-new findings without failing the run. +# Flip to "$true" once the allowlist has been validated against a few nightly runs. +set(ASA_FAIL_ON_NEW "$false") + +function(add_asa_group image) + set(DIR ${OUT}/asa-${image}) + file(MAKE_DIRECTORY ${DIR}) + + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/TestMapAsa.xml.in ${DIR}/TestMap.xml) + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/TestGroupAsa.xml.in ${DIR}/TestGroup.xml) +endfunction() + foreach(image ${CLOUDTEST_IMAGES}) add_test_group("${image}" "1" "wsl1" "not (@TestCategory='WSLC')") add_test_group("${image}" "2" "wsl2" "not (@TestCategory='WSLC')") @@ -58,3 +79,9 @@ foreach(image ${CLOUDTEST_IMAGES}) add_test_group("${image}" "2" "wslc" "@TestCategory='WSLC'") endif() endforeach() + +# The MSI bundle is only staged for nightly/release builds (INCLUDE_PACKAGE_STAGE), +# so only generate the ASA group when that bundle will be present in drop_wsl_build. +if (INCLUDE_PACKAGE_STAGE AND CLOUDTEST_IMAGES) + add_asa_group("wsl-test-image-win11-23h2-ent-2024-11-18") +endif() diff --git a/cloudtest/TestGroupAsa.xml.in b/cloudtest/TestGroupAsa.xml.in new file mode 100644 index 0000000000..8917411f4c --- /dev/null +++ b/cloudtest/TestGroupAsa.xml.in @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/cloudtest/TestMapAsa.xml.in b/cloudtest/TestMapAsa.xml.in new file mode 100644 index 0000000000..5cb3f3f486 --- /dev/null +++ b/cloudtest/TestMapAsa.xml.in @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index d606d399c0..60adf668de 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -62,19 +62,42 @@ if (-not (Test-Admin)) { } # --- Ensure the ASA CLI is available --- -$asa = Get-Command asa -ErrorAction SilentlyContinue -if (-not $asa) { +# ASA is distributed only as a .NET global tool (no prebuilt binaries), so this +# bootstraps the .NET SDK on the fly when the agent/VM does not already have it. +$AsaVersion = '2.3.331' + +function Resolve-Asa { + $cmd = Get-Command asa -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } $toolPath = Join-Path $env:USERPROFILE '.dotnet\tools\asa.exe' - if (-not (Test-Path $toolPath)) { - Write-Host '=== Installing Attack Surface Analyzer CLI ===' -ForegroundColor Cyan - & dotnet tool install --global Microsoft.CST.AttackSurfaceAnalyzer.CLI - if ($LASTEXITCODE -ne 0) { throw "Failed to install ASA CLI ($LASTEXITCODE)" } + if (Test-Path $toolPath) { return $toolPath } + + $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue + if ($dotnet) { + $dotnetExe = $dotnet.Source } - $asa = $toolPath -} -else { - $asa = $asa.Source + else { + Write-Host '=== dotnet not found; bootstrapping .NET 8 SDK ===' -ForegroundColor Cyan + $installDir = Join-Path $env:USERPROFILE '.dotnet' + $installer = Join-Path $env:TEMP 'dotnet-install.ps1' + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer -UseBasicParsing + & $installer -Channel 8.0 -InstallDir $installDir + if ($LASTEXITCODE -ne 0) { throw "dotnet bootstrap failed ($LASTEXITCODE)" } + $dotnetExe = Join-Path $installDir 'dotnet.exe' + } + $env:PATH = (Join-Path $env:USERPROFILE '.dotnet\tools') + ';' + $env:PATH + + Write-Host "=== Installing Attack Surface Analyzer CLI $AsaVersion ===" -ForegroundColor Cyan + & $dotnetExe tool install --global Microsoft.CST.AttackSurfaceAnalyzer.CLI --version $AsaVersion + if ($LASTEXITCODE -ne 0) { throw "Failed to install ASA CLI ($LASTEXITCODE)" } + + if (Test-Path $toolPath) { return $toolPath } + $cmd = Get-Command asa -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + throw 'ASA CLI not found after install.' } + +$asa = Resolve-Asa Write-Host "Using ASA: $asa" function Get-WslMsiProductCodes { @@ -93,7 +116,9 @@ function Get-WslMsiProductCodes { # --- [0] Clean baseline: remove any pre-existing WSL MSI --- Write-Host '=== [0/5] Ensuring clean baseline ===' -ForegroundColor Cyan -& wsl.exe --shutdown 2>$null +if (Get-Command wsl.exe -ErrorAction SilentlyContinue) { + try { & wsl.exe --shutdown } catch { } +} foreach ($code in (Get-WslMsiProductCodes)) { Write-Host "Uninstalling existing WSL product $code" $u = Start-Process msiexec.exe -ArgumentList @('/x', $code, '/qn', '/norestart') -Wait -PassThru From c95512eb8f6b1d854062cda2f73ca684dff08a86 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 11:08:56 -0700 Subject: [PATCH 04/10] Use CloudTest 'Console' execution type for the ASA job CloudTest's V2 schema rejects Type=Executable (session expansion failed with 'Executable is not a valid value for TestExecutionType'). Console runs an arbitrary process and reports pass/fail from its exit code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cloudtest/TestGroupAsa.xml.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudtest/TestGroupAsa.xml.in b/cloudtest/TestGroupAsa.xml.in index 8917411f4c..ab7fc1ba9f 100644 --- a/cloudtest/TestGroupAsa.xml.in +++ b/cloudtest/TestGroupAsa.xml.in @@ -11,6 +11,6 @@ - + From 4d5cda57d6414a26a88aa75fd6f8c39785065d41 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 12:08:22 -0700 Subject: [PATCH 05/10] ASA CloudTest: use Type=Exe + Parser=TRX execution schema Run-AsaInstallDiff.ps1 emits a TRX result file and exits non-zero on failure so CloudTest can parse per-test results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cloudtest/TestGroupAsa.xml.in | 2 +- tools/devops/Run-AsaInstallDiff.ps1 | 72 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/cloudtest/TestGroupAsa.xml.in b/cloudtest/TestGroupAsa.xml.in index ab7fc1ba9f..6e76a1e9b7 100644 --- a/cloudtest/TestGroupAsa.xml.in +++ b/cloudtest/TestGroupAsa.xml.in @@ -11,6 +11,6 @@ - + diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index 60adf668de..a2886ac831 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -36,12 +36,78 @@ param( # Exit non-zero when net-new (non-allowlisted) findings remain. Off by default # so the stage can be introduced as non-gating, then flipped on once clean. - [switch] $FailOnNewFindings + [switch] $FailOnNewFindings, + + # When set, write a minimal TRX result file here so CloudTest's TRX parser can + # surface the ASA job as a single pass/fail test. Empty = skip (local runs). + [string] $TrxPath = '' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# Emit a minimal, well-formed TRX so CloudTest (Parser="TRX") reports the ASA job +# as one pass/fail test. Job-level pass/fail is still driven by the process exit +# code; this just gives a readable result row + stdout in the ADO test tab. +function Write-AsaTrx { + param( + [ValidateSet('Passed', 'Failed')] [string] $Outcome, + [string] $Message = '' + ) + if ([string]::IsNullOrEmpty($TrxPath)) { return } + try { + $dir = Split-Path -Parent $TrxPath + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + $now = (Get-Date).ToString('o') + $comp = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { 'cloudtest' } + $g = { [guid]::NewGuid().ToString() } + $testId = & $g; $execId = & $g; $listId = & $g + $passed = if ($Outcome -eq 'Passed') { 1 } else { 0 } + $failed = if ($Outcome -eq 'Failed') { 1 } else { 0 } + $esc = [System.Security.SecurityElement]::Escape([string]$Message) + $xml = @" + + + + + + + + + + + + + + + + + + + + + + $esc + + + +"@ + Set-Content -Path $TrxPath -Value $xml -Encoding UTF8 + Write-Host "TRX written: $TrxPath ($Outcome)" + } + catch { + Write-Host "WARNING: failed to write TRX: $_" + } +} + +# Any uncaught terminating error (failed collect/install/export, gate failure, +# missing prerequisites) lands here: record a failed TRX and exit non-zero. +trap { + Write-Host "ERROR: $_" + Write-AsaTrx -Outcome 'Failed' -Message ("{0}`n{1}" -f $_, $_.ScriptStackTrace) + exit 1 +} + if (-not (Test-Path $MsiPath)) { throw "MSI not found: $MsiPath" } $MsiPath = (Resolve-Path $MsiPath).Path New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null @@ -228,3 +294,7 @@ if ($netNew.Count -gt 0) { else { Write-Host 'No net-new attack-surface findings. Install-diff is clean against the allowlist.' -ForegroundColor Green } + +# Reached only on success (clean, or net-new while non-gating): report a passing TRX. +Write-AsaTrx -Outcome 'Passed' -Message ("ASA install-diff: {0} total finding(s), {1} allowlisted, {2} net-new." -f $results.Count, $allowed.Count, $netNew.Count) +exit 0 From a831efedab5f695dc85fa59da35ac44b30b254f6 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 13:52:34 -0700 Subject: [PATCH 06/10] ASA CloudTest: fix switch binding and TRX output path powershell.exe -File coerces args to strings and rejects [switch]/[bool] '1'/'0', so the runbook took the boolean flags as [string] and normalizes them. CloudTest only scans [WorkingDirectory]\TestResults for the TRX, so write the result file there instead of [LoggingDirectory]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cloudtest/CMakeLists.txt | 11 ++++++----- cloudtest/TestGroupAsa.xml.in | 2 +- tools/devops/Run-AsaInstallDiff.ps1 | 20 +++++++++++++++----- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/cloudtest/CMakeLists.txt b/cloudtest/CMakeLists.txt index 6f0abe79c3..be1f433b67 100644 --- a/cloudtest/CMakeLists.txt +++ b/cloudtest/CMakeLists.txt @@ -53,16 +53,17 @@ endfunction() # Attack Surface Analyzer install-diff group. Runs on a single clean client image # and is generated only for nightly/release builds that publish the wsl.msi bundle. -# Map ALLOW_UNSIGNED_PACKAGE (0/1) to a PowerShell switch literal for the runbook. +# Map ALLOW_UNSIGNED_PACKAGE (0/1) straight through; the runbook switches accept 1/0. +# (A literal "$true" cannot be bound to a [switch] when passed on a command line.) if (ALLOW_UNSIGNED_PACKAGE STREQUAL "1") - set(ASA_ALLOW_UNSIGNED "$true") + set(ASA_ALLOW_UNSIGNED "1") else() - set(ASA_ALLOW_UNSIGNED "$false") + set(ASA_ALLOW_UNSIGNED "0") endif() # Non-gating to start: report net-new findings without failing the run. -# Flip to "$true" once the allowlist has been validated against a few nightly runs. -set(ASA_FAIL_ON_NEW "$false") +# Flip to "1" once the allowlist has been validated against a few nightly runs. +set(ASA_FAIL_ON_NEW "0") function(add_asa_group image) set(DIR ${OUT}/asa-${image}) diff --git a/cloudtest/TestGroupAsa.xml.in b/cloudtest/TestGroupAsa.xml.in index 6e76a1e9b7..9f0cfb16a6 100644 --- a/cloudtest/TestGroupAsa.xml.in +++ b/cloudtest/TestGroupAsa.xml.in @@ -11,6 +11,6 @@ - + diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index a2886ac831..99c8c065ab 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -31,12 +31,14 @@ param( [string] $InstallDir = 'C:\Program Files\WSL', # Treat all unsigned WSL PE binaries under $InstallDir as expected. Use for - # unsigned dev/nightly builds where ESRP signing has not run. - [switch] $AllowUnsignedWslBinaries, + # unsigned dev/nightly builds where ESRP signing has not run. Accepts 1/0 or + # true/false (string, so it binds correctly when passed via powershell.exe -File). + [string] $AllowUnsignedWslBinaries = '0', # Exit non-zero when net-new (non-allowlisted) findings remain. Off by default # so the stage can be introduced as non-gating, then flipped on once clean. - [switch] $FailOnNewFindings, + # Accepts 1/0 or true/false (string, for the same -File binding reason). + [string] $FailOnNewFindings = '0', # When set, write a minimal TRX result file here so CloudTest's TRX parser can # surface the ASA job as a single pass/fail test. Empty = skip (local runs). @@ -46,6 +48,14 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# The switches arrive as strings (powershell.exe -File coerces all args to string, +# and [switch]/[bool] reject "1"/"0" there). Normalize to real booleans here. +function ConvertTo-AsaBool([string] $value) { + return ($value -eq '1' -or $value -ieq 'true' -or $value -ieq '$true') +} +$allowUnsignedWslBinaries = ConvertTo-AsaBool $AllowUnsignedWslBinaries +$failOnNewFindings = ConvertTo-AsaBool $FailOnNewFindings + # Emit a minimal, well-formed TRX so CloudTest (Parser="TRX") reports the ASA job # as one pass/fail test. Job-level pass/fail is still driven by the process exit # code; this just gives a readable result row + stdout in the ADO test tab. @@ -245,7 +255,7 @@ function Test-Expected([string] $ruleId, [string] $path) { if ($norm -match $rx) { return $true } } } - if ($AllowUnsignedWslBinaries -and $ruleId -eq 'Unsigned binaries') { + if ($allowUnsignedWslBinaries -and $ruleId -eq 'Unsigned binaries') { $instNorm = $InstallDir.Replace('\', '/') if ($norm.StartsWith($instNorm, [StringComparison]::OrdinalIgnoreCase)) { return $true } } @@ -287,7 +297,7 @@ if ($netNew.Count -gt 0) { } Write-Host '' Write-Host 'Review each finding. If benign, add it to tools/devops/asa-expected-findings.json with a rationale; otherwise fix the installer/code.' - if ($FailOnNewFindings) { + if ($failOnNewFindings) { throw "ASA install-diff found $($netNew.Count) net-new finding(s)." } } From 09a7b2cdea8975427ccb86f2f0581d1b5791beea Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 15:02:16 -0700 Subject: [PATCH 07/10] ASA CloudTest: install via self-contained release zip, not dotnet tool Clean VM images have only a runtime-only dotnet host (no SDK), so dotnet tool install fails. Download the self-contained ASA_win zip (v2.3.321, the last tag with prebuilt binaries) instead; no SDK or NuGet feed required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/devops/Run-AsaInstallDiff.ps1 | 43 ++++++++++++----------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index 99c8c065ab..7b96a4cf8b 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -138,39 +138,30 @@ if (-not (Test-Admin)) { } # --- Ensure the ASA CLI is available --- -# ASA is distributed only as a .NET global tool (no prebuilt binaries), so this -# bootstraps the .NET SDK on the fly when the agent/VM does not already have it. -$AsaVersion = '2.3.331' +# ASA ships a self-contained Windows build (no .NET SDK or NuGet feed required), +# so download and unzip it here. This works on a clean VM image where only a +# runtime-only 'dotnet' host (or none) is present. The self-contained zip is only +# published up to 2.3.321; newer tags ship the dotnet global tool only. +$AsaVersion = '2.3.321' +$AsaUrl = "https://github.com/microsoft/AttackSurfaceAnalyzer/releases/download/v$AsaVersion/ASA_win_$AsaVersion.zip" function Resolve-Asa { $cmd = Get-Command asa -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } - $toolPath = Join-Path $env:USERPROFILE '.dotnet\tools\asa.exe' - if (Test-Path $toolPath) { return $toolPath } - $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue - if ($dotnet) { - $dotnetExe = $dotnet.Source - } - else { - Write-Host '=== dotnet not found; bootstrapping .NET 8 SDK ===' -ForegroundColor Cyan - $installDir = Join-Path $env:USERPROFILE '.dotnet' - $installer = Join-Path $env:TEMP 'dotnet-install.ps1' - Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer -UseBasicParsing - & $installer -Channel 8.0 -InstallDir $installDir - if ($LASTEXITCODE -ne 0) { throw "dotnet bootstrap failed ($LASTEXITCODE)" } - $dotnetExe = Join-Path $installDir 'dotnet.exe' - } - $env:PATH = (Join-Path $env:USERPROFILE '.dotnet\tools') + ';' + $env:PATH + $toolsDir = Join-Path $WorkDir 'asa-cli' + $exe = Join-Path $toolsDir "ASA_win_$AsaVersion\Asa.exe" + if (Test-Path $exe) { return $exe } - Write-Host "=== Installing Attack Surface Analyzer CLI $AsaVersion ===" -ForegroundColor Cyan - & $dotnetExe tool install --global Microsoft.CST.AttackSurfaceAnalyzer.CLI --version $AsaVersion - if ($LASTEXITCODE -ne 0) { throw "Failed to install ASA CLI ($LASTEXITCODE)" } + Write-Host "=== Downloading Attack Surface Analyzer CLI $AsaVersion ===" -ForegroundColor Cyan + New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null + $zip = Join-Path $toolsDir "ASA_win_$AsaVersion.zip" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $AsaUrl -OutFile $zip -UseBasicParsing + Expand-Archive -Path $zip -DestinationPath $toolsDir -Force - if (Test-Path $toolPath) { return $toolPath } - $cmd = Get-Command asa -ErrorAction SilentlyContinue - if ($cmd) { return $cmd.Source } - throw 'ASA CLI not found after install.' + if (Test-Path $exe) { return $exe } + throw "ASA CLI not found after download (expected $exe)." } $asa = Resolve-Asa From ae8e7e05ef0bae040059e3bc7c8b5b6d2c297c29 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 16:22:02 -0700 Subject: [PATCH 08/10] ASA CloudTest: make WSL uninstall lookup StrictMode-safe Uninstall registry keys without a DisplayName value threw under Set-StrictMode -Version Latest. Check the property exists before matching it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/devops/Run-AsaInstallDiff.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index 7b96a4cf8b..3ddb4d8f09 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -175,7 +175,9 @@ function Get-WslMsiProductCodes { ) foreach ($r in $roots) { Get-ItemProperty $r -ErrorAction SilentlyContinue | Where-Object { - $_.DisplayName -match 'Windows Subsystem for Linux' -and $_.WindowsInstaller -eq 1 + $names = $_.PSObject.Properties.Name + ($names -contains 'DisplayName') -and ($_.DisplayName -match 'Windows Subsystem for Linux') -and + ($names -contains 'WindowsInstaller') -and ($_.WindowsInstaller -eq 1) } | ForEach-Object { $codes += $_.PSChildName } } $codes | Select-Object -Unique From 124474e2df6de62d7834db9d203d54c6f1a96b61 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 17:27:45 -0700 Subject: [PATCH 09/10] ASA CloudTest: install .NET 9 runtime for the framework-dependent CLI The ASA_win 2.3.321 zip is framework-dependent on .NET 9 (NETCore + AspNetCore), which a clean VM lacks, so the collect failed with a missing hostpolicy.dll. Install the ASP.NET Core 9 runtime to the default dotnet location and pin DOTNET_ROOT before running ASA. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/devops/Run-AsaInstallDiff.ps1 | 49 +++++++++++++++++++---------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index 3ddb4d8f09..34597b86c7 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -138,30 +138,45 @@ if (-not (Test-Admin)) { } # --- Ensure the ASA CLI is available --- -# ASA ships a self-contained Windows build (no .NET SDK or NuGet feed required), -# so download and unzip it here. This works on a clean VM image where only a -# runtime-only 'dotnet' host (or none) is present. The self-contained zip is only -# published up to 2.3.321; newer tags ship the dotnet global tool only. +# ASA ships a prebuilt Windows CLI zip (ASA_win), the last such build being 2.3.321 +# (newer tags ship the dotnet global tool only). That zip is framework-dependent on +# the .NET 9 runtimes (Microsoft.NETCore.App + Microsoft.AspNetCore.App), so on a +# clean VM image we also install the matching ASP.NET Core runtime before running it. $AsaVersion = '2.3.321' $AsaUrl = "https://github.com/microsoft/AttackSurfaceAnalyzer/releases/download/v$AsaVersion/ASA_win_$AsaVersion.zip" +$AsaDotnetChannel = '9.0' +$DotnetRoot = 'C:\Program Files\dotnet' + +function Install-AsaDotnetRuntime { + # ASA_win is a framework-dependent .NET 9 app. Install the ASP.NET Core 9 shared + # runtime (which carries the base .NET runtime too) into the default location so + # the apphost resolves it; also pin DOTNET_ROOT for deterministic resolution. + Write-Host "=== Installing .NET $AsaDotnetChannel runtime for ASA ===" -ForegroundColor Cyan + $installer = Join-Path $WorkDir 'dotnet-install.ps1' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer -UseBasicParsing + & $installer -Channel $AsaDotnetChannel -Runtime aspnetcore -InstallDir $DotnetRoot + if ($LASTEXITCODE -ne 0) { throw "dotnet runtime bootstrap failed ($LASTEXITCODE)" } + $env:DOTNET_ROOT = $DotnetRoot + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' +} function Resolve-Asa { - $cmd = Get-Command asa -ErrorAction SilentlyContinue - if ($cmd) { return $cmd.Source } - $toolsDir = Join-Path $WorkDir 'asa-cli' $exe = Join-Path $toolsDir "ASA_win_$AsaVersion\Asa.exe" - if (Test-Path $exe) { return $exe } - - Write-Host "=== Downloading Attack Surface Analyzer CLI $AsaVersion ===" -ForegroundColor Cyan - New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null - $zip = Join-Path $toolsDir "ASA_win_$AsaVersion.zip" - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri $AsaUrl -OutFile $zip -UseBasicParsing - Expand-Archive -Path $zip -DestinationPath $toolsDir -Force + if (-not (Test-Path $exe)) { + Write-Host "=== Downloading Attack Surface Analyzer CLI $AsaVersion ===" -ForegroundColor Cyan + New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null + $zip = Join-Path $toolsDir "ASA_win_$AsaVersion.zip" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $AsaUrl -OutFile $zip -UseBasicParsing + Expand-Archive -Path $zip -DestinationPath $toolsDir -Force + } + if (-not (Test-Path $exe)) { throw "ASA CLI not found after download (expected $exe)." } - if (Test-Path $exe) { return $exe } - throw "ASA CLI not found after download (expected $exe)." + Install-AsaDotnetRuntime + return $exe } $asa = Resolve-Asa From 988e80c897f037cd1e102a18cd38669c0d024200 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Tue, 23 Jun 2026 20:38:42 -0700 Subject: [PATCH 10/10] ASA CloudTest: verify dotnet install via shared frameworks, not LASTEXITCODE dotnet-install.ps1 is a PowerShell script, not an exe, so it never sets \0. Reading the unset variable under StrictMode threw right after a successful runtime install. Verify the install by checking for the Microsoft.NETCore.App and Microsoft.AspNetCore.App shared frameworks instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/devops/Run-AsaInstallDiff.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/devops/Run-AsaInstallDiff.ps1 b/tools/devops/Run-AsaInstallDiff.ps1 index 34597b86c7..564228f933 100644 --- a/tools/devops/Run-AsaInstallDiff.ps1 +++ b/tools/devops/Run-AsaInstallDiff.ps1 @@ -156,7 +156,12 @@ function Install-AsaDotnetRuntime { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installer -UseBasicParsing & $installer -Channel $AsaDotnetChannel -Runtime aspnetcore -InstallDir $DotnetRoot - if ($LASTEXITCODE -ne 0) { throw "dotnet runtime bootstrap failed ($LASTEXITCODE)" } + $sharedRoot = Join-Path $DotnetRoot 'shared' + $netCore = Join-Path $sharedRoot 'Microsoft.NETCore.App' + $aspNet = Join-Path $sharedRoot 'Microsoft.AspNetCore.App' + if (-not (Test-Path $netCore) -or -not (Test-Path $aspNet)) { + throw "dotnet runtime bootstrap failed: expected shared frameworks under $sharedRoot" + } $env:DOTNET_ROOT = $DotnetRoot $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' $env:DOTNET_NOLOGO = '1'