Skip to content

Instantly share code, notes, and snippets.

@Samirbous
Created June 3, 2026 09:59
Show Gist options
  • Select an option

  • Save Samirbous/59b2e32c93665101e43aaa67b2ccf5b4 to your computer and use it in GitHub Desktop.

Select an option

Save Samirbous/59b2e32c93665101e43aaa67b2ccf5b4 to your computer and use it in GitHub Desktop.
#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