Private/AD/Checks/Invoke-ADLogonScriptChecks.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Invoke-ADLogonScriptChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'ADLogonScriptChecks' $findings = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($check in $checkDefs.checks) { $funcName = "Test-Recon$($check.id -replace '-', '')" if (Get-Command $funcName -ErrorAction SilentlyContinue) { try { $finding = & $funcName -AuditData $AuditData -CheckDefinition $check if ($finding) { $findings.Add($finding) } } catch { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' ` -CurrentValue "Check failed: $_")) } } else { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' ` -CurrentValue 'Check not yet implemented')) } } return @($findings) } # -- ADSCRIPT-001: NETLOGON Share Permissions --------------------------------- function Test-ReconADSCRIPT001 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $perms = $scripts.NetlogonPermissions if (-not $perms -or -not $perms.AccessRules) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'NETLOGON permission data not available' } # Identities considered administrative / expected to have write access $adminPatterns = @( '\\Domain Admins$', '\\Enterprise Admins$', '\\Administrators$', '\\SYSTEM$', '^BUILTIN\\Administrators$', '^NT AUTHORITY\\SYSTEM$', '\\CREATOR OWNER$' ) # Rights that indicate write access $writeRights = @('Write', 'Modify', 'FullControl', 'ChangePermissions', 'TakeOwnership') $nonAdminWriteEntries = [System.Collections.Generic.List[hashtable]]::new() foreach ($ace in $perms.AccessRules) { if ($ace.AccessType -ne 'Allow') { continue } # Check if rights include any write-level permission $hasWrite = $false foreach ($wr in $writeRights) { if ($ace.Rights -match $wr) { $hasWrite = $true break } } if (-not $hasWrite) { continue } # Check if identity is administrative $isAdmin = $false foreach ($pattern in $adminPatterns) { if ($ace.Identity -match $pattern) { $isAdmin = $true break } } if (-not $isAdmin) { $nonAdminWriteEntries.Add(@{ Identity = $ace.Identity Rights = $ace.Rights }) } } if ($nonAdminWriteEntries.Count -gt 0) { $identities = @($nonAdminWriteEntries | ForEach-Object { $_.Identity }) | Sort-Object -Unique $currentValue = "NETLOGON has write access for non-admin identities: $($identities -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ NonAdminWriteAccess = @($nonAdminWriteEntries) Owner = $perms.Owner TotalACEs = $perms.AccessRules.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "NETLOGON share permissions are properly restricted ($($perms.AccessRules.Count) ACE(s))" ` -Details @{ Owner = $perms.Owner TotalACEs = $perms.AccessRules.Count } } # -- ADSCRIPT-002: SYSVOL Share Permissions ----------------------------------- function Test-ReconADSCRIPT002 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $perms = $scripts.SysvolPermissions if (-not $perms -or -not $perms.AccessRules) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'SYSVOL permission data not available' } $adminPatterns = @( '\\Domain Admins$', '\\Enterprise Admins$', '\\Administrators$', '\\SYSTEM$', '^BUILTIN\\Administrators$', '^NT AUTHORITY\\SYSTEM$', '\\CREATOR OWNER$' ) $writeRights = @('Write', 'Modify', 'FullControl', 'ChangePermissions', 'TakeOwnership') $nonAdminWriteEntries = [System.Collections.Generic.List[hashtable]]::new() foreach ($ace in $perms.AccessRules) { if ($ace.AccessType -ne 'Allow') { continue } $hasWrite = $false foreach ($wr in $writeRights) { if ($ace.Rights -match $wr) { $hasWrite = $true break } } if (-not $hasWrite) { continue } $isAdmin = $false foreach ($pattern in $adminPatterns) { if ($ace.Identity -match $pattern) { $isAdmin = $true break } } if (-not $isAdmin) { $nonAdminWriteEntries.Add(@{ Identity = $ace.Identity Rights = $ace.Rights }) } } if ($nonAdminWriteEntries.Count -gt 0) { $identities = @($nonAdminWriteEntries | ForEach-Object { $_.Identity }) | Sort-Object -Unique $currentValue = "SYSVOL has write access for non-admin identities: $($identities -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ NonAdminWriteAccess = @($nonAdminWriteEntries) Owner = $perms.Owner TotalACEs = $perms.AccessRules.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "SYSVOL share permissions are properly restricted ($($perms.AccessRules.Count) ACE(s))" ` -Details @{ Owner = $perms.Owner TotalACEs = $perms.AccessRules.Count } } # -- ADSCRIPT-003: Logon Script Inventory ------------------------------------ function Test-ReconADSCRIPT003 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $netlogonFiles = @($scripts.NetlogonFiles) $userScripts = @($scripts.UserScripts) # Count by extension $extensionCounts = @{} foreach ($file in $netlogonFiles) { $ext = if ($file.Extension) { $file.Extension } else { '(none)' } if (-not $extensionCounts.ContainsKey($ext)) { $extensionCounts[$ext] = 0 } $extensionCounts[$ext]++ } $totalUsers = 0 foreach ($us in $userScripts) { $totalUsers += [int]$us.UserCount } $extSummary = @($extensionCounts.GetEnumerator() | Sort-Object Value -Descending | ForEach-Object { "$($_.Value) $($_.Key)" }) $currentValue = "$($netlogonFiles.Count) file(s) in NETLOGON" if ($extSummary.Count -gt 0) { $currentValue += " ($($extSummary -join ', '))" } if ($userScripts.Count -gt 0) { $currentValue += ". $($userScripts.Count) unique script(s) assigned to $totalUsers user(s)" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue $currentValue ` -Details @{ TotalNetlogonFiles = $netlogonFiles.Count ExtensionCounts = $extensionCounts UniqueUserScripts = $userScripts.Count TotalUsersAssigned = $totalUsers UserScripts = @($userScripts | Select-Object -First 20) } } # -- ADSCRIPT-004: Hardcoded Credentials in Scripts -------------------------- function Test-ReconADSCRIPT004 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } $scriptsWithCreds = @($analysis | Where-Object { $_.HardcodedCredentials -eq $true }) if ($scriptsWithCreds.Count -gt 0) { $totalMatches = 0 $credSummary = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $scriptsWithCreds) { $matchCount = @($s.CredentialMatches).Count $totalMatches += $matchCount $credSummary.Add(@{ ScriptPath = $s.RelativePath MatchCount = $matchCount Patterns = @($s.CredentialMatches | ForEach-Object { $_.Pattern } | Sort-Object -Unique) }) } $currentValue = "$($scriptsWithCreds.Count) script(s) contain hardcoded credentials ($totalMatches finding(s))" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ AffectedScripts = @($credSummary) TotalScripts = $analysis.Count TotalFindings = $totalMatches } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No hardcoded credentials found in $($analysis.Count) analyzed script(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-005: LOLBins Usage in Scripts ----------------------------------- function Test-ReconADSCRIPT005 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } $scriptsWithLOLBins = @($analysis | Where-Object { $_.LOLBinsUsage -eq $true }) if ($scriptsWithLOLBins.Count -gt 0) { $allLOLBins = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $lolSummary = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $scriptsWithLOLBins) { foreach ($lb in @($s.LOLBinsFound)) { [void]$allLOLBins.Add($lb) } $lolSummary.Add(@{ ScriptPath = $s.RelativePath LOLBins = @($s.LOLBinsFound) }) } $currentValue = "$($scriptsWithLOLBins.Count) script(s) reference LOLBins: $($allLOLBins -join ', ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ AffectedScripts = @($lolSummary) UniqueLOLBins = @($allLOLBins) TotalScripts = $analysis.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No LOLBin references found in $($analysis.Count) analyzed script(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-006: Plaintext Passwords in Scripts ---------------------------- function Test-ReconADSCRIPT006 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } $scriptsWithPasswords = @($analysis | Where-Object { $_.PlaintextPasswords -eq $true }) if ($scriptsWithPasswords.Count -gt 0) { $totalMatches = 0 $pwdSummary = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $scriptsWithPasswords) { $matchCount = @($s.PasswordMatches).Count $totalMatches += $matchCount $pwdSummary.Add(@{ ScriptPath = $s.RelativePath MatchCount = $matchCount Patterns = @($s.PasswordMatches | ForEach-Object { $_.Pattern } | Sort-Object -Unique) }) } $currentValue = "$($scriptsWithPasswords.Count) script(s) contain plaintext passwords ($totalMatches finding(s))" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ AffectedScripts = @($pwdSummary) TotalScripts = $analysis.Count TotalFindings = $totalMatches } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No plaintext passwords found in $($analysis.Count) analyzed script(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-007: World-Writable Script Permissions ------------------------- function Test-ReconADSCRIPT007 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } $worldWritable = @($analysis | Where-Object { $_.WorldWritable -eq $true }) if ($worldWritable.Count -gt 0) { $scriptNames = @($worldWritable | ForEach-Object { $_.RelativePath }) $currentValue = "$($worldWritable.Count) script(s) have world-writable permissions" if ($scriptNames.Count -le 10) { $currentValue += ": $($scriptNames -join '; ')" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ WorldWritableScripts = $scriptNames TotalScripts = $analysis.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No world-writable scripts found among $($analysis.Count) analyzed file(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-008: External Resource References ------------------------------ function Test-ReconADSCRIPT008 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } $scriptsWithExternal = @($analysis | Where-Object { $_.ExternalResources -eq $true }) if ($scriptsWithExternal.Count -gt 0) { $totalResources = 0 $extSummary = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $scriptsWithExternal) { $resources = @($s.ExternalResourceList) $totalResources += $resources.Count $extSummary.Add(@{ ScriptPath = $s.RelativePath Resources = $resources }) } $currentValue = "$($scriptsWithExternal.Count) script(s) reference $totalResources external resource(s)" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ AffectedScripts = @($extSummary) TotalResources = $totalResources TotalScripts = $analysis.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No external resource references found in $($analysis.Count) analyzed script(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-009: Malformed Scripts ----------------------------------------- function Test-ReconADSCRIPT009 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $netlogonFiles = @($scripts.NetlogonFiles) if ($netlogonFiles.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No NETLOGON files available for analysis' } # Standard script extensions $knownExtensions = @('.bat', '.cmd', '.vbs', '.ps1', '.js', '.wsf', '.kix') # Identify unusual extensions in NETLOGON $unusualFiles = [System.Collections.Generic.List[hashtable]]::new() foreach ($file in $netlogonFiles) { $ext = if ($file.Extension) { $file.Extension.ToLower() } else { '' } # Flag files with unusual extensions (not standard scripts but not common data files either) $isKnownScript = $ext -in $knownExtensions $isKnownData = $ext -in @('.txt', '.ini', '.cfg', '.xml', '.csv', '.log', '.dat', '.ico', '.bmp', '.jpg', '.png', '.gif') if (-not $isKnownScript -and -not $isKnownData -and -not [string]::IsNullOrEmpty($ext)) { $unusualFiles.Add(@{ RelativePath = $file.RelativePath Extension = $ext Size = $file.Size }) } } # Check for scripts that failed analysis (present in NetlogonFiles but errored in ScriptAnalysis) $analysisErrors = @{} if ($scripts.ContainsKey('Errors')) { foreach ($key in $scripts.Errors.Keys) { if ($key -match '^ScriptAnalysis:') { $analysisErrors[$key -replace '^ScriptAnalysis:', ''] = $scripts.Errors[$key] } } } # Also check for zero-byte script files $emptyScripts = @($netlogonFiles | Where-Object { $_.Extension -in $knownExtensions -and $_.Size -eq 0 }) $issues = [System.Collections.Generic.List[string]]::new() if ($unusualFiles.Count -gt 0) { $issues.Add("$($unusualFiles.Count) file(s) with unusual extensions") } if ($analysisErrors.Count -gt 0) { $issues.Add("$($analysisErrors.Count) script(s) failed to parse") } if ($emptyScripts.Count -gt 0) { $issues.Add("$($emptyScripts.Count) empty script file(s)") } if ($issues.Count -gt 0) { $currentValue = "Script quality issues: $($issues -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue $currentValue ` -Details @{ UnusualFiles = @($unusualFiles) ParseErrors = $analysisErrors EmptyScripts = @($emptyScripts | ForEach-Object { $_.RelativePath }) TotalFiles = $netlogonFiles.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($netlogonFiles.Count) NETLOGON file(s) have expected extensions and structure" ` -Details @{ TotalFiles = $netlogonFiles.Count } } # -- ADSCRIPT-010: UNC Paths to Non-DC Locations ---------------------------- function Test-ReconADSCRIPT010 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) if ($analysis.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for analysis' } # Collect scripts that have UNC paths flagged as external (non-DC) # The collector already identifies external UNC paths via ExternalResourceList, # but we specifically look at UNC paths (not HTTP URLs) here $scriptsWithNonDCUNC = [System.Collections.Generic.List[hashtable]]::new() foreach ($s in $analysis) { $uncPaths = @($s.UNCPaths) if ($uncPaths.Count -eq 0) { continue } # External resources include both UNC and URL; filter to UNC only $externalUNCPaths = @() if ($s.ExternalResources -eq $true -and $s.ExternalResourceList) { $externalUNCPaths = @($s.ExternalResourceList | Where-Object { $_ -match '^\\\\\S' }) } if ($externalUNCPaths.Count -gt 0) { $scriptsWithNonDCUNC.Add(@{ ScriptPath = $s.RelativePath UNCPaths = $externalUNCPaths }) } } if ($scriptsWithNonDCUNC.Count -gt 0) { $totalPaths = 0 foreach ($entry in $scriptsWithNonDCUNC) { $totalPaths += $entry.UNCPaths.Count } $currentValue = "$($scriptsWithNonDCUNC.Count) script(s) contain $totalPaths UNC path(s) to non-DC servers" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue $currentValue ` -Details @{ AffectedScripts = @($scriptsWithNonDCUNC) TotalUNCPaths = $totalPaths TotalScripts = $analysis.Count } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "No UNC paths to non-DC servers found in $($analysis.Count) analyzed script(s)" ` -Details @{ TotalScripts = $analysis.Count } } # -- ADSCRIPT-011: Script Content Analysis Summary --------------------------- function Test-ReconADSCRIPT011 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $scripts = $AuditData.LogonScripts if (-not $scripts) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Logon script data not available' } $analysis = @($scripts.ScriptAnalysis) $netlogonFiles = @($scripts.NetlogonFiles) $userScripts = @($scripts.UserScripts) if ($analysis.Count -eq 0 -and $netlogonFiles.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No scripts available for content analysis' } # Compile summary statistics $credCount = @($analysis | Where-Object { $_.HardcodedCredentials -eq $true }).Count $lolCount = @($analysis | Where-Object { $_.LOLBinsUsage -eq $true }).Count $wwCount = @($analysis | Where-Object { $_.WorldWritable -eq $true }).Count $extCount = @($analysis | Where-Object { $_.ExternalResources -eq $true }).Count $totalUsers = 0 foreach ($us in $userScripts) { $totalUsers += [int]$us.UserCount } $summaryParts = [System.Collections.Generic.List[string]]::new() $summaryParts.Add("$($analysis.Count) script(s) analyzed") $summaryParts.Add("$($netlogonFiles.Count) total NETLOGON file(s)") $summaryParts.Add("$($userScripts.Count) unique script assignment(s) across $totalUsers user(s)") $findingParts = [System.Collections.Generic.List[string]]::new() if ($credCount -gt 0) { $findingParts.Add("$credCount with credentials") } if ($lolCount -gt 0) { $findingParts.Add("$lolCount with LOLBins") } if ($wwCount -gt 0) { $findingParts.Add("$wwCount world-writable") } if ($extCount -gt 0) { $findingParts.Add("$extCount with external references") } $currentValue = $summaryParts -join '. ' if ($findingParts.Count -gt 0) { $currentValue += ". Findings: $($findingParts -join ', ')" } else { $currentValue += '. No security findings detected' } $errorCount = 0 if ($scripts.ContainsKey('Errors')) { $errorCount = $scripts.Errors.Count } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue $currentValue ` -Details @{ ScriptsAnalyzed = $analysis.Count TotalNetlogonFiles = $netlogonFiles.Count UniqueUserScripts = $userScripts.Count TotalUsersAssigned = $totalUsers ScriptsWithCredentials = $credCount ScriptsWithLOLBins = $lolCount WorldWritableScripts = $wwCount ExternalReferences = $extCount CollectionErrors = $errorCount } } |