From 6fbe47745e860c87baf1204ebcf66079ffb167b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 20:57:22 +0000 Subject: [PATCH 1/3] Add Main-Shell.ps1 master launcher and Validate-Scripts.ps1 checker Main-Shell.ps1 - Interactive console menu that discovers all 400+ repo scripts by category - Browse by directory, search by keyword, or jump straight to any script - Run scripts in the current session or launch them in a new pwsh window - Integrates with Validate-Scripts.ps1 via the [V] menu option Validate-Scripts.ps1 - AST-based syntax validation for every .ps1 in the repository - Checks #Requires -Version against the running PowerShell version - Detects missing/uninstalled module dependencies (Import-Module, Connect-* cmdlets) - Flags hardcoded Windows paths, dangerous cmdlets, missing error handling, and plain-text credential patterns - Colour-coded console report with Pass/Info/Warning/Error severity levels - Optional -ExportCsv output, -IncludePassing flag, and -Category filter https://claude.ai/code/session_01PsNTPi5ypXGXWy5nEcvM5t --- Main-Shell.ps1 | 412 ++++++++++++++++++++++++++++++++++ Validate-Scripts.ps1 | 520 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 932 insertions(+) create mode 100644 Main-Shell.ps1 create mode 100644 Validate-Scripts.ps1 diff --git a/Main-Shell.ps1 b/Main-Shell.ps1 new file mode 100644 index 0000000..2acaddb --- /dev/null +++ b/Main-Shell.ps1 @@ -0,0 +1,412 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Master Shell - PowerShell Script Directory & Launcher +.DESCRIPTION + Central hub for discovering and launching any script in this repository. + Browse by category, search by keyword, view script descriptions, and run + any subscript directly from this menu. +.NOTES + Author : Jason Lamb (jasrasr) + Website : https://jasr.me/ps1 + Version : 1.0 + Requires: PowerShell 5.1+ +#> + +Set-StrictMode -Off + +# ── Paths ───────────────────────────────────────────────────────────────────── +$script:RepoRoot = $PSScriptRoot + +# ── Colour palette ──────────────────────────────────────────────────────────── +$C = @{ + Title = 'Cyan' + Header = 'Yellow' + Category = 'Green' + Script = 'White' + Dim = 'DarkGray' + Prompt = 'Magenta' + Error = 'Red' + Success = 'Green' + Info = 'DarkCyan' + Number = 'DarkYellow' +} + +# ── Categories to skip ──────────────────────────────────────────────────────── +$script:SkipDirs = @('.git', '.github', 'ImageMagick', 'Downloads', 'Testing', 'Lock-Screen') + +# ══════════════════════════════════════════════════════════════════════════════ +# Helper functions +# ══════════════════════════════════════════════════════════════════════════════ + +function Write-Banner { + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════════════════════════════╗' -ForegroundColor $C.Title + Write-Host ' ║ POWERSHELL MASTER SHELL – Script Launcher ║' -ForegroundColor $C.Title + Write-Host ' ║ https://jasr.me/ps1 • jasrasr ║' -ForegroundColor $C.Dim + Write-Host ' ╚══════════════════════════════════════════════════════════════╝' -ForegroundColor $C.Title + Write-Host '' +} + +function Get-ScriptDescription { + <# + .SYNOPSIS Returns the first meaningful comment line from a .ps1 file as its description. + #> + param([string]$Path) + + try { + $lines = Get-Content -Path $Path -TotalCount 30 -ErrorAction SilentlyContinue + foreach ($line in $lines) { + $trimmed = $line.Trim() + # Skip blank lines, #Requires, shebang lines, and block comment markers + if ($trimmed -match '^#\s*Requires' -or + $trimmed -eq '#' -or + $trimmed -match '^<#' -or + $trimmed -match '^#>' -or + $trimmed -eq '') { continue } + + # .SYNOPSIS value (next line after .SYNOPSIS) + if ($trimmed -match '^\.(SYNOPSIS|DESCRIPTION)$') { continue } + + # Return the first non-boilerplate comment or code description + if ($trimmed -match '^#+\s*(.+)') { + return $Matches[1].Trim() + } + # Non-comment line → use first word as hint + if ($trimmed.Length -gt 0) { + return "(no description)" + } + } + } catch { } + return "(no description)" +} + +function Get-Categories { + <# + .SYNOPSIS Enumerates all script categories (subdirectories) in the repo. + #> + $dirs = Get-ChildItem -Path $script:RepoRoot -Directory | + Where-Object { $_.Name -notin $script:SkipDirs } | + Sort-Object Name + + # Add a virtual "Root Scripts" entry for top-level .ps1 files + $rootScripts = Get-ChildItem -Path $script:RepoRoot -Filter '*.ps1' -File + $categories = [System.Collections.Generic.List[object]]::new() + + if ($rootScripts.Count -gt 0) { + $categories.Add([PSCustomObject]@{ + Name = 'Root Scripts' + FullName = $script:RepoRoot + ScriptCount = $rootScripts.Count + IsRoot = $true + }) + } + + foreach ($dir in $dirs) { + $count = (Get-ChildItem -Path $dir.FullName -Filter '*.ps1' -Recurse -File -ErrorAction SilentlyContinue).Count + if ($count -gt 0) { + $categories.Add([PSCustomObject]@{ + Name = $dir.Name + FullName = $dir.FullName + ScriptCount = $count + IsRoot = $false + }) + } + } + + return $categories +} + +function Get-ScriptsInCategory { + <# + .SYNOPSIS Returns all .ps1 files inside a given directory (recursive). + #> + param([string]$Path, [bool]$RootOnly = $false) + + if ($RootOnly) { + Get-ChildItem -Path $Path -Filter '*.ps1' -File | Sort-Object Name + } else { + Get-ChildItem -Path $Path -Filter '*.ps1' -File -Recurse | + Where-Object { $_.DirectoryName -notmatch '\\\.git(\\|$)' } | + Sort-Object Name + } +} + +function Show-Categories { + param([System.Collections.Generic.List[object]]$Categories) + + Write-Host ' Script Categories' -ForegroundColor $C.Header + Write-Host ' ─────────────────────────────────────────────────' -ForegroundColor $C.Dim + Write-Host '' + + $i = 1 + foreach ($cat in $Categories) { + $num = " [{0,2}]" -f $i + $name = $cat.Name.PadRight(38) + $count = "({0} scripts)" -f $cat.ScriptCount + Write-Host $num -ForegroundColor $C.Number -NoNewline + Write-Host " $name" -ForegroundColor $C.Category -NoNewline + Write-Host " $count" -ForegroundColor $C.Dim + $i++ + } + + Write-Host '' + Write-Host ' [ S] Search all scripts by keyword' -ForegroundColor $C.Info + Write-Host ' [ V] Run Validation (Validate-Scripts.ps1)' -ForegroundColor $C.Info + Write-Host ' [ Q] Quit' -ForegroundColor $C.Dim + Write-Host '' +} + +function Show-ScriptsInCategory { + param( + [PSCustomObject]$Category, + [System.Collections.Generic.List[object]]$Scripts + ) + + Write-Host '' + Write-Host (" Category: {0}" -f $Category.Name) -ForegroundColor $C.Header + Write-Host ' ─────────────────────────────────────────────────' -ForegroundColor $C.Dim + Write-Host '' + + $i = 1 + foreach ($s in $Scripts) { + $desc = Get-ScriptDescription -Path $s.FullName + $rel = $s.FullName.Replace($script:RepoRoot, '').TrimStart('\','/') + $num = " [{0,3}]" -f $i + Write-Host $num -ForegroundColor $C.Number -NoNewline + Write-Host " $($s.Name)" -ForegroundColor $C.Script -NoNewline + Write-Host " → " -ForegroundColor $C.Dim -NoNewline + Write-Host $desc -ForegroundColor $C.Dim + if ($rel -ne $s.Name) { + Write-Host (" " + $rel) -ForegroundColor $C.Dim + } + $i++ + } + + Write-Host '' + Write-Host ' [ B] Back to categories' -ForegroundColor $C.Info + Write-Host ' [ Q] Quit' -ForegroundColor $C.Dim + Write-Host '' +} + +function Invoke-ScriptLauncher { + <# + .SYNOPSIS Prompts for optional parameters then dot-sources or launches the chosen script. + #> + param([System.IO.FileInfo]$ScriptFile) + + Write-Host '' + Write-Host (" Preparing to run: {0}" -f $ScriptFile.Name) -ForegroundColor $C.Success + Write-Host (" Full path: {0}" -f $ScriptFile.FullName) -ForegroundColor $C.Dim + Write-Host '' + Write-Host ' Enter any arguments (press Enter to skip): ' -ForegroundColor $C.Prompt -NoNewline + $argsInput = Read-Host + + Write-Host '' + Write-Host ' How would you like to run this script?' -ForegroundColor $C.Header + Write-Host ' [1] & (Run in current session)' -ForegroundColor $C.Script + Write-Host ' [2] Start-Process pwsh (new window)' -ForegroundColor $C.Script + Write-Host ' [3] Cancel' -ForegroundColor $C.Dim + Write-Host '' + Write-Host ' Choice: ' -ForegroundColor $C.Prompt -NoNewline + $runChoice = Read-Host + + switch ($runChoice.Trim()) { + '1' { + Write-Host '' + Write-Host ' Running script...' -ForegroundColor $C.Success + Write-Host (' ' + ('─' * 60)) -ForegroundColor $C.Dim + Write-Host '' + try { + if ($argsInput.Trim() -ne '') { + $argArray = $argsInput -split '\s+(?=(?:[^"]*"[^"]*")*[^"]*$)' + & $ScriptFile.FullName @argArray + } else { + & $ScriptFile.FullName + } + } catch { + Write-Host (" ERROR: {0}" -f $_.Exception.Message) -ForegroundColor $C.Error + } + Write-Host '' + Write-Host (' ' + ('─' * 60)) -ForegroundColor $C.Dim + Write-Host ' Script finished. Press Enter to continue...' -ForegroundColor $C.Dim + $null = Read-Host + } + '2' { + $pwsh = if (Get-Command 'pwsh' -ErrorAction SilentlyContinue) { 'pwsh' } else { 'powershell' } + $args = if ($argsInput.Trim() -ne '') { "-File `"$($ScriptFile.FullName)`" $argsInput" } ` + else { "-File `"$($ScriptFile.FullName)`"" } + Start-Process $pwsh -ArgumentList $args + Write-Host ' Script launched in new window.' -ForegroundColor $C.Success + Start-Sleep -Seconds 1 + } + default { + Write-Host ' Cancelled.' -ForegroundColor $C.Dim + Start-Sleep -Milliseconds 500 + } + } +} + +function Search-Scripts { + <# + .SYNOPSIS Searches all script names and descriptions for a keyword. + #> + Write-Host '' + Write-Host ' Search all scripts' -ForegroundColor $C.Header + Write-Host ' ─────────────────────────────────────────────────' -ForegroundColor $C.Dim + Write-Host ' Keyword: ' -ForegroundColor $C.Prompt -NoNewline + $keyword = Read-Host + + if ([string]::IsNullOrWhiteSpace($keyword)) { return } + + Write-Host '' + Write-Host (" Results for: '{0}'" -f $keyword) -ForegroundColor $C.Header + Write-Host ' ─────────────────────────────────────────────────' -ForegroundColor $C.Dim + Write-Host '' + + $allScripts = Get-ChildItem -Path $script:RepoRoot -Filter '*.ps1' -Recurse -File | + Where-Object { $_.FullName -notmatch '[/\\]\.git[/\\]' } + + $results = [System.Collections.Generic.List[System.IO.FileInfo]]::new() + + foreach ($s in $allScripts) { + $desc = Get-ScriptDescription -Path $s.FullName + $rel = $s.FullName.Replace($script:RepoRoot, '').TrimStart('\','/') + if ($rel -match [regex]::Escape($keyword) -or $desc -match [regex]::Escape($keyword)) { + $results.Add($s) + } + } + + if ($results.Count -eq 0) { + Write-Host ' No scripts matched your search.' -ForegroundColor $C.Dim + } else { + $i = 1 + foreach ($s in $results) { + $rel = $s.FullName.Replace($script:RepoRoot, '').TrimStart('\','/') + $desc = Get-ScriptDescription -Path $s.FullName + Write-Host (" [{0,3}] {1}" -f $i, $rel) -ForegroundColor $C.Script + Write-Host (" {0}" -f $desc) -ForegroundColor $C.Dim + $i++ + } + + Write-Host '' + Write-Host (' Enter a number to run a script, or press Enter to go back: ') -ForegroundColor $C.Prompt -NoNewline + $pick = Read-Host + if ($pick -match '^\d+$') { + $idx = [int]$pick - 1 + if ($idx -ge 0 -and $idx -lt $results.Count) { + Invoke-ScriptLauncher -ScriptFile $results[$idx] + } + } + } + + Write-Host '' + Write-Host ' Press Enter to continue...' -ForegroundColor $C.Dim + $null = Read-Host +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Main loop +# ══════════════════════════════════════════════════════════════════════════════ + +function Start-MainShell { + $validationScript = Join-Path $script:RepoRoot 'Validate-Scripts.ps1' + + :mainLoop while ($true) { + + Write-Banner + $categories = Get-Categories + Show-Categories -Categories $categories + + Write-Host ' Select a category number (or S / V / Q): ' -ForegroundColor $C.Prompt -NoNewline + $choice = Read-Host + + switch -Regex ($choice.Trim().ToUpper()) { + + '^Q$' { + Write-Host '' + Write-Host ' Goodbye!' -ForegroundColor $C.Success + Write-Host '' + break mainLoop + } + + '^S$' { + Write-Banner + Search-Scripts + } + + '^V$' { + if (Test-Path $validationScript) { + Write-Host '' + Write-Host ' Launching Validate-Scripts.ps1 ...' -ForegroundColor $C.Success + Write-Host '' + & $validationScript + Write-Host '' + Write-Host ' Press Enter to return to main menu...' -ForegroundColor $C.Dim + $null = Read-Host + } else { + Write-Host '' + Write-Host ' Validate-Scripts.ps1 not found. Run Main-Shell.ps1 from the repo root.' -ForegroundColor $C.Error + Start-Sleep -Seconds 2 + } + } + + '^\d+$' { + $idx = [int]$choice - 1 + if ($idx -lt 0 -or $idx -ge $categories.Count) { + Write-Host ' Invalid selection.' -ForegroundColor $C.Error + Start-Sleep -Milliseconds 800 + continue + } + + $selectedCat = $categories[$idx] + $isRoot = $selectedCat.IsRoot + + :categoryLoop while ($true) { + + Write-Banner + $scripts = [System.Collections.Generic.List[System.IO.FileInfo]]( + Get-ScriptsInCategory -Path $selectedCat.FullName -RootOnly $isRoot + ) + Show-ScriptsInCategory -Category $selectedCat -Scripts $scripts + + Write-Host ' Select a script number (or B / Q): ' -ForegroundColor $C.Prompt -NoNewline + $scriptChoice = Read-Host + + switch -Regex ($scriptChoice.Trim().ToUpper()) { + '^Q$' { + Write-Host '' + Write-Host ' Goodbye!' -ForegroundColor $C.Success + Write-Host '' + break mainLoop + } + '^B$' { break categoryLoop } + '^\d+$' { + $sIdx = [int]$scriptChoice - 1 + if ($sIdx -lt 0 -or $sIdx -ge $scripts.Count) { + Write-Host ' Invalid selection.' -ForegroundColor $C.Error + Start-Sleep -Milliseconds 800 + } else { + Write-Banner + Invoke-ScriptLauncher -ScriptFile $scripts[$sIdx] + } + } + default { + Write-Host ' Invalid input. Enter a number, B, or Q.' -ForegroundColor $C.Error + Start-Sleep -Milliseconds 800 + } + } + } + } + + default { + Write-Host ' Invalid input. Enter a number, S, V, or Q.' -ForegroundColor $C.Error + Start-Sleep -Milliseconds 800 + } + } + } +} + +# ── Entry point ─────────────────────────────────────────────────────────────── +Start-MainShell diff --git a/Validate-Scripts.ps1 b/Validate-Scripts.ps1 new file mode 100644 index 0000000..6a9277f --- /dev/null +++ b/Validate-Scripts.ps1 @@ -0,0 +1,520 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Validate-Scripts.ps1 – Compatibility & Health Checker for all repo subscripts +.DESCRIPTION + Scans every .ps1 file in this repository and checks: + 1. Syntax validity – parses each script with the PowerShell AST parser + 2. #Requires version – compares against the current PS version + 3. Required modules – detects Import-Module / #Requires -Module declarations + 4. Hardcoded paths – flags absolute Windows paths that may not be portable + 5. Dangerous cmdlets – warns about Remove-Item -Recurse / Format-* disk ops + 6. Missing error handling – notes scripts that use no try/catch + 7. Credential exposure – detects plain-text password patterns + + Outputs a colour-coded report to the console and (optionally) exports to CSV. + +.PARAMETER Path + Root path to scan. Defaults to the directory containing this script. + +.PARAMETER ExportCsv + If specified, saves the full report to this CSV path. + +.PARAMETER IncludePassing + When set, passing scripts are also listed (default: only warnings/errors). + +.PARAMETER Category + Limit scanning to a specific subdirectory name (e.g. "AD-Scripts"). + +.EXAMPLE + .\Validate-Scripts.ps1 + .\Validate-Scripts.ps1 -ExportCsv C:\Reports\ps1-validation.csv + .\Validate-Scripts.ps1 -Category File-Management-Scripts -IncludePassing +.NOTES + Author : Jason Lamb (jasrasr) + Website : https://jasr.me/ps1 + Version : 1.0 +#> +[CmdletBinding()] +param( + [string] $Path = $PSScriptRoot, + [string] $ExportCsv = '', + [switch] $IncludePassing, + [string] $Category = '' +) + +Set-StrictMode -Off + +# ── Colour palette ───────────────────────────────────────────────────────────── +$C = @{ + Title = 'Cyan' + Header = 'Yellow' + Pass = 'Green' + Warn = 'DarkYellow' + Fail = 'Red' + Info = 'DarkCyan' + Dim = 'DarkGray' + Prompt = 'Magenta' + Number = 'DarkYellow' +} + +# ── Directories to skip ──────────────────────────────────────────────────────── +$SkipDirs = @('.git', '.github', 'ImageMagick', 'Downloads') + +# ── Current PS version ──────────────────────────────────────────────────────── +$CurrentPSVersion = $PSVersionTable.PSVersion + +# ══════════════════════════════════════════════════════════════════════════════ +# Severity levels (ordered low → high) +# ══════════════════════════════════════════════════════════════════════════════ +enum Severity { Pass; Info; Warning; Error } + +# ══════════════════════════════════════════════════════════════════════════════ +# Check functions (each returns [PSCustomObject[]] of findings) +# ══════════════════════════════════════════════════════════════════════════════ + +function Test-Syntax { + <# + .SYNOPSIS Uses the PowerShell AST parser to detect syntax errors. + #> + param([string]$FilePath) + + $errors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($FilePath, [ref]$tokens, [ref]$errors) + + if ($errors -and $errors.Count -gt 0) { + foreach ($e in $errors) { + [PSCustomObject]@{ + Check = 'Syntax' + Severity = [Severity]::Error + Message = "Line $($e.Extent.StartLineNumber): $($e.Message)" + } + } + } else { + [PSCustomObject]@{ + Check = 'Syntax' + Severity = [Severity]::Pass + Message = 'No syntax errors' + } + } +} + +function Test-RequiresVersion { + <# + .SYNOPSIS Checks #Requires -Version against the running PS version. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { + return [PSCustomObject]@{ Check = 'PSVersion'; Severity = [Severity]::Info; Message = 'Empty or unreadable file' } + } + + $match = [regex]::Match($content, '#Requires\s+-Version\s+([\d\.]+)', 'IgnoreCase') + if (-not $match.Success) { + return [PSCustomObject]@{ Check = 'PSVersion'; Severity = [Severity]::Info; Message = 'No #Requires -Version declaration' } + } + + try { + $required = [Version]$match.Groups[1].Value + if ($CurrentPSVersion -lt $required) { + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Error + Message = "Requires PS $required but running PS $CurrentPSVersion" + } + } + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Pass + Message = "PS $required required – current PS $CurrentPSVersion OK" + } + } catch { + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Warning + Message = "Could not parse version '$($match.Groups[1].Value)'" + } + } +} + +function Test-RequiredModules { + <# + .SYNOPSIS Detects module dependencies and checks if they are installed. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $moduleNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + # #Requires -Module or #Requires -Modules @('a','b') + $requiresMatches = [regex]::Matches($content, '#Requires\s+-Modules?\s+(.+)', 'IgnoreCase') + foreach ($m in $requiresMatches) { + $raw = $m.Groups[1].Value -replace "@\(|'|""|`"|\)" , '' -replace '\s', '' + foreach ($n in ($raw -split ',')) { if ($n) { $null = $moduleNames.Add($n) } } + } + + # Import-Module "name" or Import-Module name + $importMatches = [regex]::Matches($content, 'Import-Module\s+[''"]?([A-Za-z0-9\.\-_]+)[''"]?', 'IgnoreCase') + foreach ($m in $importMatches) { $null = $moduleNames.Add($m.Groups[1].Value) } + + # Connect-* cmdlets often imply a module + $connectMap = @{ + 'Connect-AzureAD' = 'AzureAD' + 'Connect-MsolService' = 'MSOnline' + 'Connect-ExchangeOnline' = 'ExchangeOnlineManagement' + 'Connect-MgGraph' = 'Microsoft.Graph' + 'Connect-SPOService' = 'Microsoft.Online.SharePoint.PowerShell' + 'Connect-MicrosoftTeams' = 'MicrosoftTeams' + 'New-ADUser' = 'ActiveDirectory' + 'Get-ADUser' = 'ActiveDirectory' + } + foreach ($cmd in $connectMap.Keys) { + if ($content -match [regex]::Escape($cmd)) { + $null = $moduleNames.Add($connectMap[$cmd]) + } + } + + if ($moduleNames.Count -eq 0) { + return [PSCustomObject]@{ Check = 'Modules'; Severity = [Severity]::Info; Message = 'No module dependencies detected' } + } + + $results = @() + foreach ($mod in $moduleNames | Sort-Object) { + $installed = Get-Module -Name $mod -ListAvailable -ErrorAction SilentlyContinue + if ($installed) { + $ver = ($installed | Sort-Object Version -Descending | Select-Object -First 1).Version + $results += [PSCustomObject]@{ + Check = 'Modules' + Severity = [Severity]::Pass + Message = "Module '$mod' found (v$ver)" + } + } else { + $results += [PSCustomObject]@{ + Check = 'Modules' + Severity = [Severity]::Warning + Message = "Module '$mod' NOT installed on this machine" + } + } + } + return $results +} + +function Test-HardcodedPaths { + <# + .SYNOPSIS Warns about absolute Windows paths that will break on other systems. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + # Match C:\... D:\... \\server\share but skip comment lines + $lines = $content -split "`n" + $results = @() + $lineNum = 0 + foreach ($line in $lines) { + $lineNum++ + $trimmed = $line.Trim() + if ($trimmed -match '^#') { continue } # skip full-line comments + if ($trimmed -match '[A-Z]:\\' -or $trimmed -match '\\\\[A-Za-z0-9]') { + $results += [PSCustomObject]@{ + Check = 'HardcodedPaths' + Severity = [Severity]::Warning + Message = "Line $lineNum may contain a hardcoded path: $($trimmed.Substring(0, [Math]::Min(80,$trimmed.Length)))" + } + if ($results.Count -ge 3) { + $results += [PSCustomObject]@{ + Check = 'HardcodedPaths' + Severity = [Severity]::Warning + Message = "… (additional hardcoded paths truncated)" + } + break + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'HardcodedPaths'; Severity = [Severity]::Pass; Message = 'No hardcoded Windows paths detected' } + } + return $results +} + +function Test-DangerousCmdlets { + <# + .SYNOPSIS Flags cmdlets that can be destructive if run unintentionally. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $dangerous = @( + @{ Pattern = 'Remove-Item.+-Recurse'; Label = 'Remove-Item -Recurse (recursive delete)' } + @{ Pattern = 'Format-Volume|Clear-Disk'; Label = 'Format-Volume / Clear-Disk (disk format)' } + @{ Pattern = 'Stop-Computer|Restart-Computer'; Label = 'Stop/Restart-Computer (system power)' } + @{ Pattern = 'Invoke-Expression|iex\b'; Label = 'Invoke-Expression / iex (code injection risk)' } + @{ Pattern = 'DownloadString|WebClient.*Download'; Label = 'WebClient download (execution from web)' } + @{ Pattern = '\[Net\.ServicePointManager\].*ServerCertificateValidationCallback'; Label = 'SSL validation disabled' } + ) + + $results = @() + foreach ($item in $dangerous) { + if ($content -match $item.Pattern) { + $results += [PSCustomObject]@{ + Check = 'DangerousCmdlets' + Severity = [Severity]::Warning + Message = "Contains: $($item.Label)" + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'DangerousCmdlets'; Severity = [Severity]::Pass; Message = 'No dangerous cmdlets detected' } + } + return $results +} + +function Test-ErrorHandling { + <# + .SYNOPSIS Notes scripts that have no try/catch, -ErrorAction, or trap blocks. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $hasTry = $content -match '\btry\s*\{' + $hasErrorAction = $content -match '-ErrorAction\b' + $hasTrap = $content -match '\btrap\b' + + if (-not ($hasTry -or $hasErrorAction -or $hasTrap)) { + return [PSCustomObject]@{ + Check = 'ErrorHandling' + Severity = [Severity]::Info + Message = 'No try/catch, -ErrorAction, or trap found – consider adding error handling' + } + } + return [PSCustomObject]@{ Check = 'ErrorHandling'; Severity = [Severity]::Pass; Message = 'Error handling present' } +} + +function Test-CredentialExposure { + <# + .SYNOPSIS Detects patterns that suggest plain-text credentials in source code. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $patterns = @( + @{ Pattern = 'password\s*=\s*["''][^"'']+["'']'; Label = 'Plain-text password assignment' } + @{ Pattern = '\$pass(word)?\s*=\s*["''][^"'']+["'']'; Label = 'Plain-text $password variable' } + @{ Pattern = 'ConvertTo-SecureString.+-AsPlainText'; Label = 'ConvertTo-SecureString -AsPlainText (credential in code)' } + @{ Pattern = 'apikey|api_key|secret.{0,10}=\s*["'']'; Label = 'Possible API key / secret in source' } + ) + + $results = @() + foreach ($item in $patterns) { + if ($content -match $item.Pattern) { + $results += [PSCustomObject]@{ + Check = 'Credentials' + Severity = [Severity]::Warning + Message = $item.Label + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'Credentials'; Severity = [Severity]::Pass; Message = 'No exposed credential patterns detected' } + } + return $results +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Aggregation & reporting +# ══════════════════════════════════════════════════════════════════════════════ + +function Get-AllScripts { + param([string]$RootPath, [string]$CategoryFilter) + + $allScripts = Get-ChildItem -Path $RootPath -Filter '*.ps1' -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { + $skip = $false + foreach ($d in $SkipDirs) { + if ($_.FullName -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + [IO.Path]::DirectorySeparatorChar) -or + $_.FullName -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + '$')) { + $skip = $true; break + } + } + -not $skip + } + + if ($CategoryFilter) { + $allScripts = $allScripts | Where-Object { $_.FullName -match [regex]::Escape($CategoryFilter) } + } + + return $allScripts | Sort-Object FullName +} + +function Invoke-Validation { + param([System.IO.FileInfo[]]$Scripts) + + $report = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($script in $Scripts) { + $rel = $script.FullName.Replace($Path, '').TrimStart('/\') + $findings = @() + $findings += Test-Syntax -FilePath $script.FullName + $findings += Test-RequiresVersion -FilePath $script.FullName + $findings += Test-RequiredModules -FilePath $script.FullName + $findings += Test-HardcodedPaths -FilePath $script.FullName + $findings += Test-DangerousCmdlets -FilePath $script.FullName + $findings += Test-ErrorHandling -FilePath $script.FullName + $findings += Test-CredentialExposure -FilePath $script.FullName + + # Overall status = highest severity + $overallSev = $findings | Measure-Object -Property Severity -Maximum | Select-Object -ExpandProperty Maximum + $overallSev = [Severity]$overallSev + + $report.Add([PSCustomObject]@{ + Script = $rel + FullPath = $script.FullName + Status = $overallSev.ToString() + Findings = $findings + }) + } + return $report +} + +function Write-Report { + param([System.Collections.Generic.List[PSCustomObject]]$Report) + + $total = $Report.Count + $passing = ($Report | Where-Object Status -eq 'Pass').Count + $infos = ($Report | Where-Object Status -eq 'Info').Count + $warns = ($Report | Where-Object Status -eq 'Warning').Count + $errors = ($Report | Where-Object Status -eq 'Error').Count + + # ── Banner ──────────────────────────────────────────────────────────────── + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════════════════════════════╗' -ForegroundColor $C.Title + Write-Host ' ║ VALIDATE-SCRIPTS – Compatibility Report ║' -ForegroundColor $C.Title + Write-Host ' ║ https://jasr.me/ps1 • jasrasr ║' -ForegroundColor $C.Dim + Write-Host ' ╚══════════════════════════════════════════════════════════════╝' -ForegroundColor $C.Title + Write-Host '' + Write-Host (" PowerShell Version : {0}" -f $CurrentPSVersion) -ForegroundColor $C.Info + Write-Host (" Scan Path : {0}" -f $Path) -ForegroundColor $C.Info + Write-Host (" Scripts Scanned : {0}" -f $total) -ForegroundColor $C.Info + Write-Host '' + + # ── Per-script results ──────────────────────────────────────────────────── + foreach ($entry in $Report) { + + $shouldShow = switch ($entry.Status) { + 'Error' { $true } + 'Warning' { $true } + 'Info' { $IncludePassing } + 'Pass' { $IncludePassing } + default { $false } + } + if (-not $shouldShow) { continue } + + $statusColour = switch ($entry.Status) { + 'Pass' { $C.Pass } + 'Info' { $C.Info } + 'Warning' { $C.Warn } + 'Error' { $C.Fail } + } + + Write-Host (" ── {0}" -f $entry.Script) -ForegroundColor $C.Header + Write-Host (" Status: [{0}]" -f $entry.Status.ToUpper()) -ForegroundColor $statusColour + + foreach ($f in $entry.Findings) { + if ($f.Severity -eq [Severity]::Pass -and -not $IncludePassing) { continue } + $fc = switch ($f.Severity) { + ([Severity]::Pass) { $C.Dim } + ([Severity]::Info) { $C.Info } + ([Severity]::Warning) { $C.Warn } + ([Severity]::Error) { $C.Fail } + } + $prefix = switch ($f.Severity) { + ([Severity]::Pass) { ' ✓' } + ([Severity]::Info) { ' ℹ' } + ([Severity]::Warning) { ' ⚠' } + ([Severity]::Error) { ' ✗' } + } + Write-Host (" {0} [{1,-16}] {2}" -f $prefix, $f.Check, $f.Message) -ForegroundColor $fc + } + Write-Host '' + } + + # ── Summary ─────────────────────────────────────────────────────────────── + Write-Host ' ══════════════════════════════════════════════════════════════' -ForegroundColor $C.Dim + Write-Host ' SUMMARY' -ForegroundColor $C.Header + Write-Host (" Total scripts : {0}" -f $total) -ForegroundColor $C.Info + Write-Host (" ✓ Pass : {0}" -f $passing) -ForegroundColor $C.Pass + Write-Host (" ℹ Info : {0}" -f $infos) -ForegroundColor $C.Info + Write-Host (" ⚠ Warnings : {0}" -f $warns) -ForegroundColor $C.Warn + Write-Host (" ✗ Errors : {0}" -f $errors) -ForegroundColor $C.Fail + + if (-not $IncludePassing -and ($passing + $infos) -gt 0) { + Write-Host '' + Write-Host (" (Run with -IncludePassing to see all {0} clean scripts)" -f ($passing + $infos)) -ForegroundColor $C.Dim + } + Write-Host '' +} + +function Export-CsvReport { + param([System.Collections.Generic.List[PSCustomObject]]$Report, [string]$CsvPath) + + $rows = foreach ($entry in $Report) { + foreach ($f in $entry.Findings) { + [PSCustomObject]@{ + Script = $entry.Script + Status = $entry.Status + Check = $f.Check + Severity = $f.Severity.ToString() + Message = $f.Message + FullPath = $entry.FullPath + } + } + } + + try { + $rows | Export-Csv -Path $CsvPath -NoTypeInformation -Force -Encoding UTF8 + Write-Host (" CSV report saved to: {0}" -f $CsvPath) -ForegroundColor $C.Pass + } catch { + Write-Host (" Could not save CSV: {0}" -f $_.Exception.Message) -ForegroundColor $C.Fail + } +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ══════════════════════════════════════════════════════════════════════════════ + +Write-Host '' +Write-Host ' Scanning scripts, please wait...' -ForegroundColor $C.Info + +$scripts = Get-AllScripts -RootPath $Path -CategoryFilter $Category + +if ($scripts.Count -eq 0) { + Write-Host ' No .ps1 scripts found in the specified path.' -ForegroundColor $C.Warn + exit 0 +} + +Write-Host (" Found {0} scripts. Running checks..." -f $scripts.Count) -ForegroundColor $C.Info +$report = Invoke-Validation -Scripts $scripts + +Write-Report -Report $report + +if ($ExportCsv) { + Export-CsvReport -Report $report -CsvPath $ExportCsv +} + +Write-Host ' Press Enter to return...' -ForegroundColor $C.Dim +$null = Read-Host From b56f943e7af884305122aad1ca1fb326e0e4b897 Mon Sep 17 00:00:00 2001 From: Jason Lamb Date: Wed, 18 Mar 2026 14:26:42 -0400 Subject: [PATCH 2/3] editing files in sub branch --- Uninstall-Stop-Reinstall-Egnyte.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Uninstall-Stop-Reinstall-Egnyte.ps1 b/Uninstall-Stop-Reinstall-Egnyte.ps1 index 31d711b..f1cc5a3 100644 --- a/Uninstall-Stop-Reinstall-Egnyte.ps1 +++ b/Uninstall-Stop-Reinstall-Egnyte.ps1 @@ -101,7 +101,7 @@ else { # ------------------------ # Copy MSI Local and Install # ------------------------ -$sourceMsi = "\\clesccm\Application Source\Accessories\Egnyte\3.29.1.175\EgnyteDesktopApp_3.29.1_175.msi" +$sourceMsi = "\\servername\foldername\Egnyte\3.29.1.175\EgnyteDesktopApp_3.29.1_175.msi" $localFolder = "C:\Temp" $localMsi = Join-Path $localFolder "EgnyteDesktopApp_3.29.1_175.msi" From ccce555ac1420c2e02cde05aca730152e92619c0 Mon Sep 17 00:00:00 2001 From: Jason Lamb Date: Wed, 18 Mar 2026 14:28:18 -0400 Subject: [PATCH 3/3] editing files in sub branch --- .net-string-methods.ps1 | 10 +- AD-Scripts/ad-audit.ps1 | 4 +- AutoCAD/autopurge.ps1 | 4 +- AutoCAD/autopurge.scr | 6 + Validate-Scripts.ps1 | 2 +- Validate-Scripts.ps1.bak | 520 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 AutoCAD/autopurge.scr create mode 100644 Validate-Scripts.ps1.bak diff --git a/.net-string-methods.ps1 b/.net-string-methods.ps1 index cf78388..17a6fe8 100644 --- a/.net-string-methods.ps1 +++ b/.net-string-methods.ps1 @@ -36,14 +36,18 @@ $string.Length # Number of characters "one,two,three".Split(",") # → array: "one" "two" "three" @("one", "two") -join "|" # → "one|two" -# File path helpers +# File path manipulation using System.IO.Path methods +# File path helpers if $path = "C:\folder\file.txt" +$path = "C:\folder\file.txt" [System.IO.Path]::GetFileName($path) # "file.txt" [System.IO.Path]::GetDirectoryName($path) # "C:\folder" [System.IO.Path]::GetExtension($path) # ".txt" -[System.IO.Path]::ChangeExtension($path, ".bak") # Change file extension +[System.IO.Path]::ChangeExtension($path, ".bak") # C:\folder\file.bak - Change file extension + + # Practical examples -" jason.lamb ".Trim().ToUpper() # "JASON.LAMB" +"first.last".Trim().ToUpper() # "FIRST.LAST" "report-2025.pdf".Replace("2025","2026") # "report-2026.pdf" "one,two,three".Split(",") # "one","two","three" @("x", "y", "z") -join ";" # "x;y;z" diff --git a/AD-Scripts/ad-audit.ps1 b/AD-Scripts/ad-audit.ps1 index fe9fd63..8878530 100644 --- a/AD-Scripts/ad-audit.ps1 +++ b/AD-Scripts/ad-audit.ps1 @@ -22,10 +22,10 @@ param( # === CONFIGURABLE === # Only this DC will be queried. Change when needed. -$DomainController = 'CLEDC1' +$DomainController = 'SERVERNAME' # e.g. 'DC1', 'dc01.corp.local', etc. # Output location -$OutDir = 'C:\temp\powershell-exports' +$OutDir = $psexports if (-not (Test-Path $OutDir)) { New-Item -Path $OutDir -ItemType Directory | Out-Null } $Stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $OutFile = Join-Path $OutDir "ad-reactivation-audit-$($DomainController)-$Stamp.csv" diff --git a/AutoCAD/autopurge.ps1 b/AutoCAD/autopurge.ps1 index ceff4f9..64a201c 100644 --- a/AutoCAD/autopurge.ps1 +++ b/AutoCAD/autopurge.ps1 @@ -1,7 +1,7 @@ # Paths $coreConsolePath = "C:\Program Files\Autodesk\AutoCAD 2026\accoreconsole.exe" -$scriptFile = "C:\Temp\193-Scrubber-Oil\autopurge.scr" -$dwgFull = "C:\Temp\193-Scrubber-Oil\193-P-1908.dwg" +$scriptFile = "C:\folder\autopurge.scr" +$dwgFull = "C:\folder\file.dwg" Write-Host "Purging file: $dwgFull" -ForegroundColor Cyan diff --git a/AutoCAD/autopurge.scr b/AutoCAD/autopurge.scr new file mode 100644 index 0000000..45f04d4 --- /dev/null +++ b/AutoCAD/autopurge.scr @@ -0,0 +1,6 @@ +; start script +_OPEN "C:\folder\file.dwg" +-PURGE ALL * +AUDIT N +_SAVE +_CLOSE diff --git a/Validate-Scripts.ps1 b/Validate-Scripts.ps1 index 6a9277f..0133275 100644 --- a/Validate-Scripts.ps1 +++ b/Validate-Scripts.ps1 @@ -230,7 +230,7 @@ function Test-HardcodedPaths { $results += [PSCustomObject]@{ Check = 'HardcodedPaths' Severity = [Severity]::Warning - Message = "… (additional hardcoded paths truncated)" + Message = "... (additional hardcoded paths truncated)" } break } diff --git a/Validate-Scripts.ps1.bak b/Validate-Scripts.ps1.bak new file mode 100644 index 0000000..6a9277f --- /dev/null +++ b/Validate-Scripts.ps1.bak @@ -0,0 +1,520 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Validate-Scripts.ps1 – Compatibility & Health Checker for all repo subscripts +.DESCRIPTION + Scans every .ps1 file in this repository and checks: + 1. Syntax validity – parses each script with the PowerShell AST parser + 2. #Requires version – compares against the current PS version + 3. Required modules – detects Import-Module / #Requires -Module declarations + 4. Hardcoded paths – flags absolute Windows paths that may not be portable + 5. Dangerous cmdlets – warns about Remove-Item -Recurse / Format-* disk ops + 6. Missing error handling – notes scripts that use no try/catch + 7. Credential exposure – detects plain-text password patterns + + Outputs a colour-coded report to the console and (optionally) exports to CSV. + +.PARAMETER Path + Root path to scan. Defaults to the directory containing this script. + +.PARAMETER ExportCsv + If specified, saves the full report to this CSV path. + +.PARAMETER IncludePassing + When set, passing scripts are also listed (default: only warnings/errors). + +.PARAMETER Category + Limit scanning to a specific subdirectory name (e.g. "AD-Scripts"). + +.EXAMPLE + .\Validate-Scripts.ps1 + .\Validate-Scripts.ps1 -ExportCsv C:\Reports\ps1-validation.csv + .\Validate-Scripts.ps1 -Category File-Management-Scripts -IncludePassing +.NOTES + Author : Jason Lamb (jasrasr) + Website : https://jasr.me/ps1 + Version : 1.0 +#> +[CmdletBinding()] +param( + [string] $Path = $PSScriptRoot, + [string] $ExportCsv = '', + [switch] $IncludePassing, + [string] $Category = '' +) + +Set-StrictMode -Off + +# ── Colour palette ───────────────────────────────────────────────────────────── +$C = @{ + Title = 'Cyan' + Header = 'Yellow' + Pass = 'Green' + Warn = 'DarkYellow' + Fail = 'Red' + Info = 'DarkCyan' + Dim = 'DarkGray' + Prompt = 'Magenta' + Number = 'DarkYellow' +} + +# ── Directories to skip ──────────────────────────────────────────────────────── +$SkipDirs = @('.git', '.github', 'ImageMagick', 'Downloads') + +# ── Current PS version ──────────────────────────────────────────────────────── +$CurrentPSVersion = $PSVersionTable.PSVersion + +# ══════════════════════════════════════════════════════════════════════════════ +# Severity levels (ordered low → high) +# ══════════════════════════════════════════════════════════════════════════════ +enum Severity { Pass; Info; Warning; Error } + +# ══════════════════════════════════════════════════════════════════════════════ +# Check functions (each returns [PSCustomObject[]] of findings) +# ══════════════════════════════════════════════════════════════════════════════ + +function Test-Syntax { + <# + .SYNOPSIS Uses the PowerShell AST parser to detect syntax errors. + #> + param([string]$FilePath) + + $errors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($FilePath, [ref]$tokens, [ref]$errors) + + if ($errors -and $errors.Count -gt 0) { + foreach ($e in $errors) { + [PSCustomObject]@{ + Check = 'Syntax' + Severity = [Severity]::Error + Message = "Line $($e.Extent.StartLineNumber): $($e.Message)" + } + } + } else { + [PSCustomObject]@{ + Check = 'Syntax' + Severity = [Severity]::Pass + Message = 'No syntax errors' + } + } +} + +function Test-RequiresVersion { + <# + .SYNOPSIS Checks #Requires -Version against the running PS version. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { + return [PSCustomObject]@{ Check = 'PSVersion'; Severity = [Severity]::Info; Message = 'Empty or unreadable file' } + } + + $match = [regex]::Match($content, '#Requires\s+-Version\s+([\d\.]+)', 'IgnoreCase') + if (-not $match.Success) { + return [PSCustomObject]@{ Check = 'PSVersion'; Severity = [Severity]::Info; Message = 'No #Requires -Version declaration' } + } + + try { + $required = [Version]$match.Groups[1].Value + if ($CurrentPSVersion -lt $required) { + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Error + Message = "Requires PS $required but running PS $CurrentPSVersion" + } + } + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Pass + Message = "PS $required required – current PS $CurrentPSVersion OK" + } + } catch { + return [PSCustomObject]@{ + Check = 'PSVersion' + Severity = [Severity]::Warning + Message = "Could not parse version '$($match.Groups[1].Value)'" + } + } +} + +function Test-RequiredModules { + <# + .SYNOPSIS Detects module dependencies and checks if they are installed. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $moduleNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + # #Requires -Module or #Requires -Modules @('a','b') + $requiresMatches = [regex]::Matches($content, '#Requires\s+-Modules?\s+(.+)', 'IgnoreCase') + foreach ($m in $requiresMatches) { + $raw = $m.Groups[1].Value -replace "@\(|'|""|`"|\)" , '' -replace '\s', '' + foreach ($n in ($raw -split ',')) { if ($n) { $null = $moduleNames.Add($n) } } + } + + # Import-Module "name" or Import-Module name + $importMatches = [regex]::Matches($content, 'Import-Module\s+[''"]?([A-Za-z0-9\.\-_]+)[''"]?', 'IgnoreCase') + foreach ($m in $importMatches) { $null = $moduleNames.Add($m.Groups[1].Value) } + + # Connect-* cmdlets often imply a module + $connectMap = @{ + 'Connect-AzureAD' = 'AzureAD' + 'Connect-MsolService' = 'MSOnline' + 'Connect-ExchangeOnline' = 'ExchangeOnlineManagement' + 'Connect-MgGraph' = 'Microsoft.Graph' + 'Connect-SPOService' = 'Microsoft.Online.SharePoint.PowerShell' + 'Connect-MicrosoftTeams' = 'MicrosoftTeams' + 'New-ADUser' = 'ActiveDirectory' + 'Get-ADUser' = 'ActiveDirectory' + } + foreach ($cmd in $connectMap.Keys) { + if ($content -match [regex]::Escape($cmd)) { + $null = $moduleNames.Add($connectMap[$cmd]) + } + } + + if ($moduleNames.Count -eq 0) { + return [PSCustomObject]@{ Check = 'Modules'; Severity = [Severity]::Info; Message = 'No module dependencies detected' } + } + + $results = @() + foreach ($mod in $moduleNames | Sort-Object) { + $installed = Get-Module -Name $mod -ListAvailable -ErrorAction SilentlyContinue + if ($installed) { + $ver = ($installed | Sort-Object Version -Descending | Select-Object -First 1).Version + $results += [PSCustomObject]@{ + Check = 'Modules' + Severity = [Severity]::Pass + Message = "Module '$mod' found (v$ver)" + } + } else { + $results += [PSCustomObject]@{ + Check = 'Modules' + Severity = [Severity]::Warning + Message = "Module '$mod' NOT installed on this machine" + } + } + } + return $results +} + +function Test-HardcodedPaths { + <# + .SYNOPSIS Warns about absolute Windows paths that will break on other systems. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + # Match C:\... D:\... \\server\share but skip comment lines + $lines = $content -split "`n" + $results = @() + $lineNum = 0 + foreach ($line in $lines) { + $lineNum++ + $trimmed = $line.Trim() + if ($trimmed -match '^#') { continue } # skip full-line comments + if ($trimmed -match '[A-Z]:\\' -or $trimmed -match '\\\\[A-Za-z0-9]') { + $results += [PSCustomObject]@{ + Check = 'HardcodedPaths' + Severity = [Severity]::Warning + Message = "Line $lineNum may contain a hardcoded path: $($trimmed.Substring(0, [Math]::Min(80,$trimmed.Length)))" + } + if ($results.Count -ge 3) { + $results += [PSCustomObject]@{ + Check = 'HardcodedPaths' + Severity = [Severity]::Warning + Message = "… (additional hardcoded paths truncated)" + } + break + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'HardcodedPaths'; Severity = [Severity]::Pass; Message = 'No hardcoded Windows paths detected' } + } + return $results +} + +function Test-DangerousCmdlets { + <# + .SYNOPSIS Flags cmdlets that can be destructive if run unintentionally. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $dangerous = @( + @{ Pattern = 'Remove-Item.+-Recurse'; Label = 'Remove-Item -Recurse (recursive delete)' } + @{ Pattern = 'Format-Volume|Clear-Disk'; Label = 'Format-Volume / Clear-Disk (disk format)' } + @{ Pattern = 'Stop-Computer|Restart-Computer'; Label = 'Stop/Restart-Computer (system power)' } + @{ Pattern = 'Invoke-Expression|iex\b'; Label = 'Invoke-Expression / iex (code injection risk)' } + @{ Pattern = 'DownloadString|WebClient.*Download'; Label = 'WebClient download (execution from web)' } + @{ Pattern = '\[Net\.ServicePointManager\].*ServerCertificateValidationCallback'; Label = 'SSL validation disabled' } + ) + + $results = @() + foreach ($item in $dangerous) { + if ($content -match $item.Pattern) { + $results += [PSCustomObject]@{ + Check = 'DangerousCmdlets' + Severity = [Severity]::Warning + Message = "Contains: $($item.Label)" + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'DangerousCmdlets'; Severity = [Severity]::Pass; Message = 'No dangerous cmdlets detected' } + } + return $results +} + +function Test-ErrorHandling { + <# + .SYNOPSIS Notes scripts that have no try/catch, -ErrorAction, or trap blocks. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $hasTry = $content -match '\btry\s*\{' + $hasErrorAction = $content -match '-ErrorAction\b' + $hasTrap = $content -match '\btrap\b' + + if (-not ($hasTry -or $hasErrorAction -or $hasTrap)) { + return [PSCustomObject]@{ + Check = 'ErrorHandling' + Severity = [Severity]::Info + Message = 'No try/catch, -ErrorAction, or trap found – consider adding error handling' + } + } + return [PSCustomObject]@{ Check = 'ErrorHandling'; Severity = [Severity]::Pass; Message = 'Error handling present' } +} + +function Test-CredentialExposure { + <# + .SYNOPSIS Detects patterns that suggest plain-text credentials in source code. + #> + param([string]$FilePath) + + $content = Get-Content -Path $FilePath -Raw -ErrorAction SilentlyContinue + if (-not $content) { return } + + $patterns = @( + @{ Pattern = 'password\s*=\s*["''][^"'']+["'']'; Label = 'Plain-text password assignment' } + @{ Pattern = '\$pass(word)?\s*=\s*["''][^"'']+["'']'; Label = 'Plain-text $password variable' } + @{ Pattern = 'ConvertTo-SecureString.+-AsPlainText'; Label = 'ConvertTo-SecureString -AsPlainText (credential in code)' } + @{ Pattern = 'apikey|api_key|secret.{0,10}=\s*["'']'; Label = 'Possible API key / secret in source' } + ) + + $results = @() + foreach ($item in $patterns) { + if ($content -match $item.Pattern) { + $results += [PSCustomObject]@{ + Check = 'Credentials' + Severity = [Severity]::Warning + Message = $item.Label + } + } + } + + if ($results.Count -eq 0) { + return [PSCustomObject]@{ Check = 'Credentials'; Severity = [Severity]::Pass; Message = 'No exposed credential patterns detected' } + } + return $results +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Aggregation & reporting +# ══════════════════════════════════════════════════════════════════════════════ + +function Get-AllScripts { + param([string]$RootPath, [string]$CategoryFilter) + + $allScripts = Get-ChildItem -Path $RootPath -Filter '*.ps1' -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { + $skip = $false + foreach ($d in $SkipDirs) { + if ($_.FullName -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + [IO.Path]::DirectorySeparatorChar) -or + $_.FullName -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + '$')) { + $skip = $true; break + } + } + -not $skip + } + + if ($CategoryFilter) { + $allScripts = $allScripts | Where-Object { $_.FullName -match [regex]::Escape($CategoryFilter) } + } + + return $allScripts | Sort-Object FullName +} + +function Invoke-Validation { + param([System.IO.FileInfo[]]$Scripts) + + $report = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($script in $Scripts) { + $rel = $script.FullName.Replace($Path, '').TrimStart('/\') + $findings = @() + $findings += Test-Syntax -FilePath $script.FullName + $findings += Test-RequiresVersion -FilePath $script.FullName + $findings += Test-RequiredModules -FilePath $script.FullName + $findings += Test-HardcodedPaths -FilePath $script.FullName + $findings += Test-DangerousCmdlets -FilePath $script.FullName + $findings += Test-ErrorHandling -FilePath $script.FullName + $findings += Test-CredentialExposure -FilePath $script.FullName + + # Overall status = highest severity + $overallSev = $findings | Measure-Object -Property Severity -Maximum | Select-Object -ExpandProperty Maximum + $overallSev = [Severity]$overallSev + + $report.Add([PSCustomObject]@{ + Script = $rel + FullPath = $script.FullName + Status = $overallSev.ToString() + Findings = $findings + }) + } + return $report +} + +function Write-Report { + param([System.Collections.Generic.List[PSCustomObject]]$Report) + + $total = $Report.Count + $passing = ($Report | Where-Object Status -eq 'Pass').Count + $infos = ($Report | Where-Object Status -eq 'Info').Count + $warns = ($Report | Where-Object Status -eq 'Warning').Count + $errors = ($Report | Where-Object Status -eq 'Error').Count + + # ── Banner ──────────────────────────────────────────────────────────────── + Clear-Host + Write-Host '' + Write-Host ' ╔══════════════════════════════════════════════════════════════╗' -ForegroundColor $C.Title + Write-Host ' ║ VALIDATE-SCRIPTS – Compatibility Report ║' -ForegroundColor $C.Title + Write-Host ' ║ https://jasr.me/ps1 • jasrasr ║' -ForegroundColor $C.Dim + Write-Host ' ╚══════════════════════════════════════════════════════════════╝' -ForegroundColor $C.Title + Write-Host '' + Write-Host (" PowerShell Version : {0}" -f $CurrentPSVersion) -ForegroundColor $C.Info + Write-Host (" Scan Path : {0}" -f $Path) -ForegroundColor $C.Info + Write-Host (" Scripts Scanned : {0}" -f $total) -ForegroundColor $C.Info + Write-Host '' + + # ── Per-script results ──────────────────────────────────────────────────── + foreach ($entry in $Report) { + + $shouldShow = switch ($entry.Status) { + 'Error' { $true } + 'Warning' { $true } + 'Info' { $IncludePassing } + 'Pass' { $IncludePassing } + default { $false } + } + if (-not $shouldShow) { continue } + + $statusColour = switch ($entry.Status) { + 'Pass' { $C.Pass } + 'Info' { $C.Info } + 'Warning' { $C.Warn } + 'Error' { $C.Fail } + } + + Write-Host (" ── {0}" -f $entry.Script) -ForegroundColor $C.Header + Write-Host (" Status: [{0}]" -f $entry.Status.ToUpper()) -ForegroundColor $statusColour + + foreach ($f in $entry.Findings) { + if ($f.Severity -eq [Severity]::Pass -and -not $IncludePassing) { continue } + $fc = switch ($f.Severity) { + ([Severity]::Pass) { $C.Dim } + ([Severity]::Info) { $C.Info } + ([Severity]::Warning) { $C.Warn } + ([Severity]::Error) { $C.Fail } + } + $prefix = switch ($f.Severity) { + ([Severity]::Pass) { ' ✓' } + ([Severity]::Info) { ' ℹ' } + ([Severity]::Warning) { ' ⚠' } + ([Severity]::Error) { ' ✗' } + } + Write-Host (" {0} [{1,-16}] {2}" -f $prefix, $f.Check, $f.Message) -ForegroundColor $fc + } + Write-Host '' + } + + # ── Summary ─────────────────────────────────────────────────────────────── + Write-Host ' ══════════════════════════════════════════════════════════════' -ForegroundColor $C.Dim + Write-Host ' SUMMARY' -ForegroundColor $C.Header + Write-Host (" Total scripts : {0}" -f $total) -ForegroundColor $C.Info + Write-Host (" ✓ Pass : {0}" -f $passing) -ForegroundColor $C.Pass + Write-Host (" ℹ Info : {0}" -f $infos) -ForegroundColor $C.Info + Write-Host (" ⚠ Warnings : {0}" -f $warns) -ForegroundColor $C.Warn + Write-Host (" ✗ Errors : {0}" -f $errors) -ForegroundColor $C.Fail + + if (-not $IncludePassing -and ($passing + $infos) -gt 0) { + Write-Host '' + Write-Host (" (Run with -IncludePassing to see all {0} clean scripts)" -f ($passing + $infos)) -ForegroundColor $C.Dim + } + Write-Host '' +} + +function Export-CsvReport { + param([System.Collections.Generic.List[PSCustomObject]]$Report, [string]$CsvPath) + + $rows = foreach ($entry in $Report) { + foreach ($f in $entry.Findings) { + [PSCustomObject]@{ + Script = $entry.Script + Status = $entry.Status + Check = $f.Check + Severity = $f.Severity.ToString() + Message = $f.Message + FullPath = $entry.FullPath + } + } + } + + try { + $rows | Export-Csv -Path $CsvPath -NoTypeInformation -Force -Encoding UTF8 + Write-Host (" CSV report saved to: {0}" -f $CsvPath) -ForegroundColor $C.Pass + } catch { + Write-Host (" Could not save CSV: {0}" -f $_.Exception.Message) -ForegroundColor $C.Fail + } +} + +# ══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ══════════════════════════════════════════════════════════════════════════════ + +Write-Host '' +Write-Host ' Scanning scripts, please wait...' -ForegroundColor $C.Info + +$scripts = Get-AllScripts -RootPath $Path -CategoryFilter $Category + +if ($scripts.Count -eq 0) { + Write-Host ' No .ps1 scripts found in the specified path.' -ForegroundColor $C.Warn + exit 0 +} + +Write-Host (" Found {0} scripts. Running checks..." -f $scripts.Count) -ForegroundColor $C.Info +$report = Invoke-Validation -Scripts $scripts + +Write-Report -Report $report + +if ($ExportCsv) { + Export-CsvReport -Report $report -CsvPath $ExportCsv +} + +Write-Host ' Press Enter to return...' -ForegroundColor $C.Dim +$null = Read-Host