|
| 1 | + |
| 2 | +<# |
| 3 | +.SYNOPSIS |
| 4 | + Checks if a website is running the "Modular DS" WordPress plugin (slug: modular-connector) |
| 5 | + and determines whether the detected version is 2.5.1 or earlier. |
| 6 | +
|
| 7 | +.DESCRIPTION |
| 8 | + Safe, read-only heuristics: |
| 9 | + 1) Tries to fetch /wp-content/plugins/modular-connector/readme.txt (and related files) |
| 10 | + and parse "Stable tag:" / "Version:" / changelog headings. |
| 11 | + 2) (Optional) Heuristic: scans homepage HTML for references to |
| 12 | + /wp-content/plugins/modular-connector/ or /api/modular-connector/ (version may remain unknown). |
| 13 | +
|
| 14 | + No requests are made to sensitive /api/modular-connector/* routes. |
| 15 | +
|
| 16 | +.PARAMETER Url |
| 17 | + Single site (domain or full URL), e.g. "https://example.com" or "example.com". |
| 18 | +
|
| 19 | +.PARAMETER InputFile |
| 20 | + Path to a file containing one target per line. |
| 21 | +
|
| 22 | +.PARAMETER TimeoutSec |
| 23 | + HTTP timeout per request (default 10). |
| 24 | +
|
| 25 | +.PARAMETER GuessFromHtml |
| 26 | + If set, also heuristically inspects homepage HTML for plugin traces (non-invasive). |
| 27 | +
|
| 28 | +.PARAMETER Parallel |
| 29 | + If set, processes list targets in parallel (PowerShell 7+). |
| 30 | +
|
| 31 | +.PARAMETER ThrottleLimit |
| 32 | + Degree of parallelism when -Parallel is used (default 16). |
| 33 | +
|
| 34 | +.PARAMETER SkipCertificateCheck |
| 35 | + Ignore TLS certificate errors (useful for lab/dev; requires PowerShell 7+). |
| 36 | +
|
| 37 | +.PARAMETER OutputPath |
| 38 | + Where to write JSON results. Defaults to ./modulards-scan-results.json |
| 39 | +
|
| 40 | +.EXAMPLE |
| 41 | + .\Check-ModularDS.ps1 -Url https://example.com |
| 42 | +
|
| 43 | +.EXAMPLE |
| 44 | + .\Check-ModularDS.ps1 -InputFile .\targets.txt -Parallel -ThrottleLimit 24 -GuessFromHtml |
| 45 | +
|
| 46 | +.NOTES |
| 47 | + PowerShell: 7+ |
| 48 | +#> |
| 49 | + |
| 50 | +[CmdletBinding(DefaultParameterSetName = 'Single')] |
| 51 | +param( |
| 52 | + [Parameter(Mandatory = $true, ParameterSetName = 'Single', Position = 0)] |
| 53 | + [string]$Url, |
| 54 | + |
| 55 | + [Parameter(Mandatory = $true, ParameterSetName = 'List', Position = 0)] |
| 56 | + [string]$InputFile, |
| 57 | + |
| 58 | + [int]$TimeoutSec = 10, |
| 59 | + |
| 60 | + [switch]$GuessFromHtml, |
| 61 | + |
| 62 | + [switch]$Parallel, |
| 63 | + [int]$ThrottleLimit = 16, |
| 64 | + |
| 65 | + [switch]$SkipCertificateCheck, |
| 66 | + |
| 67 | + [string]$OutputPath = "$(Join-Path (Get-Location) 'modulards-scan-results.json')" |
| 68 | +) |
| 69 | + |
| 70 | +begin { |
| 71 | + $ErrorActionPreference = 'Stop' |
| 72 | + |
| 73 | + # Constants |
| 74 | + $PluginSlug = 'modular-connector' |
| 75 | + $VulnerableMax = [version]'2.5.1' |
| 76 | + |
| 77 | + # Candidate files to read version info from (ordered) |
| 78 | + $PathCandidates = @( |
| 79 | + "wp-content/plugins/$PluginSlug/readme.txt", |
| 80 | + "wp-content/plugins/$PluginSlug/README.txt", |
| 81 | + "wp-content/plugins/$PluginSlug/README.md", |
| 82 | + "wp-content/plugins/$PluginSlug/readme.md", |
| 83 | + "wp-content/plugins/$PluginSlug/changelog.txt", |
| 84 | + "wp-content/plugins/$PluginSlug/CHANGELOG.txt", |
| 85 | + "wp-content/plugins/$PluginSlug/CHANGELOG.md", |
| 86 | + "wp-content/plugins/$PluginSlug/changelog.md" |
| 87 | + ) |
| 88 | + |
| 89 | + $Headers = @{ |
| 90 | + 'User-Agent' = 'Mozilla/5.0 (compatible; ModularDS-Checker/1.0; +https://localhost)' |
| 91 | + 'Accept' = 'text/plain, text/markdown, text/*;q=0.9, */*;q=0.8' |
| 92 | + } |
| 93 | + |
| 94 | + function Normalize-BaseUrl { |
| 95 | + param([string]$t) |
| 96 | + if ([string]::IsNullOrWhiteSpace($t)) { return $null } |
| 97 | + $s = $t.Trim() |
| 98 | + if (-not ($s -match '^https?://')) { $s = "https://$s" } |
| 99 | + # strip trailing slash |
| 100 | + return $s.TrimEnd('/') |
| 101 | + } |
| 102 | + |
| 103 | + function Combine-Url { |
| 104 | + param([string]$base, [string]$path) |
| 105 | + return "$base/$path" |
| 106 | + } |
| 107 | + |
| 108 | + function Invoke-SafeRequest { |
| 109 | + param( |
| 110 | + [string]$Uri |
| 111 | + ) |
| 112 | + try { |
| 113 | + $common = @{ |
| 114 | + Uri = $Uri |
| 115 | + Headers = $Headers |
| 116 | + Method = 'GET' |
| 117 | + TimeoutSec = $TimeoutSec |
| 118 | + MaximumRedirection = 5 |
| 119 | + ErrorAction = 'Stop' |
| 120 | + } |
| 121 | + if ($PSBoundParameters.ContainsKey('SkipCertificateCheck') -and $SkipCertificateCheck) { |
| 122 | + $common['SkipCertificateCheck'] = $true # PS 7+ |
| 123 | + } |
| 124 | + return Invoke-WebRequest @common |
| 125 | + } catch { |
| 126 | + return $null |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + function Parse-VersionFromText { |
| 131 | + param([string]$Text) |
| 132 | + |
| 133 | + if ([string]::IsNullOrWhiteSpace($Text)) { return $null } |
| 134 | + |
| 135 | + # Look for "Stable tag:" or "Version:" first |
| 136 | + $patterns = @( |
| 137 | + 'Stable\s+tag:\s*([0-9]+(?:\.[0-9]+)*)', |
| 138 | + 'Version:\s*([0-9]+(?:\.[0-9]+)*)' |
| 139 | + ) |
| 140 | + |
| 141 | + foreach ($pat in $patterns) { |
| 142 | + $m = [regex]::Matches($Text, $pat, 'IgnoreCase') |
| 143 | + if ($m.Count -gt 0) { |
| 144 | + # Choose the highest parseable among matches (defensive) |
| 145 | + $candidates = $m | ForEach-Object { $_.Groups[1].Value } |
| 146 | + $best = $null |
| 147 | + foreach ($vstr in $candidates) { |
| 148 | + $ver = $null |
| 149 | + if ([version]::TryParse($vstr, [ref]$ver)) { |
| 150 | + if (-not $best -or $ver -gt $best) { $best = $ver } |
| 151 | + } |
| 152 | + } |
| 153 | + if ($best) { return $best.ToString() } |
| 154 | + return $candidates[0] |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + # Fallback: scan changelog headings (e.g., "### 2.5.1", "= 2.5.1 =") |
| 159 | + $changelogPat = '^\s*(?:=+|#+)\s*v?([0-9]+(?:\.[0-9]+)+)\s*(?:=+)?\s*$' |
| 160 | + $matches = [regex]::Matches($Text, $changelogPat, 'IgnoreCase, Multiline') |
| 161 | + if ($matches.Count -gt 0) { |
| 162 | + $best = $null |
| 163 | + foreach ($m in $matches) { |
| 164 | + $ver = $null |
| 165 | + if ([version]::TryParse($m.Groups[1].Value, [ref]$ver)) { |
| 166 | + if (-not $best -or $ver -gt $best) { $best = $ver } |
| 167 | + } |
| 168 | + } |
| 169 | + if ($best) { return $best.ToString() } |
| 170 | + } |
| 171 | + |
| 172 | + return $null |
| 173 | + } |
| 174 | + |
| 175 | + function Is-Vulnerable { |
| 176 | + param([string]$VersionString) |
| 177 | + |
| 178 | + if ([string]::IsNullOrWhiteSpace($VersionString)) { return $null } |
| 179 | + |
| 180 | + $v = $null |
| 181 | + if ([version]::TryParse($VersionString, [ref]$v)) { |
| 182 | + return ($v -le $VulnerableMax) |
| 183 | + } |
| 184 | + |
| 185 | + # Fallback: coerce dotted numeric segments |
| 186 | + try { |
| 187 | + $parts = ($VersionString -split '[^\d]+') | Where-Object { $_ -ne '' } | Select-Object -First 4 |
| 188 | + while ($parts.Count -lt 4) { $parts += '0' } |
| 189 | + $ver = [version]::new([int]$parts[0],[int]$parts[1],[int]$parts[2],[int]$parts[3]) |
| 190 | + return ($ver -le $VulnerableMax) |
| 191 | + } catch { |
| 192 | + return $null |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + function Heuristic-HomeHtml-HasModularDS { |
| 197 | + param([string]$BaseUrl) |
| 198 | + |
| 199 | + $resp = Invoke-SafeRequest -Uri $BaseUrl |
| 200 | + if (-not $resp -or [string]::IsNullOrWhiteSpace($resp.Content)) { return $false } |
| 201 | + |
| 202 | + # Non-invasive search for plugin traces |
| 203 | + if ($resp.Content -match '\/wp-content\/plugins\/modular-connector\/' -or |
| 204 | + $resp.Content -match '\/api\/modular-connector\/') { |
| 205 | + return $true |
| 206 | + } |
| 207 | + return $false |
| 208 | + } |
| 209 | + |
| 210 | + function Scan-Target { |
| 211 | + param([string]$Target) |
| 212 | + |
| 213 | + $base = Normalize-BaseUrl $Target |
| 214 | + if (-not $base) { |
| 215 | + return [pscustomobject]@{ |
| 216 | + Target = $Target |
| 217 | + PluginSlug = $PluginSlug |
| 218 | + Detected = $false |
| 219 | + Version = $null |
| 220 | + IsVulnerable = $null |
| 221 | + DetectionMethod = 'invalid-url' |
| 222 | + Evidence = $null |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + $found = $false |
| 227 | + $version = $null |
| 228 | + $method = $null |
| 229 | + $evidence= $null |
| 230 | + |
| 231 | + foreach ($rel in $PathCandidates) { |
| 232 | + $u = Combine-Url $base $rel |
| 233 | + $r = Invoke-SafeRequest -Uri $u |
| 234 | + if ($r -and $r.StatusCode -ge 200 -and $r.StatusCode -lt 300 -and $r.Content) { |
| 235 | + $ver = Parse-VersionFromText -Text $r.Content |
| 236 | + if ($ver) { |
| 237 | + $found = $true |
| 238 | + $version = $ver |
| 239 | + $method = 'readme/changelog' |
| 240 | + $evidence= $u |
| 241 | + break |
| 242 | + } elseif ($r.Content -match '(?i)Modular\s*DS') { |
| 243 | + $found = $true |
| 244 | + $method = 'readme-present-no-version' |
| 245 | + $evidence= $u |
| 246 | + # keep looking for a better source that includes a version |
| 247 | + } |
| 248 | + } elseif ($r -and $r.StatusCode -eq 403) { |
| 249 | + # Access denied; continue with other heuristics. |
| 250 | + continue |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + if (-not $found -and $GuessFromHtml) { |
| 255 | + if (Heuristic-HomeHtml-HasModularDS -BaseUrl "$base/") { |
| 256 | + $found = $true |
| 257 | + $method = 'home-html-heuristic' |
| 258 | + $evidence= "$base/" |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + [pscustomobject]@{ |
| 263 | + Target = $Target |
| 264 | + PluginSlug = $PluginSlug |
| 265 | + Detected = $found |
| 266 | + Version = $version |
| 267 | + IsVulnerable = if ($version) { Is-Vulnerable -VersionString $version } else { $null } |
| 268 | + DetectionMethod = $method |
| 269 | + Evidence = $evidence |
| 270 | + } |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +process { |
| 275 | + $targets = |
| 276 | + if ($PSCmdlet.ParameterSetName -eq 'List') { |
| 277 | + Get-Content -LiteralPath $InputFile | Where-Object { $_ -and $_.Trim() -ne '' } |
| 278 | + } else { |
| 279 | + @($Url) |
| 280 | + } |
| 281 | + |
| 282 | + if ($Parallel -and $targets.Count -gt 1) { |
| 283 | + $results = $targets | ForEach-Object -Parallel { |
| 284 | + # bring in needed vars |
| 285 | + $using:PSBoundParameters | Out-Null # keep analyzer quiet |
| 286 | + # Recreate helper functions inside the parallel runspace |
| 287 | + $PluginSlug = $using:PluginSlug |
| 288 | + $PathCandidates = $using:PathCandidates |
| 289 | + $TimeoutSec = $using:TimeoutSec |
| 290 | + $Headers = $using:Headers |
| 291 | + $VulnerableMax = $using:VulnerableMax |
| 292 | + $GuessFromHtml = $using:GuessFromHtml |
| 293 | + $SkipCertificateCheck = $using:SkipCertificateCheck |
| 294 | + |
| 295 | + function Normalize-BaseUrl { param([string]$t) |
| 296 | + if ([string]::IsNullOrWhiteSpace($t)) { return $null } |
| 297 | + $s = $t.Trim(); if (-not ($s -match '^https?://')) { $s = "https://$s" } |
| 298 | + return $s.TrimEnd('/') |
| 299 | + } |
| 300 | + function Combine-Url { param([string]$base,[string]$path) "$base/$path" } |
| 301 | + function Invoke-SafeRequest { param([string]$Uri) |
| 302 | + try { |
| 303 | + $common = @{ |
| 304 | + Uri = $Uri |
| 305 | + Headers = $Headers |
| 306 | + Method = 'GET' |
| 307 | + TimeoutSec = $TimeoutSec |
| 308 | + MaximumRedirection = 5 |
| 309 | + ErrorAction = 'Stop' |
| 310 | + } |
| 311 | + if ($SkipCertificateCheck) { $common['SkipCertificateCheck'] = $true } |
| 312 | + Invoke-WebRequest @common |
| 313 | + } catch { $null } |
| 314 | + } |
| 315 | + function Parse-VersionFromText { param([string]$Text) |
| 316 | + if ([string]::IsNullOrWhiteSpace($Text)) { return $null } |
| 317 | + $patterns = @('Stable\s+tag:\s*([0-9]+(?:\.[0-9]+)*)','Version:\s*([0-9]+(?:\.[0-9]+)*)') |
| 318 | + foreach ($pat in $patterns) { |
| 319 | + $m = [regex]::Matches($Text, $pat, 'IgnoreCase') |
| 320 | + if ($m.Count -gt 0) { |
| 321 | + $candidates = $m | ForEach-Object { $_.Groups[1].Value } |
| 322 | + $best = $null |
| 323 | + foreach ($vstr in $candidates) { $ver = $null; if ([version]::TryParse($vstr, [ref]$ver)) { if (-not $best -or $ver -gt $best) { $best = $ver } } } |
| 324 | + if ($best) { return $best.ToString() }; return $candidates[0] |
| 325 | + } |
| 326 | + } |
| 327 | + $matches = [regex]::Matches($Text, '^\s*(?:=+|#+)\s*v?([0-9]+(?:\.[0-9]+)+)\s*(?:=+)?\s*$', 'IgnoreCase, Multiline') |
| 328 | + if ($matches.Count -gt 0) { |
| 329 | + $best = $null |
| 330 | + foreach ($m in $matches) { $ver = $null; if ([version]::TryParse($m.Groups[1].Value, [ref]$ver)) { if (-not $best -or $ver -gt $best) { $best = $ver } } } |
| 331 | + if ($best) { return $best.ToString() } |
| 332 | + } |
| 333 | + $null |
| 334 | + } |
| 335 | + function Is-Vulnerable { param([string]$VersionString) |
| 336 | + if ([string]::IsNullOrWhiteSpace($VersionString)) { return $null } |
| 337 | + $v = $null |
| 338 | + if ([version]::TryParse($VersionString, [ref]$v)) { return ($v -le $VulnerableMax) } |
| 339 | + try { |
| 340 | + $parts = ($VersionString -split '[^\d]+') | Where-Object { $_ -ne '' } | Select-Object -First 4 |
| 341 | + while ($parts.Count -lt 4) { $parts += '0' } |
| 342 | + $ver = [version]::new([int]$parts[0],[int]$parts[1],[int]$parts[2],[int]$parts[3]) |
| 343 | + return ($ver -le $VulnerableMax) |
| 344 | + } catch { $null } |
| 345 | + } |
| 346 | + function Heuristic-HomeHtml-HasModularDS { param([string]$BaseUrl) |
| 347 | + $resp = Invoke-SafeRequest -Uri $BaseUrl |
| 348 | + if (-not $resp -or [string]::IsNullOrWhiteSpace($resp.Content)) { return $false } |
| 349 | + if ($resp.Content -match '\/wp-content\/plugins\/modular-connector\/' -or |
| 350 | + $resp.Content -match '\/api\/modular-connector\/') { return $true } |
| 351 | + $false |
| 352 | + } |
| 353 | + function Scan-Target { param([string]$Target) |
| 354 | + $base = Normalize-BaseUrl $Target |
| 355 | + if (-not $base) { |
| 356 | + return [pscustomobject]@{ Target=$Target; PluginSlug=$PluginSlug; Detected=$false; Version=$null; IsVulnerable=$null; DetectionMethod='invalid-url'; Evidence=$null } |
| 357 | + } |
| 358 | + $found=$false; $version=$null; $method=$null; $evidence=$null |
| 359 | + foreach ($rel in $PathCandidates) { |
| 360 | + $u = Combine-Url $base $rel |
| 361 | + $r = Invoke-SafeRequest -Uri $u |
| 362 | + if ($r -and $r.StatusCode -ge 200 -and $r.StatusCode -lt 300 -and $r.Content) { |
| 363 | + $ver = Parse-VersionFromText -Text $r.Content |
| 364 | + if ($ver) { $found=$true; $version=$ver; $method='readme/changelog'; $evidence=$u; break } |
| 365 | + elseif ($r.Content -match '(?i)Modular\s*DS') { $found=$true; $method='readme-present-no-version'; $evidence=$u } |
| 366 | + } elseif ($r -and $r.StatusCode -eq 403) { |
| 367 | + continue |
| 368 | + } |
| 369 | + } |
| 370 | + if (-not $found -and $GuessFromHtml) { |
| 371 | + if (Heuristic-HomeHtml-HasModularDS -BaseUrl "$base/") { $found=$true; $method='home-html-heuristic'; $evidence="$base/" } |
| 372 | + } |
| 373 | + [pscustomobject]@{ |
| 374 | + Target = $Target |
| 375 | + PluginSlug = $PluginSlug |
| 376 | + Detected = $found |
| 377 | + Version = $version |
| 378 | + IsVulnerable = if ($version) { Is-Vulnerable -VersionString $version } else { $null } |
| 379 | + DetectionMethod = $method |
| 380 | + Evidence = $evidence |
| 381 | + } |
| 382 | + } |
| 383 | + |
| 384 | + Scan-Target -Target $_ |
| 385 | + } -ThrottleLimit $ThrottleLimit |
| 386 | + } else { |
| 387 | + $results = foreach ($t in $targets) { Scan-Target -Target $t } |
| 388 | + } |
| 389 | + |
| 390 | + # Display & persist |
| 391 | + $results | Sort-Object Target | Format-Table -AutoSize |
| 392 | + $results | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding utf8 |
| 393 | + Write-Host "`nSaved JSON results to: $OutputPath" |
| 394 | +} |
0 commit comments