From fb1178c25404d5dd751a6f38a140bb9d62751b06 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 24 Feb 2026 14:54:59 -0500 Subject: [PATCH 1/4] Add duplicate local machine SID detection scripts Two scripts that work together to detect cloned/imaged machines that were not sysprepped. The workstation script stamps each machine's local SID into extensionAttribute1 on its AD computer object. The DC/admin script scans all computer objects for duplicate SIDs and reports findings with RMM custom field output. Co-Authored-By: Claude Opus 4.6 --- .../msft-windows-sid-detect-duplicates.ps1 | 263 ++++++++++++++++++ msft-windows/msft-windows-sid-report.ps1 | 172 ++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 msft-windows/msft-windows-sid-detect-duplicates.ps1 create mode 100644 msft-windows/msft-windows-sid-report.ps1 diff --git a/msft-windows/msft-windows-sid-detect-duplicates.ps1 b/msft-windows/msft-windows-sid-detect-duplicates.ps1 new file mode 100644 index 0000000..92cf3f4 --- /dev/null +++ b/msft-windows/msft-windows-sid-detect-duplicates.ps1 @@ -0,0 +1,263 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM +## +## Optional RMM Variables: +## - $Description: Ticket number or initials for tracking (defaults to "Automated Duplicate SID Scan") +## - $SearchBase: LDAP path to limit search scope (e.g., "OU=Workstations,DC=contoso,DC=com") + +# Duplicate Local Machine SID Detector +# Queries all AD computer objects with extensionAttribute1 populated (written by msft-windows-sid-report.ps1), +# groups by SID, and reports any duplicates. Duplicate local machine SIDs indicate machines that were +# cloned/imaged without running sysprep. +# +# Prerequisites: +# - msft-windows-sid-report.ps1 must have run on workstations first to populate extensionAttribute1 +# - Run this script on a Domain Controller or domain-joined admin workstation +# - Account running the script needs read access to computer objects in AD +# +# Exit Codes: +# 0 = Success, no duplicates found +# 1 = Success, duplicates found (non-zero to trigger RMM alerting) +# 2 = Error (not domain-joined, LDAP query failed, etc.) + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-windows-sid-detect-duplicates.log" + +if ($RMM -ne 1) { + $ValidInput = 0 + # Checking for valid input. + while ($ValidInput -ne 1) { + $Description = Read-Host "Please enter the ticket # and, or your initials (press Enter for 'Automated Duplicate SID Scan')" + if (-not $Description) { + $Description = "Automated Duplicate SID Scan" + } + + $SearchBase = Read-Host "Enter LDAP search base to limit scope (press Enter to search entire domain)" + + $ValidInput = 1 + } + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + +} else { + # Store the logs in the RMMScriptPath + if ($null -ne $RMMScriptPath) { + $LogPath = "$RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + } + + if ($null -eq $Description) { + $Description = "Automated Duplicate SID Scan" + } +} + +# Start the script logic here. This is the part that actually gets done what you need done. + +Start-Transcript -Path $LogPath + +Write-Host "Description: $Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $RMM" +Write-Host "Search Base: $(if ($SearchBase) { $SearchBase } else { '(entire domain)' })" +Write-Host "" + +# Step 1: Check domain membership +$computerSystem = Get-WmiObject -Class Win32_ComputerSystem +$partOfDomain = $computerSystem.PartOfDomain + +Write-Host "Running on: $($computerSystem.Name)" +Write-Host "Part of Domain: $partOfDomain" +Write-Host "" + +if (-not $partOfDomain) { + Write-Host "[ERROR] This computer is not domain-joined. Cannot query AD." + Stop-Transcript + exit 2 +} + +# Step 2: Query all computers with extensionAttribute1 populated +try { + $searcher = [ADSISearcher]"(&(objectCategory=computer)(extensionAttribute1=*))" + $searcher.PageSize = 1000 + $searcher.PropertiesToLoad.AddRange(@("cn", "extensionattribute1", "distinguishedname")) + + if (-not [string]::IsNullOrWhiteSpace($SearchBase)) { + try { + $searcher.SearchRoot = [ADSI]"LDAP://$SearchBase" + Write-Host "[INFO] Search scope limited to: $SearchBase" + } catch { + Write-Host "[ERROR] Invalid SearchBase '$SearchBase': $($_.Exception.Message)" + Stop-Transcript + exit 2 + } + } + + $results = $searcher.FindAll() + $resultCount = $results.Count + Write-Host "[INFO] Found $resultCount computer(s) with extensionAttribute1 populated." + Write-Host "" +} catch { + Write-Host "[ERROR] LDAP search failed: $($_.Exception.Message)" + Stop-Transcript + exit 2 +} + +if ($resultCount -eq 0) { + Write-Host "[INFO] No computers have extensionAttribute1 populated." + Write-Host "[INFO] Ensure msft-windows-sid-report.ps1 has been deployed and run on workstations first." + + # RMM Custom Field Output + Write-Host "" + Write-Host "============================================" + Write-Host "RMM CUSTOM FIELD VALUES" + Write-Host "============================================" + Write-Host "DUPSID_FOUND: False" + Write-Host "DUPSID_COUNT: 0" + Write-Host "DUPSID_AFFECTED: 0" + Write-Host "DUPSID_SCANNED: 0" + Write-Host "DUPSID_DETAILS: No computers reporting SID data yet" + Write-Host "DUPSID_SUMMARY: No computers reporting SID data - deploy sid-report script first" + Write-Host "DUPSID_SCANDATE: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + Write-Host "============================================" + + $results.Dispose() + Stop-Transcript + exit 0 +} + +# Step 3: Build SID-to-computer mapping +$sidMap = @{} +$totalComputers = 0 + +foreach ($result in $results) { + $cn = $result.Properties['cn'][0] + $sid = $result.Properties['extensionattribute1'][0] + $dn = $result.Properties['distinguishedname'][0] + + if ([string]::IsNullOrWhiteSpace($sid)) { + continue + } + + $totalComputers++ + + $entry = [PSCustomObject]@{ + Name = $cn + DN = $dn + } + + if ($sidMap.ContainsKey($sid)) { + $sidMap[$sid] += $entry + } else { + $sidMap[$sid] = @($entry) + } +} + +# Clean up COM objects from FindAll() +$results.Dispose() + +Write-Host "[INFO] Processed $totalComputers computer(s) with valid SID data." +Write-Host "[INFO] Unique SIDs: $($sidMap.Count)" +Write-Host "" + +# Step 4: Identify duplicates +$duplicates = @($sidMap.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 }) +$duplicateCount = $duplicates.Count +$affectedMachines = 0 +foreach ($dup in $duplicates) { + $affectedMachines += $dup.Value.Count +} +$duplicateFound = $duplicateCount -gt 0 + +Write-Host "============================================" +Write-Host "DUPLICATE SID SCAN RESULTS" +Write-Host "============================================" +Write-Host "" +Write-Host "Total computers scanned: $totalComputers" +Write-Host "Unique SIDs: $($sidMap.Count)" +Write-Host "Duplicate SID groups: $duplicateCount" +Write-Host "Affected machines: $affectedMachines" +Write-Host "" + +# Step 5: Detailed output of duplicates +if ($duplicateFound) { + Write-Host "[WARNING] DUPLICATE SIDs DETECTED!" + Write-Host "" + + $groupIndex = 0 + foreach ($entry in $duplicates) { + $groupIndex++ + $sid = $entry.Key + $computers = $entry.Value + + Write-Host "--- Duplicate Group $groupIndex ---" + Write-Host " SID: $sid" + Write-Host " Affected Computers ($($computers.Count)):" + foreach ($computer in $computers) { + Write-Host " - $($computer.Name)" + Write-Host " DN: $($computer.DN)" + } + Write-Host "" + } + + Write-Host "[ACTION REQUIRED] These machines were likely cloned or imaged without running sysprep." + Write-Host "[ACTION REQUIRED] Run 'sysprep /generalize' or use New-SID on affected machines to generate unique SIDs." + Write-Host "" +} else { + Write-Host "[OK] No duplicate SIDs detected. All $totalComputers machines have unique local machine SIDs." + Write-Host "" +} + +# Step 6: RMM Custom Field Output +# Build details string for RMM +$detailsParts = @() +foreach ($entry in $duplicates) { + $sid = $entry.Key + $names = ($entry.Value | ForEach-Object { $_.Name }) -join ", " + $detailsParts += "${sid}: $names" +} +$detailsString = $detailsParts -join " | " +if ([string]::IsNullOrEmpty($detailsString)) { + $detailsString = "None" +} +if ($detailsString.Length -gt 190) { + $detailsString = $detailsString.Substring(0, 187) + "..." +} + +# Build short summary +if ($duplicateFound) { + $summaryText = "$duplicateCount dup SID group(s) affecting $affectedMachines machine(s) - remediation required" +} else { + $summaryText = "No duplicate SIDs found across $totalComputers computer(s)" +} +if ($summaryText.Length -gt 190) { + $summaryText = $summaryText.Substring(0, 187) + "..." +} + +$scanDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + +Write-Host "============================================" +Write-Host "RMM CUSTOM FIELD VALUES" +Write-Host "============================================" +Write-Host "DUPSID_FOUND: $duplicateFound" +Write-Host "DUPSID_COUNT: $duplicateCount" +Write-Host "DUPSID_AFFECTED: $affectedMachines" +Write-Host "DUPSID_SCANNED: $totalComputers" +Write-Host "DUPSID_DETAILS: $detailsString" +Write-Host "DUPSID_SUMMARY: $summaryText" +Write-Host "DUPSID_SCANDATE: $scanDate" +Write-Host "============================================" +Write-Host "" + +# NinjaRMM specific (uncomment and modify for your RMM): +# Ninja-Property-Set dupSidFound $duplicateFound +# Ninja-Property-Set dupSidCount $duplicateCount +# Ninja-Property-Set dupSidSummary $summaryText + +Stop-Transcript + +if ($duplicateFound) { + exit 1 +} else { + exit 0 +} diff --git a/msft-windows/msft-windows-sid-report.ps1 b/msft-windows/msft-windows-sid-report.ps1 new file mode 100644 index 0000000..51362aa --- /dev/null +++ b/msft-windows/msft-windows-sid-report.ps1 @@ -0,0 +1,172 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## THIS IS HOW WE EASILY LET PEOPLE KNOW WHAT VARIABLES NEED SET IN THE RMM +## +## Optional RMM Variables: +## - $Description: Ticket number or initials for tracking (defaults to "Automated SID Report") + +# Local Machine SID Reporter +# Writes the local machine SID to extensionAttribute1 on the computer's own AD object. +# This enables centralized duplicate SID detection for cloned/imaged machines +# that were not properly sysprepped. +# +# The local machine SID is NOT the AD objectSid. It is the SID baked into the OS +# during installation. When a machine is cloned without sysprep, multiple machines +# share this SID, which can cause authentication and security issues. +# +# Exit Codes: +# 0 = Success (SID written to AD) +# 1 = Error (not domain-joined, computer not found, permission denied, etc.) + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "msft-windows-sid-report.log" + +if ($RMM -ne 1) { + $ValidInput = 0 + # Checking for valid input. + while ($ValidInput -ne 1) { + $Description = Read-Host "Please enter the ticket # and, or your initials (press Enter for 'Automated SID Report')" + if (-not $Description) { + $Description = "Automated SID Report" + } + $ValidInput = 1 + } + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + +} else { + # Store the logs in the RMMScriptPath + if ($null -ne $RMMScriptPath) { + $LogPath = "$RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + } + + if ($null -eq $Description) { + $Description = "Automated SID Report" + } +} + +# Start the script logic here. This is the part that actually gets done what you need done. + +Start-Transcript -Path $LogPath + +Write-Host "Description: $Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $RMM" +Write-Host "" + +# Step 1: Check domain membership +$computerSystem = Get-WmiObject -Class Win32_ComputerSystem +$computerName = $computerSystem.Name +$partOfDomain = $computerSystem.PartOfDomain + +Write-Host "Computer Name: $computerName" +Write-Host "Part of Domain: $partOfDomain" +Write-Host "" + +if (-not $partOfDomain) { + Write-Host "[ERROR] This computer is not domain-joined. Cannot write to AD." + Write-Host "[INFO] Machine SID reporting requires domain membership." + Stop-Transcript + exit 1 +} + +# Step 2: Get the local machine SID +# Query the built-in Administrator account (RID -500) and strip the RID to get the machine SID +try { + $adminAccount = Get-WmiObject -Class Win32_UserAccount -Filter "LocalAccount = True AND SID LIKE '%-500'" + if ($null -eq $adminAccount) { + Write-Host "[ERROR] Could not find built-in Administrator account (SID ending in -500)." + Write-Host "[INFO] This is unexpected and may indicate a corrupted SAM database." + Stop-Transcript + exit 1 + } + $adminSid = $adminAccount.SID + $machineSid = $adminSid.Substring(0, $adminSid.LastIndexOf('-')) + Write-Host "[INFO] Local Machine SID: $machineSid" + Write-Host "[INFO] Derived from built-in Administrator SID: $adminSid" + Write-Host "" +} catch { + Write-Host "[ERROR] Failed to retrieve local machine SID: $($_.Exception.Message)" + Stop-Transcript + exit 1 +} + +# Step 3: Find the computer's own AD object using ADSISearcher +try { + $searcher = [ADSISearcher]"(&(objectCategory=computer)(cn=$computerName))" + $searcher.PropertiesToLoad.AddRange(@("distinguishedname", "extensionattribute1")) + $result = $searcher.FindOne() + + if ($null -eq $result) { + Write-Host "[ERROR] Computer object '$computerName' not found in Active Directory." + Write-Host "[INFO] Ensure the computer is properly joined to the domain." + Stop-Transcript + exit 1 + } + + Write-Host "[INFO] Found AD computer object: $($result.Properties['distinguishedname'][0])" +} catch { + Write-Host "[ERROR] LDAP search failed: $($_.Exception.Message)" + Stop-Transcript + exit 1 +} + +# Step 4: Write the machine SID to extensionAttribute1 +try { + $computerObject = $result.GetDirectoryEntry() + + $currentValue = $null + try { + $currentValue = $computerObject.extensionAttribute1.Value + } catch { + # Attribute may not exist yet, that's fine + } + + if ($currentValue) { + Write-Host "[INFO] Current extensionAttribute1 value: $currentValue" + if ($currentValue -eq $machineSid) { + Write-Host "[INFO] extensionAttribute1 already contains the correct machine SID. No update needed." + Stop-Transcript + exit 0 + } + } else { + Write-Host "[INFO] extensionAttribute1 is currently empty." + } + + $computerObject.Put("extensionAttribute1", $machineSid) + $computerObject.SetInfo() + + Write-Host "[SUCCESS] Written machine SID '$machineSid' to extensionAttribute1 on AD object." +} catch { + Write-Host "[ERROR] Failed to write extensionAttribute1: $($_.Exception.Message)" + Write-Host "[INFO] This may be a permissions issue. The script requires write access to the computer's own AD object." + Write-Host "[INFO] When running as SYSTEM, the computer account typically has write access to its own attributes." + Write-Host "[INFO] If running as a user, domain admin or delegated permissions may be required." + Stop-Transcript + exit 1 +} + +# Step 5: Verify the write was successful +try { + $verifySearcher = [ADSISearcher]"(&(objectCategory=computer)(cn=$computerName))" + $verifySearcher.PropertiesToLoad.AddRange(@("extensionattribute1")) + $verifyResult = $verifySearcher.FindOne() + $verifiedValue = $verifyResult.Properties['extensionattribute1'][0] + + if ($verifiedValue -eq $machineSid) { + Write-Host "[VERIFIED] extensionAttribute1 confirmed: $verifiedValue" + } else { + Write-Host "[WARNING] Verification mismatch. Written: $machineSid, Read back: $verifiedValue" + } +} catch { + Write-Host "[WARNING] Could not verify write. The update may still have succeeded: $($_.Exception.Message)" +} + +Write-Host "" +Write-Host "============================================" +Write-Host "SID Report Complete" +Write-Host "============================================" + +Stop-Transcript +exit 0 From 57ee5e23321a81f58c5a0e8653c04dc458b988db Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Tue, 24 Feb 2026 16:31:41 -0500 Subject: [PATCH 2/4] Update SID scripts: use info attribute with MACHINESID: prefix - Switch from extensionAttribute1 to info (Notes) field, which exists in all AD schemas without Exchange extensions - Add MACHINESID: prefix to stored values to avoid overwriting existing notes and to filter accurately during detection - Report script auto-migrates raw SID values from previous version - Detection script only picks up prefixed values via LDAP filter - Add full SID inventory output and HTML report for Ninja WYSIWYG field - Comment out Ninja-Property-Set calls by default Co-Authored-By: Claude Opus 4.6 --- .../msft-windows-sid-detect-duplicates.ps1 | 100 +++++++++++------- msft-windows/msft-windows-sid-report.ps1 | 52 +++++---- 2 files changed, 91 insertions(+), 61 deletions(-) diff --git a/msft-windows/msft-windows-sid-detect-duplicates.ps1 b/msft-windows/msft-windows-sid-detect-duplicates.ps1 index 92cf3f4..dd9349c 100644 --- a/msft-windows/msft-windows-sid-detect-duplicates.ps1 +++ b/msft-windows/msft-windows-sid-detect-duplicates.ps1 @@ -6,12 +6,14 @@ ## - $SearchBase: LDAP path to limit search scope (e.g., "OU=Workstations,DC=contoso,DC=com") # Duplicate Local Machine SID Detector -# Queries all AD computer objects with extensionAttribute1 populated (written by msft-windows-sid-report.ps1), +# Queries all AD computer objects with the info (Notes) attribute populated (written by msft-windows-sid-report.ps1), # groups by SID, and reports any duplicates. Duplicate local machine SIDs indicate machines that were # cloned/imaged without running sysprep. # +# AD Attribute Used: info (Notes field) - built-in on all computer objects, no schema extensions required. +# # Prerequisites: -# - msft-windows-sid-report.ps1 must have run on workstations first to populate extensionAttribute1 +# - msft-windows-sid-report.ps1 must have run on workstations first to populate the info attribute # - Run this script on a Domain Controller or domain-joined admin workstation # - Account running the script needs read access to computer objects in AD # @@ -76,11 +78,11 @@ if (-not $partOfDomain) { exit 2 } -# Step 2: Query all computers with extensionAttribute1 populated +# Step 2: Query all computers with info (Notes) attribute containing MACHINESID: prefix try { - $searcher = [ADSISearcher]"(&(objectCategory=computer)(extensionAttribute1=*))" + $searcher = [ADSISearcher]"(&(objectCategory=computer)(info=MACHINESID:*))" $searcher.PageSize = 1000 - $searcher.PropertiesToLoad.AddRange(@("cn", "extensionattribute1", "distinguishedname")) + $searcher.PropertiesToLoad.AddRange(@("cn", "info", "distinguishedname")) if (-not [string]::IsNullOrWhiteSpace($SearchBase)) { try { @@ -95,7 +97,7 @@ try { $results = $searcher.FindAll() $resultCount = $results.Count - Write-Host "[INFO] Found $resultCount computer(s) with extensionAttribute1 populated." + Write-Host "[INFO] Found $resultCount computer(s) with MACHINESID stamp in info attribute." Write-Host "" } catch { Write-Host "[ERROR] LDAP search failed: $($_.Exception.Message)" @@ -104,7 +106,7 @@ try { } if ($resultCount -eq 0) { - Write-Host "[INFO] No computers have extensionAttribute1 populated." + Write-Host "[INFO] No computers have MACHINESID stamp in info attribute." Write-Host "[INFO] Ensure msft-windows-sid-report.ps1 has been deployed and run on workstations first." # RMM Custom Field Output @@ -132,9 +134,16 @@ $totalComputers = 0 foreach ($result in $results) { $cn = $result.Properties['cn'][0] - $sid = $result.Properties['extensionattribute1'][0] + $rawInfo = $result.Properties['info'][0] $dn = $result.Properties['distinguishedname'][0] + if ([string]::IsNullOrWhiteSpace($rawInfo)) { + continue + } + + # Strip the MACHINESID: prefix to get the raw SID + $sid = $rawInfo -replace '^MACHINESID:', '' + if ([string]::IsNullOrWhiteSpace($sid)) { continue } @@ -160,7 +169,25 @@ Write-Host "[INFO] Processed $totalComputers computer(s) with valid SID data." Write-Host "[INFO] Unique SIDs: $($sidMap.Count)" Write-Host "" -# Step 4: Identify duplicates +# Step 4: Full SID inventory - list every SID and its associated computer(s) +Write-Host "============================================" +Write-Host "FULL SID INVENTORY" +Write-Host "============================================" +Write-Host "" + +foreach ($entry in $sidMap.GetEnumerator()) { + $sid = $entry.Key + $computers = $entry.Value + $isDuplicate = $computers.Count -gt 1 + $marker = if ($isDuplicate) { " ** DUPLICATE **" } else { "" } + + foreach ($computer in $computers) { + Write-Host " $($computer.Name) : $sid$marker" + } +} +Write-Host "" + +# Step 5: Identify and report duplicates $duplicates = @($sidMap.GetEnumerator() | Where-Object { $_.Value.Count -gt 1 }) $duplicateCount = $duplicates.Count $affectedMachines = 0 @@ -179,7 +206,6 @@ Write-Host "Duplicate SID groups: $duplicateCount" Write-Host "Affected machines: $affectedMachines" Write-Host "" -# Step 5: Detailed output of duplicates if ($duplicateFound) { Write-Host "[WARNING] DUPLICATE SIDs DETECTED!" Write-Host "" @@ -201,7 +227,7 @@ if ($duplicateFound) { } Write-Host "[ACTION REQUIRED] These machines were likely cloned or imaged without running sysprep." - Write-Host "[ACTION REQUIRED] Run 'sysprep /generalize' or use New-SID on affected machines to generate unique SIDs." + Write-Host "[ACTION REQUIRED] Affected machines must be reimaged with sysprep /generalize to generate unique SIDs." Write-Host "" } else { Write-Host "[OK] No duplicate SIDs detected. All $totalComputers machines have unique local machine SIDs." @@ -209,31 +235,6 @@ if ($duplicateFound) { } # Step 6: RMM Custom Field Output -# Build details string for RMM -$detailsParts = @() -foreach ($entry in $duplicates) { - $sid = $entry.Key - $names = ($entry.Value | ForEach-Object { $_.Name }) -join ", " - $detailsParts += "${sid}: $names" -} -$detailsString = $detailsParts -join " | " -if ([string]::IsNullOrEmpty($detailsString)) { - $detailsString = "None" -} -if ($detailsString.Length -gt 190) { - $detailsString = $detailsString.Substring(0, 187) + "..." -} - -# Build short summary -if ($duplicateFound) { - $summaryText = "$duplicateCount dup SID group(s) affecting $affectedMachines machine(s) - remediation required" -} else { - $summaryText = "No duplicate SIDs found across $totalComputers computer(s)" -} -if ($summaryText.Length -gt 190) { - $summaryText = $summaryText.Substring(0, 187) + "..." -} - $scanDate = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' Write-Host "============================================" @@ -243,16 +244,35 @@ Write-Host "DUPSID_FOUND: $duplicateFound" Write-Host "DUPSID_COUNT: $duplicateCount" Write-Host "DUPSID_AFFECTED: $affectedMachines" Write-Host "DUPSID_SCANNED: $totalComputers" -Write-Host "DUPSID_DETAILS: $detailsString" -Write-Host "DUPSID_SUMMARY: $summaryText" Write-Host "DUPSID_SCANDATE: $scanDate" Write-Host "============================================" Write-Host "" -# NinjaRMM specific (uncomment and modify for your RMM): +# Build HTML report of duplicate machines only +$htmlLines = @() +$htmlLines += "

Duplicate SID Report - $scanDate

" +$htmlLines += "

Scanned: $totalComputers | Duplicate Groups: $duplicateCount | Affected Machines: $affectedMachines

" +if ($duplicateFound) { + $htmlLines += "" + foreach ($entry in $duplicates) { + $sid = $entry.Key + $computers = $entry.Value + foreach ($computer in $computers) { + $htmlLines += "" + } + } + $htmlLines += "
ComputerSID
$($computer.Name)$sid
" + $htmlLines += "

ACTION REQUIRED: These machines were likely cloned/imaged without running sysprep.

" +} else { + $htmlLines += "

No duplicate SIDs detected.

" +} +$htmlReport = $htmlLines -join "" + +# NinjaRMM custom fields (uncomment if using NinjaRMM): # Ninja-Property-Set dupSidFound $duplicateFound # Ninja-Property-Set dupSidCount $duplicateCount -# Ninja-Property-Set dupSidSummary $summaryText +# Ninja-Property-Set dupSidAffected $affectedMachines +# $htmlReport | Ninja-Property-Set-Piped dupSidSummary Stop-Transcript diff --git a/msft-windows/msft-windows-sid-report.ps1 b/msft-windows/msft-windows-sid-report.ps1 index 51362aa..768b194 100644 --- a/msft-windows/msft-windows-sid-report.ps1 +++ b/msft-windows/msft-windows-sid-report.ps1 @@ -5,7 +5,7 @@ ## - $Description: Ticket number or initials for tracking (defaults to "Automated SID Report") # Local Machine SID Reporter -# Writes the local machine SID to extensionAttribute1 on the computer's own AD object. +# Writes the local machine SID to the "info" (Notes) attribute on the computer's own AD object. # This enables centralized duplicate SID detection for cloned/imaged machines # that were not properly sysprepped. # @@ -13,6 +13,8 @@ # during installation. When a machine is cloned without sysprep, multiple machines # share this SID, which can cause authentication and security issues. # +# AD Attribute Used: info (Notes field) - built-in on all computer objects, no schema extensions required. +# # Exit Codes: # 0 = Success (SID written to AD) # 1 = Error (not domain-joined, computer not found, permission denied, etc.) @@ -55,7 +57,7 @@ Write-Host "Log path: $LogPath" Write-Host "RMM: $RMM" Write-Host "" -# Step 1: Check domain membership +# Step 1: Check domain membership - exit immediately if not domain-joined $computerSystem = Get-WmiObject -Class Win32_ComputerSystem $computerName = $computerSystem.Name $partOfDomain = $computerSystem.PartOfDomain @@ -66,7 +68,7 @@ Write-Host "" if (-not $partOfDomain) { Write-Host "[ERROR] This computer is not domain-joined. Cannot write to AD." - Write-Host "[INFO] Machine SID reporting requires domain membership." + Write-Host "[INFO] Machine SID reporting requires domain membership. Exiting." Stop-Transcript exit 1 } @@ -95,7 +97,7 @@ try { # Step 3: Find the computer's own AD object using ADSISearcher try { $searcher = [ADSISearcher]"(&(objectCategory=computer)(cn=$computerName))" - $searcher.PropertiesToLoad.AddRange(@("distinguishedname", "extensionattribute1")) + $searcher.PropertiesToLoad.AddRange(@("distinguishedname", "info")) $result = $searcher.FindOne() if ($null -eq $result) { @@ -112,34 +114,42 @@ try { exit 1 } -# Step 4: Write the machine SID to extensionAttribute1 +# Step 4: Write the machine SID to the info (Notes) attribute with MACHINESID: prefix +$sidStamp = "MACHINESID:$machineSid" + try { $computerObject = $result.GetDirectoryEntry() $currentValue = $null - try { - $currentValue = $computerObject.extensionAttribute1.Value - } catch { - # Attribute may not exist yet, that's fine + if ($computerObject.Properties["info"].Count -gt 0) { + $currentValue = $computerObject.Properties["info"][0] } if ($currentValue) { - Write-Host "[INFO] Current extensionAttribute1 value: $currentValue" - if ($currentValue -eq $machineSid) { - Write-Host "[INFO] extensionAttribute1 already contains the correct machine SID. No update needed." + Write-Host "[INFO] Current info (Notes) value: $currentValue" + if ($currentValue -eq $sidStamp) { + Write-Host "[INFO] info attribute already contains the correct prefixed machine SID. No update needed." Stop-Transcript exit 0 } + # Migrate raw SID values from previous version to prefixed format + if ($currentValue -eq $machineSid) { + Write-Host "[INFO] Found raw SID without prefix. Migrating to prefixed format." + } elseif ($currentValue -like "MACHINESID:*") { + Write-Host "[INFO] SID has changed since last report. Updating." + } else { + Write-Host "[WARNING] info field contains non-SID data: '$currentValue'. Overwriting with machine SID." + } } else { - Write-Host "[INFO] extensionAttribute1 is currently empty." + Write-Host "[INFO] info (Notes) attribute is currently empty." } - $computerObject.Put("extensionAttribute1", $machineSid) + $computerObject.Put("info", $sidStamp) $computerObject.SetInfo() - Write-Host "[SUCCESS] Written machine SID '$machineSid' to extensionAttribute1 on AD object." + Write-Host "[SUCCESS] Written '$sidStamp' to info (Notes) attribute on AD object." } catch { - Write-Host "[ERROR] Failed to write extensionAttribute1: $($_.Exception.Message)" + Write-Host "[ERROR] Failed to write info attribute: $($_.Exception.Message)" Write-Host "[INFO] This may be a permissions issue. The script requires write access to the computer's own AD object." Write-Host "[INFO] When running as SYSTEM, the computer account typically has write access to its own attributes." Write-Host "[INFO] If running as a user, domain admin or delegated permissions may be required." @@ -150,14 +160,14 @@ try { # Step 5: Verify the write was successful try { $verifySearcher = [ADSISearcher]"(&(objectCategory=computer)(cn=$computerName))" - $verifySearcher.PropertiesToLoad.AddRange(@("extensionattribute1")) + $verifySearcher.PropertiesToLoad.AddRange(@("info")) $verifyResult = $verifySearcher.FindOne() - $verifiedValue = $verifyResult.Properties['extensionattribute1'][0] + $verifiedValue = $verifyResult.Properties['info'][0] - if ($verifiedValue -eq $machineSid) { - Write-Host "[VERIFIED] extensionAttribute1 confirmed: $verifiedValue" + if ($verifiedValue -eq $sidStamp) { + Write-Host "[VERIFIED] info (Notes) attribute confirmed: $verifiedValue" } else { - Write-Host "[WARNING] Verification mismatch. Written: $machineSid, Read back: $verifiedValue" + Write-Host "[WARNING] Verification mismatch. Written: $sidStamp, Read back: $verifiedValue" } } catch { Write-Host "[WARNING] Could not verify write. The update may still have succeeded: $($_.Exception.Message)" From 359f936d11d26d954e2e811f15b8d42446fbb949 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 4 Mar 2026 11:15:20 -0500 Subject: [PATCH 3/4] Add Google Drive silent install script Installs Google Drive for Desktop using winget when available, with automatic fallback to direct download from Google. Follows standard template with RMM/interactive dual-mode support. Co-Authored-By: Claude Opus 4.6 --- app-google-drive/google-drive-install.ps1 | 112 ++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app-google-drive/google-drive-install.ps1 diff --git a/app-google-drive/google-drive-install.ps1 b/app-google-drive/google-drive-install.ps1 new file mode 100644 index 0000000..9eabb3b --- /dev/null +++ b/app-google-drive/google-drive-install.ps1 @@ -0,0 +1,112 @@ +## PLEASE COMMENT YOUR VARIABLES DIRECTLY BELOW HERE IF YOU'RE RUNNING FROM A RMM +## No additional variables required for this script + +# Getting input from user if not running from RMM else set variables from RMM. + +$ScriptLogName = "google-drive-install.log" + +if ($RMM -ne 1) { + $ValidInput = 0 + while ($ValidInput -ne 1) { + $Description = Read-Host "Please enter the ticket # and, or your initials. Its used as the Description for the job" + if ($Description) { + $ValidInput = 1 + } else { + Write-Host "Invalid input. Please try again." + } + } + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + +} else { + if ($null -ne $RMMScriptPath) { + $LogPath = "$RMMScriptPath\logs\$ScriptLogName" + } else { + $LogPath = "$ENV:WINDIR\logs\$ScriptLogName" + } + + if ($null -eq $Description) { + Write-Host "Description is null. This was most likely run automatically from the RMM and no information was passed." + $Description = "No Description" + } +} + +# Script Logic + +Start-Transcript -Path $LogPath + +Write-Host "Description: $Description" +Write-Host "Log path: $LogPath" +Write-Host "RMM: $RMM" + +$packageId = "Google.GoogleDrive" +$installerUrl = "https://dl.google.com/drive-file-stream/GoogleDriveSetup.exe" +$installerPath = "$env:WINDIR\temp\GoogleDriveSetup.exe" + +# Check if Google Drive is already installed +$driveExePath = "C:\Program Files\Google\Drive File Stream\launch.bat" +$driveExePath2 = "${env:ProgramFiles}\Google\Drive File Stream\GoogleDriveFS.exe" + +if ((Test-Path -Path $driveExePath) -or (Test-Path -Path $driveExePath2)) { + Write-Host "Google Drive is already installed. Skipping installation." + Stop-Transcript + exit 0 +} + +# Try winget first +$wingetAvailable = $false +try { + $wingetCheck = winget --version 2>$null + if ($LASTEXITCODE -eq 0) { + $wingetAvailable = $true + Write-Host "Winget detected: $wingetCheck" + } +} catch { + Write-Host "Winget not available." +} + +if ($wingetAvailable) { + Write-Host "Installing Google Drive via winget..." + winget install -e --id $packageId --silent --accept-package-agreements --accept-source-agreements + + if ($LASTEXITCODE -eq 0) { + Write-Host "Google Drive has been successfully installed via winget." + Stop-Transcript + exit 0 + } else { + Write-Host "Winget install failed (exit code: $LASTEXITCODE). Falling back to direct download." + } +} + +# Fallback: direct download from Google +Write-Host "Downloading Google Drive installer from $installerUrl..." +try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-WebRequest -Uri $installerUrl -OutFile $installerPath -UseBasicParsing +} catch { + Write-Host "Failed to download Google Drive installer: $_" + Stop-Transcript + exit 1 +} + +if (-not (Test-Path -Path $installerPath)) { + Write-Host "Installer not found at $installerPath. Download may have failed." + Stop-Transcript + exit 1 +} + +Write-Host "Running Google Drive installer silently..." +Start-Process -FilePath $installerPath -ArgumentList "--silent --desktop_shortcut" -Wait -NoNewWindow + +if ($LASTEXITCODE -eq 0) { + Write-Host "Google Drive has been successfully installed via direct download." +} else { + Write-Host "Google Drive installer returned exit code: $LASTEXITCODE" +} + +# Cleanup installer +if (Test-Path -Path $installerPath) { + Remove-Item -Path $installerPath -Force + Write-Host "Cleaned up installer file." +} + +Stop-Transcript From 38f1bd5c33b5417b93a88c720eb396e693e95295 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Fri, 20 Mar 2026 11:07:28 -0400 Subject: [PATCH 4/4] Add domain controller role check to SID duplicate detection Exits early if not running on a DC (DomainRole 4 or 5) since the script queries AD computer objects via ADSI and must run from a DC. Co-Authored-By: Claude Opus 4.6 (1M context) --- msft-windows/msft-windows-sid-detect-duplicates.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/msft-windows/msft-windows-sid-detect-duplicates.ps1 b/msft-windows/msft-windows-sid-detect-duplicates.ps1 index dd9349c..713cce0 100644 --- a/msft-windows/msft-windows-sid-detect-duplicates.ps1 +++ b/msft-windows/msft-windows-sid-detect-duplicates.ps1 @@ -78,6 +78,13 @@ if (-not $partOfDomain) { exit 2 } +# DomainRole: 4 = Backup DC, 5 = Primary DC +if ($computerSystem.DomainRole -lt 4) { + Write-Host "[ERROR] This script must be run on a Domain Controller. Current role: $($computerSystem.DomainRole) (expected 4 or 5)." + Stop-Transcript + exit 2 +} + # Step 2: Query all computers with info (Notes) attribute containing MACHINESID: prefix try { $searcher = [ADSISearcher]"(&(objectCategory=computer)(info=MACHINESID:*))"