Skip to content

Instantly share code, notes, and snippets.

@minanagehsalalma
Last active March 9, 2026 14:26
Show Gist options
  • Select an option

  • Save minanagehsalalma/351e506118b26ccc886292ab22ab63cf to your computer and use it in GitHub Desktop.

Select an option

Save minanagehsalalma/351e506118b26ccc886292ab22ab63cf to your computer and use it in GitHub Desktop.
Export Chrome extensions inventory (all profiles/channels and even source code) to CSV + JSON — PowerShell
<#
.SYNOPSIS
Exports a full inventory of installed Chrome extensions across all profiles to CSV and JSON,
and physically copies the extension source files and user data.
.DESCRIPTION
Scans every Chrome profile on the current machine and collects extension metadata from
manifest.json and the profile's Preferences file: name, version, enabled state, install
time, permissions, manifest version (MV2/MV3), and more.
Supports multiple Chrome channels: Stable, Beta, Dev, and Canary.
Correctly identifies Unpacked extensions loaded from custom disk locations.
Backs up the actual source code and stored User Data (Local Settings, Sync Settings,
and IndexedDB) for each selected extension to the output directory.
.PARAMETER OutputDir
Directory where the CSV and JSON files are written. Defaults to the current directory.
.PARAMETER ProfileFilter
Limit the scan to a specific profile folder name, e.g. "Default" or "Profile 3".
Omit to scan all profiles.
.PARAMETER IncludePermissions
When specified, captures the extension's declared permissions as a semicolon-delimited string.
.PARAMETER Interactive
Opens a terminal-based menu allowing you to selectively choose which extensions to export
and let you specify the save destination.
.PARAMETER Channel
One or more Chrome channels to scan: Stable, Beta, Dev, Canary. Defaults to Stable only.
.EXAMPLE
.\Export-ChromeExtensions.ps1 -Interactive
.EXAMPLE
.\Export-ChromeExtensions.ps1 -OutputDir "C:\Reports" -IncludePermissions -Channel Stable, Beta
#>
[CmdletBinding()]
param(
[string] $OutputDir = (Get-Location).Path,
[string] $ProfileFilter = "",
[switch] $IncludePermissions,
[switch] $Interactive,
[ValidateSet("Stable","Beta","Dev","Canary")]
[string[]] $Channel = @("Stable")
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
#region -- Helpers ------------------------------------------------------------
$channelPaths = @{
Stable = "Google\Chrome\User Data"
Beta = "Google\Chrome Beta\User Data"
Dev = "Google\Chrome Dev\User Data"
Canary = "Google\Chrome SxS\User Data"
}
$locationLabels = @{
1 = "Internal"
2 = "External (registry)"
3 = "External (preferences)"
4 = "Unpacked / Command line"
5 = "External (crx)"
6 = "External (crx update)"
7 = "Internal (component)"
8 = "External (policy)"
9 = "Internal (theme)"
10 = "External (CWS update)"
}
function ConvertFrom-WindowsFileTime {
param([string]$FileTimeString)
if ([string]::IsNullOrWhiteSpace($FileTimeString)) { return $null }
try {
$ft = [Int64]::Parse($FileTimeString)
if ($ft -le 0) { return $null }
return [DateTime]::FromFileTimeUtc($ft)
} catch {
return $null
}
}
function Resolve-ExtensionName {
param(
[hashtable] $Manifest,
[string] $ManifestPath
)
$name = $Manifest["name"]
if ($name -isnot [string]) { return $null }
if ($name -notmatch "^__MSG_(.+)__$") { return $name }
$key = $Matches[1]
$locales = Join-Path (Split-Path $ManifestPath -Parent) "_locales"
if (-not (Test-Path $locales)) { return $name }
$tryLocales = @("en","en_US","en_GB") +
(Get-ChildItem $locales -Directory -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty Name)
foreach ($loc in ($tryLocales | Select-Object -Unique)) {
$msgPath = Join-Path $locales "$loc\messages.json"
if (-not (Test-Path $msgPath)) { continue }
try {
$msgs = Get-Content $msgPath -Raw -ErrorAction Stop | ConvertFrom-Json
$val = $msgs.$key.message
if ($val) { return [string]$val }
} catch { continue }
}
return $name
}
function Get-ProfileNameMap {
param([string]$UserDataPath)
$map = @{}
$path = Join-Path $UserDataPath "Local State"
if (-not (Test-Path $path)) { return $map }
try {
$ls = Get-Content $path -Raw -ErrorAction Stop | ConvertFrom-Json
$cache = $ls.profile.info_cache
if ($cache) {
foreach ($p in $cache.PSObject.Properties) {
if ($p.Value.name) { $map[$p.Name] = $p.Value.name }
}
}
} catch {}
return $map
}
function Read-LockedJsonFile {
param([string]$Path)
$tmp = $null
try {
$tmp = [System.IO.Path]::GetTempFileName()
[System.IO.File]::Copy($Path, $tmp, $true) # VSS-style raw copy beats Get-Content on locked files
$json = Get-Content $tmp -Raw -Encoding UTF8 -ErrorAction Stop
return ($json | ConvertFrom-Json -ErrorAction Stop)
} catch {
# Fallback: direct read (works when Chrome is closed)
try {
return (Get-Content $Path -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop)
} catch {
Write-Warning " Could not parse '$Path': $($_.Exception.Message)"
return $null
}
} finally {
if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
}
}
function Resolve-EnabledState {
param($SettingsEntry)
# Primary: explicit state field
if ($SettingsEntry.PSObject.Properties["state"] -and ($null -ne $SettingsEntry.state)) {
$stateVal = $null
if ([int]::TryParse([string]$SettingsEntry.state, [ref]$stateVal)) {
return ($stateVal -eq 1)
}
}
# Fallback 1: disable_reasons bitmask
if ($SettingsEntry.PSObject.Properties["disable_reasons"] -and ($null -ne $SettingsEntry.disable_reasons)) {
$reasons = $null
if ([int]::TryParse([string]$SettingsEntry.disable_reasons, [ref]$reasons)) {
return ($reasons -eq 0)
}
}
# Fallback 2: blacklisted flag
if ($SettingsEntry.PSObject.Properties["blacklisted"] -and ($SettingsEntry.blacklisted -eq $true)) {
return $false
}
return $true
}
#endregion
#region -- Main scan ----------------------------------------------------------
$results = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($ch in $Channel) {
$userDataPath = Join-Path $env:LOCALAPPDATA $channelPaths[$ch]
if (-not (Test-Path $userDataPath)) {
Write-Warning "[$ch] Chrome User Data not found -- skipping: $userDataPath"
continue
}
Write-Host "[$ch] Scanning: $userDataPath" -ForegroundColor Cyan
$profileNameMap = Get-ProfileNameMap -UserDataPath $userDataPath
$profileDirs = Get-ChildItem $userDataPath -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq "Default" -or $_.Name -like "Profile *" } |
Where-Object { -not $ProfileFilter -or $_.Name -eq $ProfileFilter } |
Select-Object -ExpandProperty Name
if (-not $profileDirs) {
Write-Warning "[$ch] No matching profiles found."
continue
}
foreach ($profDir in $profileDirs) {
$profPath = Join-Path $userDataPath $profDir
$extPath = Join-Path $profPath "Extensions"
$settings = $null
function Get-ExtensionSettings {
param([string]$FilePath, [string]$Label)
if (-not (Test-Path $FilePath)) { return $null }
$parsed = Read-LockedJsonFile -Path $FilePath
if (-not $parsed) { return $null }
if ($parsed.PSObject.Properties["extensions"] -and
$parsed.extensions.PSObject.Properties["settings"]) {
Write-Verbose " [$Label] extensions.settings loaded from: $FilePath"
return $parsed.extensions.settings
}
return $null
}
$securePrefsPath = Join-Path $profPath "Secure Preferences"
$prefsPath = Join-Path $profPath "Preferences"
$settings = Get-ExtensionSettings -FilePath $securePrefsPath -Label "$ch/$profDir (Secure Preferences)"
if (-not $settings) {
$settings = Get-ExtensionSettings -FilePath $prefsPath -Label "$ch/$profDir (Preferences)"
}
if (-not $settings) {
Write-Warning " [$ch/$profDir] Could not load extensions.settings from Secure Preferences or Preferences."
}
$profileDisplay = if ($profileNameMap.ContainsKey($profDir)) { $profileNameMap[$profDir] } else { $profDir }
# Merge Extension IDs found in Preferences and physical Extension Directory
$extIds = @()
if ($settings) {
$extIds += $settings.PSObject.Properties.Name
}
if (Test-Path $extPath) {
$extIds += Get-ChildItem $extPath -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name
}
$extIds = $extIds | Select-Object -Unique | Where-Object { $_ -ne $null -and $_ -ne "" }
foreach ($extId in $extIds) {
# --- Extract Data from Preferences ---
$enabled = $null
$installSrc = $null
$locationCode = $null
$locationLbl = $null
$fromWebstore = $null
$installTime = $null
$unpackedPath = $null
if ($settings -and $settings.PSObject.Properties[$extId]) {
$s = $settings.PSObject.Properties[$extId].Value
$enabled = Resolve-EnabledState -SettingsEntry $s
if ($s.PSObject.Properties["location"] -and $null -ne $s.location) {
$locationCode = [int]$s.location
$locationLbl = if ($locationLabels.ContainsKey($locationCode)) {
$locationLabels[$locationCode]
} else {
"Unknown ($locationCode)"
}
}
if ($s.PSObject.Properties["from_webstore"] -and $null -ne $s.from_webstore) {
$fromWebstore = [bool]$s.from_webstore
}
if ($s.PSObject.Properties["install_time"] -and $null -ne $s.install_time) {
$installTime = ConvertFrom-WindowsFileTime -FileTimeString ([string]$s.install_time)
}
if ($s.PSObject.Properties["install_source"] -and $null -ne $s.install_source) {
$installSrc = [string]$s.install_source
}
# Unpacked extensions have location Code 4 and an explicit absolute 'path' attribute
if ($locationCode -eq 4 -and $s.PSObject.Properties["path"] -and $null -ne $s.path) {
$unpackedPath = [string]$s.path
if (-not [System.IO.Path]::IsPathRooted($unpackedPath)) {
$unpackedPath = Join-Path $profPath $unpackedPath
}
}
}
# --- Locate Manifest.json ---
$manifestFile = $null
if ($unpackedPath -and (Test-Path (Join-Path $unpackedPath "manifest.json"))) {
# Load manifest from the local disk path where the unpacked extension lives
$manifestFile = Join-Path $unpackedPath "manifest.json"
} elseif (Test-Path $extPath) {
# Load manifest from standard installed extensions directory
$idPath = Join-Path $extPath $extId
$verDirs = Get-ChildItem $idPath -Directory -ErrorAction SilentlyContinue |
Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") }
if ($verDirs) {
$best = $verDirs | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$manifestFile = Join-Path $best.FullName "manifest.json"
}
}
if (-not $manifestFile -or -not (Test-Path $manifestFile)) {
continue
}
# --- Parse Manifest Data ---
$manifest = $null
$manifestHash = $null
try {
$manifest = Get-Content $manifestFile -Raw -ErrorAction Stop | ConvertFrom-Json
$manifestHash = (Get-FileHash $manifestFile -Algorithm SHA256 -ErrorAction Stop).Hash
} catch { continue }
$manifestHt = @{}
try {
$manifest.PSObject.Properties | ForEach-Object { $manifestHt[$_.Name] = $_.Value }
} catch {}
$name = Resolve-ExtensionName -Manifest $manifestHt -ManifestPath $manifestFile
$ver = $manifest.version
$desc = if ($manifest.PSObject.Properties["description"]) { [string]$manifest.description } else { $null }
$mv = if ($manifest.PSObject.Properties["manifest_version"]) { [int]$manifest.manifest_version } else { $null }
$homepageUrl = if ($manifest.PSObject.Properties["homepage_url"]) { [string]$manifest.homepage_url } else { $null }
$permissions = $null
if ($IncludePermissions -and $manifest.PSObject.Properties["permissions"]) {
try { $permissions = ($manifest.permissions | ForEach-Object { [string]$_ }) -join "; " } catch {}
}
# Calculate user-friendly Install Type
$isUnpacked = ($locationCode -eq 4)
$installType = "Unknown"
if ($isUnpacked) {
$installType = "Unpacked (Local)"
} elseif ($fromWebstore) {
$installType = "Official Web Store"
} else {
$installType = "Third-Party Sideloaded"
}
$row = [pscustomobject]@{
Channel = $ch
Profile = $profileDisplay
ProfileDir = $profDir
ExtensionId = $extId
Name = $name
Version = $ver
InstallType = $installType
IsUnpacked = $isUnpacked
ManifestVersion = $mv
Description = $desc
HomepageUrl = $homepageUrl
Enabled = $enabled
FromWebStore = $fromWebstore
InstallSource = $installSrc
LocationCode = $locationCode
LocationLabel = $locationLbl
InstallTimeUTC = $installTime
ManifestPath = $manifestFile
ManifestSHA256 = $manifestHash
}
if ($IncludePermissions) {
$row | Add-Member -NotePropertyName Permissions -NotePropertyValue $permissions
}
$results.Add($row)
}
}
}
#endregion
#region -- Output & File Copy -------------------------------------------------------------
if ($results.Count -eq 0) {
Write-Warning "No extensions found. Check that Chrome is installed and profiles exist."
return
}
$sorted = $results | Sort-Object Channel, Profile, Name
if ($Interactive) {
Write-Host "`n--- Interactive Extension Selection ---" -ForegroundColor Cyan
Write-Host "Found $($sorted.Count) extensions. Please select which ones to export:"
# 1. Select Extensions via Terminal Menu
for ($i = 0; $i -lt $sorted.Count; $i++) {
$item = $sorted[$i]
Write-Host (" [{0,2}] {1} - {2}" -f ($i+1), $item.Profile, $item.Name)
}
Write-Host ""
$choice = Read-Host "Enter numbers separated by commas, or press Enter to export ALL"
if (-not [string]::IsNullOrWhiteSpace($choice) -and $choice.Trim().ToLower() -ne "all") {
$indexes = $choice -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
$selectedResults = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($idx in $indexes) {
if ($idx -ge 1 -and $idx -le $sorted.Count) {
$selectedResults.Add($sorted[$idx - 1])
}
}
if ($selectedResults.Count -eq 0) {
Write-Warning "No valid selections made. Canceling export."
return
}
# Override the sorted list with the selected items
$sorted = $selectedResults
$results = $selectedResults
}
# 2. Select Output Directory via Terminal
$defaultDir = if ($OutputDir) { $OutputDir } else { (Get-Location).Path }
Write-Host ""
$dirChoice = Read-Host "Enter output directory path (Press Enter for default: $defaultDir)"
# Fix scope persistence by using a dedicated run variable
$ActiveOutputDir = $defaultDir
if (-not [string]::IsNullOrWhiteSpace($dirChoice)) {
$ActiveOutputDir = $dirChoice
}
} else {
$ActiveOutputDir = $OutputDir
}
if (-not (Test-Path -LiteralPath $ActiveOutputDir)) {
New-Item -ItemType Directory -Path $ActiveOutputDir -Force | Out-Null
}
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
$exportFolder = Join-Path $ActiveOutputDir "ChromeExtensionsExport_$ts"
New-Item -ItemType Directory -Path $exportFolder -Force | Out-Null
$csvPath = Join-Path $exportFolder "chrome_extensions_$ts.csv"
$jsonPath = Join-Path $exportFolder "chrome_extensions_$ts.json"
$sorted | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
$sorted | ConvertTo-Json -Depth 8 | Out-File -FilePath $jsonPath -Encoding UTF8
Write-Host "`n--- Exporting Extension Source Files & User Data ---" -ForegroundColor Cyan
$copiedCount = 0
$lockedFiles = $false
foreach ($item in $sorted) {
# Sanitize names for folder creation
$safeName = ($item.Name -replace '[\\/:*?"<>|]', '_').Trim()
$safeProfile = ($item.Profile -replace '[\\/:*?"<>|]', '_').Trim()
$destFolderName = "${safeProfile} - ${safeName} (v$($item.Version))"
$extDest = Join-Path $exportFolder $destFolderName
Write-Host " -> Exporting: $($item.Name) ($($item.Profile))..."
New-Item -ItemType Directory -Path $extDest -Force | Out-Null
# 1. Copy Source Files (Safely via Get-ChildItem to bypass bracket issues)
if (-not [string]::IsNullOrWhiteSpace($item.ManifestPath) -and (Test-Path -LiteralPath $item.ManifestPath)) {
$extSource = Split-Path $item.ManifestPath -Parent
$srcDest = Join-Path $extDest "SourceFiles"
New-Item -ItemType Directory -Path $srcDest -Force | Out-Null
try {
Get-ChildItem -LiteralPath $extSource | Copy-Item -Destination $srcDest -Recurse -Force -ErrorAction Stop
} catch {
Write-Warning " [!] Some source files were inaccessible."
}
}
# 2. Copy User Data (Local Settings, Sync Settings, IndexedDB)
$profPath = Join-Path (Join-Path $env:LOCALAPPDATA $channelPaths[$item.Channel]) $item.ProfileDir
$localSettingsPath = Join-Path $profPath "Local Extension Settings\$($item.ExtensionId)"
$syncSettingsPath = Join-Path $profPath "Sync Extension Settings\$($item.ExtensionId)"
$indexedDBPath = Join-Path $profPath "IndexedDB\chrome-extension_$($item.ExtensionId)_0.indexeddb.leveldb"
$dataDest = Join-Path $extDest "UserData"
$hasData = $false
if (Test-Path -LiteralPath $localSettingsPath) {
$hasData = $true
$lsDest = Join-Path $dataDest "Local Extension Settings"
New-Item -ItemType Directory -Path $lsDest -Force | Out-Null
try { Get-ChildItem -LiteralPath $localSettingsPath | Copy-Item -Destination $lsDest -Recurse -Force -ErrorAction Stop } catch { $lockedFiles = $true }
}
if (Test-Path -LiteralPath $syncSettingsPath) {
$hasData = $true
$ssDest = Join-Path $dataDest "Sync Extension Settings"
New-Item -ItemType Directory -Path $ssDest -Force | Out-Null
try { Get-ChildItem -LiteralPath $syncSettingsPath | Copy-Item -Destination $ssDest -Recurse -Force -ErrorAction Stop } catch { $lockedFiles = $true }
}
if (Test-Path -LiteralPath $indexedDBPath) {
$hasData = $true
$idbDest = Join-Path $dataDest "IndexedDB"
New-Item -ItemType Directory -Path $idbDest -Force | Out-Null
try { Get-ChildItem -LiteralPath $indexedDBPath | Copy-Item -Destination $idbDest -Recurse -Force -ErrorAction Stop } catch { $lockedFiles = $true }
}
if (-not $hasData) {
New-Item -ItemType Directory -Path $dataDest -Force | Out-Null
Set-Content -Path (Join-Path $dataDest "NoDataFound.txt") -Value "No local storage, sync settings, or indexedDB folders were found for this extension."
}
$copiedCount++
}
Write-Host "`n------------------------------------------" -ForegroundColor DarkGray
Write-Host " Chrome Extension Export -- Summary" -ForegroundColor Green
Write-Host "------------------------------------------" -ForegroundColor DarkGray
$results |
Group-Object Channel, Profile |
Select-Object @{N="Channel/Profile"; E={$_.Name}},
@{N="Total"; E={$_.Count}},
@{N="Enabled"; E={($_.Group | Where-Object Enabled -eq $true).Count}},
@{N="Disabled";E={($_.Group | Where-Object Enabled -eq $false).Count}},
@{N="Unpacked";E={($_.Group | Where-Object IsUnpacked -eq $true).Count}} |
Format-Table -AutoSize
Write-Host "Exported $($copiedCount) extensions physically to folder:" -ForegroundColor Green
Write-Host " -> $exportFolder" -ForegroundColor White
if ($lockedFiles) {
Write-Host "`n[!] WARNING: Some User Data files were locked by Chrome and skipped." -ForegroundColor Yellow
Write-Host " Close Google Chrome completely and run the script again for a perfect backup." -ForegroundColor Yellow
}
Write-Host "------------------------------------------" -ForegroundColor DarkGray
if (-not $Interactive) {
Write-Host ""
Write-Host "💡 TIP: Try running this script with the -Interactive switch!" -ForegroundColor Cyan
Write-Host " This will open a terminal-based menu allowing you to selectively choose which extensions to export" -ForegroundColor Cyan
Write-Host " and let you specify the save destination." -ForegroundColor Cyan
Write-Host " Command: .\Export-ChromeExtensions.ps1 -Interactive" -ForegroundColor Yellow
Write-Host ""
}
#endregion
@minanagehsalalma
Copy link
Author

minanagehsalalma commented Mar 5, 2026

CHROME EXTENSION EXPORT UTILITY

USAGE
• Basic: .\Export-ChromeExtensions.ps1
• Custom: .\Export-ChromeExtensions.ps1 -OutputDir "C:\Reports" -IncludePermissions
• Multi-Channel: .\Export-ChromeExtensions.ps1 -Channel Stable, Beta, Canary
• Filter: .\Export-ChromeExtensions.ps1 -ProfileFilter "Default"
• Bypass: powershell -ExecutionPolicy Bypass -File .\Export-ChromeExtensions.ps1

NOTES
• Supports Chrome 100+; bypasses file locks via Secure Preferences temp copies.
• State resolution uses disable_reasons bitmask; Unknown count is always 0.

TERMINAL OUTPUT

[Stable] Scanning: C:\Users\Alice\AppData\Local\Google\Chrome\User Data

Channel / Profile Total Enabled Disabled Unknown
Stable, Personal 23 21 2 0
Stable, Work 14 12 2 0
Stable, Default 5 4 1 0
Total Extensions 42 37 5 0
Total: 51 CSV: ...\ext.csv JSON: ...\ext.json

CSV EXCERPT

`Stable,Personal,aapoccl...,Google Slides,1.0,2,True,True,Internal,2024-03-10`
`Stable,Work,cfhdojb...,Adblock Plus,3.22,3,False,True,External,2024-01-15`
`Stable,Work,bmnlcja...,Grammarly,14.11,3,True,True,External,2024-02-28`

JSON SNIPPET

{
  "Channel": "Stable", "Profile": "Personal", "ExtensionId": "cjpalh...",
  "Name": "LastPass", "Version": "4.119.0", "ManifestVersion": 3,
  "Enabled": true, "LocationLabel": "External (CWS update)",
  "ManifestSHA256": "A3F1C2D8E4B76091F5AA234C8D9E1047B2F38C56D7E9041A6B..."
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment