Public/Schema/Get-ADSchemaAttribute.ps1

<#
    .SYNOPSIS
    Report on the Active Directory schema attributes, with their indexing, replication and confidentiality flags.
 
    .DESCRIPTION
    Returns one object per attributeSchema object of the forest schema, with its definition (syntax, single valued,
    range, dates) and its 'searchFlags' decoded into readable columns rather than left as a raw integer.
 
    Two columns answer the confidentiality question, and they are not the same question:
 
    - 'Confidential' says the attribute IS marked confidential, which is bit 128 of 'searchFlags'. Reading it then
      requires the CONTROL_ACCESS right, a plain READ_PROPERTY is no longer enough.
    - 'CanBeConfidential' says the attribute COULD be marked confidential. Only attributes that are not part of the
      base schema can. Microsoft is explicit about it: bit 128 is "ignored for base schema attributes
      (systemFlags=0x10)". So an attribute can carry the bit and have it do nothing, which is exactly the kind of
      false sense of security an audit has to surface. That case shows up as Confidential = True and
      CanBeConfidential = False.
 
    Reference: https://learn.microsoft.com/troubleshoot/windows-server/windows-security/mark-attribute-as-confidential
 
    'IsMemberOfPartialAttributeSet' tells whether the attribute is replicated to the global catalog, and
    'RodcFiltered' whether it is deliberately kept away from read only domain controllers.
 
    .PARAMETER Attribute
    Restrict the output to the attributes whose LDAP display name matches. Wildcards are supported, so 'ms-Mcs-Adm*'
    or '*password*' work. Defaults to every attribute of the schema.
 
    .PARAMETER ConfidentialOnly
    Return only the attributes marked confidential. Shorthand for the audit question "what is confidential here".
 
    .PARAMETER Server
    Domain controller or domain to query. Defaults to the one selected by the ActiveDirectory module.
 
    .EXAMPLE
    Get-ADSchemaAttribute
 
    Returns every attribute of the schema with its decoded flags.
 
    .EXAMPLE
    Get-ADSchemaAttribute -Attribute 'msLAPS-Password'
 
    Returns the definition of the Windows LAPS password attribute. Its searchFlags of 904 decodes to RodcFiltered,
    NeverAudit, Confidential and PreserveOnDelete, which is the reference layout for a sensitive attribute.
 
    .EXAMPLE
    Get-ADSchemaAttribute -ConfidentialOnly
 
    Returns the attributes marked confidential in this forest.
 
    .EXAMPLE
    Get-ADSchemaAttribute -ConfidentialOnly | Where-Object { -not $_.CanBeConfidential }
 
    Returns the attributes carrying the confidential bit while being part of the base schema, so the bit is ignored
    and the data is readable by anyone. Worth checking on every audit.
 
    .EXAMPLE
    Get-ADSchemaAttribute | Where-Object { $_.CanBeConfidential -and -not $_.Confidential -and $_.LdapDisplayName -like '*password*' }
 
    Returns the extended attributes that look credential related and could be marked confidential but are not.
 
    .EXAMPLE
    Get-ADSchemaAttribute | Where-Object { $_.Confidential -and -not $_.RodcFiltered }
 
    Returns the confidential attributes that are still replicated to the read only domain controllers. Microsoft
    recommends doing both, so this is a gap worth reviewing.
 
    .EXAMPLE
    Get-ADSchemaAttribute | Export-Csv -Path 'C:\temp\ADSchemaInfo.csv' -NoTypeInformation -Encoding UTF8 -Delimiter ';'
 
    Exports the full schema report.
 
    .OUTPUTS
    System.Management.Automation.PSCustomObject, one per attributeSchema object, emitted as they are read.
 
    .NOTES
    Version : 2.0 - August 2026. Migrated from the ActiveDirectory-Toolbox repository.
    Author : Bastien Perez - ITPro-Tips (https://itpro-tips.com)
 
    .LINK
    https://itpro-tips.com
#>


function Get-ADSchemaAttribute {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Position = 0)]
        [string]$Attribute,

        [switch]$ConfidentialOnly,

        [string]$Server
    )

    $ErrorActionPreference = 'Stop'

    # Common parameters forwarded to every ActiveDirectory cmdlet call
    $adParameters = @{ ErrorAction = 'Stop' }

    if ($PSBoundParameters.ContainsKey('Server')) {
        $adParameters.Add('Server', $Server)
    }

    try {
        $rootDSE = Get-ADRootDSE @adParameters
    }
    catch {
        Write-Error "Unable to read the RootDSE: $($_.Exception.Message)"
        return
    }

    $schemaNamingContext = $rootDSE.schemaNamingContext

    # Filtering server side rather than in PowerShell, the schema partition holds thousands of objects
    if ([string]::IsNullOrWhiteSpace($Attribute)) {
        $filter = "objectCategory -eq 'attributeSchema'"
    }
    else {
        $filter = "objectCategory -eq 'attributeSchema' -and lDAPDisplayName -like '$Attribute'"
    }

    $properties = @(
        'lDAPDisplayName', 'adminDescription', 'adminDisplayName', 'attributeID', 'attributeSyntax',
        'isSingleValued', 'systemOnly', 'linkID', 'rangeLower', 'rangeUpper', 'searchFlags', 'systemFlags',
        'isMemberOfPartialAttributeSet', 'schemaIDGUID', 'whenCreated', 'whenChanged'
    )

    $getADObjectParams = $adParameters.Clone()
    $getADObjectParams.SearchBase = $schemaNamingContext
    $getADObjectParams.Filter = $filter
    $getADObjectParams.Properties = $properties

    try {
        $attributesFound = @(Get-ADObject @getADObjectParams)
    }
    catch {
        Write-Error "Unable to query the schema partition: $($_.Exception.Message)"
        return
    }

    if ($attributesFound.Count -eq 0) {
        Write-Warning "No attribute matched '$Attribute' in the schema"
        return
    }

    Write-Verbose "[i] $($attributesFound.Count) attributes read from $schemaNamingContext"

    foreach ($attributeFound in $attributesFound) {

        $flags = ConvertFrom-ADSearchFlags -SearchFlags $attributeFound.searchFlags

        if ($ConfidentialOnly -and (-not $flags.Confidential)) {
            continue
        }

        # systemFlags bit 0x10 is FLAG_SCHEMA_BASE_OBJECT. The confidential bit is ignored on those attributes,
        # so they cannot be protected this way whatever searchFlags says.
        $systemFlags = 0

        if ($null -ne $attributeFound.systemFlags) {
            $systemFlags = [int]$attributeFound.systemFlags
        }

        $isBaseSchemaObject = [bool]($systemFlags -band 0x10)

        # Emitted one by one rather than collected first, so the results start showing immediately
        [PSCustomObject][ordered]@{
            LdapDisplayName               = $attributeFound.lDAPDisplayName
            AdminDisplayName              = $attributeFound.adminDisplayName
            AdminDescription              = $attributeFound.adminDescription
            AttributeID                   = $attributeFound.attributeID
            AttributeSyntax               = $attributeFound.attributeSyntax
            IsSingleValued                = $attributeFound.isSingleValued
            SystemOnly                    = $attributeFound.systemOnly
            LinkID                        = $attributeFound.linkID
            RangeLower                    = $attributeFound.rangeLower
            RangeUpper                    = $attributeFound.rangeUpper
            IsMemberOfPartialAttributeSet = [bool]$attributeFound.isMemberOfPartialAttributeSet
            Confidential                  = $flags.Confidential
            CanBeConfidential             = (-not $isBaseSchemaObject)
            IsBaseSchemaObject            = $isBaseSchemaObject
            RodcFiltered                  = $flags.RodcFiltered
            NeverAudit                    = $flags.NeverAudit
            Indexed                       = $flags.Indexed
            ContainerIndexed              = $flags.ContainerIndexed
            ANR                           = $flags.ANR
            PreserveOnDelete              = $flags.PreserveOnDelete
            CopyOnCopy                    = $flags.CopyOnCopy
            TupleIndexed                  = $flags.TupleIndexed
            SubtreeIndexed                = $flags.SubtreeIndexed
            SearchFlags                   = $flags.SearchFlags
            SystemFlags                   = $systemFlags
            DistinguishedName             = $attributeFound.DistinguishedName
            ObjectGUID                    = $attributeFound.ObjectGUID
            WhenCreated                   = $attributeFound.whenCreated
            WhenChanged                   = $attributeFound.whenChanged
        }
    }
}