From 925a59b4d4aa76f80609930f072ff9321e9ff98d Mon Sep 17 00:00:00 2001 From: Initzero Date: Mon, 5 Oct 2026 08:45:03 +0200 Subject: [PATCH] Restore AD naming validators and report-chain queries --- CHANGELOG.md | 11 ++ IT-ToolBox.psd1 | 3 + IT-ToolBox.psm1 | 3 + .../ConvertTo-ITToolBoxLdapFilterValue.ps1 | 11 ++ Private/Invoke-ITToolBoxAdUser.ps1 | 7 + Private/Test-ITToolBoxDnSyntax.ps1 | 38 ++++ Public/Get-ReportChain.ps1 | 64 +++++++ Public/Test-IsValidDn.ps1 | 22 +++ Public/Test-IsValidUpn.ps1 | 27 +++ README.md | 55 +++++- Staging/v3/Get-ReportChain.ps1 | 172 ------------------ Staging/v3/README.md | 11 +- Staging/v3/Test-IsValidDn.ps1 | 36 ---- Staging/v3/Test-IsValidUpn.ps1 | 43 ----- Tests/AdHelpers.Tests.ps1 | 157 ++++++++++++++++ Tests/Module.Tests.ps1 | 2 +- 16 files changed, 402 insertions(+), 260 deletions(-) create mode 100644 Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 create mode 100644 Private/Invoke-ITToolBoxAdUser.ps1 create mode 100644 Private/Test-ITToolBoxDnSyntax.ps1 create mode 100644 Public/Get-ReportChain.ps1 create mode 100644 Public/Test-IsValidDn.ps1 create mode 100644 Public/Test-IsValidUpn.ps1 delete mode 100644 Staging/v3/Get-ReportChain.ps1 delete mode 100644 Staging/v3/Test-IsValidDn.ps1 delete mode 100644 Staging/v3/Test-IsValidUpn.ps1 create mode 100644 Tests/AdHelpers.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fa79f..e652173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## AD helper restoration (unreleased) + +- Restore Test-IsValidDn, Test-IsValidUpn and Get-ReportChain; export twenty-seven commands. +- Replace DN/UPN regexes with documented practical syntax policies and pipeline support. +- Support DN escapes, multi-valued RDNs, long UPN suffixes and IDN suffixes. +- Preserve report-chain identities, aliases, server selection and property projection. +- Escape LDAP assertion values for UPN lookup and transitive manager queries. +- Require exactly one manager, exclude the manager from results and propagate AD errors. +- Keep ActiveDirectory optional at import; add mocked query and syntax regression tests. +- Complete migration of all former Staging/v3 candidates. + ## Filesystem naming and registry restoration (unreleased) - Restore Remove-SpecialCharacters and Test-RegistryValue; export twenty-four commands. diff --git a/IT-ToolBox.psd1 b/IT-ToolBox.psd1 index 493511f..5990b7f 100644 --- a/IT-ToolBox.psd1 +++ b/IT-ToolBox.psd1 @@ -24,6 +24,9 @@ 'Get-OsUpTime' 'Remove-SpecialCharacters' 'Test-RegistryValue' + 'Test-IsValidDn' + 'Test-IsValidUpn' + 'Get-ReportChain' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/IT-ToolBox.psm1 b/IT-ToolBox.psm1 index 06114f1..8e28cd5 100644 --- a/IT-ToolBox.psm1 +++ b/IT-ToolBox.psm1 @@ -24,6 +24,9 @@ Export-ModuleMember -Function @( 'Get-OsUpTime' 'Remove-SpecialCharacters' 'Test-RegistryValue' + 'Test-IsValidDn' + 'Test-IsValidUpn' + 'Get-ReportChain' 'New-StringEncryption' 'New-StringDecryption' 'New-RandomString' diff --git a/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 b/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 new file mode 100644 index 0000000..c5778d5 --- /dev/null +++ b/Private/ConvertTo-ITToolBoxLdapFilterValue.ps1 @@ -0,0 +1,11 @@ +function ConvertTo-ITToolBoxLdapFilterValue { + param([string]$Value) + $builder = [System.Text.StringBuilder]::new() + foreach ($byte in [System.Text.UTF8Encoding]::new($false, $true).GetBytes($Value)) { + if ($byte -in @(0, 40, 41, 42, 92) -or $byte -ge 128) { + [void]$builder.Append('\').Append($byte.ToString('x2')) + } + else { [void]$builder.Append([char]$byte) } + } + return $builder.ToString() +} diff --git a/Private/Invoke-ITToolBoxAdUser.ps1 b/Private/Invoke-ITToolBoxAdUser.ps1 new file mode 100644 index 0000000..7e92e9d --- /dev/null +++ b/Private/Invoke-ITToolBoxAdUser.ps1 @@ -0,0 +1,7 @@ +function Invoke-ITToolBoxAdUser { + param([hashtable]$Query) + if (-not (Get-Command Get-ADUser -ErrorAction SilentlyContinue)) { + throw [InvalidOperationException]::new('Get-ReportChain requires Get-ADUser from the ActiveDirectory module and a reachable AD endpoint.') + } + Get-ADUser @Query +} diff --git a/Private/Test-ITToolBoxDnSyntax.ps1 b/Private/Test-ITToolBoxDnSyntax.ps1 new file mode 100644 index 0000000..7d16f06 --- /dev/null +++ b/Private/Test-ITToolBoxDnSyntax.ps1 @@ -0,0 +1,38 @@ +function Test-ITToolBoxDnSyntax { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value) -or $Value -match '\p{Cc}') { return $false } + $parts = [System.Collections.Generic.List[string]]::new() + $start = 0 + for ($i = 0; $i -lt $Value.Length; $i++) { + if ($Value[$i] -eq '\') { $i++; continue } + if ($Value[$i] -in @(',', '+')) { + $parts.Add($Value.Substring($start, $i - $start)) + $start = $i + 1 + } + } + $parts.Add($Value.Substring($start)) + foreach ($part in $parts) { + if ($part -cnotmatch '^(?:[a-zA-Z][a-zA-Z0-9-]*|[0-9]+(?:\.[0-9]+)+)=(.*)$') { return $false } + $text = $Matches[1] + if ($text.Length -eq 0) { return $false } + if ($text.StartsWith('#')) { + # Validate hex-string notation only; do not claim to validate ASN.1/BER contents. + if ($text -cnotmatch '^#(?:[a-fA-F0-9]{2})+$') { return $false } + continue + } + for ($j = 0; $j -lt $text.Length; $j++) { + $ch = $text[$j] + if ($ch -eq '\') { + if ($j + 1 -ge $text.Length) { return $false } + if ($j + 2 -lt $text.Length -and $text.Substring($j + 1, 2) -cmatch '^[a-fA-F0-9]{2}$') { + $j += 2 + } + elseif ($text[$j + 1] -in @(' ', '"', '#', '+', ',', ';', '<', '=', '>', '\')) { $j++ } + else { return $false } + } + elseif ($ch -in @('"', '+', ',', ';', '<', '>') -or + ($ch -eq ' ' -and ($j -eq 0 -or $j -eq $text.Length - 1))) { return $false } + } + } + return $true +} diff --git a/Public/Get-ReportChain.ps1 b/Public/Get-ReportChain.ps1 new file mode 100644 index 0000000..1c14d14 --- /dev/null +++ b/Public/Get-ReportChain.ps1 @@ -0,0 +1,64 @@ +function Get-ReportChain { + <# + .SYNOPSIS + Returns users reporting directly or transitively to an AD manager. + .DESCRIPTION + Resolves exactly one manager and queries AD's matching-rule-in-chain manager + relationship. Excludes the manager from results. Escapes LDAP assertion values, + applies DomainController to both queries and propagates errors. Requires the + optional ActiveDirectory Get-ADUser command at invocation, not module import. + Results are selected in Properties order; * returns all requested properties. + #> + [CmdletBinding(DefaultParameterSetName = 'DistinguishedName')] + [OutputType([pscustomobject])] + param( + [Parameter(ParameterSetName = 'SamAccountName', Mandatory = $true)] + [ValidateNotNullOrEmpty()][Alias('UserSam', 'SAM')] + [string]$SamAccountName, + [Parameter(ParameterSetName = 'UserPrincipalName', Mandatory = $true)] + [ValidateNotNullOrEmpty()][Alias('UPN', 'UserUPN')] + [string]$UserPrincipalName, + [Parameter(ParameterSetName = 'DistinguishedName', Mandatory = $true)] + [ValidateNotNullOrEmpty()][Alias('DN', 'DistinguishedName')] + [string]$UserDN, + [ValidateNotNullOrEmpty()] + [string]$DomainController, + [ValidateNotNullOrEmpty()] + [string[]]$Properties = @('SamAccountName', 'UserPrincipalName', 'Mail', 'Manager', 'DirectReports') + ) + foreach ($property in $Properties) { + if ([string]::IsNullOrWhiteSpace($property)) { throw 'Properties must contain nonempty property names.' } + } + if ($PSBoundParameters.ContainsKey('DomainController') -and [string]::IsNullOrWhiteSpace($DomainController)) { + throw 'DomainController cannot be whitespace.' + } + $lookup = @{ Properties = $Properties; ErrorAction = 'Stop' } + switch ($PSCmdlet.ParameterSetName) { + 'SamAccountName' { + if ([string]::IsNullOrWhiteSpace($SamAccountName)) { throw 'SamAccountName cannot be whitespace.' } + $lookup.Identity = $SamAccountName + } + 'UserPrincipalName' { + if (-not (Test-IsValidUpn $UserPrincipalName)) { throw 'UserPrincipalName does not match the supported UPN syntax policy.' } + $escapedUpn = ConvertTo-ITToolBoxLdapFilterValue $UserPrincipalName + $lookup.LDAPFilter = '(userPrincipalName={0})' -f $escapedUpn + } + 'DistinguishedName' { + if (-not (Test-IsValidDn $UserDN)) { throw 'UserDN does not match the supported DN syntax policy.' } + $lookup.Identity = $UserDN + } + } + if ($DomainController) { $lookup.Server = $DomainController } + $managers = @(Invoke-ITToolBoxAdUser -Query $lookup) + if ($managers.Count -ne 1) { throw 'Manager lookup must resolve exactly one user.' } + $managerDn = [string]$managers[0].DistinguishedName + if (-not (Test-IsValidDn $managerDn)) { throw 'The resolved manager did not return a supported distinguished name.' } + $escapedDn = ConvertTo-ITToolBoxLdapFilterValue $managerDn + $query = @{ + Properties = $Properties + LDAPFilter = '(&(manager:1.2.840.113556.1.4.1941:={0})(!(distinguishedName={0})))' -f $escapedDn + ErrorAction = 'Stop' + } + if ($DomainController) { $query.Server = $DomainController } + Invoke-ITToolBoxAdUser -Query $query | Select-Object -Property $Properties +} diff --git a/Public/Test-IsValidDn.ps1 b/Public/Test-IsValidDn.ps1 new file mode 100644 index 0000000..d600734 --- /dev/null +++ b/Public/Test-IsValidDn.ps1 @@ -0,0 +1,22 @@ +function Test-IsValidDn { + <# + .SYNOPSIS + Tests a practical nonempty distinguished-name string syntax. + .DESCRIPTION + Supports attribute descriptors/OIDs, escaped separators, hex escapes and + multi-valued RDNs. Does not require CN/OU/DC attributes or a DC suffix. Rejects + empty attribute values, literal controls, dangling/invalid escapes, unescaped + edge spaces and legacy quoted values. Hex-string notation is checked without + validating BER contents. No schema, directory existence, canonical equality + or decoded UTF-8 validation is performed; this is not a complete RFC parser. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [AllowNull()][AllowEmptyString()] + [Alias('DN', 'DistinguishedName')] + [string]$ObjectDN + ) + process { return (Test-ITToolBoxDnSyntax -Value $ObjectDN) } +} diff --git a/Public/Test-IsValidUpn.ps1 b/Public/Test-IsValidUpn.ps1 new file mode 100644 index 0000000..8e25670 --- /dev/null +++ b/Public/Test-IsValidUpn.ps1 @@ -0,0 +1,27 @@ +function Test-IsValidUpn { + <# + .SYNOPSIS + Tests a practical UPN syntax policy without directory access. + .DESCRIPTION + Requires one @ separator. The ASCII username starts/ends with a letter or + digit and may contain dots, underscores, hyphens and apostrophes internally; + consecutive dots are rejected. The suffix is a DNS/IDN name, including a + single-label name or long suffix. No email parsing, account existence, suffix + registration or complete AD/Entra account-creation policy is implied. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] + [AllowNull()][AllowEmptyString()] + [Alias('UPN', 'ADUpn', 'UniversalPrincipalName')] + [string]$UserUpn + ) + process { + if ([string]::IsNullOrWhiteSpace($UserUpn) -or $UserUpn -match '[\s\p{Cc}]') { return $false } + $parts = $UserUpn.Split('@') + if ($parts.Count -ne 2 -or $parts[0].Contains('..') -or + $parts[0] -cnotmatch '^[a-zA-Z0-9](?:[a-zA-Z0-9._''-]*[a-zA-Z0-9])?$') { return $false } + return (Test-ITToolBoxDnsName -Name $parts[1]) + } +} diff --git a/README.md b/README.md index 8289e05..ffd2367 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ The foundation imports without WinSCP, GnuPG, Active Directory or Exchange depen | Get-OsUpTime | Local OS uptime and remote Windows CIM queries | | Remove-SpecialCharacters | Preview or apply a recursive filesystem naming policy | | Test-RegistryValue | Windows registry value-name existence check | +| Test-IsValidDn | Practical distinguished-name syntax validation | +| Test-IsValidUpn | Practical UPN syntax validation | +| Get-ReportChain | Transitive AD manager report-chain queries | | New-StringEncryption | Passphrase-based AES-256-GCM string encryption | | New-StringDecryption | Authenticate and decrypt the versioned string format | | New-RandomString | Secure random selection from the historical alphabet | @@ -64,9 +67,9 @@ Redaction is opt-in and does not guarantee detection of every secret. - SCP and GnuPG wrappers and bundled WinSCP binaries are removed. Separate modules will own file transfer and OpenPGP; no replacement is bundled here. - `Legacy/` retains string encryption, Exchange and script-context helpers for reference. -- `Staging/v3/` retains 3 candidate commands pending tests and compatibility fixes. - These cover report chains, distinguished names and user principal names. They are not currently exported. -- Only the twenty-four listed commands are exported. Private helpers, variables and aliases +- All commands formerly retained in `Staging/v3/` now have supported implementations. + Its README records the migration; separate legacy/staged integrations remain excluded. They are not currently exported. +- Only the twenty-seven listed commands are exported. Private helpers, variables and aliases are not exported. Existing calls to other v2 commands require the v2 release until those commands return to the supported API. - The module GUID and Git history are preserved. @@ -324,3 +327,49 @@ registries or select an alternate registry view. Filesystem tests use real temporary trees. Windows CI additionally exercises real temporary HKCU keys; those registry integration tests are skipped on Linux/macOS. + +## AD naming and report chains + +`Test-IsValidDn` checks a documented practical DN syntax, with escaped separators, +hex escapes, descriptor/OID attribute types and multi-valued RDNs. It accepts +non-DC-rooted names and does not restrict attributes to CN/OU/DC. It rejects empty +names/values, literal controls, invalid/dangling escapes, unescaped leading/trailing +value spaces and legacy quoted values. Hex-string notation is checked without BER +validation; decoded escape bytes are not checked for UTF-8 validity. This is not a +complete RFC parser, a canonical comparison or a schema/existence check. + +`Test-IsValidUpn` uses a practical ASCII username policy: letters/digits at the +edges, with dots, underscores, hyphens and apostrophes internally, excluding +consecutive dots. The suffix supports DNS/IDN names, long suffixes and single-label +names. This is not email validation or a complete AD/Entra account-creation policy; +it does not check account existence or whether a suffix is configured. + +Both validators preserve their parameter aliases, support pipeline input and +return false for explicit null, empty or unsupported input. These policies replace +the old DN regex and email-derived UPN regex; previously accepted/rejected inputs +can change as described above. + +```powershell +Test-IsValidDn -DN 'CN=Last\, First,OU=People,DC=example,DC=com' +Test-IsValidUpn -UPN 'first.last@example.technology' +Get-ReportChain -SAM 'manager01' -DomainController 'dc01' -Properties SamAccountName,Mail +``` + +`Get-ReportChain` retains SAM, UPN and DN identity parameter sets and aliases, +DomainController, and ordered property selection. Its default properties remain +SamAccountName, UserPrincipalName, Mail, Manager and DirectReports; `-Properties '*'` +requests and projects all properties. It resolves exactly one manager, then uses +AD's matching-rule-in-chain filter to retrieve direct and transitive reports, +excluding the manager itself. Result order is the directory's order. + +UPN lookup uses an escaped LDAP equality assertion rather than interpolated +PowerShell filter expressions. Manager DN assertion values are escaped separately +from DN string escaping, including UTF-8 bytes. Server and terminating error +behavior are applied to both queries. Missing/ambiguous managers and AD failures +throw rather than returning a warning and undefined results. The caller's error +preference is not changed. + +No ActiveDirectory dependency is required to import IT-ToolBox or use the naming +validators. Get-ReportChain requires an available Get-ADUser command and access to +an AD endpoint when invoked; it uses the command's ambient authentication. Tests +mock AD queries and verify filter construction; no live domain query is tested. diff --git a/Staging/v3/Get-ReportChain.ps1 b/Staging/v3/Get-ReportChain.ps1 deleted file mode 100644 index 5482d74..0000000 --- a/Staging/v3/Get-ReportChain.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -function Get-ReportChain -{ - <# - .SYNOPSIS - Cmdlet will get a complete report of all users reporting to a specific manager. - - .DESCRIPTION - Cmdlet will get a complete report of all users reporting to a specific manager. - - By default the following properties are returned: - - - SamAccountName - - UserPrincipalName - - Mail - - Manager - - DirectReports - - Custom properties can be returned via the -Properties parameter - - .PARAMETER SamAccountName - A string representing the SamAccountName of the mager for which reports should be enumerated. - - .PARAMETER UserPrincipalName - A string representing the UserPrincipalName of the mager for which reports should be enumerated. - - .PARAMETER UserDN - A string representing the UserPrincipalName of the mager for which reports should be enumerated. - - .PARAMETER DomainController - A string representing the name of the domain controller that should be used to query Active Directory. - - If parameter is not specified a random domain controller will be automatically used. - - .PARAMETER Properties - An array object representing user properties that should be returned as part of the results. - - Result array will be ordered by the Properties parameter. - - If all objects should be returned the * character can be used with the parameter. - - .EXAMPLE - PS C:\> Get-ReportChain -UserDN 'value1' - - .OUTPUTS - System.Array - #> - - [CmdletBinding(DefaultParameterSetName = 'DistinguishedName', - SupportsPaging = $false, - SupportsShouldProcess = $false)] - [OutputType([array])] - param - ( - [Parameter(ParameterSetName = 'SamAccountNAme', - Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [Alias('UserSam', 'SAM')] - [string] - $SamAccountName, - [Parameter(ParameterSetName = 'UserPrincipalName', - Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [Alias('UPN', 'UserUPN')] - [string] - $UserPrincipalName, - [Parameter(ParameterSetName = 'DistinguishedName', - Mandatory = $true)] - [Alias('DN', 'DistinguishedName')] - [string] - $UserDN, - [Parameter(ParameterSetName = 'DistinguishedName')] - [Parameter(ParameterSetName = 'SamAccountNAme')] - [Parameter(ParameterSetName = 'UserPrincipalName')] - [ValidateNotNullOrEmpty()] - [string] - $DomainController, - [Parameter(ParameterSetName = 'DistinguishedName')] - [Parameter(ParameterSetName = 'SamAccountNAme')] - [Parameter(ParameterSetName = 'UserPrincipalName')] - [ValidateNotNullOrEmpty()] - [string[]] - $Properties = @( - 'SamAccountName', - 'UserPrincipalName', - 'Mail', - 'Manager', - 'DirectReports' - ) - ) - - begin - { - # Prepare command hash - [hashtable]$paramGetUserDn = @{ - Properties = $Properties - } - - # Prepare command hash - [hashtable]$paramGetADReportChain = @{ - Properties = $Properties - } - - switch ($PsCmdlet.ParameterSetName) - { - 'UserPrincipalName' - { - # Check if UPN is valid - if (!(Test-IsEmail -EmailAddress $UserPrincipalName)) - { - throw "$UserPrincipalName is not a valid UPN" - } - else - { - # Append to command hash - $paramGetUserDn.Add('Filter', "UserPrincipalName -eq '$UserPrincipalName'") - } - } - 'DistinguishedName' - { - # Check if DN is in the correct format - if (!(Test-IsValidDN -ObjectDN $UserDN)) - { - throw "$UserDN is not a valid object DN" - } - else - { - # Append to command hash - $paramGetUserDn.Add('Identity', $UserDN) - } - } - 'SamAccountName' - { - # Append to command hash - $paramGetUserDn.Add('Identity', $SamAccountName) - } - } - } - - process - { - try - { - # Check if we should use specific DC - if ($PSBoundParameters.ContainsKey('DomainController')) - { - # Append parameter - $paramGetUserDn.Add('Server', $DomainController) - $paramGetADReportChain.Add('Server', $DomainController) - } - - # Get object DN - [string]$objectDn = (Get-ADUser @paramGetUserDn).'DistinguishedName' - - # Define LDAP filter - [string]$ldapFilter = "(manager:1.2.840.113556.1.4.1941:=$objectDn)" - - # Append paramter - $paramGetADReportChain.Add('LDAPFilter', $ldapFilter) - - [array]$reportChain = Get-ADUser @paramGetADReportChain | Select-Object -Property $Properties - } - catch - { - Write-Warning -Message "Could not find identity $object in AD" - } - } - - end - { - return $reportChain - } -} \ No newline at end of file diff --git a/Staging/v3/README.md b/Staging/v3/README.md index 81c0bc5..53306e4 100644 --- a/Staging/v3/README.md +++ b/Staging/v3/README.md @@ -1,6 +1,7 @@ -# Candidates for v3 +# Completed v3 candidate migration -These existing utilities are retained unchanged but are not loaded or exported. -They return to Public only after behavioral tests and necessary PowerShell 7 fixes. -Remaining candidates cover report chains, distinguished names and user principal names. -This folder is not a supported API and must not be dot-sourced as part of normal module import. +All former candidates in this directory now have supported implementations in +Public with tests and documented compatibility changes. This directory contains +no commands and is not loaded by the module. + +Legacy commands and the separate parent Staging integrations remain excluded. diff --git a/Staging/v3/Test-IsValidDn.ps1 b/Staging/v3/Test-IsValidDn.ps1 deleted file mode 100644 index ef5eb49..0000000 --- a/Staging/v3/Test-IsValidDn.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -function Test-IsValidDN -{ - <# - .SYNOPSIS - Cmdlet will check if the input string is a valid distinguishedname. - - .DESCRIPTION - Cmdlet will check if the input string is a valid distinguishedname. - - Cmdlet is intended as a dignostic tool for input validation - - .PARAMETER ObjectDN - A string representing the object distinguishedname. - - .EXAMPLE - PS C:\> Test-IsValidDN -ObjectDN 'Value1' - - .NOTES - Additional information about the function. - #> - - [OutputType([bool])] - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [Alias('DN', 'DistinguishedName')] - [string] - $ObjectDN - ) - - # Define DN Regex - [regex]$distinguishedNameRegex = '^(?:(?CN=(?(?:[^,]|\,)*)),)?(?:(?(?:(?:CN|OU)=(?:[^,]|\,)+,?)+),)?(?(?:DC=(?:[^,]|\,)+,?)+)$' - - return $ObjectDN -match $distinguishedNameRegex -} \ No newline at end of file diff --git a/Staging/v3/Test-IsValidUpn.ps1 b/Staging/v3/Test-IsValidUpn.ps1 deleted file mode 100644 index 9f4a64c..0000000 --- a/Staging/v3/Test-IsValidUpn.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -function Test-IsValidUpn -{ - <# - .SYNOPSIS - Function will check if string is a valid UPN. - - .DESCRIPTION - Function is similar to Test-IsValidUpn but used to check - if input string is a valid Active Directory UPN using - a regex. - - .PARAMETER UserUpn - A string containing an AD UPN (Universal Principal - Name) - - .NOTES - Function is using a different mechanism than the Test-IsValidUpn - on as a valid email address could be an invalid AD UPN. - - Example: me@myself..com # Valid email address but invalid - AD UPN - - .OUTPUTS - System.Boolean - #> - - [OutputType([Boolean])] - param - ( - [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] - [Alias('UPN', 'ADUpn', 'UniversalPrincipalName')] - [string]$UserUpn - ) - - [string]$UpnRegEx = "^(?("")("".+?""@)|(([0-9a-zA-Z]((\.(?!\.))|" - $UpnRegEx += "[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-zA-Z])@))" - $UpnRegEx += "(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|" - $UpnRegEx += "(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,6}))$" - - # Return $true if valid - return $UserUpn -match $UpnRegEx -} \ No newline at end of file diff --git a/Tests/AdHelpers.Tests.ps1 b/Tests/AdHelpers.Tests.ps1 new file mode 100644 index 0000000..ee7302e --- /dev/null +++ b/Tests/AdHelpers.Tests.ps1 @@ -0,0 +1,157 @@ +BeforeAll { Import-Module (Join-Path $PSScriptRoot '../IT-ToolBox.psd1') -Force -ErrorAction Stop } + +Describe 'Distinguished-name syntax policy' { + It 'accepts supported syntax: ' -ForEach @( + @{ Value = 'CN=Person,OU=People,DC=example,DC=com' } + @{ Value = 'cn=Person,dc=example,dc=technology' } + @{ Value = 'CN=Last\, First,OU=People,DC=example' } + @{ Value = 'CN=Last\2C First,DC=example' } + @{ Value = 'CN=A\+B+UID=123,OU=People,DC=example' } + @{ Value = 'CN=\ Leading\ ,DC=example' } + @{ Value = 'CN=\#literal,DC=example' } + @{ Value = 'CN=A=B,DC=example' } + @{ Value = 'CN=Jörg,DC=example' } + @{ Value = '2.5.4.3=#04024869,DC=example' } + @{ Value = 'CN=Standalone' } + @{ Value = 'CN=Back\\Slash,DC=example' } + ) { Test-IsValidDn $Value | Should -BeTrue } + It 'rejects malformed or unsupported syntax: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' } + @{ Value = 'Person' }; @{ Value = 'CN=' }; @{ Value = 'CN=Person,' } + @{ Value = ',CN=Person' }; @{ Value = 'CN=Person,,DC=example' } + @{ Value = 'CN=Person+,DC=example' }; @{ Value = 'CN=Person,DC=' } + @{ Value = ' CN=Person' }; @{ Value = 'CN= Person' }; @{ Value = 'CN=Person ' } + @{ Value = 'CN=Person\' }; @{ Value = 'CN=A\z,DC=example' } + @{ Value = 'CN=A\2Z,DC=example' }; @{ Value = 'CN="Last, First",DC=example' } + @{ Value = 'CN=#odd' }; @{ Value = 'CN=#123' }; @{ Value = 'CN=A' -ForEach @( + @{ Value = 'alice@example.com' }; @{ Value = 'a@localhost' } + @{ Value = 'first.last@example.technology' }; @{ Value = 'first_last@sub-domain.example' } + @{ Value = "o'brien@example.com" }; @{ Value = 'alice@bücher.example' } + ) { Test-IsValidUpn $Value | Should -BeTrue } + It 'rejects unsupported UPN syntax: ' -ForEach @( + @{ Value = $null }; @{ Value = '' }; @{ Value = ' ' } + @{ Value = 'alice' }; @{ Value = '@example.com' }; @{ Value = 'alice@' } + @{ Value = 'alice@@example.com' }; @{ Value = ' alice@example.com' } + @{ Value = 'alice@example.com ' }; @{ Value = 'a..b@example.com' } + @{ Value = '.alice@example.com' }; @{ Value = 'alice.@example.com' } + @{ Value = 'alice@bad_domain.example' }; @{ Value = 'alice@example..com' } + @{ Value = 'alice@-bad.example' }; @{ Value = 'alice@[127.0.0.1]' } + @{ Value = '"alice"@example.com' }; @{ Value = 'alice+tag@example.com' } + ) { Test-IsValidUpn $Value | Should -BeFalse } + It 'preserves aliases and returns independent pipeline results' { + Test-IsValidUpn -UPN 'alice@example.com' | Should -BeTrue + Test-IsValidUpn -ADUpn 'alice@example.com' | Should -BeTrue + Test-IsValidUpn -UniversalPrincipalName 'alice@example.com' | Should -BeTrue + $result = @('a@example.com', '', 'bad') | Test-IsValidUpn + ($result -join ',') | Should -Be 'True,False,False' + } +} + +Describe 'Report chain query construction' { + BeforeEach { + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { + param($Query) + if ($Query.LDAPFilter -like '(&(manager:*') { + [pscustomobject]@{ SamAccountName = 'report'; UserPrincipalName = 'report@example.com'; Mail = 'report@example.com'; Manager = 'CN=Boss,DC=example'; DirectReports = @(); DistinguishedName = 'CN=Report,DC=example' } + } + else { [pscustomobject]@{ DistinguishedName = 'CN=Boss,DC=example' } } + } + } + It 'resolves SAM literally and returns default properties in order' { + $result = Get-ReportChain -SAM boss + $result.SamAccountName | Should -Be 'report' + ($result.PSObject.Properties.Name -join ',') | Should -Be 'SamAccountName,UserPrincipalName,Mail,Manager,DirectReports' + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 1 -ParameterFilter { $Query.Identity -eq 'boss' } + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 1 -ParameterFilter { + $Query.LDAPFilter -eq '(&(manager:1.2.840.113556.1.4.1941:=CN=Boss,DC=example)(!(distinguishedName=CN=Boss,DC=example)))' + } + } + It 'uses an LDAP UPN equality filter without PowerShell expression interpolation' { + Get-ReportChain -UserUPN "o'brien@example.com" | Out-Null + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 1 -ParameterFilter { $Query.LDAPFilter -eq "(userPrincipalName=o'brien@example.com)" } + } + It 'supports DN identity, custom property order and Server on both calls' { + $result = Get-ReportChain -DN 'CN=Boss,DC=example' -DomainController dc01 -Properties Mail,SamAccountName + ($result.PSObject.Properties.Name -join ',') | Should -Be 'Mail,SamAccountName' + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 2 -Exactly -ParameterFilter { $Query.Server -eq 'dc01' -and $Query.ErrorAction -eq 'Stop' } + } + It 'supports all-property projection' { + (Get-ReportChain -UserSam boss -Properties '*').DistinguishedName | Should -Be 'CN=Report,DC=example' + } + It 'escapes resolved manager DN metacharacters and Unicode' { + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { + param($Query) + if ($Query.Identity) { [pscustomobject]@{ DistinguishedName = 'CN=Jörg*(Boss)\, One,DC=example' } } + } + Get-ReportChain -SAM boss | Out-Null + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 1 -ParameterFilter { + $Query.LDAPFilter -like '*CN=J\c3\b6rg\2a\28Boss\29\5c, One,DC=example*' + } + } + It 'rejects malformed identities and options before querying' { + { Get-ReportChain -DN 'bad' } | Should -Throw + { Get-ReportChain -UPN 'bad' } | Should -Throw + { Get-ReportChain -SAM ' ' } | Should -Throw + { Get-ReportChain -SAM boss -DomainController ' ' } | Should -Throw + { Get-ReportChain -SAM boss -Properties ' ' } | Should -Throw + Should -Invoke Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox -Times 0 -Exactly + } + It 'rejects missing and ambiguous manager results' { + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox {} + { Get-ReportChain -SAM boss } | Should -Throw '*exactly one*' + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { @([pscustomobject]@{ DistinguishedName = 'CN=A' }, [pscustomobject]@{ DistinguishedName = 'CN=B' }) } + { Get-ReportChain -SAM boss } | Should -Throw '*exactly one*' + } + It 'rejects a malformed resolved manager DN' { + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { [pscustomobject]@{ DistinguishedName = 'bad' } } + { Get-ReportChain -SAM boss } | Should -Throw '*resolved manager*' + } + It 'propagates lookup and report-query failures without changing caller preference' { + $preference = $ErrorActionPreference + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { throw 'lookup failed' } + { Get-ReportChain -SAM boss } | Should -Throw '*lookup failed*' + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { + param($Query) + if ($Query.Identity) { [pscustomobject]@{ DistinguishedName = 'CN=Boss' } } else { throw 'report failed' } + } + { Get-ReportChain -SAM boss } | Should -Throw '*report failed*' + $ErrorActionPreference | Should -Be $preference + } + It 'returns no records when the manager has no reports' { + Mock Invoke-ITToolBoxAdUser -ModuleName IT-ToolBox { + param($Query) + if ($Query.Identity) { [pscustomobject]@{ DistinguishedName = 'CN=Boss' } } + } + @(Get-ReportChain -SAM boss).Count | Should -Be 0 + } +} + +Describe 'Private LDAP escaping and AD dependency' { + It 'escapes all assertion metacharacters and Unicode UTF-8 bytes' { + InModuleScope IT-ToolBox { + ConvertTo-ITToolBoxLdapFilterValue "a$([char]0)*()\é" | Should -Be 'a\00\2a\28\29\5c\c3\a9' + } + } + It 'reports an unavailable AD command only when the AD helper is invoked' { + InModuleScope IT-ToolBox { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Get-ADUser' } + { Invoke-ITToolBoxAdUser -Query @{ Identity = 'boss' } } | Should -Throw '*requires Get-ADUser*' + } + } + It 'does not export private helpers' { + Get-Command ConvertTo-ITToolBoxLdapFilterValue -Module IT-ToolBox -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Get-Command Invoke-ITToolBoxAdUser -Module IT-ToolBox -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } +} diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index 9fe4d96..2f3c022 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -10,7 +10,7 @@ Describe 'IT-ToolBox module boundary' { } It 'exports exactly the supported commands' { - $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-RegistryValue', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object + $expected = @('New-LogEntry', 'New-Timer', 'Get-TimerStatus', 'Stop-Timer', 'Get-ElapsedTime', 'Test-FileName', 'Test-IsValidPath', 'Test-IsIP', 'Test-IsDate', 'Test-IsEmail', 'Test-IsUrl', 'Convert-LogonTimestamp', 'Get-OsUpTime', 'Remove-SpecialCharacters', 'Test-RegistryValue', 'Test-IsValidDn', 'Test-IsValidUpn', 'Get-ReportChain', 'New-StringEncryption', 'New-StringDecryption', 'New-RandomString', 'New-RandomPassword', 'New-PhoneticPassword', 'New-ApiRequest', 'New-StringConversion', 'Get-StringCheckSum', 'Get-StringHashCode') | Sort-Object $actual = @($module.ExportedFunctions.Keys | Sort-Object) ($actual -join ',') | Should -Be ($expected -join ',') $module.ExportedVariables.Count | Should -Be 0