Managed IT • Knoxville, TN
Password File Audit
security
dateNov 5, 2024
statusRESOLVED
Incident

Financial services client undergoing SOC 2 audit. Auditor asked: "Can you prove no plaintext passwords are stored on employee workstations?" Client had password manager deployed, but couldn't prove users weren't also keeping passwords in Excel files or text documents. Needed to scan 67 endpoints.

The Risk
common password storage anti-patterns:
passwords.xlsx ··· on Desktop
logins.txt ······· in Documents
creds.docx ······· emailed to self
"New Passwords" ·· sticky note in OneNote

Even with password manager policy, users create "backup" files. These files are goldmines for attackers with endpoint access. SOC 2 requires demonstrating control over credential storage.

Solution

Deploy scan across all endpoints to find files with password-related names. Uses Windows Search index for speed, falls back to filesystem traversal. Reports findings to RMM or sends alerts.

[+] search_password_files.ps1GitHub
$ErrorActionPreference = 'Stop'
<#
██╗     ██╗███╗   ███╗███████╗██╗  ██╗ █████╗ ██╗    ██╗██╗  ██╗
██║     ██║████╗ ████║██╔════╝██║  ██║██╔══██╗██║    ██║██║ ██╔╝
██║     ██║██╔████╔██║█████╗  ███████║███████║██║ █╗ ██║█████╔╝
██║     ██║██║╚██╔╝██║██╔══╝  ██╔══██║██╔══██║██║███╗██║██╔═██╗
███████╗██║██║ ╚═╝ ██║███████╗██║  ██║██║  ██║╚███╔███╔╝██║  ██╗
╚══════╝╚═╝╚═╝     ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚══╝╚══╝ ╚═╝  ╚═╝
================================================================================
 SCRIPT   : Search Password Files                                        v1.3.1
 AUTHOR   : Limehawk.io
 DATE     : January 2026
 USAGE    : .\search_password_files.ps1
================================================================================
 FILE     : search_password_files.ps1
 DESCRIPTION : Searches user profiles for password and credential files
--------------------------------------------------------------------------------
 README
--------------------------------------------------------------------------------
 PURPOSE

   Scans all user profiles on a Windows machine for files that may contain
   passwords or credentials. Useful for security audits to identify exposed
   sensitive data in common user directories.

 DATA SOURCES & PRIORITY

   - Windows Search Index: Fast search if directories are indexed
   - Filesystem: Direct recursive search as fallback

 REQUIRED INPUTS

   All inputs are hardcoded in the script body:
     - $searchPatterns: Array of filename patterns to match
     - $subDirectories: User subdirectories to search
     - $useIndex: Whether to attempt Windows Search Index first
     - $maxDepth: Maximum folder depth for filesystem search
     - $googleChatWebhookUrl: Google Chat webhook (SuperOps replaces $GoogleChatWebhook)

 SETTINGS

   Configuration details and default values:
     - Search patterns include: *password*, *credential*, *creds*, *logins*, etc.
     - Subdirectories: Desktop, Documents, Downloads, Pictures, cloud folders
     - Index search enabled by default
     - Max depth: 10 levels
     - Webhook alert sent only when files are found (not on zero results)

 BEHAVIOR

   The script performs the following actions in order:
   1. Enumerates all user profiles in C:\Users
   2. Builds list of target directories across all profiles
   3. Attempts Windows Search Index query if enabled
   4. Falls back to filesystem search if index unavailable or incomplete
   5. Reports all matching files with path, size, and modified date
   6. Sends Google Chat alert if files found and webhook URL configured

 PREREQUISITES

   - PowerShell 5.1 or later
   - Administrator privileges (to access other user profiles)

 SECURITY NOTES

   - No secrets exposed in output
   - Read-only operation - does not modify or delete files
   - File contents are NOT read, only metadata reported

 ENDPOINTS

   - Google Chat Webhook: Receives alert when password files found

 EXIT CODES

   0 = Success (search completed, results may be empty)
   1 = Failure (error occurred)

 EXAMPLE RUN

   [INFO] INPUT VALIDATION
   ==============================================================
   All required inputs are valid

   [INFO] ENUMERATING USER PROFILES
   ==============================================================
   Found 3 user profiles
   Profile : Administrator
   Profile : JohnDoe
   Profile : Guest

   [RUN] SEARCHING FILESYSTEM
   ==============================================================
   Searching 18 directories across 3 profiles
   Search patterns: *password*, *credential*, *secret*, *creds*, *logins*

   [INFO] RESULTS
   ==============================================================
   Found 2 potential password files

   Path     : C:\Users\JohnDoe\Documents\passwords.xlsx
   Size     : 15.2 KB
   Modified : 2025-11-15

   Path     : C:\Users\JohnDoe\Desktop\old_credentials.txt
   Size     : 1.3 KB
   Modified : 2024-08-22

   [OK] FINAL STATUS
   ==============================================================
   Result : SUCCESS
   Files Found : 2

   [OK] SCRIPT COMPLETED
   ==============================================================

--------------------------------------------------------------------------------
 CHANGELOG
--------------------------------------------------------------------------------
 2026-01-19 v1.3.1 Updated to two-line ASCII console output style
 2026-01-13 v1.3.0 Add critical findings section for high-priority items (1Password kits, etc)
 2026-01-13 v1.2.0 Remove *secret* and *accounts* patterns to reduce false positives
 2026-01-13 v1.1.9 Wrap entire webhook message in code block
 2026-01-13 v1.1.8 Wrap file list in code block for monospace formatting
 2026-01-13 v1.1.7 Use bold formatting and full paths in webhook message
 2026-01-13 v1.1.6 Redesign webhook message with terminal design system, remove debug output
 2026-01-13 v1.1.5 Replace emojis with ASCII for Google Chat encoding compatibility
 2026-01-13 v1.1.4 Fix newlines in Google Chat message (use backtick-n not backslash-n)
 2026-01-13 v1.1.3 Fix placeholder syntax - use double quotes for SuperOps replacement
 2026-01-13 v1.1.2 Add debug output for webhook troubleshooting
 2026-01-13 v1.1.1 Skip webhook section entirely if URL blank or placeholder not replaced
 2026-01-13 v1.1.0 Add Google Chat webhook alert when password files found
 2026-01-13 v1.0.1 Fix DBNull handling for Size/Modified from Windows Search Index
 2026-01-12 v1.0.0 Initial release
================================================================================
#>
Set-StrictMode -Version Latest

# ============================================================================
# STATE VARIABLES
# ============================================================================
$errorOccurred = $false
$errorText = ""
$foundFiles = @()

# ============================================================================
# HARDCODED INPUTS
# ============================================================================
$searchPatterns = @(
    '*password*',
    '*credential*',
    '*creds*',
    '*logins*',
    '*login.csv',
    '*login.xlsx',
    '*login.txt'
)

# Critical patterns - high priority security concerns
# These files should NEVER exist on endpoints
$criticalPatterns = @(
    '*1Password Emergency Kit*',
    '*Emergency Kit*.pdf',
    '*BitWarden*backup*',
    '*KeePass*.kdbx',
    '*LastPass*export*',
    '*master password*',
    '*recovery key*',
    '*seed phrase*',
    '*private key*.pem',
    '*id_rsa'
)

$subDirectories = @(
    'Desktop',
    'Documents',
    'Downloads',
    'Pictures',
    'OneDrive',
    'OneDrive - *',
    'Dropbox',
    'Google Drive',
    'Box'
)

$useIndex = $true
$maxDepth = 10

$excludedProfiles = @(
    'Public',
    'Default',
    'Default User',
    'All Users'
)

# Google Chat webhook URL - SuperOps replaces $GoogleChatWebhook at runtime
# Leave as placeholder to disable webhook alerts
$googleChatWebhookUrl = "$GoogleChatWebhook"

# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

function Search-WithIndex {
    param (
        [string[]]$Patterns,
        [string[]]$Paths
    )

    $results = @()

    try {
        $connection = New-Object -ComObject ADODB.Connection
        $recordset = New-Object -ComObject ADODB.Recordset
        $connection.Open("Provider=Search.CollatorDSO;Extended Properties='Application=Windows';")

        foreach ($pattern in $Patterns) {
            $searchPattern = $pattern.Replace('*', '%')

            $pathConditions = ($Paths | ForEach-Object {
                "SCOPE='file:$_'"
            }) -join ' OR '

            $query = @"
SELECT System.ItemPathDisplay, System.Size, System.DateModified
FROM SystemIndex
WHERE System.FileName LIKE '$searchPattern'
AND ($pathConditions)
"@

            $recordset.Open($query, $connection)

            while (-not $recordset.EOF) {
                $sizeValue = $recordset.Fields.Item("System.Size").Value
                $modifiedValue = $recordset.Fields.Item("System.DateModified").Value

                $results += [PSCustomObject]@{
                    Path = $recordset.Fields.Item("System.ItemPathDisplay").Value
                    Size = if ($null -eq $sizeValue -or $sizeValue -is [DBNull]) { 0 } else { [long]$sizeValue }
                    Modified = if ($null -eq $modifiedValue -or $modifiedValue -is [DBNull]) { $null } else { $modifiedValue }
                }
                $recordset.MoveNext()
            }
            $recordset.Close()
        }

        $connection.Close()
    }
    catch {
        return $null
    }

    return $results
}

function Search-Filesystem {
    param (
        [string[]]$Patterns,
        [string[]]$Paths,
        [int]$Depth
    )

    $results = @()

    foreach ($path in $Paths) {
        if (-not (Test-Path $path -PathType Container)) {
            continue
        }

        foreach ($pattern in $Patterns) {
            try {
                $files = Get-ChildItem -Path $path -Filter $pattern -Recurse -File -Depth $Depth -ErrorAction SilentlyContinue

                foreach ($file in $files) {
                    $results += [PSCustomObject]@{
                        Path = $file.FullName
                        Size = $file.Length
                        Modified = $file.LastWriteTime
                    }
                }
            }
            catch {
                # Continue on access denied errors
            }
        }
    }

    return $results
}

function Format-FileSize {
    param ([long]$Bytes)

    if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) }
    return "$Bytes B"
}

function Send-GoogleChatAlert {
    param (
        [string]$WebhookUrl,
        [string]$Hostname,
        [int]$FileCount,
        [PSCustomObject[]]$Files,
        [PSCustomObject[]]$CriticalFiles
    )

    # Check if placeholder was replaced (use concatenation to avoid SuperOps replacing this check)
    if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or $WebhookUrl -eq '$' + 'GoogleChatWebhook') {
        return $false
    }

    $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm"
    $criticalCount = if ($CriticalFiles) { $CriticalFiles.Count } else { 0 }

    # Build critical files section
    $criticalSection = ""
    if ($criticalCount -gt 0) {
        $criticalList = ($CriticalFiles | ForEach-Object {
            "  [!] $($_.Path)"
        }) -join "`n"
        $criticalSection = @"

[!!!] CRITICAL FINDINGS [$criticalCount]
$criticalList

------------------------------
"@
    }

    # Build regular files list (excluding critical)
    $regularFiles = $Files | Where-Object { $file = $_; -not ($CriticalFiles | Where-Object { $_.Path -eq $file.Path }) }
    $fileList = ($regularFiles | Select-Object -First 10 | ForEach-Object {
        "  - $($_.Path)"
    }) -join "`n"

    if ($regularFiles.Count -gt 10) {
        $fileList += "`n  ... and $($regularFiles.Count - 10) more"
    }

    $messageText = @"
``````
> PASSWORD FILES FOUND

hostname .......... $Hostname
timestamp ......... $timestamp
files found ....... $FileCount
critical .......... $criticalCount
$criticalSection
$fileList
``````
"@

    $message = @{
        text = $messageText
    } | ConvertTo-Json -Compress

    try {
        $null = Invoke-RestMethod -Uri $WebhookUrl -Method Post -ContentType 'application/json' -Body $message
        return $true
    }
    catch {
        Write-Host "Warning : Failed to send webhook alert"
        Write-Host "Error   : $($_.Exception.Message)"
        return $false
    }
}

# ============================================================================
# INPUT VALIDATION
# ============================================================================
Write-Host ""
Write-Host "[INFO] INPUT VALIDATION"
Write-Host "=============================================================="

if ($searchPatterns.Count -eq 0) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- At least one search pattern is required"
}

if ($subDirectories.Count -eq 0) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- At least one subdirectory is required"
}

if ($maxDepth -lt 1) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- Max depth must be at least 1"
}

if ($errorOccurred) {
    Write-Host ""
    Write-Host "[ERROR] VALIDATION FAILED"
    Write-Host "=============================================================="
    Write-Host $errorText
    Write-Host ""
    exit 1
}

Write-Host "All required inputs are valid"

# ============================================================================
# ENUMERATE USER PROFILES
# ============================================================================
Write-Host ""
Write-Host "[INFO] ENUMERATING USER PROFILES"
Write-Host "=============================================================="

$usersPath = "C:\Users"
$userProfiles = @()

try {
    $profileFolders = Get-ChildItem -Path $usersPath -Directory -ErrorAction Stop

    foreach ($folder in $profileFolders) {
        if ($excludedProfiles -notcontains $folder.Name) {
            $userProfiles += $folder.FullName
            Write-Host "Profile : $($folder.Name)"
        }
    }
}
catch {
    Write-Host ""
    Write-Host "[ERROR] ENUMERATION FAILED"
    Write-Host "=============================================================="
    Write-Host "Failed to enumerate user profiles"
    Write-Host "Error : $($_.Exception.Message)"
    Write-Host ""
    Write-Host "Ensure script is running with administrator privileges"
    Write-Host ""
    exit 1
}

if ($userProfiles.Count -eq 0) {
    Write-Host "No user profiles found"
    Write-Host ""
    Write-Host "[OK] FINAL STATUS"
    Write-Host "=============================================================="
    Write-Host "Result : SUCCESS"
    Write-Host "Files Found : 0"
    Write-Host ""
    Write-Host "[OK] SCRIPT COMPLETED"
    Write-Host "=============================================================="
    Write-Host ""
    exit 0
}

Write-Host ""
Write-Host "Found $($userProfiles.Count) user profile(s)"

# ============================================================================
# BUILD TARGET PATHS
# ============================================================================
$targetPaths = @()

foreach ($profile in $userProfiles) {
    foreach ($subDir in $subDirectories) {
        if ($subDir -like '*`**') {
            $wildcardPath = Join-Path $profile $subDir
            $matchedPaths = Get-ChildItem -Path (Split-Path $wildcardPath -Parent) -Directory -Filter (Split-Path $wildcardPath -Leaf) -ErrorAction SilentlyContinue
            foreach ($matched in $matchedPaths) {
                $targetPaths += $matched.FullName
            }
        }
        else {
            $fullPath = Join-Path $profile $subDir
            if (Test-Path $fullPath -PathType Container) {
                $targetPaths += $fullPath
            }
        }
    }
}

# ============================================================================
# SEARCH
# ============================================================================
$indexUsed = $false

if ($useIndex) {
    Write-Host ""
    Write-Host "[RUN] SEARCHING INDEX"
    Write-Host "=============================================================="
    Write-Host "Attempting Windows Search Index query"

    $indexResults = Search-WithIndex -Patterns $searchPatterns -Paths $targetPaths

    if ($null -ne $indexResults) {
        $foundFiles = $indexResults
        $indexUsed = $true
        Write-Host "Index search completed"
        Write-Host "Results from index : $($foundFiles.Count)"
    }
    else {
        Write-Host "Index unavailable or query failed, falling back to filesystem"
    }
}

if (-not $indexUsed) {
    Write-Host ""
    Write-Host "[RUN] SEARCHING FILESYSTEM"
    Write-Host "=============================================================="
    Write-Host "Searching $($targetPaths.Count) directories across $($userProfiles.Count) profile(s)"
    Write-Host "Search patterns : $($searchPatterns -join ', ')"
    Write-Host "Max depth : $maxDepth"

    $foundFiles = Search-Filesystem -Patterns $searchPatterns -Paths $targetPaths -Depth $maxDepth
}

# ============================================================================
# RESULTS
# ============================================================================
Write-Host ""
Write-Host "[INFO] RESULTS"
Write-Host "=============================================================="

$uniqueFiles = $foundFiles | Sort-Object -Property Path -Unique

# Separate critical from regular findings
$criticalFiles = @()
$regularFiles = @()

foreach ($file in $uniqueFiles) {
    $isCritical = $false
    foreach ($pattern in $criticalPatterns) {
        if ($file.Path -like $pattern) {
            $isCritical = $true
            break
        }
    }
    if ($isCritical) {
        $criticalFiles += $file
    } else {
        $regularFiles += $file
    }
}

if ($uniqueFiles.Count -eq 0) {
    Write-Host "No password or credential files found"
}
else {
    # Display critical findings first
    if ($criticalFiles.Count -gt 0) {
        Write-Host ""
        Write-Host "[!] CRITICAL FINDINGS : $($criticalFiles.Count)"
        Write-Host ""
        foreach ($file in $criticalFiles) {
            Write-Host "Path     : $($file.Path)"
            Write-Host "Size     : $(Format-FileSize $file.Size)"
            $modifiedDisplay = if ($null -eq $file.Modified) { 'Unknown' } else { ([datetime]$file.Modified).ToString('yyyy-MM-dd') }
            Write-Host "Modified : $modifiedDisplay"
            Write-Host ""
        }
    }

    # Display regular findings
    if ($regularFiles.Count -gt 0) {
        Write-Host "Other findings : $($regularFiles.Count)"
        Write-Host ""
        foreach ($file in $regularFiles) {
            Write-Host "Path     : $($file.Path)"
            Write-Host "Size     : $(Format-FileSize $file.Size)"
            $modifiedDisplay = if ($null -eq $file.Modified) { 'Unknown' } else { ([datetime]$file.Modified).ToString('yyyy-MM-dd') }
            Write-Host "Modified : $modifiedDisplay"
            Write-Host ""
        }
    }

    # Send webhook alert (skip if blank or placeholder not replaced)
    $webhookConfigured = -not [string]::IsNullOrWhiteSpace($googleChatWebhookUrl) -and $googleChatWebhookUrl -ne ('$' + 'GoogleChatWebhook')
    if ($webhookConfigured) {
        Write-Host "[INFO] WEBHOOK ALERT"
        Write-Host "=============================================================="
        $hostname = $env:COMPUTERNAME
        $sent = Send-GoogleChatAlert -WebhookUrl $googleChatWebhookUrl -Hostname $hostname -FileCount $uniqueFiles.Count -Files $uniqueFiles -CriticalFiles $criticalFiles
        if ($sent) {
            Write-Host "Alert sent to Google Chat"
        }
        Write-Host ""
    }
}

# ============================================================================
# FINAL STATUS
# ============================================================================
Write-Host ""
Write-Host "[OK] FINAL STATUS"
Write-Host "=============================================================="
Write-Host "Result : SUCCESS"
Write-Host "Files Found : $($uniqueFiles.Count)"
Write-Host "Critical : $($criticalFiles.Count)"
Write-Host "Search Method : $(if ($indexUsed) { 'Windows Search Index' } else { 'Filesystem' })"

Write-Host ""
Write-Host "[OK] SCRIPT COMPLETED"
Write-Host "=============================================================="
Write-Host ""

exit 0
What It Finds
search patterns:
*password* ····· files with password in name
*credential* ··· credential lists
*login* ········· login sheets
*secret* ········ secret keys, tokens

Scans all user profile directories. Excludes system folders and application caches. Reports full path, file size, and last modified date for each finding.

Outcome
endpoints scanned67
files found23 suspicious files
confirmed issues8 with actual creds

Scan completed in 12 minutes across entire fleet. Found 23 files, 8 contained actual credentials (banking logins, vendor portals, shared service accounts). Worked with users to migrate to password manager and securely delete files. Provided auditor with remediation report.

takeaways:
password managers don't prevent file-based storage
periodic scans catch policy violations
findings enable user education
documentation satisfies compliance auditors
Security Note

This script identifies potential credential exposure but does not read file contents. Review findings manually to confirm actual credential storage. Handle results as sensitive data - findings themselves reveal security gaps. Securely delete password files after migrating to proper credential management.

Get Help

Preparing for compliance audit? We perform security assessments that identify credential exposure and policy violations.

Contact Us