<# .SYN- Sequence.SYNOPSIS - Registration type/id - Script source / script block indicators - Risk level and recommendation .PARAMETER WebAppUrl Target SharePoint Web Application URL. .PARAMETER OutputCsv Full path to the output CSV report. .PARAMETER SiteCollectionUrl Optional. Limit the scan to a single site collection. .PARAMETER NoPrompt Optional. Skips the confirmation prompt. .PARAMETER Remediate Included for toolkit consistency only. This script is READ-ONLY and does not modify SharePoint. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$WebAppUrl, [Parameter(Mandatory = $true)] [string]$OutputCsv, [string]$SiteCollectionUrl, [switch]$NoPrompt, [switch]$Remediate ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" Write-Host "" Write-Host "SCRIPT 13 - CUSTOM ACTION INVENTORY REPORT" -ForegroundColor Cyan Write-Host "READ-ONLY. NO CHANGES ARE MADE." -ForegroundColor Green Write-Host "" # ------------------------------------------------------------ # Load SharePoint snap-in # ------------------------------------------------------------ try { if (-not (Get-PSSnapin -Name "Microsoft.SharePoint.PowerShell" -ErrorAction SilentlyContinue)) { Add-PSSnapin "Microsoft.SharePoint.PowerShell" } } catch { throw "Unable to load SharePoint snap-in. Run in SharePoint Management Shell. Error: $($_.Exception.Message)" } # ------------------------------------------------------------ # Output setup # ------------------------------------------------------------ $outDir = Split-Path -Path $OutputCsv -Parent if ([string]::IsNullOrWhiteSpace($outDir)) { throw "OutputCsv must be a full path. Example: C:\Temp\CustomActionInventory.csv" } if (-not (Test-Path -Path $outDir)) { New-Item -Path $outDir -ItemType Directory -Force | Out-Null } $timestamp = (Get-Date).ToString("yyyyMMdd_HHmmss") $baseName = [System.IO.Path]::GetFileNameWithoutExtension($OutputCsv) $summaryPath = Join-Path $outDir ("{0}_{1}_Summary_ByRiskLevel.csv" -f $baseName, $timestamp) $logPath = Join-Path $outDir ("{0}_{1}_RunLog.txt" -f $baseName, $timestamp) $errorPath = Join-Path $outDir ("{0}_{1}_Errors.csv" -f $baseName, $timestamp) # ------------------------------------------------------------ # Error / log helpers # ------------------------------------------------------------ $errors = New-Object System.Collections.Generic.List[object] function Add-ErrorRecord { param( [string]$Stage, [string]$Scope, [string]$ObjectTitle, [string]$Message ) $errors.Add([pscustomobject]@{ Timestamp = Get-Date Stage = $Stage Scope = $Scope ObjectTitle = $ObjectTitle Message = $Message }) | Out-Null } $runMessages = New-Object System.Collections.Generic.List[string] function Log-Message { param([string]$Message) $stamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") $line = "[{0}] {1}" -f $stamp, $Message $runMessages.Add($line) | Out-Null Write-Host $line } if ($Remediate) { Write-Host "Note: -Remediate is included for toolkit consistency only. This script remains READ-ONLY." -ForegroundColor Yellow } if (-not $NoPrompt) { Write-Host "This script scans SharePoint custom actions and writes reports only." -ForegroundColor Yellow Write-Host "It does not modify content, configuration, or permissions." -ForegroundColor Yellow $resp = Read-Host "Type YES to continue" if ($resp -ne "YES") { Write-Host "Cancelled by user." -ForegroundColor Yellow return } } # ------------------------------------------------------------ # Resolve target sites # ------------------------------------------------------------ function Resolve-SitesToScan { param( [string]$WebAppUrl, [string]$SiteCollectionUrl ) if (-not [string]::IsNullOrWhiteSpace($SiteCollectionUrl)) { return @(Get-SPSite -Identity $SiteCollectionUrl) } return @(Get-SPSite -WebApplication $WebAppUrl -Limit All) } # ------------------------------------------------------------ # Helper functions # ------------------------------------------------------------ function Get-RiskLevel { param( [string]$Location, [string]$ScriptSrc, [string]$ScriptBlock, [string]$RegistrationType ) $score = 0 if (-not [string]::IsNullOrWhiteSpace($ScriptBlock)) { $score += 30 } if (-not [string]::IsNullOrWhiteSpace($ScriptSrc)) { $score += 25 } if ($Location -match "ScriptLink|CommandUI.Ribbon|EditControlBlock|DisplayFormRibbon|NewFormToolbar") { $score += 20 } if ($RegistrationType -match "ContentType|List|ProgId|FileType") { $score += 10 } if ($score -ge 50) { return "High" } if ($score -ge 20) { return "Medium" } return "Low" } function Get-Score { param([string]$RiskLevel) switch ($RiskLevel) { "High" { return 30 } "Medium" { return 60 } "Low" { return 90 } default { return 50 } } } function Get-Recommendation { param( [string]$RiskLevel, [string]$Location, [string]$ScriptSrc, [string]$ScriptBlock ) if ($RiskLevel -eq "High") { return "Review this customization before migration. Validate whether it must be rebuilt, replaced, or retired in the target environment." } if (-not [string]::IsNullOrWhiteSpace($ScriptBlock) -or -not [string]::IsNullOrWhiteSpace($ScriptSrc)) { return "Validate script-based customization behavior in the target environment." } if ($Location -match "CommandUI|Ribbon|ECB") { return "Review UI customization impact and confirm whether it is still required." } return "Keep in baseline customization inventory and validate during migration planning." } function Add-CustomActionResult { param( [System.Collections.Generic.List[object]]$Results, [string]$SiteCollectionUrl, [string]$WebUrl, [string]$ScopeType, $CustomAction ) $title = "" $name = "" $location = "" $sequence = "" $registrationType = "" $registrationId = "" $scriptSrc = "" $scriptBlock = "" $url = "" $imageUrl = "" try { $title = [string]$CustomAction.Title } catch {} try { $name = [string]$CustomAction.Name } catch {} try { $location = [string]$CustomAction.Location } catch {} try { $sequence = [string]$CustomAction.Sequence } catch {} try { $registrationType = [string]$CustomAction.RegistrationType } catch {} try { $registrationId = [string]$CustomAction.RegistrationId } catch {} try { $scriptSrc = [string]$CustomAction.ScriptSrc } catch {} try { $scriptBlock = [string]$CustomAction.ScriptBlock } catch {} try { $url = [string]$CustomAction.Url } catch {} try { $imageUrl = [string]$CustomAction.ImageUrl } catch {} $riskLevel = Get-RiskLevel -Location $location -ScriptSrc $scriptSrc -ScriptBlock $scriptBlock -RegistrationType $registrationType $Results.Add([pscustomobject]@{ SiteCollectionUrl = $SiteCollectionUrl WebUrl = $WebUrl ScopeType = $ScopeType Title = $title Name = $name Location = $location Sequence = $sequence RegistrationType = $registrationType RegistrationId = $registrationId ScriptSrc = $scriptSrc ScriptBlock = $scriptBlock Url = $url ImageUrl = $imageUrl RiskLevel = $riskLevel Score = Get-Score -RiskLevel $riskLevel Category = "CustomActionInventory" ActionRecommendation = Get-Recommendation -RiskLevel $riskLevel -Location $location -ScriptSrc $scriptSrc -ScriptBlock $scriptBlock }) | Out-Null } # ------------------------------------------------------------ # Main scan # ------------------------------------------------------------ $results = New-Object System.Collections.Generic.List[object] try { $sitesToScan = Resolve-SitesToScan -WebAppUrl $WebAppUrl -SiteCollectionUrl $SiteCollectionUrl Log-Message ("Resolved {0} site collection(s) for custom action inventory." -f $sitesToScan.Count) } catch { Add-ErrorRecord -Stage "ResolveSites" -Scope $WebAppUrl -ObjectTitle "Resolve-SitesToScan" -Message $_.Exception.Message throw } foreach ($site in $sitesToScan) { try { Log-Message ("Scanning site collection: {0}" -f $site.Url) # ------------------------- # Site collection scoped custom actions # ------------------------- try { foreach ($action in $site.UserCustomActions) { Add-CustomActionResult -Results $results -SiteCollectionUrl $site.Url -WebUrl "" -ScopeType "SiteCollection" -CustomAction $action } } catch { Add-ErrorRecord -Stage "CollectSiteCollectionCustomActions" -Scope $site.Url -ObjectTitle "SiteCollection" -Message $_.Exception.Message } # ------------------------- # Web scoped custom actions # ------------------------- foreach ($web in $site.AllWebs) { try { foreach ($action in $web.UserCustomActions) { Add-CustomActionResult -Results $results -SiteCollectionUrl $site.Url -WebUrl $web.Url -ScopeType "Web" -CustomAction $action } } catch { Add-ErrorRecord -Stage "CollectWebCustomActions" -Scope $site.Url -ObjectTitle $web.Url -Message $_.Exception.Message } finally { try { $web.Dispose() } catch { } } } try { $site.Dispose() } catch { } } catch { Add-ErrorRecord -Stage "CollectSiteCustomActions" -Scope $WebAppUrl -ObjectTitle $site.Url -Message $_.Exception.Message } } # ------------------------------------------------------------ # Export detail report # ------------------------------------------------------------ try { $results | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8 } catch { throw "Failed to export detail report. $($_.Exception.Message)" } # ------------------------------------------------------------ # Export summary report # ------------------------------------------------------------ try { $results | Group-Object RiskLevel, ScopeType | ForEach-Object { $parts = $_.Name -split ', ' [pscustomobject]@{ RiskLevel = $parts[0] ScopeType = $parts[1] Count = $_.Count } } | Export-Csv -Path $summaryPath -NoTypeInformation -Encoding UTF8 } catch { Add-ErrorRecord -Stage "ExportSummary" -Scope $summaryPath -ObjectTitle "SummaryExport" -Message $_.Exception.Message } # ------------------------------------------------------------ # Export run log # ------------------------------------------------------------ try { $runMessages | Set-Content -Path $logPath -Encoding UTF8 } catch { Add-ErrorRecord -Stage "ExportRunLog" -Scope $logPath -ObjectTitle "RunLog" -Message $_.Exception.Message } # ------------------------------------------------------------ # Export errors if needed # ------------------------------------------------------------ try { if ($errors.Count -gt 0) { $errors | Export-Csv -Path $errorPath -NoTypeInformation -Encoding UTF8 Write-Host ("ERROR REPORT: {0}" -f $errorPath) -ForegroundColor Yellow } } catch { Write-Host "Failed to export error report." -ForegroundColor Red } Write-Host "" Write-Host ("DETAIL REPORT: {0}" -f $OutputCsv) -ForegroundColor Green Write-Host ("SUMMARY REPORT: {0}" -f $summaryPath) -ForegroundColor Green Write-Host ("RUN LOG: {0}" -f $logPath) -ForegroundColor Green Write-Host "" Write-Host "Complete." -ForegroundColor Green `` SharePoint Production Toolkit - Script 13 - Custom Action Inventory Report .DESCRIPTION READ-ONLY. Scans a SharePoint web application or a single site collection and exports a report of custom actions found at the site collection and web levels. Captures: - Custom action title - Name - Location