diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index ec1d370..1262e12 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -205,8 +205,9 @@ jobs: $ErrorActionPreference = 'Stop' Import-Module Pester -MinimumVersion 6.2.0 $cfg = New-PesterConfiguration - # The integration suite as well: it launches the 5.1 hosts the harness uses on a device, - # and the runner is elevated, so the SYSTEM-context tests run here as they do under pwsh + # The integration suite as well: it launches the 5.1 hosts the harness uses on a device. + # The hosted runner's token is not elevated (IsInRole Administrator is false), so the + # SYSTEM-context tests skip here as they do under pwsh; they run on the lab devices $cfg.Run.Path = './Tests/Unit', './Tests/Integration', './Validation/Tests/Unit' $cfg.Run.PassThru = $true $cfg.Output.Verbosity = 'Normal' diff --git a/CHANGELOG.md b/CHANGELOG.md index a36281f..a0caa2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,29 @@ release notes. ## [Unreleased] -Nothing yet. +### Fixed + +- `Examples/Invoke-IntuneScriptGate.ps1` analyzed a Pester suite placed next to the scripts, where + `Examples/IntuneScripts.Tests.ps1.template` puts it, and reported its `Import-Module + IntuneScriptLab` as an `IslModuleDependency` warning, which failed a build at `-FailOn Warning`. + Folders are searched by the gate now and `*.Tests.ps1` files are left out; a suite named directly + on `-Path` is still analyzed. +- The gate's GitHub annotations percent-encode the `file=` and `title=` values as well as the + message, so a script outside `-Root`, whose path stays absolute, no longer ends the property list + at its drive letter. +- `Export-IntuneFindingSarif` described every `IslAssignmentIssue` as a Win32 app assignment at + warning level, where the rule also reports a policy with no assignment and, as Information, a + user-context script assigned to devices; `IslFilterIssue` and `IslScheduleIssue` had no entry and + fell back to their bare names. All three carry a description, and a rule that reports at several + severities takes the level of its most severe finding in the log. A policy check, which has no + script behind it, made the export throw ("Invalid URI: The URI is empty"); it is written as a + result without a location, with the policy's kind and name in front of the message. +- `Examples/intune-script-gate.yml` uses `actions/checkout@v7` and + `github/codeql-action/upload-sarif@v4`; CodeQL Action v3 is deprecated from December 2026. +- `Examples/IntuneScripts.Tests.ps1.template` named its static tests after the file alone, so every + remediation's detection was a test called `Detect.ps1`; the folder is part of the name now + (`Report-Uptime/Detect.ps1`). Its 64-bit host test asked for `x64` outright, which Windows on ARM + refuses; it runs the device's own 64-bit host, as the commands do by default since 0.30.0. ## [0.32.0] - 2026-10-13 diff --git a/Examples/IntuneScripts.Tests.ps1.template b/Examples/IntuneScripts.Tests.ps1.template index 7245b57..cefcd70 100644 --- a/Examples/IntuneScripts.Tests.ps1.template +++ b/Examples/IntuneScripts.Tests.ps1.template @@ -24,18 +24,23 @@ BeforeAll { Import-Module IntuneScriptLab $script:IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()). IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + # The device's 64-bit host, for the WOW64 comparison: x64 on x64, arm64 on Windows on ARM + $script:Host64 = if ("$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" -eq 'Arm64') { + 'arm64' + } + else { 'x64' } } Describe 'Static analysis' -Tag 'Static' { - It 'has no warnings or errors: <_.Name>' -AllowNullOrEmptyForEach -ForEach ( + It 'has no warnings or errors: <_.Directory.Name>/<_.Name>' -AllowNullOrEmptyForEach -ForEach ( Get-ChildItem "$PSScriptRoot\Remediations" -Recurse -Filter *.ps1 -ErrorAction SilentlyContinue) { # Detection/Remediation is inferred from the file name; use -ScriptType or a directive # comment in the script if your names differ (see the README) $_ | Should-PassIntuneAnalysis } - It 'Win32 detection scripts survive the stdout/stderr rules: <_.Name>' -AllowNullOrEmptyForEach -ForEach ( + It 'survives the stdout/stderr rules: <_.Directory.Name>/<_.Name>' -AllowNullOrEmptyForEach -ForEach ( Get-ChildItem "$PSScriptRoot\Win32" -Recurse -Filter Detect.ps1 -ErrorAction SilentlyContinue) { $_ | Should-PassIntuneAnalysis -ScriptType Win32Detection } @@ -53,8 +58,11 @@ Describe 'Remediation packages' -Tag 'Runtime' { } It 'reports the same in the 64-bit host (no WOW64 dependence)' { - $x86 = Invoke-IntuneRemediationTest -DetectionPath "$($_.FullName)\Detect.ps1" -Architecture x86 - $x64 = Invoke-IntuneRemediationTest -DetectionPath "$($_.FullName)\Detect.ps1" -Architecture x64 + # The 64-bit host is the device's own: x64 on x64, arm64 on Windows on ARM, where an + # explicit x64 is refused (the agent runs the native 64-bit host there too) + $detect = "$($_.FullName)\Detect.ps1" + $x86 = Invoke-IntuneRemediationTest -DetectionPath $detect -Architecture x86 + $x64 = Invoke-IntuneRemediationTest -DetectionPath $detect -Architecture $script:Host64 $x64.PreDetection.ExitCode | Should-Be $x86.PreDetection.ExitCode } diff --git a/Examples/Invoke-IntuneScriptGate.ps1 b/Examples/Invoke-IntuneScriptGate.ps1 index b421c56..cbf3dd7 100644 --- a/Examples/Invoke-IntuneScriptGate.ps1 +++ b/Examples/Invoke-IntuneScriptGate.ps1 @@ -22,8 +22,9 @@ then by name). .PARAMETER Path - Folders or scripts to analyze; folders are searched for .ps1 files recursively. Default: - the current directory. + Folders or scripts to analyze; folders are searched for .ps1 files recursively, leaving out + Pester suites (*.Tests.ps1), which the suite template places next to the scripts. A suite + named directly is analyzed. Default: the current directory. .PARAMETER FailOn The lowest severity that fails the build: Error (default), Warning, Information, or None @@ -147,11 +148,30 @@ function ConvertTo-AnnotationText { $Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') } -$testSplat = @{ Path = $Path; ScriptType = $ScriptType } +function ConvertTo-AnnotationProperty { + # A property value (file=, title=) also encodes the ':' and ',' that delimit the command, or a + # path outside -Root, which stays absolute, ends the property list at its drive letter + param([string]$Text) + (ConvertTo-AnnotationText -Text $Text).Replace(':', '%3A').Replace(',', '%2C') +} + +# Folders are searched here rather than by Test-IntuneScript, so that a Pester suite next to the +# scripts (IntuneScripts.Tests.ps1, where the suite template puts it) is not analyzed as an Intune +# script; a suite named directly on -Path still is +$scriptFiles = @(foreach ($item in $Path) { + foreach ($resolved in (Resolve-Path -Path $item)) { + if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) { + Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File | + Where-Object { $_.Name -notlike '*.Tests.ps1' } | ForEach-Object { $_.FullName } + } + else { $resolved.ProviderPath } + } + }) +$testSplat = @{ Path = $scriptFiles; ScriptType = $ScriptType } if ($PSBoundParameters.ContainsKey('Settings')) { $testSplat.Settings = $Settings } if ($IncludeRule) { $testSplat.IncludeRule = $IncludeRule } if ($ExcludeRule) { $testSplat.ExcludeRule = $ExcludeRule } -$findings = @(Test-IntuneScript @testSplat) +$findings = if ($scriptFiles.Count) { @(Test-IntuneScript @testSplat) } else { @() } $scripts = @($findings | ForEach-Object { $_.ScriptPath } | Sort-Object -Unique) $counts = @{ Error = 0; Warning = 0; Information = 0 } foreach ($finding in $findings) { $counts[$finding.Severity]++ } @@ -162,10 +182,11 @@ Write-Host ("IntuneScriptLab: $($findings.Count) finding(s) in $($scripts.Count) if ($Annotate) { foreach ($finding in $findings) { - $location = "file=$(Get-RelativePath -FullPath $finding.ScriptPath)" + $location = "file=$(ConvertTo-AnnotationProperty -Text (Get-RelativePath -FullPath $finding.ScriptPath))" if ($finding.Line -gt 0) { $location += ",line=$($finding.Line),col=$([Math]::Max(1, $finding.Column))" } $message = ConvertTo-AnnotationText -Text $finding.Message - Write-Host "::$($annotationLevel[$finding.Severity]) $location,title=$($finding.RuleName)::$message" + $title = ConvertTo-AnnotationProperty -Text $finding.RuleName + Write-Host "::$($annotationLevel[$finding.Severity]) $location,title=${title}::$message" } } diff --git a/Examples/intune-script-gate.yml b/Examples/intune-script-gate.yml index eedb1c5..b4cdb66 100644 --- a/Examples/intune-script-gate.yml +++ b/Examples/intune-script-gate.yml @@ -27,6 +27,7 @@ env: # Folders holding the scripts: remediations, platform scripts, Win32 detection and requirement # scripts. Folder and file names tell the analysis what each script is (Remediations\\ # Detect.ps1, Win32\\Detect.ps1, PlatformScripts\*.ps1); a directive comment overrides. + # Pester suites under them (*.Tests.ps1) are left out of the analysis. SCRIPT_PATHS: ./Intune # Error fails the build; Warning is stricter; None reports only FAIL_ON: Error @@ -42,7 +43,7 @@ jobs: name: Analyze Intune scripts runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install IntuneScriptLab from the Gallery if: env.ISL_MODULE_PATH == '' @@ -64,7 +65,7 @@ jobs: - name: Upload SARIF to code scanning if: always() && env.UPLOAD_SARIF == 'true' - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ${{ runner.temp }}/intune-script-lab.sarif category: IntuneScriptLab @@ -78,7 +79,7 @@ jobs: needs: analyze if: false # switch on once IntuneScripts.Tests.ps1 exists next to the scripts steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Pester, and IntuneScriptLab unless a checked-in copy is used shell: pwsh diff --git a/Public/Export-IntuneFindingSarif.ps1 b/Public/Export-IntuneFindingSarif.ps1 index 8a3b3ba..0cfad72 100644 --- a/Public/Export-IntuneFindingSarif.ps1 +++ b/Public/Export-IntuneFindingSarif.ps1 @@ -22,7 +22,9 @@ function Export-IntuneFindingSarif { $levels = @{ Error = 'error'; Warning = 'warning'; Information = 'note' } $module = $MyInvocation.MyCommand.Module $rootPath = (Resolve-Path -LiteralPath $Root -ErrorAction Stop).ProviderPath.TrimEnd('\', '/') - # The context note and the pre-flight's policy checks have no rule function to read help from + # The context note and the pre-flight's policy checks have no rule function to read help + # from. A rule that reports at one severity carries its level here; one that reports at + # several (IslAssignmentIssue, IslFilterIssue) takes the most severe level in the log $fixedRules = @{ IslAssumedContext = @{ Level = 'note' @@ -33,8 +35,18 @@ function Export-IntuneFindingSarif { Summary = 'A Win32 detection rule the agent does not evaluate.' } IslAssignmentIssue = @{ - Level = 'warning' - Summary = 'A Win32 app assignment that can never install.' + Summary = ('An assignment that reaches no device: a Win32 app with install behavior User ' + + 'assigned to devices, a policy with no assignment or only exclusions, or a ' + + 'user-context script assigned to devices.') + } + IslFilterIssue = @{ + Summary = ('An assignment filter with a clause no Windows device matches, or one every ' + + 'device matches, or syntax the evaluator cannot read.') + } + IslScheduleIssue = @{ + Level = 'note' + Summary = ('A run-once schedule whose time has passed; a device that has not fetched the ' + + 'policy yet still runs it once.') } IslDetectOnly = @{ Level = 'note' @@ -74,7 +86,7 @@ function Export-IntuneFindingSarif { } if ($fixedRules.ContainsKey($name)) { $summary = $fixedRules[$name].Summary - $level = $fixedRules[$name].Level + if ($fixedRules[$name].Level) { $level = $fixedRules[$name].Level } } elseif (Get-Command -Name "Find-$name" -ErrorAction SilentlyContinue) { $help = Get-Help -Name "Find-$name" -ErrorAction SilentlyContinue @@ -102,18 +114,26 @@ function Export-IntuneFindingSarif { ruleIndex = $ruleIndex[$item.RuleName] level = $levels["$($item.Severity)"] message = [ordered]@{ text = "$($item.Message)" } - locations = @( + } + # A policy check from Test-IntuneDeployedScript has no script behind it; SARIF allows a + # result without a location, and the policy's name goes into the message instead + $scriptPath = "$($item.PSObject.Properties['ScriptPath'].Value)" + if ($scriptPath) { + $result.locations = @( [ordered]@{ physicalLocation = [ordered]@{ - artifactLocation = Get-ArtifactLocation -FilePath "$($item.ScriptPath)" + artifactLocation = Get-ArtifactLocation -FilePath $scriptPath region = $region } } ) - properties = [ordered]@{ - scriptType = "$($item.ScriptType)" - evidence = "$($item.Evidence)" - } + } + elseif ($item.PSObject.Properties['PolicyName'] -and $item.PolicyName) { + $result.message.text = "$($item.Kind) '$($item.PolicyName)': $($item.Message)" + } + $result.properties = [ordered]@{ + scriptType = "$($item.ScriptType)" + evidence = "$($item.Evidence)" } if ($item.PSObject.Properties['Suppressed'] -and $item.Suppressed) { $result.suppressions = @([ordered]@{ kind = 'inSource' }) diff --git a/README.md b/README.md index ea94742..a620613 100644 --- a/README.md +++ b/README.md @@ -660,7 +660,8 @@ as a Markdown table per kind, Broken first. repository of Intune scripts: on every pull request that touches a script it takes the module from a checked-in copy when `ISL_MODULE_PATH` names its manifest, or installs it from the Gallery, runs [Examples/Invoke-IntuneScriptGate.ps1](./Examples/Invoke-IntuneScriptGate.ps1) over the script -folders and fails the build at the chosen severity. The gate script annotates the changed files +folders (a Pester suite under them, `*.Tests.ps1`, is left out) and fails the build at the chosen +severity. The gate script annotates the changed files with each finding (file, line, rule, message), appends a Markdown summary to the job, and returns the findings with `-PassThru`; it works on any CI with `-Annotate:$false`. With `-SarifPath` it also writes a SARIF 2.1.0 log (`Export-IntuneFindingSarif`: a rule entry per rule with its help diff --git a/Tests/Integration/ScriptGate.Tests.ps1 b/Tests/Integration/ScriptGate.Tests.ps1 index 59297fd..ed46f78 100644 --- a/Tests/Integration/ScriptGate.Tests.ps1 +++ b/Tests/Integration/ScriptGate.Tests.ps1 @@ -21,6 +21,9 @@ BeforeAll { "Remove-Item C:\x -ErrorAction SilentlyContinue`r`nexit 0`r`n", $utf8Bom) [System.IO.File]::WriteAllText((Join-Path $script:Scripts 'Clean\Detect-Thing.ps1'), "Write-Output 'checked'`r`nexit 0`r`n", $utf8Bom) + # A Pester suite next to the scripts, as the suite template places it: not an Intune script + [System.IO.File]::WriteAllText((Join-Path $script:Scripts 'IntuneScripts.Tests.ps1'), + "BeforeAll { Import-Module IntuneScriptLab }`r`nDescribe 'x' { It 'y' { 1 | Should-Be 1 } }`r`n", $utf8Bom) # -File carries the script's exit code out as the process exit code on both hosts; a switch # cannot be negated that way, so the quiet run uses -Command (and expects exit 0 anyway) @@ -96,6 +99,28 @@ Describe 'Invoke-IntuneScriptGate' -Tag 'Integration', 'Gate' { $text | Should-BeLikeString '*gate: fail on Error.*' } + It 'leaves a Pester suite under the folder out of the analysis, and analyzes one named directly' { + $run = Invoke-GateFile -Arguments @('-Path', $script:Scripts, '-Annotate', '-Root', $script:Scripts, + '-FailOn', 'None') + $run.ExitCode | Should-Be 0 + ($run.Output -join "`n") | Should-NotBeLikeString '*IntuneScripts.Tests.ps1*' + ($run.Output -join "`n") | Should-NotBeLikeString '*IslModuleDependency*' + $direct = Invoke-GateFile -Arguments @('-Path', (Join-Path $script:Scripts 'IntuneScripts.Tests.ps1'), + '-FailOn', 'None') + ($direct.Output -join "`n") | Should-BeLikeString '*IslModuleDependency*' + } + + It 'percent-encodes the colon of an absolute path outside -Root in the file property' { + # With -Root below the scripts, every path stays absolute and carries a drive letter + $run = Invoke-GateFile -Arguments @('-Path', $script:Scripts, '-Annotate', '-FailOn', 'None', + '-Root', (Join-Path $script:Scripts 'Clean')) + $errorLines = @($run.Output | Where-Object { $_ -like '::error file=*' }) + $errorLines.Count | Should-BeGreaterThan 0 + $fileValue = ([regex]::Match($errorLines[0], '^::error file=([^,]*),')).Groups[1].Value + $fileValue | Should-NotBeLikeString '*:*' + $fileValue | Should-BeLikeString '*%3A*Detect.ps1' + } + It 'returns the findings with -PassThru' { $output = Invoke-GateCommand -Arguments ("-Path '$($script:Scripts)' -FailOn None -PassThru " + "-Annotate:`$false | ForEach-Object { 'FINDING ' + `$_.RuleName }") diff --git a/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 b/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 index 4cb1875..7db7f91 100644 --- a/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 +++ b/Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1 @@ -82,6 +82,38 @@ Describe 'Export-IntuneFindingSarif' -Tag 'Unit', 'Public' { $exit.PSObject.Properties.Name | Should-NotContainCollection 'suppressions' } + It 'describes the policy checks and gives a mixed-severity rule the level of its worst finding' { + $policy = foreach ($row in @( + @{ Rule = 'IslAssignmentIssue'; Severity = 'Information'; Message = 'user script' } + @{ Rule = 'IslAssignmentIssue'; Severity = 'Warning'; Message = 'no assignment' } + @{ Rule = 'IslFilterIssue'; Severity = 'Information'; Message = 'matches every device' } + @{ Rule = 'IslScheduleIssue'; Severity = 'Information'; Message = 'run-once in the past' } + )) { + # The shape Test-IntuneDeployedScript gives a policy check: no ScriptPath at all + [pscustomobject]@{ + PSTypeName = 'IntuneScriptLab.DeploymentFinding'; Kind = 'Remediation'; PolicyName = 'Fix-Widget' + PolicyId = 'rem-a'; Role = 'policy'; RuleName = $row.Rule; Severity = $row.Severity + Message = $row.Message; ScriptType = ''; Line = 0; Column = 0; Text = ''; Evidence = 'ASSIGN-NONE' + } + } + $out = Join-Path $TestDrive 'policy.sarif' + $null = Export-IntuneFindingSarif -Finding $policy -Path $out -Root $script:Repo + $run = (Read-Sarif $out).runs[0] + @($run.results).Count | Should-Be 4 + $run.results[1].PSObject.Properties['locations'] | Should-BeNull + $run.results[1].message.text | Should-Be "Remediation 'Fix-Widget': no assignment" + $rules = $run.tool.driver.rules + $assignment = $rules | Where-Object id -eq 'IslAssignmentIssue' + $assignment.shortDescription.text | Should-BeLikeString '*no assignment or only exclusions*' + $assignment.defaultConfiguration.level | Should-Be 'warning' + $filter = $rules | Where-Object id -eq 'IslFilterIssue' + $filter.shortDescription.text | Should-BeLikeString '*no Windows device matches*' + $filter.defaultConfiguration.level | Should-Be 'note' + $schedule = $rules | Where-Object id -eq 'IslScheduleIssue' + $schedule.shortDescription.text | Should-BeLikeString '*run-once schedule*' + $schedule.defaultConfiguration.level | Should-Be 'note' + } + It 'takes findings from the pipeline and writes a valid empty log for none' { $out = Join-Path $TestDrive 'piped.sarif' $null = $script:Findings | Export-IntuneFindingSarif -Path $out -Root $script:Repo diff --git a/docs/IntuneScriptLab/Export-IntuneFindingSarif.md b/docs/IntuneScriptLab/Export-IntuneFindingSarif.md index 432d2da..a98ba64 100644 --- a/docs/IntuneScriptLab/Export-IntuneFindingSarif.md +++ b/docs/IntuneScriptLab/Export-IntuneFindingSarif.md @@ -34,6 +34,8 @@ that of the most severe finding it produced in this log), and a result per findi file, line, column and text of the offending code, the message, the observed Intune behaviour as a property and, for a finding a Suppress directive silenced, an in-source suppression. +A policy check from Test-IntuneDeployedScript has no script behind it: it is written as a result +without a location, with the policy kind and name in front of the message. File paths are written relative to -Root under the %SRCROOT% base, which is what GitHub code scanning and Azure DevOps expect. diff --git a/en-US/IntuneScriptLab-Help.xml b/en-US/IntuneScriptLab-Help.xml index a514297..6b1834c 100644 --- a/en-US/IntuneScriptLab-Help.xml +++ b/en-US/IntuneScriptLab-Help.xml @@ -1564,6 +1564,8 @@ that of the most severe finding it produced in this log), and a result per findi file, line, column and text of the offending code, the message, the observed Intune behaviour as a property and, for a finding a Suppress directive silenced, an in-source suppression. +A policy check from Test-IntuneDeployedScript has no script behind it: it is written as a result +without a location, with the policy kind and name in front of the message. File paths are written relative to -Root under the %SRCROOT% base, which is what GitHub code scanning and Azure DevOps expect.