Public/Get-SudoersAudit.ps1

#!/usr/bin/env pwsh
Set-StrictMode -Version Latest

if ($IsLinux) {
    function Get-SudoersAudit {
        <#
        .SYNOPSIS
            Audits sudo rules for passwordless, all-command, and wildcard grants.

        .DESCRIPTION
            Reads the sudoers file and every file it includes, and returns one object per
            user rule with three flags:

            - NoPassword: the rule has the NOPASSWD tag, so the account escalates without
              proving anything it knows.
            - AllCommand: the rule grants ALL commands.
            - Wildcard: a command contains * or ?, which sudo matches against command paths
              and arguments alike, so a grant such as /usr/bin/find * matches far more than
              its literal text.

            Follows #include, @include, #includedir, and @includedir. Included directories
            skip file names that contain a dot or end in ~, as sudo does. Joins lines that
            end in a backslash, and treats # as a comment unless it starts an include or is
            followed by a digit, which is a user ID. Commands named through a Cmnd_Alias are
            resolved, so a wildcard or ALL behind an alias is still flagged. Defaults lines and
            alias definitions are not returned.

            Reads only the sudoers files, with no external tools, so it works with any
            sudo implementation that uses them. The files are readable by root only. Linux
            only.

        .PARAMETER LiteralPath
            The main sudoers file. Defaults to /etc/sudoers.

        .INPUTS
            None. You can't pipe objects to this function.

        .OUTPUTS
            System.Management.Automation.PSCustomObject. One object per user rule with Path,
            LineNumber, User, NoPassword, AllCommand, Wildcard, and Rule.

        .EXAMPLE
            Invoke-Sudo { Import-Module ./PSSecurity.psd1; Get-SudoersAudit }

            Lists every sudo rule as root.

        .EXAMPLE
            Get-SudoersAudit | Where-Object { $_.NoPassword -or $_.Wildcard }

            Lists the rules that skip the password prompt or match commands by wildcard.

        .EXAMPLE
            Get-SudoersAudit | Where-Object -Property AllCommand | Group-Object -Property User

            Counts the rules per account that grant every command.
        #>

        [CmdletBinding()]
        [OutputType([PSCustomObject])]
        param(
            [Parameter(HelpMessage = 'Enter the main sudoers file. Defaults to /etc/sudoers.')]
            [ValidateNotNullOrEmpty()]
            [string]$LiteralPath = '/etc/sudoers'
        )

        $pending = [System.Collections.Generic.Queue[string]]::new()
        $pending.Enqueue((Resolve-Path -LiteralPath $LiteralPath -ErrorAction Stop).ProviderPath)
        $visited = [System.Collections.Generic.HashSet[string]]::new()
        $statements = [System.Collections.Generic.List[PSCustomObject]]::new()

        while ($pending.Count -gt 0) {
            $path = $pending.Dequeue()
            if (-not $visited.Add($path)) {
                continue
            }

            $lines = @(Get-Content -LiteralPath $path -ErrorAction Stop)
            $parts = [System.Collections.Generic.List[string]]::new()
            for ($index = 0; $index -lt $lines.Count; $index++) {
                if ($parts.Count -eq 0) {
                    $startLine = $index + 1
                }

                # A trailing backslash continues the statement on the next line.
                if ($lines[$index] -match '\\$') {
                    $parts.Add($lines[$index].TrimEnd('\'))
                    continue
                }
                $parts.Add($lines[$index])
                $statement = ($parts -join ' ').Trim()
                $parts.Clear()

                if ($statement -match '^[#@](include|includedir)\s+"?([^"]+)"?$') {
                    $target = $Matches[2].Trim()
                    if (-not [System.IO.Path]::IsPathRooted($target)) {
                        $target = Join-Path -Path (Split-Path -Path $path -Parent) -ChildPath $target
                    }

                    if ($Matches[1] -eq 'include') {
                        $pending.Enqueue($target)
                    }
                    elseif (Test-Path -LiteralPath $target -PathType Container) {
                        Get-ChildItem -LiteralPath $target -File |
                            Where-Object -Property Name -NotMatch '\.|~$' |
                            Sort-Object -Property Name |
                            ForEach-Object { $pending.Enqueue($_.FullName) }
                    }
                    continue
                }

                # # starts a comment, except before a digit, where it is a user ID.
                $statement = ($statement -replace '(^|\s)#(?!\d).*$', '').Trim()
                if ($statement) {
                    $statements.Add([PSCustomObject]@{ Path = $path; LineNumber = $startLine; Text = $statement })
                }
            }
        }

        # Cmnd_Alias NAME = command, command : NAME2 = command
        $commandAlias = @{}
        foreach ($statement in $statements.Where({ $_.Text -match '^Cmnd_Alias\s+' })) {
            foreach ($definition in ($statement.Text -replace '^Cmnd_Alias\s+', '') -split '\s*:\s*') {
                $aliasName, $aliasCommand = $definition -split '\s*=\s*', 2
                $commandAlias[$aliasName.Trim()] = @($aliasCommand -split '\s*,\s*')
            }
        }

        $definitionPattern = '^(Defaults|User_Alias|Runas_Alias|Host_Alias|Cmnd_Alias|Cmd_Alias)\b'
        foreach ($statement in $statements.Where({ $_.Text -notmatch $definitionPattern -and $_.Text -match '=' })) {
            # Everything after the first = is the run-as list, tags, and commands.
            $commandText = ($statement.Text -split '=', 2)[1] -replace '\([^)]*\)', '' -replace '\b[A-Z_]+\s*:', ''

            $commands = [System.Collections.Generic.List[string]]::new()
            $unresolved = [System.Collections.Generic.Queue[string]]::new()
            foreach ($command in $commandText -split ',') {
                $unresolved.Enqueue($command.Trim())
            }
            $expandedAlias = [System.Collections.Generic.HashSet[string]]::new()
            while ($unresolved.Count -gt 0) {
                $command = $unresolved.Dequeue()
                $aliasName = $command.TrimStart('!')
                if ($commandAlias.ContainsKey($aliasName)) {
                    # The set stops an alias that names itself from expanding forever.
                    if ($expandedAlias.Add($aliasName)) {
                        $commandAlias[$aliasName] | ForEach-Object { $unresolved.Enqueue($_) }
                    }
                }
                elseif ($command) {
                    $commands.Add($command)
                }
            }

            [PSCustomObject]@{
                Path       = $statement.Path
                LineNumber = $statement.LineNumber
                User       = ($statement.Text -split '\s+', 2)[0]
                NoPassword = $statement.Text -match '\bNOPASSWD\s*:'
                AllCommand = $commands -contains 'ALL'
                Wildcard   = [bool]($commands -match '[*?]')
                Rule       = $statement.Text
            }
        }
    }
}