Skip to content

Commit 834bbd7

Browse files
Merge pull request #2366 from projectdiscovery/feature/cpe-wordpress-detection
feat: add passive CPE and WordPress detection
2 parents bc2c7a2 + 81461d3 commit 834bbd7

8 files changed

Lines changed: 421 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,11 @@ PROBES:
110110
-title display page title
111111
-bp, -body-preview display first N characters of response body (default 100)
112112
-server, -web-server display server name
113-
-td, -tech-detect display technology in use based on wappalyzer dataset
113+
-td, -tech-detect display technology in use based on wappalyzer dataset
114114
-cff, -custom-fingerprint-file string path to a custom fingerprint file for technology detection
115-
-method display http request method
115+
-cpe display CPE (Common Platform Enumeration) based on awesome-search-queries
116+
-wp, -wordpress display WordPress plugins and themes
117+
-method display http request method
116118
-ws, -websocket display server using websocket
117119
-ip display host ip
118120
-cname display host cname

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ require (
128128
github.com/pierrec/lz4/v4 v4.1.23 // indirect
129129
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
130130
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
131+
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193 // indirect
131132
github.com/projectdiscovery/blackrock v0.0.1 // indirect
132133
github.com/projectdiscovery/freeport v0.0.7 // indirect
133134
github.com/projectdiscovery/gostruct v0.0.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
320320
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
321321
github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kID2iwsDqI=
322322
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
323+
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193 h1:UCZRqs1BP1wsvhCwQxfIQc7NJcXGBhQvAnEw3awhsng=
324+
github.com/projectdiscovery/awesome-search-queries v0.0.0-20260104120501-961ef30f7193/go.mod h1:nSovPcipgSx/EzAefF+iCfORolkKAuodiRWL3RCGHOM=
323325
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
324326
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
325327
github.com/projectdiscovery/cdncheck v1.2.17 h1:Ah7KIft60ZiE6etGuX/63HiDJu0C7szhEwYTQugVorU=

runner/cpe.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package runner
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
awesomesearchqueries "github.com/projectdiscovery/awesome-search-queries"
9+
)
10+
11+
type CPEInfo struct {
12+
Product string `json:"product,omitempty"`
13+
Vendor string `json:"vendor,omitempty"`
14+
CPE string `json:"cpe,omitempty"`
15+
}
16+
17+
type CPEDetector struct {
18+
titlePatterns map[string][]CPEInfo
19+
bodyPatterns map[string][]CPEInfo
20+
faviconPatterns map[string][]CPEInfo
21+
}
22+
23+
type rawQuery struct {
24+
Name string `json:"name"`
25+
Vendor json.RawMessage `json:"vendor"`
26+
Type string `json:"type"`
27+
Engines []rawEngine `json:"engines"`
28+
}
29+
30+
type rawEngine struct {
31+
Platform string `json:"platform"`
32+
Queries []string `json:"queries"`
33+
}
34+
35+
func NewCPEDetector() (*CPEDetector, error) {
36+
data, err := awesomesearchqueries.GetQueries()
37+
if err != nil {
38+
return nil, fmt.Errorf("failed to load queries: %w", err)
39+
}
40+
41+
var queries []rawQuery
42+
if err := json.Unmarshal(data, &queries); err != nil {
43+
return nil, fmt.Errorf("failed to parse queries: %w", err)
44+
}
45+
46+
detector := &CPEDetector{
47+
titlePatterns: make(map[string][]CPEInfo),
48+
bodyPatterns: make(map[string][]CPEInfo),
49+
faviconPatterns: make(map[string][]CPEInfo),
50+
}
51+
52+
for _, q := range queries {
53+
vendor := parseVendor(q.Vendor)
54+
info := CPEInfo{
55+
Product: q.Name,
56+
Vendor: vendor,
57+
CPE: generateCPE(vendor, q.Name),
58+
}
59+
60+
for _, engine := range q.Engines {
61+
for _, query := range engine.Queries {
62+
detector.extractPattern(query, info)
63+
}
64+
}
65+
}
66+
67+
return detector, nil
68+
}
69+
70+
func parseVendor(raw json.RawMessage) string {
71+
var vendorStr string
72+
if err := json.Unmarshal(raw, &vendorStr); err == nil {
73+
return vendorStr
74+
}
75+
76+
var vendorSlice []string
77+
if err := json.Unmarshal(raw, &vendorSlice); err == nil && len(vendorSlice) > 0 {
78+
return vendorSlice[0]
79+
}
80+
81+
return ""
82+
}
83+
84+
func generateCPE(vendor, product string) string {
85+
if vendor == "" || product == "" {
86+
return ""
87+
}
88+
return fmt.Sprintf("cpe:2.3:a:%s:%s:*:*:*:*:*:*:*:*",
89+
strings.ToLower(strings.ReplaceAll(vendor, " ", "_")),
90+
strings.ToLower(strings.ReplaceAll(product, " ", "_")))
91+
}
92+
93+
func (d *CPEDetector) extractPattern(query string, info CPEInfo) {
94+
query = strings.TrimSpace(query)
95+
96+
titlePrefixes := []string{
97+
"http.title:",
98+
"title=",
99+
"title==",
100+
"intitle:",
101+
"title:",
102+
"title='",
103+
`title="`,
104+
}
105+
106+
for _, prefix := range titlePrefixes {
107+
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
108+
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
109+
pattern = strings.TrimPrefix(pattern, prefix[:len(prefix)-1])
110+
if pattern != "" {
111+
pattern = strings.ToLower(pattern)
112+
d.titlePatterns[pattern] = appendUnique(d.titlePatterns[pattern], info)
113+
}
114+
return
115+
}
116+
}
117+
118+
bodyPrefixes := []string{
119+
"http.html:",
120+
"body=",
121+
"body==",
122+
"intext:",
123+
}
124+
125+
for _, prefix := range bodyPrefixes {
126+
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
127+
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
128+
if pattern != "" {
129+
pattern = strings.ToLower(pattern)
130+
d.bodyPatterns[pattern] = appendUnique(d.bodyPatterns[pattern], info)
131+
}
132+
return
133+
}
134+
}
135+
136+
faviconPrefixes := []string{
137+
"http.favicon.hash:",
138+
"icon_hash=",
139+
"icon_hash==",
140+
}
141+
142+
for _, prefix := range faviconPrefixes {
143+
if strings.HasPrefix(strings.ToLower(query), strings.ToLower(prefix)) {
144+
pattern := extractQuotedValue(strings.TrimPrefix(query, prefix))
145+
if pattern != "" {
146+
d.faviconPatterns[pattern] = appendUnique(d.faviconPatterns[pattern], info)
147+
}
148+
return
149+
}
150+
}
151+
}
152+
153+
func extractQuotedValue(s string) string {
154+
s = strings.TrimSpace(s)
155+
156+
if len(s) >= 2 {
157+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
158+
s = s[1 : len(s)-1]
159+
}
160+
}
161+
162+
if idx := strings.Index(s, "\" ||"); idx > 0 {
163+
s = s[:idx]
164+
}
165+
if idx := strings.Index(s, "' ||"); idx > 0 {
166+
s = s[:idx]
167+
}
168+
169+
return strings.TrimSpace(s)
170+
}
171+
172+
func appendUnique(slice []CPEInfo, info CPEInfo) []CPEInfo {
173+
for _, existing := range slice {
174+
if existing.Product == info.Product && existing.Vendor == info.Vendor {
175+
return slice
176+
}
177+
}
178+
return append(slice, info)
179+
}
180+
181+
func (d *CPEDetector) Detect(title, body, faviconHash string) []CPEInfo {
182+
seen := make(map[string]bool)
183+
var results []CPEInfo
184+
185+
titleLower := strings.ToLower(title)
186+
bodyLower := strings.ToLower(body)
187+
188+
for pattern, infos := range d.titlePatterns {
189+
if strings.Contains(titleLower, pattern) {
190+
for _, info := range infos {
191+
key := info.Product + "|" + info.Vendor
192+
if !seen[key] {
193+
seen[key] = true
194+
results = append(results, info)
195+
}
196+
}
197+
}
198+
}
199+
200+
for pattern, infos := range d.bodyPatterns {
201+
if strings.Contains(bodyLower, pattern) {
202+
for _, info := range infos {
203+
key := info.Product + "|" + info.Vendor
204+
if !seen[key] {
205+
seen[key] = true
206+
results = append(results, info)
207+
}
208+
}
209+
}
210+
}
211+
212+
if faviconHash != "" {
213+
if infos, ok := d.faviconPatterns[faviconHash]; ok {
214+
for _, info := range infos {
215+
key := info.Product + "|" + info.Vendor
216+
if !seen[key] {
217+
seen[key] = true
218+
results = append(results, info)
219+
}
220+
}
221+
}
222+
}
223+
224+
return results
225+
}

runner/options.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ type ScanOptions struct {
8585
NoFallback bool
8686
NoFallbackScheme bool
8787
TechDetect bool
88+
CPEDetect bool
89+
WordPress bool
8890
StoreChain bool
8991
StoreVisionReconClusters bool
9092
MaxResponseBodySizeToSave int
@@ -148,6 +150,8 @@ func (s *ScanOptions) Clone() *ScanOptions {
148150
NoFallback: s.NoFallback,
149151
NoFallbackScheme: s.NoFallbackScheme,
150152
TechDetect: s.TechDetect,
153+
CPEDetect: s.CPEDetect,
154+
WordPress: s.WordPress,
151155
StoreChain: s.StoreChain,
152156
OutputExtractRegex: s.OutputExtractRegex,
153157
MaxResponseBodySizeToSave: s.MaxResponseBodySizeToSave,
@@ -256,6 +260,8 @@ type Options struct {
256260
NoFallback bool
257261
NoFallbackScheme bool
258262
TechDetect bool
263+
CPEDetect bool
264+
WordPress bool
259265
CustomFingerprintFile string
260266
TLSGrab bool
261267
protocol string
@@ -387,6 +393,8 @@ func ParseOptions() *Options {
387393
flagSet.BoolVarP(&options.OutputServerHeader, "web-server", "server", false, "display server name"),
388394
flagSet.BoolVarP(&options.TechDetect, "tech-detect", "td", false, "display technology in use based on wappalyzer dataset"),
389395
flagSet.StringVarP(&options.CustomFingerprintFile, "custom-fingerprint-file", "cff", "", "path to a custom fingerprint file for technology detection"),
396+
flagSet.BoolVar(&options.CPEDetect, "cpe", false, "display CPE (Common Platform Enumeration) based on awesome-search-queries"),
397+
flagSet.BoolVarP(&options.WordPress, "wordpress", "wp", false, "display WordPress plugins and themes"),
390398
flagSet.BoolVar(&options.OutputMethod, "method", false, "display http request method"),
391399
flagSet.BoolVarP(&options.OutputWebSocket, "websocket", "ws", false, "display server using websocket"),
392400
flagSet.BoolVar(&options.OutputIP, "ip", false, "display host ip"),

runner/runner.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ type Runner struct {
8181
options *Options
8282
hp *httpx.HTTPX
8383
wappalyzer *wappalyzer.Wappalyze
84+
cpeDetector *CPEDetector
85+
wpDetector *WordPressDetector
8486
scanopts ScanOptions
8587
hm *hybrid.HybridMap
8688
excludeCdn bool
@@ -133,6 +135,20 @@ func New(options *Options) (*Runner, error) {
133135
return nil, errors.Wrap(err, "could not create wappalyzer client")
134136
}
135137

138+
if options.CPEDetect || options.JSONOutput || options.CSVOutput {
139+
runner.cpeDetector, err = NewCPEDetector()
140+
if err != nil {
141+
gologger.Warning().Msgf("Could not create CPE detector: %s", err)
142+
}
143+
}
144+
145+
if options.WordPress || options.JSONOutput || options.CSVOutput {
146+
runner.wpDetector, err = NewWordPressDetector()
147+
if err != nil {
148+
gologger.Warning().Msgf("Could not create WordPress detector: %s", err)
149+
}
150+
}
151+
136152
if options.StoreResponseDir != "" {
137153
_ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "response", "index.txt"))
138154
_ = os.RemoveAll(filepath.Join(options.StoreResponseDir, "screenshot", "index_screenshot.txt"))
@@ -297,6 +313,8 @@ func New(options *Options) (*Runner, error) {
297313
scanopts.NoFallback = options.NoFallback
298314
scanopts.NoFallbackScheme = options.NoFallbackScheme
299315
scanopts.TechDetect = options.TechDetect || options.JSONOutput || options.CSVOutput || options.AssetUpload
316+
scanopts.CPEDetect = options.CPEDetect || options.JSONOutput || options.CSVOutput
317+
scanopts.WordPress = options.WordPress || options.JSONOutput || options.CSVOutput
300318
scanopts.StoreChain = options.StoreChain
301319
scanopts.StoreVisionReconClusters = options.StoreVisionReconClusters
302320
scanopts.MaxResponseBodySizeToSave = options.MaxResponseBodySizeToSave
@@ -2311,6 +2329,47 @@ retry:
23112329
}
23122330
}
23132331

2332+
var cpeMatches []CPEInfo
2333+
if r.cpeDetector != nil {
2334+
cpeMatches = r.cpeDetector.Detect(title, string(resp.Data), faviconMMH3)
2335+
if len(cpeMatches) > 0 && r.options.CPEDetect {
2336+
for _, cpe := range cpeMatches {
2337+
builder.WriteString(" [")
2338+
if !scanopts.OutputWithNoColor {
2339+
builder.WriteString(aurora.Cyan(cpe.CPE).String())
2340+
} else {
2341+
builder.WriteString(cpe.CPE)
2342+
}
2343+
builder.WriteRune(']')
2344+
}
2345+
}
2346+
}
2347+
2348+
var wpInfo *WordPressInfo
2349+
if r.wpDetector != nil {
2350+
wpInfo = r.wpDetector.Detect(string(resp.Data))
2351+
if wpInfo.HasData() && r.options.WordPress {
2352+
if len(wpInfo.Plugins) > 0 {
2353+
builder.WriteString(" [")
2354+
if !scanopts.OutputWithNoColor {
2355+
builder.WriteString(aurora.Green("wp-plugins:" + strings.Join(wpInfo.Plugins, ",")).String())
2356+
} else {
2357+
builder.WriteString("wp-plugins:" + strings.Join(wpInfo.Plugins, ","))
2358+
}
2359+
builder.WriteRune(']')
2360+
}
2361+
if len(wpInfo.Themes) > 0 {
2362+
builder.WriteString(" [")
2363+
if !scanopts.OutputWithNoColor {
2364+
builder.WriteString(aurora.Green("wp-themes:" + strings.Join(wpInfo.Themes, ",")).String())
2365+
} else {
2366+
builder.WriteString("wp-themes:" + strings.Join(wpInfo.Themes, ","))
2367+
}
2368+
builder.WriteRune(']')
2369+
}
2370+
}
2371+
}
2372+
23142373
result := Result{
23152374
Timestamp: time.Now(),
23162375
Request: request,
@@ -2374,6 +2433,8 @@ retry:
23742433
RequestRaw: requestDump,
23752434
Response: resp,
23762435
FaviconData: faviconData,
2436+
CPE: cpeMatches,
2437+
WordPress: wpInfo,
23772438
}
23782439
if resp.BodyDomains != nil {
23792440
result.Fqdns = resp.BodyDomains.Fqdns

runner/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ type Result struct {
102102
Response *httpx.Response `json:"-" csv:"-" mapstructure:"-"`
103103
FaviconData []byte `json:"-" csv:"-" mapstructure:"-"`
104104
Trace *retryablehttp.TraceInfo `json:"trace,omitempty" csv:"-" mapstructure:"trace"`
105+
CPE []CPEInfo `json:"cpe,omitempty" csv:"cpe" mapstructure:"cpe"`
106+
WordPress *WordPressInfo `json:"wordpress,omitempty" csv:"wordpress" mapstructure:"wordpress"`
105107
}
106108

107109
type Trace struct {

0 commit comments

Comments
 (0)