Repository navigation
fix(gate): skip Pester suites, encode annotation properties, describe the policy checks in SARIF #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(gate): skip Pester suites, encode annotation properties, describe the policy checks in SARIF #38
Changes from all commits
e95af13
6c38861
e353761
7aaaed9
a643e0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]++ } | ||
|
|
@@ -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" | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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)" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)" | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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')) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }") | ||
|
|
||
There was a problem hiding this comment.
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.