<# bastion-collect.ps1 — Ferrum Bastion raw-capture collector (OPTIONAL, light alternative) ============================================================================================ SIGNED. READ-ONLY. AUDIT-ME. This is the thin, human-auditable alternative to the native Rust collector: a signed script a customer or pentester can READ end-to-end and run, with no binary to deploy. It captures RAW directory attributes into a JSON dump that Ferrum Bastion ingests through its `RawDumpSource`. What this script IS: - READ-ONLY. It performs LDAP SEARCH operations only. It never modifies, adds, deletes, or moves a directory object: no write cmdlet, no commit-changes, no set-info anywhere here. - PAGED, with an optional inter-page throttle. PageSize is the AD server maximum (1000); DirectorySearcher server-side-pages the subtree via FindAll (each page is fetched lazily as results are enumerated). The optional -DelayMs pauses at every PageSize boundary to bound the effective query rate. DC-safety of this LIVE capture rests on PageSize + the optional -DelayMs; the authoritative QPS ceiling is enforced in Rust at ingest/collect. - ALLOW-LISTED. It requests ONLY the attributes in $AllowedAttributes below — the exact set mirrored from bastion-collector's `ALLOWED_ATTRIBUTES` (Rust). No password, hash, key, or managed-secret attribute is ever requested. DACL only: SecurityMasks excludes the SACL. What this script does NOT do (all of this stays in Rust, on purpose — the guarantees live there): - It does NOT parse security descriptors (nTSecurityDescriptor is emitted as raw base64). - It does NOT resolve ACEs, build a graph, weight severity, or compute reachability. - It does NOT redact, and it does NOT encrypt. The Rust ingest RE-APPLIES the allow-list (dropping any attribute not on it) and encrypts the snapshot at rest. Treat this dump as sensitive but non-authoritative: the NO-SECRETS guarantee is re-enforced in Rust at ingest. Trust model: the dump is UNTRUSTED input to Bastion. Even if this script were tampered with to over-collect, `RawDumpSource` drops every non-allow-listed attribute at ingest, so nothing that is not on the Rust allow-list can reach a Bastion snapshot. This script's allow-list is a convenience for a lean capture, not the security boundary. Usage: .\bastion-collect.ps1 -OutFile domain-dump.json .\bastion-collect.ps1 -Server dc01.corp.example.com -OutFile dump.json .\bastion-collect.ps1 -BaseDN "DC=corp,DC=example,DC=com" -Credential (Get-Credential) ` -OutFile dump.json Then, in Rust (the guarantees): bastion collect --from-dump domain-dump.json --out domain.fbe --forest corp.example.com ` --base-dn "DC=corp,DC=example,DC=com" --qps 20 #> [CmdletBinding()] param( # Output path for the raw JSON dump. [Parameter(Mandatory = $true)] [string]$OutFile, # Optional domain controller / server to bind (default: the machine's own domain via serverless # binding). Integrated Kerberos is used unless -Credential is supplied. [Parameter(Mandatory = $false)] [string]$Server, # Optional explicit domain naming-context base DN. Default: RootDSE defaultNamingContext. [Parameter(Mandatory = $false)] [string]$BaseDN, # Optional alternate credentials. Omit to bind as the current user (integrated Kerberos). [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$Credential, # Page size for server-side paging (AD maximum is 1000). Combined with -DelayMs, this bounds # the effective query rate of the live capture. Range-validated to 1..1000: 0 would disable # server-side paging (unbounded, not DC-safe, I-8) AND divide-by-zero the -DelayMs throttle. [Parameter(Mandatory = $false)] [ValidateRange(1, 1000)] [int]$PageSize = 1000, # Optional inter-page delay, in milliseconds (default 0 = no delay). DirectorySearcher fetches # each server page lazily as results are enumerated, so pausing at every PageSize boundary # bounds the effective QPS for a gentler, DC-safer live capture. [Parameter(Mandatory = $false)] [int]$DelayMs = 0 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest # --------------------------------------------------------------------------------------------- # The allow-list. KEEP IN SYNC with bastion-collector's ALLOWED_ATTRIBUTES (Rust lib.rs). A Rust # test parses this array and asserts it equals the Rust allow-list and names no forbidden secret # attribute — so the script and the engine can never silently drift apart. # --------------------------------------------------------------------------------------------- $AllowedAttributes = @( # Identity / topology 'objectSid', 'objectClass', 'distinguishedName', 'sAMAccountName', 'member', 'memberOf', 'primaryGroupID', 'objectGUID', # Control (DACL only; SACL is never requested) 'nTSecurityDescriptor', # UAC / flags 'userAccountControl', # Delegation 'msDS-AllowedToDelegateTo', 'msDS-AllowedToActOnBehalfOfOtherIdentity', # Kerberos metadata 'servicePrincipalName', 'msDS-SupportedEncryptionTypes', # Hygiene 'adminCount', 'lastLogonTimestamp', 'pwdLastSet', 'whenCreated', # GPO 'gPCFileSysPath', 'gPLink', 'gPOptions', # Trusts 'trustDirection', 'trustAttributes', 'trustPartner', 'securityIdentifier', 'sIDHistory', # ADCS (Configuration NC) — template flags, EKUs, issuance policy, CA config. Metadata only. 'mspki-certificate-name-flag', 'mspki-enrollment-flag', 'pKIExtendedKeyUsage', 'msPKI-Certificate-Application-Policy', 'msPKI-RA-Signature', 'msPKI-Certificate-Policy', 'certificateTemplates', 'dNSHostName', 'msDS-OIDToGroupLink', 'msPKI-Cert-Template-OID', 'editFlags', 'interfaceFlags' ) # Attributes returned as raw byte[] by the directory: emitted as base64 strings. Rust base64-decodes # them back to bytes at ingest and only THEN parses (SD/SID/GUID parsing stays in Rust). $BinaryAttributes = @( 'objectSid', 'objectGUID', 'nTSecurityDescriptor', 'msDS-AllowedToActOnBehalfOfOtherIdentity', 'sIDHistory', 'securityIdentifier' ) $BinaryLookup = @{} foreach ($b in $BinaryAttributes) { $BinaryLookup[$b.ToLowerInvariant()] = $true } # --------------------------------------------------------------------------------------------- # Bind helpers (READ-ONLY). A DirectoryEntry is opened for SEARCH only; no property is ever set # and SetInfo() is never called. # --------------------------------------------------------------------------------------------- function New-BastionDirectoryEntry { param([string]$Path) if ($null -ne $Credential) { $netCred = $Credential.GetNetworkCredential() return New-Object System.DirectoryServices.DirectoryEntry( $Path, $netCred.UserName, $netCred.Password) } # Integrated auth (current Kerberos context) — Secure + Sealing for LDAP confidentiality. return New-Object System.DirectoryServices.DirectoryEntry($Path) } function Get-BastionRootPrefix { if ([string]::IsNullOrWhiteSpace($Server)) { return 'LDAP://' } return "LDAP://$Server/" } # Resolve the naming contexts from RootDSE unless a base DN was supplied. $rootPrefix = Get-BastionRootPrefix $rootDse = New-BastionDirectoryEntry("$rootPrefix" + 'RootDSE') if ([string]::IsNullOrWhiteSpace($BaseDN)) { $BaseDN = [string]$rootDse.Properties['defaultNamingContext'][0] } $configNc = [string]$rootDse.Properties['configurationNamingContext'][0] $pkiBase = if ([string]::IsNullOrWhiteSpace($configNc)) { $null } else { "CN=Public Key Services,CN=Services,$configNc" } # --------------------------------------------------------------------------------------------- # The paged, read-only subtree search over one naming context. # --------------------------------------------------------------------------------------------- function Invoke-BastionSearch { param([string]$SearchBaseDN) $entries = New-Object System.Collections.ArrayList if ([string]::IsNullOrWhiteSpace($SearchBaseDN)) { return , $entries } $rootEntry = New-BastionDirectoryEntry("$rootPrefix$SearchBaseDN") $searcher = New-Object System.DirectoryServices.DirectorySearcher $searcher.SearchRoot = $rootEntry $searcher.SearchScope = [System.DirectoryServices.SearchScope]::Subtree $searcher.Filter = '(objectClass=*)' $searcher.PageSize = $PageSize # DACL only — request OWNER|GROUP|DACL and NOT the SACL (mirrors the Rust SD-flags control). $searcher.SecurityMasks = ( [System.DirectoryServices.SecurityMasks]::Owner -bor [System.DirectoryServices.SecurityMasks]::Group -bor [System.DirectoryServices.SecurityMasks]::Dacl) $searcher.CacheResults = $false [void]$searcher.PropertiesToLoad.Clear() foreach ($attr in $AllowedAttributes) { [void]$searcher.PropertiesToLoad.Add($attr) } $results = $searcher.FindAll() try { $seen = 0 foreach ($result in $results) { # Optional inter-page throttle: the SearchResultCollection fetches the next server page # lazily as we enumerate, so pausing at each PageSize boundary bounds the effective QPS. if ($DelayMs -gt 0 -and $seen -gt 0 -and ($seen % $PageSize) -eq 0) { Start-Sleep -Milliseconds $DelayMs } $seen++ $props = $result.Properties $dn = if ($props.Contains('distinguishedname')) { [string]$props['distinguishedname'][0] } else { ($result.Path -replace '^LDAP://', '') -replace '^[^/]+/', '' } $strAttrs = [ordered]@{} $binAttrs = [ordered]@{} foreach ($attr in $AllowedAttributes) { $key = $attr.ToLowerInvariant() if (-not $props.Contains($key)) { continue } $values = $props[$key] if ($BinaryLookup.ContainsKey($key)) { $encoded = New-Object System.Collections.ArrayList foreach ($v in $values) { [void]$encoded.Add([System.Convert]::ToBase64String([byte[]]$v)) } $binAttrs[$attr] = @($encoded) } else { $strList = New-Object System.Collections.ArrayList foreach ($v in $values) { # Generalized-Time attributes (whenCreated / whenChanged) are returned by # System.DirectoryServices as [datetime], and their default [string] cast is # CULTURE-dependent (e.g. "1/1/2012 12:00:00 AM") — which does NOT match the # fixed LDAP generalizedTime layout the Rust ingest parses. Emit them in that # exact layout so when_created survives the dump path. InvariantCulture is # MANDATORY: ToString(format) otherwise uses CurrentCulture's calendar, so a # Thai (Buddhist, year 2566) or Arabic (Hijri) locale host would emit a wrong # year that the Rust ingest still parses as valid — silently corrupting the # age-based analyzers. Invariant = proleptic Gregorian + ASCII digits, always. if ($v -is [datetime]) { [void]$strList.Add($v.ToUniversalTime().ToString('yyyyMMddHHmmss.0Z', [System.Globalization.CultureInfo]::InvariantCulture)) } else { [void]$strList.Add([string]$v) } } $strAttrs[$attr] = @($strList) } } [void]$entries.Add([ordered]@{ dn = $dn attributes = $strAttrs binaryAttributes = $binAttrs }) } } finally { $results.Dispose() $searcher.Dispose() } return , $entries } # --------------------------------------------------------------------------------------------- # Collect the Domain NC and the Configuration-NC PKI subtree, merge, and write the dump. # --------------------------------------------------------------------------------------------- Write-Host "bastion-collect: read-only capture of $BaseDN (PageSize=$PageSize)" $allEntries = New-Object System.Collections.ArrayList foreach ($e in (Invoke-BastionSearch -SearchBaseDN $BaseDN)) { [void]$allEntries.Add($e) } if ($null -ne $pkiBase) { Write-Host "bastion-collect: read-only capture of ADCS subtree $pkiBase" foreach ($e in (Invoke-BastionSearch -SearchBaseDN $pkiBase)) { [void]$allEntries.Add($e) } } $dump = [ordered]@{ bastionRawDump = 1 meta = [ordered]@{ collector = 'bastion-collect.ps1' generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') baseDN = $BaseDN configNC = $configNc pageSize = $PageSize entryCount = $allEntries.Count readOnly = $true } entries = @($allEntries) } $json = $dump | ConvertTo-Json -Depth 12 Set-Content -Path $OutFile -Value $json -Encoding UTF8 Write-Host "bastion-collect: wrote $($allEntries.Count) raw entries to $OutFile" Write-Host "bastion-collect: this dump is NOT redacted or encrypted — Bastion re-applies the" Write-Host " allow-list and encrypts at ingest (guarantees live in Rust)."