Created
September 9, 2025 13:26
-
-
Save jinnosux/bec00c23fe4a67796e4cfa474a00fe52 to your computer and use it in GitHub Desktop.
Simple NPM Supply Chain Attack (Sept 2025) Scanner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Simple NPM Supply Chain Attack Scanner (ref https://www.bleepingcomputer.com/news/security/hackers-hijack-npm-packages-with-2-billion-weekly-downloads-in-supply-chain-attack/ ) | |
| # Scans package.json and package-lock.json in current directory for compromised packages | |
| # Usage: .\ScanNPM.ps1 [optional-directory-path] | |
| param( | |
| [string]$Path = "." | |
| ) | |
| # Compromised packages from September 2025 attack | |
| $CompromisedPackages = @{ | |
| "ansi-regex" = "6.2.1" | |
| "ansi-styles" = "6.2.2" | |
| "backslash" = "0.2.1" | |
| "chalk" = "5.6.1" | |
| "chalk-template" = "1.1.1" | |
| "color-convert" = "3.1.1" | |
| "color-name" = "2.0.1" | |
| "color-string" = "2.1.1" | |
| "debug" = "4.4.2" | |
| "error-ex" = "1.3.3" | |
| "has-ansi" = "6.0.1" | |
| "is-arrayish" = "0.3.3" | |
| "simple-swizzle" = "0.2.3" | |
| "slice-ansi" = "7.1.1" | |
| "strip-ansi" = "7.1.1" | |
| "supports-color" = "10.2.1" | |
| "supports-hyperlinks" = "4.1.1" | |
| "wrap-ansi" = "9.0.1" | |
| "color" = "5.0.1" | |
| } | |
| # Additional compromised packages | |
| $AdditionalCompromised = @{ | |
| "eslint-config-prettier" = @("8.10.1", "9.1.1", "10.1.6", "10.1.7") | |
| "eslint-plugin-prettier" = @("4.2.2", "4.2.3") | |
| "synckit" = @("0.11.9") | |
| "@pkgr/core" = @("0.2.8") | |
| "napi-postinstall" = @("0.3.1") | |
| "got-fetch" = @("5.1.11", "5.1.12") | |
| "is" = @("3.3.1", "5.0.0") | |
| } | |
| $Findings = @() | |
| function Write-ColorOutput { | |
| param([string]$Message, [string]$Color = "White") | |
| switch ($Color) { | |
| "Red" { Write-Host $Message -ForegroundColor Red } | |
| "Green" { Write-Host $Message -ForegroundColor Green } | |
| "Yellow" { Write-Host $Message -ForegroundColor Yellow } | |
| "Blue" { Write-Host $Message -ForegroundColor Blue } | |
| "Cyan" { Write-Host $Message -ForegroundColor Cyan } | |
| default { Write-Host $Message } | |
| } | |
| } | |
| function Test-VersionMatch { | |
| param([string]$PackageVersion, [string]$CompromisedVersion) | |
| # Remove semver prefixes (^, ~, >=, etc.) | |
| $cleanVersion = $PackageVersion -replace '^[\^~>=<]+', '' | |
| # Direct match | |
| if ($cleanVersion -eq $CompromisedVersion) { | |
| return $true | |
| } | |
| # Check ranges - for simplicity, if the exact version matches | |
| if ($PackageVersion.StartsWith('^') -or $PackageVersion.StartsWith('~')) { | |
| return $cleanVersion -eq $CompromisedVersion | |
| } | |
| return $false | |
| } | |
| function Add-Finding { | |
| param( | |
| [string]$Level, | |
| [string]$Package, | |
| [string]$Version, | |
| [string]$Source | |
| ) | |
| $finding = [PSCustomObject]@{ | |
| Level = $Level | |
| Package = $Package | |
| Version = $Version | |
| Source = $Source | |
| } | |
| $script:Findings += $finding | |
| $color = if ($Level -eq "CRITICAL") { "Red" } else { "Yellow" } | |
| Write-ColorOutput "[WARNING] [$Level] $Package@$Version (found in $Source)" $color | |
| } | |
| function Test-PackageJson { | |
| param([string]$FilePath) | |
| if (!(Test-Path $FilePath)) { | |
| Write-ColorOutput "[INFO] package.json not found" "Blue" | |
| return | |
| } | |
| Write-ColorOutput "[SCAN] Scanning package.json..." "Blue" | |
| try { | |
| $content = Get-Content $FilePath -Raw -ErrorAction Stop | |
| $packageData = $content | ConvertFrom-Json -ErrorAction Stop | |
| # Check all dependency types | |
| $depTypes = @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') | |
| foreach ($depType in $depTypes) { | |
| if ($packageData.$depType) { | |
| $packageData.$depType.PSObject.Properties | ForEach-Object { | |
| $packageName = $_.Name | |
| $versionSpec = $_.Value | |
| # Check main compromised packages | |
| if ($CompromisedPackages.ContainsKey($packageName)) { | |
| $compromisedVersion = $CompromisedPackages[$packageName] | |
| if (Test-VersionMatch $versionSpec $compromisedVersion) { | |
| Add-Finding -Level "CRITICAL" -Package $packageName -Version $versionSpec -Source "package.json ($depType)" | |
| } | |
| } | |
| # Check additional compromised packages | |
| if ($AdditionalCompromised.ContainsKey($packageName)) { | |
| $compromisedVersions = $AdditionalCompromised[$packageName] | |
| foreach ($version in $compromisedVersions) { | |
| if (Test-VersionMatch $versionSpec $version) { | |
| Add-Finding -Level "HIGH" -Package $packageName -Version $versionSpec -Source "package.json ($depType)" | |
| break | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| catch { | |
| Write-ColorOutput "[ERROR] Error reading package.json: $($_.Exception.Message)" "Yellow" | |
| } | |
| } | |
| function Test-PackageLockJson { | |
| param([string]$FilePath) | |
| if (!(Test-Path $FilePath)) { | |
| Write-ColorOutput "[INFO] package-lock.json not found" "Blue" | |
| return | |
| } | |
| Write-ColorOutput "[SCAN] Scanning package-lock.json..." "Blue" | |
| try { | |
| $content = Get-Content $FilePath -Raw -ErrorAction Stop | |
| # Use regex for performance on large lock files | |
| foreach ($package in $CompromisedPackages.GetEnumerator()) { | |
| $packageName = $package.Key | |
| $compromisedVersion = $package.Value | |
| # Look for exact version matches in lock file | |
| if ($content -match "`"$([regex]::Escape($packageName))`"[^}]+`"version`":\s*`"$([regex]::Escape($compromisedVersion))`"") { | |
| Add-Finding -Level "CRITICAL" -Package $packageName -Version $compromisedVersion -Source "package-lock.json" | |
| } | |
| } | |
| foreach ($package in $AdditionalCompromised.GetEnumerator()) { | |
| $packageName = $package.Key | |
| $compromisedVersions = $package.Value | |
| foreach ($version in $compromisedVersions) { | |
| if ($content -match "`"$([regex]::Escape($packageName))`"[^}]+`"version`":\s*`"$([regex]::Escape($version))`"") { | |
| Add-Finding -Level "HIGH" -Package $packageName -Version $version -Source "package-lock.json" | |
| } | |
| } | |
| } | |
| } | |
| catch { | |
| Write-ColorOutput "[ERROR] Error reading package-lock.json: $($_.Exception.Message)" "Yellow" | |
| } | |
| } | |
| function Test-YarnLock { | |
| param([string]$FilePath) | |
| if (!(Test-Path $FilePath)) { | |
| Write-ColorOutput "[INFO] yarn.lock not found" "Blue" | |
| return | |
| } | |
| Write-ColorOutput "[SCAN] Scanning yarn.lock..." "Blue" | |
| try { | |
| $content = Get-Content $FilePath -Raw -ErrorAction Stop | |
| foreach ($package in $CompromisedPackages.GetEnumerator()) { | |
| $packageName = $package.Key | |
| $compromisedVersion = $package.Value | |
| # Look for yarn.lock format: package@version: | |
| if ($content -match "^$([regex]::Escape($packageName))@.*$([regex]::Escape($compromisedVersion))") { | |
| Add-Finding -Level "CRITICAL" -Package $packageName -Version $compromisedVersion -Source "yarn.lock" | |
| } | |
| } | |
| foreach ($package in $AdditionalCompromised.GetEnumerator()) { | |
| $packageName = $package.Key | |
| $compromisedVersions = $package.Value | |
| foreach ($version in $compromisedVersions) { | |
| if ($content -match "^$([regex]::Escape($packageName))@.*$([regex]::Escape($version))") { | |
| Add-Finding -Level "HIGH" -Package $packageName -Version $version -Source "yarn.lock" | |
| } | |
| } | |
| } | |
| } | |
| catch { | |
| Write-ColorOutput "[ERROR] Error reading yarn.lock: $($_.Exception.Message)" "Yellow" | |
| } | |
| } | |
| # Main execution | |
| Write-ColorOutput "NPM Supply Chain Attack Scanner" "Cyan" | |
| Write-ColorOutput "========================================" "Cyan" | |
| Write-ColorOutput "[SCAN] Scanning directory: $((Get-Item $Path).FullName)" "Blue" | |
| Write-ColorOutput "" | |
| # Change to target directory | |
| $originalLocation = Get-Location | |
| Set-Location $Path | |
| try { | |
| # Scan files | |
| Test-PackageJson "package.json" | |
| Test-PackageLockJson "package-lock.json" | |
| Test-YarnLock "yarn.lock" | |
| Write-ColorOutput "" | |
| Write-ColorOutput "[RESULTS] Scan Results" "Cyan" | |
| Write-ColorOutput "===============" "Cyan" | |
| if ($Findings.Count -gt 0) { | |
| Write-ColorOutput "[ALERT] Found $($Findings.Count) compromised packages!" "Red" | |
| Write-ColorOutput "" | |
| $criticalCount = ($Findings | Where-Object Level -eq "CRITICAL").Count | |
| $highCount = ($Findings | Where-Object Level -eq "HIGH").Count | |
| if ($criticalCount -gt 0) { | |
| Write-ColorOutput "[CRITICAL] $criticalCount packages" "Red" | |
| } | |
| if ($highCount -gt 0) { | |
| Write-ColorOutput "[HIGH] $highCount packages" "Yellow" | |
| } | |
| Write-ColorOutput "" | |
| Write-ColorOutput "Detailed Findings:" "Yellow" | |
| $Findings | Sort-Object Level, Package | ForEach-Object { | |
| $color = if ($_.Level -eq "CRITICAL") { "Red" } else { "Yellow" } | |
| Write-ColorOutput " [$($_.Level)] $($_.Package)@$($_.Version) ($($_.Source))" $color | |
| } | |
| Write-ColorOutput "" | |
| Write-ColorOutput "[ACTION] Immediate Actions Required:" "Yellow" | |
| # Generate specific remediation commands for found packages | |
| $affectedPackages = $Findings | Select-Object -ExpandProperty Package -Unique | |
| if ($affectedPackages.Count -gt 0) { | |
| Write-ColorOutput "1. Update these specific compromised packages to safe versions:" "White" | |
| foreach ($pkg in $affectedPackages) { | |
| # Suggest updating to latest version (remove the compromised version) | |
| Write-ColorOutput " npm install $pkg@latest --save" "White" | |
| } | |
| Write-ColorOutput "" | |
| Write-ColorOutput " Or check npmjs.com for each package's latest stable version" "White" | |
| } else { | |
| Write-ColorOutput "1. Update the compromised packages found above to their latest safe versions" "White" | |
| } | |
| Write-ColorOutput "" | |
| Write-ColorOutput "2. Clear npm cache and regenerate lock files:" "White" | |
| Write-ColorOutput " npm cache clean --force" "White" | |
| Write-ColorOutput " Remove-Item package-lock.json -ErrorAction SilentlyContinue" "White" | |
| Write-ColorOutput " npm install" "White" | |
| Write-ColorOutput "" | |
| Write-ColorOutput "3. If your app has crypto functionality - ROTATE WALLET KEYS IMMEDIATELY!" "Red" | |
| Write-ColorOutput "" | |
| Write-ColorOutput "[FAILED] SCAN FAILED - Security issues found" "Red" | |
| # Save results to file | |
| $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" | |
| $resultsFile = "npm_scan_results_$timestamp.csv" | |
| $Findings | Export-Csv -Path $resultsFile -NoTypeInformation | |
| Write-ColorOutput "[SAVED] Results saved to: $resultsFile" "Blue" | |
| } | |
| else { | |
| Write-ColorOutput "[SUCCESS] No compromised packages detected!" "Green" | |
| Write-ColorOutput "[SAFE] Your project appears to be safe from this attack" "Green" | |
| } | |
| } | |
| finally { | |
| Set-Location $originalLocation | |
| } | |
| Write-ColorOutput "" | |
| Write-ColorOutput "[INFO] This scanner checks for the September 2025 npm supply chain attack" "Blue" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment