Created
June 3, 2026 09:59
-
-
Save Samirbous/59b2e32c93665101e43aaa67b2ccf5b4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #Requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| Portable scanner for github.dev VSCode GitHub OAuth token theft artifacts. | |
| .DESCRIPTION | |
| Self-contained: embeds primitive-based YARA rules and can bootstrap YARA win64 | |
| into per-user local storage (no admin). Scans Chrome/Edge github.dev IndexedDB | |
| LevelDB (*.log, *.ldb) and optional extra paths. | |
| Author: Samir Bousseaden (https://x.com/SBousseaden) | |
| Reference: https://blog.ammaraskar.com/github-token-stealing/ | |
| .PARAMETER PortableToolsDir | |
| Directory for portable YARA binaries and extracted rules. Default: | |
| %LOCALAPPDATA%\GithubDevTokenTheftScanner | |
| .PARAMETER ExtraScanPaths | |
| Additional files or directories to scan (e.g. memory string dumps). | |
| .PARAMETER ScanDownloads | |
| Also scan *.log / *.ldb in the user Downloads folder that look like github.dev LevelDB exports (contain vscode-web-db). | |
| .PARAMETER Diagnose | |
| Print a primitive checklist for -ExtraScanPaths (or Downloads candidates) and exit without YARA. | |
| .PARAMETER IncludeMemoryStringsInScriptDir | |
| Also scan strings_*.txt next to this script. | |
| .PARAMETER CopyLockedFiles | |
| Stage copies of locked DB files before scanning (browser open). | |
| .PARAMETER MinSeverity | |
| medium | high | critical | |
| .PARAMETER YaraPath | |
| Use a specific yara64.exe; skips download if valid. | |
| .PARAMETER ForceYaraDownload | |
| Re-download portable YARA even if already present. | |
| .PARAMETER SkipYaraDownload | |
| Only use PATH / -YaraPath; do not download. | |
| .PARAMETER ShowMatchingStrings | |
| Print YARA string identifiers, offsets, and matched text (-s). | |
| .PARAMETER MaxStringHitsPerRule | |
| Cap string lines printed per rule per file (default 15). Use 0 for no cap. | |
| .EXAMPLE | |
| .\Scan-GithubDevTokenTheft.ps1 | |
| .EXAMPLE | |
| .\Scan-GithubDevTokenTheft.ps1 -IncludeMemoryStringsInScriptDir -CopyLockedFiles -MinSeverity high | |
| .EXAMPLE | |
| .\Scan-GithubDevTokenTheft.ps1 -ExtraScanPaths C:\dumps\strings_gh_dev.txt -ShowMatchingStrings | |
| .EXAMPLE | |
| .\Scan-GithubDevTokenTheft.ps1 -ExtraScanPaths "$env:USERPROFILE\Downloads\000016.log" -ShowMatchingStrings -MaxStringHitsPerRule 25 | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string] $PortableToolsDir, | |
| [string[]] $ExtraScanPaths, | |
| [switch] $ScanDownloads, | |
| [switch] $Diagnose, | |
| [switch] $IncludeMemoryStringsInScriptDir, | |
| [switch] $CopyLockedFiles, | |
| [ValidateSet('medium', 'high', 'critical')] | |
| [string] $MinSeverity = 'medium', | |
| [string] $YaraPath, | |
| [switch] $ForceYaraDownload, | |
| [switch] $SkipYaraDownload, | |
| [switch] $ShowMatchingStrings, | |
| [int] $MaxStringHitsPerRule = 15 | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Stop' | |
| # --- Embedded YARA (primitive-based; no PoC extension/repo names) --- | |
| $script:EmbeddedYaraRules = @' | |
| /* | |
| * github.dev VSCode GitHub OAuth token theft — primitive-based rules | |
| * Author: Samir Bousseaden (https://x.com/SBousseaden) | |
| * https://blog.ammaraskar.com/github-token-stealing/ | |
| */ | |
| rule github_dev_token_theft_scope_escalation { | |
| meta: | |
| description = "github.dev IndexedDB: GHPR scopes include workflow without project/read:org" | |
| severity = "high" | |
| mitre_attack = "T1528" | |
| strings: | |
| $scopes_key = "githubPullRequest.lastUsedScopes" ascii wide | |
| $scopes_tuple = "\"read:user\",\"user:email\",\"repo\",\"workflow\"" ascii wide | |
| $scopes_tuple_alt = "read:user,user:email,repo,workflow" ascii wide | |
| $scopes_json_vm = "\"scopes\":[\"read:user\",\"repo\",\"user:email\",\"workflow\"]" ascii wide | |
| $ghpr_ext = "github.vscode-pull-request-github" ascii wide | |
| $session_hit = /Got 1 sessions for read:user,repo,user:email,workflow/ ascii wide | |
| $session_miss_wide = /Got 0 sessions for project,read:org,read:user,repo,user:email,workflow/ ascii wide | |
| condition: | |
| ($scopes_key and any of ($scopes_tuple*, $scopes_tuple_alt)) | |
| or ($ghpr_ext and $scopes_json_vm) | |
| or ($session_hit and $session_miss_wide) | |
| or $session_hit | |
| } | |
| rule github_dev_vscode_workspace_extension_with_notebook { | |
| meta: | |
| description = "github.dev: vfs workspace + .vscode/extensions + Jupyter notebook" | |
| severity = "high" | |
| mitre_attack = "T1059.007" | |
| strings: | |
| $github_dev_db = "vscode-web-db" ascii wide | |
| $vfs_github = "vscode-vfs://github" ascii wide | |
| $local_ext = ".vscode/extensions" ascii wide | |
| $notebook_file = ".ipynb" ascii wide | |
| $notebook_serializer = "onNotebookSerializer:jupyter-notebook" ascii wide | |
| $vfs_ext_stat = /fs\.stat\(vscode-vfs:\/\/github[^)]+\.vscode\/extensions/ ascii wide | |
| condition: | |
| $github_dev_db and $vfs_github and $local_ext and $notebook_file and | |
| any of ($notebook_serializer, $vfs_ext_stat) | |
| } | |
| rule github_dev_workspace_extension_installed_from_vfs { | |
| meta: | |
| description = "github.dev: workspace extension installed from vfs .vscode/extensions" | |
| severity = "high" | |
| mitre_attack = "T1204.001" | |
| strings: | |
| $github_dev_db = "vscode-web-db" ascii wide | |
| $vfs_github = "vscode-vfs://github" ascii wide | |
| $installing = /Installing the extension \S+ from vscode-vfs:\/\/github[^ ]+ \.vscode\/extensions\/\S+ in workspace/ ascii wide | |
| $installed = /Successfully installed the extension \S+ from vscode-vfs:\/\/github[^ ]+ \.vscode\/extensions\/\S+ in the workspace/ ascii wide | |
| condition: | |
| $github_dev_db and $vfs_github and any of ($installing, $installed) | |
| } | |
| rule github_dev_extension_install_skip_publisher_trust { | |
| meta: | |
| description = "github.dev: extension install with skipPublisherTrust via command" | |
| severity = "critical" | |
| mitre_attack = "T1204.002" | |
| strings: | |
| $github_dev_db = "vscode-web-db" ascii wide | |
| $install_ctx = /Installing extension: \S+ \{"context":\{"skipPublisherTrust":true,"extensionInstallSource":"command"\}/ ascii wide | |
| $install_ok = /Extension installed successfully: \S+ vscode-userdata:\/\/User\/extensions\.json/ ascii wide | |
| $skip_trust = "\"skipPublisherTrust\":true" ascii wide | |
| $install_cmd = "\"extensionInstallSource\":\"command\"" ascii wide | |
| $installing = "Installing extension:" ascii wide | |
| condition: | |
| $github_dev_db and any of ($install_ctx, $install_ok) | |
| or ($github_dev_db and $installing and $skip_trust and $install_cmd) | |
| } | |
| rule github_dev_session_keychain_enumeration { | |
| meta: | |
| description = "github.dev: keychain session read + notebook + github auth activation" | |
| severity = "medium" | |
| mitre_attack = "T1528" | |
| strings: | |
| $github_dev_db = "vscode-web-db" ascii wide | |
| $webworker = "Running on the web in a webworker" ascii wide | |
| $read_keychain = "Reading sessions from keychain" ascii wide | |
| $got_stored = "Got stored sessions!" ascii wide | |
| $verified_any = /Got [0-9]+ verified sessions/ ascii wide | |
| $auth_ext = "ExtensionService#_doActivateExtension vscode.github-authentication" ascii wide | |
| $ipynb_ext = "ExtensionService#_doActivateExtension vscode.ipynb" ascii wide | |
| $vfs_github = "vscode-vfs://github" ascii wide | |
| condition: | |
| $github_dev_db and $webworker and $vfs_github and $read_keychain and | |
| any of ($got_stored, $verified_any) and $auth_ext and $ipynb_ext | |
| } | |
| rule github_dev_generic_oauth_scope_escalation_via_notebook { | |
| meta: | |
| description = "github.dev: vfs + webworker + workflow scope + notebook serializer" | |
| severity = "medium" | |
| mitre_attack = "T1528" | |
| strings: | |
| $github_dev_db = "vscode-web-db" ascii wide | |
| $webworker = "Running on the web in a webworker" ascii wide | |
| $vfs_github = "vscode-vfs://github" ascii wide | |
| $workflow_combo = "read:user,repo,user:email,workflow" ascii wide | |
| $ipynb_serializer = "onNotebookSerializer:jupyter-notebook" ascii wide | |
| $got_session = /Got 1 sessions for/ ascii wide | |
| condition: | |
| $github_dev_db and $webworker and $vfs_github and $workflow_combo and | |
| $ipynb_serializer and $got_session | |
| } | |
| rule github_dev_token_theft_primitive_chain_high { | |
| meta: | |
| description = "High: install primitive + OAuth scope escalation on github.dev" | |
| severity = "critical" | |
| mitre_attack = "T1528" | |
| condition: | |
| github_dev_token_theft_scope_escalation and | |
| (github_dev_workspace_extension_installed_from_vfs or github_dev_extension_install_skip_publisher_trust) | |
| } | |
| rule github_dev_token_theft_primitive_chain_delivery_high { | |
| meta: | |
| description = "High: notebook delivery + scope escalation on github.dev" | |
| severity = "high" | |
| mitre_attack = "T1528" | |
| condition: | |
| github_dev_token_theft_scope_escalation and | |
| github_dev_vscode_workspace_extension_with_notebook | |
| } | |
| '@ | |
| $script:RuleSeverity = @{ | |
| 'github_dev_token_theft_scope_escalation' = 'high' | |
| 'github_dev_vscode_workspace_extension_with_notebook' = 'high' | |
| 'github_dev_workspace_extension_installed_from_vfs' = 'high' | |
| 'github_dev_extension_install_skip_publisher_trust' = 'critical' | |
| 'github_dev_session_keychain_enumeration' = 'medium' | |
| 'github_dev_generic_oauth_scope_escalation_via_notebook' = 'medium' | |
| 'github_dev_token_theft_primitive_chain_high' = 'critical' | |
| 'github_dev_token_theft_primitive_chain_delivery_high' = 'high' | |
| } | |
| $script:YaraReleaseVersion = '4.5.5' | |
| $script:YaraReleaseBuild = '2368' | |
| $script:YaraZipName = "yara-$($script:YaraReleaseVersion)-$($script:YaraReleaseBuild)-win64.zip" | |
| $script:YaraDownloadUrl = "https://github.com/VirusTotal/yara/releases/download/v$($script:YaraReleaseVersion)/$($script:YaraZipName)" | |
| function Get-ScriptDirectory { | |
| if ($PSScriptRoot) { return $PSScriptRoot } | |
| $inv = $MyInvocation.MyCommand.Path | |
| if ($inv) { return (Split-Path -Parent $inv) } | |
| return (Join-Path $env:USERPROFILE 'Desktop') | |
| } | |
| function Get-PortableToolsDirectory { | |
| if ($PortableToolsDir) { return $PortableToolsDir } | |
| return Join-Path $env:LOCALAPPDATA 'GithubDevTokenTheftScanner' | |
| } | |
| function Get-RuleSeverityRank { | |
| param([string] $Severity) | |
| switch ($Severity.ToLowerInvariant()) { | |
| 'critical' { 3 } | |
| 'high' { 2 } | |
| 'medium' { 1 } | |
| default { 0 } | |
| } | |
| } | |
| function Find-SystemYaraExecutable { | |
| param([string] $Explicit) | |
| if ($Explicit -and (Test-Path -LiteralPath $Explicit)) { | |
| return (Resolve-Path -LiteralPath $Explicit).Path | |
| } | |
| $candidates = @() | |
| $cmd64 = Get-Command yara64 -ErrorAction SilentlyContinue | |
| if ($cmd64) { $candidates += $cmd64.Source } | |
| $cmd = Get-Command yara -ErrorAction SilentlyContinue | |
| if ($cmd) { $candidates += $cmd.Source } | |
| $candidates += "${env:ProgramFiles}\virustotal\yara64.exe" | |
| $candidates += "${env:ProgramFiles(x86)}\virustotal\yara64.exe" | |
| $wg = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Filter yara64.exe -ErrorAction SilentlyContinue | Select-Object -First 1 | |
| if ($wg) { $candidates += $wg.FullName } | |
| foreach ($c in ($candidates | Where-Object { $_ })) { | |
| if (Test-Path -LiteralPath $c) { return $c } | |
| } | |
| return $null | |
| } | |
| function Find-PortableYaraExecutable { | |
| param([string] $ToolsDir) | |
| $yara64 = Join-Path $ToolsDir 'yara64.exe' | |
| if (Test-Path -LiteralPath $yara64) { return $yara64 } | |
| $found = Get-ChildItem -LiteralPath $ToolsDir -Recurse -Filter yara64.exe -ErrorAction SilentlyContinue | Select-Object -First 1 | |
| if ($found) { return $found.FullName } | |
| return $null | |
| } | |
| function Install-PortableYara { | |
| param( | |
| [string] $ToolsDir, | |
| [switch] $Force | |
| ) | |
| $yaraExe = Find-PortableYaraExecutable -ToolsDir $ToolsDir | |
| if ($yaraExe -and -not $Force) { | |
| return $yaraExe | |
| } | |
| Write-Host "Downloading portable YARA $($script:YaraReleaseVersion)..." -ForegroundColor Cyan | |
| Write-Host " $($script:YaraDownloadUrl)" | |
| $null = New-Item -ItemType Directory -Path $ToolsDir -Force | |
| $staging = Join-Path $ToolsDir '_download' | |
| $zipPath = Join-Path $staging $script:YaraZipName | |
| if (Test-Path -LiteralPath $staging) { | |
| Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| New-Item -ItemType Directory -Path $staging -Force | Out-Null | |
| try { | |
| $prevTls = [Net.ServicePointManager]::SecurityProtocol | |
| try { | |
| [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | |
| } catch { } | |
| if (Get-Command Invoke-WebRequest -ErrorAction SilentlyContinue) { | |
| try { | |
| Invoke-WebRequest -Uri $script:YaraDownloadUrl -OutFile $zipPath -UseBasicParsing | |
| } catch { | |
| # Windows PowerShell 5.1 without -UseBasicParsing on older builds | |
| Invoke-WebRequest -Uri $script:YaraDownloadUrl -OutFile $zipPath | |
| } | |
| } else { | |
| throw 'Invoke-WebRequest is not available.' | |
| } | |
| } finally { | |
| [Net.ServicePointManager]::SecurityProtocol = $prevTls | |
| } | |
| if (-not (Test-Path -LiteralPath $zipPath)) { | |
| throw "Download failed: $zipPath" | |
| } | |
| $extractDir = Join-Path $staging 'extract' | |
| Expand-Archive -LiteralPath $zipPath -DestinationPath $extractDir -Force | |
| $binDir = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter yara64.exe -ErrorAction SilentlyContinue | | |
| Select-Object -First 1 | |
| if (-not $binDir) { | |
| throw "yara64.exe not found inside $($script:YaraZipName)" | |
| } | |
| $sourceDir = $binDir.Directory.FullName | |
| Get-ChildItem -LiteralPath $sourceDir -File | ForEach-Object { | |
| $dest = Join-Path $ToolsDir $_.Name | |
| Copy-Item -LiteralPath $_.FullName -Destination $dest -Force | |
| } | |
| Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue | |
| $yaraExe = Find-PortableYaraExecutable -ToolsDir $ToolsDir | |
| if (-not $yaraExe) { | |
| throw "Portable YARA install failed under $ToolsDir" | |
| } | |
| Write-Host "Portable YARA installed: $yaraExe" -ForegroundColor Green | |
| return $yaraExe | |
| } | |
| function Get-YaraExecutable { | |
| $toolsDir = Get-PortableToolsDirectory | |
| if ($YaraPath -and (Test-Path -LiteralPath $YaraPath)) { | |
| return (Resolve-Path -LiteralPath $YaraPath).Path | |
| } | |
| $portable = Find-PortableYaraExecutable -ToolsDir $toolsDir | |
| if ($portable -and -not $ForceYaraDownload) { | |
| return $portable | |
| } | |
| if (-not $SkipYaraDownload) { | |
| try { | |
| return Install-PortableYara -ToolsDir $toolsDir -Force:([bool]$ForceYaraDownload) | |
| } catch { | |
| Write-Warning "Portable YARA download failed: $($_.Exception.Message)" | |
| } | |
| } | |
| $system = Find-SystemYaraExecutable -Explicit $null | |
| if ($system) { return $system } | |
| return $null | |
| } | |
| function Write-EmbeddedRulesFile { | |
| param([string] $ToolsDir) | |
| $rulesDir = Join-Path $ToolsDir 'rules' | |
| $null = New-Item -ItemType Directory -Path $rulesDir -Force | |
| $rulesPath = Join-Path $rulesDir 'github_dev_token_theft.yar' | |
| # ASCII-only rules; write without UTF-8 BOM (yarac rejects BOM as non-ascii on line 1) | |
| $utf8NoBom = New-Object System.Text.UTF8Encoding $false | |
| [System.IO.File]::WriteAllText($rulesPath, $script:EmbeddedYaraRules, $utf8NoBom) | |
| return $rulesPath | |
| } | |
| function Test-YaraRulesCompile { | |
| param( | |
| [string] $Yara, | |
| [string] $Rules | |
| ) | |
| $yaracName = 'yarac.exe' | |
| if ([IO.Path]::GetFileName($Yara) -match 'yara64') { | |
| $yaracName = 'yarac64.exe' | |
| } | |
| $yarac = Join-Path ([IO.Path]::GetDirectoryName($Yara)) $yaracName | |
| if (-not (Test-Path -LiteralPath $yarac)) { | |
| Write-Warning "yarac not found next to yara; skipping compile check." | |
| return | |
| } | |
| $compiled = Join-Path ([IO.Path]::GetTempPath()) ("ghdev-rules-{0}.yarc" -f [guid]::NewGuid().ToString('N')) | |
| try { | |
| $out = & $yarac $Rules $compiled 2>&1 | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "YARA compile failed: $out" | |
| } | |
| } finally { | |
| Remove-Item -LiteralPath $compiled -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| function Test-LooksLikeGithubDevLevelDbExport { | |
| param( | |
| [string] $FilePath, | |
| [int] $SampleBytes = 65536 | |
| ) | |
| if (-not (Test-Path -LiteralPath $FilePath)) { return $false } | |
| $ext = [IO.Path]::GetExtension($FilePath) | |
| if ($ext -notin '.log', '.ldb') { return $false } | |
| try { | |
| $fs = [IO.File]::Open($FilePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) | |
| try { | |
| $len = [Math]::Min($SampleBytes, [int]$fs.Length) | |
| $buf = New-Object byte[] $len | |
| [void]$fs.Read($buf, 0, $len) | |
| } finally { | |
| $fs.Dispose() | |
| } | |
| $sample = [Text.Encoding]::UTF8.GetString($buf) | |
| return $sample.Contains('vscode-web-db') | |
| } catch { | |
| return $false | |
| } | |
| } | |
| function Get-DownloadsLevelDbExports { | |
| $downloads = [Environment]::GetFolderPath('Downloads') | |
| if (-not $downloads -or -not (Test-Path -LiteralPath $downloads)) { | |
| return @() | |
| } | |
| $found = New-Object System.Collections.Generic.List[string] | |
| Get-ChildItem -LiteralPath $downloads -File -ErrorAction SilentlyContinue | | |
| Where-Object { $_.Extension -in '.log', '.ldb' } | | |
| ForEach-Object { | |
| if (Test-LooksLikeGithubDevLevelDbExport -FilePath $_.FullName) { | |
| [void]$found.Add($_.FullName) | |
| } | |
| } | |
| return $found | Select-Object -Unique | |
| } | |
| function Invoke-PrimitiveDiagnosis { | |
| param([string[]] $Files) | |
| $checks = [ordered]@{ | |
| 'P1 vscode-web-db' = 'vscode-web-db' | |
| 'P1 vscode-vfs://github' = 'vscode-vfs://github' | |
| 'P1 webworker' = 'Running on the web in a webworker' | |
| 'P2 .ipynb' = '.ipynb' | |
| 'P2 jupyter-notebook serializer' = 'onNotebookSerializer:jupyter-notebook' | |
| 'P3 .vscode/extensions' = '.vscode/extensions' | |
| 'P3 workspace extension install (vfs)' = 'Successfully installed the extension' | |
| 'P4 skipPublisherTrust' = 'skipPublisherTrust' | |
| 'P4 extensionInstallSource command' = '"extensionInstallSource":"command"' | |
| 'P5 githubPullRequest.lastUsedScopes' = 'githubPullRequest.lastUsedScopes' | |
| 'P5 scopes JSON (VM variant)' = '"scopes":["read:user","repo","user:email","workflow"]' | |
| 'P5 session Got 1 (workflow combo)' = 'Got 1 sessions for read:user,repo,user:email,workflow' | |
| 'P5 session Got 0 (wide probe)' = 'Got 0 sessions for project,read:org,read:user,repo,user:email,workflow' | |
| 'P6 Reading sessions from keychain' = 'Reading sessions from keychain' | |
| } | |
| foreach ($file in $Files) { | |
| Write-Host "" | |
| Write-Host "=== $file ===" -ForegroundColor Cyan | |
| if (-not (Test-Path -LiteralPath $file)) { | |
| Write-Host " (file not found)" -ForegroundColor Yellow | |
| continue | |
| } | |
| $text = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($file)) | |
| foreach ($entry in $checks.GetEnumerator()) { | |
| $count = ([regex]::Matches($text, [regex]::Escape($entry.Value), 'IgnoreCase')).Count | |
| $status = if ($count -gt 0) { 'present' } else { 'MISSING' } | |
| $color = if ($count -gt 0) { 'Green' } else { 'DarkYellow' } | |
| Write-Host (" [{0}] {1} ({2})" -f $status, $entry.Key, $count) -ForegroundColor $color | |
| } | |
| Write-Host "" | |
| Write-Host " Expected rules if scanned with current YARA (medium+):" -ForegroundColor Gray | |
| $has = @{ | |
| P5 = ($text.Contains('Got 1 sessions for read:user,repo,user:email,workflow') -or $text.Contains('"scopes":["read:user","repo","user:email","workflow"]')) | |
| P2 = ($text.Contains('.ipynb') -and $text.Contains('onNotebookSerializer:jupyter-notebook')) | |
| P4 = $text.Contains('skipPublisherTrust') | |
| P3install = $text.Contains('Successfully installed the extension') | |
| } | |
| if ($has.P5) { Write-Host " - github_dev_token_theft_scope_escalation" } | |
| if ($has.P2) { Write-Host " - github_dev_vscode_workspace_extension_with_notebook" } | |
| if ($has.P5 -and $has.P2) { Write-Host " - github_dev_token_theft_primitive_chain_delivery_high" } | |
| if ($has.P4) { Write-Host " - github_dev_extension_install_skip_publisher_trust" } | |
| if ($has.P5 -and ($has.P4 -or $has.P3install)) { Write-Host " - github_dev_token_theft_primitive_chain_high" } | |
| if (-not $has.P5 -and -not $has.P2) { | |
| Write-Host " - (likely no matches - PoC may not have completed or wrong file)" -ForegroundColor Yellow | |
| } | |
| } | |
| } | |
| function Get-GithubDevLevelDbFiles { | |
| $roots = @( | |
| Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data' | |
| Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data' | |
| ) | |
| $files = New-Object System.Collections.Generic.List[string] | |
| foreach ($root in $roots) { | |
| if (-not (Test-Path -LiteralPath $root)) { continue } | |
| $profiles = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | |
| foreach ($profile in $profiles) { | |
| $levelDir = Join-Path $profile.FullName 'IndexedDB\https_github.dev_0.indexeddb.leveldb' | |
| if (-not (Test-Path -LiteralPath $levelDir)) { continue } | |
| Get-ChildItem -LiteralPath $levelDir -File -ErrorAction SilentlyContinue | | |
| Where-Object { | |
| $_.Extension -in '.log', '.ldb' -and | |
| $_.Name -notmatch '^(LOG(\.old)?|LOCK|CURRENT|MANIFEST-)' | |
| } | | |
| ForEach-Object { [void]$files.Add($_.FullName) } | |
| } | |
| } | |
| return $files | Select-Object -Unique | |
| } | |
| function Resolve-ScanTargetFiles { | |
| param([string[]] $Paths) | |
| $result = New-Object System.Collections.Generic.List[string] | |
| foreach ($p in $Paths) { | |
| if (-not $p) { continue } | |
| if (-not (Test-Path -LiteralPath $p)) { | |
| Write-Warning "Path not found, skipping: $p" | |
| continue | |
| } | |
| $item = Get-Item -LiteralPath $p | |
| if ($item.PSIsContainer) { | |
| Get-ChildItem -LiteralPath $item.FullName -Recurse -File -ErrorAction SilentlyContinue | | |
| ForEach-Object { [void]$result.Add($_.FullName) } | |
| } else { | |
| [void]$result.Add($item.FullName) | |
| } | |
| } | |
| return $result | Select-Object -Unique | |
| } | |
| function Copy-FileForScan { | |
| param( | |
| [string] $SourcePath, | |
| [string] $StagingDir | |
| ) | |
| $name = [IO.Path]::GetFileName($SourcePath) | |
| $dest = Join-Path $StagingDir $name | |
| $i = 0 | |
| while (Test-Path -LiteralPath $dest) { | |
| $i++ | |
| $dest = Join-Path $StagingDir ("{0}.{1}" -f $name, $i) | |
| } | |
| try { | |
| Copy-Item -LiteralPath $SourcePath -Destination $dest -Force -ErrorAction Stop | |
| return $dest | |
| } catch { | |
| try { | |
| $fs = [IO.File]::Open($SourcePath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) | |
| try { | |
| $buf = New-Object byte[] $fs.Length | |
| [void]$fs.Read($buf, 0, $buf.Length) | |
| } finally { | |
| $fs.Dispose() | |
| } | |
| [IO.File]::WriteAllBytes($dest, $buf) | |
| return $dest | |
| } catch { | |
| Write-Warning "Could not copy or read locked file: $SourcePath ($($_.Exception.Message))" | |
| return $null | |
| } | |
| } | |
| } | |
| function Invoke-YaraScan { | |
| param( | |
| [string] $Yara, | |
| [string] $Rules, | |
| [string[]] $Targets, | |
| [int] $MinRank, | |
| [switch] $ShowStrings, | |
| [int] $MaxStringsPerRule | |
| ) | |
| if (-not $Targets -or $Targets.Count -eq 0) { | |
| return @() | |
| } | |
| $hits = New-Object System.Collections.Generic.List[object] | |
| $yaraArgs = @($Rules) | |
| if ($ShowStrings) { $yaraArgs = @('-s') + $yaraArgs } | |
| foreach ($target in $Targets) { | |
| if (-not (Test-Path -LiteralPath $target)) { continue } | |
| $lines = & $Yara @yaraArgs $target 2>&1 | |
| if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) { | |
| Write-Warning "YARA error on $target : $lines" | |
| continue | |
| } | |
| $currentRule = $null | |
| $currentFile = $null | |
| foreach ($line in $lines) { | |
| if ($line -is [System.Management.Automation.ErrorRecord]) { | |
| Write-Warning $line.ToString() | |
| continue | |
| } | |
| $text = [string]$line | |
| if ([string]::IsNullOrWhiteSpace($text)) { continue } | |
| if ($text -match '^(0x[0-9A-Fa-f]+):\$([^:]+):\s*(.*)$') { | |
| if (-not $ShowStrings -or -not $currentRule) { continue } | |
| # Collect string hits during parse; cap unique ids at display time | |
| $offset = $Matches[1] | |
| $stringId = $Matches[2] | |
| $matched = $Matches[3].Trim() | |
| if ($matched.Length -gt 200) { | |
| $matched = $matched.Substring(0, 200) + '...' | |
| } | |
| $matched = $matched -replace '[^\x09\x0A\x0D\x20-\x7E]', '.' | |
| $hit = $hits | Where-Object { | |
| $_.Rule -eq $currentRule -and $_.File -eq $currentFile | |
| } | Select-Object -First 1 | |
| if ($hit) { | |
| [void]$hit.StringHits.Add([PSCustomObject]@{ | |
| Offset = $offset | |
| StringId = $stringId | |
| Data = $matched | |
| }) | |
| } | |
| continue | |
| } | |
| $parts = $text -split '\s+', 2 | |
| if ($parts.Count -lt 2) { continue } | |
| $ruleName = $parts[0] | |
| if (-not $script:RuleSeverity.ContainsKey($ruleName)) { continue } | |
| $currentRule = $ruleName | |
| $currentFile = $parts[1] | |
| [void]$hits.Add([PSCustomObject]@{ | |
| Rule = $ruleName | |
| File = $currentFile | |
| Detail = '' | |
| Severity = $script:RuleSeverity[$ruleName] | |
| StringHits = (New-Object System.Collections.Generic.List[object]) | |
| }) | |
| } | |
| } | |
| return @($hits | Where-Object { (Get-RuleSeverityRank $_.Severity) -ge $MinRank }) | |
| } | |
| function Write-MatchingStringsReport { | |
| param( | |
| [array] $Hits, | |
| [int] $MaxStringsPerRule | |
| ) | |
| Write-Host "" | |
| Write-Host "MATCHING STRINGS:" -ForegroundColor Cyan | |
| $grouped = $Hits | Sort-Object @{ | |
| Expression = { Get-RuleSeverityRank $_.Severity } | |
| Descending = $true | |
| }, Rule, File | |
| foreach ($hit in $grouped) { | |
| if (-not $hit.StringHits -or $hit.StringHits.Count -eq 0) { continue } | |
| Write-Host "" | |
| Write-Host ("[{0}] {1}" -f $hit.Severity, $hit.Rule) -ForegroundColor Yellow | |
| Write-Host (" File: {0}" -f $hit.File) -ForegroundColor DarkGray | |
| # Prefer one sample per YARA string id (avoids 15x vscode-web-db hiding skip_trust, session_hit, etc.) | |
| $byStringId = @{} | |
| foreach ($s in $hit.StringHits) { | |
| if (-not $byStringId.ContainsKey($s.StringId)) { | |
| $byStringId[$s.StringId] = $s | |
| } | |
| } | |
| $ordered = $byStringId.GetEnumerator() | Sort-Object Name | |
| $shown = 0 | |
| $totalIds = $ordered.Count | |
| foreach ($entry in $ordered) { | |
| $s = $entry.Value | |
| Write-Host " $($s.Offset) `$$($s.StringId)" -ForegroundColor Gray | |
| if ($s.Data) { | |
| Write-Host (" {0}" -f $s.Data) | |
| } | |
| $shown++ | |
| if ($MaxStringsPerRule -gt 0 -and $shown -ge $MaxStringsPerRule) { | |
| $omitted = $totalIds - $shown | |
| if ($omitted -gt 0) { | |
| Write-Host (" ... ({0} more string identifiers omitted; raise -MaxStringHitsPerRule)" -f $omitted) -ForegroundColor DarkGray | |
| } | |
| break | |
| } | |
| } | |
| } | |
| } | |
| # --- main --- | |
| if ($Diagnose) { | |
| $diagFiles = @() | |
| if ($ExtraScanPaths) { | |
| $diagFiles += @(Resolve-ScanTargetFiles -Paths $ExtraScanPaths) | |
| } | |
| if ($ScanDownloads) { | |
| $diagFiles += @(Get-DownloadsLevelDbExports) | |
| } | |
| if ($diagFiles.Count -eq 0) { | |
| Write-Host "Diagnose: pass -ExtraScanPaths and/or -ScanDownloads" -ForegroundColor Yellow | |
| exit 0 | |
| } | |
| Invoke-PrimitiveDiagnosis -Files @($diagFiles | Select-Object -Unique) | |
| exit 0 | |
| } | |
| Write-Host "" | |
| Write-Host "=== github.dev token theft scanner (portable) ===" -ForegroundColor Cyan | |
| Write-Host "Author: https://x.com/SBousseaden" | |
| Write-Host "Reference: https://blog.ammaraskar.com/github-token-stealing/" | |
| Write-Host "" | |
| $toolsDir = Get-PortableToolsDirectory | |
| Write-Host "Tools directory: $toolsDir" | |
| $yara = Get-YaraExecutable | |
| if (-not $yara) { | |
| Write-Host "" | |
| Write-Host "YARA not available. Options:" -ForegroundColor Yellow | |
| Write-Host " - Run without -SkipYaraDownload (needs HTTPS to GitHub)" | |
| Write-Host " - Pass -YaraPath C:\path\to\yara64.exe" | |
| Write-Host " - winget install VirusTotal.YARA" | |
| Write-Host "" | |
| exit 2 | |
| } | |
| $rulesPath = Write-EmbeddedRulesFile -ToolsDir $toolsDir | |
| Write-Host "YARA: $yara" | |
| Write-Host "Rules: $rulesPath (embedded in script)" | |
| Write-Host "" | |
| Test-YaraRulesCompile -Yara $yara -Rules $rulesPath | |
| $minRank = Get-RuleSeverityRank $MinSeverity | |
| $targets = New-Object System.Collections.Generic.List[string] | |
| foreach ($f in (Get-GithubDevLevelDbFiles)) { | |
| if ($f) { [void]$targets.Add([string]$f) } | |
| } | |
| if ($IncludeMemoryStringsInScriptDir) { | |
| $memGlob = Join-Path (Get-ScriptDirectory) 'strings_*.txt' | |
| Get-ChildItem -Path $memGlob -File -ErrorAction SilentlyContinue | | |
| ForEach-Object { [void]$targets.Add($_.FullName) } | |
| } | |
| if ($ScanDownloads) { | |
| foreach ($f in (Get-DownloadsLevelDbExports)) { | |
| [void]$targets.Add([string]$f) | |
| } | |
| } | |
| if ($ExtraScanPaths) { | |
| foreach ($f in (Resolve-ScanTargetFiles -Paths $ExtraScanPaths)) { | |
| [void]$targets.Add([string]$f) | |
| } | |
| } | |
| if ($targets.Count -eq 0) { | |
| Write-Host "No scan targets found." -ForegroundColor Yellow | |
| Write-Host " - Use github.dev in Chrome/Edge, or" | |
| Write-Host " - Pass -ExtraScanPaths, -ScanDownloads (exported 000016.log), or -IncludeMemoryStringsInScriptDir" | |
| Write-Host " - Run: .\Scan-GithubDevTokenTheft.ps1 -Diagnose -ExtraScanPaths `$env:USERPROFILE\Downloads\000016.log" | |
| Write-Host "" | |
| exit 0 | |
| } | |
| $scanPaths = New-Object System.Collections.Generic.List[string] | |
| $stagingDir = $null | |
| if ($CopyLockedFiles) { | |
| $stagingDir = Join-Path ([IO.Path]::GetTempPath()) ("ghdev-yara-{0}" -f [guid]::NewGuid().ToString('N')) | |
| New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null | |
| Write-Host "Staging locked files to: $stagingDir" | |
| Write-Host "" | |
| } | |
| foreach ($path in $targets) { | |
| $usePath = $path | |
| if ($CopyLockedFiles) { | |
| $staged = Copy-FileForScan -SourcePath $path -StagingDir $stagingDir | |
| if ($staged) { $usePath = $staged } | |
| } | |
| [void]$scanPaths.Add($usePath) | |
| } | |
| Write-Host "Targets ($($scanPaths.Count)):" | |
| $scanPaths | ForEach-Object { Write-Host " $_" } | |
| Write-Host "" | |
| $hits = Invoke-YaraScan -Yara $yara -Rules $rulesPath -Targets @($scanPaths.ToArray()) -MinRank $minRank ` | |
| -ShowStrings:$ShowMatchingStrings -MaxStringsPerRule $MaxStringHitsPerRule | |
| if ($stagingDir -and (Test-Path -LiteralPath $stagingDir)) { | |
| Remove-Item -LiteralPath $stagingDir -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| if (-not $hits -or $hits.Count -eq 0) { | |
| Write-Host "No matches (min severity: $MinSeverity)." -ForegroundColor Green | |
| Write-Host "" | |
| exit 0 | |
| } | |
| Write-Host "MATCHES ($($hits.Count)):" -ForegroundColor Red | |
| $hits | | |
| Sort-Object @{ Expression = { Get-RuleSeverityRank $_.Severity }; Descending = $true }, Rule, File | | |
| Format-Table -AutoSize Rule, Severity, File, Detail | |
| if ($ShowMatchingStrings) { | |
| Write-MatchingStringsReport -Hits $hits -MaxStringsPerRule $MaxStringHitsPerRule | |
| } | |
| $chainRules = @($hits | ForEach-Object { $_.Rule } | Where-Object { $_ -match 'primitive_chain' }) | |
| if ($chainRules.Count -gt 0) { | |
| Write-Host "" | |
| Write-Host "High-confidence chain rule(s) fired - treat as incident candidate." -ForegroundColor Red | |
| } | |
| Write-Host "" | |
| Write-Host "Remediation: clear github.dev site data; review VS Code extensions on github.dev." | |
| Write-Host "" | |
| exit 1 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment