diff --git a/.pipelines/asa-stage.yml b/.pipelines/asa-stage.yml
new file mode 100644
index 0000000000..d18eba8a11
--- /dev/null
+++ b/.pipelines/asa-stage.yml
@@ -0,0 +1,59 @@
+# Attack Surface Analyzer (ASA) install-diff stage.
+#
+# 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: 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: ''
+
+ # 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
+ displayName: "Attack Surface Analyzer"
+ dependsOn: [build_x64]
+ jobs:
+ - job: asa_install_diff
+ displayName: "ASA install-diff (x64)"
+ dependsOn: []
+ variables:
+ ob_outputDirectory: '$(Build.SourcesDirectory)\out'
+ ob_artifactBaseName: 'drop_wsl'
+ ob_artifactSuffix: '_asa'
+ timeoutInMinutes: 180
+ cancelTimeoutInMinutes: 240
+ ${{ if eq(parameters.pool, '') }}:
+ pool: {'type': 'cloudtestagentless'}
+ ${{ else }}:
+ pool: ${{ parameters.pool }}
+ steps:
+ - task: CloudTestServerBuildTask@2
+ inputs:
+ 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 1434dd233d..ba88014715 100644
--- a/.pipelines/wsl-build-nightly-onebranch.yml
+++ b/.pipelines/wsl-build-nightly-onebranch.yml
@@ -53,4 +53,9 @@ extends:
- template: nuget-stage.yml@self
parameters:
- isNightly: true
\ No newline at end of file
+ isNightly: true
+
+ - template: asa-stage.yml@self
+ # 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..be1f433b67 100644
--- a/cloudtest/CMakeLists.txt
+++ b/cloudtest/CMakeLists.txt
@@ -51,6 +51,28 @@ 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) 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 "1")
+else()
+ set(ASA_ALLOW_UNSIGNED "0")
+endif()
+
+# Non-gating to start: report net-new findings without failing the run.
+# 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})
+ 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 +80,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..9f0cfb16a6
--- /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
new file mode 100644
index 0000000000..564228f933
--- /dev/null
+++ b/tools/devops/Run-AsaInstallDiff.ps1
@@ -0,0 +1,323 @@
+# 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. 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.
+ # 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).
+ [string] $TrxPath = ''
+)
+
+$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.
+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 = @"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"@
+ 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
+
+$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 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
+ $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'
+}
+
+function Resolve-Asa {
+ $toolsDir = Join-Path $WorkDir 'asa-cli'
+ $exe = Join-Path $toolsDir "ASA_win_$AsaVersion\Asa.exe"
+ 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)." }
+
+ Install-AsaDotnetRuntime
+ return $exe
+}
+
+$asa = Resolve-Asa
+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 {
+ $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
+}
+
+# --- [0] Clean baseline: remove any pre-existing WSL MSI ---
+Write-Host '=== [0/5] Ensuring clean baseline ===' -ForegroundColor Cyan
+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
+ 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
+}
+
+# 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
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."
+ }
+ ]
+}