Skip to content

Commit b0c1075

Browse files
authored
Merge pull request #38 from fadwen/fix/gate-ci-template
fix(gate): skip Pester suites, encode annotation properties, describe the policy checks in SARIF
2 parents d504de7 + a643e0a commit b0c1075

11 files changed

Lines changed: 162 additions & 27 deletions

‎.github/workflows/quality-gates.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,9 @@ jobs:
205205
$ErrorActionPreference = 'Stop'
206206
Import-Module Pester -MinimumVersion 6.2.0
207207
$cfg = New-PesterConfiguration
208-
# The integration suite as well: it launches the 5.1 hosts the harness uses on a device,
209-
# and the runner is elevated, so the SYSTEM-context tests run here as they do under pwsh
208+
# The integration suite as well: it launches the 5.1 hosts the harness uses on a device.
209+
# The hosted runner's token is not elevated (IsInRole Administrator is false), so the
210+
# SYSTEM-context tests skip here as they do under pwsh; they run on the lab devices
210211
$cfg.Run.Path = './Tests/Unit', './Tests/Integration', './Validation/Tests/Unit'
211212
$cfg.Run.PassThru = $true
212213
$cfg.Output.Verbosity = 'Normal'

‎CHANGELOG.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,29 @@ release notes.
1111

1212
## [Unreleased]
1313

14-
Nothing yet.
14+
### Fixed
15+
16+
- `Examples/Invoke-IntuneScriptGate.ps1` analyzed a Pester suite placed next to the scripts, where
17+
`Examples/IntuneScripts.Tests.ps1.template` puts it, and reported its `Import-Module
18+
IntuneScriptLab` as an `IslModuleDependency` warning, which failed a build at `-FailOn Warning`.
19+
Folders are searched by the gate now and `*.Tests.ps1` files are left out; a suite named directly
20+
on `-Path` is still analyzed.
21+
- The gate's GitHub annotations percent-encode the `file=` and `title=` values as well as the
22+
message, so a script outside `-Root`, whose path stays absolute, no longer ends the property list
23+
at its drive letter.
24+
- `Export-IntuneFindingSarif` described every `IslAssignmentIssue` as a Win32 app assignment at
25+
warning level, where the rule also reports a policy with no assignment and, as Information, a
26+
user-context script assigned to devices; `IslFilterIssue` and `IslScheduleIssue` had no entry and
27+
fell back to their bare names. All three carry a description, and a rule that reports at several
28+
severities takes the level of its most severe finding in the log. A policy check, which has no
29+
script behind it, made the export throw ("Invalid URI: The URI is empty"); it is written as a
30+
result without a location, with the policy's kind and name in front of the message.
31+
- `Examples/intune-script-gate.yml` uses `actions/checkout@v7` and
32+
`github/codeql-action/upload-sarif@v4`; CodeQL Action v3 is deprecated from December 2026.
33+
- `Examples/IntuneScripts.Tests.ps1.template` named its static tests after the file alone, so every
34+
remediation's detection was a test called `Detect.ps1`; the folder is part of the name now
35+
(`Report-Uptime/Detect.ps1`). Its 64-bit host test asked for `x64` outright, which Windows on ARM
36+
refuses; it runs the device's own 64-bit host, as the commands do by default since 0.30.0.
1537

1638
## [0.32.0] - 2026-10-13
1739

‎Examples/IntuneScripts.Tests.ps1.template‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,23 @@ BeforeAll {
2424
Import-Module IntuneScriptLab
2525
$script:IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).
2626
IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
27+
# The device's 64-bit host, for the WOW64 comparison: x64 on x64, arm64 on Windows on ARM
28+
$script:Host64 = if ("$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" -eq 'Arm64') {
29+
'arm64'
30+
}
31+
else { 'x64' }
2732
}
2833

2934
Describe 'Static analysis' -Tag 'Static' {
3035

31-
It 'has no warnings or errors: <_.Name>' -AllowNullOrEmptyForEach -ForEach (
36+
It 'has no warnings or errors: <_.Directory.Name>/<_.Name>' -AllowNullOrEmptyForEach -ForEach (
3237
Get-ChildItem "$PSScriptRoot\Remediations" -Recurse -Filter *.ps1 -ErrorAction SilentlyContinue) {
3338
# Detection/Remediation is inferred from the file name; use -ScriptType or a directive
3439
# comment in the script if your names differ (see the README)
3540
$_ | Should-PassIntuneAnalysis
3641
}
3742

38-
It 'Win32 detection scripts survive the stdout/stderr rules: <_.Name>' -AllowNullOrEmptyForEach -ForEach (
43+
It 'survives the stdout/stderr rules: <_.Directory.Name>/<_.Name>' -AllowNullOrEmptyForEach -ForEach (
3944
Get-ChildItem "$PSScriptRoot\Win32" -Recurse -Filter Detect.ps1 -ErrorAction SilentlyContinue) {
4045
$_ | Should-PassIntuneAnalysis -ScriptType Win32Detection
4146
}
@@ -53,8 +58,11 @@ Describe 'Remediation packages' -Tag 'Runtime' {
5358
}
5459

5560
It 'reports the same in the 64-bit host (no WOW64 dependence)' {
56-
$x86 = Invoke-IntuneRemediationTest -DetectionPath "$($_.FullName)\Detect.ps1" -Architecture x86
57-
$x64 = Invoke-IntuneRemediationTest -DetectionPath "$($_.FullName)\Detect.ps1" -Architecture x64
61+
# The 64-bit host is the device's own: x64 on x64, arm64 on Windows on ARM, where an
62+
# explicit x64 is refused (the agent runs the native 64-bit host there too)
63+
$detect = "$($_.FullName)\Detect.ps1"
64+
$x86 = Invoke-IntuneRemediationTest -DetectionPath $detect -Architecture x86
65+
$x64 = Invoke-IntuneRemediationTest -DetectionPath $detect -Architecture $script:Host64
5866
$x64.PreDetection.ExitCode | Should-Be $x86.PreDetection.ExitCode
5967
}
6068

‎Examples/Invoke-IntuneScriptGate.ps1‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
then by name).
2323
2424
.PARAMETER Path
25-
Folders or scripts to analyze; folders are searched for .ps1 files recursively. Default:
26-
the current directory.
25+
Folders or scripts to analyze; folders are searched for .ps1 files recursively, leaving out
26+
Pester suites (*.Tests.ps1), which the suite template places next to the scripts. A suite
27+
named directly is analyzed. Default: the current directory.
2728
2829
.PARAMETER FailOn
2930
The lowest severity that fails the build: Error (default), Warning, Information, or None
@@ -147,11 +148,30 @@ function ConvertTo-AnnotationText {
147148
$Text.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A')
148149
}
149150

150-
$testSplat = @{ Path = $Path; ScriptType = $ScriptType }
151+
function ConvertTo-AnnotationProperty {
152+
# A property value (file=, title=) also encodes the ':' and ',' that delimit the command, or a
153+
# path outside -Root, which stays absolute, ends the property list at its drive letter
154+
param([string]$Text)
155+
(ConvertTo-AnnotationText -Text $Text).Replace(':', '%3A').Replace(',', '%2C')
156+
}
157+
158+
# Folders are searched here rather than by Test-IntuneScript, so that a Pester suite next to the
159+
# scripts (IntuneScripts.Tests.ps1, where the suite template puts it) is not analyzed as an Intune
160+
# script; a suite named directly on -Path still is
161+
$scriptFiles = @(foreach ($item in $Path) {
162+
foreach ($resolved in (Resolve-Path -Path $item)) {
163+
if (Test-Path -LiteralPath $resolved.ProviderPath -PathType Container) {
164+
Get-ChildItem -LiteralPath $resolved.ProviderPath -Recurse -Filter *.ps1 -File |
165+
Where-Object { $_.Name -notlike '*.Tests.ps1' } | ForEach-Object { $_.FullName }
166+
}
167+
else { $resolved.ProviderPath }
168+
}
169+
})
170+
$testSplat = @{ Path = $scriptFiles; ScriptType = $ScriptType }
151171
if ($PSBoundParameters.ContainsKey('Settings')) { $testSplat.Settings = $Settings }
152172
if ($IncludeRule) { $testSplat.IncludeRule = $IncludeRule }
153173
if ($ExcludeRule) { $testSplat.ExcludeRule = $ExcludeRule }
154-
$findings = @(Test-IntuneScript @testSplat)
174+
$findings = if ($scriptFiles.Count) { @(Test-IntuneScript @testSplat) } else { @() }
155175
$scripts = @($findings | ForEach-Object { $_.ScriptPath } | Sort-Object -Unique)
156176
$counts = @{ Error = 0; Warning = 0; Information = 0 }
157177
foreach ($finding in $findings) { $counts[$finding.Severity]++ }
@@ -162,10 +182,11 @@ Write-Host ("IntuneScriptLab: $($findings.Count) finding(s) in $($scripts.Count)
162182

163183
if ($Annotate) {
164184
foreach ($finding in $findings) {
165-
$location = "file=$(Get-RelativePath -FullPath $finding.ScriptPath)"
185+
$location = "file=$(ConvertTo-AnnotationProperty -Text (Get-RelativePath -FullPath $finding.ScriptPath))"
166186
if ($finding.Line -gt 0) { $location += ",line=$($finding.Line),col=$([Math]::Max(1, $finding.Column))" }
167187
$message = ConvertTo-AnnotationText -Text $finding.Message
168-
Write-Host "::$($annotationLevel[$finding.Severity]) $location,title=$($finding.RuleName)::$message"
188+
$title = ConvertTo-AnnotationProperty -Text $finding.RuleName
189+
Write-Host "::$($annotationLevel[$finding.Severity]) $location,title=${title}::$message"
169190
}
170191
}
171192

‎Examples/intune-script-gate.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ env:
2727
# Folders holding the scripts: remediations, platform scripts, Win32 detection and requirement
2828
# scripts. Folder and file names tell the analysis what each script is (Remediations\<name>\
2929
# Detect.ps1, Win32\<app>\Detect.ps1, PlatformScripts\*.ps1); a directive comment overrides.
30+
# Pester suites under them (*.Tests.ps1) are left out of the analysis.
3031
SCRIPT_PATHS: ./Intune
3132
# Error fails the build; Warning is stricter; None reports only
3233
FAIL_ON: Error
@@ -42,7 +43,7 @@ jobs:
4243
name: Analyze Intune scripts
4344
runs-on: windows-latest
4445
steps:
45-
- uses: actions/checkout@v4
46+
- uses: actions/checkout@v7
4647

4748
- name: Install IntuneScriptLab from the Gallery
4849
if: env.ISL_MODULE_PATH == ''
@@ -64,7 +65,7 @@ jobs:
6465
6566
- name: Upload SARIF to code scanning
6667
if: always() && env.UPLOAD_SARIF == 'true'
67-
uses: github/codeql-action/upload-sarif@v3
68+
uses: github/codeql-action/upload-sarif@v4
6869
with:
6970
sarif_file: ${{ runner.temp }}/intune-script-lab.sarif
7071
category: IntuneScriptLab
@@ -78,7 +79,7 @@ jobs:
7879
needs: analyze
7980
if: false # switch on once IntuneScripts.Tests.ps1 exists next to the scripts
8081
steps:
81-
- uses: actions/checkout@v4
82+
- uses: actions/checkout@v7
8283

8384
- name: Install Pester, and IntuneScriptLab unless a checked-in copy is used
8485
shell: pwsh

‎Public/Export-IntuneFindingSarif.ps1‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ function Export-IntuneFindingSarif {
2222
$levels = @{ Error = 'error'; Warning = 'warning'; Information = 'note' }
2323
$module = $MyInvocation.MyCommand.Module
2424
$rootPath = (Resolve-Path -LiteralPath $Root -ErrorAction Stop).ProviderPath.TrimEnd('\', '/')
25-
# The context note and the pre-flight's policy checks have no rule function to read help from
25+
# The context note and the pre-flight's policy checks have no rule function to read help
26+
# from. A rule that reports at one severity carries its level here; one that reports at
27+
# several (IslAssignmentIssue, IslFilterIssue) takes the most severe level in the log
2628
$fixedRules = @{
2729
IslAssumedContext = @{
2830
Level = 'note'
@@ -33,8 +35,18 @@ function Export-IntuneFindingSarif {
3335
Summary = 'A Win32 detection rule the agent does not evaluate.'
3436
}
3537
IslAssignmentIssue = @{
36-
Level = 'warning'
37-
Summary = 'A Win32 app assignment that can never install.'
38+
Summary = ('An assignment that reaches no device: a Win32 app with install behavior User ' +
39+
'assigned to devices, a policy with no assignment or only exclusions, or a ' +
40+
'user-context script assigned to devices.')
41+
}
42+
IslFilterIssue = @{
43+
Summary = ('An assignment filter with a clause no Windows device matches, or one every ' +
44+
'device matches, or syntax the evaluator cannot read.')
45+
}
46+
IslScheduleIssue = @{
47+
Level = 'note'
48+
Summary = ('A run-once schedule whose time has passed; a device that has not fetched the ' +
49+
'policy yet still runs it once.')
3850
}
3951
IslDetectOnly = @{
4052
Level = 'note'
@@ -74,7 +86,7 @@ function Export-IntuneFindingSarif {
7486
}
7587
if ($fixedRules.ContainsKey($name)) {
7688
$summary = $fixedRules[$name].Summary
77-
$level = $fixedRules[$name].Level
89+
if ($fixedRules[$name].Level) { $level = $fixedRules[$name].Level }
7890
}
7991
elseif (Get-Command -Name "Find-$name" -ErrorAction SilentlyContinue) {
8092
$help = Get-Help -Name "Find-$name" -ErrorAction SilentlyContinue
@@ -102,18 +114,26 @@ function Export-IntuneFindingSarif {
102114
ruleIndex = $ruleIndex[$item.RuleName]
103115
level = $levels["$($item.Severity)"]
104116
message = [ordered]@{ text = "$($item.Message)" }
105-
locations = @(
117+
}
118+
# A policy check from Test-IntuneDeployedScript has no script behind it; SARIF allows a
119+
# result without a location, and the policy's name goes into the message instead
120+
$scriptPath = "$($item.PSObject.Properties['ScriptPath'].Value)"
121+
if ($scriptPath) {
122+
$result.locations = @(
106123
[ordered]@{
107124
physicalLocation = [ordered]@{
108-
artifactLocation = Get-ArtifactLocation -FilePath "$($item.ScriptPath)"
125+
artifactLocation = Get-ArtifactLocation -FilePath $scriptPath
109126
region = $region
110127
}
111128
}
112129
)
113-
properties = [ordered]@{
114-
scriptType = "$($item.ScriptType)"
115-
evidence = "$($item.Evidence)"
116-
}
130+
}
131+
elseif ($item.PSObject.Properties['PolicyName'] -and $item.PolicyName) {
132+
$result.message.text = "$($item.Kind) '$($item.PolicyName)': $($item.Message)"
133+
}
134+
$result.properties = [ordered]@{
135+
scriptType = "$($item.ScriptType)"
136+
evidence = "$($item.Evidence)"
117137
}
118138
if ($item.PSObject.Properties['Suppressed'] -and $item.Suppressed) {
119139
$result.suppressions = @([ordered]@{ kind = 'inSource' })

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,8 @@ as a Markdown table per kind, Broken first.
660660
repository of Intune scripts: on every pull request that touches a script it takes the module from a
661661
checked-in copy when `ISL_MODULE_PATH` names its manifest, or installs it from the Gallery,
662662
runs [Examples/Invoke-IntuneScriptGate.ps1](./Examples/Invoke-IntuneScriptGate.ps1) over the script
663-
folders and fails the build at the chosen severity. The gate script annotates the changed files
663+
folders (a Pester suite under them, `*.Tests.ps1`, is left out) and fails the build at the chosen
664+
severity. The gate script annotates the changed files
664665
with each finding (file, line, rule, message), appends a Markdown summary to the job, and returns
665666
the findings with `-PassThru`; it works on any CI with `-Annotate:$false`. With `-SarifPath` it
666667
also writes a SARIF 2.1.0 log (`Export-IntuneFindingSarif`: a rule entry per rule with its help

‎Tests/Integration/ScriptGate.Tests.ps1‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ BeforeAll {
2121
"Remove-Item C:\x -ErrorAction SilentlyContinue`r`nexit 0`r`n", $utf8Bom)
2222
[System.IO.File]::WriteAllText((Join-Path $script:Scripts 'Clean\Detect-Thing.ps1'),
2323
"Write-Output 'checked'`r`nexit 0`r`n", $utf8Bom)
24+
# A Pester suite next to the scripts, as the suite template places it: not an Intune script
25+
[System.IO.File]::WriteAllText((Join-Path $script:Scripts 'IntuneScripts.Tests.ps1'),
26+
"BeforeAll { Import-Module IntuneScriptLab }`r`nDescribe 'x' { It 'y' { 1 | Should-Be 1 } }`r`n", $utf8Bom)
2427

2528
# -File carries the script's exit code out as the process exit code on both hosts; a switch
2629
# 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' {
9699
$text | Should-BeLikeString '*gate: fail on Error.*'
97100
}
98101

102+
It 'leaves a Pester suite under the folder out of the analysis, and analyzes one named directly' {
103+
$run = Invoke-GateFile -Arguments @('-Path', $script:Scripts, '-Annotate', '-Root', $script:Scripts,
104+
'-FailOn', 'None')
105+
$run.ExitCode | Should-Be 0
106+
($run.Output -join "`n") | Should-NotBeLikeString '*IntuneScripts.Tests.ps1*'
107+
($run.Output -join "`n") | Should-NotBeLikeString '*IslModuleDependency*'
108+
$direct = Invoke-GateFile -Arguments @('-Path', (Join-Path $script:Scripts 'IntuneScripts.Tests.ps1'),
109+
'-FailOn', 'None')
110+
($direct.Output -join "`n") | Should-BeLikeString '*IslModuleDependency*'
111+
}
112+
113+
It 'percent-encodes the colon of an absolute path outside -Root in the file property' {
114+
# With -Root below the scripts, every path stays absolute and carries a drive letter
115+
$run = Invoke-GateFile -Arguments @('-Path', $script:Scripts, '-Annotate', '-FailOn', 'None',
116+
'-Root', (Join-Path $script:Scripts 'Clean'))
117+
$errorLines = @($run.Output | Where-Object { $_ -like '::error file=*' })
118+
$errorLines.Count | Should-BeGreaterThan 0
119+
$fileValue = ([regex]::Match($errorLines[0], '^::error file=([^,]*),')).Groups[1].Value
120+
$fileValue | Should-NotBeLikeString '*:*'
121+
$fileValue | Should-BeLikeString '*%3A*Detect.ps1'
122+
}
123+
99124
It 'returns the findings with -PassThru' {
100125
$output = Invoke-GateCommand -Arguments ("-Path '$($script:Scripts)' -FailOn None -PassThru " +
101126
"-Annotate:`$false | ForEach-Object { 'FINDING ' + `$_.RuleName }")

‎Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,38 @@ Describe 'Export-IntuneFindingSarif' -Tag 'Unit', 'Public' {
8282
$exit.PSObject.Properties.Name | Should-NotContainCollection 'suppressions'
8383
}
8484

85+
It 'describes the policy checks and gives a mixed-severity rule the level of its worst finding' {
86+
$policy = foreach ($row in @(
87+
@{ Rule = 'IslAssignmentIssue'; Severity = 'Information'; Message = 'user script' }
88+
@{ Rule = 'IslAssignmentIssue'; Severity = 'Warning'; Message = 'no assignment' }
89+
@{ Rule = 'IslFilterIssue'; Severity = 'Information'; Message = 'matches every device' }
90+
@{ Rule = 'IslScheduleIssue'; Severity = 'Information'; Message = 'run-once in the past' }
91+
)) {
92+
# The shape Test-IntuneDeployedScript gives a policy check: no ScriptPath at all
93+
[pscustomobject]@{
94+
PSTypeName = 'IntuneScriptLab.DeploymentFinding'; Kind = 'Remediation'; PolicyName = 'Fix-Widget'
95+
PolicyId = 'rem-a'; Role = 'policy'; RuleName = $row.Rule; Severity = $row.Severity
96+
Message = $row.Message; ScriptType = ''; Line = 0; Column = 0; Text = ''; Evidence = 'ASSIGN-NONE'
97+
}
98+
}
99+
$out = Join-Path $TestDrive 'policy.sarif'
100+
$null = Export-IntuneFindingSarif -Finding $policy -Path $out -Root $script:Repo
101+
$run = (Read-Sarif $out).runs[0]
102+
@($run.results).Count | Should-Be 4
103+
$run.results[1].PSObject.Properties['locations'] | Should-BeNull
104+
$run.results[1].message.text | Should-Be "Remediation 'Fix-Widget': no assignment"
105+
$rules = $run.tool.driver.rules
106+
$assignment = $rules | Where-Object id -eq 'IslAssignmentIssue'
107+
$assignment.shortDescription.text | Should-BeLikeString '*no assignment or only exclusions*'
108+
$assignment.defaultConfiguration.level | Should-Be 'warning'
109+
$filter = $rules | Where-Object id -eq 'IslFilterIssue'
110+
$filter.shortDescription.text | Should-BeLikeString '*no Windows device matches*'
111+
$filter.defaultConfiguration.level | Should-Be 'note'
112+
$schedule = $rules | Where-Object id -eq 'IslScheduleIssue'
113+
$schedule.shortDescription.text | Should-BeLikeString '*run-once schedule*'
114+
$schedule.defaultConfiguration.level | Should-Be 'note'
115+
}
116+
85117
It 'takes findings from the pipeline and writes a valid empty log for none' {
86118
$out = Join-Path $TestDrive 'piped.sarif'
87119
$null = $script:Findings | Export-IntuneFindingSarif -Path $out -Root $script:Repo

0 commit comments

Comments
 (0)