GoodEnoughRules.psm1


function Get-CommandParameter {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param (
        [System.Management.Automation.Language.CommandAst]
        $Command
    )

    $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 = @{}
    # 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) {
            '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
            }
        }
    }
    return $parameterHash
}
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'
                        }
                    }
                }
            }
        }
    }
}
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'
                }
            }
        }
    }
}
function Measure-SecureStringWithKey {
    <#
    .SYNOPSIS
    Rule to detect if ConvertFrom-SecureString is used without a Key.
    .DESCRIPTION
    This rule detects if ConvertFrom-SecureString is used without a Key which
    means the secret is user and machine bound.
    .EXAMPLE
    Measure-SecureStringWithKey -ScriptBlockAst $ScriptBlockAst
 
    This will check if the given ScriptBlockAst contains any
    ConvertFrom-SecureString commands without a Key parameter.
    .PARAMETER ScriptBlockAst
    The scriptblock AST to check.
    .INPUTS
    [System.Management.Automation.Language.ScriptBlockAst]
    .OUTPUTS
    [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
    .NOTES
    None
    #>

    [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() -eq 'ConvertFrom-SecureString'
        }
    }

    process {
        [System.Management.Automation.Language.Ast[]]$commands = $ScriptBlockAst.FindAll($predicate, $true)
        $commands | ForEach-Object {
            $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
                    RuleName = $PSCmdlet.MyInvocation.InvocationName
                    Severity = 'Error'
                }
            }
        }
    }
}
function Measure-TODOComment {
    <#
    .SYNOPSIS
    Rule to detect if TODO style comments are present.
    .DESCRIPTION
    This rule detects if TODO style comments are present in the given ScriptBlockAst.
    .EXAMPLE
    Measure-TODOComment -Token $Token
 
    This will check if the given Token contains any TODO comments.
    .PARAMETER Token
    The token to check for TODO comments.
    .INPUTS
    [System.Management.Automation.Language.Token]
    .OUTPUTS
    [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]]
    .NOTES
    None
    #>

    [CmdletBinding()]
    [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])]
    Param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Language.Token]
        $Token
    )

    Begin {
        $toDoIndicators = @(
            'TODO',
            'FIXME',
            'BUG',
            'MARK',
            '\[.\]'
        ) -join '|'
        $regExOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase #, IgnorePatternWhitespace, Multiline"
        # TODO: Add more comments to make it easier to understand the regular expression.
        # so meta hehe
        $regExPattern = "((\/\/|#|<!--|;|\*|^)((\s+(!|\?|\*|\-))?(\s+\[ \])?|(\s+(!|\?|\*)\s+\[.\])?)\s*($toDoIndicators)\s*\:?)"
        $regEx = [regex]::new($regExPattern, $regExOptions)
    }

    Process {
        if (-not $Token.Type -ne 'Comment') {
            return
        }
        #region Finds ASTs that match the predicates.
        foreach ($i in $Token.Extent.Text) {
            try {
                $matches = $regEx.Matches($i)
            } catch {
                $PSCmdlet.ThrowTerminatingError($PSItem)
            }
            if ($matches.Count -eq 0) {
                continue
            }
            $matches | ForEach-Object {
                [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]@{
                    'Message' = "TODO comment found. Please consider removing it or tracking with issue."
                    'Extent' = $Token.Extent
                    'RuleName' = $PSCmdlet.MyInvocation.InvocationName
                    'Severity' = 'Information'
                }
            }
        }
        #endregion
    }
}
# Don't need to dot load because we are compiling!