# ============================================================================= # Sanitize-NSXGroups.ps1 # Version 1.0.0 # # PURPOSE # ------- # In NSX exports, a group's Id (internal identifier) often differs from its # DisplayName (human-readable label). For example: # # Id: securitygroup-223 DisplayName: Datacenter # Id: ipset-286 DisplayName: IPNET_1314-ETZ_Beheer_ICT # Id: securitygroup-70 DisplayName: L2-Infra-ICT-management # # This script renames every group Id to match its DisplayName, and updates # all group-to-group cross-references so paths remain consistent throughout # the export. # # WHAT GETS CHANGED # ----------------- # For each group where Id != DisplayName: # # CSV columns: # - Id -> set to DisplayName # - Tags -> cleared (unless the tag is in use — see TAG SAFETY CHECK below) # # Inside RawJson: # - "id":"" -> "id":"" # - "relative_path":"" -> "relative_path":"" # - Any /groups// or /groups/" path segment # (covers path, parent_path, and paths[] arrays in PathExpressions) # - "tags":[...] -> "tags":[] (unless tag is in use) # # TAG SAFETY CHECK # ---------------- # Before any tags are removed, this script queries the live NSX Manager API # to discover which tag scope:value pairs are actively referenced inside # security group Condition expressions (key = "Tag"). These are the tags that # drive dynamic group membership — removing them would silently break firewall # coverage without any API error. # # Behaviour when a tag IS found to be in use: # - The tag is KEPT in both the CSV Tags column and RawJson "tags" array. # - A WARNING is written to the console identifying the tag and the group(s) # that reference it. # - The sanitization run continues normally for all other objects. # # Provide -NSXManager and -Headers (or let the script prompt for credentials) # to enable the live check. If -NSXManager is omitted, the check is skipped # and ALL tags are removed (original behaviour — use with caution). # # OUTPUTS # ------- # _sanitized.csv — groups CSV with corrected Ids and RawJson # _id_mapping.csv — audit log of every oldId -> newId rename # (only written in standalone mode; the # orchestrator handles this itself) # # USAGE # ----- # # Standalone with tag safety check: # .\Sanitize-NSXGroups.ps1 -InputFile "groups.csv" -NSXManager "nsx4.corp.local" # # # Standalone without tag check (original behaviour): # .\Sanitize-NSXGroups.ps1 -InputFile "groups.csv" # # # Called from Sanitize-NSX.ps1 — returns the idMap hashtable directly: # $idMap = .\Sanitize-NSXGroups.ps1 -InputFile "groups.csv" ` # -NSXManager "nsx4.corp.local" -Headers $Headers -PassThruMap # ============================================================================= [CmdletBinding()] param( [Parameter(Mandatory)][string]$InputFile, [string]$OutputFile = ($InputFile -replace '\.csv$', '_sanitized.csv'), [string]$MappingFile = ($InputFile -replace '\.csv$', '_id_mapping.csv'), # NSX Manager connection — required for the live tag-in-use check. # If omitted the check is skipped and all tags are removed unconditionally. [string] $NSXManager = '', [hashtable]$Headers = @{}, [string] $DomainId = 'default', # When set, skips writing the mapping CSV and returns the hashtable to the # caller (used by Sanitize-NSX.ps1 so it can pass the map straight to the # rules script without an intermediate file). [switch]$PassThruMap ) Write-Host "Sanitize-NSXGroups.ps1 v1.0.0" -ForegroundColor Cyan # --------------------------------------------------------------------------- # 0. Dot-source the shared tag checker # --------------------------------------------------------------------------- $tagCheckerPath = Join-Path $PSScriptRoot 'Get-NSXTagsInUse.ps1' if (Test-Path $tagCheckerPath) { . $tagCheckerPath } else { Write-Warning "Get-NSXTagsInUse.ps1 not found alongside this script — tag safety check will be skipped." } # --------------------------------------------------------------------------- # 1. Resolve NSX credentials if a manager was specified but no headers passed # --------------------------------------------------------------------------- $inUseTags = @{} if ($NSXManager) { if ($Headers.Count -eq 0) { Write-Host " [TagCheck] Enter credentials for NSX Manager: $NSXManager" -ForegroundColor Cyan $cred = Get-Credential -Message "NSX ($NSXManager) credentials for tag check" $pair = "$($cred.UserName):$($cred.GetNetworkCredential().Password)" $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair) $Headers = @{ Authorization = "Basic $([Convert]::ToBase64String($bytes))" 'Content-Type' = 'application/json' } # Trust self-signed certificates if (-not ([System.Management.Automation.PSTypeName]'TrustAllCerts').Type) { Add-Type @" using System.Net; using System.Security.Cryptography.X509Certificates; public class TrustAllCerts : ICertificatePolicy { public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; } } "@ } [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCerts [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } if (Get-Command 'Get-NSXTagsInUse' -ErrorAction SilentlyContinue) { $inUseTags = Get-NSXTagsInUse -GroupsCsv $InputFile -NSXManager $NSXManager -Headers $Headers -DomainId $DomainId } else { Write-Warning "Get-NSXTagsInUse function not available — tag safety check skipped." } } else { # No API — still run the CSV-only phase if the function is available if (Get-Command 'Get-NSXTagsInUse' -ErrorAction SilentlyContinue) { Write-Host " [TagCheck] -NSXManager not provided — running CSV-only tag check (system-owned groups will not be scanned)." -ForegroundColor Yellow $inUseTags = Get-NSXTagsInUse -GroupsCsv $InputFile } else { Write-Host " [TagCheck] Get-NSXTagsInUse not available — tag safety check skipped. ALL tags will be removed." -ForegroundColor Yellow } } # --------------------------------------------------------------------------- # 2. Load the groups CSV # --------------------------------------------------------------------------- Write-Host " [Groups] Reading: $InputFile" -ForegroundColor Cyan $rows = Import-Csv -Path $InputFile # --------------------------------------------------------------------------- # 3. Build the old-ID -> new-ID mapping table # --------------------------------------------------------------------------- function Sanitize-Id { param([string]$value) return [regex]::Replace($value.Trim(), '[^a-zA-Z0-9_-]', '-') } $idMap = @{} # Pass 1 — count occurrences of each sanitized DisplayName $displayCount = @{} foreach ($row in $rows) { $sanitized = Sanitize-Id $row.DisplayName if ($displayCount.ContainsKey($sanitized)) { $displayCount[$sanitized]++ } else { $displayCount[$sanitized] = 1 } } # Pass 2 — assign newIds with deduplication suffixes $displayCounter = @{} foreach ($row in $rows) { $oldId = $row.Id.Trim() $sanitized = Sanitize-Id $row.DisplayName if ($displayCount[$sanitized] -gt 1) { if (-not $displayCounter.ContainsKey($sanitized)) { $displayCounter[$sanitized] = 1 } $suffix = $displayCounter[$sanitized] $displayCounter[$sanitized]++ $newId = "$sanitized-$suffix" Write-Warning "Duplicate DisplayName '$sanitized' — assigned '$newId' to Id '$oldId'." } else { $newId = $sanitized } if ($oldId -ne $newId) { if ($idMap.ContainsKey($oldId)) { Write-Warning "Duplicate old ID '$oldId' — skipping." } else { $idMap[$oldId] = $newId } } } Write-Host " [Groups] $($idMap.Count) ID(s) need renaming." -ForegroundColor Yellow # --------------------------------------------------------------------------- # 4. Helpers # --------------------------------------------------------------------------- function Decode-UnicodeEscapes { param([string]$text) return [regex]::Replace($text, '\\u([0-9a-fA-F]{4})', { param($m) [char][convert]::ToInt32($m.Groups[1].Value, 16) }) } function Update-GroupPaths { param([string]$text) $sortedKeys = $idMap.Keys | Sort-Object { $_.Length } -Descending foreach ($oldId in $sortedKeys) { $escaped = [regex]::Escape($oldId) $text = [regex]::Replace($text, "(?<=/groups/)$escaped(?=/|""|$)", $idMap[$oldId]) } return $text } function Remove-Tags { <# Removes tags from a RawJson string, but KEEPS any tag whose scope|value or bare value is found in $inUseTags (actively used in group expressions). Emits a warning for every tag that is kept. #> param([string]$json, [string]$objectId) try { $obj = $json | ConvertFrom-Json $tags = if ($obj.PSObject.Properties['tags']) { @($obj.tags) } else { @() } if ($tags.Count -eq 0) { # Nothing to remove — return json unchanged return $json } $keep = [System.Collections.Generic.List[object]]::new() $removed = [System.Collections.Generic.List[string]]::new() foreach ($tag in $tags) { $scope = if ($tag.PSObject.Properties['scope']) { $tag.scope } else { '' } $value = if ($tag.PSObject.Properties['tag'] ) { $tag.tag } else { '' } # NSX Tag condition values use "scope|value" when a scope is set, # or bare "value" when there is no scope qualifier $keyWithScope = if ($scope) { "$scope|$value" } else { $null } $keyBare = $value $usedBy = @() if ($keyWithScope -and $inUseTags[$keyWithScope]) { $usedBy += $inUseTags[$keyWithScope] } if ($keyBare -and $inUseTags[$keyBare] ) { $usedBy += $inUseTags[$keyBare] } $usedBy = $usedBy | Select-Object -Unique if ($usedBy.Count -gt 0) { $keep.Add($tag) Write-Host (" [TagCheck] WARN: Keeping tag '$scope`:$value' on '$objectId' " + "— referenced by group(s): $($usedBy -join ', ')") -ForegroundColor Yellow } else { $removed.Add("$scope`:$value") } } $obj.tags = $keep.ToArray() return ($obj | ConvertTo-Json -Depth 20 -Compress) } catch { # If JSON parsing fails, fall back to regex strip (original behaviour) Write-Host " [TagCheck] Could not parse tags for '$objectId' — using regex fallback: $_" -ForegroundColor Yellow return [regex]::Replace($json, '"tags":\[.*?\]', '"tags":[]') } } # --------------------------------------------------------------------------- # 5. Apply changes to every row # --------------------------------------------------------------------------- $mappingLog = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($row in $rows) { $oldId = $row.Id.Trim() $row.RawJson = Decode-UnicodeEscapes -text $row.RawJson if ($idMap.ContainsKey($oldId)) { $newId = $idMap[$oldId] $oldDisplay = $row.DisplayName.Trim() $row.Id = $newId $row.DisplayName = $newId $json = Update-GroupPaths -text $row.RawJson $esc = [regex]::Escape($oldId) $json = $json -replace """id"":""$esc""", """id"":""$newId""" $json = $json -replace """relative_path"":""$esc""", """relative_path"":""$newId""" $escOldDisplay = [regex]::Escape($oldDisplay) $json = $json -replace """display_name"":""$escOldDisplay""", """display_name"":""$newId""" $json = Remove-Tags -json $json -objectId $newId $row.RawJson = $json $mappingLog.Add([PSCustomObject]@{ OldId = $oldId; NewId = $newId }) } else { $row.RawJson = Remove-Tags -json (Update-GroupPaths -text $row.RawJson) -objectId $oldId } # Rebuild the Tags CSV column from whatever tags survived the check try { $obj = $row.RawJson | ConvertFrom-Json $tags = if ($obj.PSObject.Properties['tags']) { @($obj.tags) } else { @() } if ($tags.Count -gt 0) { $row.Tags = ($tags | ForEach-Object { $s = if ($_.PSObject.Properties['scope']) { $_.scope } else { '' } $v = if ($_.PSObject.Properties['tag'] ) { $_.tag } else { '' } "$s`:$v" }) -join '; ' } else { $row.Tags = '' } } catch { $row.Tags = '' } } # --------------------------------------------------------------------------- # 6. Write the sanitized groups CSV # --------------------------------------------------------------------------- Write-Host " [Groups] Writing: $OutputFile" -ForegroundColor Cyan $rows | Export-Csv -Path $OutputFile -NoTypeInformation -Encoding UTF8 # --------------------------------------------------------------------------- # 7. Return the map or write it to CSV # --------------------------------------------------------------------------- if ($PassThruMap) { return $idMap } else { Write-Host " [Groups] Writing mapping log: $MappingFile" -ForegroundColor Cyan $mappingLog | Export-Csv -Path $MappingFile -NoTypeInformation -Encoding UTF8 Write-Host "" Write-Host "Done! $($mappingLog.Count) group(s) renamed." -ForegroundColor Green if ($mappingLog.Count -gt 0) { $mappingLog | Format-Table -AutoSize } }