diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5e90de1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,145 @@ +name: PSADTree Workflow +on: + push: + branches: + - main + + pull_request: + branches: + - main + + release: + types: + - published + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERSHELL_TELEMETRY_OPTOUT: 1 + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + BUILD_CONFIGURATION: ${{ fromJSON('["Debug", "Release"]')[startsWith(github.ref, 'refs/tags/v')] }} + +jobs: + build: + name: build + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Build module - Debug + shell: pwsh + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build + if: ${{ env.BUILD_CONFIGURATION == 'Debug' }} + + - name: Build module - Publish + shell: pwsh + run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build + if: ${{ env.BUILD_CONFIGURATION == 'Release' }} + + - name: Capture PowerShell Module + uses: actions/upload-artifact@v3 + with: + name: PSModule + path: output/*.nupkg + + test: + name: test + needs: + - build + runs-on: ${{ matrix.info.os }} + strategy: + fail-fast: false + matrix: + info: + - name: PS-5.1 + psversion: '5.1' + os: windows-latest + - name: PS-7-Windows + psversion: '7' + os: windows-latest + + steps: + - uses: actions/checkout@v3 + + - name: Restore Built PowerShell Module + uses: actions/download-artifact@v3 + with: + name: PSModule + path: output + + - name: Install Built PowerShell Module + shell: pwsh + run: | + $manifestItem = Get-Item ([IO.Path]::Combine('module', '*.psd1')) + $moduleName = $manifestItem.BaseName + $manifest = Test-ModuleManifest -Path $manifestItem.FullName -ErrorAction SilentlyContinue -WarningAction Ignore + + $destPath = [IO.Path]::Combine('output', $moduleName, $manifest.Version) + if (-not (Test-Path -LiteralPath $destPath)) { + New-Item -Path $destPath -ItemType Directory | Out-Null + } + + Get-ChildItem output/*.nupkg | Rename-Item -NewName { $_.Name -replace '.nupkg', '.zip' } + + Expand-Archive -Path output/*.zip -DestinationPath $destPath -Force -ErrorAction Stop + + - name: Run Tests - Windows PowerShell + if: ${{ matrix.info.psversion == '5.1' }} + shell: pwsh + run: | + powershell.exe -NoProfile -File ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test + exit $LASTEXITCODE + + - name: Run Tests - PowerShell + if: ${{ matrix.info.psversion != '5.1' }} + shell: pwsh + run: | + pwsh -NoProfile -File ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Test + exit $LASTEXITCODE + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v3 + with: + name: Unit Test Results (${{ matrix.info.name }}) + path: ./output/TestResults/Pester.xml + + - name: Upload Coverage Results + if: always() && !startsWith(github.ref, 'refs/tags/v') + uses: actions/upload-artifact@v3 + with: + name: Coverage Results (${{ matrix.info.name }}) + path: ./output/TestResults/Coverage.xml + + - name: Upload Coverage to codecov + if: always() && !startsWith(github.ref, 'refs/tags/v') + uses: codecov/codecov-action@v3 + with: + files: ./output/TestResults/Coverage.xml + flags: ${{ matrix.info.name }} + + publish: + name: publish + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build + - test + runs-on: windows-latest + steps: + - name: Restore Built PowerShell Module + uses: actions/download-artifact@v3 + with: + name: PSModule + path: ./ + + - name: Publish to Gallery + if: github.event_name == 'release' + shell: pwsh + run: >- + dotnet nuget push '*.nupkg' + --api-key $env:PSGALLERY_TOKEN + --source 'https://www.powershellgallery.com/api/v2/package' + --no-symbols + env: + PSGALLERY_TOKEN: ${{ secrets.PSGALLERY_TOKEN }} diff --git a/.gitignore b/.gitignore index 0dab36a..80a49e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,272 @@ -*.zip -**/GHacn/* -**/old/* \ No newline at end of file +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +benchmarks/ +BenchmarkDotNet.Artifacts/ +tools/dotnet + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +### Custom entries ### +output/ +tools/Modules +test.settings.json +tests/integration/.vagrant +tests/integration/cert_setup diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..0fb951d --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,7 @@ +{ + "default": true, + "no-hard-tabs": true, + "no-duplicate-heading": false, + "line-length": false, + "no-inline-html": false +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4dafc5c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "formulahendry.dotnet-test-explorer", + "ms-dotnettools.csharp", + "ms-vscode.powershell", + ], +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2a6b085 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "PowerShell launch", + "type": "coreclr", + "request": "launch", + "program": "pwsh", + "args": [ + "-NoExit", + "-NoProfile", + "-Command", + "Import-Module ./output/PSADTree" + ], + "cwd": "${workspaceFolder}", + "stopAtEntry": false, + "console": "externalTerminal", + }, + { + "name": "PowerShell Launch Current File", + "type": "PowerShell", + "request": "launch", + "script": "${file}", + "cwd": "${workspaceFolder}" + }, + { + "name": ".NET FullCLR Attach", + "type": "clr", + "request": "attach", + "processId": "${command:pickProcess}", + "justMyCode": true, + }, + { + "name": ".NET CoreCLR Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}", + "justMyCode": true, + }, + ], +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2931fe0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,29 @@ +{ + //-------- Files configuration -------- + // When enabled, will trim trailing whitespace when you save a file. + "files.trimTrailingWhitespace": true, + // When enabled, insert a final new line at the end of the file when saving it. + "files.insertFinalNewline": true, + "search.exclude": { + "Release": true, + "tools/ResGen": true, + "tools/dotnet": true, + }, + "json.schemas": [ + { + "fileMatch": [ + "/test.settings.json" + ], + "url": "./tests/settings.schema.json" + } + ], + "dotnet-test-explorer.testProjectPath": "tests/units/*.csproj", + "editor.rulers": [ + 120, + ], + //-------- PowerShell configuration -------- + // Binary modules cannot be unloaded so running in separate processes solves that problem + //"powershell.debugging.createTemporaryIntegratedConsole": true, + // We use Pester v5 so we don't need the legacy code lens + "powershell.pester.useLegacyCodeLens": false, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..922c210 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,49 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "pwsh", + "type": "shell", + "args": [ + "-File", + "${workspaceFolder}/build.ps1" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile" + }, + { + "label": "update docs", + "command": "pwsh", + "type": "shell", + "args": [ + "-Command", + "Import-Module ${workspaceFolder}/output/PSADTree; Import-Module ${workspaceFolder}/tools/Modules/platyPS; Update-MarkdownHelpModule ${workspaceFolder}/docs/en-US -AlphabeticParamsOrder -RefreshModulePage -UpdateInputOutput" + ], + "problemMatcher": [], + "dependsOn": [ + "build" + ] + }, + { + "label": "test", + "command": "pwsh", + "type": "shell", + "args": [ + "-File", + "${workspaceFolder}/build.ps1", + "-Task", + "Test" + ], + "problemMatcher": [], + "dependsOn": [ + "build" + ] + } + ] +} diff --git a/Examples/1.png b/Examples/1.png deleted file mode 100644 index 7321da8..0000000 Binary files a/Examples/1.png and /dev/null differ diff --git a/Examples/2.png b/Examples/2.png deleted file mode 100644 index 41d513c..0000000 Binary files a/Examples/2.png and /dev/null differ diff --git a/Examples/3.png b/Examples/3.png deleted file mode 100644 index 58140f3..0000000 Binary files a/Examples/3.png and /dev/null differ diff --git a/Get-Hierarchy.ps1 b/Get-Hierarchy.ps1 deleted file mode 100644 index 71b0d99..0000000 --- a/Get-Hierarchy.ps1 +++ /dev/null @@ -1,285 +0,0 @@ -function Get-Hierarchy { - <# - .SYNOPSIS - Gets group membership or parentship and draws it's hierarchy. - - .PARAMETER Name - Objects of class User or Group. - - .OUTPUTS - Object[] // System.Array. Returns with properties: - - InputParameter: The input user or group. - - Index: The object on each level of recursion. - - Recursion: The level of recursion or nesting. - - Class: The objectClass. - - SubClass: The subClass of the group (DistributionList or SecurityGroup) or user (EID, AppID, etc). - - Hierarchy: The hierarchy map of the input paremeter. - - .EXAMPLE - C:\PS> Get-Hierarchy ExampleGroup - - .EXAMPLE - C:\PS> gh ExampleGroup -RecursionProperty MemberOf - - .EXAMPLE - C:\PS> Get-ADUser ExampleUser | Get-Hierarchy -RecursionProperty MemberOf - - .EXAMPLE - C:\PS> Get-ADGroup ExampleGroup | Get-Hierarchy -RecursionProperty MemberOf - - .EXAMPLE - C:\PS> Get-ADGroup ExampleGroup | gh - - .EXAMPLE - C:\PS> Get-ADGroup -Filter {Name -like 'ExampleGroups*'} | Get-Hierarchy - - .EXAMPLE - C:\PS> 'Group1,Group2,Group3'.split(',') | Get-Hierarchy -RecursionProperty MemberOf - - .NOTES - Author: Santiago Squarzon. - #> - - [cmdletbinding()] - [alias('gh')] - param( - [parameter(Mandatory, ValueFromPipeline)] - [string]$Name, - [string]$Server, - [validateset('MemberOf', 'Member')] - [string]$RecursionProperty = 'Member' - ) - - begin { - function GetObject { - param( - [parameter(Mandatory)] - [string]$Name, - [string]$Server - ) - - try { - if ($PSBoundParameters.ContainsKey('Server')) { - $Entry = [adsi] "LDAP://$Server" - } - } - catch { - $PSCmdlet.ThrowTerminatingError($_) - } - - $Searcher = [adsisearcher]::new( - $Entry, - "(|(name=$name)(samAccountName=$Name)(distinguishedName=$Name))") - - $Object = $Searcher.FindOne() - - if (-not $Object) { - $PSCmdlet.ThrowTerminatingError( - [System.Management.Automation.ErrorRecord]::new( - [System.ArgumentException] ("Cannot find an object: '{0}' under: '{1}'." -f $Name, $Searcher.SearchRoot.distinguishedName.ToString()), - 'ObjectNotFound', - [System.Management.Automation.ErrorCategory]::ObjectNotFound, - $Name)) - } - - $Object - } - - function RecHierarchy { - param( - [parameter(mandatory)] - [String]$DistinguishedName, - [Int]$Recursion = 0, - [validateset('MemberOf', 'Member')] - [string]$RecursionProperty = 'Member' - ) - - $ErrorMessage = { - "`nGroup Name: {0}`nMember: {1}`nError Message: $_`n" -f $Group.Name, $Member.Split(',')[0].Replace('CN=', '') - } - - $queryObjectSplat = @{ - DistinguishedName = $DistinguishedName - RecursionProperty = $RecursionProperty - } - - $thisObject = QueryObject @queryObjectSplat - - $Hierarchy = & { - foreach ($object in $thisObject.Property) { - try { - QueryObject -DistinguishedName $object - } - catch { - Write-Warning (& $errorMessage) - } - } - } | Sort-Object -Descending ObjectClass - - $thisInput = if ($Index[0].Index) { - $Index[0].Index - } - else { - $thisObject.Name - } - - $Index.Add( - [pscustomobject]@{ - InputParameter = $thisInput - Index = $thisObject.Name - Class = $thisObject.ObjectClass - Recursion = $Recursion - Domain = $thisObject.Domain - Hierarchy = [Tree]::Indent($thisObject.Name, $Recursion) - }) - - $Recursion++ - - foreach ($object in $Hierarchy) { - $class = $object.ObjectClass - - if (($object.Name -in $Index.Index -and ($object.Domain -in $Index.Domain))) { - [int]$i = $Recursion - do { - $i-- - $z = $index.where({ $_.Recursion -eq $i }).Index | Select-Object -Last 1 - if ($object.Name -eq $z) { - $layer = $true - } - } until($i -eq 0 -or $layer -eq $true) - - if ($layer) { - $string = switch ($object.ObjectClass) { - default { - $object.Name - } - 'Group' { - '{0} <=> Circular Nested Group' -f $object.Name - } - } - } - else { - $string = switch ($object.ObjectClass) { - default { - $object.Name - } - 'Group' { - '{0} <=> Skipping // Processed' -f $object.Name - } - } - } - - $Index.Add( - [pscustomobject]@{ - InputParameter = $thisInput - Index = $object.Name - Class = $class - Recursion = $Recursion - Domain = $object.Domain - Hierarchy = [Tree]::Indent($string, $Recursion) - }) - } - else { - $recHierarchySplat = @{ - DistinguishedName = $object.DistinguishedName - Recursion = $Recursion - RecursionProperty = $RecursionProperty - } - - RecHierarchy @recHierarchySplat - } - } - } - - function QueryObject { - [cmdletbinding()] - param( - [string]$DistinguishedName, - [validateset('MemberOf', 'Member')] - [string]$RecursionProperty - ) - - $Object = [adsi] "LDAP://$DistinguishedName" - - $Properties = [ordered]@{ - Name = $Object.name.ToString() - UserPrincipalName = $Object.userPrincipalName.ToString() - DistinguishedName = $Object.distinguishedName.ToString() - Domain = ($Object.distinguishedName.ToString() -isplit ",DC=")[1].ToUpper() - ObjectClass = $Object.SchemaClassName.ToString() - } - - if ($RecursionProperty) { - $Properties['Property'] = $Object.$RecursionProperty - } - - return [pscustomobject] $Properties - } - - class Tree { - hidden static [regex] $s_re = [regex]::new( - '└|\S', - [System.Text.RegularExpressions.RegexOptions]::Compiled) - - static [string] Indent([string] $inputString, [int] $indentation) { - if ($indentation -eq 0) { - return $inputString - } - - return [string]::new(' ', (4 * $indentation) - 4) + '└── ' + $inputString - } - - static [object[]] ConvertToTree([object[]] $inputObject) { - for ($i = 0; $i -lt $inputObject.Length; $i++) { - $index = $inputObject[$i].Hierarchy.IndexOf('└') - - if ($index -lt 0) { - continue - } - - $z = $i - 1 - - while (-not [Tree]::s_re.IsMatch($inputObject[$z].Hierarchy[$index].ToString())) { - $replace = $inputObject[$z].Hierarchy.ToCharArray() - $replace[$index] = '│' - $inputObject[$z].Hierarchy = [string]::new($replace) - $z-- - } - - if ($inputObject[$z].Hierarchy[$index] -eq '└') { - $replace = $inputObject[$z].Hierarchy.ToCharArray() - $replace[$index] = '├' - $inputObject[$z].Hierarchy = [string]::new($replace) - } - } - - return $inputObject - } - } - } - - process { - try { - $getObjectSplat = @{ - Name = $Name - } - - if ($PSBoundParameters.ContainsKey('Server')) { - $getObjectSplat['Server'] = $Server - } - $Index = [System.Collections.Generic.List[object]]::new() - $Object = GetObject @getObjectSplat - - $recHierarchySplat = @{ - DistinguishedName = $Object.Properties.distinguishedname - RecursionProperty = $RecursionProperty - } - - RecHierarchy @recHierarchySplat - [Tree]::ConvertToTree($Index.ToArray()) - } - catch { - $PSCmdlet.ThrowTerminatingError($_) - } - } -} diff --git a/PSADTree.build.ps1 b/PSADTree.build.ps1 new file mode 100644 index 0000000..9719668 --- /dev/null +++ b/PSADTree.build.ps1 @@ -0,0 +1,252 @@ +# I might've also stolen this from jborean93 ¯\_(ツ)_/¯ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string] + $Configuration = 'Debug' +) + +$modulePath = [IO.Path]::Combine($PSScriptRoot, 'module') +$manifestItem = Get-Item ([IO.Path]::Combine($modulePath, '*.psd1')) +$ModuleName = $manifestItem.BaseName + +$testModuleManifestSplat = @{ + Path = $manifestItem.FullName + ErrorAction = 'Ignore' + WarningAction = 'Ignore' +} +$Manifest = Test-ModuleManifest @testModuleManifestSplat +$Version = $Manifest.Version +$BuildPath = [IO.Path]::Combine($PSScriptRoot, 'output') +$PowerShellPath = [IO.Path]::Combine($PSScriptRoot, 'module') +$CSharpPath = [IO.Path]::Combine($PSScriptRoot, 'src', $ModuleName) +$ReleasePath = [IO.Path]::Combine($BuildPath, $ModuleName, $Version) +$IsUnix = $PSEdition -eq 'Core' -and -not $IsWindows +$UseNativeArguments = $PSVersionTable.PSVersion -gt '7.0' +($csharpProjectInfo = [xml]::new()).Load((Get-Item ([IO.Path]::Combine($CSharpPath, '*.csproj'))).FullName) +$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0]. + TargetFrameworks.Split(';', [StringSplitOptions]::RemoveEmptyEntries)) + +$PSFramework = $TargetFrameworks[0] + +[xml] $csharpProjectInfo = Get-Content ([IO.Path]::Combine($CSharpPath, '*.csproj')) +$TargetFrameworks = @(@($csharpProjectInfo.Project.PropertyGroup)[0].TargetFrameworks.Split( + ';', [StringSplitOptions]::RemoveEmptyEntries)) +$PSFramework = $TargetFrameworks[0] + +task Clean { + if (Test-Path $ReleasePath) { + Remove-Item $ReleasePath -Recurse -Force + } + + New-Item -ItemType Directory $ReleasePath | Out-Null +} + +task BuildDocs { + $helpParams = @{ + Path = [IO.Path]::Combine($PSScriptRoot, 'docs', 'en-US') + OutputPath = [IO.Path]::Combine($ReleasePath, 'en-US') + } + New-ExternalHelp @helpParams | Out-Null +} + +task BuildManaged { + $arguments = @( + 'publish' + '--configuration', $Configuration + '--verbosity', 'q' + '-nologo' + "-p:Version=$Version" + ) + + Push-Location -LiteralPath $CSharpPath + try { + foreach ($framework in $TargetFrameworks) { + Write-Host "Compiling for $framework" + dotnet @arguments --framework $framework + + if ($LASTEXITCODE) { + throw "Failed to compiled code for $framework" + } + } + } + finally { + Pop-Location + } +} + +task CopyToRelease { + $copyParams = @{ + Path = [IO.Path]::Combine($PowerShellPath, '*') + Destination = $ReleasePath + Recurse = $true + Force = $true + } + Copy-Item @copyParams + + foreach ($framework in $TargetFrameworks) { + $buildFolder = [IO.Path]::Combine($CSharpPath, 'bin', $Configuration, $framework, 'publish') + $binFolder = [IO.Path]::Combine($ReleasePath, 'bin', $framework, $_.Name) + if (-not (Test-Path -LiteralPath $binFolder)) { + New-Item -Path $binFolder -ItemType Directory | Out-Null + } + Copy-Item ([IO.Path]::Combine($buildFolder, '*')) -Destination $binFolder -Recurse + } +} + +task Package { + $nupkgPath = [IO.Path]::Combine($BuildPath, "$ModuleName.$Version*.nupkg") + if (Test-Path $nupkgPath) { + Remove-Item $nupkgPath -Force + } + + $repoParams = @{ + Name = 'LocalRepo' + SourceLocation = $BuildPath + PublishLocation = $BuildPath + InstallationPolicy = 'Trusted' + } + if (Get-PSRepository -Name $repoParams.Name -ErrorAction SilentlyContinue) { + Unregister-PSRepository -Name $repoParams.Name + } + + Register-PSRepository @repoParams + try { + Publish-Module -Path $ReleasePath -Repository $repoParams.Name + } + finally { + Unregister-PSRepository -Name $repoParams.Name + } +} + +task Analyze { + $pssaSplat = @{ + Path = $ReleasePath + Settings = [IO.Path]::Combine($PSScriptRoot, 'ScriptAnalyzerSettings.psd1') + Recurse = $true + ErrorAction = 'SilentlyContinue' + } + $results = Invoke-ScriptAnalyzer @pssaSplat + if ($null -ne $results) { + $results | Out-String + throw 'Failed PsScriptAnalyzer tests, build failed' + } +} + +task DoUnitTest { + $testsPath = [IO.Path]::Combine($PSScriptRoot, 'tests', 'units') + if (-not (Test-Path -LiteralPath $testsPath)) { + Write-Host 'No unit tests found, skipping' + return + } + + $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults') + if (-not (Test-Path -LiteralPath $resultsPath)) { + New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null + } + + $tempResultsPath = [IO.Path]::Combine($resultsPath, 'TempUnit') + if (Test-Path -LiteralPath $tempResultsPath) { + Remove-Item -LiteralPath $tempResultsPath -Force -Recurse + } + New-Item -Path $tempResultsPath -ItemType Directory | Out-Null + + try { + $runSettingsPrefix = 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration' + $arguments = @( + 'test' + $testsPath + '--results-directory', $tempResultsPath + if ($Configuration -eq 'Debug') { + '--collect:"XPlat Code Coverage"' + '--' + "$runSettingsPrefix.Format=json" + if ($UseNativeArguments) { + "$runSettingsPrefix.IncludeDirectory=`"$CSharpPath`"" + } + else { + "$runSettingsPrefix.IncludeDirectory=\`"$CSharpPath\`"" + } + } + ) + + Write-Host 'Running unit tests' + dotnet @arguments + + if ($LASTEXITCODE) { + throw 'Unit tests failed' + } + + if ($Configuration -eq 'Debug') { + Move-Item -Path $tempResultsPath/*/*.json -Destination $resultsPath/UnitCoverage.json -Force + } + } + finally { + Remove-Item -LiteralPath $tempResultsPath -Force -Recurse + } +} + +task DoTest { + $pesterScript = [IO.Path]::Combine($PSScriptRoot, 'tools', 'PesterTest.ps1') + if (-not (Test-Path $pesterScript)) { + Write-Host 'No Pester tests found, skipping' + return + } + + $resultsPath = [IO.Path]::Combine($BuildPath, 'TestResults') + if (-not (Test-Path $resultsPath)) { + New-Item $resultsPath -ItemType Directory -ErrorAction Stop | Out-Null + } + + $resultsFile = [IO.Path]::Combine($resultsPath, 'Pester.xml') + if (Test-Path $resultsFile) { + Remove-Item $resultsFile -ErrorAction Stop -Force + } + + $pwsh = [Environment]::GetCommandLineArgs()[0] -replace '\.dll$', '' + $arguments = @( + '-NoProfile' + '-NonInteractive' + if (-not $IsUnix) { + '-ExecutionPolicy', 'Bypass' + } + '-File', $pesterScript + '-TestPath', ([IO.Path]::Combine($PSScriptRoot, 'tests')) + '-OutputFile', $resultsFile + ) + + if ($Configuration -eq 'Debug') { + $unitCoveragePath = [IO.Path]::Combine($resultsPath, 'UnitCoverage.json') + $targetArgs = '"' + ($arguments -join '" "') + '"' + + if ($UseNativeArguments) { + $watchFolder = [IO.Path]::Combine($ReleasePath, 'bin', $PSFramework) + } + else { + $targetArgs = '"' + ($targetArgs -replace '"', '\"') + '"' + $watchFolder = '"{0}"' -f ([IO.Path]::Combine($ReleasePath, 'bin', $PSFramework)) + } + + $arguments = @( + $watchFolder + '--target', $pwsh + '--targetargs', $targetArgs + '--output', ([IO.Path]::Combine($resultsPath, 'Coverage.xml')) + '--format', 'cobertura' + if (Test-Path -LiteralPath $unitCoveragePath) { + '--merge-with', $unitCoveragePath + } + ) + $pwsh = 'coverlet' + } + + & $pwsh $arguments + + if ($LASTEXITCODE) { + throw 'Pester failed tests' + } +} + +task Build -Jobs Clean, BuildManaged, CopyToRelease, BuildDocs, Package +task Test -Jobs BuildManaged, Analyze, DoUnitTest, DoTest +task . Build diff --git a/README.md b/README.md index 38c2e17..2827e03 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,213 @@ -# Get-Hierarchy +

PSADTree

-### DESCRIPTION -Gets Group's and User's membership or Group parentship and draws it's hierarchy. -Helps identifying Circular Nested Groups. +
+ Tree like cmdlets for Active Directory Principals! +

-### PARAMETER +[![build](https://github.com/santisq/PSADTree/actions/workflows/ci.yml/badge.svg)](https://github.com/santisq/PSADTree/actions/workflows/ci.yml) +[![PowerShell Gallery](https://img.shields.io/powershellgallery/v/PSADTree?label=gallery)](https://www.powershellgallery.com/packages/PSADTree) +[![LICENSE](https://img.shields.io/github/license/santisq/PSADTree)](https://github.com/santisq/PSADTree/blob/main/LICENSE) -| Parameter Name | Description | -| --- | --- | -| `-Name ` | __Name, SamAccountName or DistinguishedName__ of an AD Object of the class __User__ or __Group__ | -| `[-RecursionProperty ]` | Set AD attribute for recursion. __Valid Values__: `Member` / `MemberOf`. __Default Value__: `Member` | -| `[-Server ]` | __FQDN or NetBIOS name__ for the instance to connect to. | -| `[]` | See [`about_CommonParameters`](https://go.microsoft.com/fwlink/?LinkID=113216) | +
-### OUTPUTS -`` +PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals. +This Module currently includes two cmdlets: -- `InputParameter` The input user or group -- `Index` The object on each level of recursion -- `Recursion` The level of recursion or nesting -- `Class` The objectClass -- `Hierarchy` The hierarchy map of the input paremeter +- [Get-ADTreeGroupMember](Get-ADTreeGroupMember.md) for AD Group Members. +- [Get-ADTreePrincipalGroupMembership](Get-ADTreePrincipalGroupMembership.md) for AD Principal Group Membership. -### REQUIREMENTS +__Both cmdlets help with discovery of Circular Nested Groups.__ -- PowerShell v5.1 +## Documentation +Check out [__the docs__](./docs/en-US/PSADTree.md) for information about how to use this Module. -### USAGE EXAMPLES +## Installation +### Gallery + +The module is available through the [PowerShell Gallery](https://www.powershellgallery.com/packages/PSADTree): + +```powershell +Install-Module PSADTree -Scope CurrentUser +``` + +### Source + +```powershell +git clone 'https://github.com/santisq/PSADTree.git' +Set-Location ./PSADTree +./build.ps1 ``` -PS C:\> Get-Hierarchy ExampleGroup -PS C:\> gh ExampleGroup -RecursionProperty MemberOf -PS C:\> Get-ADGroup ExampleGroup | Get-Hierarchy -RecursionProperty MemberOf -PS C:\> Get-ADGroup ExampleGroup | gh -PS C:\> Get-ADGroup -Filter "Name -like 'ExampleGroups*'" | Get-Hierarchy -PS C:\> 'Group1,Group2,Group3'.split(',') | Get-Hierarchy -RecursionProperty MemberOf + +## Requirements + +This Module uses the [`System.DirectoryServices.AccountManagement` Namespace](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement?view=dotnet-plat-ext-7.0) to query Active Directory, its System Requirement is __Windows OS__ and is compatible with __Windows PowerShell v5.1__ or [__PowerShell 7+__](https://github.com/PowerShell/PowerShell). + +## Usage + +These are some examples of what the cmdlets from this Module allow you to do. For more examples check out the docs. + +### Get the members of a group + +```powershell +PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007 + + Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain group TestGroup007 +ChildDomain msDS-ManagedServiceAccount ├── testMSA$ +ChildDomain user ├── TestUser013 +ChildDomain user ├── TestUser010 +ChildDomain user ├── TestUser007 +ChildDomain group ├── TestGroup001 +ChildDomain user │ ├── TestUser015 +ChildDomain user │ ├── TestUser013 +ChildDomain user │ ├── TestUser010 +ChildDomain user │ ├── TestUser007 +ChildDomain user │ ├── TestUser002 +ChildDomain group │ ├── TestGroup005 +ParentDomain group │ │ ├── TestGroup001 +ParentDomain group │ │ └── TestGroup002 +ChildDomain group │ ├── TestGroup006 +ChildDomain computer │ │ ├── TestComputer0000004$ +ChildDomain computer │ │ ├── TestComputer0000003$ +ChildDomain computer │ │ ├── TestComputer0000002$ +ChildDomain computer │ │ └── TestComputer0000001$ +ChildDomain group │ └── TestGroup007 ↔ Circular Reference +ChildDomain group ├── TestGroup005 ↔ Processed Group +ChildDomain group └── TestGroup006 ↔ Processed Group +``` + +### Control the grade of recursion with the `-Depth` parameter + +The default value for `-Depth` is 3. + +```powershell +PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007 -Depth 2 + + Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain group TestGroup007 +ChildDomain msDS-ManagedServiceAccount ├── testMSA$ +ChildDomain user ├── TestUser013 +ChildDomain user ├── TestUser010 +ChildDomain user ├── TestUser007 +ChildDomain group ├── TestGroup001 +ChildDomain user │ ├── TestUser015 +ChildDomain user │ ├── TestUser013 +ChildDomain user │ ├── TestUser010 +ChildDomain user │ ├── TestUser007 +ChildDomain user │ ├── TestUser002 +ChildDomain group │ ├── TestGroup005 +ChildDomain group │ ├── TestGroup006 +ChildDomain group │ └── TestGroup007 ↔ Circular Reference +ChildDomain group ├── TestGroup005 ↔ Processed Group +ChildDomain group └── TestGroup006 ↔ Processed Group +``` + +### Get group members recursively, include only groups and display all processed groups + +The `-Recursive` switch indicates that the cmdlet should traverse all the group hierarchy. +The `-Group` switch limits the members tree view to nested groups only. +By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. +The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. + +```powershell +PS ..\PSADTree> Get-ADTreeGroupMember TestGroup007 -Recursive -Group -ShowAll + + Source: CN=TestGroup007,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain group TestGroup007 +ChildDomain group ├── TestGroup001 +ChildDomain group │ ├── TestGroup005 +ParentDomain group │ │ ├── TestGroup001 +ParentDomain group │ │ │ └── TestGroup002 +ParentDomain group │ │ └── TestGroup002 +ChildDomain group │ ├── TestGroup006 +ChildDomain group │ └── TestGroup007 ↔ Circular Reference +ChildDomain group ├── TestGroup005 +ParentDomain group │ ├── TestGroup001 +ParentDomain group │ │ └── TestGroup002 +ParentDomain group │ └── TestGroup002 +ChildDomain group └── TestGroup006 +``` + +### Get group memberships for a user + +```powershell +PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002 + + Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain user TestUser002 +ChildDomain group ├── TestGroup003 +ChildDomain group │ └── TestGroup000 +ChildDomain group ├── TestGroup001 +ChildDomain group │ ├── TestGroup007 +ChildDomain group │ │ ├── TestGroup004 +ChildDomain group │ │ ├── TestGroup002 +ChildDomain group │ │ └── TestGroup001 ↔ Circular Reference +ChildDomain group │ └── TestGroup000 ↔ Processed Group +ChildDomain group ├── Terminal Server License Servers +ChildDomain group └── Domain Users +ChildDomain group └── Users +``` + +### Control the grade of recursion with the `-Depth` parameter + +Same as `Get-ADTreeGroupMember`, the default depth to display the principal memberships is 2. + +```powershell +PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002 -Depth 2 + + Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain user TestUser002 +ChildDomain group ├── TestGroup003 +ChildDomain group │ └── TestGroup000 +ChildDomain group ├── TestGroup001 +ChildDomain group │ ├── TestGroup007 +ChildDomain group │ └── TestGroup000 ↔ Processed Group +ChildDomain group ├── Terminal Server License Servers +ChildDomain group └── Domain Users +ChildDomain group └── Users +``` + +### Get the user principal membership recursively and display all processed groups + +```powershell +PS ..\PSADTree> Get-ADTreePrincipalGroupMembership TestUser002 -Recursive -ShowAll + + Source: CN=TestUser002,OU=Operations,DC=ChildDomain,DC=ParentDomain,DC=myDomain,DC=xyz + +Domain ObjectClass Hierarchy +------ ----------- --------- +ChildDomain user TestUser002 +ChildDomain group ├── TestGroup003 +ChildDomain group │ └── TestGroup000 +ChildDomain group ├── TestGroup001 +ChildDomain group │ ├── TestGroup007 +ChildDomain group │ │ ├── TestGroup004 +ChildDomain group │ │ ├── TestGroup002 +ChildDomain group │ │ │ └── TestGroup000 +ChildDomain group │ │ └── TestGroup001 ↔ Circular Reference +ChildDomain group │ └── TestGroup000 +ChildDomain group ├── Terminal Server License Servers +ChildDomain group └── Domain Users +ChildDomain group └── Users ``` +## Contributing -![Alt text](/Examples/1.png?raw=true) -![Alt text](/Examples/2.png?raw=true) -![Alt text](/Examples/3.png?raw=true) +Contributions are more than welcome, if you wish to contribute, fork this repository and submit a pull request with the changes. diff --git a/ScriptAnalyzerSettings.psd1 b/ScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..882e626 --- /dev/null +++ b/ScriptAnalyzerSettings.psd1 @@ -0,0 +1,29 @@ +# The PowerShell Script Analyzer will generate a warning +# diagnostic record for this file due to a bug - +# https://github.com/PowerShell/PSScriptAnalyzer/issues/472 +@{ + # Only diagnostic records of the specified severity will be generated. + # Uncomment the following line if you only want Errors and Warnings but + # not Information diagnostic records. + + # Severity = @('Error','Warning') + + # Analyze **only** the following rules. Use IncludeRules when you want + # to invoke only a small subset of the defualt rules. + + # IncludeRules = @('PSAvoidDefaultValueSwitchParameter', + # 'PSMissingModuleManifestField', + # 'PSReservedCmdletChar', + # 'PSReservedParams', + # 'PSShouldProcess', + # 'PSUseApprovedVerbs', + # 'PSUseDeclaredVarsMoreThanAssigments') + + # Do not analyze the following rules. Use ExcludeRules when you have + # commented out the IncludeRules settings above and want to include all + # the default rules except for those you exclude below. + # Note: if a rule is in both IncludeRules and ExcludeRules, the rule + # will be excluded. + + # ExcludeRules = @('') +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..26e2b0b --- /dev/null +++ b/build.ps1 @@ -0,0 +1,70 @@ +# I may've totally stolen this from jborean93 :D +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('Debug', 'Release')] + [string] + $Configuration = 'Debug', + + [Parameter()] + [string[]] + $Task = 'Build' +) + +end { + if ($PSEdition -eq 'Desktop') { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 'Tls12' + } + + $modulePath = [IO.Path]::Combine($PSScriptRoot, 'tools', 'Modules') + $requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'tools', 'requiredModules.psd1')) + + foreach ($req in $requirements.GetEnumerator()) { + $targetPath = [IO.Path]::Combine($modulePath, $req.Key) + + if (Test-Path -LiteralPath $targetPath) { + Import-Module -Name $targetPath -Force -ErrorAction Stop + continue + } + + Write-Host "Installing build pre-req $($req.Key) as it is not installed" + $null = New-Item -Path $targetPath -ItemType Directory -Force + + $webParams = @{ + Uri = "https://www.powershellgallery.com/api/v2/package/$($req.Key)/$($req.Value)" + OutFile = [IO.Path]::Combine($modulePath, "$($req.Key).zip") + UseBasicParsing = $true + } + + if ('Authentication' -in (Get-Command -Name Invoke-WebRequest).Parameters.Keys) { + $webParams.Authentication = 'None' + } + + $oldProgress = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + try { + Invoke-WebRequest @webParams + Expand-Archive -Path $webParams.OutFile -DestinationPath $targetPath -Force + Remove-Item -LiteralPath $webParams.OutFile -Force + } + finally { + $ProgressPreference = $oldProgress + } + + Import-Module -Name $targetPath -Force -ErrorAction Stop + } + + $dotnetTools = @(dotnet tool list --global) -join "`n" + if (-not $dotnetTools.Contains('coverlet.console')) { + Write-Host 'Installing dotnet tool coverlet.console' + dotnet tool install --global coverlet.console + } + + $invokeBuildSplat = @{ + Task = $Task + File = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '*.build.ps1'))).FullName + Configuration = $Configuration + } + Invoke-Build @invokeBuildSplat +} diff --git a/docs/en-US/Get-ADTreeGroupMember.md b/docs/en-US/Get-ADTreeGroupMember.md new file mode 100644 index 0000000..e085ac4 --- /dev/null +++ b/docs/en-US/Get-ADTreeGroupMember.md @@ -0,0 +1,231 @@ +--- +external help file: PSADTree.dll-Help.xml +Module Name: PSADTree +online version: +schema: 2.0.0 +--- + +# Get-ADTreeGroupMember + +## SYNOPSIS + +`tree` like cmdlet for Active Directory group members. + +## SYNTAX + +### Depth (Default) + +```powershell +Get-ADTreeGroupMember [-Group] [-Identity] [-Server ] [-Depth ] [-ShowAll] + [] +``` + +### Recursive + +```powershell +Get-ADTreeGroupMember [-Group] [-Identity] [-Server ] [-Recursive] [-ShowAll] + [] +``` + +## DESCRIPTION + +The `Get-ADTreeGroupMember` cmdlet gets the Active Directory members of a specified group and displays them in a tree like structure. The members of a group can be users, groups, computers and service accounts. This cmdlet also helps identifying Circular Nested Groups. + +## EXAMPLES + +### Example 1: Get the members of a group + +```powershell +PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 +``` + +By default, this cmdlet uses `-Depth` with a default value of `3`. + +### Example 2: Get the members of a group recursively + +```powershell +PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Recursive +``` + +### Example 3: Get the members of all groups under an Organizational Unit + +```powershell +PS ..\PSADTree\> Get-ADGroup -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | + Get-ADTreeGroupMember +``` + +You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. + +### Example 4: Find any Circular Nested Groups from previous example + +```powershell +PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | + Get-ADTreeGroupMember -Recursive -Group | + Where-Object IsCircular +``` + +The `-Group` switch limits the members tree view to nested groups only. + +### Example 5: Get group members in a different Domain + +```powershell +PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -Server otherDomain +``` + +### Example 6: Get group members including processed groups + +```powershell +PS ..\PSADTree\> Get-ADTreeGroupMember TestGroup001 -ShowAll +``` + +By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. +The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. + +__NOTE:__ The use of this switch should not infer in a great performance cost, for more details see the parameter details. + +## PARAMETERS + +### -Depth + +Determines the number of nested groups and their members included in the recursion. +By default, only 3 levels of recursion are included. + +```yaml +Type: UInt32 +Parameter Sets: Depth +Aliases: + +Required: False +Position: Named +Default value: 3 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Group + +The `-Group` switch indicates that the cmdlet should display nested group members only. Essentially, a built-in filter where [`ObjectClass`](https://learn.microsoft.com/en-us/windows/win32/adschema/a-objectclass) is `group`. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity + +Specifies an Active Directory group by providing one of the following property values: + +- A DistinguishedName +- A GUID +- A SID (Security Identifier) +- A sAMAccountName +- A UserPrincipalName + +See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: DistinguishedName + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### -Recursive + +Specifies that the cmdlet should get all group members of the specified group. + +```yaml +Type: SwitchParameter +Parameter Sets: Recursive +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server + +Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server. + +Domain name values: + +- Fully qualified domain name +- NetBIOS name + +Directory server values: + +- Fully qualified directory server name +- NetBIOS name +- Fully qualified directory server name and port + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ShowAll + +By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. +This switch forces the cmdlet to display the full hierarchy including previously processed groups. + +> __NOTE:__ This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity. +This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost. +The intent behind this switch is to not clutter the cmdlet's output by default. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.String + +You can pipe strings containing an identity to this cmdlet. [__`ADGroup`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adgroup?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. + +## OUTPUTS + +### PSADTree.TreeGroup + +### PSADTree.TreeUser + +### PSADTree.TreeComputer + +## NOTES + +`treegroupmember` is the alias for this cmdlet. + +## RELATED LINKS diff --git a/docs/en-US/Get-ADTreePrincipalGroupMembership.md b/docs/en-US/Get-ADTreePrincipalGroupMembership.md new file mode 100644 index 0000000..93ffdac --- /dev/null +++ b/docs/en-US/Get-ADTreePrincipalGroupMembership.md @@ -0,0 +1,213 @@ +--- +external help file: PSADTree.dll-Help.xml +Module Name: PSADTree +online version: +schema: 2.0.0 +--- + +# Get-ADTreePrincipalGroupMembership + +## SYNOPSIS + +`tree` like cmdlet for Active Directory Principals Group Membership. + +## SYNTAX + +### Depth (Default) + +```powershell +Get-ADTreePrincipalGroupMembership [-Identity] [-Server ] [-Depth ] [-ShowAll] + [] +``` + +### Recursive + +```powershell +Get-ADTreePrincipalGroupMembership [-Identity] [-Server ] [-Recursive] [-ShowAll] + [] +``` + +## DESCRIPTION + +The `Get-ADTreePrincipalGroupMembership` cmdlet gets the Active Directory groups that have a specified user, computer, group, or service account as a member and displays them in a tree like structure. This cmdlet also helps identifying Circular Nested Groups. + +## EXAMPLES + +### Example 1: Get group memberships for a user + +```powershell +PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe +``` + +By default, this cmdlet uses `-Depth` with a default value of `3`. + +### Example 2: Get the recursive group memberships for a user + +```powershell +PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Recursive +``` + +### Example 3: Get group memberships for all computers under an Organizational Unit + +```powershell +PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | + Get-ADTreePrincipalGroupMembership +``` + +You can pipe strings containing an identity to this cmdlet. [__`ADObject`__](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. + +### Example 4: Find any Circular Nested Groups from previous example + +```powershell +PS ..\PSADTree\> Get-ADComputer -Filter * -SearchBase 'OU=myOU,DC=myDomain,DC=com' | + Get-ADTreePrincipalGroupMembership -Recursive | + Where-Object IsCircular +``` + +### Example 5: Get group memberships for a user in a different Domain + +```powershell +PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -Server otherDomain +``` + +### Example 6: Get group memberships for a user, including processed groups + +```powershell +PS ..\PSADTree\> Get-ADTreePrincipalGroupMembership john.doe -ShowAll +``` + +By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. +The `-ShowAll` switch indicates that the cmdlet should display the hierarchy of all previously processed groups. + +__NOTE:__ The use of this switch should not infer in a great performance cost, for more details see the parameter details. + +## PARAMETERS + +### -Depth + +Determines the number of nested group memberships included in the recursion. +By default, only 3 levels of recursion are included. + +```yaml +Type: UInt32 +Parameter Sets: Depth +Aliases: + +Required: False +Position: Named +Default value: 3 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Identity + +Specifies an Active Directory principal by providing one of the following property values: + +- A DistinguishedName +- A GUID +- A SID (Security Identifier) +- A sAMAccountName +- A UserPrincipalName + +See [`IdentityType` Enum](https://learn.microsoft.com/en-us/dotnet/api/system.directoryservices.accountmanagement.identitytype?view=dotnet-plat-ext-7.0) for more information. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: DistinguishedName + +Required: True +Position: 0 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### -Recursive + +Specifies that the cmdlet should get all group membership of the specified principal. + +```yaml +Type: SwitchParameter +Parameter Sets: Recursive +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server + +Specifies the AD DS instance to connect to by providing one of the following values for a corresponding domain name or directory server. + +Domain name values: + +- Fully qualified domain name +- NetBIOS name + +Directory server values: + +- Fully qualified directory server name +- NetBIOS name +- Fully qualified directory server name and port + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ShowAll + +By default, previously processed groups will be marked as _"Processed Group"_ and their hierarchy will not be displayed. +This switch forces the cmdlet to display the full hierarchy including previously processed groups. + +> __NOTE:__ This cmdlet uses a caching mechanism to ensure that Active Directory Groups are only queried once per Identity. +This caching mechanism is also used to reconstruct the pre-processed group's hierarchy when the `-ShowAll` switch is used, thus not incurring a performance cost. +The intent behind this switch is to not clutter the cmdlet's output by default. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.String + +You can pipe strings containing an identity to this cmdlet. [`ADObject`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.activedirectory.management.adobject?view=activedirectory-management-10.0) instances piped to this cmdlet are also supported. + +## OUTPUTS + +### PSADTree.TreeGroup + +### PSADTree.TreeUser + +### PSADTree.TreeComputer + +## NOTES + +`treeprincipalmembership` is the alias for this cmdlet. + +## RELATED LINKS diff --git a/docs/en-US/PSADTree.md b/docs/en-US/PSADTree.md new file mode 100644 index 0000000..82e6f4b --- /dev/null +++ b/docs/en-US/PSADTree.md @@ -0,0 +1,23 @@ +--- +Module Name: PSADTree +Module Guid: e49013dc-4106-4a95-aebc-b2669cbadeab +Download Help Link: +Help Version: 1.0.0.0 +Locale: en-US +--- + +# PSADTree Module + +## Description + +PSADTree is a PowerShell Module with cmdlets that emulate the [`tree` command](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/tree) for Active Directory Principals. + +## PSADTree Cmdlets + +### [Get-ADTreeGroupMember](Get-ADTreeGroupMember.md) + +The `Get-ADTreeGroupMember` cmdlet displays members of an Active Directory Group in a tree structure. + +### [Get-ADTreePrincipalGroupMembership](Get-ADTreePrincipalGroupMembership.md) + +The `Get-ADTreePrincipalGroupMembership` cmdlet displays an Active Directory Principal group membership in a tree structure. diff --git a/module/PSADTree.Format.ps1xml b/module/PSADTree.Format.ps1xml new file mode 100644 index 0000000..9eef827 --- /dev/null +++ b/module/PSADTree.Format.ps1xml @@ -0,0 +1,56 @@ + + + + + treeview + + PSADTree.TreeObjectBase + + + [PSADTree.Internal._FormattingInternals]::GetSource($_) + + + + + + + + + Left + + + + + Right + + + + Left + + + + + + + + $_.Domain -replace '^DC=|(?<!\\),.+' + + + ObjectClass + + + Hierarchy + + + + + + + + diff --git a/module/PSADTree.psd1 b/module/PSADTree.psd1 new file mode 100644 index 0000000..3755427 --- /dev/null +++ b/module/PSADTree.psd1 @@ -0,0 +1,143 @@ +# +# Module manifest for module 'PSADTree' +# +# Generated by: Santiago Squarzon +# +# Generated on: 8/14/2023 +# + +@{ + + # Script module or binary module file associated with this manifest. + RootModule = if ($PSEdition -eq 'Core') { + 'bin/net6.0/PSADTree.dll' + } + else { + 'bin/net472/PSADTree.dll' + } + + # Version number of this module. + ModuleVersion = '1.1.0' + + # Supported PSEditions + # CompatiblePSEditions = @() + + # ID used to uniquely identify this module + GUID = 'e49013dc-4106-4a95-aebc-b2669cbadeab' + + # Author of this module + Author = 'Santiago Squarzon' + + # Company or vendor of this module + CompanyName = 'Unknown' + + # Copyright statement for this module + Copyright = '(c) Santiago Squarzon. All rights reserved.' + + # Description of the functionality provided by this module + Description = 'tree commands for Active Directory' + + # Minimum version of the PowerShell engine required by this module + PowerShellVersion = '5.1' + + # Name of the PowerShell host required by this module + # PowerShellHostName = '' + + # Minimum version of the PowerShell host required by this module + # PowerShellHostVersion = '' + + # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. + # DotNetFrameworkVersion = '' + + # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. + # ClrVersion = '' + + # Processor architecture (None, X86, Amd64) required by this module + # ProcessorArchitecture = '' + + # Modules that must be imported into the global environment prior to importing this module + # RequiredModules = @() + + # Assemblies that must be loaded prior to importing this module + RequiredAssemblies = @('System.DirectoryServices.AccountManagement') + + # Script files (.ps1) that are run in the caller's environment prior to importing this module. + # ScriptsToProcess = @() + + # Type files (.ps1xml) to be loaded when importing this module + # TypesToProcess = @() + + # Format files (.ps1xml) to be loaded when importing this module + FormatsToProcess = @('PSADTree.Format.ps1xml') + + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess + # NestedModules = @() + + # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. + FunctionsToExport = @() + + # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. + CmdletsToExport = @( + 'Get-ADTreeGroupMember' + 'Get-ADTreePrincipalGroupMembership' + ) + + # Variables to export from this module + VariablesToExport = @() + + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. + AliasesToExport = @( + 'treegroupmember' + 'treeprincipalmembership' + ) + + # DSC resources to export from this module + # DscResourcesToExport = @() + + # List of all modules packaged with this module + # ModuleList = @() + + # List of all files packaged with this module + # FileList = @() + + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. + PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + # Tags = @() + + # A URL to the license for this module. + LicenseUri = 'https://github.com/santisq/PSADTree/blob/main/LICENSE' + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/santisq/PSADTree' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + + } # End of PrivateData hashtable + + # HelpInfo URI of this module + # HelpInfoURI = '' + + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. + # DefaultCommandPrefix = '' + +} + diff --git a/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs new file mode 100644 index 0000000..ab2ffca --- /dev/null +++ b/src/PSADTree/Commands/GetADTreeGroupMemberCommand.cs @@ -0,0 +1,225 @@ +using System; +using System.DirectoryServices.AccountManagement; +using System.Management.Automation; + +namespace PSADTree; + +[Cmdlet( + VerbsCommon.Get, "ADTreeGroupMember", + DefaultParameterSetName = DepthParameterSet)] +[Alias("treegroupmember")] +[OutputType( + typeof(TreeGroup), + typeof(TreeUser), + typeof(TreeComputer))] +public sealed class GetADTreeGroupMemberCommand : PSADTreeCmdletBase +{ + [Parameter] + public SwitchParameter Group { get; set; } + + protected override void ProcessRecord() + { + Dbg.Assert(Identity is not null); + Dbg.Assert(_context is not null); + + try + { + using GroupPrincipal? group = GroupPrincipal.FindByIdentity(_context, Identity); + if (group is null) + { + WriteError(ErrorHelper.IdentityNotFound(Identity)); + return; + } + + WriteObject( + sendToPipeline: Traverse( + groupPrincipal: group, + source: group.DistinguishedName), + enumerateCollection: true); + } + catch (Exception e) when (e is PipelineStoppedException or FlowControlException) + { + throw; + } + catch (MultipleMatchesException e) + { + WriteError(ErrorHelper.AmbiguousIdentity(Identity, e)); + } + catch (Exception e) + { + WriteError(ErrorHelper.Unspecified(Identity, e)); + } + } + + private TreeObjectBase[] Traverse( + GroupPrincipal groupPrincipal, + string source) + { + int depth; + Clear(); + Push(groupPrincipal, new TreeGroup(source, groupPrincipal)); + + while (_stack.Count > 0) + { + (GroupPrincipal? current, TreeGroup treeGroup) = _stack.Pop(); + + try + { + depth = treeGroup.Depth + 1; + + // if this node has been already processed + if (!_cache.TryAdd(treeGroup)) + { + current?.Dispose(); + treeGroup.Hook(_cache); + _index.Add(treeGroup); + + // if it's a circular reference, go next + if (TreeCache.IsCircular(treeGroup)) + { + treeGroup.SetCircularNested(); + continue; + } + + // else, if we want to show all nodes + if (ShowAll.IsPresent) + { + // reconstruct the output without querying AD again + EnumerateMembers(treeGroup, depth); + continue; + } + + // else, just skip this reference and go next + treeGroup.SetProcessed(); + continue; + } + + using PrincipalSearchResult? search = current?.GetMembers(); + + if (search is not null) + { + EnumerateMembers(treeGroup, search, source, depth); + } + + _index.Add(treeGroup); + _index.TryAddPrincipals(); + current?.Dispose(); + } + catch (Exception e) when (e is PipelineStoppedException or FlowControlException) + { + throw; + } + catch (Exception e) + { + WriteError(ErrorHelper.EnumerationFailure(current, e)); + } + } + + return _index.GetTree(); + } + + private void EnumerateMembers( + TreeGroup parent, + PrincipalSearchResult searchResult, + string source, + int depth) + { + foreach (Principal member in searchResult) + { + IDisposable? disposable = null; + try + { + if (member is { DistinguishedName: null }) + { + disposable = member; + continue; + } + + if (member is not GroupPrincipal) + { + disposable = member; + + if (Group.IsPresent) + { + continue; + } + } + + TreeObjectBase treeObject = ProcessPrincipal( + principal: member, + parent: parent, + source: source, + depth: depth); + + if (ShowAll.IsPresent) + { + parent.AddChild(treeObject); + } + } + finally + { + disposable?.Dispose(); + } + } + } + + private TreeObjectBase ProcessPrincipal( + Principal principal, + TreeGroup parent, + string source, + int depth) + { + return principal switch + { + UserPrincipal user => AddTreeObject(new TreeUser(source, parent, user, depth)), + ComputerPrincipal computer => AddTreeObject(new TreeComputer(source, parent, computer, depth)), + GroupPrincipal group => HandleGroup(parent, group, source, depth), + _ => throw new ArgumentOutOfRangeException(nameof(principal)), + }; + + TreeObjectBase AddTreeObject(TreeObjectBase obj) + { + if (Recursive.IsPresent || depth <= Depth) + { + _index.AddPrincipal(obj); + } + + return obj; + } + + TreeObjectBase HandleGroup( + TreeGroup parent, + GroupPrincipal group, + string source, + int depth) + { + if (_cache.TryGet(group.DistinguishedName, out TreeGroup? treeGroup)) + { + Push(group, (TreeGroup)treeGroup.Clone(parent, depth)); + return treeGroup; + } + + treeGroup = new(source, parent, group, depth); + Push(group, treeGroup); + return treeGroup; + } + } + + private void EnumerateMembers(TreeGroup parent, int depth) + { + bool shouldProcess = Recursive.IsPresent || depth <= Depth; + foreach (TreeObjectBase member in parent.Childs) + { + if (member is TreeGroup treeGroup) + { + Push(null, (TreeGroup)treeGroup.Clone(parent, depth)); + continue; + } + + if (shouldProcess) + { + _index.Add(member.Clone(parent, depth)); + } + } + } +} diff --git a/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs new file mode 100644 index 0000000..799e703 --- /dev/null +++ b/src/PSADTree/Commands/GetADTreePrincipalGroupMembershipCommand.cs @@ -0,0 +1,200 @@ +using System; +using System.DirectoryServices.AccountManagement; +using System.Linq; +using System.Management.Automation; + +namespace PSADTree; + +[Cmdlet( + VerbsCommon.Get, "ADTreePrincipalGroupMembership", + DefaultParameterSetName = DepthParameterSet)] +[Alias("treeprincipalmembership")] +[OutputType( + typeof(TreeGroup), + typeof(TreeUser), + typeof(TreeComputer))] +public sealed class GetADTreePrincipalGroupMembershipCommand : PSADTreeCmdletBase +{ + protected override void ProcessRecord() + { + Dbg.Assert(Identity is not null); + Dbg.Assert(_context is not null); + Principal? principal; + Clear(); + + try + { + principal = Principal.FindByIdentity(_context, Identity); + } + catch (Exception e) when (e is PipelineStoppedException or FlowControlException) + { + throw; + } + catch (MultipleMatchesException e) + { + WriteError(ErrorHelper.AmbiguousIdentity(Identity, e)); + return; + } + catch (Exception e) + { + WriteError(ErrorHelper.Unspecified(Identity, e)); + return; + } + + if (principal is null) + { + WriteError(ErrorHelper.IdentityNotFound(Identity)); + return; + } + + string source = principal.DistinguishedName; + switch (principal) + { + case UserPrincipal user: + _index.Add(new TreeUser(source, user)); + break; + + case ComputerPrincipal computer: + _index.Add(new TreeComputer(source, computer)); + break; + + case GroupPrincipal group: + TreeGroup treeGroup = new(source, group); + _index.Add(treeGroup); + _cache.Add(treeGroup); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(principal)); + } + + try + { + using PrincipalSearchResult search = principal.GetGroups(); + foreach (GroupPrincipal parent in search.Cast()) + { + TreeGroup treeGroup = new(source, null, parent, 1); + Push(parent, treeGroup); + } + } + catch (Exception e) when (e is PipelineStoppedException or FlowControlException) + { + throw; + } + catch (Exception e) + { + WriteError(ErrorHelper.EnumerationFailure(null, e)); + } + finally + { + principal?.Dispose(); + } + + WriteObject( + sendToPipeline: Traverse(source), + enumerateCollection: true); + } + + private TreeObjectBase[] Traverse(string source) + { + int depth; + while (_stack.Count > 0) + { + (GroupPrincipal? current, TreeGroup treeGroup) = _stack.Pop(); + + try + { + depth = treeGroup.Depth + 1; + + // if this node has been already processed + if (!_cache.TryAdd(treeGroup)) + { + current?.Dispose(); + treeGroup.Hook(_cache); + _index.Add(treeGroup); + + // if it's a circular reference, go next + if (TreeCache.IsCircular(treeGroup)) + { + treeGroup.SetCircularNested(); + continue; + } + + // else, if we want to show all nodes + if (ShowAll.IsPresent) + { + // reconstruct the output without querying AD again + EnumerateMembership(treeGroup, depth); + continue; + } + + // else, just skip this reference and go next + treeGroup.SetProcessed(); + continue; + } + + using PrincipalSearchResult? search = current?.GetGroups(); + + if (search is not null) + { + EnumerateMembership(treeGroup, search, source, depth); + } + + _index.Add(treeGroup); + current?.Dispose(); + } + catch (Exception e) when (e is PipelineStoppedException or FlowControlException) + { + throw; + } + catch (Exception e) + { + WriteError(ErrorHelper.EnumerationFailure(current, e)); + } + } + + return _index.GetTree(); + } + + private void EnumerateMembership( + TreeGroup parent, + PrincipalSearchResult searchResult, + string source, + int depth) + { + foreach (GroupPrincipal group in searchResult.Cast()) + { + TreeGroup treeGroup = ProcessGroup(group); + if (ShowAll.IsPresent) + { + parent.AddChild(treeGroup); + } + } + + TreeGroup ProcessGroup(GroupPrincipal group) + { + if (_cache.TryGet(group.DistinguishedName, out TreeGroup? treeGroup)) + { + Push(group, (TreeGroup)treeGroup.Clone(parent, depth)); + return treeGroup; + } + + treeGroup = new(source, parent, group, depth); + Push(group, treeGroup); + return treeGroup; + } + } + + private void EnumerateMembership(TreeGroup parent, int depth) + { + if (!Recursive.IsPresent && depth > Depth) + { + return; + } + + foreach (TreeGroup group in parent.Childs.Cast()) + { + Push(null, (TreeGroup)group.Clone(parent, depth)); + } + } +} diff --git a/src/PSADTree/Dbg.cs b/src/PSADTree/Dbg.cs new file mode 100644 index 0000000..1dee2c7 --- /dev/null +++ b/src/PSADTree/Dbg.cs @@ -0,0 +1,11 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace PSADTree; + +internal static class Dbg +{ + [Conditional("DEBUG")] + public static void Assert([DoesNotReturnIf(false)] bool condition) => + Debug.Assert(condition); +} diff --git a/src/PSADTree/ErrorHelper.cs b/src/PSADTree/ErrorHelper.cs new file mode 100644 index 0000000..dddb695 --- /dev/null +++ b/src/PSADTree/ErrorHelper.cs @@ -0,0 +1,26 @@ +using System; +using System.DirectoryServices.AccountManagement; +using System.Management.Automation; + +namespace PSADTree; + +internal static class ErrorHelper +{ + internal static ErrorRecord IdentityNotFound(string? identity) => + new( + new NoMatchingPrincipalException($"Cannot find an object with identity: '{identity}'."), + "IdentityNotFound", + ErrorCategory.ObjectNotFound, + identity); + + internal static ErrorRecord AmbiguousIdentity(string? identity, Exception exception) => + new(exception, "AmbiguousIdentity", ErrorCategory.InvalidResult, identity); + + internal static ErrorRecord Unspecified(string? identity, Exception exception) => + new(exception, "Unspecified", ErrorCategory.NotSpecified, identity); + + internal static ErrorRecord EnumerationFailure( + GroupPrincipal? groupPrincipal, + Exception exception) => + new(exception, "EnumerationFailure", ErrorCategory.NotSpecified, groupPrincipal); +} diff --git a/src/PSADTree/Internal/_FormattingInternals.cs b/src/PSADTree/Internal/_FormattingInternals.cs new file mode 100644 index 0000000..4cb3171 --- /dev/null +++ b/src/PSADTree/Internal/_FormattingInternals.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using System.Management.Automation; + +namespace PSADTree.Internal; + +#pragma warning disable IDE1006 + +[EditorBrowsable(EditorBrowsableState.Never)] +public static class _FormattingInternals +{ + [Hidden, EditorBrowsable(EditorBrowsableState.Never)] + public static string GetSource(TreeObjectBase treeObject) => + treeObject.Source; +} diff --git a/src/PSADTree/Nullable.cs b/src/PSADTree/Nullable.cs new file mode 100644 index 0000000..97c2603 --- /dev/null +++ b/src/PSADTree/Nullable.cs @@ -0,0 +1,140 @@ +#if !NETCOREAPP + +namespace System.Diagnostics.CodeAnalysis +{ + /// Specifies that null is allowed as an input even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class AllowNullAttribute : Attribute { } + + /// Specifies that null is disallowed as an input even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)] + internal sealed class DisallowNullAttribute : Attribute { } + + /// Specifies that an output may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class MaybeNullAttribute : Attribute { } + + /// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns. + [AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)] + internal sealed class NotNullAttribute : Attribute { } + + /// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class MaybeNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter may be null. + /// + public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class NotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue; + + /// Gets the return value condition. + public bool ReturnValue { get; } + } + + /// Specifies that the output will be non-null if the named parameter is non-null. + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] + internal sealed class NotNullIfNotNullAttribute : Attribute + { + /// Initializes the attribute with the associated parameter name. + /// + /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null. + /// + public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName; + + /// Gets the associated parameter name. + public string ParameterName { get; } + } + + /// Applied to a method that will never return under any circumstance. + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + internal sealed class DoesNotReturnAttribute : Attribute { } + + /// Specifies that the method will not return if the associated Boolean parameter is passed the specified value. + [AttributeUsage(AttributeTargets.Parameter, Inherited = false)] + internal sealed class DoesNotReturnIfAttribute : Attribute + { + /// Initializes the attribute with the specified parameter value. + /// + /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to + /// the associated parameter matches this value. + /// + public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue; + + /// Gets the condition parameter value. + public bool ParameterValue { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullAttribute : Attribute + { + /// Initializes the attribute with a field or property member. + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullAttribute(string member) => Members = new[] { member }; + + /// Initializes the attribute with the list of field and property members. + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullAttribute(params string[] members) => Members = members; + + /// Gets field or property member names. + public string[] Members { get; } + } + + /// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition. + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] + internal sealed class MemberNotNullWhenAttribute : Attribute + { + /// Initializes the attribute with the specified return value condition and a field or property member. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The field or property member that is promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, string member) + { + ReturnValue = returnValue; + Members = new[] { member }; + } + + /// Initializes the attribute with the specified return value condition and list of field and property members. + /// + /// The return value condition. If the method returns this value, the associated parameter will not be null. + /// + /// + /// The list of field and property members that are promised to be not-null. + /// + public MemberNotNullWhenAttribute(bool returnValue, params string[] members) + { + ReturnValue = returnValue; + Members = members; + } + + /// Gets the return value condition. + public bool ReturnValue { get; } + + /// Gets field or property member names. + public string[] Members { get; } + } +} + +#endif diff --git a/src/PSADTree/PSADTree.csproj b/src/PSADTree/PSADTree.csproj new file mode 100644 index 0000000..55fd80f --- /dev/null +++ b/src/PSADTree/PSADTree.csproj @@ -0,0 +1,28 @@ + + + + net6.0;net472 + enable + true + PSADTree + latest + CA1416 + + + + $(DefineConstants);CORE + + + + + + + + + + + + + + + diff --git a/src/PSADTree/PSADTreeCmdletBase.cs b/src/PSADTree/PSADTreeCmdletBase.cs new file mode 100644 index 0000000..1c801c1 --- /dev/null +++ b/src/PSADTree/PSADTreeCmdletBase.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.DirectoryServices.AccountManagement; +using System.Management.Automation; + +namespace PSADTree; + +public abstract class PSADTreeCmdletBase : PSCmdlet, IDisposable +{ + protected const string DepthParameterSet = "Depth"; + + protected const string RecursiveParameterSet = "Recursive"; + + protected PrincipalContext? _context; + + private bool _disposed; + + protected readonly Stack<(GroupPrincipal? group, TreeGroup treeGroup)> _stack = new(); + + internal readonly TreeCache _cache = new(); + + internal readonly TreeIndex _index = new(); + + [Parameter( + Position = 0, + Mandatory = true, + ValueFromPipeline = true, + ValueFromPipelineByPropertyName = true)] + [Alias("DistinguishedName")] + public string? Identity { get; set; } + + [Parameter] + public string? Server { get; set; } + + [Parameter(ParameterSetName = DepthParameterSet)] + public uint Depth { get; set; } = 3; + + [Parameter(ParameterSetName = RecursiveParameterSet)] + public SwitchParameter Recursive { get; set; } + + [Parameter] + public SwitchParameter ShowAll { get; set; } + + protected override void BeginProcessing() + { + try + { + if (Server is null) + { + _context = new PrincipalContext(ContextType.Domain); + return; + } + + _context = new PrincipalContext(ContextType.Domain, Server); + } + catch (Exception e) + { + ThrowTerminatingError(new ErrorRecord( + e, "SetPrincipalContext", ErrorCategory.ConnectionError, null)); + } + } + + protected void Push(GroupPrincipal? groupPrincipal, TreeGroup treeGroup) + { + if (Recursive.IsPresent || treeGroup.Depth <= Depth) + { + _stack.Push((groupPrincipal, treeGroup)); + } + } + + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _context?.Dispose(); + _disposed = true; + } + } + + protected void Clear() + { + _index.Clear(); + _cache.Clear(); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/PSADTree/TreeCache.cs b/src/PSADTree/TreeCache.cs new file mode 100644 index 0000000..f045052 --- /dev/null +++ b/src/PSADTree/TreeCache.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace PSADTree; + +internal sealed class TreeCache +{ + private readonly Dictionary _cache; + + internal TreeGroup this[string distinguishedName] => + _cache[distinguishedName]; + + internal TreeCache() => _cache = new(); + + internal void Add(TreeGroup treeGroup) => + _cache.Add(treeGroup.DistinguishedName, treeGroup); + + internal bool TryAdd(TreeGroup group) + { + if (_cache.ContainsKey(group.DistinguishedName)) + { + return false; + } + + _cache.Add(group.DistinguishedName, group); + return true; + } + + internal bool TryGet( + string distinguishedName, + [NotNullWhen(true)] out TreeGroup? principal) => + _cache.TryGetValue(distinguishedName, out principal); + + internal static bool IsCircular(TreeGroup node) + { + if (node.Parent is null) + { + return false; + } + + TreeGroup? current = node.Parent; + while (current is not null) + { + if (node.DistinguishedName == current.DistinguishedName) + { + return true; + } + + current = current.Parent; + } + + return false; + } + + internal void Clear() => _cache.Clear(); +} diff --git a/src/PSADTree/TreeComputer.cs b/src/PSADTree/TreeComputer.cs new file mode 100644 index 0000000..e06906a --- /dev/null +++ b/src/PSADTree/TreeComputer.cs @@ -0,0 +1,30 @@ +using System.DirectoryServices.AccountManagement; + +namespace PSADTree; + +public sealed class TreeComputer : TreeObjectBase +{ + private TreeComputer( + TreeComputer computer, + TreeGroup parent, + int depth) + : base(computer, parent, depth) + { } + + internal TreeComputer( + string source, + TreeGroup? parent, + ComputerPrincipal computer, + int depth) + : base(source, parent, computer, depth) + { } + + internal TreeComputer( + string source, + ComputerPrincipal computer) + : base(source, computer) + { } + + internal override TreeObjectBase Clone(TreeGroup parent, int depth) => + new TreeComputer(this, parent, depth); +} diff --git a/src/PSADTree/TreeExtensions.cs b/src/PSADTree/TreeExtensions.cs new file mode 100644 index 0000000..d489690 --- /dev/null +++ b/src/PSADTree/TreeExtensions.cs @@ -0,0 +1,70 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PSADTree; + +internal static class TreeExtensions +{ + private static readonly Regex s_reDefaultNamingContext = new( + "(?<=,)DC=.+$", + RegexOptions.Compiled); + + private static readonly StringBuilder s_sb = new(); + + internal static string Indent(this string inputString, int indentation) + { + s_sb.Clear(); + + return s_sb + .Append(' ', (4 * indentation) - 4) + .Append("└── ") + .Append(inputString) + .ToString(); + } + + internal static string GetDefaultNamingContext(this string distinguishedName) => + s_reDefaultNamingContext.Match(distinguishedName).Value; + + internal static TreeObjectBase[] ConvertToTree( + this TreeObjectBase[] inputObject) + { + int index; + TreeObjectBase current; + for (int i = 0; i < inputObject.Length; i++) + { + current = inputObject[i]; + if ((index = current.Hierarchy.IndexOf('└')) == -1) + { + continue; + } + + int z; + char[] replace; + for (z = i - 1; z >= 0; z--) + { + current = inputObject[z]; + if (!char.IsWhiteSpace(current.Hierarchy[index])) + { + UpdateCorner(index, current); + break; + } + + replace = current.Hierarchy.ToCharArray(); + replace[index] = '│'; + current.Hierarchy = new string(replace); + } + } + + return inputObject; + } + + private static void UpdateCorner(int index, TreeObjectBase current) + { + if (current.Hierarchy[index] == '└') + { + char[] replace = current.Hierarchy.ToCharArray(); + replace[index] = '├'; + current.Hierarchy = new string(replace); + } + } +} diff --git a/src/PSADTree/TreeGroup.cs b/src/PSADTree/TreeGroup.cs new file mode 100644 index 0000000..54696c6 --- /dev/null +++ b/src/PSADTree/TreeGroup.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.DirectoryServices.AccountManagement; + +namespace PSADTree; + +public sealed class TreeGroup : TreeObjectBase +{ + private const string _isCircular = " ↔ Circular Reference"; + + private const string _isProcessed = " ↔ Processed Group"; + + private const string _vtBrightRed = "\x1B[91m"; + + private const string _vtReset = "\x1B[0m"; + + private List? _childs; + + public ReadOnlyCollection Childs => new(_childs ??= new()); + + public bool IsCircular { get; private set; } + + private TreeGroup( + TreeGroup group, + TreeGroup parent, + int depth) + : base(group, parent, depth) + { + _childs = group._childs; + } + + internal TreeGroup( + string source, + GroupPrincipal group) + : base(source, group) + { } + + internal TreeGroup( + string source, + TreeGroup? parent, + GroupPrincipal group, + int depth) + : base(source, parent, group, depth) + { } + + internal void SetCircularNested() + { + IsCircular = true; + Hierarchy = string.Concat( + Hierarchy.Insert( + Hierarchy.IndexOf("─ ") + 2, + _vtBrightRed), + _isCircular, + _vtReset); + } + + internal void SetProcessed() => + Hierarchy = string.Concat(Hierarchy, _isProcessed); + + internal void Hook(TreeCache cache) => + _childs ??= cache[DistinguishedName]._childs; + + internal void AddChild(TreeObjectBase child) + { + _childs ??= new(); + _childs.Add(child); + } + + internal override TreeObjectBase Clone(TreeGroup parent, int depth) => + new TreeGroup(this, parent, depth); +} diff --git a/src/PSADTree/TreeIndex.cs b/src/PSADTree/TreeIndex.cs new file mode 100644 index 0000000..8df3a0c --- /dev/null +++ b/src/PSADTree/TreeIndex.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace PSADTree; + +internal sealed class TreeIndex +{ + private readonly List _principals; + + private readonly List _output; + + internal TreeIndex() + { + _principals = new(); + _output = new(); + } + + internal void AddPrincipal(TreeObjectBase principal) => + _principals.Add(principal); + + internal void Add(TreeObjectBase principal) => + _output.Add(principal); + + internal void TryAddPrincipals() + { + if (_principals.Count > 0) + { + _output.AddRange(_principals.ToArray()); + _principals.Clear(); + } + } + + internal TreeObjectBase[] GetTree() => + _output.ToArray().ConvertToTree(); + + internal void Clear() + { + _output.Clear(); + _principals.Clear(); + } +} diff --git a/src/PSADTree/TreeObjectBase.cs b/src/PSADTree/TreeObjectBase.cs new file mode 100644 index 0000000..ed515a0 --- /dev/null +++ b/src/PSADTree/TreeObjectBase.cs @@ -0,0 +1,87 @@ +using System; +using System.DirectoryServices.AccountManagement; +using System.Security.Principal; + +namespace PSADTree; + +public abstract class TreeObjectBase +{ + internal int Depth { get; set; } + + internal string Source { get; } + + public TreeGroup? Parent { get; } + + public string Domain { get; } + + public string SamAccountName { get; } + + public string ObjectClass { get; } + + public string Hierarchy { get; internal set; } + + public string DistinguishedName { get; } + + public Guid? ObjectGuid { get; } + + public string UserPrincipalName { get; } + + public string Description { get; } + + public string DisplayName { get; } + + public SecurityIdentifier ObjectSid { get; } + + protected TreeObjectBase( + TreeObjectBase treeObject, + TreeGroup? parent, + int depth) + { + Depth = depth; + Source = treeObject.Source; + SamAccountName = treeObject.SamAccountName; + Domain = treeObject.Domain; + ObjectClass = treeObject.ObjectClass; + DistinguishedName = treeObject.DistinguishedName; + ObjectGuid = treeObject.ObjectGuid; + ObjectSid = treeObject.ObjectSid; + Hierarchy = treeObject.SamAccountName.Indent(depth); + Parent = parent; + UserPrincipalName = treeObject.UserPrincipalName; + Description = treeObject.Description; + DisplayName = treeObject.DisplayName; + } + + protected TreeObjectBase( + string source, + Principal principal) + { + Source = source; + SamAccountName = principal.SamAccountName; + Domain = principal.DistinguishedName.GetDefaultNamingContext(); + ObjectClass = principal.StructuralObjectClass; + DistinguishedName = principal.DistinguishedName; + ObjectGuid = principal.Guid; + ObjectSid = principal.Sid; + Hierarchy = SamAccountName; + UserPrincipalName = principal.UserPrincipalName; + Description = principal.Description; + DisplayName = principal.DisplayName; + } + + protected TreeObjectBase( + string source, + TreeGroup? parent, + Principal principal, + int depth) + : this(source, principal) + { + Depth = depth; + Hierarchy = principal.SamAccountName.Indent(depth); + Parent = parent; + } + + public override string ToString() => DistinguishedName; + + internal abstract TreeObjectBase Clone(TreeGroup parent, int depth); +} diff --git a/src/PSADTree/TreeUser.cs b/src/PSADTree/TreeUser.cs new file mode 100644 index 0000000..2497243 --- /dev/null +++ b/src/PSADTree/TreeUser.cs @@ -0,0 +1,30 @@ +using System.DirectoryServices.AccountManagement; + +namespace PSADTree; + +public sealed class TreeUser : TreeObjectBase +{ + private TreeUser( + TreeUser user, + TreeGroup parent, + int depth) + : base(user, parent, depth) + { } + + internal TreeUser( + string source, + TreeGroup? parent, + UserPrincipal user, + int depth) + : base(source, parent, user, depth) + { } + + internal TreeUser( + string source, + UserPrincipal user) + : base(source, user) + { } + + internal override TreeObjectBase Clone(TreeGroup parent, int depth) => + new TreeUser(this, parent, depth); +} diff --git a/tests/PSADTree.tests.ps1 b/tests/PSADTree.tests.ps1 new file mode 100644 index 0000000..5d1eb4d --- /dev/null +++ b/tests/PSADTree.tests.ps1 @@ -0,0 +1,11 @@ +# I know, this is a shame but setting up a Domain for tests is too much trouble :D +$ErrorActionPreference = 'Stop' + +Describe 'PSADTreeModule' { + It 'Should not throw on import' { + $moduleName = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '..', 'module', '*.psd1'))).BaseName + $manifestPath = [IO.Path]::Combine($PSScriptRoot, '..', 'output', $moduleName) + + { Import-Module $manifestPath } | Should -Not -Throw + } +} diff --git a/tools/PesterTest.ps1 b/tools/PesterTest.ps1 new file mode 100644 index 0000000..92c4a15 --- /dev/null +++ b/tools/PesterTest.ps1 @@ -0,0 +1,63 @@ +<# +.SYNOPSIS +Run Pester test + +.PARAMETER TestPath +The path to the tests to run + +.PARAMETER OutputFile +The path to write the Pester test results to. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [String] + $TestPath, + + [Parameter(Mandatory)] + [String] + $OutputFile +) + +$ErrorActionPreference = 'Stop' +$requirements = Import-PowerShellDataFile ([IO.Path]::Combine($PSScriptRoot, 'requiredModules.psd1')) +foreach ($req in $requirements.GetEnumerator()) { + $importModuleSplat = @{ + Name = ([IO.Path]::Combine($PSScriptRoot, 'Modules', $req.Key)) + Force = $true + DisableNameChecking = $true + } + + Write-Host "Importing: $($importModuleSplat['Name'])" + Import-Module @importModuleSplat +} + +[PSCustomObject] $PSVersionTable | + Select-Object -Property *, @{ + Name = 'Architecture' + Expression = { + switch ([IntPtr]::Size) { + 4 { + 'x86' + } + 8 { + 'x64' + } + default { + 'Unknown' + } + } + } + } | + Format-List | + Out-Host + +$configuration = [PesterConfiguration]::Default +$configuration.Output.Verbosity = 'Detailed' +$configuration.Run.Exit = $true +$configuration.Run.Path = $TestPath +$configuration.TestResult.Enabled = $true +$configuration.TestResult.OutputPath = $OutputFile +$configuration.TestResult.OutputFormat = 'NUnitXml' + +Invoke-Pester -Configuration $configuration -WarningAction Ignore diff --git a/tools/requiredModules.psd1 b/tools/requiredModules.psd1 new file mode 100644 index 0000000..b40540a --- /dev/null +++ b/tools/requiredModules.psd1 @@ -0,0 +1,6 @@ +@{ + InvokeBuild = '5.10.3' + platyPS = '0.14.2' + PSScriptAnalyzer = '1.21.0' + Pester = '5.4.1' +}