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
2 changes: 2 additions & 0 deletions .github/workflows/CI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ on:
pull_request:
workflow_dispatch:
permissions:
contents: read
issues: write
checks: write
pull-requests: write
jobs:
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.3.0] 2025-12-13

### 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).

## [0.2.0] Measure-SecureStringWithKey

### Added
Expand Down
7 changes: 2 additions & 5 deletions GoodEnoughRules/GoodEnoughRules.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
RootModule = 'GoodEnoughRules.psm1'

# Version number of this module.
ModuleVersion = '0.2.0'
ModuleVersion = '0.3.0'

# Supported PSEditions
# CompatiblePSEditions = @()
Expand Down Expand Up @@ -52,10 +52,7 @@

# Modules that must be imported into the global environment prior to importing this module
RequiredModules = @(
@{
ModuleName = 'PSScriptAnalyzer'
ModuleVersion = '1.23'
}
# WARNING: Do not require PSScriptAnalyzer here to avoid circular dependency
)

# Assemblies that must be loaded prior to importing this module
Expand Down
37 changes: 33 additions & 4 deletions GoodEnoughRules/Private/Get-CommandParameters.ps1
Original file line number Diff line number Diff line change
@@ -1,20 +1,49 @@
function Get-CommandParameters {
function Get-CommandParameter {
[CmdletBinding()]
[OutputType([hashtable])]
param (
[System.Management.Automation.Language.CommandAst]
$Command
)

$commandElements = $CommandAst.CommandElements
$commandElements = $Command.CommandElements
Write-Verbose "Processing command: $($Command.GetCommandName())"
Write-Verbose "Total command elements: $($commandElements.Count - 1)"
#region Gather Parameters
# Create a hash to hold the parameter name as the key, and the value
$parameterHash = @{}
for($i=1; $i -lt $commandElements.Count; $i++){
# Track positional parameter index
$positionalIndex = 0
# Start at index 1 to skip the command name
for ($i = 1; $i -lt $commandElements.Count; $i++) {
Write-Debug $commandElements[$i]
# Switch on type
switch ($commandElements[$i].GetType().Name){
switch ($commandElements[$i].GetType().Name) {
'ParameterAst' {
$parameterName = $commandElements[$i].ParameterName
# Next element is the value
continue
}
'CommandParameterAst' {
$parameterName = $commandElements[$i].ParameterName
# Initialize to $true for switch parameters
$parameterHash[$parameterName] = $true
continue
}
'StringConstantExpressionAst' {
$value = $commandElements[$i].Value
# Check if a parameter name was set
if (-not $parameterName) {
Write-Verbose "Positional parameter or argument detected: $value"
$parameterHash["PositionalParameter$positionalIndex"] = $value
$positionalIndex++
continue
}
$parameterHash[$parameterName] = $value
continue
}
default {
Write-Verbose "Unhandled command element type: $($commandElements[$i].GetType().Name)"
# Grab the string from the extent
$value = $commandElements[$i].SafeGetValue()
$parameterHash[$parameterName] = $value
Expand Down
123 changes: 123 additions & 0 deletions GoodEnoughRules/Public/Measure-BasicWebRequestProperty.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
function Measure-BasicWebRequestProperty {
<#
.SYNOPSIS
Rule to detect if Invoke-WebRequest is used with UseBasicParsing and
incompatible properties.

.DESCRIPTION
This rule detects if Invoke-WebRequest (or its aliases) is used with the
UseBasicParsing parameter and then attempts to access properties that are
incompatible with UseBasicParsing. This includes properties like 'Forms',
'ParsedHtml', 'Scripts', and 'AllElements'. This checks for both direct
member access after the command as well as variable assignments.

.PARAMETER ScriptBlockAst
The scriptblock AST to check.

.INPUTS
[System.Management.Automation.Language.ScriptBlockAst]

.OUTPUTS
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]

.EXAMPLE
Measure-BasicWebRequestProperty -ScriptBlockAst $ScriptBlockAst

This will check if the given ScriptBlockAst contains any Invoke-WebRequest
commands with UseBasicParsing that access incompatible properties.
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.ScriptBlockAst]
$ScriptBlockAst
)
begin {
# We need to find any assignments or uses of Invoke-WebRequest (or its aliases)
# to check if they attempt to use incompatible properties with UseBasicParsing.
# Examples to find:
# $bar = (iwr -Uri 'https://example.com' -UseBasicParsing).Forms
$iwrMemberRead = {
param($Ast)
$Ast -is [System.Management.Automation.Language.CommandAst] -and
# IWR Command
$Ast.GetCommandName() -match '(Invoke-WebRequest|iwr|curl)' -and
# With UseBasicParsing
$Ast.CommandElements.ParameterName -contains 'UseBasicParsing' -and
# That is being read as a member expression
$Ast.Parent.Parent -is [System.Management.Automation.Language.ParenExpressionAst] -and
$Ast.Parent.Parent.Parent -is [System.Management.Automation.Language.MemberExpressionAst] -and
# The member being accessed is a string constant
$Ast.Parent.Parent.Parent.Member -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
# The member is one of the incompatible properties
$incompatibleProperties -contains $Ast.Parent.Parent.Parent.Member
}
# Predicate to find assignments involving Invoke-WebRequest with UseBasicParsing
# $foo = Invoke-WebRequest -Uri 'https://example.com' -UseBasicParsing
$varAssignPredicate = {
param($Ast)
$Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$Ast.Right -is [System.Management.Automation.Language.PipelineAst] -and
$Ast.Right.PipelineElements.Count -eq 1 -and
$Ast.Right.PipelineElements[0] -is [System.Management.Automation.Language.CommandAst] -and
$Ast.Right.PipelineElements[0].GetCommandName() -match '(Invoke-WebRequest|iwr|curl)' -and
$Ast.Right.PipelineElements[0].CommandElements.ParameterName -contains 'UseBasicParsing'
}
$incompatibleProperties = @(
'AllElements',
'Forms',
'ParsedHtml',
'Scripts'
)

}

process {
# Find all member expression ASTs that match our criteria
[System.Management.Automation.Language.Ast[]]$memberReads = $ScriptBlockAst.FindAll($iwrMemberRead, $true)
foreach ($memberRead in $memberReads) {
# ParenExpression would be the whole line: (iwr -Uri ... -UseBasicParsing).Foo
$parenExpression = $memberRead.Parent.Parent
$propertyName = $memberRead.Parent.Parent.Parent.Member.Value
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
Message = "Invoke-WebRequest cannot use the '$propertyName' parameter when 'UseBasicParsing' is specified."
Extent = $parenExpression.Extent
Severity = 'Error'
}
}
# Find all assignment ASTs that match our criteria
[System.Management.Automation.Language.Ast[]]$assignments = $ScriptBlockAst.FindAll($varAssignPredicate, $true)
# Now use that to search for var reads of the assigned variable
foreach ($assignment in $assignments) {
$variableName = $assignment.Left.VariablePath.UserPath
$lineAfter = $assignment.Extent.EndLineNumber
Write-Verbose "Checking variable '$variableName' for incompatible property usage after line $lineAfter"
# Find all reads of that variable
#region Dynamically Build the AST Search Predicate
$varReadPredicateScript = @()
$varReadPredicateScript += 'param($Ast)'
$varReadPredicateScript += '$Ast -is [System.Management.Automation.Language.VariableExpressionAst] -and'
$varReadPredicateScript += '$Ast.VariablePath.UserPath -eq "' + $variableName + '" -and'
$varReadPredicateScript += '$Ast.Extent.StartLineNumber -gt ' + $lineAfter
$varReadPredicate = [scriptblock]::Create($($varReadPredicateScript -join "`n"))
[System.Management.Automation.Language.Ast[]]$varReads = $ScriptBlockAst.FindAll($varReadPredicate, $true)
foreach ($varRead in $varReads) {
Write-Verbose "Found read of variable '$variableName' at line $($varRead.Extent.StartLineNumber)"
if ($varRead.Parent -is [System.Management.Automation.Language.MemberExpressionAst]) {
$propertyName = $varRead.Parent.Member.Value
if ($incompatibleProperties -contains $propertyName) {
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
Message = "Invoke-WebRequest cannot use the '$propertyName' parameter when 'UseBasicParsing' is specified."
RuleName = $PSCmdlet.MyInvocation.InvocationName
Extent = $varRead.Parent.Extent
Severity = 'Error'
}
}
}
}
}
}
}
58 changes: 58 additions & 0 deletions GoodEnoughRules/Public/Measure-InvokeWebRequestWithoutBasic.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
function Measure-InvokeWebRequestWithoutBasic {
<#
.SYNOPSIS
Rule to detect if Invoke-WebRequest is used without UseBasicParsing.

.DESCRIPTION
This rule detects if Invoke-WebRequest (or its aliases) is used without the
UseBasicParsing parameter.

.PARAMETER ScriptBlockAst
The scriptblock AST to check.

.INPUTS
[System.Management.Automation.Language.ScriptBlockAst]

.OUTPUTS
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]

.EXAMPLE
Measure-InvokeWebRequestWithoutBasic -ScriptBlockAst $ScriptBlockAst

This will check if the given ScriptBlockAst contains any Invoke-WebRequest
commands without the UseBasicParsing parameter.
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.ScriptBlockAst]
$ScriptBlockAst
)
begin {
$predicate = {
param($Ast)
$Ast -is [System.Management.Automation.Language.CommandAst] -and
$Ast.GetCommandName() -imatch '(Invoke-WebRequest|iwr|curl)$'
}
}

process {
[System.Management.Automation.Language.Ast[]]$commands = $ScriptBlockAst.FindAll($predicate, $true)
$commands | ForEach-Object {
Write-Verbose "Analyzing command: $($_.GetCommandName())"
$command = $_
$parameterHash = Get-CommandParameter -Command $command
if (-not $parameterHash.ContainsKey('UseBasicParsing')) {
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
Message = 'Invoke-WebRequest should be used with the UseBasicParsing parameter.'
Extent = $command.Extent
RuleName = $PSCmdlet.MyInvocation.InvocationName
Severity = 'Error'
}
}
}
}
}
11 changes: 6 additions & 5 deletions GoodEnoughRules/Public/Measure-SecureStringWithKey.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,33 @@ function Measure-SecureStringWithKey {
#>
[CmdletBinding()]
[OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
Param
param
(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[System.Management.Automation.Language.ScriptBlockAst]
$ScriptBlockAst
)

Begin {
begin {
$predicate = {
param($Ast)
$Ast -is [System.Management.Automation.Language.CommandAst] -and
$Ast.GetCommandName() -eq 'ConvertFrom-SecureString'
}
}

Process {
process {
[System.Management.Automation.Language.Ast[]]$commands = $ScriptBlockAst.FindAll($predicate, $true)
$commands | ForEach-Object {
$command = $_
$parameterHash = Get-CommandParameters -Command $command
$parameterHash = Get-CommandParameter -Command $command
if (-not $parameterHash.ContainsKey('Key')) {
[Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
Message = 'ConvertFrom-SecureString should be used with a Key.'
Extent = $command.Extent
Severity = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Severity]::Error
RuleName = $PSCmdlet.MyInvocation.InvocationName
Severity = 'Error'
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ Install-PSResource GoodEnoughRules

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)

## Examples

### Running a Single Rule

```powershell
# Install and import
Install-PSResource GoodEnoughRules
$module = Import-Module GoodEnoughRules -PassThru
# Get the path the psm1
$modulePath = Join-Path $module.ModuleBase $module.RootModule
# Run against a folder
Invoke-ScriptAnalyzer -CustomRulePath $modulePath -IncludeRule 'Measure-InvokeWebRequestWithoutBasic' -Path '.\scripts\'
```

## Walk Through

> [!TIP]
Expand Down
Loading