diff --git a/README.md b/README.md index 9b789bc..1ad0198 100644 --- a/README.md +++ b/README.md @@ -13,5 +13,5 @@ Individual projects opt into recipes by copying or referencing them. | Claude Agents | [claude/agents/implementation-planner/] | Claude Agents | [claude/agents/quality-analyst/](https://github.com/ColoredCow/engineering-recipes/blob/main/claude/agents/quality-analyst) | Claude Code agent that generates requirement-driven test plans covering manual, functional, performance, security, accessibility, usability, and reliability test cases. Supports selective generation via `--type` flag. |(claude/agents/implementation-planner/) | Claude Code agent that creates 4-hour task breakdown implementation plans from feature requirements and posts them to GitHub issues | | Claude Agents | [claude/agents/](claude/agents/) | Claude Code agents for requirement refinement, implementation planning, and plan execution | -| Security | [malware-scan/](malware-scan/) | Static scanner, reusable CI workflow and local sweep for hidden-payload malware in repos (never executes scanned code) | +| Security | [malware-scan/](malware-scan/) | Static scanner, reusable CI workflow and local sweep for hidden-payload malware in repos, plus a whole-system Windows laptop check for non-technical users (never executes scanned code) | | Claude Skills | [claude/skills/](claude/skills/) | Companion skills that guarantee correct agent invocation and show live task progress in the terminal | diff --git a/malware-scan/README.md b/malware-scan/README.md index cb7a596..c0fccf5 100644 --- a/malware-scan/README.md +++ b/malware-scan/README.md @@ -13,6 +13,7 @@ objects without checking anything out. | [`scan_all.sh`](scan_all.sh) | Local sweep: finds every project on a machine and scans each one. | | [`workflow.yml`](workflow.yml) | Caller template for projects (copy into `.github/workflows/`). | | [`pre-commit`](pre-commit) | Optional local git hook. | +| [`windows/`](windows/) | Whole-system, read-only check for one Windows laptop, for non-technical users. | | [`../.github/workflows/malware-scan.yml`](../.github/workflows/malware-scan.yml) | Reusable workflow the template calls. | | [`../claude/skills/cc-malware-scan/`](../claude/skills/cc-malware-scan/) | Claude Code skill wrapping the local sweep. | @@ -112,6 +113,16 @@ The hook uses the scanner installed on **your machine** (`$MALWARE_SCANNER`, the `~/.engineering-recipes`), never a copy from the repo. It is a convenience layer only: `--no-verify` skips it. +## 4. Windows machine check (non-technical users) + +When the machine to check runs Windows, or belongs to someone who can't run Python and bash (a +designer, a content editor, a project manager), send them the [`windows/`](windows/) check instead of the +local sweep. It reads every file on every drive, checks running processes, network connections, +persistence, every git branch and Defender history, and writes a plain-text report for you to read. +They download a ZIP, paste one line into PowerShell, and send back the report. +[`windows/README.md`](windows/README.md) has the step-by-step messages to send them, what they +will see, and how to read the report. + ## If a scan flags something - **Don't** run `npm install`, `dev`, `build` or tests in that project, or open it in VS Code. diff --git a/malware-scan/windows/ColoredCow-Laptop-Check.data.txt b/malware-scan/windows/ColoredCow-Laptop-Check.data.txt new file mode 100644 index 0000000..c3e3c2d --- /dev/null +++ b/malware-scan/windows/ColoredCow-Laptop-Check.data.txt @@ -0,0 +1,268 @@ +{ + "about": "Indicator data for ColoredCow-Laptop-Check.ps1. Kept out of the script so antivirus script scanning does not mistake the scanner for the malware.", + "strong": [ + { + "id": "S01", + "s": "rmcej%otb%" + }, + { + "id": "S02", + "s": "Cot%3t=shtP" + }, + { + "id": "S03", + "s": "_$_1e42" + }, + { + "id": "S04", + "s": "function MDy" + }, + { + "id": "S05", + "s": "global['!']" + }, + { + "id": "S06", + "s": "global['_V']" + }, + { + "id": "S07", + "s": "global[\"_V\"]" + }, + { + "id": "S08", + "s": "2[gWfGj;<:-93Z^C" + }, + { + "id": "S09", + "s": "m6:tTh^D)cBz?NM]" + }, + { + "id": "S10", + "s": "TMfKQEd7TJJa5xNZJZ2Lep838vrzrs7mAP" + }, + { + "id": "S11", + "s": "TXfxHUet9pJVU1BgVkBAbrES4YUc1nGzcG" + }, + { + "id": "S12", + "s": "0xbe037400670fbf1c32364f762975908dc43eeb38759263e7dfcdabc76380811e" + }, + { + "id": "S13", + "s": "0x3f0e5781d0855fb460661ac63257376db1941b2bb522499e4757ecb3ebd5dce3" + }, + { + "id": "S14", + "s": "e9b53a7c-2342-4b15-b02d-bd8b8f6a03f9" + }, + { + "id": "S15", + "s": "default-configuration.vercel.app" + }, + { + "id": "S16", + "s": "vscode-settings-bootstrap.vercel.app" + }, + { + "id": "S17", + "s": "vscode-settings-config.vercel.app" + }, + { + "id": "S18", + "s": "vscode-bootstrapper.vercel.app" + }, + { + "id": "S19", + "s": "vscode-load-config.vercel.app" + }, + { + "id": "S20", + "s": "260120.vercel.app" + }, + { + "id": "S21", + "s": "global['_V']='8-" + }, + { + "id": "S22", + "s": "global[\"_V\"]=\"8-" + }, + { + "id": "S23", + "s": "Lordplay/system-releases" + }, + { + "id": "S24", + "s": "jpeek998/system-releases" + }, + { + "id": "S25", + "s": "copilot-ai.whisdev.org" + }, + { + "id": "S26", + "s": "-1448-du'" + }, + { + "id": "S27", + "s": "-1489-du'" + } + ], + "badPackages": [ + "tailwindcss-style-animate", + "tailwind-mainanimation", + "tailwind-autoanimation", + "tailwindcss-typography-style", + "tailwindcss-style-modify", + "polymarket-validator", + "changelog-logger-utilities", + "js-logger-pack", + "ts-logger-pack", + "terminal-logger-utils", + "pretty-logger-utils", + "pinno-loggers", + "node-env-resolve" + ], + "network": [ + "default-configuration.vercel.app", + "vscode-settings-bootstrap.vercel.app", + "vscode-settings-config.vercel.app", + "vscode-bootstrapper.vercel.app", + "vscode-load-config.vercel.app", + "260120.vercel.app", + "api.trongrid.io", + "fullnode.mainnet.aptoslabs.com", + "whisdev.org", + "166.88.54.158", + "198.105.127.210", + "23.27.202.27", + "154.91.0.103", + "136.0.9.8", + "166.88.4.2", + "23.27.120.142", + "202.155.8.173", + "166.88.134.82", + "188.43.33.249", + "23.27.13.43", + "195.201.194.107" + ], + "implantNames": [ + "MicrosoftSystem64", + "MicrosoftSystem64.exe", + "MicrosoftSystem64-win.exe", + "com.launchkeeper.MicrosoftSystem64" + ], + "hashes": { + "f2c754a7f7b56e0e2a6dd429f06c42a1860c52c37b25d0a8e91c67d1239fa577": "MicrosoftSystem64 Windows", + "457a3323fe0cfa82d6e102074c6f07a399f55c7e0ce2d3b40643d9cfde0cf220": "MicrosoftSystem64 macOS x64", + "2fec04f2985510654d9656d57f6817de1ca0d6ae49e7085b1e33abb38f89cc55": "MicrosoftSystem64 macOS arm64", + "b505f1d1ca3dca8cb7e2b2dd99991b5a929ec9387f3de31ad36549823af07dfd": "MicrosoftSystem64 Linux", + "b2954c945b51dbd6fa88ac72338b7fbf76dec7d9909ceada9d36b21330842c97": "MicrosoftSystem64 Linux 1.0.8", + "33401580619ae79bf3f87aab16208f169a44a038f18671b1def7836fb2682c9a": "print.cjs dropper" + }, + "seaFuse": "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1", + "binNeedles": [ + "vercel.app", + "trongrid", + "aptoslabs", + "whisdev", + "system-releases", + "-du'", + "_$_", + "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2:1", + "tailwindcss-style-animate", + "tailwind-mainanimation", + "tailwind-autoanimation", + "tailwindcss-typography-style", + "tailwindcss-style-modify", + "js-logger-pack", + "ts-logger-pack", + "MicrosoftSystem64", + "rmcej%otb%", + "Cot%3t=shtP", + "global['", + "global[\"", + "global.o=", + "global.i=", + "TMfKQEd7TJJa5x", + "TXfxHUet9pJVU1", + "0xbe037400670f", + "0x3f0e5781d085", + "e9b53a7c-2342", + "function MDy", + "2[gWfGj;<:-93Z^C", + "m6:tTh^D)cBz?NM]", + "temp_auto_push" + ], + "high": [ + { + "id": "GLOBAL_MARKER", + "pre": "global", + "d": "campaign marker (global.o='...-du' style)", + "re": "global\\s*(\\.\\s*o|\\[\\s*['\"](!|_V|o|r)['\"]\\s*\\])\\s*=\\s*['\"]|global\\s*\\.\\s*i\\s*=\\s*['\"](\\d+-\\d+(-\\d+)?-du|A8-)" + }, + { + "id": "GLOBAL_REQUIRE", + "pre": "global", + "d": "require() exported onto the global object", + "re": "global\\s*(\\[[^=;]{1,60}\\]|\\.\\w+)\\s*=\\s*require\\b(?!\\s*\\()" + }, + { + "id": "OBFUSCATOR_ID", + "pre": "_$_", + "d": "obfuscator identifier (_$_ + 4 hex)", + "re": "_\\$_[0-9a-fA-F]{4}\\b" + }, + { + "id": "HIDDEN_AFTER_PAD", + "pre": "", + "d": "code hidden after a long run of spaces (pushed off-screen)", + "re": "\\S[ \\t]{150,}(global\\s*[\\[.]|require\\s*\\(|\\beval\\s*\\(|Function\\s*\\(|fromCharCode|child_process|_\\$_[0-9a-fA-F]{4}|\\batob\\s*\\(|Buffer\\.from)" + } + ], + "medium": [ + { + "id": "CHAR127_DECODER", + "pre": "fromCharCode", + "d": "string decoder using char 127", + "re": "fromCharCode\\(\\s*127\\s*\\)" + }, + { + "id": "B64_EXEC", + "pre": "base64", + "d": "base64 payload being executed", + "re": "Buffer\\.from\\([^)]{0,300}['\\\"]base64['\\\"]\\)[\\s\\S]{0,300}\\b(eval|Function|exec|spawn)\\b" + } + ], + "rx": { + "marker": "global\\s*\\.\\s*[oi]\\s*=\\s*['\\\"]([^'\\\"]{1,20})['\\\"]", + "persist": "(curl|wget|powershell|pwsh|node|mshta|certutil|bitsadmin|wscript|cscript|rundll32|regsvr32).*(http|-enc|iex|\\.js\\b|\\.vbs\\b|javascript:)", + "netCmd": "(curl|wget|iwr|irm|Invoke-WebRequest|Invoke-RestMethod|base64|node\\s+-e|https?://|powershell|mshta)", + "tasksDl": "(curl|wget|iwr|irm|Invoke-WebRequest).{0,300}(\\||iex|bash|sh\\b|cmd)", + "install": "\\\"(preinstall|install|postinstall|prepare)\\\"\\s*:\\s*\\\"[^\\\"]*(curl|wget|node\\s+-e|base64|powershell|https?://)", + "profile": "(iex|Invoke-Expression).*(http|DownloadString)", + "history": "(iwr|irm|Invoke-WebRequest|Invoke-RestMethod|curl|wget|DownloadString).*(\\||iex|Invoke-Expression)|-enc(odedcommand)?\\s", + "service": "(powershell|cmd\\.exe /c|node\\.exe|mshta|wscript|cscript)", + "inline": "\\s-(e|c|p)\\s|--eval", + "dns": "\\.vercel\\.app$|trongrid|aptoslabs|bsc-dataseed|huggingface|whisdev" + }, + "gitEre": [ + "global[[:space:]]*\\.[[:space:]]*[oi][[:space:]]*=[[:space:]]*['\\\"]", + "global[[:space:]]*\\[[[:space:]]*['\\\"](!|_V|o|r)['\\\"][[:space:]]*\\][[:space:]]*=", + "_\\$_[0-9a-fA-F]{4}" + ], + "credPaths": [ + ".ssh", + ".aws\\credentials", + ".docker\\config.json", + ".kube\\config", + "_netrc", + ".netrc", + ".git-credentials", + ".config\\gh\\hosts.yml", + "AppData\\Roaming\\GitHub CLI\\hosts.yml", + "AppData\\Roaming\\GitHub Desktop" + ] +} \ No newline at end of file diff --git a/malware-scan/windows/ColoredCow-Laptop-Check.ps1 b/malware-scan/windows/ColoredCow-Laptop-Check.ps1 new file mode 100644 index 0000000..e8b80b6 --- /dev/null +++ b/malware-scan/windows/ColoredCow-Laptop-Check.ps1 @@ -0,0 +1,768 @@ +<# + ColoredCow laptop check (PolinRider) - Windows - WHOLE SYSTEM + + READ-ONLY. It only reads files and settings. It never changes, moves or + deletes anything, never runs npm / node / project code, and never sends + anything over the network. The only files it writes are the report and two + small pattern files in %TEMP% (removed at the end). + + All indicators live in ColoredCow-Laptop-Check.data.txt next to this file, + not in the script: antivirus script scanning (AMSI) blocks a script that + contains the malware's own signature strings. + + Built as one self-contained file from: + - github.com/meSingh/polinrider-cleaner machine-cleanup/check-windows.ps1 + ioc/ + - github.com/ColoredCow/engineering-recipes malware-scan/scan_malicious.py rules + and widened to: every file on every internal/USB drive (C:\Windows, AppData, + node_modules, .git, Program Files, ProgramData, Temp, Recycle Bin, inside zip + and .vsix files), every git branch/stash/tag tip, every user profile, + persistence, Defender history, DNS cache, unsigned executables. + + Tuned to report too much rather than too little. [HIT] = confirmed indicator, + [review] = for a human, [skip] = could not be checked. The COVERAGE section + names everything that was not read. +#> +[CmdletBinding()] +param([switch]$NoPause) + +$ErrorActionPreference = 'Continue' + +# --- admin: needed to read other users' folders, C:\Windows, Defender, WMI ---- +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +try { $Host.UI.RawUI.WindowTitle = 'ColoredCow laptop check - starting' } catch { } + +$started = Get-Date +$stamp = $started.ToString('yyyy-MM-dd_HHmm') +# As admin, save to the Public Desktop: it shows on every user's desktop, even +# if the admin password typed into the Windows prompt belonged to someone else. +$ReportDir = $null +if ($isAdmin) { $ReportDir = Join-Path $env:PUBLIC 'Desktop' } +if (-not $ReportDir -or -not (Test-Path -LiteralPath $ReportDir)) { $ReportDir = [Environment]::GetFolderPath('Desktop') } +if (-not $ReportDir -or -not (Test-Path -LiteralPath $ReportDir)) { $ReportDir = $env:USERPROFILE } +$Report = Join-Path $ReportDir ("ColoredCow-laptop-check-{0}-{1}.txt" -f $env:COMPUTERNAME, $stamp) +$SelfPath = $PSCommandPath + +$script:W = New-Object System.IO.StreamWriter($Report, $false, (New-Object System.Text.UTF8Encoding($false))) +$script:W.AutoFlush = $true +$script:Hits = 0; $script:Review = 0 +$TotalSteps = 8 + +function Say ($m) { $script:W.WriteLine([string]$m) } +function Hdr ($m) { Say ""; Say "== $m ==" } +function Bad ($m) { $script:Hits++; Say " [HIT] $m" } +function Warn ($m) { $script:Review++; Say " [review] $m" } +function Ok ($m) { Say " [ok] $m" } +function Skip ($m) { Say " [skip] $m" } +function Step ([int]$n, [string]$m) { Write-Host ""; Write-Host (" Step {0} of {1}: {2}" -f $n, $TotalSteps, $m) -ForegroundColor White } +function Short ([string]$s, [int]$n = 200) { if ($s -and $s.Length -gt $n) { return $s.Substring(0, $n) + '...' } return $s } +function Title ([string]$t) { try { $Host.UI.RawUI.WindowTitle = $t } catch { } } + +# --------------------------------------------------------------------------- +# Indicators +# --------------------------------------------------------------------------- +$DataFile = Join-Path $PSScriptRoot 'ColoredCow-Laptop-Check.data.txt' +if (-not (Test-Path -LiteralPath $DataFile)) { + Write-Host "" + Write-Host " The file ColoredCow-Laptop-Check.data.txt is missing next to this script." -ForegroundColor Red + Write-Host " Please send ColoredCow a screenshot of this window." -ForegroundColor Red + $script:W.Close(); $null = Read-Host; exit 1 +} +$IOC = Get-Content -LiteralPath $DataFile -Raw -Encoding UTF8 | ConvertFrom-Json +$Strong = @($IOC.strong | ForEach-Object { $_.s }) +$StrongId = @{}; foreach ($x in $IOC.strong) { $StrongId[$x.s] = $x.id } +$BadPkgs = @($IOC.badPackages) +$Net = @($IOC.network) +$Implants = @($IOC.implantNames) +$KnownHashes = @{}; foreach ($pp in $IOC.hashes.PSObject.Properties) { $KnownHashes[$pp.Name] = $pp.Value } +$SeaFuse = [string]$IOC.seaFuse +$BinNeedles = @($IOC.binNeedles) +$RX = @{}; foreach ($pp in $IOC.rx.PSObject.Properties) { $RX[$pp.Name] = [string]$pp.Value } +$RxOpt = [Text.RegularExpressions.RegexOptions]::Compiled +$HighRules = @($IOC.high | ForEach-Object { @{ id = $_.id; pre = $_.pre; d = $_.d; re = New-Object regex ($_.re, $RxOpt) } }) +$MediumRules = @($IOC.medium | ForEach-Object { @{ id = $_.id; pre = $_.pre; d = $_.d; re = New-Object regex ($_.re, $RxOpt) } }) +$Pad150 = ' ' * 150 +$Tab20 = "`t" * 20 +Say ("indicator data: $($Strong.Count) strings, $($HighRules.Count + $MediumRules.Count) rules, $($BadPkgs.Count) packages, $($Net.Count) hosts (file: $DataFile)") +Say "note: indicators are reported by id (S01..S$($Strong.Count)); the id list is in the data file" + +function Find-Strong ([string]$t) { + if (-not $t) { return $null } + foreach ($s in $Strong) { if ($t.Contains($s)) { return $s } } + return $null +} +function Find-High ([string]$t) { + $why = @() + if (-not $t) { return $why } + $s = Find-Strong $t + if ($s) { $why += "indicator $($StrongId[$s])" } + foreach ($r in $HighRules) { + if ($r.pre -eq '') { if (-not $t.Contains($Pad150) -and -not $t.Contains($Tab20)) { continue } } + elseif (-not $t.Contains($r.pre)) { continue } + if ($r.re.IsMatch($t)) { $why += ("{0}: {1}" -f $r.id, $r.d) } + } + return $why +} +function Find-Medium ([string]$t) { + foreach ($r in $MediumRules) { if ($t.Contains($r.pre) -and $r.re.IsMatch($t)) { return $r.d } } + return $null +} + +# --------------------------------------------------------------------------- +# Scope +# --------------------------------------------------------------------------- +$Drives = @() +try { $Drives = @(Get-CimInstance Win32_LogicalDisk -ErrorAction Stop | Where-Object { $_.DriveType -eq 2 -or $_.DriveType -eq 3 } | ForEach-Object { $_.DeviceID + '\' }) } catch { } +if ($Drives.Count -eq 0) { $Drives = @($env:SystemDrive + '\') } +$WinDir = $env:windir.TrimEnd('\') + +$Profiles = @() +try { $Profiles = @(Get-CimInstance Win32_UserProfile -ErrorAction Stop | Where-Object { -not $_.Special -and $_.LocalPath -and (Test-Path -LiteralPath $_.LocalPath) } | ForEach-Object { $_.LocalPath }) } catch { } +if ($Profiles -notcontains $env:USERPROFILE) { $Profiles += $env:USERPROFILE } + +$TextExt = @('.js','.cjs','.mjs','.jsx','.ts','.tsx','.mts','.cts','.vue','.svelte','.json','.jsonc','.map', + '.html','.htm','.php','.py','.sh','.bash','.zsh','.bat','.cmd','.ps1','.psm1','.psd1','.vbs','.vbe','.wsf', + '.hta','.yml','.yaml','.toml','.ini','.cfg','.conf','.txt','.md','.plist','.service','.desktop','.xml','.env', + '.lock','.gitignore','.npmrc','.log','.csv','.svg') +$ZipExt = @('.zip','.vsix','.nupkg','.jar','.whl','.xpi','.crx') +$MediaExt = @('.mp4','.mov','.mkv','.avi','.wmv','.m4v','.webm','.mp3','.wav','.flac','.m4a','.aac', + '.jpg','.jpeg','.png','.gif','.heic','.bmp','.tif','.tiff','.raw','.cr2','.nef','.psd') +$ExeExt = @('.exe','.scr','.com','.pif','.node','.msi','.bat','.cmd','.vbs','.ps1') +$UserWritableRx = '\\(AppData|Temp|Downloads|Desktop|Documents)\\|^[A-Za-z]:\\Users\\Public\\|^[A-Za-z]:\\ProgramData\\|\\Windows\\Temp\\|\$Recycle\.Bin' +$NewSince = (Get-Date).AddDays(-120) +$TextFull = 64MB # text files up to this size are read whole with every rule +$StreamMax = 4GB # anything up to this size is read in chunks +$MediaFull = 64MB # big photos/videos above this are not read (listed in COVERAGE) + +function Get-ReadPlan ([System.IO.FileInfo]$f) { + # Returns: text | zip | stream | skip-cloud | skip-media | skip-huge | skip-self + if (([int64]$f.Attributes) -band 0x441000) { return 'skip-cloud' } # OneDrive online-only + $n = $f.Name + if ($f.FullName -eq $SelfPath -or $n -like 'ColoredCow-Laptop-Check*' -or $n -like 'ColoredCow-laptop-check-*.txt' -or $n -like 'st-check-*.txt') { return 'skip-self' } + $ext = $f.Extension.ToLower() + if ($n -match '^(hiberfil|pagefile|swapfile)\.sys$') { return 'skip-huge' } + if ($f.Length -gt $StreamMax) { return 'skip-huge' } + if (($MediaExt -contains $ext) -and $f.Length -gt $MediaFull) { return 'skip-media' } + if (($TextExt -contains $ext) -or $n -like '.env*' -or $n -like '*rc') { if ($f.Length -le $TextFull) { return 'text' } else { return 'stream' } } + if (($ZipExt -contains $ext) -and $f.Length -le 1GB) { return 'zip' } + return 'stream' +} + +# --------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------- +Clear-Host +Write-Host "" +Write-Host " ==========================================================" -ForegroundColor Cyan +Write-Host " ColoredCow laptop check" -ForegroundColor Cyan +Write-Host " ==========================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " This only LOOKS at your laptop. It does not change or delete" +Write-Host " anything, and it does not send anything anywhere." +Write-Host "" +Write-Host " It reads EVERY file, so it takes a while (often 1 to 3 hours)." -ForegroundColor Yellow +Write-Host " - Keep the charger plugged in and the lid OPEN." -ForegroundColor Yellow +Write-Host " - Do not close this window. You can use other apps meanwhile." -ForegroundColor Yellow +Write-Host " - A green bar at the top shows how much is done." -ForegroundColor Yellow +Write-Host " - If you click inside this window and it seems stuck, press Esc once." -ForegroundColor Yellow +Write-Host " - If the laptop goes to sleep, just wake it up. The check carries on." -ForegroundColor Yellow +if (-not $isAdmin) { Write-Host " - Running WITHOUT admin: some folders will be skipped." -ForegroundColor DarkYellow } + +Say ("ColoredCow / PolinRider WHOLE-SYSTEM check - Windows - " + $started.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')) +Say ("host: $env:COMPUTERNAME user: $env:USERNAME admin: $isAdmin PowerShell: $($PSVersionTable.PSVersion)") +try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop; Say ("os: $($os.Caption) $($os.Version) installed: $($os.InstallDate)") } catch { } +Say ("drives: " + ($Drives -join ', ') + " user profiles: " + ($Profiles -join ', ')) +Say "mode: read-only - nothing was changed" +Say "legend: [HIT] confirmed indicator [review] needs a human look [skip] could not be checked" + +# --- Step 1: count, so the progress bar is real ----------------------------- +Step 1 "Counting all files so we can show progress..." +$Denied = New-Object System.Collections.Generic.List[string] +$Junction = New-Object System.Collections.Generic.List[string] +$GitDirs = New-Object System.Collections.Generic.List[string] +$totalFiles = [int64]0; $totalBytes = [int64]0; $lastTick = Get-Date +$stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo] +foreach ($r in $Drives) { $stack.Push((New-Object System.IO.DirectoryInfo $r)) } +while ($stack.Count -gt 0) { + $d = $stack.Pop() + try { $entries = $d.GetFileSystemInfos() } catch { $Denied.Add($d.FullName); continue } + foreach ($e in $entries) { + try { + if ($e -is [System.IO.DirectoryInfo]) { + # Junctions/symlinks point at folders that are scanned directly (and can loop). + # OneDrive folders are reparse points too but have no LinkType, so they are kept. + if (([int64]$e.Attributes -band 1024) -and $e.LinkType) { $Junction.Add("$($e.FullName) -> $(@($e.Target) -join ',')"); continue } + if ($e.Name -eq '.git') { $GitDirs.Add($e.FullName) } + $stack.Push($e) + } else { + $totalFiles++ + $plan = Get-ReadPlan $e + if ($plan -eq 'text' -or $plan -eq 'stream' -or $plan -eq 'zip') { $totalBytes += $e.Length } + } + } catch { } + } + if (((Get-Date) - $lastTick).TotalSeconds -ge 1) { + $lastTick = Get-Date + Write-Progress -Activity 'Step 1: counting files' -Status ("{0:N0} files found so far..." -f $totalFiles) -PercentComplete -1 + Title ("Counting files: {0:N0} - ColoredCow laptop check" -f $totalFiles) + } +} +Write-Progress -Activity 'Step 1: counting files' -Completed +Write-Host (" Found {0:N0} files ({1:N1} GB to read)." -f $totalFiles, ($totalBytes / 1GB)) -ForegroundColor Gray + +# --- Step 2: read every file ---------------------------------------------- +Step 2 "Reading every file for the malware. This is the long part." +Write-Host " A progress line appears here every 30 seconds." -ForegroundColor Gray +Hdr "Files with malware content (every file on every drive)" + +$Tasks = New-Object System.Collections.Generic.List[string] +$ClaudeSettings = New-Object System.Collections.Generic.List[string] +$HookFiles = New-Object System.Collections.Generic.List[string] +$PkgFiles = New-Object System.Collections.Generic.List[string] +$CfgFiles = New-Object System.Collections.Generic.List[string] +$FontFiles = New-Object System.Collections.Generic.List[string] +$EnvFiles = New-Object System.Collections.Generic.List[string] +$NewExes = New-Object System.Collections.Generic.List[System.IO.FileInfo] +$SkippedMedia = New-Object System.Collections.Generic.List[string] +$SkippedHuge = New-Object System.Collections.Generic.List[string] +$Unreadable = New-Object System.Collections.Generic.List[string] +$script:cloudOnly = 0; $script:filesDone = [int64]0; $script:bytesDone = [int64]0 +$script:contentHits = 0; $script:hashed = 0 +$Latin1 = [Text.Encoding]::GetEncoding(28591) +$Buf = New-Object byte[] 8388608 + +function Report-Content ([string]$path, [string[]]$why, [datetime]$mtime) { + $script:contentHits++ + Bad ("malicious content: {0} [modified {1}]" -f $path, $mtime.ToString('yyyy-MM-dd HH:mm')) + foreach ($w in $why) { Say " - $w" } +} + +function Scan-TextFile ([System.IO.FileInfo]$f) { + try { $t = [IO.File]::ReadAllText($f.FullName) } catch { $Unreadable.Add($f.FullName); return } + $why = Find-High $t + if ($why.Count -gt 0) { + Report-Content $f.FullName $why $f.LastWriteTime + $mk = [regex]::Match($t, $RX.marker) + if ($mk.Success) { Say " - marker value: $($mk.Groups[1].Value)" } + return + } + $m = Find-Medium $t + if ($m) { Warn ("{0}: {1}" -f $m, $f.FullName) } +} + +function Scan-StreamFile ([System.IO.FileInfo]$f) { + # Reads any file in 8 MB chunks. Checks the short needle list on every chunk, + # the full rules when a needle is seen, the Node single-executable fuse, and + # the SHA-256 of executables in the implant's size range. + $fs = $null; $sha = $null + try { + $fs = [IO.File]::Open($f.FullName, 'Open', 'Read', 'ReadWrite') + $first = $true; $isBin = $false; $carry = ''; $why = @(); $sea = $false; $needle = $null + while (($n = $fs.Read($Buf, 0, $Buf.Length)) -gt 0) { + if ($first) { + $first = $false + $isBin = ($n -ge 4 -and (($Buf[0] -eq 0x4D -and $Buf[1] -eq 0x5A) -or ($Buf[0] -eq 0x7F -and $Buf[1] -eq 0x45 -and $Buf[2] -eq 0x4C -and $Buf[3] -eq 0x46))) + if ($isBin -and $f.Length -ge 15MB -and $f.Length -le 400MB) { $sha = [Security.Cryptography.SHA256]::Create() } + } + if ($sha) { $null = $sha.TransformBlock($Buf, 0, $n, $null, 0) } + $chunk = $carry + $Latin1.GetString($Buf, 0, $n) + if (-not $sea -and $chunk.IndexOf($SeaFuse, [StringComparison]::Ordinal) -ge 0) { $sea = $true } + foreach ($nd in $BinNeedles) { + if ($chunk.IndexOf($nd, [StringComparison]::Ordinal) -ge 0) { + if (-not $needle) { $needle = $nd } + if ($why.Count -eq 0) { $why = @(Find-High $chunk) } + break + } + } + $script:bytesDone += $n + $carry = if ($chunk.Length -gt 4096) { $chunk.Substring($chunk.Length - 4096) } else { $chunk } + } + if ($sha) { + $null = $sha.TransformFinalBlock((New-Object byte[] 0), 0, 0) + $h = -join ($sha.Hash | ForEach-Object { $_.ToString('x2') }) + $script:hashed++ + if ($KnownHashes.ContainsKey($h)) { Bad "file matches known implant hash ($($KnownHashes[$h])): $($f.FullName)" } + } + if ($why.Count -gt 0) { Report-Content $f.FullName $why $f.LastWriteTime } + elseif ($needle -and $needle -ne $SeaFuse -and $needle -ne $BinNeedles[6]) { Warn ("file contains campaign-related text (needle N{0:D2} in the data file), check it: {1}" -f ([array]::IndexOf($BinNeedles, $needle) + 1), $f.FullName) } + if ($sea) { + $sig = $null; try { $sig = Get-AuthenticodeSignature -LiteralPath $f.FullName -ErrorAction SilentlyContinue } catch { } + Warn ("Node single-executable app (same format as the implant), signature={0} {1}: {2}" -f $sig.Status, (Short ([string]$sig.SignerCertificate.Subject) 80), $f.FullName) + } + } catch { + $Unreadable.Add($f.FullName) + } finally { + if ($fs) { $fs.Close() } + if ($sha) { $sha.Dispose() } + } +} + +function Scan-ZipFile ([System.IO.FileInfo]$f) { + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + $z = [IO.Compression.ZipFile]::OpenRead($f.FullName) + try { + foreach ($en in $z.Entries) { + if ($en.Length -le 0 -or $en.Length -gt 64MB) { continue } + $x = [IO.Path]::GetExtension($en.Name).ToLower() + if ($ZipExt -contains $x -or $MediaExt -contains $x) { continue } + if ($en.Name -like 'ColoredCow-Laptop-Check*') { continue } # this scanner, e.g. the gist's Download ZIP + $sr = New-Object IO.StreamReader($en.Open()) + try { $t = $sr.ReadToEnd() } finally { $sr.Close() } + $why = Find-High $t + if ($why.Count -gt 0) { Report-Content ("{0} > {1}" -f $f.FullName, $en.FullName) $why $f.LastWriteTime } + if ($Implants -contains $en.Name -or $en.Name -ieq 'temp_auto_push.bat') { Bad "archive contains $($en.Name): $($f.FullName)" } + } + } finally { $z.Dispose() } + $script:bytesDone += $f.Length + } catch { + Scan-StreamFile $f # not a readable zip: fall back to raw bytes + } +} + +function Visit-File ([System.IO.FileInfo]$f) { + $name = $f.Name; $ext = $f.Extension.ToLower(); $full = $f.FullName; $dir = $f.DirectoryName + if ($Implants -contains $name) { Bad "implant-named file: $full" } + if ($name -ieq 'temp_auto_push.bat') { Bad "propagation script temp_auto_push.bat: $full [modified $($f.LastWriteTime.ToString('yyyy-MM-dd HH:mm'))]" } + if ($name -eq 'tasks.json' -and $dir -like '*\.vscode') { $Tasks.Add($full) } + if ($name -like 'settings*.json' -and $dir -like '*\.claude') { $ClaudeSettings.Add($full) } + if ($dir -like '*\.husky' -or $dir -like '*\.husky\_' -or $dir -like '*\.githooks' -or $dir -like '*\.git\hooks') { $HookFiles.Add($full) } + if (@('package.json','package-lock.json','pnpm-lock.yaml','yarn.lock','npm-shrinkwrap.json') -contains $name) { $PkgFiles.Add($full) } + if ($name -match '\.config\.[cm]?[jt]s$|^truffle\.js$|^\.(eslintrc|babelrc)(\.c?js)?$' -and $full -notlike '*\node_modules\*') { $CfgFiles.Add($full) } + if ($ext -eq '.woff' -or $ext -eq '.woff2') { $FontFiles.Add($full) } + if ($name -like '.env*' -and $full -notlike '*\node_modules\*') { $EnvFiles.Add($full) } + if (($ExeExt -contains $ext) -and $full -match $UserWritableRx -and ($f.CreationTime -gt $NewSince -or $f.LastWriteTime -gt $NewSince)) { $NewExes.Add($f) } + + switch (Get-ReadPlan $f) { + 'text' { Scan-TextFile $f; $script:bytesDone += $f.Length } + 'stream' { Scan-StreamFile $f } + 'zip' { Scan-ZipFile $f } + 'skip-cloud' { $script:cloudOnly++ } + 'skip-media' { $SkippedMedia.Add(("{0} ({1:N0} MB)" -f $full, ($f.Length / 1MB))) } + 'skip-huge' { $SkippedHuge.Add(("{0} ({1:N1} GB)" -f $full, ($f.Length / 1GB))) } + default { } + } +} + +$scanStart = Get-Date; $lastBar = Get-Date; $lastLine = Get-Date +$stack = New-Object System.Collections.Generic.Stack[System.IO.DirectoryInfo] +foreach ($r in $Drives) { $stack.Push((New-Object System.IO.DirectoryInfo $r)) } +$DeniedPass2 = 0 +while ($stack.Count -gt 0) { + $d = $stack.Pop() + try { $entries = $d.GetFileSystemInfos() } catch { $DeniedPass2++; continue } + foreach ($e in $entries) { + try { + if ($e -is [System.IO.DirectoryInfo]) { + if (([int64]$e.Attributes -band 1024) -and $e.LinkType) { continue } + $stack.Push($e) + continue + } + $script:filesDone++ + Visit-File $e + } catch { $Unreadable.Add([string]$e.FullName) } + + $now = Get-Date + if (($now - $lastBar).TotalMilliseconds -ge 750) { + $lastBar = $now + $pct = if ($totalBytes -gt 0) { [math]::Min(99, [math]::Floor(100 * $script:bytesDone / $totalBytes)) } else { 0 } + $fpct = if ($totalFiles -gt 0) { [math]::Min(99, [math]::Floor(100 * $script:filesDone / $totalFiles)) } else { 0 } + $pct = [math]::Max($pct, [math]::Min($fpct, $pct + 5)) + $el = ($now - $scanStart).TotalSeconds + $left = '' + if ($pct -ge 2 -and $el -gt 60) { + $rem = [math]::Ceiling(($el / $pct * (100 - $pct)) / 60) + $left = if ($rem -ge 90) { "about {0:N1} hours left" -f ($rem / 60) } else { "about $rem minutes left" } + } else { $left = 'working out how long it will take...' } + Write-Progress -Activity ("Reading every file: {0}% done ({1})" -f $pct, $left) ` + -Status ("{0:N0} of {1:N0} files checked" -f $script:filesDone, $totalFiles) ` + -CurrentOperation (Short $d.FullName 90) -PercentComplete $pct + Title ("{0}% done - ColoredCow laptop check" -f $pct) + if (($now - $lastLine).TotalSeconds -ge 30) { + $lastLine = $now + Write-Host (" [{0}] {1,3}% done - {2} - {3:N0} files checked. All fine, please keep waiting." -f $now.ToString('HH:mm'), $pct, $left, $script:filesDone) -ForegroundColor DarkGray + } + } + } +} +Write-Progress -Activity 'Reading every file' -Completed +if ($script:contentHits -eq 0) { Ok ("no malware content in {0:N0} files ({1:N1} GB read)" -f $script:filesDone, ($script:bytesDone / 1GB)) } +Write-Host " Finished reading files. A few quick checks are left." -ForegroundColor Green + +# --- Step 3: implant, processes, network ------------------------------------ +Step 3 "Checking for the hidden background program and running programs..." +Title 'Almost done - ColoredCow laptop check' +Hdr "Second-stage implant" +$implantFound = $false +foreach ($pr in $Profiles) { + $cand = @($Implants | ForEach-Object { Join-Path $pr ('AppData\Local\' + $_) }) + @((Join-Path $pr '.pcl-data'), (Join-Path $pr '.pcl-state')) + foreach ($p in $cand) { + if (Test-Path -LiteralPath $p) { $implantFound = $true; Bad "implant artifact present: $p" } + } +} +Get-Process -ErrorAction SilentlyContinue | Where-Object { $Implants -contains $_.ProcessName -or $Implants -contains ($_.ProcessName + '.exe') } | + ForEach-Object { $implantFound = $true; Bad "implant process running now: $($_.ProcessName) (PID $($_.Id)) path=$($_.Path)" } +Say " executables hashed against known implant hashes: $script:hashed" +if (-not $implantFound) { Ok "no implant path or process (file hashes and names were checked in the file pass)" } + +Hdr "Executables created or changed in the last 120 days in user-writable folders" +foreach ($f in $NewExes) { + $sig = Get-AuthenticodeSignature -LiteralPath $f.FullName -ErrorAction SilentlyContinue + $line = "{0} [{1} MB, created {2}, signature {3} {4}]" -f $f.FullName, [math]::Round($f.Length / 1MB, 1), $f.CreationTime.ToString('yyyy-MM-dd'), $sig.Status, (Short ([string]$sig.SignerCertificate.Subject) 60) + if ($sig.Status -ne 'Valid') { Warn "unsigned: $line" } else { Say " signed: $line" } +} +if ($NewExes.Count -eq 0) { Ok "none" } + +Hdr "Running processes" +try { + foreach ($p in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + $path = [string]$p.ExecutablePath; $cmd = [string]$p.CommandLine + if ($cmd -and (Find-Strong $cmd)) { Bad "process command line contains an indicator: PID $($p.ProcessId) $(Short $cmd)" } + if ($p.Name -match '^(node|python[0-9.]*|pythonw|bun|deno)\.exe$') { + Say (" interpreter running: PID {0} {1}" -f $p.ProcessId, (Short $cmd 300)) + if ($cmd -match $RX.inline) { Warn "interpreter running inline code: PID $($p.ProcessId) $(Short $cmd)" } + } + if ($path -and $path -match $UserWritableRx) { + $sig = Get-AuthenticodeSignature -LiteralPath $path -ErrorAction SilentlyContinue + if ($sig.Status -ne 'Valid') { Warn ("unsigned program running from a user folder: PID {0} {1}" -f $p.ProcessId, $path) } + } + } +} catch { Skip "could not list processes" } + +Hdr "Live network connections" +try { + $conns = @(Get-NetTCPConnection -State Established -ErrorAction Stop | ForEach-Object { + $pp = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue + $pn = if ($pp) { $pp.ProcessName } else { [string]$_.OwningProcess } + "{0,-24} {1}:{2}" -f $pn, $_.RemoteAddress, $_.RemotePort }) + $onC2 = @($conns | Where-Object { $c = $_; @($Net | Where-Object { $c -like "*$_*" }).Count -gt 0 }) + foreach ($c in $onC2) { Bad "live connection to known campaign address: $c" } + $conns | Where-Object { $_ -match '^(node|Code|Cursor|Windsurf|electron|bun|deno|python)' } | Select-Object -First 60 | ForEach-Object { Say (" " + $_) } + if ($onC2.Count -eq 0) { Ok "no live connection to known campaign addresses ($($conns.Count) connections checked)" } +} catch { Skip "could not list TCP connections" } + +Hdr "DNS cache (recently looked-up names)" +try { + $dns = @(Get-DnsClientCache -ErrorAction Stop) + $badDns = @($dns | Where-Object { $e = [string]$_.Entry + ' ' + [string]$_.Data; @($Net | Where-Object { $e -like "*$_*" }).Count -gt 0 }) + foreach ($x in $badDns) { Bad "DNS cache has a campaign host: $($x.Entry) -> $($x.Data)" } + $dns | Where-Object { $_.Entry -match $RX.dns } | + ForEach-Object { Warn "DNS cache entry worth a look: $($_.Entry) -> $($_.Data)" } + if ($badDns.Count -eq 0) { Ok "no campaign host in DNS cache ($($dns.Count) entries)" } +} catch { Skip "could not read DNS cache" } +$hostsFile = Join-Path $WinDir 'System32\drivers\etc\hosts' +if (Test-Path -LiteralPath $hostsFile) { + Get-Content -LiteralPath $hostsFile -ErrorAction SilentlyContinue | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { Warn "hosts file entry: $_" } +} + +# --- Step 4: persistence ----------------------------------------------------- +Step 4 "Checking programs that start with Windows..." +Hdr "Persistence: Run keys, Winlogon, IFEO, Startup folders, services, scheduled tasks, WMI" +function Check-Cmd ([string]$where, [string]$val) { + $isImpl = $false; foreach ($i in $Implants) { if ($val -like "*$i*") { $isImpl = $true } } + if ($isImpl -or (Find-Strong $val)) { Bad "$where -> $val" } + elseif ($val -match $RX.persist) { Warn "$where runs a network/interpreter command -> $val" } + elseif ($val -match $UserWritableRx) { Warn "$where starts something from a user folder -> $val" } + else { Say " $where -> $(Short $val 250)" } +} +$runKeys = @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Run','HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\Microsoft\Windows\CurrentVersion\RunOnce', + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer\Run', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run','HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') +foreach ($k in $runKeys) { + if (-not (Test-Path $k)) { continue } + $props = Get-ItemProperty -Path $k -ErrorAction SilentlyContinue + foreach ($p in $props.PSObject.Properties) { if ($p.Name -notlike 'PS*') { Check-Cmd "$k :: $($p.Name)" ([string]$p.Value) } } +} +$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction SilentlyContinue +if ($wl) { + if ($wl.Shell -and $wl.Shell -ne 'explorer.exe') { Warn "Winlogon Shell is not the default: $($wl.Shell)" } + if ($wl.Userinit -and $wl.Userinit -notmatch '^[A-Za-z]:\\Windows\\system32\\userinit\.exe,?$') { Warn "Winlogon Userinit is not the default: $($wl.Userinit)" } +} +Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options' -ErrorAction SilentlyContinue | ForEach-Object { + $dbg = (Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue).Debugger + if ($dbg) { Warn "IFEO debugger hijack on $($_.PSChildName): $dbg" } +} +$startupDirs = @(Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp') + + @($Profiles | ForEach-Object { Join-Path $_ 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' }) +foreach ($sf in $startupDirs) { + if (-not (Test-Path -LiteralPath $sf)) { continue } + Get-ChildItem -LiteralPath $sf -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne 'desktop.ini' } | + ForEach-Object { Warn "startup folder item: $($_.FullName) [created $($_.CreationTime.ToString('yyyy-MM-dd'))]" } +} +try { + Get-CimInstance Win32_Service -ErrorAction Stop | Where-Object { $_.PathName } | ForEach-Object { + $pn = [string]$_.PathName + if ((Find-Strong $pn) -or (@($Implants | Where-Object { $pn -like "*$_*" }).Count -gt 0)) { Bad "service $($_.Name) -> $pn" } + elseif ($pn -match $UserWritableRx -or $pn -match $RX.service) { Warn "service $($_.Name) [$($_.State)] -> $pn" } + } +} catch { Skip "could not list services" } +try { + Get-ScheduledTask -ErrorAction Stop | ForEach-Object { + $t = $_ + $acts = (@($t.Actions) | ForEach-Object { "$($_.Execute) $($_.Arguments)" }) -join ' ; ' + $label = "scheduled task $($t.TaskPath)$($t.TaskName) [$($t.State)]" + if (@($Implants | Where-Object { $t.TaskName -like "*$_*" }).Count -gt 0) { Bad "$label -> $acts"; return } + if ($t.TaskPath -like '\Microsoft\*' -and $acts -notmatch $UserWritableRx -and -not (Find-Strong $acts)) { return } + Check-Cmd $label $acts + } +} catch { Skip "could not list scheduled tasks" } +try { + foreach ($cls in @('CommandLineEventConsumer','ActiveScriptEventConsumer')) { + Get-CimInstance -Namespace 'root\subscription' -ClassName $cls -ErrorAction Stop | ForEach-Object { + Warn "WMI event consumer ($cls) $($_.Name): $(Short ([string]$_.CommandLineTemplate + [string]$_.ScriptText) 300)" + } + } +} catch { Skip "WMI subscriptions not readable (needs admin)" } + +# --- Step 5: editors, auto-run files, packages, configs, fonts -------------- +Step 5 "Checking VS Code / Cursor, project settings and packages..." +Hdr "Editor extensions installed or updated in the last 120 days" +foreach ($pr in $Profiles) { + foreach ($d in @('.vscode\extensions','.vscode-insiders\extensions','.cursor\extensions','.windsurf\extensions','.vscode-oss\extensions')) { + $p = Join-Path $pr $d + if (-not (Test-Path -LiteralPath $p)) { continue } + Get-ChildItem -LiteralPath $p -Directory -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -gt $NewSince } | + ForEach-Object { Say (" {0} {1}" -f $_.FullName, $_.LastWriteTime.ToString('yyyy-MM-dd')) } + } +} +Say " (every extension file was already read for malware content in step 2)" + +Hdr "Auto-run: .vscode/tasks.json, .claude/settings, git/husky hooks" +$NetCmd = $RX.netCmd +foreach ($p in $Tasks) { + try { $t = [IO.File]::ReadAllText($p) } catch { continue } + if ($t -match 'folderOpen') { + if ((Find-Strong $t) -or $t -match $RX.tasksDl) { Bad "tasks.json downloads and runs code on folder open: $p" } + else { Warn "tasks.json runs on folder open, check the command: $p" } + } elseif ($t -match $NetCmd) { Warn "tasks.json has a network/interpreter command: $p" } +} +foreach ($p in $ClaudeSettings) { + try { $t = [IO.File]::ReadAllText($p) } catch { continue } + if ($t -match '"hooks"') { if ($t -match $NetCmd) { Warn "Claude Code hook with a network/interpreter command: $p" } else { Say " Claude Code hooks defined: $p" } } +} +foreach ($p in $HookFiles) { + if ($p -like '*.sample') { continue } + $t = ''; try { $t = [IO.File]::ReadAllText($p) } catch { } + if (Find-Strong $t) { Bad "hook file contains an indicator: $p" } + elseif ($t -match $NetCmd) { Warn "hook runs a network/interpreter command: $p" } + elseif ($p -like '*\.git\hooks\*') { Warn "active git hook, verify: $p" } +} + +Hdr "Known-bad packages and install scripts" +$pk = 0 +foreach ($p in $PkgFiles) { + try { $t = [IO.File]::ReadAllText($p) } catch { continue } + foreach ($b in $BadPkgs) { if ($t.Contains('"' + $b + '"') -or $t.Contains($b + '@') -or $t.Contains('/' + $b + '/')) { $pk++; Bad "known-bad package '$b' in $p"; break } } + if ($t.Contains('@common-stack/generate-plugin') -and $t -match '9\.0\.2-alpha\.2[12]') { $pk++; Bad "hijacked @common-stack/generate-plugin version in $p" } + if ((Split-Path -Leaf $p) -eq 'package.json' -and $p -notlike '*\node_modules\*' -and + $t -match $RX.install) { + Warn "install script runs a network/interpreter command: $p" + } +} +if ($pk -eq 0) { Ok "no known-bad packages in $($PkgFiles.Count) package files" } + +Hdr "Build configs with extra code after the end" +foreach ($p in $CfgFiles) { + $lines = @(Get-Content -LiteralPath $p -ErrorAction SilentlyContinue) + $end = 0 + for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^(export default|module\.exports)') { $end = $i + 1 } } + if ($end -gt 0 -and $lines.Count -gt ($end + 15)) { Warn "content after module end ($($lines.Count) lines, ends at $end): $p" } + if (@($lines | Where-Object { $_.Length -gt 3000 }).Count -gt 0) { Warn "line over 3000 characters: $p" } +} +Say " config files checked: $($CfgFiles.Count)" + +Hdr "Font files that are not fonts" +$fontBad = 0 +foreach ($p in $FontFiles) { + try { + $fs = [IO.File]::Open($p, 'Open', 'Read', 'ReadWrite'); $b = New-Object byte[] 4; $got = $fs.Read($b, 0, 4); $fs.Close() + if ($got -eq 4) { + $m = $Latin1.GetString($b) + if (@('wOFF','wOF2','vers') -notcontains $m) { $fontBad++; Bad "font file is not a font (starts with '$m'): $p" } + } + } catch { } +} +if ($fontBad -eq 0) { Ok "$($FontFiles.Count) font files look like fonts" } + +# --- Step 6: git ------------------------------------------------------------- +Step 6 ("Checking {0} Git project(s), including every branch..." -f $GitDirs.Count) +Hdr "Git: every branch, tag and stash tip, plus history evidence" +$gitExe = $null +$gc = Get-Command git -ErrorAction SilentlyContinue +if ($gc) { $gitExe = $gc.Source } +if (-not $gitExe) { + $cands = @('C:\Program Files\Git\cmd\git.exe','C:\Program Files (x86)\Git\cmd\git.exe') + foreach ($pr in $Profiles) { $cands += @(Get-ChildItem -Path (Join-Path $pr 'AppData\Local\GitHubDesktop\app-*\resources\app\git\cmd\git.exe') -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) } + $gitExe = $cands | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -Last 1 +} +Say " git: $(if ($gitExe) { $gitExe } else { 'NOT FOUND' })" +$fixedFile = Join-Path $env:TEMP ("st-check-fixed-{0}.txt" -f $PID) +$ereFile = Join-Path $env:TEMP ("st-check-ere-{0}.txt" -f $PID) +[IO.File]::WriteAllLines($fixedFile, [string[]](@($Strong) + @($BadPkgs))) +[IO.File]::WriteAllLines($ereFile, [string[]]@($IOC.gitEre)) +if ($gitExe) { + foreach ($l in @(& $gitExe config --global --list 2>$null)) { + if ($l -match '^(core\.hookspath|core\.fsmonitor|core\.sshcommand|core\.pager|core\.editor|alias\.)') { Warn "global git config: $l" } + elseif ($l -match '^(user\.|url\..*insteadof|http\..*proxy|credential\.)') { Say " global: $l" } + } +} else { Warn "git not found: branch history was NOT checked, only files on disk" } +$gi = 0 +foreach ($gd in $GitDirs) { + $gi++ + Write-Progress -Activity 'Checking Git projects' -Status ("{0} of {1}: {2}" -f $gi, $GitDirs.Count, (Short $gd 80)) -PercentComplete ([math]::Floor(100 * ($gi - 1) / [math]::Max(1, $GitDirs.Count))) + $repo = Split-Path -Parent $gd + Say "" + Say " --- repo: $repo" + $cfgPath = Join-Path $gd 'config' + if (Test-Path -LiteralPath $cfgPath) { + Get-Content -LiteralPath $cfgPath -ErrorAction SilentlyContinue | Where-Object { $_ -match '^\s*(url|fsmonitor|hookspath|sshcommand|pager|editor)\s*=' } | ForEach-Object { + if ($_ -match 'fsmonitor|hookspath|sshcommand|pager|editor') { Warn "repo config: $($_.Trim())" } else { Say " $($_.Trim())" } + } + } + if (-not $gitExe) { continue } + # safe.directory: an admin git refuses repos owned by the normal user otherwise. + # core.fsmonitor / core.hooksPath: a repo's own config must not make git run a program. + $gb = @('-C', $repo, '-c', 'safe.directory=*', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=NUL') + Say (" current branch: " + (& $gitExe @gb rev-parse --abbrev-ref HEAD 2>$null)) + $refs = @(& $gitExe @gb for-each-ref '--format=%(objectname) %(refname:short)' refs/heads refs/remotes refs/tags refs/stash 2>$null) + $bySha = @{} + foreach ($r in $refs) { $pp = $r -split ' ', 2; if ($pp.Count -eq 2) { if (-not $bySha.ContainsKey($pp[0])) { $bySha[$pp[0]] = @() }; $bySha[$pp[0]] += $pp[1] } } + $shas = @($bySha.Keys) + Say " refs: $($refs.Count) (unique tips: $($shas.Count))" + $found = @{} + for ($i = 0; $i -lt $shas.Count; $i += 30) { + $batch = @($shas[$i..([math]::Min($i + 29, $shas.Count - 1))]) + foreach ($o in @(& $gitExe @gb grep -I -l -F -f $fixedFile @batch '--' 2>$null)) { $found[$o] = 'indicator' } + foreach ($o in @(& $gitExe @gb grep -I -l -E -f $ereFile @batch '--' 2>$null)) { if (-not $found.ContainsKey($o)) { $found[$o] = 'pattern' } } + } + foreach ($k in $found.Keys) { + $kv = $k -split ':', 2 + Bad ("git branch/tag contains malware ({0}): {1} in [{2}]" -f $found[$k], $kv[1], (($bySha[$kv[0]]) -join ', ')) + } + if ($found.Count -eq 0) { Ok "no malware in any branch, tag or stash tip" } + Say " recent commits on all branches (author date | committer date):" + & $gitExe @gb log '--all' '--since=2026-08-01' '--date=iso' '--format= %h %ad | %cd %an %d %s' '-n' '40' 2>$null | ForEach-Object { Say $_ } + Say " HEAD reflog (amends / resets / force-push evidence):" + & $gitExe @gb reflog '--date=iso' '-n' '40' 2>$null | ForEach-Object { Say (" " + $_) } +} +Write-Progress -Activity 'Checking Git projects' -Completed +Remove-Item -LiteralPath $fixedFile, $ereFile -ErrorAction SilentlyContinue + +# --- Step 7: shells, npm, antivirus, credentials ---------------------------- +Step 7 "Checking settings, antivirus history and saved logins (no passwords are read)..." +Hdr "PowerShell profiles and command history" +$psProfiles = @($PROFILE.AllUsersAllHosts, $PROFILE.AllUsersCurrentHost) +foreach ($pr in $Profiles) { + $psProfiles += (Join-Path $pr 'Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1') + $psProfiles += (Join-Path $pr 'Documents\WindowsPowerShell\profile.ps1') + $psProfiles += (Join-Path $pr 'Documents\PowerShell\Microsoft.PowerShell_profile.ps1') + $psProfiles += (Join-Path $pr 'OneDrive\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1') +} +foreach ($p in ($psProfiles | Select-Object -Unique)) { + if (-not $p -or -not (Test-Path -LiteralPath $p)) { continue } + $t = Get-Content -LiteralPath $p -Raw -ErrorAction SilentlyContinue + if ($t -and ((Find-Strong $t) -or $t -match $RX.profile)) { Bad "PowerShell profile runs downloaded code: $p" } + else { Warn "PowerShell profile exists, verify: $p" } +} +foreach ($pr in $Profiles) { + $hist = Join-Path $pr 'AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt' + if (-not (Test-Path -LiteralPath $hist)) { continue } + Get-Content -LiteralPath $hist -ErrorAction SilentlyContinue | + Where-Object { $_ -match $RX.history } | + Select-Object -Last 20 | ForEach-Object { Warn "PowerShell history ($pr) - downloaded and ran code: $(Short $_ 250)" } +} + +Hdr "npm configuration (files only, npm is not run)" +foreach ($pr in $Profiles) { + foreach ($npmrc in @((Join-Path $pr '.npmrc'), (Join-Path $pr 'AppData\Roaming\npm\etc\npmrc'))) { + if (-not (Test-Path -LiteralPath $npmrc)) { continue } + Say " $npmrc :" + Get-Content -LiteralPath $npmrc | ForEach-Object { Say (" " + ($_ -replace '(_authToken|_auth|_password)\s*=.*', '$1=')) } + if (Select-String -LiteralPath $npmrc -Pattern '_authToken' -Quiet) { Warn "npm auth token stored on disk - rotate it: $npmrc" } + $reg = Select-String -LiteralPath $npmrc -Pattern '^registry=' | Select-Object -First 1 + if ($reg -and $reg.Line -notmatch 'registry\.npmjs\.org') { Bad "non-default npm registry configured: $($reg.Line)" } + } + $npmGlobal = Join-Path $pr 'AppData\Roaming\npm\node_modules' + if (Test-Path -LiteralPath $npmGlobal) { + Say " global npm packages ($pr): $((Get-ChildItem -LiteralPath $npmGlobal -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) -join ', ')" + } +} + +Hdr "Microsoft Defender detections" +try { + $det = @(Get-MpThreatDetection -ErrorAction Stop) + $thr = @{}; try { Get-MpThreat -ErrorAction Stop | ForEach-Object { $thr[[string]$_.ThreatID] = $_.ThreatName } } catch { } + foreach ($x in $det) { Warn ("Defender detected {0} on {1}: {2}" -f $thr[[string]$x.ThreatID], $x.InitialDetectionTime, (($x.Resources) -join ' | ')) } + if ($det.Count -eq 0) { Ok "no Defender detections on record" } + $st = Get-MpComputerStatus -ErrorAction SilentlyContinue + if ($st) { Say " Defender real-time: $($st.RealTimeProtectionEnabled) signatures: $($st.AntivirusSignatureLastUpdated) last full scan: $($st.FullScanEndTime)" } +} catch { Skip "Defender history not readable (other antivirus, or needs admin)" } + +Hdr "Credential surface (names only, no secrets are read)" +foreach ($pr in $Profiles) { + foreach ($c in @($IOC.credPaths)) { + $p = Join-Path $pr $c + if (Test-Path -LiteralPath $p) { Warn "credential material present: $p" } + } +} +if ($EnvFiles.Count -gt 0) { + Warn "$($EnvFiles.Count) .env files on this laptop (contents only scanned for malware, not copied):" + $EnvFiles | Select-Object -First 50 | ForEach-Object { Say (" " + $_) } +} + +# --- Step 8: coverage + verdict --------------------------------------------- +Step 8 "Writing the report..." +$mins = [math]::Round(((Get-Date) - $started).TotalMinutes, 1) +Hdr "COVERAGE (everything that was NOT read is listed here)" +Say (" files: {0:N0} found, {1:N0} visited data read: {2:N1} GB git repos: {3} admin: {4}" -f $totalFiles, $script:filesDone, ($script:bytesDone / 1GB), $GitDirs.Count, $isAdmin) +Say " folders that could not be opened (access denied): $($Denied.Count)" +$Denied | ForEach-Object { Say " $_" } +Say " files that could not be read (locked or access denied): $($Unreadable.Count)" +$Unreadable | ForEach-Object { Say " $_" } +Say " OneDrive online-only files (not stored on this laptop, so not downloaded): $script:cloudOnly" +Say " photos/videos over $($MediaFull / 1MB) MB not read: $($SkippedMedia.Count)" +$SkippedMedia | ForEach-Object { Say " $_" } +Say " files over $($StreamMax / 1GB) GB, or pagefile/hiberfil/swapfile, not read: $($SkippedHuge.Count)" +$SkippedHuge | ForEach-Object { Say " $_" } +Say " junctions/shortcuts to other folders not followed (their targets are scanned directly): $($Junction.Count)" +$Junction | Select-Object -First 100 | ForEach-Object { Say " $_" } + +Hdr "RESULT" +Say " confirmed hits : $script:Hits" +Say " review items : $script:Review" +Say " took : $mins minutes" +if ($script:Hits -gt 0) { Say "VERDICT: COMPROMISED - at least one confirmed PolinRider indicator." } +elseif ($script:Review -gt 0) { Say "VERDICT: no confirmed indicator; $script:Review items for a human to look at." } +else { Say "VERDICT: clean against the current indicator set." } +$script:W.Close() + +Title 'DONE - ColoredCow laptop check' +Write-Host "" +Write-Host " ==========================================================" -ForegroundColor Green +Write-Host " DONE - 100%" -ForegroundColor Green +Write-Host " ==========================================================" -ForegroundColor Green +Write-Host "" +if ($script:Hits -gt 0) { + Write-Host " Something was found. Don't worry, and don't try to fix it." -ForegroundColor Red + Write-Host " Please do not open your code or push anything to GitHub" -ForegroundColor Red + Write-Host " until ColoredCow has looked at the report." -ForegroundColor Red + Write-Host "" +} +Write-Host " Your report has been saved on your Desktop as:" -ForegroundColor White +Write-Host (" " + (Split-Path -Leaf $Report)) -ForegroundColor Yellow +Write-Host "" +Write-Host " A folder window is opening with the report selected." +Write-Host " Please send that file to ColoredCow." +Write-Host "" +try { Start-Process explorer.exe -ArgumentList ('/select,"' + $Report + '"') } catch { } +try { [console]::Beep(880, 300); [console]::Beep(1175, 400) } catch { } +try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + $null = [System.Windows.Forms.MessageBox]::Show("The laptop check is finished.`n`nThe report is on your Desktop:`n$(Split-Path -Leaf $Report)`n`nPlease send it to ColoredCow.", 'ColoredCow laptop check - DONE') +} catch { } +if (-not $NoPause) { + Write-Host " You can close this window now (or press Enter)." -ForegroundColor Gray + $null = Read-Host +} diff --git a/malware-scan/windows/README.md b/malware-scan/windows/README.md new file mode 100644 index 0000000..cc7db98 --- /dev/null +++ b/malware-scan/windows/README.md @@ -0,0 +1,175 @@ +# Windows machine check + +A read-only, whole-system PolinRider check for **one Windows laptop**, built for people who +don't use developer tools. They download a +ZIP, paste one line into PowerShell, wait, and send back a text report. You read the report. + +**It never changes anything.** It reads files and settings only. It never runs npm, node or any +project code, never deletes or quarantines, and never sends anything over the network. The only +files it writes are the report and two small pattern files in `%TEMP%` that it removes at the end. + +| File | What it is | +|------|------------| +| [`ColoredCow-Laptop-Check.ps1`](ColoredCow-Laptop-Check.ps1) | The scanner. Windows PowerShell 5.1, which ships with Windows 10 and 11. Nothing to install. | +| [`ColoredCow-Laptop-Check.data.txt`](ColoredCow-Laptop-Check.data.txt) | Every indicator and rule the scanner uses. Must sit next to the script. | + +Use [`../scan_all.sh`](../scan_all.sh) for macOS and Linux developer machines. Use this one when +the machine runs Windows, or the person can't run Python and bash. + +## What it checks + +It reads **every file on every internal and USB drive**, including `C:\Windows`, `AppData`, +`node_modules`, `.git`, Program Files, ProgramData, Temp and the Recycle Bin, plus the contents +of `.zip` and `.vsix` archives. Text and code files get every rule. Every other file gets a fast +check for the campaign's signature strings, and executables are hashed and checked for the +implant's format (a Node.js single-executable app). + +On top of the file pass: + +- **Every git repository:** every branch, tag and stash tip (not just what is checked out), the + last commits and the HEAD reflog, which shows amended commits and force-push evidence. +- **Running processes:** command lines carrying the payload, interpreters running inline code, + and unsigned programs running from user folders. +- **Network:** live connections and the DNS cache, compared against the campaign's hosts. +- **Persistence:** Run keys, Winlogon, image-hijack (IFEO) debuggers, Startup folders, services, + scheduled tasks and WMI subscriptions. +- **Auto-run project files:** `.vscode/tasks.json` set to run on folder open, `.claude/settings` + hooks, and husky and git hooks. +- **Other checks:** known-bad npm packages and install scripts, build configs with code hidden + after the end, and font files that aren't fonts. +- **Credential exposure:** Microsoft Defender history, plus where credentials and `.env` files + sit on disk. It reports locations only and never reads or copies secrets. + +The **COVERAGE** section at the end of the report names everything that could not be read: +access-denied folders, locked files, OneDrive online-only files (never downloaded), and files +over 4 GB. + +### Why the indicators live in a separate file + +Microsoft Defender's script scanner (AMSI) blocks a PowerShell script that contains the malware's +own signature strings (`ScriptContainedMaliciousContent`). So the script contains no indicators; it loads them from the `.data.txt` file at +runtime, and the report refers to them by ID (`S01`…`S27`) instead of repeating them, so the +report itself doesn't get quarantined. + +The file is JSON but named `.txt` on purpose: [`../scan_malicious.py`](../scan_malicious.py) scans +`.json` files, so a `.json` name would make every clone of this repo show up as HIGH in the +local sweep. Keep the `.txt` name. + +## Tested on + +Windows 11 with Windows PowerShell 5.1, run as administrator. Reading every file is slow: on a +laptop with close to a million files, a full run can take **most of a day**, so plan for an +overnight run. + +## How to send it + +The flow below is the one that was tested end to end with a non-technical user. + +1. Put the two files in a **secret gist**: only people with the link can see it, and its + **Download ZIP** button gives exactly these two files. + + ```bash + gh gist create malware-scan/windows/ColoredCow-Laptop-Check.ps1 \ + malware-scan/windows/ColoredCow-Laptop-Check.data.txt \ + --desc "ColoredCow laptop check - read-only Windows scanner" + ``` + + Note the gist ID, the long hex part of the URL. The ZIP will download as `-.zip`. + + Don't point people at this repository's own Download ZIP. It holds the other scanners and + READMEs, which contain the indicators and would show up as false hits in the report. + +2. Send the steps below **one at a time**, and wait for "done" or a screenshot before sending the + next one. Put the command in a code block so the chat app doesn't turn the straight quotes + into curly ones. + +### Steps for the person running it + +> **Before you start:** plug in the charger and keep the laptop lid open the whole time. The +> check reads every file, so it can take many hours. It's best started at the end of the day. +> +> **1. Download the file.** Open this link: `https://gist.github.com//` and +> click **Download ZIP** (top right). If Chrome shows a warning, click **Keep**. You don't need to +> open or unzip it. +> +> **2. Open PowerShell as administrator.** +> - Press the **Windows key** and type **powershell**. +> - On the right side of the menu, click **Run as administrator**. +> - Windows asks *"Do you want to allow this app to make changes to your device?"*. Click +> **Yes**. (Windows always words it this way. The check doesn't change anything.) +> - A window opens whose title starts with **Administrator**. It may be black or blue. +> - *If Windows asks for a password instead of showing a Yes button:* press **Esc** and tell us. +> +> **3. Start the check.** Copy this whole line, click inside the PowerShell window, press +> **Ctrl + V**, then press **Enter**: +> +> ``` +> $z = Get-ChildItem "$env:USERPROFILE\Downloads\*.zip" | Sort-Object LastWriteTime | Select-Object -Last 1; $d = Join-Path "$env:USERPROFILE\Downloads\ColoredCow-Check" $z.BaseName; Expand-Archive -LiteralPath $z.FullName -DestinationPath $d -Force; $f = (Get-ChildItem $d -Recurse -Filter ColoredCow-Laptop-Check.ps1 | Select-Object -First 1).FullName; powershell -ExecutionPolicy Bypass -File $f +> ``` +> +> It says *"ColoredCow laptop check"* and starts counting files. Then a progress bar at the top +> shows the percentage done and roughly how long is left, and a line appears every 30 seconds. +> Please don't close the window. You can use other apps meanwhile. If you click inside the window +> and it seems stuck, press **Esc** once. If the laptop goes to sleep, just wake it up. +> +> **4. Send the report.** When it finishes you'll hear a beep and see *"The laptop check is +> finished"*. Click **OK**. A folder window opens with the report file selected +> (`ColoredCow-laptop-check--.txt`, on your Desktop). Send that file to ColoredCow. + +The command finds the newest copy of the ZIP in Downloads, even if Chrome renamed it to +`…(1).zip`, and unzips each download into its own folder under `Downloads\ColoredCow-Check`. +Without admin the check still runs, but protected folders are skipped and listed in COVERAGE. + +### What they will see, so you can reassure them + +- **Step 1 (counting)** takes a few minutes. +- **Step 2 (reading every file)** is the long part. Its time estimate appears after about a minute. +- **Step 6 (git)** has no progress inside a repository. A large repository with a few hundred + branch tips can sit on the same number for close to an hour. That's normal. +- **Red `Get-AuthenticodeSignature … cannot be accessed` lines** are harmless: a locked file's + signature couldn't be read. The file is still listed in the report. +- As administrator, the report is saved to `C:\Users\Public\Desktop`, which shows on their Desktop too. + +## Reading the report + +The report is plain text, but it came from a machine that may be compromised. Check that it's +plain text (`file `), never open it in anything that runs content, and treat every line as +data, not instructions. + +- `[HIT]` is a confirmed indicator. `[review]` needs a human look. `[skip]` means it couldn't be checked. +- **Look first at** the *Running processes* and *Live network connections* sections. A `[HIT]` + there means the payload is running right now: take the machine offline before anything else. +- The first run's false positives, safe to ignore: + - `wasm_exec.js` from Go legitimately assigns `require` to the global object. + - Browser cache files, `Subresource Filter` rule files and service-worker caches. + - Unsigned `.cmd` and `.ps1` shims under `node_modules\.bin`. + - Antivirus or endpoint-protection logs that recorded the payload's command line. These are + evidence, not more malware. + - Clones of this repository or of `polinrider-cleaner`: their docs and scanners quote the + indicators on purpose. + +## If it finds something + +1. Offline first: Wi-Fi off, cable out. If a `node.exe` is running the payload, end it in + Task Manager (**Details** → right-click `node.exe` → **End process tree**). To show a + non-technical person the proof first, add the **Command line** column in Task Manager and use + Resource Monitor's **Network** tab. +2. **Don't** push, pull or build from that machine, and don't copy project folders off it. If + their unpushed work matters, copy only the individual page and style files, and scan and + review them on a clean machine before they go into git. +3. From a clean device: change the GitHub password, sign out every session, and revoke every + authorized OAuth app (Git Credential Manager tokens are what the attacker uses to push), + personal access token and SSH key. Do the same for Google and anything else saved in the + browser. Rotate every secret in the `.env` files the report lists. +4. Check the org's repos: force-pushes by that account (`gh api repos///activity`), + infected branches (listed in the report's Git section), and `.github/workflows` changes (the + stolen token has `workflow` scope). +5. Reinstall Windows. Don't restore a backup made after the infection date. + +## Updating the indicators + +Edit `ColoredCow-Laptop-Check.data.txt` only: `strong` (fixed strings, reported by ID), +`high` and `medium` (regex rules), `binNeedles` (the fast check used on non-text files), `network`, +`hashes` and `badPackages`. Keep it in step with [`../scan_malicious.py`](../scan_malicious.py)'s +rules. Re-create the gist after any change: each revision gets a new ZIP, and the command always +picks the newest one in Downloads.