Skip to content

Commit 3f55fba

Browse files
authored
Add files via upload
1 parent e589d13 commit 3f55fba

File tree

1 file changed

+394
-0
lines changed

1 file changed

+394
-0
lines changed

generic/Check-ModularDS.ps1

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
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

Comments
 (0)