Skip to content

Commit 99555f3

Browse files
release: [0.3.0] 2025-12-13 (#6)
### Added - `Measure-BasicWebRequestProperty`: Detects when `Invoke-WebRequest` uses `UseBasicParsing` with incompatible properties like `Forms`, `ParsedHtml`, `Scripts`, or `AllElements`. Works with both direct property access and variable assignments. - `Measure-InvokeWebRequestWithoutBasic`: Flags `Invoke-WebRequest` (and its aliases `iwr`, `curl`) when used without the `UseBasicParsing` parameter. - `Get-CommandParameter`: New private helper function to parse command parameters from AST, including support for positional parameters. - Documentation for new rules in `docs/en-US/` directory. - Comprehensive test coverage for new rules. ### Changed - Updated `about_GoodEnoughRules.help.md` with complete module documentation including examples, rule descriptions, and troubleshooting guidance. - `Measure-SecureStringWithKey`: Standardized parameter block formatting and updated to use `Get-CommandParameter` helper function. - Test files: Added BeforeAll checks to ensure module builds before testing. - Improved code consistency across all rule files (param block formatting, using consistent helper function names).
1 parent 6cf5778 commit 99555f3

18 files changed

+744
-74
lines changed

.github/workflows/CI.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ on:
33
pull_request:
44
workflow_dispatch:
55
permissions:
6+
contents: read
7+
issues: write
68
checks: write
79
pull-requests: write
810
jobs:

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [0.3.0] 2025-12-13
9+
10+
### Added
11+
12+
- `Measure-BasicWebRequestProperty`: Detects when `Invoke-WebRequest` uses
13+
`UseBasicParsing` with incompatible properties like `Forms`, `ParsedHtml`,
14+
`Scripts`, or `AllElements`. Works with both direct property access and
15+
variable assignments.
16+
- `Measure-InvokeWebRequestWithoutBasic`: Flags `Invoke-WebRequest` (and its
17+
aliases `iwr`, `curl`) when used without the `UseBasicParsing` parameter.
18+
- `Get-CommandParameter`: New private helper function to parse command
19+
parameters from AST, including support for positional parameters.
20+
- Documentation for new rules in `docs/en-US/` directory.
21+
- Comprehensive test coverage for new rules.
22+
23+
### Changed
24+
25+
- Updated `about_GoodEnoughRules.help.md` with complete module documentation
26+
including examples, rule descriptions, and troubleshooting guidance.
27+
- `Measure-SecureStringWithKey`: Standardized parameter block formatting and
28+
updated to use `Get-CommandParameter` helper function.
29+
- Test files: Added BeforeAll checks to ensure module builds before testing.
30+
- Improved code consistency across all rule files (param block formatting,
31+
using consistent helper function names).
32+
833
## [0.2.0] Measure-SecureStringWithKey
934

1035
### Added

GoodEnoughRules/GoodEnoughRules.psd1

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
RootModule = 'GoodEnoughRules.psm1'
1313

1414
# Version number of this module.
15-
ModuleVersion = '0.2.0'
15+
ModuleVersion = '0.3.0'
1616

1717
# Supported PSEditions
1818
# CompatiblePSEditions = @()
@@ -52,10 +52,7 @@
5252

5353
# Modules that must be imported into the global environment prior to importing this module
5454
RequiredModules = @(
55-
@{
56-
ModuleName = 'PSScriptAnalyzer'
57-
ModuleVersion = '1.23'
58-
}
55+
# WARNING: Do not require PSScriptAnalyzer here to avoid circular dependency
5956
)
6057

6158
# Assemblies that must be loaded prior to importing this module

GoodEnoughRules/Private/Get-CommandParameters.ps1

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,49 @@
1-
function Get-CommandParameters {
1+
function Get-CommandParameter {
2+
[CmdletBinding()]
3+
[OutputType([hashtable])]
24
param (
35
[System.Management.Automation.Language.CommandAst]
46
$Command
57
)
68

7-
$commandElements = $CommandAst.CommandElements
9+
$commandElements = $Command.CommandElements
10+
Write-Verbose "Processing command: $($Command.GetCommandName())"
11+
Write-Verbose "Total command elements: $($commandElements.Count - 1)"
812
#region Gather Parameters
913
# Create a hash to hold the parameter name as the key, and the value
1014
$parameterHash = @{}
11-
for($i=1; $i -lt $commandElements.Count; $i++){
15+
# Track positional parameter index
16+
$positionalIndex = 0
17+
# Start at index 1 to skip the command name
18+
for ($i = 1; $i -lt $commandElements.Count; $i++) {
19+
Write-Debug $commandElements[$i]
1220
# Switch on type
13-
switch ($commandElements[$i].GetType().Name){
21+
switch ($commandElements[$i].GetType().Name) {
22+
'ParameterAst' {
23+
$parameterName = $commandElements[$i].ParameterName
24+
# Next element is the value
25+
continue
26+
}
1427
'CommandParameterAst' {
1528
$parameterName = $commandElements[$i].ParameterName
29+
# Initialize to $true for switch parameters
30+
$parameterHash[$parameterName] = $true
31+
continue
32+
}
33+
'StringConstantExpressionAst' {
34+
$value = $commandElements[$i].Value
35+
# Check if a parameter name was set
36+
if (-not $parameterName) {
37+
Write-Verbose "Positional parameter or argument detected: $value"
38+
$parameterHash["PositionalParameter$positionalIndex"] = $value
39+
$positionalIndex++
40+
continue
41+
}
42+
$parameterHash[$parameterName] = $value
43+
continue
1644
}
1745
default {
46+
Write-Verbose "Unhandled command element type: $($commandElements[$i].GetType().Name)"
1847
# Grab the string from the extent
1948
$value = $commandElements[$i].SafeGetValue()
2049
$parameterHash[$parameterName] = $value
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
function Measure-BasicWebRequestProperty {
2+
<#
3+
.SYNOPSIS
4+
Rule to detect if Invoke-WebRequest is used with UseBasicParsing and
5+
incompatible properties.
6+
7+
.DESCRIPTION
8+
This rule detects if Invoke-WebRequest (or its aliases) is used with the
9+
UseBasicParsing parameter and then attempts to access properties that are
10+
incompatible with UseBasicParsing. This includes properties like 'Forms',
11+
'ParsedHtml', 'Scripts', and 'AllElements'. This checks for both direct
12+
member access after the command as well as variable assignments.
13+
14+
.PARAMETER ScriptBlockAst
15+
The scriptblock AST to check.
16+
17+
.INPUTS
18+
[System.Management.Automation.Language.ScriptBlockAst]
19+
20+
.OUTPUTS
21+
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
22+
23+
.EXAMPLE
24+
Measure-BasicWebRequestProperty -ScriptBlockAst $ScriptBlockAst
25+
26+
This will check if the given ScriptBlockAst contains any Invoke-WebRequest
27+
commands with UseBasicParsing that access incompatible properties.
28+
#>
29+
[CmdletBinding()]
30+
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
31+
param
32+
(
33+
[Parameter(Mandatory = $true)]
34+
[ValidateNotNullOrEmpty()]
35+
[System.Management.Automation.Language.ScriptBlockAst]
36+
$ScriptBlockAst
37+
)
38+
begin {
39+
# We need to find any assignments or uses of Invoke-WebRequest (or its aliases)
40+
# to check if they attempt to use incompatible properties with UseBasicParsing.
41+
# Examples to find:
42+
# $bar = (iwr -Uri 'https://example.com' -UseBasicParsing).Forms
43+
$iwrMemberRead = {
44+
param($Ast)
45+
$Ast -is [System.Management.Automation.Language.CommandAst] -and
46+
# IWR Command
47+
$Ast.GetCommandName() -match '(Invoke-WebRequest|iwr|curl)' -and
48+
# With UseBasicParsing
49+
$Ast.CommandElements.ParameterName -contains 'UseBasicParsing' -and
50+
# That is being read as a member expression
51+
$Ast.Parent.Parent -is [System.Management.Automation.Language.ParenExpressionAst] -and
52+
$Ast.Parent.Parent.Parent -is [System.Management.Automation.Language.MemberExpressionAst] -and
53+
# The member being accessed is a string constant
54+
$Ast.Parent.Parent.Parent.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
55+
# The member is one of the incompatible properties
56+
$incompatibleProperties -contains $Ast.Parent.Parent.Parent.Member
57+
}
58+
# Predicate to find assignments involving Invoke-WebRequest with UseBasicParsing
59+
# $foo = Invoke-WebRequest -Uri 'https://example.com' -UseBasicParsing
60+
$varAssignPredicate = {
61+
param($Ast)
62+
$Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -and
63+
$Ast.Right -is [System.Management.Automation.Language.PipelineAst] -and
64+
$Ast.Right.PipelineElements.Count -eq 1 -and
65+
$Ast.Right.PipelineElements[0] -is [System.Management.Automation.Language.CommandAst] -and
66+
$Ast.Right.PipelineElements[0].GetCommandName() -match '(Invoke-WebRequest|iwr|curl)' -and
67+
$Ast.Right.PipelineElements[0].CommandElements.ParameterName -contains 'UseBasicParsing'
68+
}
69+
$incompatibleProperties = @(
70+
'AllElements',
71+
'Forms',
72+
'ParsedHtml',
73+
'Scripts'
74+
)
75+
76+
}
77+
78+
process {
79+
# Find all member expression ASTs that match our criteria
80+
[System.Management.Automation.Language.Ast[]]$memberReads = $ScriptBlockAst.FindAll($iwrMemberRead, $true)
81+
foreach ($memberRead in $memberReads) {
82+
# ParenExpression would be the whole line: (iwr -Uri ... -UseBasicParsing).Foo
83+
$parenExpression = $memberRead.Parent.Parent
84+
$propertyName = $memberRead.Parent.Parent.Parent.Member.Value
85+
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
86+
Message = "Invoke-WebRequest cannot use the '$propertyName' parameter when 'UseBasicParsing' is specified."
87+
Extent = $parenExpression.Extent
88+
Severity = 'Error'
89+
}
90+
}
91+
# Find all assignment ASTs that match our criteria
92+
[System.Management.Automation.Language.Ast[]]$assignments = $ScriptBlockAst.FindAll($varAssignPredicate, $true)
93+
# Now use that to search for var reads of the assigned variable
94+
foreach ($assignment in $assignments) {
95+
$variableName = $assignment.Left.VariablePath.UserPath
96+
$lineAfter = $assignment.Extent.EndLineNumber
97+
Write-Verbose "Checking variable '$variableName' for incompatible property usage after line $lineAfter"
98+
# Find all reads of that variable
99+
#region Dynamically Build the AST Search Predicate
100+
$varReadPredicateScript = @()
101+
$varReadPredicateScript += 'param($Ast)'
102+
$varReadPredicateScript += '$Ast -is [System.Management.Automation.Language.VariableExpressionAst] -and'
103+
$varReadPredicateScript += '$Ast.VariablePath.UserPath -eq "' + $variableName + '" -and'
104+
$varReadPredicateScript += '$Ast.Extent.StartLineNumber -gt ' + $lineAfter
105+
$varReadPredicate = [scriptblock]::Create($($varReadPredicateScript -join "`n"))
106+
[System.Management.Automation.Language.Ast[]]$varReads = $ScriptBlockAst.FindAll($varReadPredicate, $true)
107+
foreach ($varRead in $varReads) {
108+
Write-Verbose "Found read of variable '$variableName' at line $($varRead.Extent.StartLineNumber)"
109+
if ($varRead.Parent -is [System.Management.Automation.Language.MemberExpressionAst]) {
110+
$propertyName = $varRead.Parent.Member.Value
111+
if ($incompatibleProperties -contains $propertyName) {
112+
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
113+
Message = "Invoke-WebRequest cannot use the '$propertyName' parameter when 'UseBasicParsing' is specified."
114+
RuleName = $PSCmdlet.MyInvocation.InvocationName
115+
Extent = $varRead.Parent.Extent
116+
Severity = 'Error'
117+
}
118+
}
119+
}
120+
}
121+
}
122+
}
123+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
function Measure-InvokeWebRequestWithoutBasic {
2+
<#
3+
.SYNOPSIS
4+
Rule to detect if Invoke-WebRequest is used without UseBasicParsing.
5+
6+
.DESCRIPTION
7+
This rule detects if Invoke-WebRequest (or its aliases) is used without the
8+
UseBasicParsing parameter.
9+
10+
.PARAMETER ScriptBlockAst
11+
The scriptblock AST to check.
12+
13+
.INPUTS
14+
[System.Management.Automation.Language.ScriptBlockAst]
15+
16+
.OUTPUTS
17+
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
18+
19+
.EXAMPLE
20+
Measure-InvokeWebRequestWithoutBasic -ScriptBlockAst $ScriptBlockAst
21+
22+
This will check if the given ScriptBlockAst contains any Invoke-WebRequest
23+
commands without the UseBasicParsing parameter.
24+
#>
25+
[CmdletBinding()]
26+
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
27+
param
28+
(
29+
[Parameter(Mandatory = $true)]
30+
[ValidateNotNullOrEmpty()]
31+
[System.Management.Automation.Language.ScriptBlockAst]
32+
$ScriptBlockAst
33+
)
34+
begin {
35+
$predicate = {
36+
param($Ast)
37+
$Ast -is [System.Management.Automation.Language.CommandAst] -and
38+
$Ast.GetCommandName() -imatch '(Invoke-WebRequest|iwr|curl)$'
39+
}
40+
}
41+
42+
process {
43+
[System.Management.Automation.Language.Ast[]]$commands = $ScriptBlockAst.FindAll($predicate, $true)
44+
$commands | ForEach-Object {
45+
Write-Verbose "Analyzing command: $($_.GetCommandName())"
46+
$command = $_
47+
$parameterHash = Get-CommandParameter -Command $command
48+
if (-not $parameterHash.ContainsKey('UseBasicParsing')) {
49+
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
50+
Message = 'Invoke-WebRequest should be used with the UseBasicParsing parameter.'
51+
Extent = $command.Extent
52+
RuleName = $PSCmdlet.MyInvocation.InvocationName
53+
Severity = 'Error'
54+
}
55+
}
56+
}
57+
}
58+
}

GoodEnoughRules/Public/Measure-SecureStringWithKey.ps1

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,32 +21,33 @@ function Measure-SecureStringWithKey {
2121
#>
2222
[CmdletBinding()]
2323
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
24-
Param
24+
param
2525
(
2626
[Parameter(Mandatory = $true)]
2727
[ValidateNotNullOrEmpty()]
2828
[System.Management.Automation.Language.ScriptBlockAst]
2929
$ScriptBlockAst
3030
)
3131

32-
Begin {
32+
begin {
3333
$predicate = {
3434
param($Ast)
3535
$Ast -is [System.Management.Automation.Language.CommandAst] -and
3636
$Ast.GetCommandName() -eq 'ConvertFrom-SecureString'
3737
}
3838
}
3939

40-
Process {
40+
process {
4141
[System.Management.Automation.Language.Ast[]]$commands = $ScriptBlockAst.FindAll($predicate, $true)
4242
$commands | ForEach-Object {
4343
$command = $_
44-
$parameterHash = Get-CommandParameters -Command $command
44+
$parameterHash = Get-CommandParameter -Command $command
4545
if (-not $parameterHash.ContainsKey('Key')) {
4646
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
4747
Message = 'ConvertFrom-SecureString should be used with a Key.'
4848
Extent = $command.Extent
49-
Severity = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Severity]::Error
49+
RuleName = $PSCmdlet.MyInvocation.InvocationName
50+
Severity = 'Error'
5051
}
5152
}
5253
}

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ Install-PSResource GoodEnoughRules
2929

3030
The docs are automatically generated from the rule comment based help. You can see the docs at [HeyItsGilbert.GitHub.io/GoodEnoughRules](https://heyitsgilbert.github.io/GoodEnoughRules)
3131

32+
## Examples
33+
34+
### Running a Single Rule
35+
36+
```powershell
37+
# Install and import
38+
Install-PSResource GoodEnoughRules
39+
$module = Import-Module GoodEnoughRules -PassThru
40+
# Get the path the psm1
41+
$modulePath = Join-Path $module.ModuleBase $module.RootModule
42+
# Run against a folder
43+
Invoke-ScriptAnalyzer -CustomRulePath $modulePath -IncludeRule 'Measure-InvokeWebRequestWithoutBasic' -Path '.\scripts\'
44+
```
45+
3246
## Walk Through
3347

3448
> [!TIP]

0 commit comments

Comments
 (0)