Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 12 additions & 4 deletions Examples/IntuneScripts.Tests.ps1.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
33 changes: 27 additions & 6 deletions Examples/Invoke-IntuneScriptGate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub's workflow-command grammar reserves ':' and ',' inside the property list, so a property value needs these two on top of the message's three. A relative path never carries a colon; the case this covers is a script outside -Root, which Get-RelativePath returns absolute, and the ',' is for a rule or file name that one day has one.

}

# 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 }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtered here rather than in Test-IntuneScript so the command's own behaviour on a folder stays what it was (every .ps1); the gate is the place that knows a Pester suite sits next to the scripts, because the suite template says to put it there. A suite given directly on -Path goes through the else branch and is analyzed, which the test checks.

}
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]++ }
Expand All @@ -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"
}
}

Expand Down
7 changes: 4 additions & 3 deletions Examples/intune-script-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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\<name>\
# Detect.ps1, Win32\<app>\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
Expand All @@ -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 == ''
Expand All @@ -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
Expand All @@ -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
Expand Down
40 changes: 30 additions & 10 deletions Public/Export-IntuneFindingSarif.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fixed entry without Level now means 'take the computed one', so IslAssignmentIssue (Warning and Information) and IslFilterIssue (Warning and Information) get the level of their worst finding in the log, the same rule the Find-* rules follow. The single-severity entries keep their level.

}
elseif (Get-Command -Name "Find-$name" -ErrorAction SilentlyContinue) {
$help = Get-Help -Name "Find-$name" -ErrorAction SilentlyContinue
Expand Down Expand Up @@ -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)"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through PSObject.Properties because a DeploymentFinding from Test-IntuneDeployedScript has no ScriptPath member at all; in strict mode a direct $item.ScriptPath would throw rather than give $null.

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)"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SARIF 2.1.0 makes locations optional on a result, and code scanning accepts a location-less result, but it then shows only the message, so the policy's kind and name go in front of it; otherwise two 'No assignment' results are indistinguishable.

}
$result.properties = [ordered]@{
scriptType = "$($item.ScriptType)"
evidence = "$($item.Evidence)"
}
if ($item.PSObject.Properties['Suppressed'] -and $item.Suppressed) {
$result.suppressions = @([ordered]@{ kind = 'inSource' })
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions Tests/Integration/ScriptGate.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-Root is pointed below the scripts so every path is outside it and stays absolute; that is the only way to get a drive letter into file= on a runner whose TestDrive could be on C: or D:, which is why the assertion checks for the absence of ':' rather than a particular drive.

$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 }")
Expand Down
32 changes: 32 additions & 0 deletions Tests/Unit/Public/Export-IntuneFindingSarif.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading